Compare commits

..

2 Commits

Author SHA1 Message Date
xiaoju e8dd398f28 fix: add workflow-agent-claude-code to publish order
小橘 <xiaoju@shazhou.work>
2026-05-27 00:00:09 +00:00
xiaoju 61d95cc47f chore: release v0.5.1
- Add 5 persona-based skills (actor, user, author, developer, adapter)
- Fix skill CLI description truncation (#549)

小橘 <xiaoju@shazhou.work>
2026-05-26 17:30:00 +00:00
102 changed files with 815 additions and 5224 deletions
-30
View File
@@ -1,30 +0,0 @@
{
"mode": "exit",
"tag": "alpha",
"initialVersions": {
"@uncaged/cli-workflow": "0.4.5",
"@uncaged/workflow-agent-cursor": "0.4.5",
"@uncaged/workflow-agent-hermes": "0.4.5",
"@uncaged/workflow-agent-llm": "0.4.5",
"@uncaged/workflow-agent-react": "0.4.5",
"@uncaged/workflow-cas": "0.4.5",
"@uncaged/workflow-dashboard": "0.1.0",
"@uncaged/workflow-execute": "0.4.5",
"@uncaged/workflow-gateway": "0.4.5",
"@uncaged/workflow-protocol": "0.4.5",
"@uncaged/workflow-reactor": "0.4.5",
"@uncaged/workflow-register": "0.4.5",
"@uncaged/workflow-runtime": "0.4.5",
"@uncaged/workflow-template-develop": "0.4.5",
"@uncaged/workflow-template-solve-issue": "0.4.5",
"@uncaged/workflow-util": "0.4.5",
"@uncaged/workflow-util-agent": "0.4.5"
},
"changesets": [
"env-api-unify",
"fix-internal-deps",
"fix-publish-src",
"fix-workspace-deps",
"rfc-252-agent-fn"
]
}
+6 -7
View File
@@ -7,19 +7,18 @@ on:
branches: [main]
jobs:
check:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- run: bun install
- name: Install dependencies
run: bun install
- name: Build
run: bun run build
- name: Lint
- name: Check
run: bun run check
- name: Test
+19 -66
View File
@@ -23,12 +23,6 @@ roles:
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
2. Put the hash in frontmatter.plan (required when $status=ready)
3. Set repoPath to the absolute path of the repository root
IMPORTANT: Extract the repo remote (owner/repo) from git:
```bash
git remote get-url origin | sed 's|.*[:/]\([^/]*/[^.]*\).*|\1|'
```
Store the result as repoRemote in your frontmatter output so downstream roles can use it for tea/API calls.
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
frontmatter:
oneOf:
@@ -36,8 +30,7 @@ roles:
$status: { const: "ready" }
plan: { type: string }
repoPath: { type: string }
repoRemote: { type: string }
required: [$status, plan, repoPath, repoRemote]
required: [$status, plan, repoPath]
- properties:
$status: { const: "insufficient_info" }
required: [$status]
@@ -68,17 +61,6 @@ roles:
9. Implement the code to make tests pass
10. Ensure `bun run build` passes with no errors
11. Run `bun test` to verify all tests pass
- If tests fail on first run:
* Read the test output carefully for missing imports or setup issues
* Check if you're running tests from the correct working directory (package root vs workspace root)
* Fix the immediate issue and rerun ONCE
* If tests still fail after 2 attempts: check the test spec for ambiguities
* If stuck after 3 test cycles: set $status=failed with detailed error report rather than continuing blind retries
12. MANDATORY VERIFICATION before reporting done:
- Run `git branch --show-current` and confirm branch name matches expected
- Run `git status` and verify changed files exist
- Run `ls -la <key-implementation-files>` to verify they exist on disk
- If ANY verification fails: retry the implementation, do NOT report done
If you cannot complete the implementation (e.g. the issue is too complex, blocked by external factors,
or repeated attempts fail), set $status=failed with a reason.
@@ -89,7 +71,6 @@ roles:
$status: { const: "done" }
branch: { type: string }
worktree: { type: string }
repoRemote: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "failed" }
@@ -104,12 +85,7 @@ roles:
procedure: |
The worktree path is provided in your task prompt. cd into it first.
CRITICAL: You MUST execute every verification command below. Do NOT report results without running the actual commands. Do NOT rely on prior context or assumptions.
Before reviewing, verify the worktree and branch exist:
0. Run `cd <worktree-path> && pwd` to confirm the path is accessible
- If the cd fails: the worktree truly doesn't exist, reject with that reason
- If the cd succeeds: proceed with step 1 below
Before reviewing, verify the git branch:
1. Run `git branch --show-current` — confirm the branch name references the issue number being worked on
2. If the branch doesn't correspond to the issue, flag it in your output and reject
@@ -133,13 +109,11 @@ roles:
$status: { const: "approved" }
branch: { type: string }
worktree: { type: string }
repoRemote: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "rejected" }
comments: { type: string }
worktree: { type: string }
repoRemote: { type: string }
required: [$status, comments, worktree]
tester:
description: "Functional correctness verification"
@@ -163,48 +137,33 @@ roles:
$status: { const: "passed" }
branch: { type: string }
worktree: { type: string }
repoRemote: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "fix_code" }
report: { type: string }
repoRemote: { type: string }
worktree: { type: string }
branch: { type: string }
required: [$status, report]
- properties:
$status: { const: "fix_spec" }
report: { type: string }
repoRemote: { type: string }
worktree: { type: string }
branch: { type: string }
required: [$status, report]
committer:
description: "Commits and creates PR"
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
capabilities: []
procedure: |
The worktree path, branch name, and repo remote (owner/repo) are provided in your task prompt.
The worktree path, branch name, and repo info are provided in your task prompt.
cd into the worktree first.
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
1. Check `git status` — if working tree is clean and branch is ahead of origin, skip to step 3 (push).
2. If there are unstaged/uncommitted changes: `git add -A` then `git commit -m "type: description\n\nFixes #N"`
1. Stage all changes: `git add -A`
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
3. Push the branch: `git push -u origin <branch-name>`
4. **Verify push succeeded** — run `git ls-remote origin <branch-name>` and confirm it prints a commit hash.
- If no output or push failed: capture the error, mark hook_failed
5. Create a PR using the Gitea API (do NOT use `tea pr create` — it fails in worktrees):
```bash
GITEA_TOKEN=$(cfg get GITEA_TOKEN)
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \
"https://git.shazhou.work/api/v1/repos/<owner>/<repo>/pulls" \
-d '{"title":"...","body":"...","head":"<branch>","base":"main"}'
```
- The repo remote (owner/repo format, e.g. "uncaged/workflow") is given in your task prompt — use it directly.
- PR body must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
6. **Verify PR was created** — parse the curl response JSON: it must contain a `"number"` field. Print the PR URL.
- If curl returns an error or no number field: capture the response, mark hook_failed
7. After PR creation, clean up the worktree:
- If push hook fails: capture the error log in your output, mark hook_failed
4. On push success: create a PR via `tea pr create --repo <owner/repo> --title "..." --description "..."`
- Extract owner/repo from: `git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/'`
- PR description must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
- On tea failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed
5. After PR creation, clean up the worktree:
- cd to the repo root (parent of .worktrees)
- `git worktree remove <worktree-path>`
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
@@ -213,33 +172,27 @@ roles:
- properties:
$status: { const: "committed" }
prUrl: { type: string }
repoRemote: { type: string }
worktree: { type: string }
branch: { type: string }
required: [$status, prUrl]
- properties:
$status: { const: "hook_failed" }
error: { type: string }
repoRemote: { type: string }
worktree: { type: string }
branch: { type: string }
required: [$status, error]
graph:
$START:
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
planner:
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}. Repo remote: {{{repoRemote}}}." }
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}." }
developer:
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance. Repo remote: {{{repoRemote}}}." }
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
reviewer:
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in repo {{{worktree}}}. Repo remote: {{{repoRemote}}}." }
approved: { role: "tester", prompt: "Review passed. Run tests on branch {{{branch}}} at {{{worktree}}}. Repo remote: {{{repoRemote}}}." }
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in repo {{{worktree}}}." }
approved: { role: "tester", prompt: "Review passed. Run tests on branch {{{branch}}} at {{{worktree}}}." }
tester:
fix_code: { role: "developer", prompt: "Tests found code issues: {{{report}}}. Fix and re-submit. Worktree: {{{worktree}}}. Repo remote: {{{repoRemote}}}." }
fix_spec: { role: "planner", prompt: "Tests found spec issues: {{{report}}}. Revise the test spec. Repo remote: {{{repoRemote}}}." }
passed: { role: "committer", prompt: "All tests passed. Commit and push branch {{{branch}}} from {{{worktree}}}. Repo remote (owner/repo): {{{repoRemote}}}." }
fix_code: { role: "developer", prompt: "Tests found code issues: {{{report}}}. Fix and re-submit." }
fix_spec: { role: "planner", prompt: "Tests found spec issues: {{{report}}}. Revise the test spec." }
passed: { role: "committer", prompt: "All tests passed. Commit and push branch {{{branch}}} from {{{worktree}}}." }
committer:
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit. Worktree: {{{worktree}}}. Repo remote: {{{repoRemote}}}." }
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow complete." }
-183
View File
@@ -1,183 +0,0 @@
# UWF Bootstrap Guide
This guide helps any AI agent set up `uwf` (Uncaged Workflow) from scratch — or self-check and upgrade an existing installation.
## Prerequisites
- **bun** — `uwf` is built with bun. Install: `curl -fsSL https://bun.sh/install | bash`
- **Network access** — to install npm packages
> **Already have uwf?** Jump to [Self-Check & Upgrade](#self-check--upgrade).
---
## Fresh Install
### 1. Install uwf CLI
```bash
bun install -g @uncaged/cli-workflow
```
**Check:** `uwf --version` prints a version number (e.g. `0.5.1`).
### 2. Install Agent Adapter
Install the adapter that matches your agent runtime. Pick **one**:
| Agent | Package | Binary |
|-------|---------|--------|
| Hermes | `@uncaged/workflow-agent-hermes` | `uwf-hermes` |
```bash
# Example: Hermes agent
bun install -g @uncaged/workflow-agent-hermes
```
**Check:** `uwf-hermes --version` prints a version number.
### 3. Setup
Run the interactive wizard:
```bash
uwf setup
```
Or configure non-interactively:
```bash
uwf setup \
--provider <name> \
--base-url <url> \
--api-key <key> \
--model <model-name> \
--agent hermes
```
This creates `~/.uncaged/workflow/config.yaml` with your provider, model, and default agent.
#### Config Structure
```yaml
providers:
my-provider:
baseUrl: https://api.example.com/v1
apiKey: sk-xxx
models:
default:
provider: my-provider
name: my-model
agents:
hermes:
command: uwf-hermes
args: []
defaultAgent: hermes
defaultModel: default
```
**Check:** `cat ~/.uncaged/workflow/config.yaml` shows valid provider, model, and agent config.
### 4. Verify Installation
```bash
uwf workflow list # should return empty array or existing workflows
uwf skill user # prints usage guide
uwf skill author # prints workflow authoring guide
```
**Check:** All three commands run without errors.
### 5. Add the uwf Skill
```bash
mkdir -p ~/.hermes/skills/devops/uwf
uwf skill bootstrap > ~/.hermes/skills/devops/uwf/SKILL.md
```
**Check:** `cat ~/.hermes/skills/devops/uwf/SKILL.md` shows the skill content with triggers `uwf`, `workflow`, `工作流`.
### 6. Smoke Test
```bash
# Register an example workflow
uwf workflow add examples/analyze-topic.yaml
# Start a thread
uwf thread start analyze-topic -p "Analyze the concept of technical debt"
# Execute it (one moderator → agent → extract cycle)
uwf thread exec <thread-id>
```
**Check:** Thread reaches `completed` status. Verify with `uwf thread list`.
---
## Self-Check & Upgrade
Already have uwf installed? Run through this checklist to verify and upgrade.
### Version Check
```bash
uwf --version
uwf-hermes --version # or your agent adapter
```
Compare with latest published versions:
```bash
bun pm ls -g | grep -E "cli-workflow|workflow-agent"
npm info @uncaged/cli-workflow version
npm info @uncaged/workflow-agent-hermes version
```
If local version < published version, upgrade:
```bash
bun install -g @uncaged/cli-workflow@latest
bun install -g @uncaged/workflow-agent-hermes@latest
```
**Check:** `uwf --version` matches `npm info @uncaged/cli-workflow version`.
### Config Check
```bash
cat ~/.uncaged/workflow/config.yaml
```
Verify:
- [ ] `providers` has at least one entry with valid `baseUrl` and `apiKey`
- [ ] `models.default` references an existing provider
- [ ] `agents` has your adapter configured
- [ ] `defaultAgent` and `defaultModel` are set
### Skill Check
```bash
cat ~/.hermes/skills/devops/uwf/SKILL.md
```
Verify the skill is up to date:
```bash
uwf skill bootstrap | diff - ~/.hermes/skills/devops/uwf/SKILL.md
```
If `diff` produces any output, the local skill is outdated. Update:
```bash
uwf skill bootstrap > ~/.hermes/skills/devops/uwf/SKILL.md
```
### Functional Check
```bash
uwf workflow list # should not error
uwf skill user # should print usage guide
uwf skill author # should print authoring guide
```
✅ All green? You're good to go.
+1 -1
View File
@@ -270,7 +270,7 @@ node scripts/publish-all.mjs --dry-run # preview without publishing
examples/solve-issue.yaml — write a workflow YAML definition
│ uwf workflow put
~/.uncaged/json-cas/ — Workflow stored as CAS node (unified CAS store)
~/.uncaged/workflow/cas/ — Workflow stored as CAS node
~/.uncaged/workflow/registry.yaml — name → hash mapping updated
│ uwf thread start <name> -p "..."
+1 -2
View File
@@ -39,8 +39,7 @@
"linter": {
"rules": {
"suspicious": {
"noExplicitAny": "off",
"noConsole": "off"
"noExplicitAny": "off"
},
"style": {
"noNonNullAssertion": "off"
+9 -45
View File
@@ -8,46 +8,22 @@ roles:
- issue-analysis
- planning
procedure: |
CRITICAL: First, determine which mode you are in by scanning the task prompt.
Choose EXACTLY ONE mode — do NOT default to Mode A if Mode B applies.
**How to choose:**
- If the prompt contains ANY of these keywords: "PR #", "PR#", "pulls/", "继续修复", "continue", "review feedback", "existing branch", "fix/", or mentions a branch name → **Mode B**
- If the prompt was forwarded from tester with fix_spec → **Mode C**
- Otherwise → **Mode A**
**Mode A — Fresh issue (first time, no existing PR):**
On first run (no previous steps):
1. Read the issue and all comments from Gitea using `tea issues <number> -r <owner/repo>`
2. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
3. Assess whether the issue has enough information to produce a test spec
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output $status=insufficient_info
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
6. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
7. Output **$status=ready** with plan hash and repoPath
**Mode B — Continue on existing PR (prompt mentions PR, branch, or review feedback):**
YOU MUST output $status=continue (NOT ready) when in this mode.
1. Extract the PR number and branch name from the prompt
2. Read the PR and its review comments from Gitea: `tea pr <number> --comments -r <owner/repo>`
3. Read the existing issue for full context: `tea issues <number> -r <owner/repo>`
4. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
5. Produce a TDD test spec that ONLY covers the changes requested in the review — do NOT re-spec already-implemented features
6. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
7. Find the existing worktree: `git worktree list` and locate the branch
8. Output **$status=continue** with plan hash, repoPath, branch name, and worktree path
**Mode C — Bounced back by tester (fix_spec):**
On subsequent runs (bounced back by tester with fix_spec):
1. Read the tester's output from the previous step to understand what's wrong with the spec
2. Revise the test spec accordingly
3. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
4. Output **$status=ready** with plan hash and repoPath
IMPORTANT: Extract the repo remote (owner/repo) from git:
```bash
git remote get-url origin | sed 's|.*[:/]\([^/]*/[^.]*\).*|\1|'
```
Store the result as repoRemote in your frontmatter output so downstream roles can use it.
output: "Output a brief summary of the test spec. Set $status to ready (fresh), continue (existing PR), or insufficient_info."
After producing the test spec:
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
2. Put the hash in frontmatter.plan (required when $status=ready)
3. Set repoPath to the absolute path of the repository root
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
frontmatter:
oneOf:
- properties:
@@ -55,13 +31,6 @@ roles:
plan: { type: string }
repoPath: { type: string }
required: [$status, plan, repoPath]
- properties:
$status: { const: "continue" }
plan: { type: string }
repoPath: { type: string }
branch: { type: string }
worktree: { type: string }
required: [$status, plan, repoPath, branch, worktree]
- properties:
$status: { const: "insufficient_info" }
required: [$status]
@@ -80,14 +49,10 @@ roles:
3. First time (no existing branch):
- `git worktree add .worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
- `cd .worktrees/fix/<issue-number>-<short-slug> && bun install`
4. If continuing on existing branch (prompt says "Continue work on existing branch" or provides a worktree path):
- cd directly into the worktree path provided in the prompt
- `git fetch origin && git rebase origin/main`
- Do NOT create a new branch or worktree
5. If bounced back from reviewer or tester (branch already exists but no explicit worktree path):
4. If bounced back from reviewer or tester (branch already exists):
- cd into the existing worktree under `.worktrees/fix/<issue-number>-<short-slug>`
- `git fetch origin && git rebase origin/main`
6. ALL subsequent work must happen inside the worktree directory.
5. ALL subsequent work must happen inside the worktree directory.
Then implement TDD:
6. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner's output in your task prompt)
@@ -218,7 +183,6 @@ graph:
planner:
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}." }
continue: { role: "developer", prompt: "Continue work on existing branch {{{branch}}} at worktree {{{worktree}}}. Implement the revised TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}. Do NOT create a new branch or worktree — cd into the existing worktree and work there." }
developer:
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
+1 -7
View File
@@ -1,14 +1,11 @@
{
"name": "@uncaged/workflow-monorepo",
"private": true,
"packageManager": "bun@1.3.14",
"workspaces": [
"packages/*"
],
"scripts": {
"uwf": "bun packages/cli-workflow/src/cli.ts",
"preinstall": "npx only-allow bun",
"prepublishOnly": "echo 'Use bun run release instead' && exit 1",
"build": "bunx tsc --build",
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
"typecheck": "bunx tsc --build",
@@ -26,10 +23,7 @@
"@types/node": "^25.7.0",
"@types/xxhashjs": "^0.2.4",
"@uncaged/workflow-agent-hermes": "workspace:*",
"bun-types": "^1.3.13",
"typescript": "^5.8.3",
"vitest": "^4.1.7",
"yaml": "^2.9.0"
"bun-types": "^1.3.13"
},
"repository": {
"type": "git",
+11
View File
@@ -0,0 +1,11 @@
# @uncaged/cli-workflow
## 0.5.1
### Patch Changes
- Add 5 persona-based skills (actor, user, author, developer, adapter) and fix skill CLI description truncation
- Updated dependencies
- @uncaged/workflow-util@0.5.1
- @uncaged/workflow-protocol@0.5.1
- @uncaged/workflow-util-agent@0.5.1
+1 -10
View File
@@ -209,13 +209,4 @@ src/
| `~/.uncaged/workflow/.env` | API keys (referenced by `apiKeyEnv` in config) |
| `~/.uncaged/workflow/registry.yaml` | Workflow name → CAS hash |
| `~/.uncaged/workflow/threads.yaml` | Active thread head pointers |
| `~/.uncaged/json-cas/` | Content-addressed node storage (unified CAS store, shared with `json-cas` CLI) |
### Environment Variables
| Variable | Purpose | Default |
|----------|---------|---------|
| `UNCAGED_CAS_DIR` | Override the global CAS directory location | `~/.uncaged/json-cas` |
| `UNCAGED_WORKFLOW_STORAGE_ROOT` | Internal override for workflow metadata storage | `~/.uncaged/workflow` |
| `WORKFLOW_STORAGE_ROOT` | User override for workflow metadata storage | `~/.uncaged/workflow` |
| `~/.uncaged/workflow/cas/` | Content-addressed node storage |
+4 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/cli-workflow",
"version": "0.5.0",
"version": "0.5.1",
"files": [
"src",
"dist",
@@ -22,7 +22,6 @@
"yaml": "^2.8.4"
},
"scripts": {
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
"test": "vitest run",
"test:ci": "vitest run"
},
@@ -35,12 +34,12 @@
},
"repository": {
"type": "git",
"url": "https://git.shazhou.work/uncaged/workflow.git",
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
"directory": "packages/cli-workflow"
},
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
"bugs": {
"url": "https://git.shazhou.work/uncaged/workflow/issues"
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
},
"license": "MIT"
}
@@ -1,178 +0,0 @@
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { registerUwfSchemas } from "../schemas.js";
import { saveThreadsIndex } from "../store.js";
// ── schemas ──────────────────────────────────────────────────────────────────
const OUTPUT_SCHEMA = {
type: "object" as const,
properties: {
$status: { type: "string" as const, enum: ["done", "failed"] },
result: { type: "string" as const },
},
required: ["$status"],
additionalProperties: false,
};
// ── fixture ──────────────────────────────────────────────────────────────────
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-roundtrip-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
describe("C1: adapter JSON round-trip integration", () => {
test("mock agent outputs JSON, CLI parses it and updates thread head in CAS", async () => {
// 1. Set up CAS store with workflow, start node, and output schema
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const outputSchemaHash = await putSchema(store, OUTPUT_SCHEMA);
const workflowHash = await store.put(schemas.workflow, {
name: "test-roundtrip",
description: "roundtrip integration test",
roles: {
worker: {
description: "Worker role",
goal: "Do work",
capabilities: [],
procedure: "work",
output: "result",
frontmatter: outputSchemaHash,
},
},
graph: {
$START: { _: { role: "worker", prompt: "Do the work", location: null } },
worker: { done: { role: "$END", prompt: "completed", location: null } },
},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test round-trip task",
});
const threadId = "01ROUNDTRIPTEST0000000000" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: startHash });
// 2. Pre-create CAS nodes that the mock agent would produce
const outputHash = await store.put(outputSchemaHash, {
$status: "done",
result: "test-ok",
});
// Use text schema for detail (simple placeholder)
const detailHash = await store.put(schemas.text, "mock detail");
const startedAtMs = 1716600000000;
const completedAtMs = 1716600001500;
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-mock",
edgePrompt: "Do the work",
startedAtMs,
completedAtMs,
cwd: tmpDir,
});
// 3. Create a minimal mock agent shell script that just outputs JSON
// The step node is already in CAS — the agent just needs to print the JSON line
const mockAgentPath = join(tmpDir, "mock-agent.sh");
const adapterJson = JSON.stringify({
stepHash,
detailHash,
role: "worker",
frontmatter: { $status: "done", result: "test-ok" },
body: "",
startedAtMs,
completedAtMs,
});
await writeFile(mockAgentPath, `#!/bin/sh\necho '${adapterJson}'\n`, { mode: 0o755 });
// 4. Write config.yaml
const configPath = join(tmpDir, "config.yaml");
await writeFile(
configPath,
`defaultAgent: uwf-hermes\ndefaultModel: test-model\nagentOverrides: null\nagents: {}\nproviders: {}\nmodels: {}\n`,
);
// 5. Run CLI with agent override pointing to our mock
const cliPath = join(import.meta.dirname, "..", "cli.js");
let stdout: string;
let stderr: string;
let exitCode: number;
try {
stdout = execFileSync(
"bun",
["run", cliPath, "thread", "exec", threadId, "--agent", mockAgentPath],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
WORKFLOW_STORAGE_ROOT: tmpDir,
UNCAGED_CAS_DIR: casDir,
},
cwd: tmpDir,
timeout: 30000,
},
);
stderr = "";
exitCode = 0;
} catch (e: unknown) {
const err = e as NodeJS.ErrnoException & {
stdout?: string;
stderr?: string;
status?: number;
};
stdout = err.stdout ?? "";
stderr = err.stderr ?? "";
exitCode = err.status ?? 1;
}
// 6. Verify
if (exitCode !== 0) {
throw new Error(`CLI exited with code ${exitCode}\nstdout: ${stdout}\nstderr: ${stderr}`);
}
// Parse CLI output
const cliOutput = JSON.parse(stdout.trim());
expect(cliOutput).toHaveProperty("thread", threadId);
expect(cliOutput).toHaveProperty("head", stepHash);
expect(cliOutput.head).toMatch(/^[0-9A-HJ-NP-TV-Z]{13}$/);
// Verify the CAS step node exists and has correct metadata
const storeAfter = createFsStore(casDir);
const stepNode = storeAfter.get(cliOutput.head as CasRef);
expect(stepNode).not.toBeNull();
const payload = stepNode!.payload as StepNodePayload;
expect(payload.role).toBe("worker");
expect(payload.agent).toBe("uwf-mock");
expect(payload.startedAtMs).toBe(1716600000000);
expect(payload.completedAtMs).toBe(1716600001500);
expect(payload.output).toBe(outputHash);
expect(payload.detail).toBe(detailHash);
});
});
@@ -6,22 +6,14 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdCasPutText } from "../commands/cas.js";
let storageRoot: string;
let casDir: string;
let uwfPath: string;
let originalEnv: string | undefined;
beforeEach(async () => {
storageRoot = join(
tmpdir(),
`uwf-cas-exit-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
casDir = join(storageRoot, "cas");
await mkdir(storageRoot, { recursive: true });
await mkdir(casDir, { recursive: true });
// Set UNCAGED_CAS_DIR for this test
originalEnv = process.env.UNCAGED_CAS_DIR;
process.env.UNCAGED_CAS_DIR = casDir;
// Find the uwf CLI path
uwfPath = join(__dirname, "../../src/cli.ts");
@@ -29,13 +21,6 @@ beforeEach(async () => {
afterEach(async () => {
await rm(storageRoot, { recursive: true, force: true });
// Restore original environment
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
});
type ExecResult = {
@@ -47,11 +32,7 @@ type ExecResult = {
function execUwf(args: string[]): ExecResult {
try {
const stdout = execSync(`bun ${uwfPath} ${args.join(" ")}`, {
env: {
...process.env,
WORKFLOW_STORAGE_ROOT: storageRoot,
UNCAGED_CAS_DIR: casDir,
},
env: { ...process.env, WORKFLOW_STORAGE_ROOT: storageRoot },
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});
@@ -143,44 +143,6 @@ defaultModel: default
const masked = maskApiKeys(config);
expect(masked).toEqual(config);
});
test("does not mask non-provider apiKey fields", () => {
const config = {
apiKey: "root-level-key",
providers: {
dashscope: { apiKey: "sk-secret" },
},
models: {
default: { provider: "dashscope" },
},
};
const masked = maskApiKeys(config);
// Root-level apiKey should NOT be masked
expect(masked.apiKey).toBe("root-level-key");
// Provider apiKey SHOULD be masked
const providers = masked.providers as Record<string, Record<string, unknown>>;
expect(providers.dashscope.apiKey).toBe("***MASKED***");
});
test("handles empty provider object", () => {
const config = {
providers: { dashscope: {} },
};
const masked = maskApiKeys(config);
expect(masked).toEqual({ providers: { dashscope: {} } });
});
test("handles provider with null apiKey", () => {
const config = {
providers: {
dashscope: { apiKey: null, baseUrl: "https://example.com" },
},
};
const masked = maskApiKeys(config);
const providers = masked.providers as Record<string, Record<string, unknown>>;
expect(providers.dashscope.apiKey).toBe("***MASKED***");
expect(providers.dashscope.baseUrl).toBe("https://example.com");
});
});
});
@@ -656,82 +618,5 @@ defaultModel: default
rmSync(tempDir, { recursive: true, force: true });
}
});
test("agentOverrides — accepts valid 3-segment path", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await cmdConfigSet(tempDir, "agentOverrides.solve-issue.planner", "claude-code");
const value = await cmdConfigGet(tempDir, "agentOverrides.solve-issue.planner");
expect(value).toBe("claude-code");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("agentOverrides — rejects incomplete path (2 segments)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "agentOverrides.solve-issue", "hermes")).rejects.toThrow(
/incomplete path|must specify a field/i,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("modelOverrides — accepts valid 2-segment path", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await cmdConfigSet(tempDir, "modelOverrides.extract", "gpt4");
const value = await cmdConfigGet(tempDir, "modelOverrides.extract");
expect(value).toBe("gpt4");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("modelOverrides — rejects incomplete path (1 segment only)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "modelOverrides", "gpt4")).rejects.toThrow(
/incomplete path|must specify a field/i,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects unknown top-level key (regression)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "randomKey", "value")).rejects.toThrow(
/Unknown config key/,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
describe("no legacy apiKeyEnv references", () => {
test("config.ts has no references to apiKeyEnv", () => {
const configSource = readFileSync(join(__dirname, "..", "commands", "config.ts"), "utf8");
expect(configSource).not.toContain("apiKeyEnv");
});
test("config.test.ts has no references to apiKeyEnv (except this test)", () => {
const testSource = readFileSync(__filename, "utf8");
// Remove this test block's own mentions before checking
const withoutThisTest = testSource.replace(
/describe\("no legacy apiKeyEnv references"[\s\S]*$/,
"",
);
expect(withoutThisTest).not.toContain("apiKeyEnv");
});
});
});
@@ -1,456 +0,0 @@
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { putSchema } from "@uncaged/json-cas";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { createMarker, deleteMarker } from "../background/index.js";
import { cmdThreadList, cmdThreadShow, cmdThreadStart } from "../commands/thread.js";
import {
appendThreadHistory,
createUwfStore,
loadThreadsIndex,
saveThreadsIndex,
} from "../store.js";
const OUTPUT_SCHEMA = {
type: "object" as const,
properties: {
$status: { type: "string" as const },
},
};
const SIMPLE_WORKFLOW_YAML = `
name: test-current-role
description: Test workflow for currentRole
roles:
roleA:
description: First role
goal: Do A
capabilities: ["coding"]
procedure: Do A
output: |
$status: "ready"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string, enum: ["ready", "not-ready"] }
roleB:
description: Second role
goal: Do B
capabilities: ["coding"]
procedure: Do B
output: |
$status: "done"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string }
graph:
$START:
_:
role: roleA
prompt: "Do A"
location: null
roleA:
ready:
role: roleB
prompt: "Do B"
location: null
not-ready:
role: roleA
prompt: "Try again"
location: null
roleB:
_:
role: $END
prompt: "Done"
location: null
`;
const CONDITIONAL_WORKFLOW_YAML = `
name: test-conditional-role
description: Conditional routing workflow
roles:
roleA:
description: First role
goal: Do A
capabilities: ["coding"]
procedure: Do A
output: |
$status: "pass"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string, enum: ["pass", "fail"] }
roleB:
description: Pass role
goal: Do B
capabilities: ["coding"]
procedure: Do B
output: |
$status: "done"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string }
roleC:
description: Fail role
goal: Do C
capabilities: ["coding"]
procedure: Do C
output: |
$status: "done"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string }
graph:
$START:
_:
role: roleA
prompt: "Do A"
location: null
roleA:
pass:
role: roleB
prompt: "Do B (pass)"
location: null
fail:
role: roleC
prompt: "Do C (fail)"
location: null
roleB:
_:
role: $END
prompt: "Done"
location: null
roleC:
_:
role: $END
prompt: "Done"
location: null
`;
const SINGLE_ROLE_WORKFLOW_YAML = `
name: test-single-role
description: Single role that goes to END
roles:
worker:
description: Worker
goal: Work
capabilities: ["coding"]
procedure: Work
output: |
$status: "done"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string }
graph:
$START:
_:
role: worker
prompt: "Work"
location: null
worker:
_:
role: $END
prompt: "Done"
location: null
`;
/** Helper: insert a completed step node after the current head. */
async function insertStepNode(
storageRoot: string,
threadId: ThreadId,
role: string,
outputPayload: Record<string, unknown>,
): Promise<void> {
const uwf = await createUwfStore(storageRoot);
const index = await loadThreadsIndex(storageRoot);
const head = index[threadId];
if (head === undefined) throw new Error(`thread ${threadId} not in index`);
const outputSchemaHash = await putSchema(uwf.store, OUTPUT_SCHEMA);
const outputHash = await uwf.store.put(outputSchemaHash, outputPayload);
// Use text schema for detail (simple placeholder)
const detailHash = await uwf.store.put(uwf.schemas.text, "detail-placeholder");
// Resolve start hash from head
const headNode = uwf.store.get(head);
if (headNode === null) throw new Error(`head ${head} not found`);
const isStart = headNode.type === uwf.schemas.startNode;
const startHash = isStart ? head : (headNode.payload as { start: CasRef }).start;
const stepHash = (await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: isStart ? null : head,
role,
prompt: `Do ${role}`,
output: outputHash,
detail: detailHash,
})) as CasRef;
index[threadId] = stepHash;
await saveThreadsIndex(storageRoot, index);
}
describe("currentRole field", () => {
let tmpDir: string;
let storageRoot: string;
let casDir: string;
let originalEnv: string | undefined;
async function setup() {
tmpDir = join(
tmpdir(),
`uwf-test-current-role-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
storageRoot = join(tmpDir, "storage");
casDir = join(tmpDir, "cas");
await mkdir(storageRoot, { recursive: true });
await mkdir(casDir, { recursive: true });
// Set UNCAGED_CAS_DIR for this test
originalEnv = process.env.UNCAGED_CAS_DIR;
process.env.UNCAGED_CAS_DIR = casDir;
}
async function teardown() {
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true });
}
// Restore original environment
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
}
// T1: idle at start — currentRole = first role from graph
test("thread show — idle at start returns first role as currentRole", async () => {
await setup();
try {
const wf = join(tmpDir, "test-current-role.yaml");
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
const { thread } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
const result = await cmdThreadShow(storageRoot, thread as ThreadId);
expect(result.status).toBe("idle");
expect(result.currentRole).toBe("roleA");
} finally {
await teardown();
}
});
// T2: idle after one step — currentRole = next role
test("thread show — idle after step returns next role as currentRole", async () => {
await setup();
try {
const wf = join(tmpDir, "test-current-role.yaml");
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
const { thread } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
await insertStepNode(storageRoot, thread as ThreadId, "roleA", { $status: "ready" });
const result = await cmdThreadShow(storageRoot, thread as ThreadId);
expect(result.status).toBe("idle");
expect(result.currentRole).toBe("roleB");
} finally {
await teardown();
}
});
// T3: completed → currentRole = null
test("thread show — completed thread returns null currentRole", async () => {
await setup();
try {
const wf = join(tmpDir, "test-current-role.yaml");
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
const { thread, workflow } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
const tid = thread as ThreadId;
const index = await loadThreadsIndex(storageRoot);
const head = index[tid]!;
delete index[tid];
await saveThreadsIndex(storageRoot, index);
await appendThreadHistory(storageRoot, {
thread: tid,
workflow,
head,
completedAt: Date.now(),
reason: "completed",
});
const result = await cmdThreadShow(storageRoot, tid);
expect(result.status).toBe("completed");
expect(result.currentRole).toBe(null);
} finally {
await teardown();
}
});
// T4: cancelled → currentRole = null
test("thread show — cancelled thread returns null currentRole", async () => {
await setup();
try {
const wf = join(tmpDir, "test-current-role.yaml");
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
const { thread, workflow } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
const tid = thread as ThreadId;
const index = await loadThreadsIndex(storageRoot);
const head = index[tid]!;
delete index[tid];
await saveThreadsIndex(storageRoot, index);
await appendThreadHistory(storageRoot, {
thread: tid,
workflow,
head,
completedAt: Date.now(),
reason: "cancelled",
});
const result = await cmdThreadShow(storageRoot, tid);
expect(result.status).toBe("cancelled");
expect(result.currentRole).toBe(null);
} finally {
await teardown();
}
});
// T5: running → currentRole = role being executed
test("thread show — running thread returns current role", async () => {
await setup();
try {
const wf = join(tmpDir, "test-current-role.yaml");
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
const { thread, workflow } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
const tid = thread as ThreadId;
await createMarker(storageRoot, {
thread: tid,
workflow,
pid: process.pid,
startedAt: Date.now(),
});
try {
const result = await cmdThreadShow(storageRoot, tid);
expect(result.status).toBe("running");
expect(result.currentRole).toBe("roleA");
} finally {
await deleteMarker(storageRoot, tid);
}
} finally {
await teardown();
}
});
// T6: thread list — mixed statuses with correct currentRole
test("thread list — returns correct currentRole for each status", async () => {
await setup();
try {
const wf = join(tmpDir, "test-current-role.yaml");
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
// idle thread
const idle = await cmdThreadStart(storageRoot, wf, "idle", tmpDir);
const idleId = idle.thread as ThreadId;
// completed thread
const comp = await cmdThreadStart(storageRoot, wf, "completed", tmpDir);
const compId = comp.thread as ThreadId;
const index = await loadThreadsIndex(storageRoot);
const compHead = index[compId]!;
delete index[compId];
await saveThreadsIndex(storageRoot, index);
await appendThreadHistory(storageRoot, {
thread: compId,
workflow: comp.workflow,
head: compHead,
completedAt: Date.now(),
reason: "completed",
});
const list = await cmdThreadList(storageRoot, null, null, null, 0, 100);
const idleItem = list.find((i) => i.thread === idleId);
expect(idleItem).toBeDefined();
expect(idleItem!.currentRole).toBe("roleA");
const compItem = list.find((i) => i.thread === compId);
expect(compItem).toBeDefined();
expect(compItem!.currentRole).toBe(null);
} finally {
await teardown();
}
});
// T7: thread list — idle at start has correct currentRole
test("thread list — idle thread at start has correct currentRole", async () => {
await setup();
try {
const wf = join(tmpDir, "test-current-role.yaml");
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
const { thread } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
const list = await cmdThreadList(storageRoot, null, null, null, 0, 100);
const item = list.find((i) => i.thread === (thread as ThreadId));
expect(item).toBeDefined();
expect(item!.currentRole).toBe("roleA");
} finally {
await teardown();
}
});
// T8: conditional routing — $status=pass vs fail
test("thread show — conditional routing selects correct next role", async () => {
await setup();
try {
const wf = join(tmpDir, "test-conditional-role.yaml");
await writeFile(wf, CONDITIONAL_WORKFLOW_YAML, "utf8");
// pass path
const t1 = await cmdThreadStart(storageRoot, wf, "pass test", tmpDir);
await insertStepNode(storageRoot, t1.thread as ThreadId, "roleA", { $status: "pass" });
const r1 = await cmdThreadShow(storageRoot, t1.thread as ThreadId);
expect(r1.currentRole).toBe("roleB");
// fail path
const t2 = await cmdThreadStart(storageRoot, wf, "fail test", tmpDir);
await insertStepNode(storageRoot, t2.thread as ThreadId, "roleA", { $status: "fail" });
const r2 = await cmdThreadShow(storageRoot, t2.thread as ThreadId);
expect(r2.currentRole).toBe("roleC");
} finally {
await teardown();
}
});
// T9: next role is $END → currentRole = null
test("thread show — when next is $END, currentRole is null", async () => {
await setup();
try {
const wf = join(tmpDir, "test-single-role.yaml");
await writeFile(wf, SINGLE_ROLE_WORKFLOW_YAML, "utf8");
const { thread } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
// worker → _ maps to $END
await insertStepNode(storageRoot, thread as ThreadId, "worker", {});
const result = await cmdThreadShow(storageRoot, thread as ThreadId);
expect(result.currentRole).toBe(null);
} finally {
await teardown();
}
});
});
@@ -5,17 +5,17 @@ import { evaluate } from "../moderator/evaluate.js";
const solveIssueGraph: WorkflowPayload["graph"] = {
$START: {
_: { role: "planner", prompt: "Start planning from the issue in the task.", location: null },
_: { role: "planner", prompt: "Start planning from the issue in the task." },
},
planner: {
_: { role: "developer", prompt: "Implement the plan: {{plan}}", location: null },
_: { role: "developer", prompt: "Implement the plan: {{plan}}" },
},
developer: {
_: { role: "reviewer", prompt: "Review the changes: {{summary}}", location: null },
_: { role: "reviewer", prompt: "Review the changes: {{summary}}" },
},
reviewer: {
approved: { role: "$END", prompt: "Done.", location: null },
rejected: { role: "developer", prompt: "Fix: {{comments}}", location: null },
approved: { role: "$END", prompt: "Done." },
rejected: { role: "developer", prompt: "Fix: {{comments}}" },
},
};
@@ -24,11 +24,7 @@ describe("evaluate", () => {
const result = evaluate(solveIssueGraph, "$START", { $status: "_" });
expect(result).toEqual({
ok: true,
value: {
role: "planner",
prompt: "Start planning from the issue in the task.",
location: null,
},
value: { role: "planner", prompt: "Start planning from the issue in the task." },
});
});
@@ -39,7 +35,7 @@ describe("evaluate", () => {
});
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: "Fix: missing tests", location: null },
value: { role: "developer", prompt: "Fix: missing tests" },
});
});
@@ -47,7 +43,7 @@ describe("evaluate", () => {
const result = evaluate(solveIssueGraph, "reviewer", { $status: "approved" });
expect(result).toEqual({
ok: true,
value: { role: "$END", prompt: "Done.", location: null },
value: { role: "$END", prompt: "Done." },
});
});
@@ -74,11 +70,7 @@ describe("evaluate", () => {
});
expect(result).toEqual({
ok: true,
value: {
role: "developer",
prompt: "Implement the plan: Add auth middleware",
location: null,
},
value: { role: "developer", prompt: "Implement the plan: Add auth middleware" },
});
});
@@ -89,14 +81,14 @@ describe("evaluate", () => {
});
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: 'Fix: use <T> & "Result<T, E>" types', location: null },
value: { role: "developer", prompt: 'Fix: use <T> & "Result<T, E>" types' },
});
});
test("triple mustache also works for unescaped output", () => {
const graph: Record<string, Record<string, Target>> = {
reviewer: {
_: { role: "developer", prompt: "Fix: {{{comments}}}", location: null },
_: { role: "developer", prompt: "Fix: {{{comments}}}" },
},
};
const result = evaluate(graph, "reviewer", {
@@ -105,7 +97,7 @@ describe("evaluate", () => {
});
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: "Fix: <script>alert(1)</script>", location: null },
value: { role: "developer", prompt: "Fix: <script>alert(1)</script>" },
});
});
@@ -115,11 +107,7 @@ describe("evaluate", () => {
});
expect(result).toEqual({
ok: true,
value: {
role: "developer",
prompt: "Implement the plan: Add auth middleware",
location: null,
},
value: { role: "developer", prompt: "Implement the plan: Add auth middleware" },
});
});
@@ -129,7 +117,6 @@ describe("evaluate", () => {
_: {
role: "developer",
prompt: "Address: {{review.comments}}",
location: null,
},
},
};
@@ -139,7 +126,7 @@ describe("evaluate", () => {
});
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: "Address: refactor the handler", location: null },
value: { role: "developer", prompt: "Address: refactor the handler" },
});
});
});
@@ -6,17 +6,27 @@ import { describe, expect, test } from "vitest";
const __dirname = dirname(fileURLToPath(import.meta.url));
import {
cmdSkillActor,
cmdSkillAdapter,
cmdSkillArchitecture,
cmdSkillAuthor,
cmdSkillCli,
cmdSkillDeveloper,
cmdSkillList,
cmdSkillModerator,
cmdSkillUser,
cmdSkillYaml,
} from "../commands/skill.js";
describe("skill commands", () => {
test("skill list returns all skill names", () => {
const result = cmdSkillList();
expect(result).toBeInstanceOf(Array);
expect(result).toContain("cli");
expect(result).toContain("architecture");
expect(result).toContain("yaml");
expect(result).toContain("moderator");
expect(result).toContain("actor");
expect(result).toContain("user");
expect(result).toContain("author");
expect(result).toContain("developer");
@@ -26,6 +36,50 @@ describe("skill commands", () => {
}
});
test("skill architecture returns non-empty markdown string", () => {
const result = cmdSkillArchitecture();
expect(typeof result).toBe("string");
expect(result).toContain("CAS");
expect(result).toContain("Thread");
expect(result).toContain("Workflow");
expect(result).toContain("Step");
expect(result.length).toBeGreaterThan(200);
});
test("skill yaml returns non-empty markdown string", () => {
const result = cmdSkillYaml();
expect(typeof result).toBe("string");
expect(result).toContain("roles");
expect(result).toContain("graph");
expect(result).toContain("frontmatter");
expect(result.length).toBeGreaterThan(200);
});
test("skill moderator returns non-empty markdown string", () => {
const result = cmdSkillModerator();
expect(typeof result).toBe("string");
expect(result).toContain("routing");
expect(result).toContain("status");
expect(result.length).toBeGreaterThan(200);
// Check for edge or graph
expect(result).toMatch(/edge|graph/i);
});
test("skill cli returns CLI reference markdown", () => {
const result = cmdSkillCli();
expect(typeof result).toBe("string");
expect(result).toContain("uwf");
});
test("skill actor returns non-empty markdown string", () => {
const result = cmdSkillActor();
expect(typeof result).toBe("string");
expect(result).toContain("frontmatter");
expect(result).toContain("CAS");
expect(result).toContain("status");
expect(result.length).toBeGreaterThan(200);
});
test("skill user returns non-empty markdown string", () => {
const result = cmdSkillUser();
expect(typeof result).toBe("string");
@@ -72,6 +126,11 @@ describe("skill commands", () => {
env: { ...process.env, PATH: `/opt/homebrew/bin:${process.env.PATH}` },
});
expect(output).not.toMatch(/help\s+\[command\]/i);
expect(output).toContain("cli");
expect(output).toContain("architecture");
expect(output).toContain("yaml");
expect(output).toContain("moderator");
expect(output).toContain("actor");
expect(output).toContain("user");
expect(output).toContain("author");
expect(output).toContain("developer");
@@ -8,11 +8,11 @@ import { parse } from "yaml";
* Test: Issue #474 - tea pr create fails in git worktree directories
*
* This test verifies that the solve-issue workflow's committer role
* uses direct Gitea API calls via curl instead of tea pr create,
* which fixes the "path segment [0] is empty" error in worktree directories.
* includes the --repo flag when running tea pr create, which fixes
* the "path segment [0] is empty" error in worktree directories.
*/
describe("solve-issue workflow: Gitea API PR creation", () => {
describe("solve-issue workflow: tea pr create worktree fix", () => {
// Navigate up from packages/cli-workflow/src/__tests__ to repo root
const workflowPath = join(
import.meta.dirname,
@@ -24,7 +24,7 @@ describe("solve-issue workflow: Gitea API PR creation", () => {
"solve-issue.yaml",
);
test("committer procedure should use curl API instead of tea pr create", async () => {
test("committer procedure should include --repo flag in tea pr create command", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
const workflow = parse(yamlContent) as WorkflowPayload;
@@ -32,38 +32,43 @@ describe("solve-issue workflow: Gitea API PR creation", () => {
const committerProcedure = workflow.roles.committer?.procedure;
expect(committerProcedure).toBeDefined();
// Verify the procedure uses curl API, not tea pr create
expect(committerProcedure).toContain("curl");
expect(committerProcedure).toContain("api/v1/repos");
expect(committerProcedure).toContain("/pulls");
// Verify the procedure includes tea pr create with --repo flag
expect(committerProcedure).toContain("tea pr create");
expect(committerProcedure).toContain("--repo");
// Verify it explicitly warns against tea pr create
expect(committerProcedure).toMatch(/do NOT use.*tea pr create/i);
// Verify the --repo flag appears before or together with tea pr create
// This ensures the command is: tea pr create --repo <owner/repo> ...
const teaPrCreateMatch = committerProcedure?.match(/tea pr create[^\n]*/);
expect(teaPrCreateMatch).not.toBeNull();
if (teaPrCreateMatch) {
const teaCommandLine = teaPrCreateMatch[0];
expect(teaCommandLine).toContain("--repo");
}
});
test("committer procedure should reference repoRemote from task prompt", async () => {
test("committer procedure should mention repo extraction from git remote", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
const workflow = parse(yamlContent) as WorkflowPayload;
const committerProcedure = workflow.roles.committer?.procedure;
expect(committerProcedure).toBeDefined();
// Verify the procedure mentions repoRemote is provided in task prompt
expect(committerProcedure).toMatch(/repo remote.*provided.*task prompt/i);
expect(committerProcedure).toMatch(/owner\/repo/i);
// Verify the procedure mentions extracting repo info from git remote
// This ensures fallback logic is documented
expect(committerProcedure).toMatch(/git remote/i);
});
test("committer procedure should include error handling for curl failures", async () => {
test("committer procedure should include error handling for tea failures", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
const workflow = parse(yamlContent) as WorkflowPayload;
const committerProcedure = workflow.roles.committer?.procedure;
expect(committerProcedure).toBeDefined();
// Verify the procedure includes error handling guidance for curl
// Verify the procedure includes error handling guidance
// This ensures we capture failures and provide actionable output
expect(committerProcedure).toMatch(/error|fail/i);
expect(committerProcedure).toContain("hook_failed");
});
test("workflow should be parseable as valid WorkflowPayload", async () => {
@@ -93,51 +98,9 @@ describe("solve-issue workflow: Gitea API PR creation", () => {
expect(frontmatter).toBeDefined();
expect(frontmatter?.oneOf).toBeDefined();
const committedVariant = frontmatter.oneOf.find(
(v: any) => v.properties?.$status?.const === "committed",
(v: any) => v.properties?.["$status"]?.const === "committed",
);
expect(committedVariant).toBeDefined();
expect(committedVariant.required).toContain("$status");
});
test("developer procedure should include mandatory verification step", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
const workflow = parse(yamlContent) as WorkflowPayload;
const developerProcedure = workflow.roles.developer?.procedure;
expect(developerProcedure).toBeDefined();
// Verify the procedure includes mandatory verification step
expect(developerProcedure).toContain("MANDATORY VERIFICATION");
expect(developerProcedure).toContain("git branch --show-current");
expect(developerProcedure).toContain("git status");
expect(developerProcedure).toMatch(/ls -la|verify.*exist/i);
});
test("reviewer procedure should enforce worktree path verification", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
const workflow = parse(yamlContent) as WorkflowPayload;
const reviewerProcedure = workflow.roles.reviewer?.procedure;
expect(reviewerProcedure).toBeDefined();
// Verify the procedure includes critical enforcement
expect(reviewerProcedure).toContain("CRITICAL");
expect(reviewerProcedure).toMatch(/cd.*pwd/);
expect(reviewerProcedure).toContain(
"Do NOT report results without running the actual commands",
);
});
test("developer procedure should include test debugging escalation", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
const workflow = parse(yamlContent) as WorkflowPayload;
const developerProcedure = workflow.roles.developer?.procedure;
expect(developerProcedure).toBeDefined();
// Verify the procedure includes test failure guidance
expect(developerProcedure).toMatch(/tests fail.*first run/i);
expect(developerProcedure).toMatch(/3 test cycles|after 3 attempts/i);
expect(developerProcedure).toContain("$status=failed");
});
});
@@ -1,100 +0,0 @@
import { describe, expect, test } from "vitest";
/**
* B-group tests: validate JSON parsing logic used by spawnAgent.
*
* We test the parsing logic inline since spawnAgent is a private function.
* These tests verify the contract: last line of stdout must be valid JSON
* with a valid stepHash CasRef.
*/
const CASREF_PATTERN = /^[0-9A-HJ-NP-TV-Z]{13}$/;
function isCasRef(s: string): boolean {
return CASREF_PATTERN.test(s);
}
type AdapterOutput = {
stepHash: string;
detailHash: string;
role: string;
frontmatter: Record<string, unknown>;
body: string;
startedAtMs: number;
completedAtMs: number;
};
function parseAgentStdout(stdout: string): AdapterOutput {
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
throw new Error(`agent stdout last line is not valid JSON: ${line || "(empty)"}`);
}
const obj = parsed as Record<string, unknown>;
if (
typeof obj !== "object" ||
obj === null ||
typeof obj.stepHash !== "string" ||
!isCasRef(obj.stepHash as string)
) {
throw new Error(`agent stdout JSON missing valid stepHash: ${line}`);
}
return obj as unknown as AdapterOutput;
}
const VALID_OUTPUT: AdapterOutput = {
stepHash: "0123456789ABC",
detailHash: "DEFGH12345678",
role: "planner",
frontmatter: { $status: "ready", plan: "somehash" },
body: "Plan body",
startedAtMs: 1000,
completedAtMs: 2000,
};
describe("spawnAgent JSON parsing", () => {
test("B1. parses valid JSON from agent stdout", () => {
const stdout = `${JSON.stringify(VALID_OUTPUT)}\n`;
const result = parseAgentStdout(stdout);
expect(result.stepHash).toBe("0123456789ABC");
expect(result.detailHash).toBe("DEFGH12345678");
expect(result.role).toBe("planner");
expect(result.frontmatter).toEqual({ $status: "ready", plan: "somehash" });
expect(result.body).toBe("Plan body");
expect(result.startedAtMs).toBe(1000);
expect(result.completedAtMs).toBe(2000);
});
test("B2. extracts stepHash for head pointer", () => {
const stdout = `${JSON.stringify(VALID_OUTPUT)}\n`;
const result = parseAgentStdout(stdout);
expect(result.stepHash).toBe("0123456789ABC");
expect(isCasRef(result.stepHash)).toBe(true);
});
test("B3. handles debug lines before JSON", () => {
const debugLines = "[debug] loading context...\n[debug] running agent...\n";
const stdout = `${debugLines + JSON.stringify(VALID_OUTPUT)}\n`;
const result = parseAgentStdout(stdout);
expect(result.stepHash).toBe("0123456789ABC");
});
test("B4. rejects non-JSON last line", () => {
const stdout = "not-json-at-all\n";
expect(() => parseAgentStdout(stdout)).toThrow("not valid JSON");
});
test("B5. rejects JSON missing stepHash", () => {
const incomplete = { detailHash: "DEFGH12345678", role: "planner" };
const stdout = `${JSON.stringify(incomplete)}\n`;
expect(() => parseAgentStdout(stdout)).toThrow("missing valid stepHash");
});
test("B6. rejects JSON with invalid stepHash", () => {
const bad = { ...VALID_OUTPUT, stepHash: "not-a-hash" };
const stdout = `${JSON.stringify(bad)}\n`;
expect(() => parseAgentStdout(stdout)).toThrow("missing valid stepHash");
});
});
@@ -66,21 +66,13 @@ function generateContent(size: number, prefix = "Content"): string {
// ── fixture ───────────────────────────────────────────────────────────────────
let tmpDir: string;
let originalEnv: string | undefined;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-step-read-test-"));
originalEnv = process.env.UNCAGED_CAS_DIR;
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
// Restore original environment
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
});
// ── step read tests ───────────────────────────────────────────────────────────
@@ -88,10 +80,7 @@ afterEach(async () => {
describe("step read", () => {
test("test 1: basic single-step read with 3 turns", async () => {
const casDir = join(tmpDir, "cas");
process.env.UNCAGED_CAS_DIR = casDir;
await mkdir(casDir, { recursive: true });
process.env.UNCAGED_CAS_DIR = casDir;
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
@@ -157,11 +146,10 @@ describe("step read", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
// Read step with large quota
const markdown = await cmdStepRead(tmpDir, stepHash, 10000, false);
const markdown = await cmdStepRead(tmpDir, stepHash, 10000);
// Assert structure
expect(markdown).toContain(`# Step ${stepHash}`);
@@ -177,9 +165,7 @@ describe("step read", () => {
test("test 2: quota enforcement - multiple turns", async () => {
const casDir = join(tmpDir, "cas");
process.env.UNCAGED_CAS_DIR = casDir;
await mkdir(casDir, { recursive: true });
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
@@ -245,11 +231,10 @@ describe("step read", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
// Read step with limited quota (700 chars)
const markdown = await cmdStepRead(tmpDir, stepHash, 700, false);
const markdown = await cmdStepRead(tmpDir, stepHash, 700);
// Assert only most recent turns fit
expect(markdown).toContain(`# Step ${stepHash}`);
@@ -263,9 +248,7 @@ describe("step read", () => {
test("test 3: minimal quota edge case - always show at least one turn", async () => {
const casDir = join(tmpDir, "cas");
process.env.UNCAGED_CAS_DIR = casDir;
await mkdir(casDir, { recursive: true });
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
@@ -327,11 +310,10 @@ describe("step read", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
// Read step with minimal quota (1 char)
const markdown = await cmdStepRead(tmpDir, stepHash, 1, false);
const markdown = await cmdStepRead(tmpDir, stepHash, 1);
// Assert at least one turn is always shown
expect(markdown).toContain("LongTurn");
@@ -340,9 +322,7 @@ describe("step read", () => {
test("test 4: step with no detail field", async () => {
const casDir = join(tmpDir, "cas");
process.env.UNCAGED_CAS_DIR = casDir;
await mkdir(casDir, { recursive: true });
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
@@ -385,11 +365,10 @@ describe("step read", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
// Read step - should return metadata only (no error)
const markdown = await cmdStepRead(tmpDir, stepHash, 4000, false);
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
// Assert metadata is present
expect(markdown).toContain(`# Step ${stepHash}`);
@@ -401,9 +380,7 @@ describe("step read", () => {
test("test 5: step with detail but no turns array", async () => {
const casDir = join(tmpDir, "cas");
process.env.UNCAGED_CAS_DIR = casDir;
await mkdir(casDir, { recursive: true });
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
await registerDetailSchemas(store);
@@ -464,11 +441,10 @@ describe("step read", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
// Read step - should return metadata only (no error)
const markdown = await cmdStepRead(tmpDir, stepHash, 4000, false);
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
// Assert metadata is present
expect(markdown).toContain(`# Step ${stepHash}`);
@@ -479,9 +455,7 @@ describe("step read", () => {
test("test 6: displays role and tool calls in turn body", async () => {
const casDir = join(tmpDir, "cas");
process.env.UNCAGED_CAS_DIR = casDir;
await mkdir(casDir, { recursive: true });
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
@@ -541,10 +515,9 @@ describe("step read", () => {
agent: "uwf-hermes",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const markdown = await cmdStepRead(tmpDir, stepHash, 4000, false);
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
expect(markdown).toContain("**Turn role:** assistant");
expect(markdown).toContain("**terminal**");
@@ -553,9 +526,7 @@ describe("step read", () => {
test("test 7: turn content with special characters", async () => {
const casDir = join(tmpDir, "cas");
process.env.UNCAGED_CAS_DIR = casDir;
await mkdir(casDir, { recursive: true });
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
@@ -617,11 +588,10 @@ describe("step read", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
// Read step
const markdown = await cmdStepRead(tmpDir, stepHash, 4000, false);
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
// Assert content is rendered correctly without corruption
expect(markdown).toContain("`backticks`");
@@ -1,372 +0,0 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, type Hash, type JSONSchema, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, StepNodePayload } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdStepShow } from "../commands/step.js";
import { formatOutput } from "../format.js";
import { registerUwfSchemas } from "../schemas.js";
const TURN_SCHEMA: JSONSchema = {
title: "test-turn",
type: "object",
required: ["index", "role", "content"],
properties: {
index: { type: "integer" },
role: { type: "string", enum: ["assistant", "tool"] },
content: { type: "string" },
toolCalls: {
anyOf: [
{
type: "array",
items: {
type: "object",
required: ["name", "args"],
properties: {
name: { type: "string" },
args: { type: "string" },
},
additionalProperties: false,
},
},
{ type: "null" },
],
},
},
additionalProperties: false,
};
const DETAIL_SCHEMA: JSONSchema = {
title: "test-detail",
type: "object",
required: ["turns"],
properties: {
turns: {
type: "array",
items: { type: "string", format: "cas_ref" },
},
},
additionalProperties: false,
};
type TestSetup = {
store: ReturnType<typeof createFsStore>;
schemas: {
workflow: Hash;
startNode: Hash;
stepNode: Hash;
text: Hash;
};
turnType: Hash;
detailType: Hash;
};
async function setupTest(casDir: string): Promise<TestSetup> {
const store = createFsStore(casDir);
await bootstrap(store);
const schemas = await registerUwfSchemas(store);
const [turnType, detailType] = await Promise.all([
putSchema(store, TURN_SCHEMA),
putSchema(store, DETAIL_SCHEMA),
]);
return { store, schemas, turnType, detailType };
}
async function createTestStep(
setup: TestSetup,
turnPayloads: Array<{
index: number;
role: string;
content: string;
toolCalls: Array<{ name: string; args: string }> | null;
}>,
): Promise<CasRef> {
const { store, schemas, turnType, detailType } = setup;
// Create turn nodes
const turnHashes: CasRef[] = [];
for (const payload of turnPayloads) {
const turnHash = await store.put(turnType, payload);
turnHashes.push(turnHash);
}
// Create detail node
const detailHash = await store.put(detailType, { turns: turnHashes });
// Create dummy start node
const startHash = await store.put(schemas.startNode, {
workflow: "0000000000000" as CasRef,
prompt: "test prompt",
cwd: "/tmp",
});
// Create dummy output node
const outputHash = await store.put(schemas.text, { $status: "done" });
// Create step node
const stepPayload: StepNodePayload = {
prev: null,
start: startHash,
role: "test-role",
agent: "test-agent",
output: outputHash,
detail: detailHash,
edgePrompt: "",
startedAtMs: Date.now(),
completedAtMs: Date.now() + 1000,
assembledPrompt: null,
cwd: "/tmp",
};
return store.put(schemas.stepNode, stepPayload);
}
describe("cmdStepShow JSON serialization", () => {
let testDir: string;
let casDir: string;
let originalEnv: string | undefined;
beforeEach(async () => {
testDir = await mkdtemp(join(tmpdir(), "uwf-test-"));
casDir = join(testDir, "cas");
await mkdir(casDir, { recursive: true });
originalEnv = process.env.UNCAGED_CAS_DIR;
process.env.UNCAGED_CAS_DIR = casDir;
});
afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
});
test("escapes newlines in tool call args", async () => {
const setup = await setupTest(casDir);
const stepHash = await createTestStep(setup, [
{
index: 0,
role: "assistant",
content: "Running command",
toolCalls: [
{
name: "Bash",
args: "echo 'line1'\necho 'line2'",
},
],
},
]);
const result = await cmdStepShow(testDir, stepHash);
const jsonOutput = formatOutput(result, "json");
expect(() => JSON.parse(jsonOutput)).not.toThrow();
expect(jsonOutput).toContain("\\n");
const parsed = JSON.parse(jsonOutput);
expect(parsed.turns[0].toolCalls[0].args).toContain("\n");
});
test("escapes tabs in tool call args", async () => {
const setup = await setupTest(casDir);
const stepHash = await createTestStep(setup, [
{
index: 0,
role: "assistant",
content: "",
toolCalls: [
{
name: "Bash",
args: "cat <<EOF\nfield1\tfield2\tfield3\nEOF",
},
],
},
]);
const result = await cmdStepShow(testDir, stepHash);
const jsonOutput = formatOutput(result, "json");
expect(() => JSON.parse(jsonOutput)).not.toThrow();
expect(jsonOutput).toContain("\\t");
});
test("escapes carriage returns", async () => {
const setup = await setupTest(casDir);
const stepHash = await createTestStep(setup, [
{
index: 0,
role: "assistant",
content: "Committing changes",
toolCalls: [
{
name: "Bash",
args: 'git commit -m "First line\r\nSecond line"',
},
],
},
]);
const result = await cmdStepShow(testDir, stepHash);
const jsonOutput = formatOutput(result, "json");
expect(() => JSON.parse(jsonOutput)).not.toThrow();
expect(jsonOutput).toContain("\\r\\n");
});
test("escapes backslashes and quotes", async () => {
const setup = await setupTest(casDir);
const stepHash = await createTestStep(setup, [
{
index: 0,
role: "assistant",
content: "",
toolCalls: [
{
name: "Bash",
args: 'echo "He said \\"hello\\""',
},
],
},
]);
const result = await cmdStepShow(testDir, stepHash);
const jsonOutput = formatOutput(result, "json");
expect(() => JSON.parse(jsonOutput)).not.toThrow();
const parsed = JSON.parse(jsonOutput);
expect(parsed.turns).toBeDefined();
});
test("handles Unicode control characters", async () => {
const setup = await setupTest(casDir);
const stepHash = await createTestStep(setup, [
{
index: 0,
role: "assistant",
content: "",
toolCalls: [
{
name: "Bash",
args: "echo '\u0001\u001F'",
},
],
},
]);
const result = await cmdStepShow(testDir, stepHash);
const jsonOutput = formatOutput(result, "json");
expect(() => JSON.parse(jsonOutput)).not.toThrow();
});
test("handles nested CAS refs with control characters", async () => {
const setup = await setupTest(casDir);
const stepHash = await createTestStep(setup, [
{
index: 0,
role: "assistant",
content: "First turn\nwith newline",
toolCalls: [
{
name: "Bash",
args: "cmd1\nline2",
},
],
},
{
index: 1,
role: "assistant",
content: "Second turn\twith tab",
toolCalls: null,
},
]);
const result = await cmdStepShow(testDir, stepHash);
const jsonOutput = formatOutput(result, "json");
expect(() => JSON.parse(jsonOutput)).not.toThrow();
const parsed = JSON.parse(jsonOutput);
expect(parsed.turns).toHaveLength(2);
});
test("YAML output format is unaffected", async () => {
const setup = await setupTest(casDir);
const stepHash = await createTestStep(setup, [
{
index: 0,
role: "assistant",
content: "Running command",
toolCalls: [
{
name: "Bash",
args: "echo 'line1'\necho 'line2'",
},
],
},
]);
const result = await cmdStepShow(testDir, stepHash);
const yamlOutput = formatOutput(result, "yaml");
expect(yamlOutput).toContain("turns:");
expect(yamlOutput.length).toBeGreaterThan(0);
});
test("handles empty and null values", async () => {
const setup = await setupTest(casDir);
const stepHash = await createTestStep(setup, [
{
index: 0,
role: "assistant",
content: "",
toolCalls: null,
},
]);
const result = await cmdStepShow(testDir, stepHash);
const jsonOutput = formatOutput(result, "json");
expect(() => JSON.parse(jsonOutput)).not.toThrow();
const parsed = JSON.parse(jsonOutput);
expect(parsed.turns).toBeDefined();
});
test("handles large step with multiple tool calls", async () => {
const setup = await setupTest(casDir);
const turns = [];
for (let i = 0; i < 25; i++) {
turns.push({
index: i,
role: "assistant" as const,
content: `Turn ${i}\nwith newline`,
toolCalls: [
{
name: "Bash",
args: `command${i}\nline2\tfield${i}`,
},
{
name: "Read",
args: `/path/to/file${i}`,
},
],
});
}
const stepHash = await createTestStep(setup, turns);
const startTime = Date.now();
const result = await cmdStepShow(testDir, stepHash);
const jsonOutput = formatOutput(result, "json");
const duration = Date.now() - startTime;
expect(duration).toBeLessThan(2000);
expect(() => JSON.parse(jsonOutput)).not.toThrow();
const parsed = JSON.parse(jsonOutput);
expect(parsed.turns).toHaveLength(25);
});
});
@@ -63,22 +63,13 @@ async function registerDetailSchemas(store: ReturnType<typeof createFsStore>) {
// ── fixture ──────────────────────────────────────────────────────────────────
let tmpDir: string;
let originalEnv: string | undefined;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-step-timing-test-"));
originalEnv = process.env.UNCAGED_CAS_DIR;
process.env.UNCAGED_CAS_DIR = join(tmpDir, "cas");
await mkdir(process.env.UNCAGED_CAS_DIR, { recursive: true });
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
});
// ── 1. Protocol types (compile-time) ─────────────────────────────────────────
@@ -94,8 +85,6 @@ describe("protocol types", () => {
edgePrompt: "",
startedAtMs: 1000,
completedAtMs: 2000,
assembledPrompt: null,
cwd: "/test/path",
};
expect(record.startedAtMs).toBe(1000);
expect(record.completedAtMs).toBe(2000);
@@ -163,7 +152,6 @@ describe("StepNode JSON schema", () => {
edgePrompt: "",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
expect(hash).toBeTruthy();
});
@@ -251,8 +239,8 @@ describe("thread read timing", () => {
},
},
graph: {
$START: { _: { role: "worker", prompt: "go", location: null } },
worker: { _: { role: "$END", prompt: "", location: null } },
$START: { _: { role: "worker", prompt: "go" } },
worker: { _: { role: "$END", prompt: "" } },
},
});
@@ -317,8 +305,8 @@ describe("thread read timing", () => {
},
},
graph: {
$START: { _: { role: "worker", prompt: "go", location: null } },
worker: { _: { role: "$END", prompt: "", location: null } },
$START: { _: { role: "worker", prompt: "go" } },
worker: { _: { role: "$END", prompt: "" } },
},
});
@@ -1,224 +0,0 @@
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createUwfStore, getCasDir, getGlobalCasDir } from "../store.js";
describe("Global CAS directory", () => {
let tmpDir: string;
let originalEnv: string | undefined;
beforeEach(async () => {
tmpDir = join(tmpdir(), `uwf-test-global-cas-${Date.now()}`);
await mkdir(tmpDir, { recursive: true });
originalEnv = process.env.UNCAGED_CAS_DIR;
});
afterEach(async () => {
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true });
}
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
});
test("getGlobalCasDir returns default path when no env var set", () => {
delete process.env.UNCAGED_CAS_DIR;
const casDir = getGlobalCasDir();
// Should return ~/.uncaged/json-cas
expect(casDir).toContain(".uncaged");
expect(casDir).toContain("json-cas");
});
test("getGlobalCasDir respects UNCAGED_CAS_DIR environment variable", () => {
const customPath = join(tmpDir, "custom-cas");
process.env.UNCAGED_CAS_DIR = customPath;
const casDir = getGlobalCasDir();
expect(casDir).toBe(customPath);
});
test("getGlobalCasDir ignores empty UNCAGED_CAS_DIR", () => {
process.env.UNCAGED_CAS_DIR = "";
const casDir = getGlobalCasDir();
expect(casDir).toContain(".uncaged");
expect(casDir).toContain("json-cas");
});
test("getCasDir is deprecated but still works for backward compatibility", () => {
const storageRoot = join(tmpDir, "storage");
const casDir = getCasDir(storageRoot);
expect(casDir).toBe(join(storageRoot, "cas"));
});
test("createUwfStore uses global CAS directory", async () => {
const globalCasDir = join(tmpDir, "global-cas");
process.env.UNCAGED_CAS_DIR = globalCasDir;
const storageRoot = join(tmpDir, "storage");
await mkdir(storageRoot, { recursive: true });
const uwf = await createUwfStore(storageRoot);
// Verify the store was created in the global CAS directory
expect(uwf.storageRoot).toBe(storageRoot);
expect(uwf.store).toBeDefined();
expect(uwf.schemas).toBeDefined();
// The global CAS directory should be created
const { stat } = await import("node:fs/promises");
const stats = await stat(globalCasDir);
expect(stats.isDirectory()).toBe(true);
});
test("createUwfStore creates global CAS directory if it does not exist", async () => {
const globalCasDir = join(tmpDir, "new-global-cas");
process.env.UNCAGED_CAS_DIR = globalCasDir;
const storageRoot = join(tmpDir, "storage");
await mkdir(storageRoot, { recursive: true });
await createUwfStore(storageRoot);
// Verify the directory was created
const { stat } = await import("node:fs/promises");
const stats = await stat(globalCasDir);
expect(stats.isDirectory()).toBe(true);
});
test("multiple uwfStore instances share the same global CAS filesystem", async () => {
const globalCasDir = join(tmpDir, "shared-cas");
process.env.UNCAGED_CAS_DIR = globalCasDir;
const storageRoot1 = join(tmpDir, "storage1");
const storageRoot2 = join(tmpDir, "storage2");
await mkdir(storageRoot1, { recursive: true });
await mkdir(storageRoot2, { recursive: true });
const uwf1 = await createUwfStore(storageRoot1);
const uwf2 = await createUwfStore(storageRoot2);
// Both should use the same global CAS directory
expect(uwf1.store).toBeDefined();
expect(uwf2.store).toBeDefined();
// Store a node in the first store
const testData = { test: "data" };
const _hash = uwf1.store.put(uwf1.schemas.text, JSON.stringify(testData));
// Both stores share the same CAS filesystem directory
// Since schemas are registered idempotently, they should have the same hash
expect(uwf2.schemas.text).toBe(uwf1.schemas.text);
// Verify the CAS files are written to the shared directory
const { readdir } = await import("node:fs/promises");
const files = await readdir(globalCasDir);
expect(files.length).toBeGreaterThan(0);
});
test("workflow metadata remains in storageRoot, not global CAS", async () => {
const globalCasDir = join(tmpDir, "global-cas");
process.env.UNCAGED_CAS_DIR = globalCasDir;
const storageRoot = join(tmpDir, "storage");
await mkdir(storageRoot, { recursive: true });
const _uwf = await createUwfStore(storageRoot);
// Write workflow registry file
const { saveWorkflowRegistry } = await import("../store.js");
await saveWorkflowRegistry(storageRoot, { "test-workflow": "ABC123" });
// Verify registry is in storageRoot, not global CAS
const { readFile } = await import("node:fs/promises");
const registryPath = join(storageRoot, "workflows.yaml");
const content = await readFile(registryPath, "utf8");
expect(content).toContain("test-workflow");
expect(content).toContain("ABC123");
// Verify registry is NOT in global CAS directory
const globalRegistryPath = join(globalCasDir, "workflows.yaml");
await expect(readFile(globalRegistryPath, "utf8")).rejects.toThrow();
});
test("thread metadata remains in storageRoot", async () => {
const globalCasDir = join(tmpDir, "global-cas");
process.env.UNCAGED_CAS_DIR = globalCasDir;
const storageRoot = join(tmpDir, "storage");
await mkdir(storageRoot, { recursive: true });
await createUwfStore(storageRoot);
// Write threads index
const { saveThreadsIndex } = await import("../store.js");
await saveThreadsIndex(storageRoot, { "thread-123": "hash-456" });
// Verify threads.yaml is in storageRoot, not global CAS
const { readFile } = await import("node:fs/promises");
const threadsPath = join(storageRoot, "threads.yaml");
const content = await readFile(threadsPath, "utf8");
expect(content).toContain("thread-123");
expect(content).toContain("hash-456");
// Verify threads.yaml is NOT in global CAS directory
const globalThreadsPath = join(globalCasDir, "threads.yaml");
await expect(readFile(globalThreadsPath, "utf8")).rejects.toThrow();
});
test("history remains in storageRoot", async () => {
const globalCasDir = join(tmpDir, "global-cas");
process.env.UNCAGED_CAS_DIR = globalCasDir;
const storageRoot = join(tmpDir, "storage");
await mkdir(storageRoot, { recursive: true });
await createUwfStore(storageRoot);
// Write history
const { appendThreadHistory } = await import("../store.js");
await appendThreadHistory(storageRoot, {
thread: "thread-123" as any,
workflow: "workflow-456",
head: "hash-789",
completedAt: Date.now(),
reason: "completed",
});
// Verify history.jsonl is in storageRoot, not global CAS
const { readFile } = await import("node:fs/promises");
const historyPath = join(storageRoot, "history.jsonl");
const content = await readFile(historyPath, "utf8");
expect(content).toContain("thread-123");
expect(content).toContain("workflow-456");
// Verify history.jsonl is NOT in global CAS directory
const globalHistoryPath = join(globalCasDir, "history.jsonl");
await expect(readFile(globalHistoryPath, "utf8")).rejects.toThrow();
});
test("CAS nodes are stored in global directory", async () => {
const globalCasDir = join(tmpDir, "global-cas");
process.env.UNCAGED_CAS_DIR = globalCasDir;
const storageRoot = join(tmpDir, "storage");
await mkdir(storageRoot, { recursive: true });
const uwf = await createUwfStore(storageRoot);
// Store a CAS node
const testPayload = JSON.stringify({ test: "node" });
const _hash = uwf.store.put(uwf.schemas.text, testPayload);
// Verify the node is in global CAS directory
const { readdir } = await import("node:fs/promises");
const files = await readdir(globalCasDir);
expect(files.length).toBeGreaterThan(0);
// Verify the node is NOT in the old storageRoot/cas location
const oldCasDir = join(storageRoot, "cas");
await expect(readdir(oldCasDir)).rejects.toThrow();
});
});
@@ -15,8 +15,6 @@ import { appendThreadHistory, createUwfStore, saveThreadsIndex } from "../store.
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = join(storageRoot, "cas");
await mkdir(casDir, { recursive: true });
// Set UNCAGED_CAS_DIR to use the test's CAS directory
process.env.UNCAGED_CAS_DIR = casDir;
return createUwfStore(storageRoot);
}
@@ -1,188 +0,0 @@
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasRef, StartNodePayload, ThreadId } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { cmdThreadStart } from "../commands/thread.js";
import { createUwfStore } from "../store.js";
describe("Thread and edge location integration", () => {
let tmpDir: string;
let storageRoot: string;
let casDir: string;
let originalEnv: string | undefined;
async function setupTestEnv() {
tmpDir = join(tmpdir(), `uwf-test-location-${Date.now()}`);
storageRoot = join(tmpDir, "storage");
casDir = join(tmpDir, "cas");
await mkdir(storageRoot, { recursive: true });
await mkdir(casDir, { recursive: true });
// Set UNCAGED_CAS_DIR for this test
originalEnv = process.env.UNCAGED_CAS_DIR;
process.env.UNCAGED_CAS_DIR = casDir;
}
async function teardown() {
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true });
}
// Restore original environment
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
}
test("thread start captures cwd in StartNode", async () => {
await setupTestEnv();
const workflowYaml = `
name: test-location
description: Test workflow for location feature
roles:
planner:
description: Plans the work
goal: Plan implementation
capabilities: ["planning"]
procedure: Plan
output: |
$status: "ready"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string }
graph:
$START:
_:
role: planner
prompt: "Plan the work"
location: null
planner:
_:
role: $END
prompt: "Done"
location: null
`;
const workflowPath = join(tmpDir, "test-location.yaml");
await writeFile(workflowPath, workflowYaml, "utf8");
const testCwd = "/test/project/path";
const result = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir, testCwd);
expect(result.thread).toBeDefined();
expect(result.workflow).toBeDefined();
// Verify StartNode has the cwd field
const uwf = await createUwfStore(storageRoot);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
const headHash = index[result.thread as ThreadId];
expect(headHash).toBeDefined();
const startNode = uwf.store.get(headHash as CasRef);
expect(startNode).not.toBe(null);
expect(startNode?.type).toBe(uwf.schemas.startNode);
const startPayload = startNode?.payload as StartNodePayload;
expect(startPayload.cwd).toBe(testCwd);
await teardown();
});
test("thread start validates cwd is absolute path", async () => {
await setupTestEnv();
const workflowYaml = `
name: test-location
description: Test workflow
roles:
planner:
description: Plans
goal: Plan
capabilities: ["planning"]
procedure: Plan
output: |
$status: "ready"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string }
graph:
$START:
_:
role: planner
prompt: "Plan"
location: null
planner:
_:
role: $END
prompt: "Done"
location: null
`;
const workflowPath = join(tmpDir, "test-location.yaml");
await writeFile(workflowPath, workflowYaml, "utf8");
// Relative path should fail (process.exit is wrapped by vitest)
await expect(
cmdThreadStart(storageRoot, workflowPath, "test", tmpDir, "relative/path"),
).rejects.toThrow();
await teardown();
});
test("thread start uses process.cwd() as default", async () => {
await setupTestEnv();
const workflowYaml = `
name: test-default-cwd
description: Test default cwd
roles:
planner:
description: Plans
goal: Plan
capabilities: ["planning"]
procedure: Plan
output: |
$status: "ready"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string }
graph:
$START:
_:
role: planner
prompt: "Plan"
location: null
planner:
_:
role: $END
prompt: "Done"
location: null
`;
const workflowPath = join(tmpDir, "test-default-cwd.yaml");
await writeFile(workflowPath, workflowYaml, "utf8");
const result = await cmdThreadStart(storageRoot, workflowPath, "test", tmpDir);
const uwf = await createUwfStore(storageRoot);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
const headHash = index[result.thread as ThreadId];
const startNode = uwf.store.get(headHash as CasRef);
const startPayload = startNode?.payload as StartNodePayload;
// Should default to process.cwd()
expect(startPayload.cwd).toBe(process.cwd());
await teardown();
});
});
@@ -67,22 +67,13 @@ function generateContent(size: number, prefix = "Content"): string {
// ── fixture ───────────────────────────────────────────────────────────────────
let tmpDir: string;
let originalEnv: string | undefined;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-quota-test-"));
originalEnv = process.env.UNCAGED_CAS_DIR;
process.env.UNCAGED_CAS_DIR = join(tmpDir, "cas");
await mkdir(process.env.UNCAGED_CAS_DIR, { recursive: true });
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
});
// ── thread read quota enforcement ─────────────────────────────────────────────
@@ -152,7 +143,6 @@ describe("thread read --quota flag", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
steps.push(stepHash);
}
@@ -235,7 +225,6 @@ describe("thread read --quota flag", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const step2Content = generateContent(600, "Second");
@@ -262,7 +251,6 @@ describe("thread read --quota flag", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01HX2Q3R4S5T6V7W8X9YZ1" as ThreadId;
@@ -348,7 +336,6 @@ describe("thread read --quota flag", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
steps.push(stepHash);
}
@@ -428,7 +415,6 @@ describe("thread read --quota flag", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01HX2Q3R4S5T6V7W8X9YZ4" as ThreadId;
@@ -506,7 +492,6 @@ describe("thread read --quota flag", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
steps.push(stepHash);
}
@@ -588,7 +573,6 @@ describe("thread read --quota flag", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
steps.push(stepHash);
}
@@ -53,8 +53,6 @@ const DETAIL_SCHEMA = {
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = join(storageRoot, "cas");
await mkdir(casDir, { recursive: true });
// Set UNCAGED_CAS_DIR to use the test's CAS directory
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
return { storageRoot, store, schemas };
@@ -143,7 +141,6 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-claude-code",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01JTEST0000000000000001" as ThreadId;
@@ -221,7 +218,6 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-claude-code",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01JTEST0000000000000002" as ThreadId;
@@ -284,7 +280,6 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
@@ -296,7 +291,6 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01JTEST0000000000000003" as ThreadId;
@@ -351,7 +345,6 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01JTEST0000000000000004" as ThreadId;
@@ -406,7 +399,6 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01JTEST0000000000000005" as ThreadId;
@@ -461,7 +453,6 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01JTEST0000000000000006" as ThreadId;
@@ -536,7 +527,6 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
@@ -548,7 +538,6 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const step3 = await uwf.store.put(uwf.schemas.stepNode, {
@@ -560,7 +549,6 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01JTEST0000000000000007" as ThreadId;
@@ -641,7 +629,6 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01JTEST0000000000000008" as ThreadId;
@@ -698,7 +685,6 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
})) as CasRef;
steps.push(step);
prev = step;
@@ -1,227 +0,0 @@
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ThreadId } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { createMarker, deleteMarker } from "../background/index.js";
import { cmdThreadShow, cmdThreadStart } from "../commands/thread.js";
import { appendThreadHistory, loadThreadsIndex } from "../store.js";
const TEST_WORKFLOW_YAML = `
name: test-status
description: Test workflow for status field
roles:
planner:
description: Plans the work
goal: Plan implementation
capabilities: ["planning"]
procedure: Plan
output: |
$status: "ready"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string }
graph:
$START:
_:
role: planner
prompt: "Plan the work"
location: null
planner:
_:
role: $END
prompt: "Done"
location: null
`;
describe("thread show status field", () => {
let tmpDir: string;
let storageRoot: string;
async function setupTestEnv() {
tmpDir = join(tmpdir(), `uwf-test-status-${Date.now()}`);
storageRoot = join(tmpDir, "storage");
await mkdir(storageRoot, { recursive: true });
}
async function teardown() {
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true });
}
}
test("active idle thread shows status 'idle'", async () => {
await setupTestEnv();
const workflowPath = join(tmpDir, "test-status.yaml");
await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8");
// Create a thread
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
const threadId = startResult.thread as ThreadId;
// Show the thread (should be idle)
const result = await cmdThreadShow(storageRoot, threadId);
expect(result.status).toBe("idle");
expect(result.done).toBe(false);
expect(result.background).toBe(null);
expect(result.thread).toBe(threadId);
await teardown();
});
test("active running thread shows status 'running'", async () => {
await setupTestEnv();
const workflowPath = join(tmpDir, "test-status.yaml");
await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8");
// Create a thread
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
const threadId = startResult.thread as ThreadId;
const workflow = startResult.workflow;
// Create a running marker
await createMarker(storageRoot, {
thread: threadId,
workflow,
pid: process.pid,
startedAt: Date.now(),
});
try {
const result = await cmdThreadShow(storageRoot, threadId);
expect(result.status).toBe("running");
expect(result.done).toBe(false);
expect(result.background).toBe(null);
expect(result.thread).toBe(threadId);
} finally {
// Cleanup: delete marker
await deleteMarker(storageRoot, threadId);
await teardown();
}
});
test("completed thread shows status 'completed'", async () => {
await setupTestEnv();
const workflowPath = join(tmpDir, "test-status.yaml");
await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8");
// Create a thread
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
const threadId = startResult.thread as ThreadId;
const workflow = startResult.workflow;
// Get the head hash before moving to history
const index = await loadThreadsIndex(storageRoot);
const head = index[threadId];
if (!head) throw new Error("Thread not found in index");
// Move thread to history with reason 'completed'
const { saveThreadsIndex } = await import("../store.js");
const newIndex = { ...index };
delete newIndex[threadId];
await saveThreadsIndex(storageRoot, newIndex);
await appendThreadHistory(storageRoot, {
thread: threadId,
workflow,
head,
completedAt: Date.now(),
reason: "completed",
});
const result = await cmdThreadShow(storageRoot, threadId);
expect(result.status).toBe("completed");
expect(result.done).toBe(true);
expect(result.background).toBe(null);
expect(result.thread).toBe(threadId);
await teardown();
});
test("cancelled thread shows status 'cancelled'", async () => {
await setupTestEnv();
const workflowPath = join(tmpDir, "test-status.yaml");
await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8");
// Create a thread
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
const threadId = startResult.thread as ThreadId;
const workflow = startResult.workflow;
// Get the head hash before moving to history
const index = await loadThreadsIndex(storageRoot);
const head = index[threadId];
if (!head) throw new Error("Thread not found in index");
// Move thread to history with reason 'cancelled'
const { saveThreadsIndex } = await import("../store.js");
const newIndex = { ...index };
delete newIndex[threadId];
await saveThreadsIndex(storageRoot, newIndex);
await appendThreadHistory(storageRoot, {
thread: threadId,
workflow,
head,
completedAt: Date.now(),
reason: "cancelled",
});
const result = await cmdThreadShow(storageRoot, threadId);
expect(result.status).toBe("cancelled");
expect(result.done).toBe(true);
expect(result.background).toBe(null);
expect(result.thread).toBe(threadId);
await teardown();
});
test("legacy completed thread without reason shows status 'completed'", async () => {
await setupTestEnv();
const workflowPath = join(tmpDir, "test-status.yaml");
await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8");
// Create a thread
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
const threadId = startResult.thread as ThreadId;
const workflow = startResult.workflow;
// Get the head hash before moving to history
const index = await loadThreadsIndex(storageRoot);
const head = index[threadId];
if (!head) throw new Error("Thread not found in index");
// Move thread to history with reason null (legacy format)
const { saveThreadsIndex } = await import("../store.js");
const newIndex = { ...index };
delete newIndex[threadId];
await saveThreadsIndex(storageRoot, newIndex);
await appendThreadHistory(storageRoot, {
thread: threadId,
workflow,
head,
completedAt: Date.now(),
reason: null,
});
const result = await cmdThreadShow(storageRoot, threadId);
expect(result.status).toBe("completed");
expect(result.done).toBe(true);
expect(result.background).toBe(null);
await teardown();
});
});
@@ -1,162 +0,0 @@
import { execFileSync } from "node:child_process";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasRef, StartNodePayload, ThreadId } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { cmdThreadStart } from "../commands/thread.js";
import { createUwfStore, loadThreadsIndex } from "../store.js";
describe("thread start --cwd CLI option", () => {
let tmpDir: string;
let storageRoot: string;
let casDir: string;
let originalEnv: string | undefined;
async function setupTestEnv() {
tmpDir = join(tmpdir(), `uwf-test-cwd-cli-${Date.now()}`);
storageRoot = join(tmpDir, "storage");
casDir = join(tmpDir, "cas");
await mkdir(storageRoot, { recursive: true });
await mkdir(casDir, { recursive: true });
// Set UNCAGED_CAS_DIR for this test
originalEnv = process.env.UNCAGED_CAS_DIR;
process.env.UNCAGED_CAS_DIR = casDir;
}
async function teardown() {
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true });
}
// Restore original environment
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
}
async function createTestWorkflow(): Promise<string> {
const workflowYaml = `
name: test-cwd-cli
description: Test workflow for CLI cwd option
roles:
planner:
description: Plans the work
goal: Plan implementation
capabilities: ["planning"]
procedure: Plan
output: |
$status: "ready"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string }
graph:
$START:
_:
role: planner
prompt: "Plan the work"
location: null
planner:
_:
role: $END
prompt: "Done"
location: null
`;
const workflowPath = join(tmpDir, "test-cwd-cli.yaml");
await writeFile(workflowPath, workflowYaml, "utf8");
return workflowPath;
}
async function getStartNodeCwd(threadId: string): Promise<string> {
const uwf = await createUwfStore(storageRoot);
const index = await loadThreadsIndex(storageRoot);
const headHash = index[threadId as ThreadId];
expect(headHash).toBeDefined();
const startNode = uwf.store.get(headHash as CasRef);
expect(startNode).not.toBe(null);
expect(startNode?.type).toBe(uwf.schemas.startNode);
const startPayload = startNode?.payload as StartNodePayload;
return startPayload.cwd;
}
test("thread start with custom cwd via cmdThreadStart", async () => {
await setupTestEnv();
const workflowPath = await createTestWorkflow();
const testCwd = "/test/custom/path";
const result = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir, testCwd);
expect(result.thread).toBeDefined();
const actualCwd = await getStartNodeCwd(result.thread);
expect(actualCwd).toBe(testCwd);
await teardown();
});
test("thread start without cwd defaults to process.cwd()", async () => {
await setupTestEnv();
const workflowPath = await createTestWorkflow();
// Call without cwd parameter (it defaults to process.cwd())
const result = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
expect(result.thread).toBeDefined();
const actualCwd = await getStartNodeCwd(result.thread);
expect(actualCwd).toBe(process.cwd());
await teardown();
});
test("thread start with relative path fails", async () => {
await setupTestEnv();
const workflowPath = await createTestWorkflow();
await expect(
cmdThreadStart(storageRoot, workflowPath, "test", tmpDir, "relative/path"),
).rejects.toThrow();
await teardown();
});
test("CLI accepts --cwd option without error", async () => {
await setupTestEnv();
const workflowPath = await createTestWorkflow();
const testCwd = "/test/cli/path";
const uwfBin = join(process.cwd(), "dist", "cli.js");
// Register the workflow
execFileSync("node", [uwfBin, "workflow", "add", workflowPath], {
env: { ...process.env, UWF_STORAGE_ROOT: storageRoot, UNCAGED_CAS_DIR: casDir },
encoding: "utf8",
});
// Verify CLI accepts --cwd option (no error thrown)
const output = execFileSync(
"node",
[uwfBin, "thread", "start", "test-cwd-cli", "-p", "test prompt", "--cwd", testCwd],
{
env: { ...process.env, UWF_STORAGE_ROOT: storageRoot, UNCAGED_CAS_DIR: casDir },
encoding: "utf8",
},
);
const result = JSON.parse(output);
expect(result.thread).toBeDefined();
expect(result.workflow).toBeDefined();
// The fact that we got here without throwing means CLI accepted the --cwd option
// The actual cwd functionality is tested by the other tests using cmdThreadStart directly
await teardown();
});
});
@@ -58,8 +58,6 @@ const DETAIL_SCHEMA = {
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = join(storageRoot, "cas");
await mkdir(casDir, { recursive: true });
// Set UNCAGED_CAS_DIR to use the test's CAS directory
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
return { storageRoot, store, schemas };
@@ -51,11 +51,11 @@ function makeWorkflow(overrides?: Partial<WorkflowPayload>): WorkflowPayload {
},
},
graph: {
$START: { _: { role: "writer", prompt: "Begin writing", location: null } },
writer: { _: { role: "reviewer", prompt: "Review this: {{{plan}}}", location: null } },
$START: { _: { role: "writer", prompt: "Begin writing" } },
writer: { _: { role: "reviewer", prompt: "Review this: {{{plan}}}" } },
reviewer: {
approved: { role: "$END", prompt: "Done: {{{summary}}}", location: null },
rejected: { role: "writer", prompt: "Fix: {{{reason}}}", location: null },
approved: { role: "$END", prompt: "Done: {{{summary}}}" },
rejected: { role: "writer", prompt: "Fix: {{{reason}}}" },
},
},
};
@@ -67,7 +67,7 @@ function makeWorkflow(overrides?: Partial<WorkflowPayload>): WorkflowPayload {
describe("Suite 1: Role Reference Integrity", () => {
test("1.1 graph references unknown role", () => {
const wf = makeWorkflow();
wf.graph.nonexistent = { _: { role: "$END", prompt: "done", location: null } };
wf.graph.nonexistent = { _: { role: "$END", prompt: "done" } };
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes('unknown role "nonexistent"'))).toBe(true);
});
@@ -138,8 +138,8 @@ describe("Suite 2: Graph Structure", () => {
test("2.2 $START has multiple status keys", () => {
const wf = makeWorkflow();
wf.graph.$START = {
_: { role: "writer", prompt: "Begin", location: null },
other: { role: "reviewer", prompt: "Also", location: null },
_: { role: "writer", prompt: "Begin" },
other: { role: "reviewer", prompt: "Also" },
};
const errors = validateWorkflow(wf);
expect(
@@ -149,7 +149,7 @@ describe("Suite 2: Graph Structure", () => {
test("2.3 $START edge uses non-_ status", () => {
const wf = makeWorkflow();
wf.graph.$START = { ready: { role: "writer", prompt: "Begin", location: null } };
wf.graph.$START = { ready: { role: "writer", prompt: "Begin" } };
const errors = validateWorkflow(wf);
expect(
errors.some((e) => e.includes('$START must have exactly one edge with status "_"')),
@@ -158,7 +158,7 @@ describe("Suite 2: Graph Structure", () => {
test("2.4 $END has outgoing edges", () => {
const wf = makeWorkflow();
wf.graph.$END = { _: { role: "writer", prompt: "Loop", location: null } };
wf.graph.$END = { _: { role: "writer", prompt: "Loop" } };
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("$END must not have outgoing edges"))).toBe(true);
});
@@ -177,7 +177,7 @@ describe("Suite 2: Graph Structure", () => {
required: ["$status"],
} as unknown as string,
};
wf.graph.isolated = { _: { role: "$END", prompt: "done", location: null } };
wf.graph.isolated = { _: { role: "$END", prompt: "done" } };
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes('role "isolated" is not reachable from $START'))).toBe(
true,
@@ -186,7 +186,7 @@ describe("Suite 2: Graph Structure", () => {
test("2.6 edge target references invalid role", () => {
const wf = makeWorkflow();
wf.graph.writer = { _: { role: "ghost", prompt: "Go to ghost", location: null } };
wf.graph.writer = { _: { role: "ghost", prompt: "Go to ghost" } };
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes('unknown target role "ghost"'))).toBe(true);
});
@@ -196,8 +196,8 @@ describe("Suite 3: Status-Edge Consistency", () => {
test("3.1 single-exit role with multiple graph keys", () => {
const wf = makeWorkflow();
wf.graph.writer = {
_: { role: "reviewer", prompt: "Review", location: null },
extra: { role: "$END", prompt: "Done", location: null },
_: { role: "reviewer", prompt: "Review" },
extra: { role: "$END", prompt: "Done" },
};
const errors = validateWorkflow(wf);
expect(
@@ -209,7 +209,7 @@ describe("Suite 3: Status-Edge Consistency", () => {
test("3.2 single-exit role missing _ key", () => {
const wf = makeWorkflow();
wf.graph.writer = { done: { role: "reviewer", prompt: "Review", location: null } };
wf.graph.writer = { done: { role: "reviewer", prompt: "Review" } };
const errors = validateWorkflow(wf);
expect(
errors.some((e) => e.includes('role "writer" is single-exit but graph has no "_" key')),
@@ -219,9 +219,9 @@ describe("Suite 3: Status-Edge Consistency", () => {
test("3.3 multi-exit role with extra statuses", () => {
const wf = makeWorkflow();
wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done", location: null },
rejected: { role: "writer", prompt: "Fix", location: null },
timeout: { role: "$END", prompt: "Timed out", location: null },
approved: { role: "$END", prompt: "Done" },
rejected: { role: "writer", prompt: "Fix" },
timeout: { role: "$END", prompt: "Timed out" },
};
const errors = validateWorkflow(wf);
expect(
@@ -232,7 +232,7 @@ describe("Suite 3: Status-Edge Consistency", () => {
test("3.4 multi-exit role missing a status", () => {
const wf = makeWorkflow();
wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done", location: null },
approved: { role: "$END", prompt: "Done" },
};
const errors = validateWorkflow(wf);
expect(
@@ -242,7 +242,7 @@ describe("Suite 3: Status-Edge Consistency", () => {
test("3.5 multi-exit role with _ key", () => {
const wf = makeWorkflow();
wf.graph.reviewer = { _: { role: "$END", prompt: "Done", location: null } };
wf.graph.reviewer = { _: { role: "$END", prompt: "Done" } };
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes('role "reviewer" is multi-exit but graph uses "_"'))).toBe(
true,
@@ -265,8 +265,8 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
} as unknown as string,
};
wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done", location: null },
rejected: { role: "writer", prompt: "Fix: {{{comments}}}", location: null },
approved: { role: "$END", prompt: "Done" },
rejected: { role: "writer", prompt: "Fix: {{{comments}}}" },
};
const errors = validateWorkflow(wf);
expect(errors).toEqual([]);
@@ -286,9 +286,9 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
} as unknown as string,
};
wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done", location: null },
rejected: { role: "writer", prompt: "Fix", location: null },
timeout: { role: "$END", prompt: "Timed out", location: null },
approved: { role: "$END", prompt: "Done" },
rejected: { role: "writer", prompt: "Fix" },
timeout: { role: "$END", prompt: "Timed out" },
};
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("extra status keys: timeout"))).toBe(true);
@@ -308,7 +308,7 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
} as unknown as string,
};
wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done", location: null },
approved: { role: "$END", prompt: "Done" },
};
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("missing status keys: rejected"))).toBe(true);
@@ -327,7 +327,7 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
required: ["$status", "plan"],
} as unknown as string,
};
wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{plan}}}", location: null } };
wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{plan}}}" } };
const errors = validateWorkflow(wf);
expect(errors).toEqual([]);
});
@@ -346,8 +346,8 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
} as unknown as string,
};
wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done: {{{nonexistent}}}", location: null },
rejected: { role: "writer", prompt: "Fix: {{{comments}}}", location: null },
approved: { role: "$END", prompt: "Done: {{{nonexistent}}}" },
rejected: { role: "writer", prompt: "Fix: {{{comments}}}" },
};
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("nonexistent") && e.includes("not found"))).toBe(true);
@@ -357,7 +357,7 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
describe("Suite 4: Mustache Template Variable Existence", () => {
test("4.1 prompt references nonexistent variable (single-exit)", () => {
const wf = makeWorkflow();
wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{branch}}}", location: null } };
wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{branch}}}" } };
const errors = validateWorkflow(wf);
expect(
errors.some((e) =>
@@ -369,8 +369,8 @@ describe("Suite 4: Mustache Template Variable Existence", () => {
test("4.2 prompt references nonexistent variable (multi-exit)", () => {
const wf = makeWorkflow();
wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done: {{{branch}}}", location: null },
rejected: { role: "writer", prompt: "Fix: {{{reason}}}", location: null },
approved: { role: "$END", prompt: "Done: {{{branch}}}" },
rejected: { role: "writer", prompt: "Fix: {{{reason}}}" },
};
const errors = validateWorkflow(wf);
expect(
@@ -388,7 +388,7 @@ describe("Suite 4: Mustache Template Variable Existence", () => {
test("4.4 $status variable is always valid", () => {
const wf = makeWorkflow();
wf.graph.writer = { _: { role: "reviewer", prompt: "Status: {{$status}}", location: null } };
wf.graph.writer = { _: { role: "reviewer", prompt: "Status: {{$status}}" } };
const errors = validateWorkflow(wf);
expect(errors).toEqual([]);
});
@@ -461,9 +461,9 @@ describe("Suite 6: Multiple Errors Collection", () => {
} as unknown as string,
};
// unknown graph reference
wf.graph.nonexistent = { _: { role: "$END", prompt: "done", location: null } };
wf.graph.nonexistent = { _: { role: "$END", prompt: "done" } };
// bad mustache var
wf.graph.writer = { _: { role: "reviewer", prompt: "{{{badvar}}}", location: null } };
wf.graph.writer = { _: { role: "reviewer", prompt: "{{{badvar}}}" } };
const errors = validateWorkflow(wf);
expect(errors.length).toBeGreaterThanOrEqual(3);
});
@@ -15,8 +15,6 @@ import { loadWorkflowRegistry, saveWorkflowRegistry } from "../store.js";
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = join(storageRoot, "cas");
await mkdir(casDir, { recursive: true });
// Set UNCAGED_CAS_DIR to use the test's CAS directory
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
return { storageRoot, store, schemas };
@@ -43,8 +41,8 @@ function makeMinimalPayload(name: string, description: string): WorkflowPayload
},
},
graph: {
$START: { _: { role: "worker", prompt: "start working", location: null } },
worker: { _: { role: "$END", prompt: "done", location: null } },
$START: { _: { role: "worker", prompt: "start working" } },
worker: { _: { role: "$END", prompt: "done" } },
},
};
}
+47 -27
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import type { CasRef, ThreadId, ThreadStatus } from "@uncaged/workflow-protocol";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { Command } from "commander";
import {
cmdCasGet,
@@ -17,12 +17,16 @@ import { cmdConfigGet, cmdConfigList, cmdConfigSet } from "./commands/config.js"
import { cmdLogClean, cmdLogList, cmdLogShow } from "./commands/log.js";
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
import {
cmdSkillActor,
cmdSkillAdapter,
cmdSkillArchitecture,
cmdSkillAuthor,
cmdSkillBootstrap,
cmdSkillCli,
cmdSkillDeveloper,
cmdSkillList,
cmdSkillModerator,
cmdSkillUser,
cmdSkillYaml,
} from "./commands/skill.js";
import { cmdStepFork, cmdStepList, cmdStepRead, cmdStepShow } from "./commands/step.js";
import {
@@ -34,6 +38,7 @@ import {
cmdThreadStart,
cmdThreadStop,
THREAD_READ_DEFAULT_QUOTA,
type ThreadStatus,
} from "./commands/thread.js";
import { parseTimeInput } from "./commands/thread-time-parser.js";
import { cmdWorkflowAdd, cmdWorkflowList, cmdWorkflowShow } from "./commands/workflow.js";
@@ -113,17 +118,10 @@ thread
.description("Create a thread without executing")
.argument("<workflow>", "Workflow name or hash")
.requiredOption("-p, --prompt <text>", "User prompt")
.option("--cwd <path>", "Working directory for thread execution (default: process.cwd())")
.action((workflow: string, opts: { prompt: string; cwd: string | undefined }) => {
.action((workflow: string, opts: { prompt: string }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadStart(
storageRoot,
workflow,
opts.prompt,
process.cwd(),
opts.cwd ?? process.cwd(),
);
const result = await cmdThreadStart(storageRoot, workflow, opts.prompt, process.cwd());
writeOutput(result);
});
});
@@ -364,8 +362,7 @@ step
.description("Read a step's turns as human-readable markdown")
.argument("<step-hash>", "CAS hash of the StepNode")
.option("--quota <chars>", "Max output characters", "4000")
.option("--prompt", "Show the assembled prompt sent to the agent instead of turns")
.action((stepHash: string, opts: { quota: string; prompt: boolean }) => {
.action((stepHash: string, opts: { quota: string }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const quota = Number.parseInt(opts.quota, 10);
@@ -373,12 +370,7 @@ step
process.stderr.write("invalid --quota: must be a positive integer\n");
process.exit(1);
}
const markdown = await cmdStepRead(
storageRoot,
stepHash as CasRef,
quota,
opts.prompt === true,
);
const markdown = await cmdStepRead(storageRoot, stepHash as CasRef, quota);
process.stdout.write(markdown.endsWith("\n") ? markdown : `${markdown}\n`);
});
});
@@ -495,6 +487,34 @@ For more information, see: uwf help thread list
const skill = program.command("skill").description("Built-in skill references for agents");
skill.addHelpCommand(false);
skill
.command("cli")
.description("Print a markdown reference of all uwf commands")
.action(() => {
console.log(cmdSkillCli());
});
skill
.command("architecture")
.description("Print the architecture reference")
.action(() => {
console.log(cmdSkillArchitecture());
});
skill
.command("yaml")
.description("Print the workflow YAML schema reference")
.action(() => {
console.log(cmdSkillYaml());
});
skill
.command("actor")
.description("Print the actor reference (frontmatter protocol + CAS)")
.action(() => {
console.log(cmdSkillActor());
});
skill
.command("adapter")
.description("Print the adapter reference (building agent adapters)")
@@ -516,6 +536,13 @@ skill
console.log(cmdSkillDeveloper());
});
skill
.command("moderator")
.description("Print the moderator reference")
.action(() => {
console.log(cmdSkillModerator());
});
skill
.command("user")
.description("Print the user reference (CLI guide + typical workflows)")
@@ -523,13 +550,6 @@ skill
console.log(cmdSkillUser());
});
skill
.command("bootstrap")
.description("Print the bootstrap skill YAML for Hermes agents")
.action(() => {
console.log(cmdSkillBootstrap());
});
skill
.command("list")
.description("List all available skill names")
@@ -544,7 +564,7 @@ program
.option("--base-url <url>", "OpenAI-compatible API base URL")
.option("--api-key <key>", "API key")
.option("--model <name>", "Default model name")
.option("--agent <name>", "Default agent adapter (e.g. hermes → uwf-hermes)")
.option("--agent <name>", "Default agent alias")
.action(
(opts: {
provider?: string;
+3 -18
View File
@@ -5,10 +5,7 @@ import { parse, stringify } from "yaml";
/**
* Valid configuration key schema
*/
const VALID_CONFIG_KEYS: Record<
string,
{ nested: boolean; knownFields?: string[]; minDepth?: number }
> = {
const VALID_CONFIG_KEYS: Record<string, { nested: boolean; knownFields?: string[] }> = {
providers: {
nested: true,
knownFields: ["baseUrl", "apiKey"],
@@ -21,17 +18,6 @@ const VALID_CONFIG_KEYS: Record<
nested: true,
knownFields: ["command", "args"],
},
agentOverrides: {
nested: true,
// agentOverrides.<workflowName>.<roleName> = agentAlias (string value)
// No knownFields — workflow/role names are user-defined
},
modelOverrides: {
nested: true,
minDepth: 2,
// modelOverrides.<scenario> = modelAlias (string value)
// No knownFields — scenarios are user-defined
},
defaultAgent: { nested: false },
defaultModel: { nested: false },
};
@@ -57,9 +43,8 @@ function validateConfigKey(path: string[]): void {
throw new Error(`${topLevel} is a scalar key and cannot have nested properties`);
}
// Nested keys must have at least minDepth segments (default 3)
const minDepth = schema.minDepth ?? 3;
if (schema.nested && path.length < minDepth) {
// Nested keys must have at least 3 segments (e.g., providers.myProvider.baseUrl)
if (schema.nested && path.length < 3) {
const fields = schema.knownFields?.join(", ") ?? "";
throw new Error(
`Incomplete path for ${topLevel}. Must specify a field (e.g., ${topLevel}.<name>.<field>). Valid fields: ${fields}`,
+16 -2
View File
@@ -1,12 +1,26 @@
export {
generateActorReference as cmdSkillActor,
generateAdapterReference as cmdSkillAdapter,
generateArchitectureReference as cmdSkillArchitecture,
generateAuthorReference as cmdSkillAuthor,
generateBootstrapReference as cmdSkillBootstrap,
generateCliReference as cmdSkillCli,
generateDeveloperReference as cmdSkillDeveloper,
generateModeratorReference as cmdSkillModerator,
generateUserReference as cmdSkillUser,
generateYamlReference as cmdSkillYaml,
} from "@uncaged/workflow-util";
const SKILL_NAMES = ["user", "author", "developer", "adapter", "bootstrap"] as const;
const SKILL_NAMES = [
"cli",
"architecture",
"yaml",
"moderator",
"actor",
"user",
"author",
"developer",
"adapter",
] as const;
export function cmdSkillList(): ReadonlyArray<string> {
return [...SKILL_NAMES];
@@ -289,7 +289,6 @@ export async function cmdStepRead(
storageRoot: string,
stepHash: CasRef,
quota: number,
showPrompt: boolean,
): Promise<string> {
const uwf = await createUwfStore(storageRoot);
const node = uwf.store.get(stepHash);
@@ -301,23 +300,6 @@ export async function cmdStepRead(
}
const payload = node.payload as StepNodePayload;
// --prompt mode: show the assembled prompt that was sent to the agent
if (showPrompt) {
const promptRef = (payload as Record<string, unknown>).assembledPrompt;
if (typeof promptRef !== "string") {
return `# Step ${stepHash}\n\n_Prompt not recorded (legacy step)._`;
}
const promptNode = uwf.store.get(promptRef as CasRef);
if (promptNode === null) {
return `# Step ${stepHash}\n\n_Prompt CAS node not found: ${promptRef}_`;
}
const promptText =
typeof promptNode.payload === "string"
? promptNode.payload
: JSON.stringify(promptNode.payload);
return `# Step ${stepHash}\n\n**Role:** ${payload.role}\n**Agent:** ${payload.agent}\n\n## Prompt\n\n${promptText}`;
}
if (payload.detail === null) {
return formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
}
+8 -82
View File
@@ -12,7 +12,6 @@ import type {
StepOutput,
ThreadId,
ThreadListItem,
ThreadStatus,
ThreadsIndex,
WorkflowConfig,
WorkflowPayload,
@@ -23,7 +22,6 @@ import {
generateUlid,
type ProcessLogger,
} from "@uncaged/workflow-util";
import type { AdapterOutput } from "@uncaged/workflow-util-agent";
import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-util-agent";
import { config as loadDotenv } from "dotenv";
import { parse } from "yaml";
@@ -57,21 +55,6 @@ const END_ROLE = "$END";
const START_ROLE = "$START";
export const THREAD_READ_DEFAULT_QUOTA = 4000;
/**
* Derive the current/next role from the workflow graph and chain state.
* Returns null when the next role is $END or evaluation fails.
*/
function resolveCurrentRole(uwf: UwfStore, head: CasRef, workflowRef: CasRef): string | null {
const chain = walkChain(uwf, head);
const { lastRole, lastOutput } = resolveEvaluateArgs(uwf, chain);
const workflow = loadWorkflowPayload(uwf, workflowRef);
const result = evaluate(workflow.graph, lastRole, lastOutput);
if (!result.ok) {
return null;
}
return result.value.role === END_ROLE ? null : result.value.role;
}
const PL_THREAD_START = "7HNQ4B2X";
const PL_MODERATOR = "M3K8V9T1";
const PL_AGENT_SPAWN = "R5J2W8N4";
@@ -283,13 +266,7 @@ export async function cmdThreadStart(
workflowId: string,
prompt: string,
projectRoot: string,
cwd: string = process.cwd(),
): Promise<StartOutput> {
// Validate cwd is an absolute path
if (!isAbsolute(cwd)) {
fail("cwd must be an absolute path");
}
const uwf = await createUwfStore(storageRoot);
const workflowHash = await resolveWorkflowCasRef(uwf, storageRoot, workflowId, projectRoot);
@@ -301,7 +278,6 @@ export async function cmdThreadStart(
const startPayload: StartNodePayload = {
workflow: workflowHash,
prompt,
cwd,
};
const headHash = await uwf.store.put(uwf.schemas.startNode, startPayload);
@@ -332,18 +308,10 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
if (workflow === null) {
fail(`failed to resolve workflow from head: ${activeHead}`);
}
// Check if thread is running
const runningMarker = await isThreadRunning(storageRoot, threadId);
const status: ThreadStatus = runningMarker !== null ? "running" : "idle";
const currentRole = resolveCurrentRole(uwf, activeHead, workflow);
return {
workflow,
thread: threadId,
head: activeHead,
status,
currentRole,
done: false,
background: null,
};
@@ -351,14 +319,10 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
const hist = await findThreadInHistory(storageRoot, threadId);
if (hist !== null) {
const status: ThreadStatus = hist.reason === "cancelled" ? "cancelled" : "completed";
return {
workflow: hist.workflow,
thread: threadId,
head: hist.head,
status,
currentRole: null,
done: true,
background: null,
};
@@ -367,9 +331,10 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
fail(`thread not found: ${threadId}`);
}
export type ThreadStatus = "idle" | "running" | "completed" | "cancelled";
export type ThreadListItemWithStatus = ThreadListItem & {
status: ThreadStatus;
currentRole: string | null;
};
async function threadListItemFromActive(
@@ -387,13 +352,7 @@ async function threadListItemFromActive(
const runningMarker = await isThreadRunning(storageRoot, threadId);
const status: ThreadStatus = runningMarker !== null ? "running" : "idle";
return {
thread: threadId,
workflow,
head,
status,
currentRole: resolveCurrentRole(uwf, head, workflow),
};
return { thread: threadId, workflow, head, status };
}
async function collectActiveThreads(
@@ -431,7 +390,6 @@ async function collectCompletedThreads(
workflow: entry.workflow,
head: entry.head,
status: entry.reason === "cancelled" ? "cancelled" : "completed",
currentRole: null,
});
}
}
@@ -814,8 +772,7 @@ function spawnAgent(
threadId: ThreadId,
role: string,
edgePrompt: string,
cwd: string,
): AdapterOutput {
): CasRef {
const argv = [...agent.args, "--thread", threadId, "--role", role, "--prompt", edgePrompt];
let stdout: string;
try {
@@ -823,7 +780,6 @@ function spawnAgent(
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 50 * 1024 * 1024, // 50 MB — stream-json output can be large
cwd,
});
} catch (e) {
const err = e as NodeJS.ErrnoException & { stderr?: Buffer | string | null };
@@ -838,22 +794,10 @@ function spawnAgent(
}
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
failStep(plog, `agent stdout last line is not valid JSON: ${line || "(empty)"}`);
if (!isCasRef(line)) {
failStep(plog, `agent stdout is not a valid CAS hash: ${line || "(empty)"}`);
}
const obj = parsed as Record<string, unknown>;
if (
typeof obj !== "object" ||
obj === null ||
typeof obj.stepHash !== "string" ||
!isCasRef(obj.stepHash as string)
) {
failStep(plog, `agent stdout JSON missing valid stepHash: ${line}`);
}
return obj as unknown as AdapterOutput;
return line;
}
async function archiveThread(
@@ -964,8 +908,6 @@ async function cmdThreadStepBackground(
failStep(plog, `thread not active: ${threadId}`);
}
const uwf = await createUwfStore(storageRoot);
// Spawn detached background process
const scriptPath = process.argv[1];
if (scriptPath === undefined) {
@@ -996,8 +938,6 @@ async function cmdThreadStepBackground(
workflow: workflowHash,
thread: threadId,
head: headHash,
status: "running",
currentRole: resolveCurrentRole(uwf, headHash, workflowHash),
done: false,
background: true,
},
@@ -1040,8 +980,6 @@ async function cmdThreadStepOnce(
workflow: workflowHash,
thread: threadId,
head: headHash,
status: "completed",
currentRole: null,
done: true,
background: null,
};
@@ -1049,11 +987,6 @@ async function cmdThreadStepOnce(
const role = nextResult.value.role;
const edgePrompt = nextResult.value.prompt;
// Resolve cwd: use edge location if provided, otherwise inherit thread.cwd
const threadCwd = chain.start.cwd;
const effectiveCwd = nextResult.value.location !== null ? nextResult.value.location : threadCwd;
const config = await loadWorkflowConfig(storageRoot);
const agent = resolveAgentConfig(config, workflow, role, agentOverride);
@@ -1062,8 +995,7 @@ async function cmdThreadStepOnce(
});
loadDotenv({ path: getEnvPath(storageRoot) });
const agentResult = spawnAgent(plog, agent, threadId, role, edgePrompt, effectiveCwd);
const newHead = agentResult.stepHash as CasRef;
const newHead = spawnAgent(plog, agent, threadId, role, edgePrompt);
plog.log(PL_AGENT_DONE, `agent returned head=${newHead}`, null);
@@ -1095,16 +1027,10 @@ async function cmdThreadStepOnce(
await archiveThread(storageRoot, threadId, workflowHash, newHead);
}
// Determine status based on whether thread is done and running state
const status: ThreadStatus = done ? "completed" : "idle";
const currentRole = done ? null : afterResult.value.role;
return {
workflow: workflowHash,
thread: threadId,
head: newHead,
status,
currentRole,
done,
background: null,
};
@@ -61,7 +61,6 @@ function normalizeGraph(
normalized[status] = {
role: target.role,
prompt: target.prompt,
location: target.location ?? null,
};
}
result[node] = normalized;
@@ -1,198 +0,0 @@
import { describe, expect, test } from "vitest";
import { evaluate } from "../evaluate.js";
describe("Edge prompt template variable resolution", () => {
test("returns error when rendered prompt is empty string", () => {
const graph = {
$START: {
_: { role: "classifier", prompt: "{{{userPrompt}}}", location: null },
},
};
const result = evaluate(graph, "$START", {});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain("prompt");
expect(result.error.message).toContain("empty");
}
});
test("returns error when rendered prompt is whitespace-only", () => {
const graph = {
$START: {
_: { role: "classifier", prompt: " {{{userPrompt}}} ", location: null },
},
};
const result = evaluate(graph, "$START", {});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain("prompt");
expect(result.error.message).toContain("empty");
}
});
test("succeeds when all template variables resolve to non-empty values", () => {
const graph = {
$START: {
_: { role: "classifier", prompt: "{{{userPrompt}}}", location: null },
},
};
const result = evaluate(graph, "$START", { userPrompt: "Fix the bug" });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.prompt).toBe("Fix the bug");
}
});
test("succeeds with static (no-variable) prompt", () => {
const graph = {
$START: {
_: { role: "classifier", prompt: "Classify this input", location: null },
},
};
const result = evaluate(graph, "$START", {});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.prompt).toBe("Classify this input");
}
});
test("succeeds when prompt has mix of static text and unresolved variables", () => {
const graph = {
$START: {
_: { role: "classifier", prompt: "Please handle: {{{userPrompt}}}", location: null },
},
};
const result = evaluate(graph, "$START", {});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.prompt).toBe("Please handle: ");
}
});
test("returns error when ALL variables missing and no static text remains", () => {
const graph = {
$START: {
_: { role: "classifier", prompt: "{{{a}}}{{{b}}}", location: null },
},
};
const result = evaluate(graph, "$START", {});
expect(result.ok).toBe(false);
});
});
describe("Moderator location resolution", () => {
test("returns null location when edge has no location field", () => {
const graph = {
planner: {
ready: {
role: "coder",
prompt: "Implement the code",
location: null,
},
},
};
const result = evaluate(graph, "planner", { $status: "ready" });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.location).toBe(null);
}
});
test("resolves static location string", () => {
const graph = {
planner: {
ready: {
role: "coder",
prompt: "Implement the code",
location: "/static/path",
},
},
};
const result = evaluate(graph, "planner", { $status: "ready" });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.location).toBe("/static/path");
}
});
test("resolves mustache template location", () => {
const graph = {
planner: {
ready: {
role: "coder",
prompt: "Implement the code",
location: "{{{repoPath}}}",
},
},
};
const result = evaluate(graph, "planner", {
$status: "ready",
repoPath: "/home/user/repo",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.location).toBe("/home/user/repo");
}
});
test("resolves mustache template with multiple variables", () => {
const graph = {
planner: {
ready: {
role: "coder",
prompt: "Implement the code",
location: "{{{basePath}}}/{{{projectName}}}",
},
},
};
const result = evaluate(graph, "planner", {
$status: "ready",
basePath: "/home/user",
projectName: "myproject",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.location).toBe("/home/user/myproject");
}
});
test("handles missing template variable gracefully", () => {
const graph = {
planner: {
ready: {
role: "coder",
prompt: "Implement the code",
location: "{{{repoPath}}}",
},
},
};
const result = evaluate(graph, "planner", { $status: "ready" });
expect(result.ok).toBe(true);
if (result.ok) {
// Mustache renders missing variables as empty string
expect(result.value.location).toBe("");
}
});
});
@@ -43,16 +43,7 @@ export function evaluate(
try {
const prompt = mustache.render(target.prompt, lastOutput);
if (prompt.trim() === "") {
return {
ok: false,
error: new Error(
`edge prompt resolved to empty string for role "${target.role}" (template: "${target.prompt}"). Check that upstream output includes required variables.`,
),
};
}
const location = target.location !== null ? mustache.render(target.location, lastOutput) : null;
return { ok: true, value: { role: target.role, prompt, location } };
return { ok: true, value: { role: target.role, prompt } };
} catch (error) {
return {
ok: false,
@@ -4,6 +4,4 @@ export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
export type EvaluateResult = {
role: string;
prompt: string;
/** Resolved working directory from edge location field (null = inherit thread cwd). */
location: string | null;
};
+1 -17
View File
@@ -70,26 +70,10 @@ export function resolveStorageRoot(): string {
return getDefaultStorageRoot();
}
/**
* Deprecated: Use `getGlobalCasDir()` instead.
* Returns the old CAS directory for backward compatibility.
*/
export function getCasDir(storageRoot: string): string {
return join(storageRoot, "cas");
}
/**
* Returns the global CAS directory shared by all uwf and json-cas tools.
* Priority: UNCAGED_CAS_DIR environment variable default ~/.uncaged/json-cas
*/
export function getGlobalCasDir(): string {
const envPath = process.env.UNCAGED_CAS_DIR;
if (envPath !== undefined && envPath !== "") {
return envPath;
}
return join(homedir(), ".uncaged", "json-cas");
}
export function getRegistryPath(storageRoot: string): string {
return join(storageRoot, "workflows.yaml");
}
@@ -114,7 +98,7 @@ export type UwfStore = {
};
export async function createUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = getGlobalCasDir();
const casDir = getCasDir(storageRoot);
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
+2 -24
View File
@@ -36,13 +36,8 @@ function isTarget(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
const hasValidLocation =
value.location === undefined || value.location === null || typeof value.location === "string";
return (
typeof value.role === "string" &&
typeof value.prompt === "string" &&
value.prompt.trim() !== "" &&
hasValidLocation
typeof value.role === "string" && typeof value.prompt === "string" && value.prompt.trim() !== ""
);
}
@@ -100,22 +95,5 @@ export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null {
if (!isStringRecord(raw.roles, isRoleDefinition) || !isGraph(raw.graph)) {
return null;
}
// Normalize location field: undefined → null
const normalized = { ...raw } as WorkflowPayload;
for (const roleName of Object.keys(normalized.graph)) {
const statusMap = normalized.graph[roleName];
if (statusMap !== undefined) {
for (const status of Object.keys(statusMap)) {
const target = statusMap[status];
if (target !== undefined) {
if (target.location === undefined) {
target.location = null;
}
}
}
}
}
return normalized;
return raw as WorkflowPayload;
}
-1
View File
@@ -3,6 +3,5 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
@@ -0,0 +1,9 @@
# @uncaged/workflow-agent-builtin
## 0.5.1
### Patch Changes
- Updated dependencies
- @uncaged/workflow-util@0.5.1
- @uncaged/workflow-util-agent@0.5.1
+6 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-agent-builtin",
"version": "0.5.0",
"version": "0.5.1",
"files": [
"src",
"dist",
@@ -18,9 +18,8 @@
}
},
"scripts": {
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
"test": "vitest run",
"test:ci": "vitest run"
"test": "bun test",
"test:ci": "bun test"
},
"dependencies": {
"@uncaged/json-cas": "^0.5.3",
@@ -35,12 +34,12 @@
},
"repository": {
"type": "git",
"url": "https://git.shazhou.work/uncaged/workflow.git",
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
"directory": "packages/workflow-agent-builtin"
},
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
"bugs": {
"url": "https://git.shazhou.work/uncaged/workflow/issues"
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
},
"license": "MIT"
}
+2 -7
View File
@@ -82,7 +82,7 @@ async function runBuiltinWithMessages(
if (loopResult.turnCount === 0) {
log("5RWTK9NB", "no turns produced, returning empty output");
return { output: "", detailHash: "", sessionId: session.sessionId, assembledPrompt: "" };
return { output: "", detailHash: "", sessionId: session.sessionId };
}
// Read jsonl → persist turns to CAS → store detail
@@ -94,12 +94,7 @@ async function runBuiltinWithMessages(
session.startedAtMs,
);
return {
output: stripPreamble(loopResult.finalText),
detailHash,
sessionId: session.sessionId,
assembledPrompt: "",
};
return { output: stripPreamble(loopResult.finalText), detailHash, sessionId: session.sessionId };
}
async function runBuiltin(ctx: AgentContext): Promise<AgentRunResult> {
@@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
@@ -0,0 +1,9 @@
# @uncaged/workflow-agent-claude-code
## 0.5.1
### Patch Changes
- Updated dependencies
- @uncaged/workflow-util@0.5.1
- @uncaged/workflow-util-agent@0.5.1
@@ -301,179 +301,6 @@ describe("storeClaudeCodeDetail", () => {
});
});
describe("parseClaudeCodeStreamOutput — incomplete output (no result line)", () => {
test("Test 1.1: parses stream with turns but no result line", () => {
const lines = [
JSON.stringify({
type: "system",
subtype: "init",
session_id: "sess-incomplete-1",
model: "claude-sonnet-4.5",
}),
JSON.stringify({
type: "assistant",
message: {
role: "assistant",
content: [{ type: "text", text: "Starting work..." }],
},
}),
JSON.stringify({
type: "assistant",
message: {
role: "assistant",
content: [{ type: "text", text: "This is the last assistant message." }],
},
}),
];
const stdout = lines.join("\n");
const parsed = parseClaudeCodeStreamOutput(stdout);
expect(parsed).not.toBeNull();
expect(parsed!.subtype).toBe("incomplete");
expect(parsed!.result).toBe("This is the last assistant message.");
expect(parsed!.sessionId).toBe("sess-incomplete-1");
expect(parsed!.model).toBe("claude-sonnet-4.5");
expect(parsed!.turns).toHaveLength(2);
expect(parsed!.stopReason).toBe("incomplete_no_result_line");
expect(parsed!.numTurns).toBe(2);
expect(parsed!.durationMs).toBe(0);
expect(parsed!.totalCostUsd).toBe(0);
});
test("Test 1.2: parses stream with no turns and no result line", () => {
const lines = [
JSON.stringify({
type: "system",
session_id: "sess-no-turns",
model: "claude-opus-4",
}),
];
const stdout = lines.join("\n");
const parsed = parseClaudeCodeStreamOutput(stdout);
expect(parsed).not.toBeNull();
expect(parsed!.subtype).toBe("incomplete");
expect(parsed!.result).toBe("");
expect(parsed!.sessionId).toBe("sess-no-turns");
expect(parsed!.model).toBe("claude-opus-4");
expect(parsed!.turns).toHaveLength(0);
expect(parsed!.stopReason).toBe("incomplete_no_result_line");
});
test("Test 1.3: returns null for completely empty output", () => {
const parsed1 = parseClaudeCodeStreamOutput("");
expect(parsed1).toBeNull();
const parsed2 = parseClaudeCodeStreamOutput(" \n \n ");
expect(parsed2).toBeNull();
});
test("Test 1.4: returns null for malformed JSON lines only", () => {
const stdout = "not json\n{broken json\n[invalid";
const parsed = parseClaudeCodeStreamOutput(stdout);
expect(parsed).toBeNull();
});
test("Test 6.1: extracts from last assistant text-only turn", () => {
const lines = [
JSON.stringify({ type: "system", session_id: "s1", model: "test" }),
JSON.stringify({
type: "assistant",
message: { role: "assistant", content: [{ type: "text", text: "First message" }] },
}),
JSON.stringify({
type: "assistant",
message: { role: "assistant", content: [{ type: "text", text: "Last message" }] },
}),
];
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
expect(parsed).not.toBeNull();
expect(parsed!.result).toBe("Last message");
});
test("Test 6.2: extracts from last assistant turn with tool calls", () => {
const lines = [
JSON.stringify({ type: "system", session_id: "s1", model: "test" }),
JSON.stringify({
type: "assistant",
message: {
role: "assistant",
content: [
{ type: "text", text: "Text with tools" },
{ type: "tool_use", name: "Bash", input: { command: "ls" } },
],
},
}),
];
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
expect(parsed).not.toBeNull();
expect(parsed!.result).toBe("Text with tools");
});
test("Test 6.3: returns empty string when no assistant turns", () => {
const lines = [JSON.stringify({ type: "system", session_id: "s1", model: "test" })];
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
expect(parsed).not.toBeNull();
expect(parsed!.result).toBe("");
});
test("Test 6.4: extracts from most recent assistant turn before tool_result", () => {
const lines = [
JSON.stringify({ type: "system", session_id: "s1", model: "test" }),
JSON.stringify({
type: "assistant",
message: { role: "assistant", content: [{ type: "text", text: "Before tool call" }] },
}),
JSON.stringify({
type: "user",
message: { role: "user", content: [{ type: "tool_result", content: "tool output" }] },
}),
];
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
expect(parsed).not.toBeNull();
expect(parsed!.result).toBe("Before tool call");
});
});
describe("storeClaudeCodeDetail — incomplete results", () => {
test("Test 4.1: stores incomplete result as detail", async () => {
const store = createMemoryStore();
const incompleteParsed: ClaudeCodeParsedResult = {
type: "result",
subtype: "incomplete",
result: "Partial output",
sessionId: "sess-incomplete",
numTurns: 2,
totalCostUsd: 0,
durationMs: 0,
model: "claude-sonnet-4.5",
stopReason: "incomplete_no_result_line",
usage: {
inputTokens: 0,
outputTokens: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
turns: [
{ index: 0, role: "assistant", content: "Turn 1", toolCalls: null },
{ index: 1, role: "assistant", content: "Partial output", toolCalls: null },
],
};
const { detailHash, output, sessionId } = await storeClaudeCodeDetail(store, incompleteParsed);
expect(detailHash).toHaveLength(13);
expect(output).toBe("Partial output");
expect(sessionId).toBe("sess-incomplete");
const node = await store.get(detailHash);
expect(node).not.toBeNull();
expect(node!.payload.subtype).toBe("incomplete");
expect(node!.payload.stopReason).toBe("incomplete_no_result_line");
expect(node!.payload.turns).toHaveLength(2);
});
});
describe("storeClaudeCodeRawOutput", () => {
test("stores raw text when JSON parsing fails", async () => {
const store = createMemoryStore();
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-agent-claude-code",
"version": "0.1.0",
"version": "0.5.1",
"files": [
"src",
"dist",
@@ -18,9 +18,8 @@
}
},
"scripts": {
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
"test": "vitest run",
"test:ci": "vitest run"
"test": "bun test",
"test:ci": "bun test"
},
"dependencies": {
"@uncaged/json-cas": "^0.5.3",
@@ -35,12 +34,12 @@
},
"repository": {
"type": "git",
"url": "https://git.shazhou.work/uncaged/workflow.git",
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
"directory": "packages/workflow-agent-claude-code"
},
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
"bugs": {
"url": "https://git.shazhou.work/uncaged/workflow/issues"
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
},
"license": "MIT"
}
@@ -48,9 +48,7 @@ export function buildClaudeCodePrompt(ctx: AgentContext): string {
return parts.join("\n");
}
function spawnClaude(
args: string[],
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
function spawnClaude(args: string[]): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawn(CLAUDE_COMMAND, args, {
env: process.env,
@@ -74,7 +72,7 @@ function spawnClaude(
child.on("close", (code) => {
if (code === 0) {
resolve({ stdout, stderr, exitCode: code });
resolve({ stdout, stderr });
return;
}
const detail = stderr.trim() !== "" ? ` stderr=${stderr.trim()}` : "";
@@ -83,9 +81,7 @@ function spawnClaude(
});
}
function spawnClaudeRun(
prompt: string,
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
function spawnClaudeRun(prompt: string): Promise<{ stdout: string; stderr: string }> {
const args = [
"-p",
prompt,
@@ -105,7 +101,7 @@ function spawnClaudeRun(
function spawnClaudeResume(
sessionId: string,
message: string,
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
): Promise<{ stdout: string; stderr: string }> {
const args = [
"-p",
message,
@@ -124,36 +120,16 @@ function spawnClaudeResume(
return spawnClaude(args);
}
async function processClaudeOutput(
stdout: string,
stderr: string,
exitCode: number | null,
store: Store,
assembledPrompt: string,
): Promise<AgentRunResult> {
async function processClaudeOutput(stdout: string, store: Store): Promise<AgentRunResult> {
const parsed = parseClaudeCodeStreamOutput(stdout);
if (parsed !== null) {
const { detailHash, output, sessionId } = await storeClaudeCodeDetail(store, parsed);
// Log incomplete results for visibility
if (parsed.subtype === "incomplete") {
log(
"7NQW8R4P",
`Claude Code exited with incomplete output (no result line). Exit code: ${exitCode ?? "null"}, stderr: ${stderr.slice(0, 200)}`,
);
}
return { output, detailHash, sessionId, assembledPrompt };
return { output, detailHash, sessionId };
}
// Truly unparseable output - provide enhanced error message
const exitInfo = exitCode !== null && exitCode !== 0 ? `Exit code: ${exitCode}\n` : "";
const stderrInfo = stderr.trim() !== "" ? `Stderr: ${stderr.slice(0, 200)}\n` : "";
const stdoutSnippet = stdout.slice(0, 200);
throw new Error(
`Claude Code exited without producing parseable output.\n${exitInfo}${stderrInfo}Stdout (first 200 chars): ${stdoutSnippet}`,
`Claude Code returned unparseable output (first 200 chars): ${stdout.slice(0, 200)}`,
);
}
@@ -167,8 +143,8 @@ async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
const cachedSessionId = await getCachedSessionId("claude-code", ctx.threadId, ctx.role);
if (cachedSessionId !== null) {
try {
const { stdout, stderr, exitCode } = await spawnClaudeResume(cachedSessionId, fullPrompt);
const result = await processClaudeOutput(stdout, stderr, exitCode, ctx.store, fullPrompt);
const { stdout } = await spawnClaudeResume(cachedSessionId, fullPrompt);
const result = await processClaudeOutput(stdout, ctx.store);
if (result.sessionId !== undefined && result.sessionId !== "") {
await setCachedSessionId("claude-code", ctx.threadId, ctx.role, result.sessionId);
}
@@ -176,14 +152,16 @@ async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
} catch (err) {
log(
"5VKR8N3Q",
`resume failed for session ${cachedSessionId}, falling back to fresh run: ${err}`,
"resume failed for session %s, falling back to fresh run: %s",
cachedSessionId,
err,
);
}
}
}
const { stdout, stderr, exitCode } = await spawnClaudeRun(fullPrompt);
const result = await processClaudeOutput(stdout, stderr, exitCode, ctx.store, fullPrompt);
const { stdout } = await spawnClaudeRun(fullPrompt);
const result = await processClaudeOutput(stdout, ctx.store);
if (result.sessionId !== undefined && result.sessionId !== "") {
await setCachedSessionId("claude-code", ctx.threadId, ctx.role, result.sessionId);
}
@@ -195,8 +173,8 @@ async function continueClaudeCode(
message: string,
store: Store,
): Promise<AgentRunResult> {
const { stdout, stderr, exitCode } = await spawnClaudeResume(sessionId, message);
return processClaudeOutput(stdout, stderr, exitCode, store, "");
const { stdout } = await spawnClaudeResume(sessionId, message);
return processClaudeOutput(stdout, store);
}
/** Agent CLI factory: parses argv, runs Claude Code, extracts output, writes StepNode. */
@@ -71,7 +71,6 @@ type ParseState = {
turns: ClaudeCodeTurnPayload[];
resultLine: Record<string, unknown> | null;
model: string;
sessionId: string;
turnIndex: number;
};
@@ -79,9 +78,6 @@ function processSystemLine(parsed: Record<string, unknown>, state: ParseState):
if (typeof parsed.model === "string") {
state.model = parsed.model;
}
if (typeof parsed.session_id === "string") {
state.sessionId = parsed.session_id;
}
}
function processAssistantLine(parsed: Record<string, unknown>, state: ParseState): void {
@@ -128,52 +124,8 @@ function processLine(line: string, state: ParseState): void {
else if (type === "result") state.resultLine = parsed;
}
/**
* Extract output text from the last assistant turn.
* Used for best-effort extraction when no result line is present.
*/
function extractLastAssistantContent(turns: ClaudeCodeTurnPayload[]): string {
for (let i = turns.length - 1; i >= 0; i--) {
const turn = turns[i];
if (turn !== undefined && turn.role === "assistant" && turn.content !== "") {
return turn.content;
}
}
return "";
}
function assembleResult(state: ParseState): ClaudeCodeParsedResult | null {
// Handle incomplete result (no result line)
if (state.resultLine === null) {
// Need at least a session_id from system line to be parseable
if (state.sessionId === "") {
return null;
}
// Best-effort extraction: get output from last assistant turn
const result = extractLastAssistantContent(state.turns);
return {
type: "result",
subtype: "incomplete",
result,
sessionId: state.sessionId,
numTurns: state.turns.length,
totalCostUsd: 0,
durationMs: 0,
model: state.model,
stopReason: "incomplete_no_result_line",
usage: {
inputTokens: 0,
outputTokens: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
turns: state.turns,
};
}
// Handle complete result (has result line)
if (state.resultLine === null) return null;
const sessionId = state.resultLine.session_id;
const result = state.resultLine.result;
const subtype = state.resultLine.subtype;
@@ -207,13 +159,7 @@ function assembleResult(state: ParseState): ClaudeCodeParsedResult | null {
*/
export function parseClaudeCodeStreamOutput(stdout: string): ClaudeCodeParsedResult | null {
const lines = stdout.trim().split("\n");
const state: ParseState = {
turns: [],
resultLine: null,
model: "",
sessionId: "",
turnIndex: 0,
};
const state: ParseState = { turns: [], resultLine: null, model: "", turnIndex: 0 };
for (const line of lines) {
processLine(line, state);
}
@@ -1,4 +1,4 @@
export type ClaudeCodeResultSubtype = "success" | "error_max_turns" | "error_budget" | "incomplete";
export type ClaudeCodeResultSubtype = "success" | "error_max_turns" | "error_budget";
/** A single tool call within an assistant turn. */
export type ClaudeCodeToolCall = {
@@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
@@ -0,0 +1,10 @@
# @uncaged/workflow-agent-hermes
## 0.5.1
### Patch Changes
- Updated dependencies
- @uncaged/workflow-util@0.5.1
- @uncaged/workflow-protocol@0.5.1
- @uncaged/workflow-util-agent@0.5.1
+2 -4
View File
@@ -1,12 +1,10 @@
# @uncaged/workflow-agent-hermes
`uwf-hermes` — an **agent adapter** that bridges the `uwf` workflow engine and the Hermes CLI.
`uwf-hermes` agent — spawns Hermes chat via ACP and captures session detail.
## Overview
`uwf-hermes` is an adapter (not the Hermes CLI itself). The `uwf` engine speaks a generic agent protocol (stdin/stdout frontmatter contract); `uwf-hermes` translates that protocol into Hermes ACP (Agent Client Protocol) calls. Other adapters (e.g. `uwf-claude-code`, `uwf-cursor`) do the same for their respective CLIs.
On first visit to a role it sends a composed prompt (role definition, task, history, edge prompt); on continuation it resumes the cached session. Session transcripts and raw output are stored as CAS detail nodes.
Layer 3 agent implementation. Wraps the Hermes CLI using the Agent Client Protocol (ACP). On first visit to a role it sends a composed prompt (role definition, task, history, edge prompt); on continuation it resumes the cached session. Session transcripts and raw output are stored as CAS detail nodes.
**Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-util-agent`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`
@@ -1,28 +0,0 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const PKG_ROOT = join(import.meta.dir, "..");
describe("Issue #551 — bin entry & engines", () => {
test("package.json declares bun in engines", () => {
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf-8"));
expect(pkg.engines).toBeDefined();
expect(pkg.engines.bun).toBeDefined();
expect(pkg.engines.bun).toMatch(/^>=?\s*[\d.]+/);
});
test("bin entry file has bun shebang", () => {
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf-8"));
const binPath = pkg.bin["uwf-hermes"];
const content = readFileSync(join(PKG_ROOT, binPath), "utf-8");
expect(content.startsWith("#!/usr/bin/env bun")).toBe(true);
});
test("README.md explains uwf-hermes is an adapter", () => {
const readme = readFileSync(join(PKG_ROOT, "README.md"), "utf-8");
expect(readme.toLowerCase()).toContain("adapter");
expect(readme).toMatch(/uwf-hermes/);
expect(readme).toMatch(/hermes/);
});
});
+6 -10
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-agent-hermes",
"version": "0.5.0",
"version": "0.5.1",
"files": [
"src",
"dist",
@@ -18,9 +18,8 @@
}
},
"scripts": {
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
"test": "vitest run",
"test:ci": "vitest run"
"test": "bun test",
"test:ci": "bun test __tests__/*.test.ts"
},
"dependencies": {
"@uncaged/json-cas": "^0.5.3",
@@ -36,15 +35,12 @@
},
"repository": {
"type": "git",
"url": "https://git.shazhou.work/uncaged/workflow.git",
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
"directory": "packages/workflow-agent-hermes"
},
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
"bugs": {
"url": "https://git.shazhou.work/uncaged/workflow/issues"
},
"engines": {
"bun": ">= 1.0.0"
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
},
"license": "MIT"
}
+2 -2
View File
@@ -117,7 +117,7 @@ export function createHermesAgent(): () => Promise<void> {
await setCachedSessionId(ctx.threadId, ctx.role, sessionId);
}
return { output: text, detailHash, sessionId, assembledPrompt: fullPrompt };
return { output: text, detailHash, sessionId };
}
async function runHermes(ctx: AgentContext): Promise<AgentRunResult> {
@@ -148,7 +148,7 @@ export function createHermesAgent(): () => Promise<void> {
// so the agent sees the full conversation history (crucial for retries).
const { text, sessionId } = await client.prompt(message);
const { detailHash } = await storePromptResult(store, sessionId);
return { output: text, detailHash, sessionId, assembledPrompt: "" };
return { output: text, detailHash, sessionId };
}
const agentMain = createAgent({
@@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
+1 -3
View File
@@ -5,9 +5,7 @@
"type": "module",
"scripts": {
"dev": "bun server.ts",
"build": "vite build",
"test": "vitest run",
"test:ci": "vitest run"
"build": "vite build"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
@@ -14,7 +14,7 @@ export const editNodeViewModel = define.view("editNodeView", editNodeView, (set,
function start(nodeId: string) {
const [nodes] = model.use(nodesModel);
const node = nodes.find((n) => n.id === nodeId);
if (node?.type !== "role") return;
if (!node || node.type !== "role") return;
set({ node: node as WorkNode<"role"> });
}
@@ -40,7 +40,7 @@ function traverse(
visited.add(nodeId);
const node = nodeMap.get(nodeId);
if (node?.type !== "role") return;
if (!node || node.type !== "role") return;
const roleNode = node as WorkNode<"role">;
const outEdges = outgoingEdges.get(nodeId) ?? [];
+3
View File
@@ -0,0 +1,3 @@
# @uncaged/workflow-protocol
## 0.5.1
+4 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-protocol",
"version": "0.5.0",
"version": "0.5.1",
"files": [
"src",
"dist",
@@ -14,11 +14,6 @@
"import": "./dist/index.js"
}
},
"scripts": {
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
"test": "vitest run",
"test:ci": "vitest run"
},
"dependencies": {
"@uncaged/json-cas": "^0.5.3",
"@uncaged/json-cas-fs": "^0.5.3"
@@ -31,12 +26,12 @@
},
"repository": {
"type": "git",
"url": "https://git.shazhou.work/uncaged/workflow.git",
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
"directory": "packages/workflow-protocol"
},
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
"bugs": {
"url": "https://git.shazhou.work/uncaged/workflow/issues"
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
},
"license": "MIT"
}
@@ -1,69 +0,0 @@
import { describe, expect, test } from "vitest";
import type { StartNodePayload, StepRecord, Target } from "../types.js";
describe("Protocol types for thread/edge location", () => {
describe("StartNodePayload", () => {
test("has required cwd field", () => {
const payload: StartNodePayload = {
workflow: "0123456789ABC",
prompt: "Test prompt",
cwd: "/home/user/project",
};
expect(payload.cwd).toBe("/home/user/project");
expect(typeof payload.cwd).toBe("string");
});
});
describe("StepRecord", () => {
test("has required cwd field", () => {
const record: StepRecord = {
role: "planner",
output: "0123456789ABC",
detail: "DEF0123456789",
agent: "uwf-hermes",
edgePrompt: "Plan the implementation",
startedAtMs: Date.now(),
completedAtMs: Date.now() + 1000,
assembledPrompt: null,
cwd: "/home/user/project",
};
expect(record.cwd).toBe("/home/user/project");
expect(typeof record.cwd).toBe("string");
});
});
describe("Target", () => {
test("has location field that accepts string", () => {
const target: Target = {
role: "coder",
prompt: "Implement the code",
location: "/custom/path",
};
expect(target.location).toBe("/custom/path");
expect(typeof target.location).toBe("string");
});
test("has location field that accepts null", () => {
const target: Target = {
role: "coder",
prompt: "Implement the code",
location: null,
};
expect(target.location).toBe(null);
});
test("location supports mustache template syntax", () => {
const target: Target = {
role: "coder",
prompt: "Implement the code",
location: "{{{repoPath}}}",
};
expect(target.location).toBe("{{{repoPath}}}");
});
});
});
-1
View File
@@ -29,7 +29,6 @@ export type {
ThreadForkOutput,
ThreadId,
ThreadListItem,
ThreadStatus,
ThreadStepsOutput,
ThreadsIndex,
WorkflowConfig,
+2 -20
View File
@@ -20,9 +20,6 @@ const TARGET: JSONSchema = {
properties: {
role: { type: "string" },
prompt: { type: "string" },
location: {
anyOf: [{ type: "string" }, { type: "null" }],
},
},
additionalProperties: false,
};
@@ -52,11 +49,10 @@ export const WORKFLOW_SCHEMA: JSONSchema = {
export const START_NODE_SCHEMA: JSONSchema = {
title: "StartNode",
type: "object",
required: ["workflow", "prompt", "cwd"],
required: ["workflow", "prompt"],
properties: {
workflow: { type: "string", format: "cas_ref" },
prompt: { type: "string" },
cwd: { type: "string" },
},
additionalProperties: false,
};
@@ -64,17 +60,7 @@ export const START_NODE_SCHEMA: JSONSchema = {
export const STEP_NODE_SCHEMA: JSONSchema = {
title: "StepNode",
type: "object",
required: [
"start",
"prev",
"role",
"output",
"detail",
"agent",
"startedAtMs",
"completedAtMs",
"cwd",
],
required: ["start", "prev", "role", "output", "detail", "agent", "startedAtMs", "completedAtMs"],
properties: {
start: { type: "string", format: "cas_ref" },
prev: {
@@ -87,10 +73,6 @@ export const STEP_NODE_SCHEMA: JSONSchema = {
edgePrompt: { type: "string" },
startedAtMs: { type: "integer" },
completedAtMs: { type: "integer" },
cwd: { type: "string" },
assembledPrompt: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
},
additionalProperties: false,
};
+1 -21
View File
@@ -18,10 +18,6 @@ export type StepRecord = {
startedAtMs: number;
/** Date.now() after agent returns */
completedAtMs: number;
/** Working directory where the agent executed. Missing in legacy nodes → "". */
cwd: string;
/** CAS ref to the fully assembled prompt sent to the agent. null for legacy steps. */
assembledPrompt: CasRef | null;
};
// ── 4.2 Workflow 定义 ───────────────────────────────────────────────
@@ -38,8 +34,6 @@ export type RoleDefinition = {
export type Target = {
role: string;
prompt: string;
/** Optional working directory override via mustache template. */
location: string | null;
};
export type WorkflowPayload = {
@@ -54,8 +48,6 @@ export type WorkflowPayload = {
export type StartNodePayload = {
workflow: CasRef;
prompt: string;
/** Working directory where the thread was created. */
cwd: string;
};
export type StepNodePayload = StepRecord & {
@@ -78,29 +70,17 @@ export type ModeratorContext = {
// ── 4.5 CLI 输出 ────────────────────────────────────────────────────
/** Thread status — unified status representation */
export type ThreadStatus = "idle" | "running" | "completed" | "cancelled";
/** uwf thread start */
export type StartOutput = {
workflow: CasRef;
thread: ThreadId;
};
/**
* Output from thread show and thread exec commands.
*
* @property status - Current thread status (idle/running/completed/cancelled)
* @property done - @deprecated Use status field instead. True if thread is completed or cancelled.
* @property background - @deprecated Use status field instead. Always null in current implementation.
*/
/** uwf thread step / uwf thread show */
export type StepOutput = {
workflow: CasRef;
thread: ThreadId;
head: CasRef;
status: ThreadStatus;
/** The current or next role. Null when completed, cancelled, or next is $END. */
currentRole: string | null;
done: boolean;
background: boolean | null;
};
@@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
@@ -0,0 +1,9 @@
# @uncaged/workflow-util-agent
## 0.5.1
### Patch Changes
- Updated dependencies
- @uncaged/workflow-util@0.5.1
- @uncaged/workflow-protocol@0.5.1
@@ -1,73 +0,0 @@
import { createMemoryStore, putSchema } from "@uncaged/json-cas";
import { describe, expect, test } from "vitest";
import { tryFrontmatterFastPath } from "../src/frontmatter.js";
// ── Helpers ───────────────────────────────────────────────────────────────────
const PLANNER_SCHEMA = {
type: "object",
properties: {
$status: { type: "string", enum: ["ready", "failed"] },
plan: { type: "string" },
},
required: ["$status"],
additionalProperties: false,
};
describe("adapter-stdout: A4 retry loop survives JSON output", () => {
test("A4. first extraction fails, second succeeds — final result has correct data", async () => {
const store = createMemoryStore();
const schemaHash = await putSchema(store, PLANNER_SCHEMA);
// Simulate the retry loop from createAgent (run.ts lines 163-173):
// First attempt: agent outputs garbage (no frontmatter)
const badOutput = "Here is my response without frontmatter.\nJust plain text.";
const firstAttempt = await tryFrontmatterFastPath(badOutput, schemaHash, store);
expect(firstAttempt).toBeNull();
// Second attempt (after correction message): agent outputs valid frontmatter
const goodOutput = `---\n$status: ready\nplan: corrected-hash\n---\nCorrected body with valid frontmatter.`;
const secondAttempt = await tryFrontmatterFastPath(goodOutput, schemaHash, store);
expect(secondAttempt).not.toBeNull();
expect(secondAttempt!.outputHash).toMatch(/^[0-9A-Z]{13}$/);
expect(secondAttempt!.frontmatter).toEqual({ $status: "ready", plan: "corrected-hash" });
expect(secondAttempt!.body).toBe("Corrected body with valid frontmatter.");
// Verify the final AdapterOutput shape would be correct
const adapterOutput = {
stepHash: "MOCK_STEP_HASH",
detailHash: "MOCK_DETAIL_HA",
role: "planner",
frontmatter: secondAttempt!.frontmatter,
body: secondAttempt!.body,
startedAtMs: 1000,
completedAtMs: 2000,
assembledPrompt: null,
};
const json = JSON.stringify(adapterOutput);
const parsed = JSON.parse(json);
expect(parsed.frontmatter).toEqual({ $status: "ready", plan: "corrected-hash" });
expect(parsed.body).toBe("Corrected body with valid frontmatter.");
expect(parsed.completedAtMs).toBeGreaterThanOrEqual(parsed.startedAtMs);
});
test("A4. all retries fail — extraction returns null on every attempt", async () => {
const store = createMemoryStore();
const schemaHash = await putSchema(store, PLANNER_SCHEMA);
const MAX_RETRIES = 2;
const badOutput = "No frontmatter here";
// Simulate MAX_FRONTMATTER_RETRIES iterations all failing
let extracted = await tryFrontmatterFastPath(badOutput, schemaHash, store);
for (let retry = 0; retry < MAX_RETRIES && extracted === null; retry++) {
// Each retry also gets bad output
extracted = await tryFrontmatterFastPath(badOutput, schemaHash, store);
}
expect(extracted).toBeNull();
});
});
@@ -1,105 +0,0 @@
import { createMemoryStore, putSchema } from "@uncaged/json-cas";
import { describe, expect, test } from "vitest";
import { tryFrontmatterFastPath } from "../src/frontmatter.js";
// ── Helpers ───────────────────────────────────────────────────────────────────
const PLANNER_SCHEMA = {
type: "object",
properties: {
$status: { type: "string", enum: ["ready", "failed"] },
plan: { type: "string" },
},
required: ["$status"],
additionalProperties: false,
};
const FRONTMATTER_SCHEMA = {
type: "object",
properties: {
status: { anyOf: [{ type: "string" }, { type: "null" }] },
next: { anyOf: [{ type: "string" }, { type: "null" }] },
confidence: { anyOf: [{ type: "number" }, { type: "null" }] },
artifacts: { type: "array", items: { type: "string" } },
scope: { type: "string" },
},
required: ["status", "next", "confidence", "artifacts", "scope"],
additionalProperties: false,
};
describe("adapter-stdout: FrontmatterFastPathResult includes frontmatter", () => {
test("A2. frontmatter field contains the parsed YAML frontmatter object", async () => {
const store = createMemoryStore();
const schemaHash = await putSchema(store, PLANNER_SCHEMA);
const raw = `---\n$status: ready\nplan: abc123\n---\nSome body text`;
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).not.toBeNull();
expect(result!.frontmatter).toEqual({ $status: "ready", plan: "abc123" });
});
test("A3. body field contains the markdown body after frontmatter", async () => {
const store = createMemoryStore();
const schemaHash = await putSchema(store, PLANNER_SCHEMA);
const raw = `---\n$status: ready\nplan: hash123\n---\nHere is the body.\n\nWith multiple paragraphs.`;
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).not.toBeNull();
expect(result!.body).toBe("Here is the body.\n\nWith multiple paragraphs.");
});
test("A1. result contains outputHash as valid CasRef", async () => {
const store = createMemoryStore();
const schemaHash = await putSchema(store, FRONTMATTER_SCHEMA);
const raw = `---\nstatus: done\nnext: null\nconfidence: 0.9\nartifacts: []\nscope: test\n---\nBody`;
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).not.toBeNull();
expect(result!.outputHash).toMatch(/^[0-9A-Z]{13}$/);
expect(result!.frontmatter).toBeDefined();
expect(result!.body).toBe("Body");
});
});
describe("adapter-stdout: AdapterOutput JSON shape", () => {
test("A5. JSON.stringify produces valid parseable JSON with all fields", () => {
const output = {
stepHash: "0123456789ABC",
detailHash: "DEFGH12345678",
role: "planner",
frontmatter: { $status: "ready", plan: "somehash" },
body: "Plan body text",
startedAtMs: 1000,
completedAtMs: 2000,
};
const json = JSON.stringify(output);
const parsed = JSON.parse(json);
expect(parsed.stepHash).toBe("0123456789ABC");
expect(parsed.detailHash).toBe("DEFGH12345678");
expect(parsed.role).toBe("planner");
expect(parsed.frontmatter).toEqual({ $status: "ready", plan: "somehash" });
expect(parsed.body).toBe("Plan body text");
expect(parsed.startedAtMs).toBe(1000);
expect(parsed.completedAtMs).toBe(2000);
});
test("completedAtMs >= startedAtMs", () => {
const output = {
stepHash: "0123456789ABC",
detailHash: "DEFGH12345678",
role: "planner",
frontmatter: {},
body: "",
startedAtMs: 1000,
completedAtMs: 2000,
};
expect(output.completedAtMs).toBeGreaterThanOrEqual(output.startedAtMs);
});
});
@@ -5,13 +5,17 @@ import { tryFrontmatterFastPath } from "../src/frontmatter.js";
// ── Helpers ───────────────────────────────────────────────────────────────────
/** JSON Schema that matches the new status-only AgentFrontmatter. */
const STATUS_ONLY_SCHEMA = {
/** JSON Schema that exactly matches the AgentFrontmatter fields. */
const FRONTMATTER_SCHEMA = {
type: "object",
properties: {
status: { anyOf: [{ type: "string" }, { type: "null" }] },
next: { anyOf: [{ type: "string" }, { type: "null" }] },
confidence: { anyOf: [{ type: "number" }, { type: "null" }] },
artifacts: { type: "array", items: { type: "string" } },
scope: { type: "string" },
},
required: ["status"],
required: ["status", "next", "confidence", "artifacts", "scope"],
additionalProperties: false,
};
@@ -52,41 +56,24 @@ async function makeStoreWithSchema(schema: Record<string, unknown>) {
return { store, schemaHash };
}
// ── STANDARD_KEYS ────────────────────────────────────────────────────────────
describe("STANDARD_KEYS contains only status", () => {
test("STANDARD_KEYS is ['status']", async () => {
// We verify indirectly: defaultCandidate (no schema fields) returns only { status }
const { store, schemaHash } = await makeStoreWithSchema({
type: "object",
properties: {
status: { anyOf: [{ type: "string" }, { type: "null" }] },
},
});
const raw = "---\nstatus: done\n---\n\nBody.";
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).not.toBeNull();
const node = store.get(result!.outputHash);
expect(node).not.toBeNull();
const payload = node!.payload as Record<string, unknown>;
expect(payload.status).toBe("done");
// Legacy fields must NOT be present
expect(payload.next).toBeUndefined();
expect(payload.confidence).toBeUndefined();
expect(payload.artifacts).toBeUndefined();
expect(payload.scope).toBeUndefined();
});
});
// ── Happy path ─────────────────────────────────────────────────────────────────
describe("tryFrontmatterFastPath — happy path", () => {
test("parses valid frontmatter and returns outputHash + stripped body", async () => {
const { store, schemaHash } = await makeStoreWithSchema(STATUS_ONLY_SCHEMA);
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA);
const raw = ["---", "status: done", "---", "", "## Summary", "Work is complete."].join("\n");
const raw = [
"---",
"status: done",
"next: reviewer",
"confidence: 0.9",
"artifacts: [src/foo.ts]",
"scope: role",
"---",
"",
"## Summary",
"Work is complete.",
].join("\n");
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
@@ -98,10 +85,11 @@ describe("tryFrontmatterFastPath — happy path", () => {
expect((result?.outputHash ?? "").length).toBeGreaterThan(0);
});
test("stored CAS node payload has only status", async () => {
const { store, schemaHash } = await makeStoreWithSchema(STATUS_ONLY_SCHEMA);
test("stored CAS node payload matches frontmatter fields", async () => {
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA);
const raw = "---\nstatus: done\n---\n\nBody.";
const raw =
"---\nstatus: done\nnext: null\nconfidence: null\nartifacts: []\nscope: role\n---\n\nBody.";
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).not.toBeNull();
@@ -110,29 +98,10 @@ describe("tryFrontmatterFastPath — happy path", () => {
expect(node).not.toBeNull();
const payload = node!.payload as Record<string, unknown>;
expect(payload.status).toBe("done");
expect(Object.keys(payload)).toEqual(["status"]);
});
});
// ── Legacy fields in input are ignored ──────────────────────────────────────
describe("tryFrontmatterFastPath — legacy fields ignored", () => {
test("legacy fields in input do not appear in CAS output", async () => {
const { store, schemaHash } = await makeStoreWithSchema(STATUS_ONLY_SCHEMA);
const raw =
"---\nstatus: done\nnext: reviewer\nconfidence: 0.9\nartifacts: [a.ts]\nscope: thread\n---\n\nBody.";
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).not.toBeNull();
const node = store.get(result!.outputHash);
const payload = node!.payload as Record<string, unknown>;
expect(payload.status).toBe("done");
expect(payload.next).toBeUndefined();
expect(payload.confidence).toBeUndefined();
expect(payload.artifacts).toBeUndefined();
expect(payload.scope).toBeUndefined();
expect(payload.next).toBeNull();
expect(payload.confidence).toBeNull();
expect(payload.artifacts).toEqual([]);
expect(payload.scope).toBe("role");
});
});
@@ -140,7 +109,7 @@ describe("tryFrontmatterFastPath — legacy fields ignored", () => {
describe("tryFrontmatterFastPath — fallback: no frontmatter", () => {
test("returns null for plain markdown without frontmatter block", async () => {
const { store, schemaHash } = await makeStoreWithSchema(STATUS_ONLY_SCHEMA);
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA);
const result = await tryFrontmatterFastPath(
"This is plain markdown without any frontmatter.",
@@ -152,13 +121,35 @@ describe("tryFrontmatterFastPath — fallback: no frontmatter", () => {
});
});
// ── Fallback: invalid frontmatter ─────────────────────────────────────────────
describe("tryFrontmatterFastPath — fallback: invalid frontmatter", () => {
test("returns null when confidence is out of range [0, 1]", async () => {
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA);
const raw = "---\nstatus: done\nconfidence: 1.5\nscope: role\n---\n\nBody.";
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).toBeNull();
});
test("returns null when next contains whitespace", async () => {
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA);
const raw = "---\nstatus: done\nnext: some role\nscope: role\n---\n\nBody.";
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).toBeNull();
});
});
// ── Fallback: schema mismatch ─────────────────────────────────────────────────
describe("tryFrontmatterFastPath — fallback: schema mismatch", () => {
test("returns null when outputSchema requires fields not in frontmatter", async () => {
const { store, schemaHash } = await makeStoreWithSchema(STRICT_SCHEMA);
const raw = "---\nstatus: done\n---\n\nBody.";
const raw = "---\nstatus: done\nscope: role\n---\n\nBody.";
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).toBeNull();
@@ -203,7 +194,7 @@ describe("tryFrontmatterFastPath — role-specific fields", () => {
test("returns null when required role-specific field is missing", async () => {
const { store, schemaHash } = await makeStoreWithSchema(REVIEWER_SCHEMA);
const raw = "---\nstatus: done\n---\n\nBody.";
const raw = "---\nstatus: done\nscope: role\n---\n\nBody.";
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).toBeNull();
+6 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-util-agent",
"version": "0.5.0",
"version": "0.5.1",
"files": [
"src",
"dist",
@@ -15,9 +15,8 @@
}
},
"scripts": {
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
"test": "vitest run",
"test:ci": "vitest run"
"test": "bun test",
"test:ci": "bun test"
},
"dependencies": {
"@uncaged/json-cas": "^0.5.3",
@@ -35,12 +34,12 @@
},
"repository": {
"type": "git",
"url": "https://git.shazhou.work/uncaged/workflow.git",
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
"directory": "packages/workflow-util-agent"
},
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
"bugs": {
"url": "https://git.shazhou.work/uncaged/workflow/issues"
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
},
"license": "MIT"
}
@@ -1,45 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
describe("parseArgv empty prompt error message", () => {
let stderrOutput: string;
let _exitCode: number | null;
const originalExit = process.exit;
const originalStderrWrite = process.stderr.write;
beforeEach(() => {
stderrOutput = "";
_exitCode = null;
process.exit = ((code?: number) => {
_exitCode = code ?? 1;
throw new Error("process.exit called");
}) as any;
process.stderr.write = ((chunk: string) => {
stderrOutput += chunk;
return true;
}) as any;
});
afterEach(() => {
process.exit = originalExit;
process.stderr.write = originalStderrWrite;
});
test("empty prompt produces error message mentioning template variables", async () => {
const { parseArgv } = await import("../run.js");
const argv = [
"node",
"uwf-hermes",
"--thread",
"01ABCDEFGHIJKLMNOPQRSTUVWX",
"--role",
"classifier",
"--prompt",
"",
];
expect(() => parseArgv(argv)).toThrow("process.exit called");
expect(stderrOutput).toContain("prompt");
expect(stderrOutput).toContain("empty");
expect(stderrOutput).toContain("template");
});
});
@@ -214,7 +214,7 @@ function getConstValue(propSchema: JSONSchema): string {
function buildVariantBlock(variant: JSONSchema, discriminant: string): string {
const props = extractSchemaProperties(variant);
const value = getConstValue(
(variant.properties as Record<string, JSONSchema>)?.[discriminant] ?? {},
((variant.properties as Record<string, JSONSchema>) ?? {})[discriminant] ?? {},
);
const yamlExample = buildYamlExampleBlock(props);
const fieldList = buildFieldList(props);
@@ -130,8 +130,6 @@ async function buildHistory(
edgePrompt: step.edgePrompt ?? "",
startedAtMs: step.startedAtMs,
completedAtMs: step.completedAtMs,
cwd: step.cwd ?? "",
assembledPrompt: step.assembledPrompt ?? null,
content,
});
}
@@ -13,14 +13,13 @@ import { extractSchemaFields } from "./build-output-format-instruction.js";
const log = createLogger({ sink: { kind: "stderr" } });
const STANDARD_KEYS = ["status"] as const;
const STANDARD_KEYS = ["status", "next", "confidence", "artifacts", "scope"] as const;
type StandardKey = (typeof STANDARD_KEYS)[number];
export type FrontmatterFastPathResult = {
body: string;
outputHash: CasRef;
frontmatter: Record<string, unknown>;
};
function extractYamlBlock(raw: string): string | null {
@@ -63,6 +62,10 @@ function parseRawFrontmatterFields(raw: string): Record<string, unknown> {
function defaultCandidate(frontmatter: AgentFrontmatter): Record<string, unknown> {
return {
status: frontmatter.status,
next: frontmatter.next,
confidence: frontmatter.confidence,
artifacts: [...frontmatter.artifacts],
scope: frontmatter.scope,
};
}
@@ -70,6 +73,14 @@ function pickStandardField(frontmatter: AgentFrontmatter, key: StandardKey): unk
switch (key) {
case "status":
return frontmatter.status;
case "next":
return frontmatter.next;
case "confidence":
return frontmatter.confidence;
case "artifacts":
return [...frontmatter.artifacts];
case "scope":
return frontmatter.scope;
}
}
@@ -87,6 +98,9 @@ function pickFieldValue(
}
const coerced = pickStandardField(frontmatter, field);
if (field === "artifacts" || field === "scope") {
return coerced;
}
if (coerced !== null) {
return coerced;
}
@@ -96,8 +110,8 @@ function pickFieldValue(
/**
* Build a CAS candidate object from schema property keys and parsed frontmatter.
*
* When the schema has no inspectable properties, falls back to the standard
* agent frontmatter field (status only).
* When the schema has no inspectable properties, falls back to the five standard
* agent frontmatter fields for backward compatibility.
*/
function buildCandidate(
frontmatter: AgentFrontmatter,
@@ -177,5 +191,5 @@ export async function tryFrontmatterFastPath(
return null;
}
return { body, outputHash, frontmatter: candidate };
return { body, outputHash };
}
+1 -2
View File
@@ -11,11 +11,10 @@ export {
} from "./extract.js";
export type { FrontmatterFastPathResult } from "./frontmatter.js";
export { tryFrontmatterFastPath } from "./frontmatter.js";
export { createAgent, parseArgv } from "./run.js";
export { createAgent } from "./run.js";
export { getCachedSessionId, getCachePath, setCachedSessionId } from "./session-cache.js";
export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
export type {
AdapterOutput,
AgentContext,
AgentContinueFn,
AgentOptions,
+11 -49
View File
@@ -6,7 +6,7 @@ import { buildContextWithMeta } from "./context.js";
import { tryFrontmatterFastPath } from "./frontmatter.js";
import type { AgentStore } from "./storage.js";
import { getEnvPath, resolveStorageRoot } from "./storage.js";
import type { AdapterOutput, AgentOptions } from "./types.js";
import type { AgentOptions } from "./types.js";
const MAX_FRONTMATTER_RETRIES = 2;
@@ -32,16 +32,13 @@ function getNamedArg(argv: string[], name: string): string {
return argv[idx + 1];
}
export function parseArgv(argv: string[]): { threadId: ThreadId; role: string; prompt: string } {
function parseArgv(argv: string[]): { threadId: ThreadId; role: string; prompt: string } {
const threadId = getNamedArg(argv, "--thread");
const role = getNamedArg(argv, "--role");
const prompt = getNamedArg(argv, "--prompt");
if (threadId === "") fail(USAGE);
if (role === "") fail(USAGE);
if (prompt === "")
fail(
`--prompt is empty. If this agent was spawned by uwf, the edge prompt template may have unresolved variables. ${USAGE}`,
);
if (prompt === "") fail(USAGE);
return { threadId: threadId as ThreadId, role, prompt };
}
@@ -64,7 +61,6 @@ async function writeStepNode(options: {
edgePrompt: string;
startedAtMs: number;
completedAtMs: number;
assembledPromptHash: CasRef | null;
}): Promise<CasRef> {
const payload: StepNodePayload = {
start: options.startHash,
@@ -76,8 +72,6 @@ async function writeStepNode(options: {
edgePrompt: options.edgePrompt,
startedAtMs: options.startedAtMs,
completedAtMs: options.completedAtMs,
cwd: process.cwd(),
assembledPrompt: options.assembledPromptHash,
};
const hash = await options.store.put(options.schemas.stepNode, payload);
const node = options.store.get(hash);
@@ -87,24 +81,14 @@ async function writeStepNode(options: {
return hash;
}
type ExtractedOutput = {
outputHash: CasRef;
frontmatter: Record<string, unknown>;
body: string;
};
async function tryExtractOutput(
rawOutput: string,
outputSchema: CasRef,
ctx: Awaited<ReturnType<typeof buildContextWithMeta>>,
): Promise<ExtractedOutput | null> {
): Promise<CasRef | null> {
const fastPath = await tryFrontmatterFastPath(rawOutput, outputSchema, ctx.meta.store);
if (fastPath !== null) {
return {
outputHash: fastPath.outputHash,
frontmatter: fastPath.frontmatter,
body: fastPath.body,
};
return fastPath.outputHash;
}
return null;
}
@@ -116,7 +100,6 @@ async function persistStep(options: {
agentName: string;
startedAtMs: number;
completedAtMs: number;
assembledPromptHash: CasRef | null;
}): Promise<CasRef> {
const { store, schemas, chain, headHash } = options.ctx.meta;
return writeStepNode({
@@ -131,7 +114,6 @@ async function persistStep(options: {
edgePrompt: options.ctx.edgePrompt,
startedAtMs: options.startedAtMs,
completedAtMs: options.completedAtMs,
assembledPromptHash: options.assembledPromptHash,
});
}
@@ -155,7 +137,6 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
const startedAtMs = Date.now();
let agentResult = await runWithMessage("agent run failed", () => options.run(ctx));
agentResult.output = agentResult.output.trimStart();
// Preserve the primary detail from the first run — it contains the full
// tool-call turn history. Continuation retries only fix frontmatter
@@ -163,9 +144,9 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
const primaryDetailHash = agentResult.detailHash;
// Try to extract frontmatter; retry via continue if it fails
let extracted = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
let outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
for (let retry = 0; retry < MAX_FRONTMATTER_RETRIES && extracted === null; retry++) {
for (let retry = 0; retry < MAX_FRONTMATTER_RETRIES && outputHash === null; retry++) {
const correctionMessage =
"Your previous response did not contain valid YAML frontmatter matching the role schema.\n" +
"You MUST begin your response with a YAML frontmatter block (--- delimited).\n" +
@@ -174,11 +155,10 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
agentResult = await runWithMessage("agent continue failed", () =>
options.continue(agentResult.sessionId, correctionMessage, ctx.meta.store),
);
agentResult.output = agentResult.output.trimStart();
extracted = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
}
if (extracted === null) {
if (outputHash === null) {
fail(
"Agent output does not contain valid YAML frontmatter matching the role schema " +
`after ${MAX_FRONTMATTER_RETRIES} retries.\n` +
@@ -186,33 +166,15 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
);
}
const completedAtMs = Date.now();
// Store the assembled prompt in CAS for later inspection via `step read --prompt`
const promptText = agentResult.assembledPrompt;
const assembledPromptHash =
promptText !== ""
? await ctx.meta.store.put(ctx.meta.schemas.text, promptText).catch(() => null)
: null;
const stepHash = await persistStep({
ctx,
outputHash: extracted.outputHash,
outputHash,
detailHash: primaryDetailHash,
agentName: agentLabel(options.name),
startedAtMs,
completedAtMs,
assembledPromptHash,
});
const adapterOutput: AdapterOutput = {
stepHash,
detailHash: primaryDetailHash,
role,
frontmatter: extracted.frontmatter,
body: extracted.body,
startedAtMs,
completedAtMs,
};
process.stdout.write(`${JSON.stringify(adapterOutput)}\n`);
process.stdout.write(`${stepHash}\n`);
};
}
+2 -6
View File
@@ -6,21 +6,17 @@ export type UwfAgentSchemaHashes = {
workflow: Hash;
startNode: Hash;
stepNode: Hash;
text: Hash;
};
const TEXT_SCHEMA = { type: "string" as const };
/**
* Register Workflow, StartNode, and StepNode JSON Schemas in the CAS store.
* Idempotent: safe to call on every agent invocation.
*/
export async function registerAgentSchemas(store: Store): Promise<UwfAgentSchemaHashes> {
const [workflow, startNode, stepNode, text] = await Promise.all([
const [workflow, startNode, stepNode] = await Promise.all([
putSchema(store, WORKFLOW_SCHEMA),
putSchema(store, START_NODE_SCHEMA),
putSchema(store, STEP_NODE_SCHEMA),
putSchema(store, TEXT_SCHEMA),
]);
return { workflow, startNode, stepNode, text };
return { workflow, startNode, stepNode };
}
-12
View File
@@ -27,8 +27,6 @@ export type AgentRunResult = {
output: string;
detailHash: string;
sessionId: string;
/** The fully assembled prompt that was sent to the agent. */
assembledPrompt: string;
};
export type AgentContinueFn = (
@@ -39,16 +37,6 @@ export type AgentContinueFn = (
export type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
export type AdapterOutput = {
stepHash: string;
detailHash: string;
role: string;
frontmatter: Record<string, unknown>;
body: string;
startedAtMs: number;
completedAtMs: number;
};
export type AgentOptions = {
name: string;
run: AgentRunFn;
+1 -1
View File
@@ -5,5 +5,5 @@
"outDir": "dist"
},
"include": ["src"],
"references": [{ "path": "../workflow-protocol" }, { "path": "../workflow-util" }]
"references": [{ "path": "../workflow-protocol" }]
}
@@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
+7
View File
@@ -0,0 +1,7 @@
# @uncaged/workflow-util
## 0.5.1
### Patch Changes
- Add 5 persona-based skills (actor, user, author, developer, adapter) and fix skill CLI description truncation
@@ -41,13 +41,31 @@ describe("parseFrontmatterMarkdown", () => {
});
});
describe("status-only frontmatter", () => {
it("parses status-only frontmatter", () => {
const raw = "---\nstatus: done\n---\nbody";
describe("full frontmatter document", () => {
it("parses all fields from a well-formed document", () => {
const raw = `---
status: done
next: reviewer
confidence: 0.9
artifacts:
- src/foo.ts
- src/bar.ts
scope: thread
---
## Summary
Everything looks good.`;
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter).not.toBeNull();
expect(result.frontmatter).toEqual({ status: "done" });
expect(result.body).toBe("body");
const fm = result.frontmatter!;
expect(fm.status).toBe("done");
expect(fm.next).toBe("reviewer");
expect(fm.confidence).toBe(0.9);
expect(fm.artifacts).toEqual(["src/foo.ts", "src/bar.ts"]);
expect(fm.scope).toBe("thread");
expect(result.body).toBe("## Summary\n\nEverything looks good.");
});
it("strips leading newline from body", () => {
@@ -69,22 +87,6 @@ describe("parseFrontmatterMarkdown", () => {
});
});
describe("ignores legacy fields", () => {
it("legacy fields next/confidence/artifacts/scope are NOT present on result", () => {
const raw =
"---\nstatus: done\nnext: reviewer\nconfidence: 0.9\nartifacts:\n - src/foo.ts\nscope: thread\n---\n\nBody.";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter).not.toBeNull();
const fm = result.frontmatter!;
expect(fm.status).toBe("done");
// Legacy fields must not exist on the object at all
expect("next" in fm).toBe(false);
expect("confidence" in fm).toBe(false);
expect("artifacts" in fm).toBe(false);
expect("scope" in fm).toBe(false);
});
});
describe("status field", () => {
it.each([
"done",
@@ -104,18 +106,109 @@ describe("parseFrontmatterMarkdown", () => {
});
it("returns null status when omitted", () => {
const raw = "---\nfoo: bar\n---\nbody";
const raw = "---\nconfidence: 0.5\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.status).toBeNull();
});
});
describe("confidence field", () => {
it("parses integer as number", () => {
const raw = "---\nconfidence: 1\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.confidence).toBe(1);
});
it("parses decimal", () => {
const raw = "---\nconfidence: 0.75\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.confidence).toBe(0.75);
});
it("returns null when omitted", () => {
const raw = "---\nstatus: done\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.confidence).toBeNull();
});
it("returns null for non-numeric value", () => {
const raw = "---\nconfidence: high\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.confidence).toBeNull();
});
});
describe("artifacts field", () => {
it("parses block sequence", () => {
const raw = "---\nartifacts:\n - a.ts\n - b.ts\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.artifacts).toEqual(["a.ts", "b.ts"]);
});
it("parses inline sequence", () => {
const raw = "---\nartifacts: [a.ts, b.ts]\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.artifacts).toEqual(["a.ts", "b.ts"]);
});
it("returns empty array when omitted", () => {
const raw = "---\nstatus: done\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.artifacts).toEqual([]);
});
it("wraps single scalar in array", () => {
const raw = "---\nartifacts: only-one.ts\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.artifacts).toEqual(["only-one.ts"]);
});
});
describe("scope field", () => {
it('parses scope "role"', () => {
const raw = "---\nscope: role\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.scope).toBe("role");
});
it('parses scope "thread"', () => {
const raw = "---\nscope: thread\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.scope).toBe("thread");
});
it('defaults to "role" when omitted', () => {
const raw = "---\nstatus: done\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.scope).toBe("role");
});
it('defaults to "role" for unknown scope value', () => {
const raw = "---\nscope: global\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.scope).toBe("role");
});
});
describe("next field", () => {
it("parses a role name", () => {
const raw = "---\nnext: planner\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.next).toBe("planner");
});
it("returns null when omitted", () => {
const raw = "---\nstatus: done\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.next).toBeNull();
});
});
describe("unknown fields", () => {
it("ignores unknown keys silently", () => {
const raw = "---\nunknown_field: some_value\nstatus: done\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.status).toBe("done");
expect(Object.keys(result.frontmatter!)).toEqual(["status"]);
});
});
@@ -128,58 +221,123 @@ describe("parseFrontmatterMarkdown", () => {
});
describe("empty frontmatter block", () => {
it("parses empty frontmatter with status null", () => {
it("parses empty frontmatter and uses all defaults", () => {
const raw = "---\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter).not.toBeNull();
const fm = result.frontmatter!;
expect(fm.status).toBeNull();
expect(Object.keys(fm)).toEqual(["status"]);
expect(fm.next).toBeNull();
expect(fm.confidence).toBeNull();
expect(fm.artifacts).toEqual([]);
expect(fm.scope).toBe("role");
expect(result.body).toBe("body");
});
});
describe("AgentFrontmatter has exactly one field", () => {
it("has only status key", () => {
const fm: AgentFrontmatter = { status: null };
expect(Object.keys(fm)).toEqual(["status"]);
});
});
describe("FrontmatterValidationError only has status variant", () => {
it("status variant is valid", () => {
const err: import("../src/index.js").FrontmatterValidationError = {
field: "status",
message: "test",
};
expect(err.field).toBe("status");
});
});
});
// ── validateFrontmatter ──────────────────────────────────────────────────────
function validFm(overrides: Partial<AgentFrontmatter> = {}): AgentFrontmatter {
return {
status: "done",
next: null,
confidence: null,
artifacts: [],
scope: "role",
...overrides,
};
}
describe("validateFrontmatter", () => {
it("returns no errors for a valid status", () => {
const errors = validateFrontmatter({ status: "done" });
it("returns no errors for a fully valid frontmatter", () => {
const errors = validateFrontmatter(validFm());
expect(errors).toHaveLength(0);
});
it("returns no errors when status is null", () => {
const errors = validateFrontmatter({ status: null });
expect(errors).toHaveLength(0);
});
it("returns error for invalid status", () => {
const errors = validateFrontmatter({ status: "bogus" as never });
expect(errors).toHaveLength(1);
expect(errors[0]?.field).toBe("status");
});
it("no validation for next/confidence/artifacts/scope — fields do not exist", () => {
// AgentFrontmatter only has status — verify at runtime
const fm: AgentFrontmatter = { status: "done" };
expect(Object.keys(fm)).toEqual(["status"]);
it("returns no errors when all nullable fields are null", () => {
const fm: AgentFrontmatter = {
status: null,
next: null,
confidence: null,
artifacts: [],
scope: "role",
};
expect(validateFrontmatter(fm)).toHaveLength(0);
});
describe("confidence validation", () => {
it("accepts 0.0", () => {
expect(validateFrontmatter(validFm({ confidence: 0 }))).toHaveLength(0);
});
it("accepts 1.0", () => {
expect(validateFrontmatter(validFm({ confidence: 1 }))).toHaveLength(0);
});
it("rejects value below 0", () => {
const errors = validateFrontmatter(validFm({ confidence: -0.1 }));
expect(errors).toHaveLength(1);
expect(errors[0]?.field).toBe("confidence");
});
it("rejects value above 1", () => {
const errors = validateFrontmatter(validFm({ confidence: 1.01 }));
expect(errors).toHaveLength(1);
expect(errors[0]?.field).toBe("confidence");
});
});
describe("next validation", () => {
it("accepts a simple role name", () => {
expect(validateFrontmatter(validFm({ next: "reviewer" }))).toHaveLength(0);
});
it("accepts kebab-case role name", () => {
expect(validateFrontmatter(validFm({ next: "code-reviewer" }))).toHaveLength(0);
});
it("rejects role name with whitespace", () => {
const errors = validateFrontmatter(validFm({ next: "role name" }));
expect(errors).toHaveLength(1);
expect(errors[0]?.field).toBe("next");
});
});
describe("artifacts validation", () => {
it("accepts non-empty path strings", () => {
expect(
validateFrontmatter(validFm({ artifacts: ["src/foo.ts", "src/bar.ts"] })),
).toHaveLength(0);
});
it("rejects empty string artifact entries", () => {
const errors = validateFrontmatter(validFm({ artifacts: [""] }));
expect(errors).toHaveLength(1);
expect(errors[0]?.field).toBe("artifacts");
});
it("rejects whitespace-only artifact entries", () => {
const errors = validateFrontmatter(validFm({ artifacts: [" "] }));
expect(errors).toHaveLength(1);
expect(errors[0]?.field).toBe("artifacts");
});
});
describe("multiple errors", () => {
it("reports multiple violations at once", () => {
const fm: AgentFrontmatter = {
status: "done",
next: "bad role",
confidence: 2,
artifacts: [""],
scope: "role",
};
const errors = validateFrontmatter(fm);
const fields = errors.map((e) => e.field);
expect(fields).toContain("next");
expect(fields).toContain("confidence");
expect(fields).toContain("artifacts");
});
});
});
+4 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-util",
"version": "0.5.0",
"version": "0.5.1",
"files": [
"src",
"dist",
@@ -14,11 +14,6 @@
"import": "./dist/index.js"
}
},
"scripts": {
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
"test": "vitest run",
"test:ci": "vitest run"
},
"dependencies": {},
"devDependencies": {
"typescript": "^5.8.3"
@@ -28,12 +23,12 @@
},
"repository": {
"type": "git",
"url": "https://git.shazhou.work/uncaged/workflow.git",
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
"directory": "packages/workflow-util"
},
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
"bugs": {
"url": "https://git.shazhou.work/uncaged/workflow/issues"
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
},
"license": "MIT"
}
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it } from "bun:test";
import { extractUlidTimestamp, generateUlid } from "../ulid.js";
describe("extractUlidTimestamp", () => {
@@ -1,39 +0,0 @@
export function generateBootstrapReference(): string {
return `---
name: uwf
description: "Uncaged Workflow (uwf) — YAML 状态机工作流引擎。任务涉及 workflow 时加载此 skill。"
tags: [workflow, uwf, uncaged]
triggers:
- uwf
- workflow
-
---
# uwf (Uncaged Workflow)
YAML workflow **uwf workflow**YAML Hermes skill \`uwf\` CLI 操作,不要混淆。
## 使
\`\`\`bash
uwf skill user # 使CLI thread
uwf skill author # workflow role graph schema
\`\`\`
##
\`\`\`bash
uwf workflow list # workflow
uwf workflow add <file.yaml> # workflow
uwf thread start <workflow> -p "prompt" # thread
uwf thread exec <thread-id> -c 10 # 10
uwf thread list # thread
\`\`\`
## workflow
\`examples/\` 目录下的 YAML 文件(analyze-topic、debate、solve-issue)。
`;
}
@@ -1,5 +1,6 @@
import type {
AgentFrontmatter,
FrontmatterScope,
FrontmatterStatus,
FrontmatterValidationError,
ParsedFrontmatterMarkdown,
@@ -158,12 +159,40 @@ function parseMinimalYaml(yaml: string): Record<string, YamlValue> {
const VALID_STATUS: readonly FrontmatterStatus[] = ["done", "needs_input", "in_progress", "failed"];
const VALID_SCOPE: readonly FrontmatterScope[] = ["role", "thread"];
function coerceStatus(raw: YamlValue): FrontmatterStatus | null {
if (raw === null || raw === undefined) return null;
const s = String(raw).trim().toLowerCase();
return VALID_STATUS.includes(s as FrontmatterStatus) ? (s as FrontmatterStatus) : null;
}
function coerceNext(raw: YamlValue): string | null {
if (raw === null || raw === undefined) return null;
const s = String(raw).trim();
return s === "" ? null : s;
}
function coerceConfidence(raw: YamlValue): number | null {
if (raw === null || raw === undefined) return null;
const n = typeof raw === "number" ? raw : Number(String(raw).trim());
if (Number.isNaN(n)) return null;
return n;
}
function coerceArtifacts(raw: YamlValue): readonly string[] {
if (raw === null || raw === undefined) return [];
if (Array.isArray(raw)) return raw.map(String).filter((s) => s !== "");
const s = String(raw).trim();
return s === "" ? [] : [s];
}
function coerceScope(raw: YamlValue): FrontmatterScope {
if (raw === null || raw === undefined) return "role";
const s = String(raw).trim().toLowerCase();
return VALID_SCOPE.includes(s as FrontmatterScope) ? (s as FrontmatterScope) : "role";
}
// ── Public API ───────────────────────────────────────────────────────────────
/**
@@ -191,6 +220,10 @@ export function parseFrontmatterMarkdown(raw: string): ParsedFrontmatterMarkdown
const frontmatter: AgentFrontmatter = {
status: coerceStatus(fields.status ?? null),
next: coerceNext(fields.next ?? null),
confidence: coerceConfidence(fields.confidence ?? null),
artifacts: coerceArtifacts(fields.artifacts ?? null),
scope: coerceScope(fields.scope ?? null),
};
return { frontmatter, body };
@@ -202,7 +235,11 @@ export function parseFrontmatterMarkdown(raw: string): ParsedFrontmatterMarkdown
* An empty array means the frontmatter is valid.
*
* Validated constraints:
* - `status` must be one of the FrontmatterStatus literals (if non-null)
* - `status` must be one of the FrontmatterStatus literals (if non-null)
* - `confidence` must be in [0.0, 1.0] (if non-null)
* - `next` must be a non-empty string with no whitespace (if non-null)
* - `artifacts` each entry must be a non-empty string
* - `scope` must be one of the FrontmatterScope literals
*/
export function validateFrontmatter(
frontmatter: AgentFrontmatter,
@@ -216,5 +253,39 @@ export function validateFrontmatter(
});
}
if (frontmatter.confidence !== null) {
if (frontmatter.confidence < 0 || frontmatter.confidence > 1) {
errors.push({
field: "confidence",
message: `confidence ${frontmatter.confidence} is out of range; must be between 0.0 and 1.0 inclusive`,
});
}
}
if (frontmatter.next !== null) {
if (frontmatter.next.trim() === "") {
errors.push({ field: "next", message: "next must be a non-empty string when present" });
} else if (/\s/.test(frontmatter.next)) {
errors.push({
field: "next",
message: `next "${frontmatter.next}" must not contain whitespace`,
});
}
}
for (const artifact of frontmatter.artifacts) {
if (artifact.trim() === "") {
errors.push({ field: "artifacts", message: "artifact entries must be non-empty strings" });
break;
}
}
if (!VALID_SCOPE.includes(frontmatter.scope)) {
errors.push({
field: "scope",
message: `invalid scope "${frontmatter.scope}"; must be one of: ${VALID_SCOPE.join(", ")}`,
});
}
return errors;
}
@@ -1,6 +1,7 @@
export { parseFrontmatterMarkdown, validateFrontmatter } from "./frontmatter-markdown.js";
export type {
AgentFrontmatter,
FrontmatterScope,
FrontmatterStatus,
FrontmatterValidationError,
ParsedFrontmatterMarkdown,
@@ -1,5 +1,5 @@
/**
* Frontmatter Markdown agent output format.
* Frontmatter Markdown agent output format (RFC #351 Phase 1).
*
* An agent response is a Markdown document with an optional YAML frontmatter
* block at the top. The frontmatter carries structured signals that the
@@ -9,12 +9,17 @@
*
* ---
* status: done
* next: reviewer
* confidence: 0.9
* artifacts:
* - src/foo.ts
* scope: role
* ---
*
* ... free-form markdown body ...
*
* Only `status` is a standard frontmatter field. All other fields are
* role-specific and defined by the output schema.
* All frontmatter fields are optional at the parse level. `validateFrontmatter`
* enforces the constraints documented on each field below.
*/
// ── Vocabulary types ─────────────────────────────────────────────────────────
@@ -29,12 +34,20 @@
*/
export type FrontmatterStatus = "done" | "needs_input" | "in_progress" | "failed";
/**
* Scope of frontmatter signals.
*
* - `role` signals apply to the current role execution only (default)
* - `thread` signals are suggestions for the entire thread moderator
*/
export type FrontmatterScope = "role" | "thread";
// ── Core frontmatter schema ──────────────────────────────────────────────────
/**
* Parsed and validated frontmatter from an agent response.
*
* Only `status` is a standard field. All other fields are role-specific.
* All fields use explicit `T | null` (no optional `?:` per convention).
*/
export type AgentFrontmatter = {
/**
@@ -42,6 +55,32 @@ export type AgentFrontmatter = {
* Null when omitted engine treats it as "done" for backward compatibility.
*/
status: FrontmatterStatus | null;
/**
* Suggested next role name for the moderator.
* The moderator is NOT obligated to follow this it is advisory only.
* Null when the agent has no preference.
*/
next: string | null;
/**
* Agent's self-assessed confidence in its output (0.0 1.0 inclusive).
* Null when omitted.
*/
confidence: number | null;
/**
* Relative file paths or CAS hashes the agent considers its primary outputs.
* Used for GC ref-tracing and human-readable summaries.
* Empty array when omitted (never null an absent list is an empty list).
*/
artifacts: readonly string[];
/**
* Scope of the frontmatter signals.
* Defaults to "role" when omitted.
*/
scope: FrontmatterScope;
};
// ── Parse output ─────────────────────────────────────────────────────────────
@@ -64,4 +103,9 @@ export type ParsedFrontmatterMarkdown = {
// ── Validation error ─────────────────────────────────────────────────────────
export type FrontmatterValidationError = { field: "status"; message: string };
export type FrontmatterValidationError =
| { field: "status"; message: string }
| { field: "next"; message: string }
| { field: "confidence"; message: string }
| { field: "artifacts"; message: string }
| { field: "scope"; message: string };
+1 -1
View File
@@ -3,12 +3,12 @@ export { generateAdapterReference } from "./adapter-reference.js";
export { generateArchitectureReference } from "./architecture-reference.js";
export { generateAuthorReference } from "./author-reference.js";
export { encodeUint64AsCrockford } from "./base32.js";
export { generateBootstrapReference } from "./bootstrap-reference.js";
export { generateCliReference } from "./cli-reference.js";
export { generateDeveloperReference } from "./developer-reference.js";
export { env } from "./env.js";
export type {
AgentFrontmatter,
FrontmatterScope,
FrontmatterStatus,
FrontmatterValidationError,
ParsedFrontmatterMarkdown,
@@ -121,15 +121,5 @@ uwf log clean --before <date> # delete old logs
uwf --format <json|yaml> # output format (default: json)
uwf -V, --version # print version
\`\`\`
## Other Skill References
For specific scenarios, run the corresponding \`uwf skill\` command:
| Scenario | Command | When to use |
|----------|---------|-------------|
| Writing workflow YAML | \`uwf skill author\` | Designing roles, conditions, graphs, and edge prompts |
| Contributing to the engine | \`uwf skill developer\` | Modifying the workflow engine codebase itself |
| Building a new agent adapter | \`uwf skill adapter\` | Creating a new \`uwf-<name>\` CLI adapter |
`;
}
-8
View File
@@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
+1 -1
View File
@@ -60,7 +60,7 @@ let failed = false;
for (const name of publishOrder) {
const pkgDir = join(root, "packages", name);
const tagFlag = tag ? `--tag ${tag}` : "";
const cmd = `npm publish --access public --ignore-scripts ${tagFlag}`;
const cmd = `npm publish --access public ${tagFlag}`;
console.log(`📦 ${name}...`);
-36
View File
@@ -1,36 +0,0 @@
---
name: uwf
description: "Uncaged Workflow (uwf) — YAML 状态机工作流引擎。任务涉及 workflow 时加载此 skill。"
tags: [workflow, uwf, uncaged]
triggers:
- uwf
- workflow
- 工作流
---
# uwf (Uncaged Workflow)
YAML 状态机工作流引擎。当用户提到「workflow」「工作流」时,指的是 **uwf workflow**(YAML 定义的状态机),不是 Hermes skill。用 `uwf` CLI 操作,不要混淆。
## 首次使用
运行以下命令获取完整用法:
```bash
uwf skill user # 用户使用手册(CLI 命令、thread 生命周期)
uwf skill author # workflow 编写指南(role 定义、graph 路由、schema)
```
## 快速参考
```bash
uwf workflow list # 查看已注册 workflow
uwf workflow add <file.yaml> # 注册 workflow
uwf thread start <workflow> -p "prompt" # 创建 thread
uwf thread exec <thread-id> -c 10 # 执行最多 10 步
uwf thread list # 查看所有 thread
```
## 示例 workflow
参考项目 `examples/` 目录下的 YAML 文件(analyze-topic、debate、solve-issue)。
-1
View File
@@ -23,7 +23,6 @@
{ "path": "packages/workflow-util-agent" },
{ "path": "packages/workflow-agent-hermes" },
{ "path": "packages/workflow-agent-builtin" },
{ "path": "packages/workflow-agent-claude-code" },
{ "path": "packages/cli-workflow" }
]
}

Some files were not shown because too many files have changed in this diff Show More