Compare commits

...

35 Commits

Author SHA1 Message Date
xiaomo 7a0c928a4a docs: update all docs to reference @ocas/core and ocas_ref
CI / check (pull_request) Failing after 1m2s
- README.md, docs/architecture.md, docs/wf-stateless-design.md
- docs/builtin-agent-research.md
- All package README.md files
- cas_ref → ocas_ref, @uncaged/json-cas → @ocas/core, json-cas-fs → @ocas/fs
2026-06-02 02:55:42 +00:00
xiaomo d8181e9fdf fix: config test reads source file from correct path
CI / check (pull_request) Failing after 58s
Test was reading from dist/commands/config.ts which doesn't exist
(only .js files in dist). Navigate to src/ instead.
2026-06-02 02:53:35 +00:00
xiaomo ef0174a6f1 chore: migrate @uncaged/json-cas to @ocas/core, @uncaged/json-cas-fs to @ocas/fs
CI / check (pull_request) Failing after 1m10s
- Replace all package.json dependencies
- Update all imports across 7 packages + scripts
- cas_ref → ocas_ref in schema definitions
- listByType() adapted for ListEntry[] return type
- Update CLAUDE.md references

Fixes #585
2026-06-02 02:51:21 +00:00
xiaomo 2a72dcde20 Merge pull request 'feat: !include YAML tag and folder-based workflow layout' (#584) from feat/include-and-folder-workflow into main
CI / check (push) Successful in 1m32s
2026-05-31 04:54:16 +00:00
xiaomo b1759096a2 fix: biome 2.4.16 migration, reduce scanWorkflowDir complexity, fix formatting
CI / check (pull_request) Successful in 1m23s
2026-05-31 04:52:08 +00:00
xiaomo f8c06ada64 style: fix biome lint (template literal, import sorting)
CI / check (pull_request) Failing after 2m7s
2026-05-31 04:48:16 +00:00
xiaomo 806edb2750 style: fix biome lint (import sorting, formatting)
CI / check (pull_request) Failing after 2m4s
2026-05-31 04:44:09 +00:00
xiaomo da1678ffef fix: address review feedback on !include and folder workflow
CI / check (pull_request) Failing after 1m37s
- Fix nested !include: pass customTags recursively, scoped to included file's dir
- Add path traversal guard: !include paths must resolve within base directory
- Fix discoverProjectWorkflows: scan both .workflow/ and .workflows/ (consistent with findWorkflowInDir)
- Add tests: path traversal blocking, nested !include, absolute path rejection
2026-05-31 04:26:54 +00:00
xiaomo 88c251fc14 feat: !include YAML tag and folder-based workflow layout
CI / check (pull_request) Failing after 1m58s
- Add !include custom YAML tag for referencing external files (Fixes #582)
  - .md/.txt files included as strings
  - .json files parsed as JSON objects
  - .yaml/.yml files parsed as YAML objects
  - Paths resolved relative to the workflow YAML file

- Support foo/index.yaml as alternative to foo.yaml (Fixes #583)
  - Updated discoverProjectWorkflows(), findWorkflowInDir()
  - Updated workflowNameFromPath() for index.yaml detection
  - Flat files take priority over folder layout

- Added tests for both features
2026-05-31 04:12:11 +00:00
xiaoju 9fb817a99c Merge pull request 'improve: solve-issue — replace tea pr create with Gitea API' (#581) from retrospect/fix-committer-tea into main
CI / check (push) Successful in 1m13s
2026-05-30 23:37:31 +00:00
xiaoju f9b8cf025e fix: add repoRemote to planner required fields
CI / check (pull_request) Successful in 1m15s
2026-05-30 23:36:49 +00:00
xiaoju d10f55294a improve: solve-issue — replace tea pr create with Gitea API curl
CI / check (pull_request) Successful in 1m17s
Fixes the committer role's inefficiency (thread 06F7JE4NDERP6J3W2RWVFQVQ7G analysis).

1. **Committer procedure**: Replace `tea pr create` with direct Gitea API calls via curl
   - Eliminates 15-18 wasted turns (~30-40% overhead) caused by incorrect tea CLI syntax
   - Adds verification steps: check push success + verify PR creation response
   - Warns explicitly: "do NOT use tea pr create — it fails in worktrees"

2. **Planner enhancement**: Extract and propagate `repoRemote` (owner/repo) in frontmatter
   - Downstream roles no longer need to extract repo info from git remote
   - Reduces discovery overhead and shell parsing errors

3. **Frontmatter schema updates**: Add `repoRemote` field to all roles
   - Developer, reviewer, tester, committer all propagate repoRemote
   - Ensures consistent data flow through the graph

4. **Graph prompt updates**: Pass `{{{repoRemote}}}` through all transitions
   - All roles receive repo remote context in task prompts
   - Committer receives "Repo remote (owner/repo): {{{repoRemote}}}"

5. **Test updates**: Update `solve-issue-tea-worktree.test.ts`
   - Expect curl API instead of tea pr create
   - Verify warning against tea pr create exists
   - All 8 tests pass

-  15-18 fewer turns per thread in committer role (30-40% reduction)
-  ~20-30 seconds saved per thread execution
-  Improved reliability — no CLI version/config dependencies
-  Cross-platform compatibility — works anywhere with curl + git

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-30 22:38:28 +00:00
xiaoju 0ece23f03e improve: solve-issue — add mandatory verification and escalation steps
Fixes hallucination issues observed in thread 06F7FSTXQGY3D5CY5YPQFK2Y3W:

1. Developer self-verification (critical): Added step 12 requiring
   mandatory verification of branch, file existence, and git status
   before reporting done status. Prevents hallucinated completions
   without actual tool execution.

2. Reviewer hard-check enforcement (critical): Added critical warning
   and step 0 requiring cd/pwd verification before review. Prevents
   false rejections based on assumptions without actual path checks.

3. Test debugging escalation (medium): Added structured debugging
   guidance with escalation path after 3 test cycles. Prevents
   infinite retry loops by providing strategy and fail-fast guidance.

Also added 3 test cases to verify the new procedure steps exist.

Based on change plan 9EVZPDTS16PMG analyzing execution anomalies
that resulted in 58% waste (13 of 23 minutes).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-30 22:37:42 +00:00
xiaonuo 389924c3ab Merge pull request 'improve: solve-issue — fix hallucination patterns (thread 06F7FSTXQGY3D5CY5YPQFK2Y3W)' (#579) from retrospect/solve-issue-fixes into main
CI / check (push) Successful in 1m55s
2026-05-30 08:57:58 +00:00
xiaoju 0dfa20f1d7 improve: solve-issue — add mandatory verification and escalation steps
CI / check (pull_request) Successful in 1m28s
Fixes hallucination issues observed in thread 06F7FSTXQGY3D5CY5YPQFK2Y3W:

1. Developer self-verification (critical): Added step 12 requiring
   mandatory verification of branch, file existence, and git status
   before reporting done status. Prevents hallucinated completions
   without actual tool execution.

2. Reviewer hard-check enforcement (critical): Added critical warning
   and step 0 requiring cd/pwd verification before review. Prevents
   false rejections based on assumptions without actual path checks.

3. Test debugging escalation (medium): Added structured debugging
   guidance with escalation path after 3 test cycles. Prevents
   infinite retry loops by providing strategy and fail-fast guidance.

Also added 3 test cases to verify the new procedure steps exist.

Based on change plan 9EVZPDTS16PMG analyzing execution anomalies
that resulted in 58% waste (13 of 23 minutes).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-30 08:40:34 +00:00
xingyue 0fcab06b80 improve(workflow): solve-issue — fix committer tea PR creation in worktrees
CI / check (pull_request) Successful in 1m20s
- Remove --repo flag from tea pr create (fails in git worktrees)
- Add guard to skip staging when developer already committed changes

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-05-30 15:41:08 +08:00
xiaonuo 15dcdee1cb Merge pull request 'fix(agent-claude-code): handle missing result line gracefully' (#576) from fix/574-silent-fail-handling into main
CI / check (push) Successful in 2m18s
2026-05-30 05:52:54 +00:00
xiaoju 53fa4d8972 fix(agent-claude-code): handle missing result line gracefully
CI / check (pull_request) Successful in 1m44s
Handle the case where Claude Code exits without producing a result line
(timeout, OOM, signal kill). Previously returned null and threw an error;
now returns incomplete result with best-effort output extraction.

Changes:
- Add "incomplete" as new ClaudeCodeResultSubtype value
- Extract output from last assistant turn when no result line exists
- Enhanced error messages distinguish incomplete vs unparseable output
- Store incomplete results in CAS with appropriate metadata
- Add 10 comprehensive test cases for incomplete result handling

Fixes #574

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-30 05:37:09 +00:00
xiaonuo ff8542d811 Merge pull request 'feat(cli): unify uwf CAS store with global json-cas store' (#575) from fix/573-unify-cas-store into main
CI / check (push) Successful in 1m36s
feat(cli): unify uwf CAS store with global json-cas store

Fixes #573
2026-05-30 05:25:12 +00:00
xiaoju 97637ad831 feat(cli): unify uwf CAS store with global json-cas store
CI / check (pull_request) Successful in 1m27s
This resolves issue #573 by moving uwf's CAS directory from
~/.uncaged/workflow/cas/ to the shared ~/.uncaged/json-cas/ location.

Changes:
- Added getGlobalCasDir() function with UNCAGED_CAS_DIR support
- Updated createUwfStore() to use global CAS directory
- Added comprehensive test coverage (11 new tests)
- Updated all existing tests for environment isolation
- Updated documentation (CLAUDE.md, README.md)

Benefits:
- Cross-tool visibility: json-cas CLI can read uwf-created nodes
- Schema sharing: both tools access same schema registry
- Future-proofing: enables json-cas render/verbose for uwf data

Fixes #573

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-30 04:52:47 +00:00
xiaoju 27d699fa73 feat(cli): unify uwf CAS store with global json-cas store
This resolves issue #573 by moving uwf's CAS directory from
~/.uncaged/workflow/cas/ to the shared ~/.uncaged/json-cas/ location.

Changes:
- Added getGlobalCasDir() function that respects UNCAGED_CAS_DIR env var
- Updated createUwfStore() to use the global CAS directory
- Updated all tests to set UNCAGED_CAS_DIR for test isolation
- Added comprehensive test suite for global CAS functionality
- Updated documentation (CLAUDE.md, README.md) to reflect new architecture

Benefits:
- Cross-tool visibility: json-cas CLI can now read uwf-created nodes
- Schema sharing: both tools access the same schema registry
- Future-proofing: enables json-cas render/verbose features for uwf data

Workflow metadata (threads.yaml, registry.yaml, history.jsonl) remains in
~/.uncaged/workflow/ as intended.

All tests pass. No breaking changes to existing functionality.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-30 04:45:16 +00:00
xiaoju 80bbb8b5f9 fix: add anti-hallucination ground rules and build artifact detection to normalize workflow
CI / check (push) Failing after 1m15s
- Add GROUND RULES section to all role procedures: require real command output, no fabrication
- Add 'skipped' status for roles where everything is already configured
- Add skipped routing in graph so workflow continues normally
- Add build artifact detection in committer: scan for .d.ts/.js.map/.js before commit
- Add verification enforcement notes to all roles

Fixes hallucination issue where agents reported completing work without actually writing files.
2026-05-29 04:45:31 +00:00
xiaoju d310d43ab8 feat(step-read): store assembled prompt in CAS, add --prompt flag
CI / check (push) Failing after 1m6s
Store the fully assembled prompt sent to each agent in CAS as a text
node, referenced from StepNodePayload.assembledPrompt. This enables
exact reproduction of what the agent received for debugging hallucinations.

Changes:
- workflow-protocol: StepRecord + STEP_NODE_SCHEMA add assembledPrompt field
- workflow-util-agent: AgentRunResult includes assembledPrompt, run.ts stores it
- workflow-util-agent: schemas register TEXT_SCHEMA for prompt storage
- workflow-agent-claude-code: return assembled prompt from buildClaudeCodePrompt
- workflow-agent-hermes: return assembled prompt from buildHermesPrompt
- workflow-agent-builtin: return empty prompt (no prompt assembly)
- cli-workflow: step read --prompt renders the stored prompt
- All test fixtures updated for new field

Legacy steps without assembledPrompt show 'Prompt not recorded' message.

小橘 🍊
2026-05-29 01:42:43 +00:00
xiaoju 7612c97ae7 fix(solve-issue): committer post-condition verification + use API instead of tea
CI / check (push) Failing after 49s
- Add git ls-remote verification after push
- Switch from tea pr create to Gitea API (tea fails in worktrees)
- Add PR creation verification (check response JSON has number field)
- Explicitly mark steps 4 and 6 as verification gates

小橘 🍊
2026-05-28 23:51:46 +00:00
xiaoju 8ffea10db0 fix: pass repoRemote through solve-issue workflow pipeline
CI / check (push) Failing after 1m16s
Root cause: committer role had to parse owner/repo from git remote URL,
which failed in worktrees with token-embedded URLs. Agent hallucinated
a fake PR URL instead of reporting the error.

Fix:
- planner extracts repoRemote from git remote, stores in frontmatter
- repoRemote flows through all roles via graph prompts
- committer uses repoRemote directly for tea/API calls
- Added Gitea API fallback when tea CLI fails

小橘 <xiaoju@shazhou.work>
2026-05-28 10:34:17 +00:00
xiaomo abe516f739 feat: add uwf skill bootstrap subcommand, update BOOTSTRAP.md to use it
CI / check (push) Failing after 1m23s
2026-05-28 10:32:25 +00:00
xiaomo 647f40bdd5 docs: use diff command for skill freshness check
CI / check (push) Successful in 1m21s
2026-05-28 10:28:06 +00:00
xiaomo 2265b32933 docs: clarify workflow means uwf workflow, not skill
CI / check (push) Successful in 1m14s
2026-05-28 10:04:13 +00:00
xiaoju 3971c26dc1 fix: solve-issue-workflow writes to .workflows/ instead of registering
CI / check (push) Successful in 1m11s
- Place workflow YAML in .workflows/ (dot-prefix convention)
- Do not run uwf workflow add — use directly via uwf thread start
- Fixes agent hallucination issue where registration was faked

小橘 <xiaoju@shazhou.work>
2026-05-28 09:58:52 +00:00
xiaoju 27d6062992 feat: normalize workflow v2 — JS/MJS project support
CI / check (push) Successful in 1m18s
- typescript role: detect .ts/.tsx files, skip tsc for pure JS/MJS projects
- testing role: vitest --passWithNoTests for empty test suites
- committer role: tsconfig.json optional in spot-check
- all roles: proper frontmatter schemas with repoPath

小橘 <xiaoju@shazhou.work>
2026-05-28 09:49:39 +00:00
xiaomo 625d975c3d docs: add per-step verification and self-check/upgrade section to BOOTSTRAP.md
CI / check (push) Successful in 1m13s
2026-05-28 09:44:26 +00:00
xiaomo b4919aa921 docs: add BOOTSTRAP.md and uwf skill for agent onboarding
CI / check (push) Successful in 1m58s
2026-05-28 09:42:38 +00:00
xiaoju 3a927de63f chore: normalize to bun monorepo conventions
CI / check (push) Successful in 1m41s
- Enhanced Biome config with test file override for noConsole
- Applied Biome auto-fixes (8 files: formatting, template literals, optional chains)
- Updated all package repository URLs to git.shazhou.work/uncaged/workflow.git
- Added workflow-agent-claude-code to publish order in scripts/publish-all.mjs
- Added --ignore-scripts flag to publish command to bypass prepublishOnly guard
- Installed vitest in root devDependencies for test infrastructure
- Created vitest.config.ts for all 8 packages with passWithNoTests: true
- Fixed 3 test files to use vitest imports instead of bun:test
- Added test and test:ci scripts to packages missing them
- Added missing build step to .gitea/workflows/ci.yml
- Renamed CI job from 'test' to 'check' for clarity
- Created workflows/solve-issue.yaml with TDD-driven issue resolution workflow
- Registered solve-issue workflow with uwf (hash: 084YVM60BR8G6)
- Added packageManager: bun@1.3.14 to root package.json
- Added preinstall guard to block npm/pnpm/yarn
- Added prepublishOnly guard to root and all 7 public packages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-28 08:02:52 +00:00
xiaomo 512a3f8653 refactor(skill): remove non-scenario skill commands, add navigation to user reference
CI / test (push) Failing after 2m49s
Removed: cli, architecture, yaml, moderator, actor skill subcommands
Kept: user, author, developer, adapter (scenario-based)
Added: scenario navigation table to user-reference.ts
2026-05-28 06:39:36 +00:00
xiaoju 0b20e88317 feat(cli): add currentRole field to thread show and thread list output (#572)
CI / test (push) Successful in 1m10s
Co-authored-by: 小橘 <xiaoju@shazhou.work>
Co-committed-by: 小橘 <xiaoju@shazhou.work>
2026-05-28 01:58:23 +00:00
104 changed files with 3378 additions and 420 deletions
+7 -6
View File
@@ -7,18 +7,19 @@ on:
branches: [main]
jobs:
test:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install
- run: bun install
- name: Check
- name: Build
run: bun run build
- name: Lint
run: bun run check
- name: Test
+66 -19
View File
@@ -23,6 +23,12 @@ 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:
@@ -30,7 +36,8 @@ roles:
$status: { const: "ready" }
plan: { type: string }
repoPath: { type: string }
required: [$status, plan, repoPath]
repoRemote: { type: string }
required: [$status, plan, repoPath, repoRemote]
- properties:
$status: { const: "insufficient_info" }
required: [$status]
@@ -61,6 +68,17 @@ 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.
@@ -71,6 +89,7 @@ roles:
$status: { const: "done" }
branch: { type: string }
worktree: { type: string }
repoRemote: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "failed" }
@@ -85,7 +104,12 @@ roles:
procedure: |
The worktree path is provided in your task prompt. cd into it first.
Before reviewing, verify the git branch:
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
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
@@ -109,11 +133,13 @@ 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"
@@ -137,33 +163,48 @@ 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 info are provided in your task prompt.
The worktree path, branch name, and repo remote (owner/repo) 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. Stage all changes: `git add -A`
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
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"`
3. Push the branch: `git push -u origin <branch-name>`
- 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:
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:
- 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)."
@@ -172,27 +213,33 @@ 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}}}." }
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}. Repo remote: {{{repoRemote}}}." }
developer:
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance. Repo remote: {{{repoRemote}}}." }
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
reviewer:
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}}}." }
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}}}." }
tester:
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}}}." }
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}}}." }
committer:
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit. Worktree: {{{worktree}}}. Repo remote: {{{repoRemote}}}." }
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow complete." }
+183
View File
@@ -0,0 +1,183 @@
# 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.
+3 -3
View File
@@ -13,7 +13,7 @@ This monorepo implements a stateless workflow engine driven by a single-step CLI
| **Role** | A named actor within a workflow. Each role has a system prompt and a JSON Schema `outputSchema`. |
| **Moderator** | Status-based graph evaluator — determines the next role (or `$END`) with zero LLM cost. |
| **Agent** | An external CLI command (`uwf-hermes`, etc.) spawned by `uwf thread step`. Produces frontmatter markdown output. |
| **CAS** | Content-Addressed Storage via `@uncaged/json-cas` — all workflow definitions, thread nodes, and outputs are immutable CAS nodes. |
| **CAS** | Content-Addressed Storage via `@ocas/core` — all workflow definitions, thread nodes, and outputs are immutable CAS nodes. |
| **Registry** | `~/.uncaged/workflow/registry.yaml` — maps workflow names to current CAS hashes. |
### Monorepo Structure
@@ -35,7 +35,7 @@ workflow/
- Dependency layers: `workflow-protocol``workflow-util``workflow-util-agent``workflow-agent-hermes` / `cli-workflow`
- Packages use `workspace:^` protocol (resolves to `^x.y.z` on publish)
- External CAS: `@uncaged/json-cas` (store API, hashing, schema validation) + `@uncaged/json-cas-fs` (filesystem backend)
- External CAS: `@ocas/core` (store API, hashing, schema validation) + `@ocas/fs` (filesystem backend)
## Language & Paradigm
@@ -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/workflow/cas/ — Workflow stored as CAS node
~/.uncaged/json-cas/ — Workflow stored as CAS node (unified CAS store)
~/.uncaged/workflow/registry.yaml — name → hash mapping updated
│ uwf thread start <name> -p "..."
+1 -1
View File
@@ -67,7 +67,7 @@ App (uses protocol; not in the runtime engine stack)
workflow-dashboard Web UI for visual workflow editing
```
External CAS: [`@uncaged/json-cas`](https://www.npmjs.com/package/@uncaged/json-cas) (store API, hashing, schema validation) + `@uncaged/json-cas-fs` (filesystem backend).
External CAS: [`@ocas/core`](https://www.npmjs.com/package/@ocas/core) (store API, hashing, schema validation) + `@ocas/fs` (filesystem backend).
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, CAS node types, storage layout, agent CLI protocol, and design decisions.
+3 -2
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
"files": {
"includes": [
"**",
@@ -39,7 +39,8 @@
"linter": {
"rules": {
"suspicious": {
"noExplicitAny": "off"
"noExplicitAny": "off",
"noConsole": "off"
},
"style": {
"noNonNullAssertion": "off"
+15 -15
View File
@@ -8,13 +8,13 @@
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions stored as CAS nodes; threads are immutable chains of CAS-linked step nodes. No daemon — each `uwf thread step` invocation runs one moderator→agent→extract cycle and exits.
The implementation lives in **5** active packages under `packages/`, plus two external CAS packages (`@uncaged/json-cas`, `@uncaged/json-cas-fs`). Legacy packages reside in `legacy-packages/` and are not part of the active stack.
The implementation lives in **5** active packages under `packages/`, plus two external CAS packages (`@ocas/core`, `@ocas/fs`). Legacy packages reside in `legacy-packages/` and are not part of the active stack.
## Package map
| Layer | Package | One-line role |
|-------|---------|---------------|
| Contract | `@uncaged/workflow-protocol``workflow-protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `ModeratorContext`, `WorkflowConfig`, etc.). No runtime deps beyond `@uncaged/json-cas-fs`. |
| Contract | `@uncaged/workflow-protocol``workflow-protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `ModeratorContext`, `WorkflowConfig`, etc.). No runtime deps beyond `@ocas/fs`. |
| Shared infra | `@uncaged/workflow-util``workflow-util` | Crockford Base32, ULID generation, `createLogger`, frontmatter parsing/validation. |
| Agent framework | `@uncaged/workflow-util-agent``workflow-util-agent` | `createAgent` entrypoint factory, context builder, frontmatter fast-path extractor, LLM extract fallback, output format instruction builder. |
| Agent: Hermes | `@uncaged/workflow-agent-hermes``workflow-agent-hermes` | `uwf-hermes` CLI binary — spawns `hermes chat`, pipes prompt, captures session detail. |
@@ -24,8 +24,8 @@ The implementation lives in **5** active packages under `packages/`, plus two ex
| Package | Role |
|---------|------|
| `@uncaged/json-cas` | Content-addressed store API, XXH64 hashing, JSON Schema registration and validation. |
| `@uncaged/json-cas-fs` | Filesystem backend for `json-cas`. |
| `@ocas/core` | Content-addressed store API, XXH64 hashing, JSON Schema registration and validation. |
| `@ocas/fs` | Filesystem backend for `ocas`. |
| `mustache` | Template renderer for edge prompts (used by `cli-workflow` moderator). |
| `commander` | CLI argument parsing (used by `cli-workflow`). |
| `dotenv` | Loads `.env` files for API keys. |
@@ -36,8 +36,8 @@ The implementation lives in **5** active packages under `packages/`, plus two ex
```mermaid
flowchart BT
subgraph External
jcas["@uncaged/json-cas"]
jcasfs["@uncaged/json-cas-fs"]
jcas["@ocas/core"]
jcasfs["@ocas/fs"]
end
subgraph L0["Layer 0 — contract"]
protocol["@uncaged/workflow-protocol"]
@@ -146,7 +146,7 @@ Key properties:
- **`roles`** — inline role definitions; each `meta` is a JSON Schema (stored as its own CAS node on registration)
- **`graph`** — `Record<Role | "$START", Record<Status, Target>>` — status-based routing; each role maps statuses to targets
- **No agent binding** — agent selection is a deployment concern, configured in `config.yaml`
- **No Zod** — all schemas are JSON Schema, validated through `@uncaged/json-cas`
- **No Zod** — all schemas are JSON Schema, validated through `@ocas/core`
## Three-phase engine loop
@@ -263,7 +263,7 @@ Structured output extraction uses a two-layer strategy (`workflow-util-agent`):
2. Validate required fields (`validateFrontmatter`)
3. Build a candidate object from frontmatter fields (`status`, `next`, `confidence`, `artifacts`, `scope`)
4. `store.put()` the candidate against the role's `meta` schema
5. Validate with `json-cas` schema validation
5. Validate with `ocas` schema validation
6. If valid → return `outputHash` (zero LLM cost)
### Layer 2: LLM extract fallback (`extract.ts`)
@@ -302,7 +302,7 @@ payload:
capabilities: [planning, issue-analysis]
procedure: "Analyze the issue and create a plan."
output: "Output the plan summary."
meta: "5GWKR8TN1V3JA" # cas_ref → JSON Schema node
meta: "5GWKR8TN1V3JA" # ocas_ref → JSON Schema node
conditions:
notApproved:
description: "Reviewer rejected"
@@ -318,7 +318,7 @@ payload:
```yaml
type: <start-node-schema-hash>
payload:
workflow: "4KNM2PXR3B1QW" # cas_ref → Workflow
workflow: "4KNM2PXR3B1QW" # ocas_ref → Workflow
prompt: "Fix the login bug..."
```
@@ -327,11 +327,11 @@ payload:
```yaml
type: <step-node-schema-hash>
payload:
start: "4TNVW8KR2B3MA" # cas_ref → StartNode
prev: "2MXBG6PN4A8JR" # cas_ref → previous StepNode (null for first step)
start: "4TNVW8KR2B3MA" # ocas_ref → StartNode
prev: "2MXBG6PN4A8JR" # ocas_ref → previous StepNode (null for first step)
role: "developer"
output: "9KRVW3TN5F1QA" # cas_ref → structured output (validated against meta schema)
detail: "7BQST3VW9F2MA" # cas_ref → execution detail (raw turns, session data)
output: "9KRVW3TN5F1QA" # ocas_ref → structured output (validated against meta schema)
detail: "7BQST3VW9F2MA" # ocas_ref → execution detail (raw turns, session data)
agent: "uwf-hermes" # agent command used (plain string)
```
@@ -484,7 +484,7 @@ Binary: `uwf`
| **Frontmatter markdown output** | Agents produce structured meta (YAML frontmatter) alongside free-form content (markdown body). Enables zero-cost extraction when frontmatter is well-formed. |
| **Two-layer extract** | Fast path avoids LLM calls when agents follow the format; LLM fallback handles messy output gracefully. |
| **Prompt injection for format** | Output format instruction prepended to system prompt ensures agents produce parseable output without per-agent configuration. |
| **JSON Schema (not Zod)** | Schemas are CAS-native data — storable, hashable, validatable through `json-cas`. No code generation, no runtime library dependency. |
| **JSON Schema (not Zod)** | Schemas are CAS-native data — storable, hashable, validatable through `ocas`. No code generation, no runtime library dependency. |
| **Agent as external command** | Agents are independent CLI binaries (`uwf-hermes`, `uwf-cursor`). Swappable per workflow/role via config. No tight coupling to the engine. |
| **No daemon** | Process starts, does one step, exits. Simpler failure model, no connection management. |
| **Crockford Base32** | Filesystem-safe, case-insensitive, readable, compact. |
+1 -1
View File
@@ -630,7 +630,7 @@ flowchart TB
Spawn -->|"stdout: step hash"| Step
```
**新包**:`packages/workflow-agent-builtin`,bin `uwf-builtin`,仅依赖 `workflow-util-agent`、`workflow-protocol`、`workflow-util`(可选 `@uncaged/json-cas` 写 detail schema)。
**新包**:`packages/workflow-agent-builtin`,bin `uwf-builtin`,仅依赖 `workflow-util-agent`、`workflow-protocol`、`workflow-util`(可选 `@ocas/core` 写 detail schema)。
**分层**:
+25 -25
View File
@@ -22,7 +22,7 @@ uwf workflow show <workflow-id> # 查看 workflow 定义
uwf workflow list # 列出已注册 workflows
```
两组对称,各 3-4 个子命令。CAS 操作交给 `json-cas` CLI,不在 `uwf` 中重复。
两组对称,各 3-4 个子命令。CAS 操作交给 `ocas` CLI,不在 `uwf` 中重复。
### 1.2 `uwf thread start`
@@ -136,14 +136,14 @@ uwf-hermes <thread-id> <role>
沿用 json-cas 的三层:bootstrap meta-schema → JSON Schema nodes → data nodes。
下面所有 CAS 节点都遵循 `{ type: cas_ref, payload: T, timestamp: number }` 的标准格式。
`cas_ref` 类型的字符串字段在 json-cas 中已内置支持,不需要额外的 `$ref` 包装。
下面所有 CAS 节点都遵循 `{ type: ocas_ref, payload: T, timestamp: number }` 的标准格式。
`ocas_ref` 类型的字符串字段在 ocas 中已内置支持,不需要额外的 `$ref` 包装。
### 2.2 数据节点
#### `Workflow`
Roles 和 moderator 内联在 Workflow 中,只有 meta 独立为 CAS 节点(方便 json-cas 校验)。
Roles 和 moderator 内联在 Workflow 中,只有 meta 独立为 CAS 节点(方便 ocas 校验)。
```yaml
type: <workflow-schema-hash>
@@ -157,21 +157,21 @@ payload:
capabilities: [planning, issue-analysis]
procedure: "Analyze the issue and create a plan."
output: "Output the plan summary."
meta: "5GWKR8TN1V3JA" # cas_ref → JSON Schema 节点(json-cas 内置)
meta: "5GWKR8TN1V3JA" # ocas_ref → JSON Schema 节点(ocas 内置)
developer:
description: "Implements code changes"
goal: "You are a developer agent..."
capabilities: [file-edit, shell]
procedure: "Implement the plan."
output: "List all files changed."
meta: "8CNWT4KR6D1HV" # cas_ref → JSON Schema 节点
meta: "8CNWT4KR6D1HV" # ocas_ref → JSON Schema 节点
reviewer:
description: "Reviews code changes"
goal: "You are a code reviewer..."
capabilities: [code-review]
procedure: "Review the implementation."
output: "Approve or reject with comments."
meta: "1VPBG9SM5E7WK" # cas_ref → JSON Schema 节点
meta: "1VPBG9SM5E7WK" # ocas_ref → JSON Schema 节点
conditions:
needsClarification:
description: "Planner requests clarification from user"
@@ -198,7 +198,7 @@ payload:
condition: null
```
- `roles` — 内联定义,每个 role 的 `meta` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
- `roles` — 内联定义,每个 role 的 `meta` 是独立的 ocas_ref(指向 ocas 内置 JSON Schema 节点)
- `graph``Record<Role | "$START", Record<Status, Target>>`,每个 Target = `{ role, prompt }`
- Status 来自上一个 role 输出的 `status` 字段,`$START``_` 作为初始 status
- Prompt 模板使用 Mustache 渲染,变量来自 lastOutput
@@ -220,7 +220,7 @@ evaluate(graph, lastRole, lastOutput) → { role, prompt }
```yaml
type: <start-node-schema-hash>
payload:
workflow: "4KNM2PXR3B1QW" # cas_ref → Workflow
workflow: "4KNM2PXR3B1QW" # ocas_ref → Workflow
prompt: "Fix the login bug..."
```
@@ -232,18 +232,18 @@ payload:
```yaml
type: <step-node-schema-hash>
payload:
start: "4TNVW8KR2B3MA" # cas_ref → StartNode(每个 step 都引用)
prev: "2MXBG6PN4A8JR" # cas_ref → 前一个 StepNode,第一步为 null
start: "4TNVW8KR2B3MA" # ocas_ref → StartNode(每个 step 都引用)
prev: "2MXBG6PN4A8JR" # ocas_ref → 前一个 StepNode,第一步为 null
role: "developer"
output: "9KRVW3TN5F1QA" # cas_ref → 结构化输出节点(符合 role 的 meta schema)
detail: "7BQST3VW9F2MA" # cas_ref → 执行详情(content node / 子 workflow terminal StepNode / ...)
output: "9KRVW3TN5F1QA" # ocas_ref → 结构化输出节点(符合 role 的 meta schema)
detail: "7BQST3VW9F2MA" # ocas_ref → 执行详情(content node / 子 workflow terminal StepNode / ...)
agent: "uwf-cursor" # 实际使用的 agent 命令(纯字符串)
```
- `start` — 每个 StepNode 都直接引用 StartNode,方便随机访问
- `prev` — 前一个 StepNode 的 cas_ref,第一步为 `null`(不指向 StartNode)
- `output` — cas_ref,指向符合 role meta schema 的 CAS 节点,可用 json-cas 校验
- `detail` — cas_ref,指向执行详情。可以是原始 agent 输出(content node),也可以是子 workflow thread 的 terminal StepNode(workflowAsAgent 场景)
- `prev` — 前一个 StepNode 的 ocas_ref,第一步为 `null`(不指向 StartNode)
- `output`ocas_ref,指向符合 role meta schema 的 CAS 节点,可用 ocas 校验
- `detail`ocas_ref,指向执行详情。可以是原始 agent 输出(content node),也可以是子 workflow thread 的 terminal StepNode(workflowAsAgent 场景)
- `agent` — 纯字符串,不是 CAS 节点
### 2.3 链式结构
@@ -337,7 +337,7 @@ OPENROUTER_API_KEY=sk-or-...
## 3. 包结构
全新包,不复用现有 packages,避免命名冲突。CAS 直接依赖 `@uncaged/json-cas`
全新包,不复用现有 packages,避免命名冲突。CAS 直接依赖 `@ocas/core`
```
packages/
@@ -349,8 +349,8 @@ packages/
```
**外部依赖:**
- `@uncaged/json-cas` — CAS 存储、hash、schema 校验
- `@uncaged/json-cas-fs` — 文件系统 CAS 后端
- `@ocas/core` — CAS 存储、hash、schema 校验
- `@ocas/fs` — 文件系统 CAS 后端
**现有包全部保留不动**,新旧并存,逐步迁移。
@@ -372,8 +372,8 @@ type ThreadId = string;
/** 一个 step 的核心数据,被 StepNode payload 和 moderator 上下文共享 */
type StepRecord = {
role: string;
output: CasRef; // cas_ref → 结构化输出节点(符合 role meta schema)
detail: CasRef; // cas_ref → 执行详情(content node / 子 workflow terminal StepNode)
output: CasRef; // ocas_ref → 结构化输出节点(符合 role meta schema)
detail: CasRef; // ocas_ref → 执行详情(content node / 子 workflow terminal StepNode)
agent: string; // 实际使用的 agent 命令(纯字符串)
};
```
@@ -387,7 +387,7 @@ type RoleDefinition = {
capabilities: string[];
procedure: string;
output: string;
meta: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
meta: CasRef; // ocas_ref → ocas 内置 JSON Schema 节点
};
type Target = {
@@ -407,13 +407,13 @@ type WorkflowPayload = {
```typescript
type StartNodePayload = {
workflow: CasRef; // cas_ref → Workflow
workflow: CasRef; // ocas_ref → Workflow
prompt: string;
};
type StepNodePayload = StepRecord & {
start: CasRef; // cas_ref → StartNode(每个 step 都引用)
prev: CasRef | null; // cas_ref → 前一个 StepNode,第一步为 null
start: CasRef; // ocas_ref → StartNode(每个 step 都引用)
prev: CasRef | null; // ocas_ref → 前一个 StepNode,第一步为 null
};
```
+45 -9
View File
@@ -8,22 +8,46 @@ roles:
- issue-analysis
- planning
procedure: |
On first run (no previous steps):
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):**
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
On subsequent runs (bounced back by tester with fix_spec):
**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):**
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
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."
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."
frontmatter:
oneOf:
- properties:
@@ -31,6 +55,13 @@ 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]
@@ -49,10 +80,14 @@ 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 bounced back from reviewer or tester (branch already exists):
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):
- cd into the existing worktree under `.worktrees/fix/<issue-number>-<short-slug>`
- `git fetch origin && git rebase origin/main`
5. ALL subsequent work must happen inside the worktree directory.
6. 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)
@@ -183,6 +218,7 @@ 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." }
+7 -1
View File
@@ -1,11 +1,14 @@
{
"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",
@@ -23,7 +26,10 @@
"@types/node": "^25.7.0",
"@types/xxhashjs": "^0.2.4",
"@uncaged/workflow-agent-hermes": "workspace:*",
"bun-types": "^1.3.13"
"bun-types": "^1.3.13",
"typescript": "^5.8.3",
"vitest": "^4.1.7",
"yaml": "^2.9.0"
},
"repository": {
"type": "git",
+11 -2
View File
@@ -20,7 +20,7 @@ workflow → thread → step → turn
This package has no library `src/index.ts` — it is consumed as a CLI binary only.
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`, `@uncaged/workflow-util-agent`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`, `commander`, `dotenv`, `mustache`, `yaml`
**Dependencies:** `@ocas/core`, `@ocas/fs`, `@uncaged/workflow-util-agent`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`, `commander`, `dotenv`, `mustache`, `yaml`
## Installation
@@ -209,4 +209,13 @@ 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/workflow/cas/` | Content-addressed node storage |
| `~/.uncaged/json-cas/` | Content-addressed node storage (unified CAS store, shared with `ocas` 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` |
+6 -5
View File
@@ -11,8 +11,8 @@
"uwf": "./dist/cli.js"
},
"dependencies": {
"@uncaged/json-cas": "^0.5.3",
"@uncaged/json-cas-fs": "^0.5.3",
"@ocas/core": "^0.1.1",
"@ocas/fs": "^0.1.1",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^",
@@ -22,6 +22,7 @@
"yaml": "^2.8.4"
},
"scripts": {
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
"test": "vitest run",
"test:ci": "vitest run"
},
@@ -34,12 +35,12 @@
},
"repository": {
"type": "git",
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
"url": "https://git.shazhou.work/uncaged/workflow.git",
"directory": "packages/cli-workflow"
},
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
"bugs": {
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
"url": "https://git.shazhou.work/uncaged/workflow/issues"
},
"license": "MIT"
}
@@ -2,8 +2,8 @@ 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 { putSchema } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { registerUwfSchemas } from "../schemas.js";
@@ -129,7 +129,11 @@ describe("C1: adapter JSON round-trip integration", () => {
{
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, WORKFLOW_STORAGE_ROOT: tmpDir },
env: {
...process.env,
WORKFLOW_STORAGE_ROOT: tmpDir,
UNCAGED_CAS_DIR: casDir,
},
cwd: tmpDir,
timeout: 30000,
},
@@ -6,14 +6,22 @@ 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");
@@ -21,6 +29,13 @@ 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 = {
@@ -32,7 +47,11 @@ type ExecResult = {
function execUwf(args: string[]): ExecResult {
try {
const stdout = execSync(`bun ${uwfPath} ${args.join(" ")}`, {
env: { ...process.env, WORKFLOW_STORAGE_ROOT: storageRoot },
env: {
...process.env,
WORKFLOW_STORAGE_ROOT: storageRoot,
UNCAGED_CAS_DIR: casDir,
},
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});
@@ -720,7 +720,7 @@ defaultModel: default
describe("no legacy apiKeyEnv references", () => {
test("config.ts has no references to apiKeyEnv", () => {
const configSource = readFileSync(join(__dirname, "..", "commands", "config.ts"), "utf8");
const configSource = readFileSync(join(__dirname, "..", "..", "src", "commands", "config.ts"), "utf8");
expect(configSource).not.toContain("apiKeyEnv");
});
@@ -0,0 +1,456 @@
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { putSchema } from "@ocas/core";
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();
}
});
});
@@ -0,0 +1,84 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { parse } from "yaml";
import { createIncludeTag } from "../include.js";
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "include-tag-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
describe("!include tag", () => {
test("includes .md file as string", async () => {
await writeFile(join(tmpDir, "prompt.md"), "You are an analyst.");
const yaml = "system: !include prompt.md";
const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] });
expect(result.system).toBe("You are an analyst.");
});
test("includes .json file as parsed object", async () => {
await writeFile(join(tmpDir, "schema.json"), '{"type":"object","properties":{}}');
const yaml = "outputSchema: !include schema.json";
const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] });
expect(result.outputSchema).toEqual({ type: "object", properties: {} });
});
test("includes .yaml file as parsed object", async () => {
await writeFile(join(tmpDir, "config.yaml"), "key: value\nlist:\n - a\n - b");
const yaml = "config: !include config.yaml";
const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] });
expect(result.config).toEqual({ key: "value", list: ["a", "b"] });
});
test("resolves relative subdirectory paths", async () => {
const subdir = join(tmpDir, "roles");
await mkdir(subdir, { recursive: true });
await writeFile(join(subdir, "analyst.md"), "Analyze data.");
const yaml = "system: !include roles/analyst.md";
const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] });
expect(result.system).toBe("Analyze data.");
});
test("throws on missing file", () => {
const yaml = "system: !include nonexistent.md";
expect(() => parse(yaml, { customTags: [createIncludeTag(tmpDir)] })).toThrow();
});
test("includes .txt file as string", async () => {
await writeFile(join(tmpDir, "note.txt"), "Hello world");
const yaml = "note: !include note.txt";
const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] });
expect(result.note).toBe("Hello world");
});
test("blocks path traversal with ../", async () => {
const yaml = "secret: !include ../../etc/passwd";
expect(() => parse(yaml, { customTags: [createIncludeTag(tmpDir)] })).toThrow(
/path traversal blocked/,
);
});
test("blocks absolute path traversal", async () => {
const yaml = "secret: !include /etc/passwd";
expect(() => parse(yaml, { customTags: [createIncludeTag(tmpDir)] })).toThrow(
/path traversal blocked/,
);
});
test("supports nested !include in yaml files", async () => {
const subdir = join(tmpDir, "parts");
await mkdir(subdir, { recursive: true });
await writeFile(join(subdir, "inner.md"), "nested content");
await writeFile(join(tmpDir, "outer.yaml"), "value: !include parts/inner.md");
const yaml = "config: !include outer.yaml";
const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] });
expect(result.config).toEqual({ value: "nested content" });
});
});
@@ -6,27 +6,17 @@ 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");
@@ -36,50 +26,6 @@ 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");
@@ -126,11 +72,6 @@ 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
* includes the --repo flag when running tea pr create, which fixes
* the "path segment [0] is empty" error in worktree directories.
* uses direct Gitea API calls via curl instead of tea pr create,
* which fixes the "path segment [0] is empty" error in worktree directories.
*/
describe("solve-issue workflow: tea pr create worktree fix", () => {
describe("solve-issue workflow: Gitea API PR creation", () => {
// 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: tea pr create worktree fix", () => {
"solve-issue.yaml",
);
test("committer procedure should include --repo flag in tea pr create command", async () => {
test("committer procedure should use curl API instead of tea pr create", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
const workflow = parse(yamlContent) as WorkflowPayload;
@@ -32,43 +32,38 @@ describe("solve-issue workflow: tea pr create worktree fix", () => {
const committerProcedure = workflow.roles.committer?.procedure;
expect(committerProcedure).toBeDefined();
// Verify the procedure includes tea pr create with --repo flag
expect(committerProcedure).toContain("tea pr create");
expect(committerProcedure).toContain("--repo");
// 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 --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");
}
// Verify it explicitly warns against tea pr create
expect(committerProcedure).toMatch(/do NOT use.*tea pr create/i);
});
test("committer procedure should mention repo extraction from git remote", async () => {
test("committer procedure should reference repoRemote from task prompt", 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 extracting repo info from git remote
// This ensures fallback logic is documented
expect(committerProcedure).toMatch(/git remote/i);
// 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);
});
test("committer procedure should include error handling for tea failures", async () => {
test("committer procedure should include error handling for curl 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
// Verify the procedure includes error handling guidance for curl
// 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 () => {
@@ -98,9 +93,51 @@ describe("solve-issue workflow: tea pr create worktree fix", () => {
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");
});
});
@@ -56,7 +56,7 @@ const VALID_OUTPUT: AdapterOutput = {
describe("spawnAgent JSON parsing", () => {
test("B1. parses valid JSON from agent stdout", () => {
const stdout = JSON.stringify(VALID_OUTPUT) + "\n";
const stdout = `${JSON.stringify(VALID_OUTPUT)}\n`;
const result = parseAgentStdout(stdout);
expect(result.stepHash).toBe("0123456789ABC");
expect(result.detailHash).toBe("DEFGH12345678");
@@ -68,7 +68,7 @@ describe("spawnAgent JSON parsing", () => {
});
test("B2. extracts stepHash for head pointer", () => {
const stdout = JSON.stringify(VALID_OUTPUT) + "\n";
const stdout = `${JSON.stringify(VALID_OUTPUT)}\n`;
const result = parseAgentStdout(stdout);
expect(result.stepHash).toBe("0123456789ABC");
expect(isCasRef(result.stepHash)).toBe(true);
@@ -76,7 +76,7 @@ describe("spawnAgent JSON parsing", () => {
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 stdout = `${debugLines + JSON.stringify(VALID_OUTPUT)}\n`;
const result = parseAgentStdout(stdout);
expect(result.stepHash).toBe("0123456789ABC");
});
@@ -88,13 +88,13 @@ describe("spawnAgent JSON parsing", () => {
test("B5. rejects JSON missing stepHash", () => {
const incomplete = { detailHash: "DEFGH12345678", role: "planner" };
const stdout = JSON.stringify(incomplete) + "\n";
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";
const stdout = `${JSON.stringify(bad)}\n`;
expect(() => parseAgentStdout(stdout)).toThrow("missing valid stepHash");
});
});
@@ -1,8 +1,8 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import { bootstrap, putSchema } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import type { CasRef } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdStepRead } from "../commands/step.js";
@@ -40,7 +40,7 @@ const DETAIL_SCHEMA = {
turnCount: { type: "integer" as const },
turns: {
type: "array" as const,
items: { type: "string" as const, format: "cas_ref" },
items: { type: "string" as const, format: "ocas_ref" },
},
},
additionalProperties: false,
@@ -66,13 +66,21 @@ 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 ───────────────────────────────────────────────────────────
@@ -80,7 +88,10 @@ 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);
@@ -146,10 +157,11 @@ describe("step read", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
// Read step with large quota
const markdown = await cmdStepRead(tmpDir, stepHash, 10000);
const markdown = await cmdStepRead(tmpDir, stepHash, 10000, false);
// Assert structure
expect(markdown).toContain(`# Step ${stepHash}`);
@@ -165,7 +177,9 @@ 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);
@@ -231,10 +245,11 @@ 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);
const markdown = await cmdStepRead(tmpDir, stepHash, 700, false);
// Assert only most recent turns fit
expect(markdown).toContain(`# Step ${stepHash}`);
@@ -248,7 +263,9 @@ 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);
@@ -310,10 +327,11 @@ 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);
const markdown = await cmdStepRead(tmpDir, stepHash, 1, false);
// Assert at least one turn is always shown
expect(markdown).toContain("LongTurn");
@@ -322,7 +340,9 @@ 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);
@@ -365,10 +385,11 @@ 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);
const markdown = await cmdStepRead(tmpDir, stepHash, 4000, false);
// Assert metadata is present
expect(markdown).toContain(`# Step ${stepHash}`);
@@ -380,7 +401,9 @@ 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);
@@ -441,10 +464,11 @@ 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);
const markdown = await cmdStepRead(tmpDir, stepHash, 4000, false);
// Assert metadata is present
expect(markdown).toContain(`# Step ${stepHash}`);
@@ -455,7 +479,9 @@ 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);
@@ -515,9 +541,10 @@ describe("step read", () => {
agent: "uwf-hermes",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
const markdown = await cmdStepRead(tmpDir, stepHash, 4000, false);
expect(markdown).toContain("**Turn role:** assistant");
expect(markdown).toContain("**terminal**");
@@ -526,7 +553,9 @@ 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);
@@ -588,10 +617,11 @@ describe("step read", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
// Read step
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
const markdown = await cmdStepRead(tmpDir, stepHash, 4000, false);
// Assert content is rendered correctly without corruption
expect(markdown).toContain("`backticks`");
@@ -1,8 +1,8 @@
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 { bootstrap, type Hash, type JSONSchema, putSchema } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import type { CasRef, StepNodePayload } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdStepShow } from "../commands/step.js";
@@ -45,7 +45,7 @@ const DETAIL_SCHEMA: JSONSchema = {
properties: {
turns: {
type: "array",
items: { type: "string", format: "cas_ref" },
items: { type: "string", format: "ocas_ref" },
},
},
additionalProperties: false,
@@ -116,6 +116,7 @@ async function createTestStep(
edgePrompt: "",
startedAtMs: Date.now(),
completedAtMs: Date.now() + 1000,
assembledPrompt: null,
cwd: "/tmp",
};
return store.put(schemas.stepNode, stepPayload);
@@ -124,15 +125,23 @@ async function createTestStep(
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 () => {
@@ -1,8 +1,8 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import { bootstrap, putSchema } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { STEP_NODE_SCHEMA } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
@@ -43,7 +43,7 @@ const DETAIL_SCHEMA = {
turnCount: { type: "integer" as const },
turns: {
type: "array" as const,
items: { type: "string" as const, format: "cas_ref" },
items: { type: "string" as const, format: "ocas_ref" },
},
},
additionalProperties: false,
@@ -63,13 +63,22 @@ 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) ─────────────────────────────────────────
@@ -85,6 +94,7 @@ describe("protocol types", () => {
edgePrompt: "",
startedAtMs: 1000,
completedAtMs: 2000,
assembledPrompt: null,
cwd: "/test/path",
};
expect(record.startedAtMs).toBe(1000);
@@ -153,6 +163,7 @@ describe("StepNode JSON schema", () => {
edgePrompt: "",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
expect(hash).toBeTruthy();
});
@@ -0,0 +1,224 @@
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,6 +15,8 @@ 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);
}
@@ -9,17 +9,31 @@ 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 () => {
@@ -1,8 +1,8 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import { bootstrap, putSchema } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdThreadRead } from "../commands/thread.js";
@@ -41,7 +41,7 @@ const DETAIL_SCHEMA = {
turnCount: { type: "integer" as const },
turns: {
type: "array" as const,
items: { type: "string" as const, format: "cas_ref" },
items: { type: "string" as const, format: "ocas_ref" },
},
},
additionalProperties: false,
@@ -67,13 +67,22 @@ 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 ─────────────────────────────────────────────
@@ -143,6 +152,7 @@ describe("thread read --quota flag", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
steps.push(stepHash);
}
@@ -225,6 +235,7 @@ describe("thread read --quota flag", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const step2Content = generateContent(600, "Second");
@@ -251,6 +262,7 @@ describe("thread read --quota flag", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01HX2Q3R4S5T6V7W8X9YZ1" as ThreadId;
@@ -336,6 +348,7 @@ describe("thread read --quota flag", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
steps.push(stepHash);
}
@@ -415,6 +428,7 @@ describe("thread read --quota flag", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01HX2Q3R4S5T6V7W8X9YZ4" as ThreadId;
@@ -492,6 +506,7 @@ describe("thread read --quota flag", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
steps.push(stepHash);
}
@@ -573,6 +588,7 @@ describe("thread read --quota flag", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
steps.push(stepHash);
}
@@ -1,8 +1,8 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import { bootstrap, putSchema } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdThreadRead, THREAD_READ_DEFAULT_QUOTA } from "../commands/thread.js";
@@ -42,7 +42,7 @@ const DETAIL_SCHEMA = {
turnCount: { type: "integer" as const },
turns: {
type: "array" as const,
items: { type: "string" as const, format: "cas_ref" },
items: { type: "string" as const, format: "ocas_ref" },
},
},
additionalProperties: false,
@@ -53,6 +53,8 @@ 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 };
@@ -141,6 +143,7 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-claude-code",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01JTEST0000000000000001" as ThreadId;
@@ -218,6 +221,7 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-claude-code",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01JTEST0000000000000002" as ThreadId;
@@ -280,6 +284,7 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
@@ -291,6 +296,7 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01JTEST0000000000000003" as ThreadId;
@@ -345,6 +351,7 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01JTEST0000000000000004" as ThreadId;
@@ -399,6 +406,7 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01JTEST0000000000000005" as ThreadId;
@@ -453,6 +461,7 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01JTEST0000000000000006" as ThreadId;
@@ -527,6 +536,7 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
@@ -538,6 +548,7 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const step3 = await uwf.store.put(uwf.schemas.stepNode, {
@@ -549,6 +560,7 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01JTEST0000000000000007" as ThreadId;
@@ -629,6 +641,7 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01JTEST0000000000000008" as ThreadId;
@@ -685,6 +698,7 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
})) as CasRef;
steps.push(step);
prev = step;
@@ -10,17 +10,31 @@ 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> {
@@ -123,7 +137,7 @@ graph:
// Register the workflow
execFileSync("node", [uwfBin, "workflow", "add", workflowPath], {
env: { ...process.env, UWF_STORAGE_ROOT: storageRoot },
env: { ...process.env, UWF_STORAGE_ROOT: storageRoot, UNCAGED_CAS_DIR: casDir },
encoding: "utf8",
});
@@ -132,7 +146,7 @@ graph:
"node",
[uwfBin, "thread", "start", "test-cwd-cli", "-p", "test prompt", "--cwd", testCwd],
{
env: { ...process.env, UWF_STORAGE_ROOT: storageRoot },
env: { ...process.env, UWF_STORAGE_ROOT: storageRoot, UNCAGED_CAS_DIR: casDir },
encoding: "utf8",
},
);
@@ -1,8 +1,8 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import { bootstrap, putSchema } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdStepList, cmdStepShow } from "../commands/step.js";
@@ -47,7 +47,7 @@ const DETAIL_SCHEMA = {
turnCount: { type: "integer" as const },
turns: {
type: "array" as const,
items: { type: "string" as const, format: "cas_ref" },
items: { type: "string" as const, format: "ocas_ref" },
},
},
additionalProperties: false,
@@ -58,6 +58,8 @@ 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 };
@@ -1,7 +1,7 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createFsStore } from "@uncaged/json-cas-fs";
import { createFsStore } from "@ocas/fs";
import type { CasRef, WorkflowPayload } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { stringify } from "yaml";
@@ -15,6 +15,8 @@ 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 };
@@ -255,6 +257,49 @@ describe("Strategy 3: Local Discovery", () => {
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("should find workflow in folder-based layout (name/index.yaml)", async () => {
await makeUwfStore(storageRoot);
const workflowDir = join(projectRoot, ".workflow", "solve-issue");
await mkdir(workflowDir, { recursive: true });
await writeFile(join(workflowDir, "index.yaml"), await createWorkflowYaml("solve-issue"));
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot);
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
const uwf = await makeUwfStore(storageRoot);
const node = uwf.store.get(result.workflow);
expect(node).not.toBeNull();
if (node !== null) {
expect((node.payload as WorkflowPayload).name).toBe("solve-issue");
}
});
test("should prefer flat file over folder-based layout", async () => {
await makeUwfStore(storageRoot);
const workflowDir = join(projectRoot, ".workflow");
await mkdir(workflowDir, { recursive: true });
await writeFile(
join(workflowDir, "solve-issue.yaml"),
await createWorkflowYaml("solve-issue", "flat"),
);
const folderDir = join(workflowDir, "solve-issue");
await mkdir(folderDir, { recursive: true });
await writeFile(
join(folderDir, "index.yaml"),
await createWorkflowYaml("solve-issue", "folder"),
);
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot);
const uwf = await makeUwfStore(storageRoot);
const node = uwf.store.get(result.workflow);
expect(node).not.toBeNull();
if (node !== null) {
expect((node.payload as WorkflowPayload).description).toBe("Test workflow (flat)");
}
});
});
// ── Strategy 4: Global Registry Fallback ──────────────────────────────────────
+16 -42
View File
@@ -17,16 +17,12 @@ 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,
cmdSkillCli,
cmdSkillBootstrap,
cmdSkillDeveloper,
cmdSkillList,
cmdSkillModerator,
cmdSkillUser,
cmdSkillYaml,
} from "./commands/skill.js";
import { cmdStepFork, cmdStepList, cmdStepRead, cmdStepShow } from "./commands/step.js";
import {
@@ -368,7 +364,8 @@ 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")
.action((stepHash: string, opts: { quota: string }) => {
.option("--prompt", "Show the assembled prompt sent to the agent instead of turns")
.action((stepHash: string, opts: { quota: string; prompt: boolean }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const quota = Number.parseInt(opts.quota, 10);
@@ -376,7 +373,12 @@ step
process.stderr.write("invalid --quota: must be a positive integer\n");
process.exit(1);
}
const markdown = await cmdStepRead(storageRoot, stepHash as CasRef, quota);
const markdown = await cmdStepRead(
storageRoot,
stepHash as CasRef,
quota,
opts.prompt === true,
);
process.stdout.write(markdown.endsWith("\n") ? markdown : `${markdown}\n`);
});
});
@@ -493,34 +495,6 @@ 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)")
@@ -542,13 +516,6 @@ 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)")
@@ -556,6 +523,13 @@ 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")
+9 -5
View File
@@ -1,9 +1,9 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { JSONSchema, Store } from "@uncaged/json-cas";
import { bootstrap, getSchema, putSchema, refs, walk } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { JSONSchema, Store } from "@ocas/core";
import { bootstrap, getSchema, putSchema, refs, walk } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import { TEXT_SCHEMA } from "../schemas.js";
@@ -85,13 +85,17 @@ export type SchemaListEntry = {
export async function cmdCasSchemaList(storageRoot: string): Promise<SchemaListEntry[]> {
const store = openStore(storageRoot);
const metaHash = await bootstrap(store);
const aliases = await bootstrap(store);
const metaHash = aliases["@ocas/schema"];
if (metaHash === undefined) {
throw new Error("Meta-schema not found in bootstrap result");
}
const entries: SchemaListEntry[] = [];
// Include meta-schema itself
entries.push({ hash: metaHash, title: "(meta-schema)" });
for (const hash of store.listByType(metaHash)) {
for (const { hash } of store.listByType(metaHash)) {
if (hash === metaHash) continue;
const node = store.get(hash);
if (node !== null) {
+5 -5
View File
@@ -1,5 +1,5 @@
import type { Store as CasStore, JSONSchema } from "@uncaged/json-cas";
import { getSchema } from "@uncaged/json-cas";
import type { Store as CasStore, JSONSchema } from "@ocas/core";
import { getSchema } from "@ocas/core";
import type {
CasRef,
StartNodePayload,
@@ -88,7 +88,7 @@ function expandOutput(uwf: UwfStore, outputRef: CasRef): unknown {
}
/**
* Recursively expand all cas_ref fields in a CAS node's payload,
* Recursively expand all ocas_ref fields in a CAS node's payload,
* replacing hash strings with the referenced node's expanded payload.
*/
function expandDeep(store: CasStore, hash: CasRef, visited?: Set<string>): unknown {
@@ -120,7 +120,7 @@ function expandAnyOfField(
): unknown {
if (!Array.isArray(schema.anyOf)) return value;
for (const sub of schema.anyOf as JSONSchema[]) {
if (sub.format === "cas_ref" && typeof value === "string") {
if (sub.format === "ocas_ref" && typeof value === "string") {
return expandDeep(store, value as CasRef, visited);
}
}
@@ -163,7 +163,7 @@ function expandValue(
value: unknown,
visited: Set<string>,
): unknown {
if (schema.format === "cas_ref") return expandCasRefField(store, value, visited);
if (schema.format === "ocas_ref") return expandCasRefField(store, value, visited);
if (Array.isArray(schema.anyOf)) return expandAnyOfField(store, schema, value, visited);
if (schema.type === "array") return expandArrayField(store, schema, value, visited);
return expandObjectField(store, schema, value, visited);
+2 -16
View File
@@ -1,26 +1,12 @@
export {
generateActorReference as cmdSkillActor,
generateAdapterReference as cmdSkillAdapter,
generateArchitectureReference as cmdSkillArchitecture,
generateAuthorReference as cmdSkillAuthor,
generateCliReference as cmdSkillCli,
generateBootstrapReference as cmdSkillBootstrap,
generateDeveloperReference as cmdSkillDeveloper,
generateModeratorReference as cmdSkillModerator,
generateUserReference as cmdSkillUser,
generateYamlReference as cmdSkillYaml,
} from "@uncaged/workflow-util";
const SKILL_NAMES = [
"cli",
"architecture",
"yaml",
"moderator",
"actor",
"user",
"author",
"developer",
"adapter",
] as const;
const SKILL_NAMES = ["user", "author", "developer", "adapter", "bootstrap"] as const;
export function cmdSkillList(): ReadonlyArray<string> {
return [...SKILL_NAMES];
+19 -1
View File
@@ -1,4 +1,4 @@
import type { BootstrapCapableStore } from "@uncaged/json-cas";
import type { BootstrapCapableStore } from "@ocas/core";
import type {
CasRef,
StartEntry,
@@ -289,6 +289,7 @@ 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);
@@ -300,6 +301,23 @@ 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, [], []);
}
+54 -3
View File
@@ -1,7 +1,7 @@
import { execFileSync, spawn } from "node:child_process";
import { access, readFile } from "node:fs/promises";
import { dirname, isAbsolute, resolve as resolvePath } from "node:path";
import { validate } from "@uncaged/json-cas";
import { validate } from "@ocas/core";
import type {
AgentAlias,
AgentConfig,
@@ -28,6 +28,7 @@ import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-util-agent";
import { config as loadDotenv } from "dotenv";
import { parse } from "yaml";
import { createMarker, deleteMarker, isThreadRunning } from "../background/index.js";
import { createIncludeTag } from "../include.js";
import { evaluate } from "../moderator/index.js";
import {
appendThreadHistory,
@@ -57,6 +58,21 @@ 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";
@@ -103,6 +119,15 @@ async function findWorkflowInDir(dir: string, name: string): Promise<string | nu
return result;
}
}
for (const indexName of ["index.yaml", "index.yml"]) {
const candidate = resolvePath(dir, ".workflow", name, indexName);
try {
await access(candidate);
return candidate;
} catch {
/* not found */
}
}
// Check .workflows/ directory as fallback (legacy)
for (const ext of [".yaml", ".yml"]) {
@@ -111,6 +136,15 @@ async function findWorkflowInDir(dir: string, name: string): Promise<string | nu
return result;
}
}
for (const indexName of ["index.yaml", "index.yml"]) {
const candidate = resolvePath(dir, ".workflows", name, indexName);
try {
await access(candidate);
return candidate;
} catch {
/* not found */
}
}
return null;
}
@@ -157,7 +191,7 @@ async function materializeLocalWorkflow(uwf: UwfStore, filePath: string): Promis
let raw: unknown;
try {
raw = parse(text) as unknown;
raw = parse(text, { customTags: [createIncludeTag(dirname(filePath))] }) as unknown;
} catch (e) {
fail(`invalid YAML in ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
}
@@ -321,12 +355,14 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
// 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,
};
@@ -341,6 +377,7 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
thread: threadId,
head: hist.head,
status,
currentRole: null,
done: true,
background: null,
};
@@ -351,6 +388,7 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
export type ThreadListItemWithStatus = ThreadListItem & {
status: ThreadStatus;
currentRole: string | null;
};
async function threadListItemFromActive(
@@ -368,7 +406,13 @@ async function threadListItemFromActive(
const runningMarker = await isThreadRunning(storageRoot, threadId);
const status: ThreadStatus = runningMarker !== null ? "running" : "idle";
return { thread: threadId, workflow, head, status };
return {
thread: threadId,
workflow,
head,
status,
currentRole: resolveCurrentRole(uwf, head, workflow),
};
}
async function collectActiveThreads(
@@ -406,6 +450,7 @@ async function collectCompletedThreads(
workflow: entry.workflow,
head: entry.head,
status: entry.reason === "cancelled" ? "cancelled" : "completed",
currentRole: null,
});
}
}
@@ -938,6 +983,8 @@ 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) {
@@ -969,6 +1016,7 @@ async function cmdThreadStepBackground(
thread: threadId,
head: headHash,
status: "running",
currentRole: resolveCurrentRole(uwf, headHash, workflowHash),
done: false,
background: true,
},
@@ -1012,6 +1060,7 @@ async function cmdThreadStepOnce(
thread: threadId,
head: headHash,
status: "completed",
currentRole: null,
done: true,
background: null,
};
@@ -1067,12 +1116,14 @@ async function cmdThreadStepOnce(
// 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,
};
@@ -1,9 +1,11 @@
import { readFile } from "node:fs/promises";
import { dirname, resolve as resolvePath } from "node:path";
import type { JSONSchema } from "@uncaged/json-cas";
import { putSchema, validate } from "@uncaged/json-cas";
import type { JSONSchema } from "@ocas/core";
import { putSchema, validate } from "@ocas/core";
import type { CasRef, RoleDefinition, Target, WorkflowPayload } from "@uncaged/workflow-protocol";
import { parse } from "yaml";
import { createIncludeTag } from "../include.js";
import {
createUwfStore,
@@ -123,7 +125,9 @@ export async function cmdWorkflowAdd(
let raw: unknown;
try {
raw = parse(text) as unknown;
raw = parse(text, {
customTags: [createIncludeTag(dirname(resolvePath(filePath)))],
}) as unknown;
} catch (e) {
fail(`invalid YAML: ${e instanceof Error ? e.message : String(e)}`);
}
+37
View File
@@ -0,0 +1,37 @@
import { readFileSync } from "node:fs";
import { dirname, extname, resolve } from "node:path";
import { parse as parseYaml } from "yaml";
/**
* Create a YAML customTags entry for !include that resolves file paths
* relative to the given base directory.
*
* Security: resolved paths must stay within baseDir (path traversal prevention).
* Nested !include in .yaml/.yml files is supported (customTags passed recursively).
*/
export function createIncludeTag(baseDir: string) {
const resolvedBase = resolve(baseDir);
return {
tag: "!include",
resolve(str: string) {
const filePath = resolve(resolvedBase, str);
// Path traversal guard: resolved path must be inside baseDir
if (!filePath.startsWith(`${resolvedBase}/`) && filePath !== resolvedBase) {
throw new Error(
`!include path traversal blocked: "${str}" resolves outside base directory`,
);
}
const content = readFileSync(filePath, "utf8");
const ext = extname(filePath).toLowerCase();
if (ext === ".json") {
return JSON.parse(content);
}
if (ext === ".yaml" || ext === ".yml") {
// Pass customTags recursively so nested !include works,
// scoped to the included file's directory
return parseYaml(content, { customTags: [createIncludeTag(dirname(filePath))] });
}
return content;
},
};
}
+2 -2
View File
@@ -1,5 +1,5 @@
import type { Hash, Store } from "@uncaged/json-cas";
import { putSchema } from "@uncaged/json-cas";
import type { Hash, Store } from "@ocas/core";
import { putSchema } from "@ocas/core";
import { START_NODE_SCHEMA, STEP_NODE_SCHEMA, WORKFLOW_SCHEMA } from "@uncaged/workflow-protocol";
export const TEXT_SCHEMA = { type: "string" as const };
+78 -17
View File
@@ -1,9 +1,10 @@
import { appendFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import type { Dirent } from "node:fs";
import { access, appendFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import type { BootstrapCapableStore, Hash } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { BootstrapCapableStore, Hash } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import type { CasRef, ThreadId, ThreadListItem, ThreadsIndex } from "@uncaged/workflow-protocol";
import { parse, stringify } from "yaml";
@@ -19,17 +20,38 @@ export type ProjectWorkflowEntry = {
filePath: string;
};
/** Extract workflow name from a YAML filename (strip .yaml/.yml extension). */
function stemFromYaml(name: string): string {
if (name.endsWith(".yaml")) return name.slice(0, -5);
if (name.endsWith(".yml")) return name.slice(0, -4);
return name;
}
/** Check if a directory contains an index.yaml or index.yml workflow file. */
async function findIndexWorkflow(
dir: string,
dirName: string,
): Promise<ProjectWorkflowEntry | null> {
for (const indexName of ["index.yaml", "index.yml"]) {
const indexPath = join(dir, dirName, indexName);
try {
await access(indexPath);
return { name: dirName, filePath: indexPath };
} catch {
// not found, try next
}
}
return null;
}
/**
* Scan `<projectRoot>/.workflows/*.yaml` (non-recursive) and return discovered entries.
* Returns an empty array if the directory does not exist.
* Scan a single directory for workflow entries (flat YAML files + folder/index.yaml).
* Returns discovered entries. Returns empty array if directory does not exist.
*/
export async function discoverProjectWorkflows(
projectRoot: string,
): Promise<ProjectWorkflowEntry[]> {
const dir = join(projectRoot, ".workflows");
let entries: string[];
async function scanWorkflowDir(dir: string): Promise<ProjectWorkflowEntry[]> {
let dirents: Dirent[];
try {
entries = await readdir(dir);
dirents = await readdir(dir, { withFileTypes: true });
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
@@ -39,16 +61,39 @@ export async function discoverProjectWorkflows(
}
const result: ProjectWorkflowEntry[] = [];
for (const entry of entries) {
if (!entry.endsWith(".yaml") && !entry.endsWith(".yml")) {
continue;
for (const entry of dirents) {
if (entry.isFile() && (entry.name.endsWith(".yaml") || entry.name.endsWith(".yml"))) {
result.push({ name: stemFromYaml(entry.name), filePath: join(dir, entry.name) });
} else if (entry.isDirectory()) {
const found = await findIndexWorkflow(dir, entry.name);
if (found !== null) {
result.push(found);
}
}
const stem = entry.endsWith(".yaml") ? entry.slice(0, -5) : entry.slice(0, -4);
result.push({ name: stem, filePath: join(dir, entry) });
}
return result;
}
/**
* Scan `<projectRoot>/.workflow/` (preferred) and `.workflows/` (legacy) for workflow entries.
* .workflow/ takes priority: if a name is found in both, .workflow/ wins.
* Returns an empty array if neither directory exists.
*/
export async function discoverProjectWorkflows(
projectRoot: string,
): Promise<ProjectWorkflowEntry[]> {
const primary = await scanWorkflowDir(join(projectRoot, ".workflow"));
const legacy = await scanWorkflowDir(join(projectRoot, ".workflows"));
const seen = new Set(primary.map((e) => e.name));
const merged = [...primary];
for (const entry of legacy) {
if (!seen.has(entry.name)) {
merged.push(entry);
}
}
return merged;
}
/** Default filesystem root for uwf data (`~/.uncaged/workflow`). */
export function getDefaultStorageRoot(): string {
return join(homedir(), ".uncaged", "workflow");
@@ -70,10 +115,26 @@ 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");
}
@@ -98,7 +159,7 @@ export type UwfStore = {
};
export async function createUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = getCasDir(storageRoot);
const casDir = getGlobalCasDir();
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
+10 -4
View File
@@ -1,4 +1,4 @@
import { basename } from "node:path";
import { basename, dirname } from "node:path";
import type { CasRef, WorkflowPayload } from "@uncaged/workflow-protocol";
const CAS_REF_PATTERN = /^[0-9A-HJKMNP-TV-Z]{13}$/;
@@ -68,9 +68,15 @@ function isGraph(value: unknown): boolean {
*/
export function workflowNameFromPath(filePath: string): string {
const base = basename(filePath);
if (base.endsWith(".yaml")) return base.slice(0, -5);
if (base.endsWith(".yml")) return base.slice(0, -4);
return base;
const stem = base.endsWith(".yaml")
? base.slice(0, -5)
: base.endsWith(".yml")
? base.slice(0, -4)
: base;
if (stem === "index") {
return basename(dirname(filePath));
}
return stem;
}
/**
+1
View File
@@ -3,5 +3,6 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
+1 -1
View File
@@ -8,7 +8,7 @@ Layer 3 agent implementation. Runs an OpenAI-compatible chat completion loop wit
Useful when you want a self-contained agent without an external CLI like Hermes or Claude Code.
**Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-util-agent`, `@uncaged/workflow-util`
**Dependencies:** `@ocas/core`, `@uncaged/workflow-util-agent`, `@uncaged/workflow-util`
## Installation
+7 -6
View File
@@ -18,11 +18,12 @@
}
},
"scripts": {
"test": "bun test",
"test:ci": "bun test"
"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",
"@ocas/core": "^0.1.1",
"@uncaged/workflow-util-agent": "workspace:^",
"@uncaged/workflow-util": "workspace:^"
},
@@ -34,12 +35,12 @@
},
"repository": {
"type": "git",
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
"url": "https://git.shazhou.work/uncaged/workflow.git",
"directory": "packages/workflow-agent-builtin"
},
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
"bugs": {
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
"url": "https://git.shazhou.work/uncaged/workflow/issues"
},
"license": "MIT"
}
+8 -3
View File
@@ -1,4 +1,4 @@
import type { Store } from "@uncaged/json-cas";
import type { Store } from "@ocas/core";
import { createLogger, generateUlid } from "@uncaged/workflow-util";
import {
type AgentContext,
@@ -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 };
return { output: "", detailHash: "", sessionId: session.sessionId, assembledPrompt: "" };
}
// Read jsonl → persist turns to CAS → store detail
@@ -94,7 +94,12 @@ async function runBuiltinWithMessages(
session.startedAtMs,
);
return { output: stripPreamble(loopResult.finalText), detailHash, sessionId: session.sessionId };
return {
output: stripPreamble(loopResult.finalText),
detailHash,
sessionId: session.sessionId,
assembledPrompt: "",
};
}
async function runBuiltin(ctx: AgentContext): Promise<AgentRunResult> {
@@ -1,4 +1,4 @@
import { bootstrap, putSchema, type Store } from "@uncaged/json-cas";
import { bootstrap, putSchema, type Store } from "@ocas/core";
import { BUILTIN_DETAIL_SCHEMA, BUILTIN_TURN_SCHEMA } from "./schemas.js";
import { readSessionTurns } from "./session.js";
@@ -1,4 +1,4 @@
import type { JSONSchema } from "@uncaged/json-cas";
import type { JSONSchema } from "@ocas/core";
const BUILTIN_TOOL_CALL_SCHEMA: JSONSchema = {
type: "object",
@@ -38,7 +38,7 @@ export const BUILTIN_DETAIL_SCHEMA: JSONSchema = {
turnCount: { type: "integer" },
turns: {
type: "array",
items: { type: "string", format: "cas_ref" },
items: { type: "string", format: "ocas_ref" },
},
},
additionalProperties: false,
@@ -1,4 +1,4 @@
import type { JSONSchema } from "@uncaged/json-cas";
import type { JSONSchema } from "@ocas/core";
export type ToolContext = {
cwd: string;
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
@@ -6,7 +6,7 @@
Layer 3 agent implementation. Spawns the `claude` CLI with a composed system prompt (role definition, task, prior steps, edge prompt). Parses stream or JSON stdout, caches session IDs for multi-turn continuation, and stores raw output plus structured detail in CAS.
**Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-util-agent`
**Dependencies:** `@ocas/core`, `@uncaged/workflow-util-agent`
## Installation
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { createMemoryStore, walk } from "@uncaged/json-cas";
import { createMemoryStore, walk } from "@ocas/core";
import {
parseClaudeCodeJsonOutput,
parseClaudeCodeStreamOutput,
@@ -301,6 +301,179 @@ 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();
@@ -18,11 +18,12 @@
}
},
"scripts": {
"test": "bun test",
"test:ci": "bun test"
"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",
"@ocas/core": "^0.1.1",
"@uncaged/workflow-util-agent": "workspace:^",
"@uncaged/workflow-util": "workspace:^"
},
@@ -34,12 +35,12 @@
},
"repository": {
"type": "git",
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
"url": "https://git.shazhou.work/uncaged/workflow.git",
"directory": "packages/workflow-agent-claude-code"
},
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
"bugs": {
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
"url": "https://git.shazhou.work/uncaged/workflow/issues"
},
"license": "MIT"
}
@@ -1,5 +1,5 @@
import { spawn } from "node:child_process";
import type { Store } from "@uncaged/json-cas";
import type { Store } from "@ocas/core";
import { createLogger } from "@uncaged/workflow-util";
import {
type AgentContext,
@@ -48,7 +48,9 @@ export function buildClaudeCodePrompt(ctx: AgentContext): string {
return parts.join("\n");
}
function spawnClaude(args: string[]): Promise<{ stdout: string; stderr: string }> {
function spawnClaude(
args: string[],
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
return new Promise((resolve, reject) => {
const child = spawn(CLAUDE_COMMAND, args, {
env: process.env,
@@ -72,7 +74,7 @@ function spawnClaude(args: string[]): Promise<{ stdout: string; stderr: string }
child.on("close", (code) => {
if (code === 0) {
resolve({ stdout, stderr });
resolve({ stdout, stderr, exitCode: code });
return;
}
const detail = stderr.trim() !== "" ? ` stderr=${stderr.trim()}` : "";
@@ -81,7 +83,9 @@ function spawnClaude(args: string[]): Promise<{ stdout: string; stderr: string }
});
}
function spawnClaudeRun(prompt: string): Promise<{ stdout: string; stderr: string }> {
function spawnClaudeRun(
prompt: string,
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
const args = [
"-p",
prompt,
@@ -101,7 +105,7 @@ function spawnClaudeRun(prompt: string): Promise<{ stdout: string; stderr: strin
function spawnClaudeResume(
sessionId: string,
message: string,
): Promise<{ stdout: string; stderr: string }> {
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
const args = [
"-p",
message,
@@ -120,16 +124,36 @@ function spawnClaudeResume(
return spawnClaude(args);
}
async function processClaudeOutput(stdout: string, store: Store): Promise<AgentRunResult> {
async function processClaudeOutput(
stdout: string,
stderr: string,
exitCode: number | null,
store: Store,
assembledPrompt: string,
): Promise<AgentRunResult> {
const parsed = parseClaudeCodeStreamOutput(stdout);
if (parsed !== null) {
const { detailHash, output, sessionId } = await storeClaudeCodeDetail(store, parsed);
return { output, detailHash, sessionId };
// 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 };
}
// 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 returned unparseable output (first 200 chars): ${stdout.slice(0, 200)}`,
`Claude Code exited without producing parseable output.\n${exitInfo}${stderrInfo}Stdout (first 200 chars): ${stdoutSnippet}`,
);
}
@@ -143,8 +167,8 @@ async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
const cachedSessionId = await getCachedSessionId("claude-code", ctx.threadId, ctx.role);
if (cachedSessionId !== null) {
try {
const { stdout } = await spawnClaudeResume(cachedSessionId, fullPrompt);
const result = await processClaudeOutput(stdout, ctx.store);
const { stdout, stderr, exitCode } = await spawnClaudeResume(cachedSessionId, fullPrompt);
const result = await processClaudeOutput(stdout, stderr, exitCode, ctx.store, fullPrompt);
if (result.sessionId !== undefined && result.sessionId !== "") {
await setCachedSessionId("claude-code", ctx.threadId, ctx.role, result.sessionId);
}
@@ -152,16 +176,14 @@ async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
} catch (err) {
log(
"5VKR8N3Q",
"resume failed for session %s, falling back to fresh run: %s",
cachedSessionId,
err,
`resume failed for session ${cachedSessionId}, falling back to fresh run: ${err}`,
);
}
}
}
const { stdout } = await spawnClaudeRun(fullPrompt);
const result = await processClaudeOutput(stdout, ctx.store);
const { stdout, stderr, exitCode } = await spawnClaudeRun(fullPrompt);
const result = await processClaudeOutput(stdout, stderr, exitCode, ctx.store, fullPrompt);
if (result.sessionId !== undefined && result.sessionId !== "") {
await setCachedSessionId("claude-code", ctx.threadId, ctx.role, result.sessionId);
}
@@ -173,8 +195,8 @@ async function continueClaudeCode(
message: string,
store: Store,
): Promise<AgentRunResult> {
const { stdout } = await spawnClaudeResume(sessionId, message);
return processClaudeOutput(stdout, store);
const { stdout, stderr, exitCode } = await spawnClaudeResume(sessionId, message);
return processClaudeOutput(stdout, stderr, exitCode, store, "");
}
/** Agent CLI factory: parses argv, runs Claude Code, extracts output, writes StepNode. */
@@ -1,4 +1,4 @@
import type { JSONSchema } from "@uncaged/json-cas";
import type { JSONSchema } from "@ocas/core";
export const CLAUDE_CODE_DETAIL_SCHEMA: JSONSchema = {
title: "claude-code-detail",
@@ -34,7 +34,7 @@ export const CLAUDE_CODE_DETAIL_SCHEMA: JSONSchema = {
},
turns: {
type: "array",
items: { type: "string", format: "cas_ref" },
items: { type: "string", format: "ocas_ref" },
},
},
additionalProperties: false,
@@ -1,4 +1,4 @@
import { bootstrap, putSchema, type Store } from "@uncaged/json-cas";
import { bootstrap, putSchema, type Store } from "@ocas/core";
import {
CLAUDE_CODE_DETAIL_SCHEMA,
@@ -71,6 +71,7 @@ type ParseState = {
turns: ClaudeCodeTurnPayload[];
resultLine: Record<string, unknown> | null;
model: string;
sessionId: string;
turnIndex: number;
};
@@ -78,6 +79,9 @@ 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 {
@@ -124,8 +128,52 @@ 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 {
if (state.resultLine === null) return 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)
const sessionId = state.resultLine.session_id;
const result = state.resultLine.result;
const subtype = state.resultLine.subtype;
@@ -159,7 +207,13 @@ 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: "", turnIndex: 0 };
const state: ParseState = {
turns: [],
resultLine: null,
model: "",
sessionId: "",
turnIndex: 0,
};
for (const line of lines) {
processLine(line, state);
}
@@ -1,4 +1,4 @@
export type ClaudeCodeResultSubtype = "success" | "error_max_turns" | "error_budget";
export type ClaudeCodeResultSubtype = "success" | "error_max_turns" | "error_budget" | "incomplete";
/** A single tool call within an assistant turn. */
export type ClaudeCodeToolCall = {
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
+1 -1
View File
@@ -8,7 +8,7 @@
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`
**Dependencies:** `@ocas/core`, `@uncaged/workflow-util-agent`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`
## Installation
@@ -3,7 +3,7 @@ import { describe, expect, test } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createMemoryStore, refs, validate, walk } from "@uncaged/json-cas";
import { createMemoryStore, refs, validate, walk } from "@ocas/core";
import {
computeDurationMs,
@@ -82,7 +82,7 @@ describe("computeDurationMs", () => {
});
describe("storeHermesSessionDetail", () => {
test("stores hermes-detail root with cas_ref turns walkable", async () => {
test("stores hermes-detail root with ocas_ref turns walkable", async () => {
const session: HermesSessionJson = {
session_id: "20260518_133159_6a84e8",
model: "claude-opus-4.6",
+7 -6
View File
@@ -18,11 +18,12 @@
}
},
"scripts": {
"test": "bun test",
"test:ci": "bun test __tests__/*.test.ts"
"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",
"@ocas/core": "^0.1.1",
"@uncaged/workflow-util-agent": "workspace:^",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^"
@@ -35,12 +36,12 @@
},
"repository": {
"type": "git",
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
"url": "https://git.shazhou.work/uncaged/workflow.git",
"directory": "packages/workflow-agent-hermes"
},
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
"bugs": {
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
"url": "https://git.shazhou.work/uncaged/workflow/issues"
},
"engines": {
"bun": ">= 1.0.0"
+3 -3
View File
@@ -1,4 +1,4 @@
import type { Store } from "@uncaged/json-cas";
import type { Store } from "@ocas/core";
import { createLogger } from "@uncaged/workflow-util";
import {
type AgentContext,
@@ -117,7 +117,7 @@ export function createHermesAgent(): () => Promise<void> {
await setCachedSessionId(ctx.threadId, ctx.role, sessionId);
}
return { output: text, detailHash, sessionId };
return { output: text, detailHash, sessionId, assembledPrompt: fullPrompt };
}
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 };
return { output: text, detailHash, sessionId, assembledPrompt: "" };
}
const agentMain = createAgent({
@@ -1,4 +1,4 @@
import type { JSONSchema } from "@uncaged/json-cas";
import type { JSONSchema } from "@ocas/core";
const HERMES_TOOL_CALL_SCHEMA: JSONSchema = {
type: "object",
@@ -39,7 +39,7 @@ export const HERMES_DETAIL_SCHEMA: JSONSchema = {
turnCount: { type: "integer" },
turns: {
type: "array",
items: { type: "string", format: "cas_ref" },
items: { type: "string", format: "ocas_ref" },
},
},
additionalProperties: false,
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema, type Store } from "@uncaged/json-cas";
import { bootstrap, putSchema, type Store } from "@ocas/core";
import { HERMES_DETAIL_SCHEMA, HERMES_RAW_OUTPUT_SCHEMA, HERMES_TURN_SCHEMA } from "./schemas.js";
import type {
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
+3 -1
View File
@@ -5,7 +5,9 @@
"type": "module",
"scripts": {
"dev": "bun server.ts",
"build": "vite build"
"build": "vite build",
"test": "vitest run",
"test:ci": "vitest run"
},
"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 || node.type !== "role") return;
if (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 || node.type !== "role") return;
if (node?.type !== "role") return;
const roleNode = node as WorkNode<"role">;
const outEdges = outgoingEdges.get(nodeId) ?? [];
+2 -2
View File
@@ -4,9 +4,9 @@ Shared TypeScript types and JSON Schema constants for the workflow engine.
## Overview
This is the contract layer (Layer 0). It defines `WorkflowPayload`, thread node payloads, moderator context, CLI output shapes, and configuration types used across every other package. It has no runtime logic beyond exporting schema objects from `@uncaged/json-cas`.
This is the contract layer (Layer 0). It defines `WorkflowPayload`, thread node payloads, moderator context, CLI output shapes, and configuration types used across every other package. It has no runtime logic beyond exporting schema objects from `@ocas/core`.
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`
**Dependencies:** `@ocas/core`, `@ocas/fs`
## Installation
+10 -5
View File
@@ -14,9 +14,14 @@
"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"
"@ocas/core": "^0.1.1",
"@ocas/fs": "^0.1.1"
},
"devDependencies": {
"typescript": "^5.8.3"
@@ -26,12 +31,12 @@
},
"repository": {
"type": "git",
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
"url": "https://git.shazhou.work/uncaged/workflow.git",
"directory": "packages/workflow-protocol"
},
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
"bugs": {
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
"url": "https://git.shazhou.work/uncaged/workflow/issues"
},
"license": "MIT"
}
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, test } from "vitest";
import type { StartNodePayload, StepRecord, Target } from "../types.js";
describe("Protocol types for thread/edge location", () => {
@@ -25,6 +25,7 @@ describe("Protocol types for thread/edge location", () => {
edgePrompt: "Plan the implementation",
startedAtMs: Date.now(),
completedAtMs: Date.now() + 1000,
assembledPrompt: null,
cwd: "/home/user/project",
};
+10 -7
View File
@@ -1,4 +1,4 @@
import type { JSONSchema } from "@uncaged/json-cas";
import type { JSONSchema } from "@ocas/core";
const ROLE_DEFINITION: JSONSchema = {
type: "object",
@@ -9,7 +9,7 @@ const ROLE_DEFINITION: JSONSchema = {
capabilities: { type: "array", items: { type: "string" } },
procedure: { type: "string" },
output: { type: "string" },
frontmatter: { type: "string", format: "cas_ref" },
frontmatter: { type: "string", format: "ocas_ref" },
},
additionalProperties: false,
};
@@ -54,7 +54,7 @@ export const START_NODE_SCHEMA: JSONSchema = {
type: "object",
required: ["workflow", "prompt", "cwd"],
properties: {
workflow: { type: "string", format: "cas_ref" },
workflow: { type: "string", format: "ocas_ref" },
prompt: { type: "string" },
cwd: { type: "string" },
},
@@ -76,18 +76,21 @@ export const STEP_NODE_SCHEMA: JSONSchema = {
"cwd",
],
properties: {
start: { type: "string", format: "cas_ref" },
start: { type: "string", format: "ocas_ref" },
prev: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
anyOf: [{ type: "string", format: "ocas_ref" }, { type: "null" }],
},
role: { type: "string" },
output: { type: "string", format: "cas_ref" },
detail: { type: "string", format: "cas_ref" },
output: { type: "string", format: "ocas_ref" },
detail: { type: "string", format: "ocas_ref" },
agent: { type: "string" },
edgePrompt: { type: "string" },
startedAtMs: { type: "integer" },
completedAtMs: { type: "integer" },
cwd: { type: "string" },
assembledPrompt: {
anyOf: [{ type: "string", format: "ocas_ref" }, { type: "null" }],
},
},
additionalProperties: false,
};
+4
View File
@@ -20,6 +20,8 @@ export type StepRecord = {
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 定义 ───────────────────────────────────────────────
@@ -97,6 +99,8 @@ export type StepOutput = {
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;
};
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
+1 -1
View File
@@ -8,7 +8,7 @@ Layer 2 agent framework. Provides the standard entrypoint for all agent CLIs: pa
Also exports prompt builders, config/storage helpers, and session ID caching for multi-turn agents.
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`, `dotenv`, `yaml`
**Dependencies:** `@ocas/core`, `@ocas/fs`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`, `dotenv`, `yaml`
## Installation
@@ -1,4 +1,4 @@
import { createMemoryStore, putSchema } from "@uncaged/json-cas";
import { createMemoryStore, putSchema } from "@ocas/core";
import { describe, expect, test } from "vitest";
import { tryFrontmatterFastPath } from "../src/frontmatter.js";
@@ -44,6 +44,7 @@ describe("adapter-stdout: A4 retry loop survives JSON output", () => {
body: secondAttempt!.body,
startedAtMs: 1000,
completedAtMs: 2000,
assembledPrompt: null,
};
const json = JSON.stringify(adapterOutput);
@@ -1,4 +1,4 @@
import { createMemoryStore, putSchema } from "@uncaged/json-cas";
import { createMemoryStore, putSchema } from "@ocas/core";
import { describe, expect, test } from "vitest";
import { tryFrontmatterFastPath } from "../src/frontmatter.js";
@@ -1,4 +1,4 @@
import { createMemoryStore, putSchema } from "@uncaged/json-cas";
import { createMemoryStore, putSchema } from "@ocas/core";
import { describe, expect, test } from "vitest";
import { tryFrontmatterFastPath } from "../src/frontmatter.js";
+8 -7
View File
@@ -15,12 +15,13 @@
}
},
"scripts": {
"test": "bun test",
"test:ci": "bun test"
"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",
"@ocas/core": "^0.1.1",
"@ocas/fs": "^0.1.1",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"dotenv": "^16.6.1",
@@ -34,12 +35,12 @@
},
"repository": {
"type": "git",
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
"url": "https://git.shazhou.work/uncaged/workflow.git",
"directory": "packages/workflow-util-agent"
},
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
"bugs": {
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
"url": "https://git.shazhou.work/uncaged/workflow/issues"
},
"license": "MIT"
}
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
describe("parseArgv empty prompt error message", () => {
let stderrOutput: string;
@@ -1,4 +1,4 @@
import type { JSONSchema } from "@uncaged/json-cas";
import type { JSONSchema } from "@ocas/core";
type SchemaProperty = {
name: string;
@@ -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);
+2 -1
View File
@@ -1,4 +1,4 @@
import type { Store } from "@uncaged/json-cas";
import type { Store } from "@ocas/core";
import type {
CasRef,
StartNodePayload,
@@ -131,6 +131,7 @@ async function buildHistory(
startedAtMs: step.startedAtMs,
completedAtMs: step.completedAtMs,
cwd: step.cwd ?? "",
assembledPrompt: step.assembledPrompt ?? null,
content,
});
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { getSchema, validate } from "@uncaged/json-cas";
import { getSchema, validate } from "@ocas/core";
import type { CasRef, ModelAlias, WorkflowConfig } from "@uncaged/workflow-protocol";
import { createAgentStore, resolveStorageRoot } from "./storage.js";
@@ -1,5 +1,5 @@
import type { Store } from "@uncaged/json-cas";
import { getSchema, validate } from "@uncaged/json-cas";
import type { Store } from "@ocas/core";
import { getSchema, validate } from "@ocas/core";
import type { CasRef } from "@uncaged/workflow-protocol";
import {
type AgentFrontmatter,
+14 -1
View File
@@ -1,4 +1,4 @@
import { getSchema, validate } from "@uncaged/json-cas";
import { getSchema, validate } from "@ocas/core";
import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/workflow-protocol";
import { config as loadDotenv } from "dotenv";
import { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
@@ -64,6 +64,7 @@ async function writeStepNode(options: {
edgePrompt: string;
startedAtMs: number;
completedAtMs: number;
assembledPromptHash: CasRef | null;
}): Promise<CasRef> {
const payload: StepNodePayload = {
start: options.startHash,
@@ -76,6 +77,7 @@ async function writeStepNode(options: {
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);
@@ -114,6 +116,7 @@ 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({
@@ -128,6 +131,7 @@ async function persistStep(options: {
edgePrompt: options.ctx.edgePrompt,
startedAtMs: options.startedAtMs,
completedAtMs: options.completedAtMs,
assembledPromptHash: options.assembledPromptHash,
});
}
@@ -182,6 +186,14 @@ 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,
@@ -189,6 +201,7 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
agentName: agentLabel(options.name),
startedAtMs,
completedAtMs,
assembledPromptHash,
});
const adapterOutput: AdapterOutput = {
+8 -4
View File
@@ -1,22 +1,26 @@
import type { Hash, Store } from "@uncaged/json-cas";
import { putSchema } from "@uncaged/json-cas";
import type { Hash, Store } from "@ocas/core";
import { putSchema } from "@ocas/core";
import { START_NODE_SCHEMA, STEP_NODE_SCHEMA, WORKFLOW_SCHEMA } from "@uncaged/workflow-protocol";
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] = await Promise.all([
const [workflow, startNode, stepNode, text] = 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 };
return { workflow, startNode, stepNode, text };
}
+2 -2
View File
@@ -2,8 +2,8 @@ import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import type { Store } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { Store } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import type {
AgentAlias,
AgentConfig,
+3 -1
View File
@@ -1,4 +1,4 @@
import type { Store } from "@uncaged/json-cas";
import type { Store } from "@ocas/core";
import type { ModeratorContext, ThreadId, WorkflowPayload } from "@uncaged/workflow-protocol";
export type AgentContext = ModeratorContext & {
@@ -27,6 +27,8 @@ export type AgentRunResult = {
output: string;
detailHash: string;
sessionId: string;
/** The fully assembled prompt that was sent to the agent. */
assembledPrompt: string;
};
export type AgentContinueFn = (
+1 -1
View File
@@ -5,5 +5,5 @@
"outDir": "dist"
},
"include": ["src"],
"references": [{ "path": "../workflow-protocol" }]
"references": [{ "path": "../workflow-protocol" }, { "path": "../workflow-util" }]
}
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
+8 -3
View File
@@ -14,6 +14,11 @@
"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"
@@ -23,12 +28,12 @@
},
"repository": {
"type": "git",
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
"url": "https://git.shazhou.work/uncaged/workflow.git",
"directory": "packages/workflow-util"
},
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
"bugs": {
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
"url": "https://git.shazhou.work/uncaged/workflow/issues"
},
"license": "MIT"
}
@@ -1,4 +1,4 @@
import { describe, expect, it } from "bun:test";
import { describe, expect, it } from "vitest";
import { extractUlidTimestamp, generateUlid } from "../ulid.js";
describe("extractUlidTimestamp", () => {
@@ -0,0 +1,39 @@
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)。
`;
}
@@ -20,7 +20,7 @@ Dependency layers (each only imports from packages above it):
protocol util util-agent agent-hermes / agent-builtin / cli-workflow
\`\`\`
External CAS: \`@uncaged/json-cas\` (store API, hashing, schema validation) + \`@uncaged/json-cas-fs\` (filesystem backend).
External CAS: \`@ocas/core\` (store API, hashing, schema validation) + \`@ocas/fs\` (filesystem backend).
## Coding Conventions
@@ -122,7 +122,7 @@ Shared entry point for all agent CLIs. Handles:
### CAS Integration
All data is CAS-addressed via \`@uncaged/json-cas\`:
All data is CAS-addressed via \`@ocas/core\`:
- \`store.put(schemaHash, data)\` → content hash
- \`store.get(hash)\` → node
- \`validate(store, node)\` → schema check
+1
View File
@@ -3,6 +3,7 @@ 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";
@@ -121,5 +121,15 @@ 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
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bun
// Mock agent for smoke testing
import { bootstrap, type JSONSchema, putSchema } from "@uncaged/json-cas";
import { bootstrap, type JSONSchema, putSchema } from "@ocas/core";
import { createAgent } from "../packages/uwf-agent-kit/src/index.js";
const MOCK_RAW_OUTPUT_SCHEMA: JSONSchema = {
+2 -1
View File
@@ -21,6 +21,7 @@ const publishOrder = [
"workflow-util-agent",
"workflow-agent-hermes",
"workflow-agent-builtin",
"workflow-agent-claude-code",
"cli-workflow",
];
@@ -59,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 ${tagFlag}`;
const cmd = `npm publish --access public --ignore-scripts ${tagFlag}`;
console.log(`📦 ${name}...`);

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