Compare commits

...

85 Commits

Author SHA1 Message Date
xiaoju 6b3aa4ce35 fix(cli): usage not red + skill subcommand + --help flag on groups
1. No-args usage uses printCliLine (not printCliError), exit 1
2. 'skill [topic]' as first-class command (help --skill kept as compat)
3. 'workflow --help', 'thread --help' etc. show group subcommands
4. Role prompts updated: 'uncaged-workflow skill develop'

240 tests (6 new), build clean.

Closes #83

小橘 🍊
2026-05-07 15:17:20 +00:00
xingyue f042c9d640 Merge pull request 'feat(cli): help --skill <topic> for context-specific agent docs' (#82) from feat/81-skill-topics into main 2026-05-07 15:08:41 +00:00
xiaoju 66bca9ef03 feat(cli): help --skill <topic> — context-specific docs for agents
- help --skill (no args) → lists available topics
- help --skill cli → full CLI reference (was: help --skill)
- help --skill develop → thread ID, CAS, meta output guide for roles
- help --skill author → bundle structure, descriptor, role definition
- Role prompts updated: planner/coder reference 'help --skill develop'
- Legacy formatSkillDoc() preserved for compat
- 234 tests (15 new), build clean

Closes #81

小橘 🍊
2026-05-07 15:03:08 +00:00
xiaoju 309af39447 Merge pull request 'fix(cli): review nits — live --latest args + dispatchInit consistency' (#79) from fix/75-nits into main 2026-05-07 14:54:28 +00:00
xiaoju 86a422f7e2 fix(cli): nits from review — live --latest in args, dispatchInit uses dispatchGroup
小橘 🍊
2026-05-07 14:54:02 +00:00
xiaoju 648f0c6dec Merge pull request 'refactor: merge role packages into templates + slim prompts' (#78) from refactor/75-merge-roles-phase1 into main 2026-05-07 14:52:25 +00:00
xiaoju 8456a8337b refactor: slim planner & coder prompts with help --skill
Replace inline CLI tutorials (thread ID lookup, cas put/get examples)
with a single 'uncaged-workflow help --skill' reference. Keeps minimal
task-specific instructions (what to store, what to report).

Closes #77
Refs #75, #72

小橘 🍊
2026-05-07 14:47:14 +00:00
xiaoju 9c8b98a551 refactor: merge 7 workflow-role-* packages into templates
- planner/coder/reviewer/tester/committer → workflow-template-develop/src/roles/
- preparer/submitter → workflow-template-solve-issue/src/roles/
- Moved tests, updated imports, removed role packages
- 219 tests pass, build clean

Closes #76
Refs #75, #73

小橘 🍊
2026-05-07 14:45:11 +00:00
xiaoju c3272be760 Merge pull request 'refactor(cli): auto-generate skill doc from command registry' (#74) from refactor/71-auto-gen-skill-doc into main 2026-05-07 14:39:51 +00:00
xiaomo c44b773a86 refactor(cli): auto-generate skill doc from command registry (#71) 2026-05-07 14:35:53 +00:00
xingyue 2776f8e419 Merge pull request 'feat(cli): add WORKFLOW_STORAGE_ROOT env var support' (#68) from feat/63-workflow-storage-root into main 2026-05-07 14:30:03 +00:00
xiaoju 7b0e256c13 feat(cli): add WORKFLOW_STORAGE_ROOT env var support
Add user-facing WORKFLOW_STORAGE_ROOT environment variable to override
the default storage directory (~/.uncaged/workflow). The existing
UNCAGED_WORKFLOW_STORAGE_ROOT (internal/test) takes priority.

- Update storage-env.ts with priority chain: internal > user > default
- Add env var documentation to CLI help text
- Add 5 tests covering all priority/fallback scenarios

Fixes #63
2026-05-07 22:29:26 +08:00
xiaomo c663ba9e9c Merge pull request 'feat(cli): help --skill command for agent-consumable docs' (#70) from feat/69-help-skill into main 2026-05-07 14:25:31 +00:00
xiaoju 71b413f20c feat(planner): add phase granularity guidance to reduce over-splitting
Simple tasks were getting 3 phases when 1 would suffice. Added explicit
complexity-to-phase-count mapping in the planner system prompt.

小橘 🍊
2026-05-07 14:20:37 +00:00
xiaomo 61be1c662a feat(cli): help --skill command for agent-consumable docs (#69) 2026-05-07 14:20:06 +00:00
xiaomo 84e8d70da4 Merge pull request 'refactor(cli): group commands by noun-verb pattern' (#67) from refactor/cli-noun-verb-grouping into main 2026-05-07 14:09:46 +00:00
xiaomo 8976f4cf3b fix(cli): move 'remove' from workflow table to deprecation path
Per review nit: 'workflow rm' is canonical, 'workflow remove' now shows
deprecation warning. Consistent with top-level 'remove' → 'workflow rm'.
2026-05-07 14:09:37 +00:00
xiaomo 07730dd24c refactor(cli): group commands by noun-verb pattern (RFC #54)
Phase 1: workflow subcommand group (add/list/show/rm/history/rollback)
Phase 2: thread subcommand group (run/list/show/rm/fork/ps/kill/live/pause/resume)
Phase 3: cas gc + top-level aliases + deprecation warnings for old flat commands

- Follow existing CAS_SUBCOMMAND_TABLE pattern for workflow and thread groups
- Top-level 'run' and 'live' stay as shortcuts (no deprecation)
- Old flat commands print deprecation warning then delegate
- Update usage string to show grouped format
- Update tests to use new grouped syntax
2026-05-07 14:03:35 +00:00
xiaoju 4eff4d2370 Merge pull request 'feat: developer + submitter roles, solve-issue as parent workflow' (#62) from feat/59-solve-issue-refactor into main 2026-05-07 13:51:56 +00:00
xiaoju 1d6da18b18 feat: developer + submitter roles, solve-issue refactored to parent workflow
- Developer role (react extract): delegates to workflowAsAgent("develop")
- Submitter role: push branch + create PR
- solve-issue now 3-role parent: preparer → developer → submitter
- Removed direct planner/coder/reviewer/committer from solve-issue
- 188 tests passing

Fixes #59
2026-05-07 13:51:37 +00:00
xiaomo c342ff3737 Merge pull request 'feat(cli): live command — real-time thread monitoring' (#57) from feat/37-live-command into main 2026-05-07 13:45:09 +00:00
xingyue 8fe26417cf feat(cli): add --latest, --debug, --role flags to live command (#37 Phase 2)
- --latest: auto-find most recent thread by start timestamp
- --debug: display .info.jsonl debug log with tags
- --role: filter output to specific role
- Add live-argv.ts for flag parsing
- Add fixtures and test coverage for all flags

Testing: #50
2026-05-07 21:44:19 +08:00
xingyue 990200230b feat(cli): add live command for real-time thread monitoring (#37 Phase 1)
- Add cmd-live.ts: tail .data.jsonl with formatted output
- Display role steps with timestamp, role name, truncated content, meta
- fs.watch for running threads, auto-exit on completion
- Write WorkflowResult to .data.jsonl in worker.ts for completion detection
- Add live.test.ts with JSONL fixtures

Testing: #49
2026-05-07 21:42:32 +08:00
xiaoju 4eaefd9974 Merge pull request 'feat: tester role + develop workflow template' (#61) from feat/58-develop-workflow into main 2026-05-07 13:42:16 +00:00
xiaoju 1a685583bd feat: tester role + develop workflow template
- New workflow-role-tester: runs tests/build/lint, reports pass/fail
- Committer: removed push, only creates branch and commits
- New workflow-template-develop: planner → coder ⟲ → reviewer ⟲ → tester → committer
- 173 tests passing

Fixes #58
2026-05-07 13:42:01 +00:00
xiaomo 19769efea6 Merge pull request 'feat(cli): init command — scaffold workflow workspace' (#56) from feat/36-init-command into main 2026-05-07 13:37:56 +00:00
xiaoju 7f64541c5b Merge pull request 'feat: ReAct ExtractFn with tool-use' (#53) from feat/44-react-extract into main 2026-05-07 13:28:22 +00:00
xiaoju 43a6600378 feat: ReAct ExtractFn with tool-use
- RoleDefinition.extractMode: "single" | "react"
- reactExtract: multi-turn LLM with cas_get tool for DAG traversal
- Max 10 tool-call rounds, schema validation on final output
- create-workflow routes to reactExtract when extractMode is "react"
- All existing roles set to "single" (no behavior change)
- 162 tests passing

Fixes #44
2026-05-07 13:28:00 +00:00
xingyue 74e3f5434c feat(cli): complete AGENTS.md generation (#36 Phase 3)
- Replace placeholder with comprehensive coding agent instructions
- Covers: project structure, core concepts, dev workflow, coding
  conventions, template reuse, build/test, common pitfalls
- Add test coverage for AGENTS.md sections and terms

Testing: #48
2026-05-07 21:23:41 +08:00
xiaoju 220c9c5224 Merge pull request 'feat: global extract provider config' (#52) from feat/43-extract-provider-config into main 2026-05-07 13:21:57 +00:00
xiaoju cae59b589e feat: global extract provider config
- workflow.yaml supports config section (maxDepth, extract provider)
- ExtractProviderConfig with env: prefix for apiKey resolution
- getExtractProvider(storageRoot) returns LlmProvider from config
- workflowAsAgent uses config maxDepth (fallback 3)
- Registry read/write preserves config
- 158 tests passing

Fixes #43
2026-05-07 13:21:38 +00:00
xingyue 703ac9dfcc feat(cli): add init template command (#36 Phase 2)
- Implement cmdInitTemplate: find workspace root, generate template package
- Generate roles.ts, moderator.ts, index.ts with hello-world boilerplate
- Detect workspace by walking up to find package.json with workspaces
- Error on existing template dir or outside workspace
- Add init-template.test.ts

Testing: #47
2026-05-07 21:21:23 +08:00
xingyue 2df8accf2f feat(cli): add init workspace command (#36 Phase 1)
- Add cmd-init.ts with cmdInitWorkspace and stub cmdInitTemplate
- Wire init subcommands into cli-dispatch.ts
- Generate monorepo skeleton: package.json (bun workspace), biome.json,
  tsconfig.json, AGENTS.md placeholder, README.md, templates/, workflows/
- Error on existing directory
- Add init-workspace.test.ts (all passing)

Testing: #46
2026-05-07 21:18:58 +08:00
xiaoju b5cc0db17e Merge pull request 'feat: thread root node + workflowAsAgent returns root hash' (#51) from feat/42-thread-root-node into main 2026-05-07 13:18:13 +00:00
xiaoju 6196e0974a feat: thread root node + workflowAsAgent returns root hash
- Engine generates step Merkle nodes (type: step) after each role step
- Engine generates thread root Merkle node (type: thread) on completion
- WorkflowResult gains rootHash field
- WorkflowFn returns WorkflowCompletion (no rootHash), engine wraps with rootHash
- workflowAsAgent returns rootHash instead of summary
- Full DAG traversal: root → steps → content
- 151 tests passing

Fixes #42
2026-05-07 13:17:44 +00:00
xiaoju 410e9e6d9b Merge pull request 'feat: Merkle node format + content → CAS' (#45) from feat/41-merkle-content-cas into main 2026-05-07 13:14:16 +00:00
xiaoju 84de74721d feat: Merkle node format + content → CAS
- MerkleNode type: { type, payload, children } serialized as YAML
- RoleOutput.content → contentHash (CAS hash of Merkle content node)
- Engine stores content in global CAS before writing to .data.jsonl
- create-workflow puts content as Merkle node, merges contentHash into refs
- fork/parse adapted for contentHash format
- buildAgentPrompt now async, reads content from CAS
- Bundle validator allows @uncaged/workflow import
- 150 tests passing

BREAKING: .data.jsonl no longer contains inline content

Fixes #41
2026-05-07 13:14:01 +00:00
xiaoju 4403532f35 Merge pull request 'feat: workflowAsAgent factory' (#39) from feat/33-workflow-as-agent into main 2026-05-07 10:52:40 +00:00
xiaoju e95e76c145 feat: workflowAsAgent factory
- workflowAsAgent(name) resolves via registry → bundle → child thread
- System-level depth limit (max 3, constant)
- Returns summary string, errors as string (no throw)
- Integration test with nested workflow execution
- 146 tests passing

Fixes #33
2026-05-07 10:52:26 +00:00
xiaoju af69e773a0 Merge pull request 'feat: CAS garbage collection' (#38) from feat/32-cas-gc into main 2026-05-07 10:48:07 +00:00
xiaoju 6488b7bbb4 feat: CAS garbage collection
- garbageCollectCas() mark-and-sweep: scan .data.jsonl refs, delete orphans
- 'uncaged-workflow gc' CLI command
- thread rm triggers GC automatically
- 141 tests passing

Fixes #32
2026-05-07 10:47:52 +00:00
xiaoju 15d39c96a7 Merge pull request 'feat: add refs tracking to RoleStep' (#35) from feat/31-refs-tracking into main 2026-05-07 10:44:44 +00:00
xiaoju 30e4e99908 feat: add refs tracking to RoleStep
- RoleOutput gains refs: string[] for CAS reference tracking
- RoleDefinition gains extractRefs: ((meta) => string[]) | null
- planner: phases.map(p => p.hash), coder: [completedPhase]
- Engine persists refs, fork preserves refs
- Backward compat: missing refs normalized to []
- 137 tests passing

Fixes #31
2026-05-07 10:44:25 +00:00
xiaoju a3c70a5041 Merge pull request 'feat: migrate CAS to global storage' (#34) from feat/30-global-cas into main 2026-05-07 10:40:41 +00:00
xiaoju 12d58a8206 feat: migrate CAS to global storage
- Add getGlobalCasDir() to storage-root.ts
- cmd-cas.ts uses global CAS dir, threadId kept for CLI compat
- thread rm no longer deletes .cas/ directories
- Rename createThreadCas → createCasStore (deprecated alias kept)
- 134 tests passing

BREAKING: CAS moves from <thread>.cas/ to <storageRoot>/cas/

Fixes #30
2026-05-07 10:40:14 +00:00
xiaoju c096f4d94e docs: add workflow-as-agent implementation plan
Covers 4 phases:
1. Global CAS migration
2. RoleStep refs tracking
3. CAS garbage collection
4. workflowAsAgent(name) factory

Refs #25
2026-05-07 10:35:26 +00:00
xiaoju 500401d93c feat(workflow): add preparer role to solve-issue workflow (#29, closes #28) 2026-05-07 10:18:05 +00:00
xiaoju 43f466eb67 style(solve-issue): fix biome formatting in test file
Refs #28
2026-05-07 10:15:10 +00:00
xiaoju fe829d9ae6 feat(workflow): add preparer role as first step in solve-issue workflow
- New package: @uncaged/workflow-role-preparer with PreparerMeta type,
  schema, system prompt, and extract prompt
- Preparer locates/clones repo, detects toolchain and conventions
- Moderator updated: preparer → planner → coder → reviewer → committer
- solve-issue template re-exports preparer types
- Tests updated for new flow (129 pass, 0 fail)

Fixes #28
2026-05-07 10:12:59 +00:00
xiaoju f80535d742 fix(planner,coder): clarify CAS CLI usage in agent prompts (#27) 2026-05-07 09:43:21 +00:00
xiaoju 0eab3b7001 chore: remove temporary bundle entry file 2026-05-07 09:43:00 +00:00
xiaoju 37c5b89c98 fix(planner,coder): clarify CAS CLI usage in agent prompts
- Planner: mandatory CAS put with complete command template, thread ID guidance
- Coder: CAS get command template with thread ID guidance
- Forbid inventing storage paths — must use uncaged-workflow cas CLI

Fixes #26
2026-05-07 09:42:52 +00:00
xiaoju 0fdf19879a Merge pull request 'test(workflow): add unit tests for validateWorkflowDescriptor' (#20) from test/19-validate-workflow-descriptor into main 2026-05-07 09:38:54 +00:00
xiaoju f73bf1e313 test(workflow): add unit tests for validateWorkflowDescriptor
Fixes #19
2026-05-07 09:38:18 +00:00
xiaoju 8c4441bf6b feat: thread-scoped CAS for phase tracking (#23) 2026-05-07 04:59:03 +00:00
xiaoju 341ff656dc feat(planner,coder,moderator): integrate CAS for phase tracking
Phase 2 of #23:
- Planner schema compact: {hash, title} only, details stored via CAS CLI
- Planner prompt instructs agent to shell out `cas put` for each phase
- Coder prompt instructs agent to `cas get` for phase details, report hash
- Moderator compares hashes instead of names
- Removed COMPLETED_PHASE_SENTINELS — hash matching eliminates ambiguity

Refs #23
2026-05-07 04:54:25 +00:00
xiaoju 4b44665c7e feat(workflow): add thread-scoped CAS (Content-Addressable Storage)
Phase 1 of #23:
- createThreadCas() core API: put/get/delete/list with XXH64 hashing
- hashString() utility for string → 13-char Crockford Base32
- CLI: uncaged-workflow cas get/put/list/rm subcommands
- thread rm now cleans up .cas/ directory
- 10 new tests for CAS operations

Refs #23
2026-05-07 04:30:19 +00:00
xiaoju 172e9b34cc feat(planner): add hash and title fields to phase schema
Each phase now carries a hash (Crockford Base32 identifier) and a
one-line title alongside the existing name/description/acceptance.
This gives agents immediate semantic context in the prompt without
needing to load full phase details from CAS.

Refs #23
2026-05-07 04:18:42 +00:00
xiaoju 96fc3e220a fix(solve-issue): handle one-shot coder completing all phases at once
The moderator now treats completedPhase matching the last planned phase name
as full completion. Also recognizes sentinel values (all-done, all_done,
complete) for robustness.

Fixes #21

小橘 🍊(NEKO Team)
2026-05-07 03:13:44 +00:00
xiaoju 7926751b01 docs: replace RFC-001 with up-to-date architecture doc
RFC-001 was severely outdated (pre-refactor types). New architecture.md
covers the current three-phase engine, pure data roles, AgentBinding,
ExtractFn, and all design decisions.

小橘 <xiaoju@shazhou.work>
2026-05-07 01:45:51 +00:00
xiaoju 43e1f82303 refactor: extractPrompt out of ExtractContext, into ExtractFn parameter
ExtractFn = (schema, prompt, ctx) => Promise<T>
extractPrompt stays in RoleDefinition (definition layer), not in context (state layer).
Callers pass their own prompt — engine uses roleDef.extractPrompt, cursor agent uses its own.

小橘 <xiaoju@shazhou.work>
2026-05-07 01:27:12 +00:00
xiaoju d472de1247 refactor: three-phase context (Moderator/Agent/Extract) + extractPrompt + unified ExtractFn
- ModeratorContext → AgentContext → ExtractContext progressive types
- ThreadContext is now alias for AgentContext
- RoleDefinition adds extractPrompt field
- ExtractFn = (schema, ctx: ExtractContext) => Promise<T>
- createWorkflow takes ExtractFn, engine loop: moderator → agent → extract
- Remove ExtractConfig, extractMetaOrThrow, extract-meta.ts

小橘 <xiaoju@shazhou.work>
2026-05-07 01:05:31 +00:00
xiaoju 99a137422c feat: add ExtractFn utility, cursor agent workspace from thread context
- New ExtractFn = <T>(schema, prompt) => (ctx) => Promise<T>
- createExtract(provider) creates an LLM-backed ExtractFn
- CursorAgent removes workdir config, uses ExtractFn to resolve workspace from ThreadContext at runtime
- buildAgentPrompt(ctx) — reads systemPrompt from ctx.currentRole

小橘 <xiaoju@shazhou.work>
2026-05-07 00:17:31 +00:00
xiaoju d351343aa8 fix: pure data role packages use 'echo no tests' instead of 'bun test'
workflow-role-coder and workflow-role-planner have no test files —
bun test exits 1 on empty. Changed to 'echo no tests' for clean CI.

小橘 <xiaoju@shazhou.work>
2026-05-06 14:33:16 +00:00
xiaoju 2482fb7e62 chore: remove all dryRun infrastructure
dryRun no longer needed — tests use mock agents + mock fetch instead.
Removes isDryRun from WorkflowFnOptions, dryRun from ExtractConfig,
dryRunMeta from RoleDefinition, --dry-run from CLI, and all related
plumbing in engine/worker/fork/extract.

小橘 <xiaoju@shazhou.work>
2026-05-06 14:25:44 +00:00
xiaoju fa9163e462 refactor: all-agentic architecture — roles as pure data, agent binding at runtime
BREAKING: Major architecture change.

- RoleDefinition = pure data (systemPrompt + schema + dryRunMeta)
- AgentFn = (ctx: ThreadContext) => Promise<string>, reads ctx.currentRole
- WorkflowDefinition decoupled from agents, bound via AgentBinding at runtime
- createWorkflow(def, binding, extract) replaces createRoleModerator
- Meta extraction moved into engine loop
- Delete workflow-util-role package (createRole, decorators, extract all gone)
- Role packages become pure data exports
- Agent packages updated to single-arg AgentFn

小橘 <xiaoju@shazhou.work>
2026-05-06 14:14:33 +00:00
xiaoju fce2bf7441 refactor: systemPrompt → pure string, threadId injected by agent layer
- CreateRoleArgs.systemPrompt simplified to string (no more ctx callback)
- buildAgentPrompt injects real ctx.threadId in Tools section
- All four roles compute prompt at construction time from config only
- Removed duplicate ctx.threadId injection from reviewer/committer prompts

小橘 <xiaoju@shazhou.work>
2026-05-06 12:25:41 +00:00
xiaoju c9cdfe37db refactor: extract planner and coder into standalone role packages
- New @uncaged/workflow-role-planner (phaseSchema, createPlannerRole)
- New @uncaged/workflow-role-coder (coderMetaSchema, createCoderRole)
- solve-issue template imports from new packages, keeps dry-run defaults

小橘 <xiaoju@shazhou.work>
2026-05-06 11:40:19 +00:00
xiaoju 45bb5af99a feat: per-role agent config + phased planner/coder in solve-issue template
- SolveIssueRolesConfig.agents allows per-role AgentFn overrides
- PlannerMeta now outputs phases (name, description, acceptance)
- CoderMeta reports completedPhase, works one phase at a time
- Moderator routes coder→coder until all phases done, then reviewer

小橘 <xiaoju@shazhou.work>
2026-05-06 11:35:45 +00:00
xiaoju c7b0beb6be refactor: unify RoleDefinition + WorkflowDefinition with description & schema
- Add RoleDefinition<Meta> = { description, run, schema } to core types
- WorkflowDefinition now carries description and RoleDefinition per role
- Add buildDescriptor() in core to derive WorkflowDescriptor from WorkflowDefinition
- Remove buildDescriptorFromRoles / RoleDescriptorInput from workflow-util-role
- Update solve-issue template, examples, and all tests

小橘 <xiaoju@shazhou.work>
2026-05-06 11:19:49 +00:00
xiaoju 79cf97e617 refactor: remove name from WorkflowDefinition, fix threadId type errors
WorkflowDefinition no longer carries 'name' — name is a registry-level
concern, not a bundle property. Also fixed all missing threadId in test
fixtures to match the updated ThreadContext type.

小橘 <xiaoju@shazhou.work>
2026-05-06 11:01:09 +00:00
xiaoju 196562c82a feat: committer distinguishes recoverable vs unrecoverable failures
CommitterMeta is now a 3-way discriminated union:
- committed: success with branch + commitSha
- recoverable: coder can fix (hook failures, lint, test, conflicts)
- unrecoverable: can't be fixed by code (auth, permissions, disk)

Moderator routes recoverable → coder for retry.
2026-05-06 10:53:17 +00:00
xiaoju 267ca73a1b fix: committer prompt — don't fix failures, just report them 2026-05-06 10:49:22 +00:00
xiaoju aee71fd2e7 refactor: simplify committer — minimal prompt, config simplified
- CommitterGitConfig → CommitterConfig { cwd } (remote dropped)
- threadId from ctx.threadId, not config
- Remove summarizeThreadContext — agent uses uncaged-workflow thread CLI
- Prompt doesn't teach git or require structured output
2026-05-06 10:39:09 +00:00
xiaoju 8ce1dd3cca refactor: remove JSON output requirement from reviewer prompt
Let extractMetaOrThrow do its job — agent outputs naturally,
LLM extract handles structuring.
2026-05-06 10:35:53 +00:00
xiaoju 4b27943871 refactor: simplify reviewer — discriminated union meta, minimal prompt
- ReviewerMeta → discriminated union: approved | rejected (with issues)
- Remove method-heavy prompt — agent has built-in code review capability
- Prompt now just says: project path, threadId for context, approve or reject
- No non-blocking suggestions (they get ignored anyway)
- ReviewerConfig simplified to just { cwd }
2026-05-06 10:29:48 +00:00
xiaoju 8d5b97c67e feat: add threadId to ThreadContext and WorkflowFnOptions
Agentic roles can now access ctx.threadId to query thread details
via uncaged-workflow CLI for richer context.
2026-05-06 10:23:25 +00:00
xiaoju 513c006ce3 refactor: rename workflow-role-llm → workflow-agent-llm
The package only contains createLlmAdapter (OpenAI chat → AgentFn),
which is an agent adapter, not a role. Aligns with workflow-agent-cursor
and workflow-agent-hermes naming.
2026-05-06 10:14:35 +00:00
xiaoju 2cd2a7d713 Merge pull request 'fix(workflow): add typecheck script and fix remaining type errors' (#18) from fix/review-feedback-and-typecheck into main 2026-05-06 10:11:47 +00:00
xingyue 94fa964b84 fix(workflow): add typecheck script and fix remaining type errors
- Add typecheck script (bunx tsc --build) to package.json
- Update check script to run typecheck before biome
- Fix mock fetch casts in test files (bun-types preconnect)
- Fix RequestInfo → Request | string | URL in llm-extract test
- Fix ThreadContext generic cast in solve-issue-template test
- Fix git-exec.ts missing return and module resolution
- Remove @types/node from workflow-role-committer
- Add exports field to workflow-util-agent/package.json
2026-05-06 18:10:25 +08:00
xiaoju 2c642b1a53 refactor: committer as pure agent role with discriminated union meta (#17)
- Remove hardcoded git commands (git-exec.ts deleted)
- CommitterMeta is now a discriminated union: committed | failed
- Agent handles git operations, committer extracts structured result
- Moderator can route on meta.status for retry logic

Closes #17
2026-05-06 10:06:27 +00:00
xiaoju c15a5554c0 refactor: replace gitExec with spawnCli from workflow-util-agent
Remove hand-rolled execFile + promisify, delegate to spawnCli.
Same throw-on-error interface, better error messages.
2026-05-06 09:51:06 +00:00
xiaoju e38852a761 Merge pull request 'fix(workflow): resolve type errors across all packages' (#16) from fix/type-errors-and-tsbuildinfo into main 2026-05-06 09:44:49 +00:00
xiaoju fd8f1f2491 refactor: move createRole to workflow-util-role
workflow-role-llm now only contains createLlmAdapter (OpenAI chat
completions → AgentFn). All role infrastructure lives in util-role.
2026-05-06 09:42:49 +00:00
xingyue 98b6153070 fix(workflow): resolve type errors across all packages and remove tsbuildinfo from tracking
- Add bun-types and @types/xxhashjs to root devDependencies
- Add types: ['bun-types'] to root and package tsconfigs
- Fix AST Node type narrowing in bundle-validator.ts with AcornNode type and narrowNode helper
- Fix generic ThreadContext variance in create-role-moderator.ts
- Add explicit parameter types in worker.ts
- Fix ChildProcess type in worker-spawn.ts to match spawn() stdio config
- Remove all tsconfig.tsbuildinfo from git tracking
- Add tsconfig.tsbuildinfo to .gitignore
2026-05-06 17:41:11 +08:00
158 changed files with 8730 additions and 2636 deletions
+1
View File
@@ -2,3 +2,4 @@ node_modules/
dist/
bun.lock
*.tgz
tsconfig.tsbuildinfo
+2
View File
@@ -0,0 +1,2 @@
[test]
pathIgnorePatterns = ["dist/**"]
+259
View File
@@ -0,0 +1,259 @@
# @uncaged/workflow — Architecture
**Last updated:** 2026-05-06 by 小橘 🍊(NEKO Team)
---
## Overview
A workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file identified by its XXH64 hash (Crockford Base32). No daemon — processes start on demand and exit when done.
## Package Structure
| Package | npm Name | Purpose |
|---------|----------|---------|
| `workflow` | `@uncaged/workflow` | Core: types, engine, ExtractFn, hash/ULID/registry |
| `cli-workflow` | `@uncaged/cli-workflow` | CLI: `uncaged-workflow` command |
| `workflow-agent-cursor` | `@uncaged/workflow-agent-cursor` | Cursor CLI agent (extracts workspace from ctx) |
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | Hermes CLI agent |
| `workflow-agent-llm` | `@uncaged/workflow-agent-llm` | OpenAI-compatible LLM agent |
| `workflow-role-planner` | `@uncaged/workflow-role-planner` | Pure data: phased planning prompt + schema |
| `workflow-role-coder` | `@uncaged/workflow-role-coder` | Pure data: coding prompt + schema |
| `workflow-role-reviewer` | `@uncaged/workflow-role-reviewer` | Pure data: code review prompt + schema |
| `workflow-role-committer` | `@uncaged/workflow-role-committer` | Pure data: git commit prompt + schema |
| `workflow-template-solve-issue` | `@uncaged/workflow-template-solve-issue` | Composes roles + moderator into a complete workflow |
| `workflow-util-agent` | `@uncaged/workflow-util-agent` | `buildAgentPrompt` + `spawnCli` utilities |
Monorepo with **bun workspace**, `workspace:*` protocol.
## Core Types
```typescript
// --- Sentinel values ---
const START = "__start__";
const END = "__end__";
// --- RoleMeta: maps role names → their meta types ---
type RoleMeta = Record<string, Record<string, unknown>>;
// --- Role Definition: pure data, no execution logic ---
type RoleDefinition<Meta> = {
description: string; // human-readable
systemPrompt: string; // given to agent
extractPrompt: string; // given to extractor
schema: z.ZodType<Meta>; // meta shape (Zod v4)
};
// --- Workflow Definition: pure data, no agent binding ---
type WorkflowDefinition<M extends RoleMeta> = {
description: string;
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
moderator: Moderator<M>;
};
// --- Agent: raw string output, reads role info from context ---
type AgentFn = (ctx: AgentContext) => Promise<string>;
// --- Agent Binding: runtime assignment ---
type AgentBinding = {
agent: AgentFn;
overrides?: Partial<Record<string, AgentFn>>;
};
// --- Extract: structured data from context ---
type ExtractFn = <T>(schema: z.ZodType<T>, prompt: string, ctx: ExtractContext) => Promise<T>;
// --- Moderator: pure routing function ---
type Moderator<M extends RoleMeta> = (ctx: ModeratorContext<M>) => (keyof M & string) | typeof END;
// --- Composition ---
// createWorkflow(def, binding, extract) => WorkflowFn
```
## Three-Phase Engine Loop
Each role execution has three distinct phases with progressive context:
```
┌─→ Phase 1: MODERATOR
│ Context: ModeratorContext { threadId, start, steps }
│ Action: moderator(ctx) → role name | END
│ Phase 2: AGENT
│ Context: AgentContext = ModeratorCtx + { currentRole: { name, systemPrompt } }
│ Action: agent(ctx) → raw string
│ Phase 3: EXTRACTOR
│ Context: ExtractContext = AgentCtx + { agentContent }
│ Action: extract(schema, extractPrompt, ctx) → typed meta
│ Merge: RoleStep { role, content, meta, timestamp }
│ Append to steps
└─────────────────────────────────────────────────────┘
```
### Context Types (progressive)
```typescript
// Phase 1: Moderator sees accumulated state only
type ModeratorContext<M> = {
threadId: string;
start: StartStep;
steps: RoleStep<M>[];
};
// Phase 2: Agent knows its identity
type AgentContext<M> = ModeratorContext<M> & {
currentRole: { name: string; systemPrompt: string };
};
// Phase 3: Extractor has agent output
type ExtractContext<M> = AgentContext<M> & {
agentContent: string;
};
// ThreadContext is an alias for AgentContext (backward compat)
type ThreadContext<M> = AgentContext<M>;
```
### Key Properties
- **Moderator is synchronous and pure** — no I/O, no state mutation
- **Agent gets context, not instructions** — reads `ctx.currentRole.systemPrompt`
- **Extractor is a general tool** — not limited to post-agent extraction; agents can use it too (e.g. Cursor agent extracts workspace path before execution)
- **extractPrompt is a call parameter**, not context state — different callers use different prompts
## Agent Information Sources
An agent has exactly three information sources:
1. **Prior knowledge** — LLM training, agent memory, agent skills
2. **Thread context**`AgentContext` (start, steps, currentRole)
3. **Derived information** — from 1 & 2 (e.g. tool calls, shell commands)
No hidden environment parameters. If an agent needs something (like a workspace path), it extracts it from context using `ExtractFn`.
## Bundle Contract
A workflow bundle is a single `.esm.js` file with two named exports:
```typescript
// Named exports (no default export)
export const descriptor: WorkflowDescriptor;
export const run: WorkflowFn;
type WorkflowFn = (
input: { prompt: string; steps: RoleOutput[] },
options: { threadId: string; maxRounds: number },
) => AsyncGenerator<RoleOutput, WorkflowResult>;
```
### Constraints
- Single `.esm.js` file
- No dynamic `import()`
- All static imports must be Node built-in modules only
- XXH64 hash (Crockford Base32) = globally unique version ID
### Why AsyncGenerator?
- Each `yield` → engine writes to `.data.jsonl`, checks abort/pause
- `return` → engine marks thread complete
- Fork = pass historical steps as `input.steps` to a new generator
- Zero injection — bundle doesn't import from the engine
## Storage Layout
```
~/.uncaged/workflow/
├── bundles/
│ ├── C9NMV6V2TQT81.esm.js # Crockford Base32 of XXH64
│ └── C9NMV6V2TQT81.yaml # Role descriptor
├── logs/ # One folder per bundle hash
│ └── C9NMV6V2TQT81/
│ ├── 01KQXKW…YG.data.jsonl # Thread state
│ └── 01KQXKW…YG.info.jsonl # Debug log
└── workflow.yaml # Registry
```
### ID Encoding: Crockford Base32
- Case-insensitive, filesystem-safe, no ambiguous chars (0/O, 1/I/L)
- Bundle hash: XXH64 → 13-char
- Thread ID: ULID → 26-char (10 timestamp + 16 random)
### Registry (`workflow.yaml`)
```yaml
workflows:
solve-issue:
hash: "C9NMV6V2TQT81"
timestamp: 1714963200000
history:
- hash: "A7BKR3M1NPQ40"
timestamp: 1714876800000
```
### Thread JSONL
**`.data.jsonl`** — Line 1: start record, Line 2+: role outputs
```jsonc
// Start record
{ "name": "solve-issue", "hash": "C9NMV6V2TQT81", "threadId": "01KQXKW…",
"parameters": { "prompt": "Fix bug #3", "options": { "maxRounds": 5 } },
"timestamp": 1714963200000 }
// Role output
{ "role": "planner", "content": "...", "meta": { "phases": [...] }, "timestamp": ... }
```
**`.info.jsonl`** — Structured debug log
```jsonc
{ "tag": "4KNMR2PX", "content": "Loading bundle...", "timestamp": ... }
```
Tags are 8-char Crockford Base32 (40-bit random), one per call site. `grep "4KNMR2PX"` → instant code location.
## Execution Model
- **No daemon.** `uncaged-workflow run <name>` starts a worker process
- Same bundle's threads share one process (memory efficiency)
- Process exits when all threads complete
- Thread termination via IPC within the process
## CLI Commands
| Priority | Command | Description |
|----------|---------|-------------|
| P1 | `add <name> <file.esm.js>` | Register a bundle |
| P1 | `list` | List registered workflows |
| P1 | `show <name>` | Show workflow details |
| P1 | `remove <name>` | Remove a workflow |
| P1 | `run <name> [--prompt] [--max-rounds]` | Start a thread |
| P1 | `threads [name]` | List threads |
| P1 | `thread <id>` | Show thread state |
| P1 | `thread rm <id>` | Delete a thread |
| P1 | `ps` | List running threads |
| P1 | `kill <thread-id>` | Terminate a running thread |
| P2 | `history <name>` | Show version history |
| P2 | `rollback <name> [hash]` | Switch to a previous version |
| P2 | `pause <thread-id>` | Pause a running thread |
| P2 | `resume <thread-id>` | Resume a paused thread |
| P3 | `fork <thread-id> [--from-role <role>]` | Fork from historical state |
All commands implemented and tested. ✅
## Design Decisions
| Decision | Rationale |
|----------|-----------|
| **Role = pure data** | Decouples definition from execution; same role with different agents |
| **Agent bound at runtime** | WorkflowDefinition is reusable; agent choice is deployment concern |
| **Three-phase context** | Each phase sees only what it needs; clean separation |
| **ExtractFn as general tool** | Agents use it for pre-execution extraction; engine uses it for meta |
| **Single-file ESM** | Hash = version, no dependency hell, self-contained |
| **No daemon** | OS handles process lifecycle; unnecessary complexity |
| **Crockford Base32** | Filesystem-safe, readable, compact |
| **No concurrency in registry** | Different workflows have different constraints; belongs at workflow/role level |
| **No dryRun** | Tests use mock agents + mock fetch; simpler architecture |
+315
View File
@@ -0,0 +1,315 @@
# Workflow-as-Agent Implementation Plan
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Enable workflows to invoke other workflows as agents, backed by global CAS and refs tracking.
**Architecture:** Migrate CAS from thread-local to global (`~/.uncaged/workflow/cas/`), add `refs` to RoleStep for GC traceability, then build `workflowAsAgent(name)` factory that resolves workflow name → bundle via registry and spawns a child thread.
**Tech Stack:** TypeScript, Bun, Zod v4, monorepo with `packages/`
**Issue:** https://git.shazhou.work/uncaged/workflow/issues/25
---
## Phase 1: Global CAS Migration
Move CAS storage from `<threadDir>/<threadId>.cas/` to `~/.uncaged/workflow/cas/` (global, content-addressed, immutable). This is a **breaking change** — thread-local `.cas/` directories are abandoned.
### Task 1.1: Add `globalCasDir` helper to `storage-root.ts`
**Objective:** Provide a single function that returns the global CAS directory path.
**Files:**
- Modify: `packages/workflow/src/storage-root.ts`
- Test: `packages/workflow/__tests__/storage-root.test.ts`
**Implementation:**
```typescript
// storage-root.ts — add export
export function getGlobalCasDir(storageRoot?: string): string {
const root = storageRoot ?? getDefaultWorkflowStorageRoot();
return join(root, "cas");
}
```
Export from `packages/workflow/src/index.ts`.
### Task 1.2: Update `cmd-cas.ts` to use global CAS
**Objective:** CLI `cas get/put/list/rm` no longer needs threadId for storage location — CAS is global. But keep threadId in CLI for backward compat of planner/coder prompts (they pass threadId).
**Files:**
- Modify: `packages/cli-workflow/src/cmd-cas.ts`
**Changes:**
- `resolveCasDir` → use `getGlobalCasDir(storageRoot)` instead of deriving from thread data path
- `cmdCasPut` / `cmdCasGet` / `cmdCasList` / `cmdCasRm`: threadId is still accepted (prompts pass it) but storage goes to global dir
- Remove the `resolveThreadDataPath` dependency for CAS operations — thread doesn't need to exist to read CAS
```typescript
import { createThreadCas, getGlobalCasDir } from "@uncaged/workflow";
export async function cmdCasGet(
storageRoot: string,
_threadId: string, // kept for CLI compat, not used for path
hash: string,
): Promise<Result<string, string>> {
const cas = createThreadCas(getGlobalCasDir(storageRoot));
const content = await cas.get(hash);
if (content === null) {
return err(`cas entry not found: ${hash}`);
}
return ok(content);
}
// ... same pattern for put/list/rm
```
### Task 1.3: Update `cmd-thread.ts` — thread rm no longer deletes `.cas/`
**Objective:** Since CAS is global, `thread rm` should NOT delete CAS entries. CAS cleanup is GC's job.
**Files:**
- Modify: `packages/cli-workflow/src/cmd-thread.ts`
- Check: remove any `rmdir` / `unlink` of `<threadId>.cas/` directory
### Task 1.4: Rename `createThreadCas` → `createCasStore`
**Objective:** The name `createThreadCas` is misleading now. Rename to `createCasStore`.
**Files:**
- Modify: `packages/workflow/src/cas.ts` — rename function
- Modify: `packages/workflow/src/index.ts` — update export (keep `createThreadCas` as deprecated alias for one release)
- Modify: all consumers (`cmd-cas.ts`)
### Task 1.5: Update tests
**Objective:** All CAS-related tests use global dir instead of thread-local.
**Files:**
- Modify: `packages/cli-workflow/__tests__/commands.test.ts`
- Verify: `bun test` passes
### Task 1.6: Clean up old thread-local `.cas/` references
**Objective:** Remove dead code that creates/reads thread-local `.cas/` directories.
**Files:**
- Search all `*.ts` for `.cas` path construction patterns
- Remove orphaned helpers
---
## Phase 2: RoleStep `refs` Tracking
Add `refs: string[]` to persisted role steps so GC can trace which CAS entries are alive.
### Task 2.1: Add `refs` to `RoleOutput` and engine persistence
**Objective:** Every role step can declare which CAS hashes it produced or consumed.
**Files:**
- Modify: `packages/workflow/src/types.ts`
- Modify: `packages/workflow/src/engine.ts`
**Changes to `types.ts`:**
```typescript
export type RoleOutput = {
role: string;
content: string;
meta: Record<string, unknown>;
refs: string[]; // CAS hashes produced/consumed by this step
};
```
**Changes to `engine.ts`:**
- `appendDataLine` for role steps: include `refs` field (default `[]` if not provided)
### Task 2.2: Auto-populate refs from meta hashes
**Objective:** The engine should automatically extract CAS hashes from `meta` to populate `refs`, so roles don't need to manually track them.
**Strategy:** After meta extraction, walk the meta object and collect any string that looks like a CAS hash (Crockford Base32, 13 chars). This is a heuristic but works because CAS hashes are distinctive.
Alternative (simpler): Let each `RoleDefinition` optionally declare a `extractRefs(meta: M) => string[]` function. For planner, this returns `meta.phases.map(p => p.hash)`. For coder, `[meta.completedPhase]`.
**Recommended:** The explicit `extractRefs` approach — no magic, no false positives.
**Files:**
- Modify: `packages/workflow/src/types.ts` — add optional `extractRefs` to `RoleDefinition`
- Modify: `packages/workflow/src/create-workflow.ts` — call `extractRefs` after meta extraction, set on `RoleOutput.refs`
- Modify: `packages/workflow-role-planner/src/planner.ts` — implement `extractRefs`
- Modify: `packages/workflow-role-coder/src/coder.ts` — implement `extractRefs`
```typescript
// types.ts — RoleDefinition addition
export type RoleDefinition<Meta extends Record<string, unknown>> = {
description: string;
systemPrompt: string;
extractPrompt: string;
schema: z.ZodType<Meta>;
extractRefs?: (meta: Meta) => string[]; // CAS hashes to track
};
// planner.ts
extractRefs: (meta) => meta.phases.map(p => p.hash),
// coder.ts
extractRefs: (meta) => [meta.completedPhase],
```
### Task 2.3: Update fork logic to preserve refs
**Objective:** When forking a thread, `refs` from historical steps must be carried over.
**Files:**
- Modify: `packages/workflow/src/fork-thread.ts`
- Verify: `ForkHistoricalStep` / `PrefilledDiskStep` include `refs`
### Task 2.4: Tests for refs tracking
**Files:**
- Add: `packages/workflow/__tests__/refs-tracking.test.ts`
- Verify: refs appear in `.data.jsonl` output
---
## Phase 3: CAS Garbage Collection
### Task 3.1: Implement `gc.ts` in `@uncaged/workflow`
**Objective:** Mark-and-sweep GC — scan all thread `.data.jsonl` files, collect `refs`, delete orphaned CAS entries.
**Files:**
- Create: `packages/workflow/src/gc.ts`
- Export from: `packages/workflow/src/index.ts`
```typescript
export type GcResult = {
scannedThreads: number;
activeRefs: number;
deletedEntries: number;
deletedHashes: string[];
};
export async function garbageCollectCas(storageRoot: string): Promise<GcResult> {
// 1. Find all .data.jsonl files under storageRoot
// 2. Parse each, flatMap step.refs → Set<string>
// 3. List all CAS entries via createCasStore(globalCasDir).list()
// 4. Delete entries not in active set
// 5. Return stats
}
```
### Task 3.2: Add `uncaged-workflow gc` CLI command
**Files:**
- Create: `packages/cli-workflow/src/cmd-gc.ts`
- Modify: `packages/cli-workflow/src/cli-dispatch.ts` — add `gc` subcommand
### Task 3.3: Run GC on `thread rm`
**Files:**
- Modify: `packages/cli-workflow/src/cmd-thread.ts` — after deleting thread data, optionally run GC
### Task 3.4: Tests for GC
**Files:**
- Create: `packages/cli-workflow/__tests__/gc-cli.test.ts`
---
## Phase 4: `workflowAsAgent` Factory
### Task 4.1: Create `workflowAsAgent` in `@uncaged/workflow`
**Objective:** Factory function that takes a workflow name, resolves to bundle, returns an `AgentFn`.
**Files:**
- Create: `packages/workflow/src/workflow-as-agent.ts`
- Export from: `packages/workflow/src/index.ts`
```typescript
import type { AgentFn } from "./types.js";
export type WorkflowAsAgentOptions = {
storageRoot?: string;
};
export function workflowAsAgent(
workflowName: string,
options?: WorkflowAsAgentOptions,
): AgentFn {
return async (ctx) => {
const storageRoot = options?.storageRoot ?? getDefaultWorkflowStorageRoot();
// 1. Read registry → resolve name to bundle hash + path
const registry = await readWorkflowRegistry(storageRoot);
const entry = getRegisteredWorkflow(registry, workflowName);
if (entry === null) {
return `ERROR: workflow "${workflowName}" not found in registry`;
}
// 2. Load bundle
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
const bundleExports = await extractBundleExports(bundlePath);
// 3. Create child thread input from ctx.start.content (parent prompt)
const input: ThreadInput = {
prompt: ctx.start.content,
steps: [],
};
// 4. Generate child threadId
const childThreadId = generateUlid();
// 5. Execute — collect all yields, return final content
const io: ExecuteThreadIo = { ... };
const result = await executeThread(bundleExports.run, workflowName, input, ...);
// 6. Return summary as agent content
return result.summary;
};
}
```
### Task 4.2: System-level depth limit
**Objective:** Prevent infinite recursion. Track depth via thread metadata, enforce a global max (default 3, configurable in `workflow.yaml`).
**Files:**
- Modify: `packages/workflow/src/types.ts` — add `depth` to `WorkflowFnOptions`
- Modify: `packages/workflow/src/workflow-as-agent.ts` — increment depth, check limit
- Modify: registry or config types for `maxDepth` setting
### Task 4.3: Tests for workflowAsAgent
**Files:**
- Create: `packages/workflow/__tests__/workflow-as-agent.test.ts`
- Test: name resolution, depth limit, child thread execution
### Task 4.4: Integration test — nested workflow
**Objective:** Create a minimal test workflow that calls another workflow via `workflowAsAgent`.
**Files:**
- Create: `packages/workflow/__tests__/workflow-as-agent-integration.test.ts`
---
## Execution Order
```
Phase 1 (Global CAS) → Phase 2 (refs) → Phase 3 (GC) → Phase 4 (workflowAsAgent)
```
Each phase is independently mergeable. Phase 3 depends on Phase 2 (needs refs to know what's alive). Phase 4 depends on Phase 1 (global CAS for cross-thread sharing).
## Breaking Changes
- CAS storage location moves from `<thread>.cas/` to `~/.uncaged/workflow/cas/`
- `RoleOutput` gains required `refs: string[]` field
- Existing threads with thread-local CAS will lose access to old CAS data (acceptable — those are short-lived workflow artifacts)
- `createThreadCas` renamed to `createCasStore` (alias kept temporarily)
-438
View File
@@ -1,438 +0,0 @@
# RFC-001: Workflow Engine Design
**Author:** 小橘 🍊(NEKO Team)
**Date:** 2026-05-06
**Status:** Draft
---
## 1. Package Structure
| Package | npm Name | Binary |
|---------|----------|--------|
| Core lib | `@uncaged/workflow` | — |
| CLI | `@uncaged/cli-workflow` | `uncaged-workflow` |
Future: `@uncaged/cli` umbrella, invoke via `uncaged workflow <subcommand>`.
Monorepo uses **bun workspace**.
## 2. Workflow Physical Implementation
A **Workflow** is a single-file ESM module that **named-exports** an **AsyncGenerator** function as `run` and workflow metadata as `descriptor`:
```typescript
/** What each yield produces — one role's output. */
type RoleOutput = {
role: string;
content: string;
meta: Record<string, unknown>;
};
/** What the generator returns when done. */
type WorkflowResult = {
returnCode: number;
summary: string;
};
/** Input to a workflow — prompt + optional historical steps for fork/resume. */
type ThreadInput = {
prompt: string;
steps: RoleOutput[]; // [] for new thread, pre-filled for fork/resume
};
/** The bundle contract — an AsyncGenerator, not a Promise. */
type WorkflowFn = (
input: ThreadInput,
options: { isDryRun: boolean; maxRounds: number }
) => AsyncGenerator<RoleOutput, WorkflowResult>;
```
### Why AsyncGenerator?
The workflow **yields** each role output instead of writing to an injected writer or
exporting a framework-specific shape:
```typescript
// Example bundle — zero framework dependency (named exports only)
export const descriptor = {
description: "Fix auth bug",
roles: {
planner: {
description: "Plans the fix",
schema: { type: "object", properties: { files: { type: "array", items: { type: "string" } } } },
},
coder: {
description: "Implements the plan",
schema: { type: "object", properties: { diff: { type: "string" } } },
},
},
};
export const run = async function* (input, options) {
const plan = await callLLM("plan: " + input.prompt);
yield { role: "planner", content: plan, meta: { files: ["src/auth.ts"] } };
const code = await callLLM("implement: " + plan);
yield { role: "coder", content: code, meta: { diff: "..." } };
return { returnCode: 0, summary: "Fixed auth bug" };
};
```
**Engine controls the loop**, not the bundle:
- Each `yield` → engine writes to `.data.jsonl`, checks `AbortSignal`, handles pause/resume
- `return` → engine writes the final result, marks thread complete
- **Fork** = read historical steps from `.data.jsonl`, pass as `input.steps` to a new generator
- **Zero injection** — the bundle doesn't import or receive anything from the engine
### Fork/Resume via ThreadInput
When using the `createRoleModerator` helper, fork is **naturally handled**:
```typescript
// The moderator receives ThreadContext with historical steps
// It sees planner already ran → routes to coder automatically
const gen = workflow(
{ prompt: "fix bug #3", steps: [{ role: "planner", content: "...", meta: {} }] },
{ isDryRun: false, maxRounds: 10 }
);
// First yield will be coder's output, not planner's
```
No special replay logic needed — the moderator/role pattern inherently supports
resuming from any snapshot, because moderator routing is a pure function of the
accumulated steps.
This follows the **Dependency Inversion Principle**: the engine depends on the
generator protocol (a language primitive), not on a framework-specific `WorkflowDefinition`.
Bundles remain pure functions with no coupling to `@uncaged/workflow`.
### Relationship to Role/Moderator Pattern
The Role + Moderator pattern from Section 8 is one **implementation strategy** inside a
bundle, not the bundle contract itself. A helper like `createRoleModerator(roles, moderator)`
can produce the AsyncGenerator internally, but simple workflows can yield directly without
any framework types.
### Constraints
- Single `.esm.js` file
- Named exports `run` (callable AsyncGenerator workflow) and `descriptor` (metadata object)
- No default export
- No dynamic `import()`
- All static imports must be Node built-in modules only
This guarantees the file is self-contained, and its **XXH64 hash** (encoded as Crockford Base32) serves as a globally unique version identifier.
### Role Descriptor (`export const descriptor`)
The bundle **must** export a `descriptor` object describing roles for tooling/agent consumption.
Shape: `{ description: string, roles: Record<string, { description: string, schema: JSONSchema }> }`
When you register a bundle via `uncaged-workflow add`, the engine imports the module, validates `descriptor`, and writes `{hash}.yaml` next to `{hash}.esm.js` under `bundles/` (same serialized shape as below):
```yaml
description: "Workflow brief introduction"
roles:
planner:
description: "Analyzes the issue and creates a plan"
schema:
type: object
properties:
plan:
type: string
files:
type: array
items:
type: string
coder:
description: "Implements the plan"
schema:
type: object
properties:
diff:
type: string
```
Execution uses `run` only; YAML is for tooling and introspection.
## 3. Storage Layout
All data lives under `~/.uncaged/workflow/`:
```
~/.uncaged/workflow/
├── bundles/ # ESM bundles
│ ├── C9NMV6V2TQT81.esm.js # Crockford Base32 of XXH64 hash
│ └── C9NMV6V2TQT81.yaml # Role descriptor (from bundle export, at register time)
├── logs/ # Thread data, one folder per bundle hash
│ └── C9NMV6V2TQT81/
│ ├── 01KQXKW18CT8G75T53R8F4G7YG.data.jsonl
│ └── 01KQXKW18CT8G75T53R8F4G7YG.info.jsonl
└── workflow.yaml # Registry
```
**Not** a git repo. **Not** an npm package. Bundles are self-contained single files.
### ID Encoding
All IDs use **Crockford Base32**:
- Better readability than Base64
- Higher density than hex (shorter filenames)
- ULID: 10 chars timestamp (high 2 bits zero-padded for future use) + 16 chars random
## 4. Registry (`workflow.yaml`)
```yaml
workflows:
solve-issue:
hash: "C9NMV6V2TQT81"
timestamp: 1714963200000
history:
- hash: "A7BKR3M1NPQ40"
timestamp: 1714876800000
- hash: "X2FGH8J4KLM56"
timestamp: 1714790400000
```
Type:
```typescript
{
workflows: Record<string, {
hash: string; // Crockford Base32 of current XXH64
timestamp: number;
history: { hash: string; timestamp: number }[];
}>
}
```
No concurrency control or timeout settings in the registry — those belong to each workflow/role/adapter.
## 5. Thread JSONL Format
### `.data.jsonl` — Thread State
**Line 1: Start record**
```jsonc
{
"name": "solve-issue",
"hash": "C9NMV6V2TQT81",
"threadId": "01KQXKW18CT8G75T53R8F4G7YG",
"parameters": {
"prompt": "Fix the login redirect bug in #3",
"options": {
"isDryRun": false,
"maxRounds": 5
}
},
"timestamp": 1714963200000
}
```
**Line 2+: Role outputs**
```jsonc
{
"role": "planner",
"content": "Plan: modify auth middleware...",
"meta": { "plan": "...", "files": ["src/auth.ts"] },
"timestamp": 1714963201000
}
```
### `.info.jsonl` — Debug Log
```jsonc
{
"tag": "4KNMR2PX", // 40-bit random, Crockford Base32 (8 chars)
"content": "Loading workflow bundle...",
"timestamp": 1714963200500
}
```
## 6. Execution Model
- **No daemon.** `uncaged-workflow run <name>` starts a worker process.
- Same bundle's threads share one process (memory efficiency).
- Process exits automatically when all threads complete.
- Thread termination requires **IPC** within the process (not just kill PID).
## 7. CLI Requirements
### P1 (Must Have)
| Command | Description |
|---------|-------------|
| `uncaged-workflow add <name> <file.esm.js> [--types <path>]` | Register a compiled `.esm.js` bundle (descriptor extracted from `export const descriptor`) |
| `uncaged-workflow list` | List registered workflows |
| `uncaged-workflow show <name>` | Show workflow details |
| `uncaged-workflow remove <name>` | Remove a workflow |
| `uncaged-workflow run <name> [--prompt] [--dry-run] [--max-rounds]` | Start a thread |
| `uncaged-workflow threads [name]` | List threads (optionally filter by workflow) |
| `uncaged-workflow thread <id>` | Show thread state |
| `uncaged-workflow thread rm <id>` | Delete a thread |
| `uncaged-workflow ps` | List running threads |
| `uncaged-workflow kill <thread-id>` | Terminate a running thread (via IPC) |
### P2 (Should Have)
| Command | Description |
|---------|-------------|
| `uncaged-workflow history <name>` | Show version history |
| `uncaged-workflow rollback <name> [hash]` | Switch to a previous version |
| `uncaged-workflow pause <thread-id>` | Pause a running thread |
| `uncaged-workflow resume <thread-id>` | Resume a paused thread |
### P3 (Nice to Have)
| Command | Description |
|---------|-------------|
| `uncaged-workflow fork <thread-id> [--from-role <role>]` | Fork from a historical thread state |
## 8. Role/Moderator Pattern (Helper, Not Contract)
The bundle contract is the AsyncGenerator from Section 2. The Role + Moderator pattern
below is a **convenience helper** for the common case of multi-role workflows with a
routing function. It lives in `@uncaged/workflow` as an optional utility.
### Helper Function
```typescript
function createRoleModerator<M extends RoleMeta>(
def: { roles: { [K in keyof M & string]: Role<M[K]> }; moderator: Moderator<M> }
): WorkflowFn; // returns (input: ThreadInput, options) => AsyncGenerator
```
Usage in a bundle:
```typescript
import { createRoleModerator, END } from "@uncaged/workflow";
export const descriptor = {
description: "Example multi-role workflow",
roles: {
planner: { description: "Plans work", schema: {} },
coder: { description: "Writes code", schema: {} },
},
};
export const run = createRoleModerator({
roles: { planner, coder },
moderator(ctx) { return ctx.steps.length === 0 ? "planner" : END; },
});
// Accepts ThreadInput — fork with pre-filled steps works automatically
```
### Supporting Types
```typescript
/** Sentinel values for automaton control flow. */
const START = "__start__" as const;
const END = "__end__" as const;
/** Maps role names → their meta types. Single generic drives all inference. */
type RoleMeta = Record<string, Record<string, unknown>>;
/** Typed output of a Role execution. */
type RoleResult<Meta> = { content: string; meta: Meta };
/** Engine start frame: initial prompt + thread identity. */
type StartStep = {
role: START;
content: string; // the user prompt
meta: { maxRounds: number; threadId: string };
timestamp: number;
};
/** A completed role step in the thread. */
type RoleStep<M extends RoleMeta> = {
[K in keyof M & string]: { role: K; meta: M[K]; content: string; timestamp: number };
}[keyof M & string];
/** Thread-scoped context passed to roles and moderator. */
type ThreadContext<M extends RoleMeta = RoleMeta> = {
threadId: string;
start: StartStep;
steps: RoleStep<M>[];
};
/**
* A Role — receives full thread context, returns typed content + meta.
* Implementation can be an agent, LLM call, script, HTTP request, etc.
*/
type Role<Meta> = (ctx: ThreadContext) => Promise<RoleResult<Meta>>;
/**
* An Agent — raw string output interface for LLM/CLI adapters.
* Structured meta is extracted by the role's extract layer.
*/
type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise<string>;
/**
* The Moderator — a pure routing function.
* Receives the full thread context (start + all prior steps).
* On initial call, `steps` is empty.
* Returns the next role name or END to terminate.
*/
type Moderator<M extends RoleMeta> = (ctx: ThreadContext<M>) => (keyof M & string) | END;
/** Complete workflow definition as authored by users. */
type WorkflowDefinition<M extends RoleMeta> = {
name: string;
roles: { [K in keyof M & string]: Role<M[K]> };
moderator: Moderator<M>;
};
```
### Execution Flow (when using createRoleModerator)
```
START (prompt) → Moderator → Role A → Moderator → Role B → ... → Moderator → END
```
1. Engine creates a `StartStep` with the user prompt and maxRounds
2. Moderator is called with `steps = []`, returns the first role name
3. Role executes, appends a `RoleStep` to the thread
4. Moderator is called again with updated steps, returns next role or END
5. Repeat until END or maxRounds reached
### Responsibilities
| Component | Responsibility | Purity |
|-----------|---------------|--------|
| **Moderator** | Route to next role based on thread state | Pure function, no side effects |
| **Role** | Execute a step (call LLM, run script, etc.) | Async, may have side effects |
| **AgentFn** | Low-level LLM/CLI invocation adapter | Async, side effects |
### Key Constraints
- Moderator is **synchronous and pure** — no I/O, no state mutation
- Roles receive the **full thread context** (not just the last message)
- Round count = `steps.length`; max rounds in `start.meta.maxRounds`
- The `meta` field on each step is **typed per role** via the `RoleMeta` generic
## 9. Design Decisions & Rationale
### Why single-file ESM?
- Hash = version. No ambiguity.
- No dependency hell. Self-contained.
- Simple to distribute, store, and verify.
### Why no daemon?
- Unnecessary complexity for process-per-bundle model.
- OS process management (systemd, etc.) handles restarts.
- IPC within process handles thread lifecycle.
### Why Crockford Base32?
- Case-insensitive, filesystem-safe.
- No ambiguous characters (0/O, 1/I/L).
- More compact than hex (13 chars for 64-bit vs 16).
### Why not control concurrency in registry?
- Different workflows have different constraints.
- Same workflow may allow cross-project concurrency but not intra-project.
- Concurrency belongs at workflow/role/adapter level.
+31 -9
View File
@@ -1,9 +1,14 @@
import { createRoleModerator, END, type Role } from "@uncaged/workflow";
import { createExtract, createWorkflow, END, type RoleDefinition } from "@uncaged/workflow";
import * as z from "zod/v4";
type Roles = {
greeter: { greeting: string };
};
const greeterMetaSchema = z.object({
greeting: z.string(),
});
export const descriptor = {
description: "A simple hello world workflow",
roles: {
@@ -18,14 +23,31 @@ export const descriptor = {
},
};
const greeter: Role<Roles["greeter"]> = async (ctx) => ({
content: `Hello, ${ctx.start.content}`,
meta: { greeting: "Hello!" },
const greeter: RoleDefinition<Roles["greeter"]> = {
description: "Generates a greeting",
systemPrompt: "You greet the user briefly.",
extractPrompt: "Extract the greeting string produced for the user.",
schema: greeterMetaSchema,
extractRefs: null,
extractMode: "single",
};
const extract = createExtract({
baseUrl: "http://127.0.0.1:9",
apiKey: "",
model: "",
});
export const run = createRoleModerator<Roles>({
roles: { greeter },
moderator(ctx) {
return ctx.steps.length === 0 ? "greeter" : END;
export const run = createWorkflow<Roles>(
{
roles: { greeter },
moderator(ctx) {
return ctx.steps.length === 0 ? "greeter" : END;
},
},
});
{
agent: async (ctx) => `Hello, ${ctx.start.content}`,
},
extract,
null,
);
+2 -1
View File
@@ -3,6 +3,7 @@
"private": true,
"type": "module",
"dependencies": {
"@uncaged/workflow": "workspace:*"
"@uncaged/workflow": "workspace:*",
"zod": "^4.0.0"
}
}
+5 -2
View File
@@ -7,11 +7,14 @@
],
"scripts": {
"build": "bun run --filter '*' build",
"check": "biome check .",
"check": "bunx tsc --build && biome check .",
"typecheck": "bunx tsc --build",
"format": "biome format --write .",
"test": "bun run --filter '*' test"
},
"devDependencies": {
"@biomejs/biome": "^2.4.14"
"@biomejs/biome": "^2.4.14",
"@types/xxhashjs": "^0.2.4",
"bun-types": "^1.3.13"
}
}
@@ -3,8 +3,9 @@ import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promise
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow";
import { getGlobalCasDir, getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow";
import { cmdAdd } from "../src/cmd-add.js";
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/cmd-cas.js";
import { cmdHistory } from "../src/cmd-history.js";
import { cmdList, formatListLines } from "../src/cmd-list.js";
import { cmdRemove } from "../src/cmd-remove.js";
@@ -15,6 +16,9 @@ import { addCliArgs } from "./bundle-fixture.js";
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {} };
`;
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
`;
describe("cli workflow commands", () => {
let prevEnv: string | undefined;
let storageRoot: string;
@@ -40,11 +44,13 @@ describe("cli workflow commands", () => {
const bundlePath = join(bundleDir, "demo.esm.js");
await writeFile(
bundlePath,
`${fixtureDescriptor}import fs from "node:fs";
`${fixtureDescriptor}${wfPutImport}import fs from "node:fs";
export const run = async function* (input) {
export const run = async function* (input, options) {
fs.existsSync(".");
yield { role: "noop", content: input.prompt, meta: { done: true } };
const cas = options.cas;
const h = await putContentMerkleNode(cas, input.prompt);
yield { role: "noop", contentHash: h, meta: { done: true }, refs: [h] };
return { returnCode: 0, summary: "done" };
}
`,
@@ -111,8 +117,8 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
const bundlePath = join(storageRoot, "solo.esm.js");
await writeFile(
bundlePath,
`export const run = async function* (input) {
yield { role: "x", content: input.prompt, meta: {} };
`export const run = async function* () {
yield { role: "x", contentHash: "STUBHASH00000000000000001", meta: {}, refs: [] };
return { returnCode: 0, summary: "ok" };
}
`,
@@ -140,8 +146,11 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
},
},
};
export const run = async function* (input) {
yield { role: "greeter", content: input.prompt, meta: { greeting: "hi" } };
${wfPutImport}
export const run = async function* (input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, input.prompt);
yield { role: "greeter", contentHash: h, meta: { greeting: "hi" }, refs: [h] };
return { returnCode: 0, summary: "ok" };
};
`,
@@ -179,8 +188,10 @@ export const run = async function* (input) {
const bundlePath = join(bundleDir, "demo.esm.js");
await writeFile(
bundlePath,
`${fixtureDescriptor}export const run = async function* (input) {
yield { role: "a", content: "x", meta: {} };
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "x");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "x" };
}
`,
@@ -208,8 +219,10 @@ export const run = async function* (input) {
const dtsPath = join(bundleDir, "types.d.ts");
await writeFile(
bundlePath,
`${fixtureDescriptor}export const run = async function* (input) {
yield { role: "a", content: "x", meta: {} };
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "x");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "x" };
}
`,
@@ -239,8 +252,10 @@ export const run = async function* (input) {
const bundlePath = join(bundleDir, "demo.esm.js");
await writeFile(
bundlePath,
`${fixtureDescriptor}export const run = async function* (input) {
yield { role: "a", content: "x", meta: {} };
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "x");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "x" };
}
`,
@@ -260,13 +275,17 @@ export const run = async function* (input) {
const bundleDir = join(storageRoot, "src");
await mkdir(bundleDir, { recursive: true });
const bundlePath = join(bundleDir, "demo.esm.js");
const v1 = `${fixtureDescriptor}export const run = async function* (input) {
yield { role: "a", content: "v1", meta: {} };
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "v1");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "v1" };
}
`;
const v2 = `${fixtureDescriptor}export const run = async function* (input) {
yield { role: "a", content: "v2", meta: {} };
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "v2");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "v2" };
}
`;
@@ -298,13 +317,17 @@ export const run = async function* (input) {
const bundleDir = join(storageRoot, "src");
await mkdir(bundleDir, { recursive: true });
const bundlePath = join(bundleDir, "demo.esm.js");
const v1 = `${fixtureDescriptor}export const run = async function* (input) {
yield { role: "a", content: "v1", meta: {} };
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "v1");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "v1" };
}
`;
const v2 = `${fixtureDescriptor}export const run = async function* (input) {
yield { role: "a", content: "v2", meta: {} };
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "v2");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "v2" };
}
`;
@@ -346,8 +369,10 @@ export const run = async function* (input) {
const bundlePath = join(bundleDir, "demo.esm.js");
await writeFile(
bundlePath,
`${fixtureDescriptor}export const run = async function* (input) {
yield { role: "a", content: "x", meta: {} };
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "x");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "x" };
}
`,
@@ -357,8 +382,10 @@ export const run = async function* (input) {
expect(add1.ok).toBe(true);
await writeFile(
bundlePath,
`${fixtureDescriptor}export const run = async function* (input) {
yield { role: "a", content: "y", meta: {} };
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "y");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "y" };
}
`,
@@ -371,14 +398,47 @@ export const run = async function* (input) {
expect(bad.ok).toBe(false);
});
test("cas put/get/list/rm use global cas dir (thread id not required for storage)", async () => {
const put = await cmdCasPut(storageRoot, "nonexistent-thread-id", "phase doc");
expect(put.ok).toBe(true);
if (!put.ok) {
return;
}
const hash = put.value;
const blobPath = join(getGlobalCasDir(storageRoot), `${hash}.txt`);
expect(await readFile(blobPath, "utf8")).toBe("phase doc");
const got = await cmdCasGet(storageRoot, "other-thread", hash);
expect(got.ok).toBe(true);
if (!got.ok) {
return;
}
expect(got.value).toBe("phase doc");
const listed = await cmdCasList(storageRoot, "another-thread");
expect(listed.ok).toBe(true);
if (!listed.ok) {
return;
}
expect(listed.value).toContain(hash);
const removed = await cmdCasRm(storageRoot, "rm-thread", hash);
expect(removed.ok).toBe(true);
const missing = await cmdCasGet(storageRoot, "after-rm", hash);
expect(missing.ok).toBe(false);
});
test("rollback rejects missing bundle file for target hash", async () => {
const bundleDir = join(storageRoot, "src");
await mkdir(bundleDir, { recursive: true });
const bundlePath = join(bundleDir, "demo.esm.js");
await writeFile(
bundlePath,
`${fixtureDescriptor}export const run = async function* (input) {
yield { role: "a", content: "x", meta: {} };
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "x");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "x" };
}
`,
@@ -392,8 +452,10 @@ export const run = async function* (input) {
const hash1 = add1.value.hash;
await writeFile(
bundlePath,
`${fixtureDescriptor}export const run = async function* (input) {
yield { role: "a", content: "y", meta: {} };
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "y");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "y" };
}
`,
@@ -0,0 +1,4 @@
{"name":"demo-live","hash":"C9NMV6V2TQT81","threadId":"01LIVECMPLT01DDDDDDDDDDDDG","parameters":{"prompt":"hello","options":{"maxRounds":5,"depth":0}},"timestamp":1714963400000}
{"role":"planner","contentHash":"FF7YQ5W3S2EV6","meta":{"phase":"plan","flags":[1,2]},"refs":[],"timestamp":1714963201000}
{"role":"coder","contentHash":"EN34XX1W4WAFJ","meta":{},"refs":[],"timestamp":1714963202000}
{"returnCode":0,"summary":"fixture completed"}
@@ -0,0 +1,2 @@
{"tag":"DEBUGTAG1","content":"bundle loaded","timestamp":1714963400050}
{"tag":"DEBUGTAG2","content":"multi\nline","timestamp":1714963400500}
@@ -0,0 +1,2 @@
{"name":"demo-live","hash":"C9NMV6V2TQT81","threadId":"01LIVEINFLY01DDDDDDDDDDDDG","parameters":{"prompt":"hello","options":{"maxRounds":5,"depth":0}},"timestamp":1714963200000}
{"role":"planner","contentHash":"P6M9FHE1GSBN0","meta":{"x":1},"refs":[],"timestamp":1714963201000}
@@ -0,0 +1,2 @@
{"name":"demo-live-old","hash":"C9NMV6V2TQT81","threadId":"01LIVEOLDER01DDDDDDDDDDDDG","parameters":{"prompt":"old","options":{"maxRounds":5,"depth":0}},"timestamp":1714963000000}
{"returnCode":0,"summary":"older thread"}
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createCasStore, getContentMerklePayload, getGlobalCasDir } from "@uncaged/workflow";
import { cmdAdd } from "../src/cmd-add.js";
import { cmdFork } from "../src/cmd-fork.js";
import { cmdRun } from "../src/cmd-run.js";
@@ -9,7 +10,9 @@ import { pathExists } from "../src/fs-utils.js";
import { addCliArgs } from "./bundle-fixture.js";
/** Three-role workflow that respects `input.steps` for fork/resume. */
const threeRoleBundleSource = `export const descriptor = {
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
export const descriptor = {
description: "fork-cli",
roles: {
planner: { description: "planner", schema: {} },
@@ -17,20 +20,21 @@ const threeRoleBundleSource = `export const descriptor = {
reviewer: { description: "reviewer", schema: {} },
},
};
export const run = async function* (input) {
export const run = async function* (input, options) {
const cas = options.cas;
const has = (r) => input.steps.some((s) => s.role === r);
if (!has("planner")) {
yield { role: "planner", content: "p1", meta: { k: "planner" } };
const h = await putContentMerkleNode(cas, "p1");
yield { role: "planner", contentHash: h, meta: { k: "planner" }, refs: [h] };
}
if (!has("coder")) {
yield { role: "coder", content: "c1", meta: { k: "coder" } };
const h = await putContentMerkleNode(cas, "c1");
yield { role: "coder", contentHash: h, meta: { k: "coder" }, refs: [h] };
}
if (!has("reviewer")) {
yield {
role: "reviewer",
content: "rev-" + String(input.steps.length),
meta: { k: "reviewer" },
};
const body = "rev-" + String(input.steps.length);
const h = await putContentMerkleNode(cas, body);
yield { role: "reviewer", contentHash: h, meta: { k: "reviewer" }, refs: [h] };
}
return { returnCode: 0, summary: "done" };
};
@@ -98,7 +102,7 @@ describe("cli fork", () => {
}
const hash = added.value.hash;
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
expect(ran.ok).toBe(true);
if (!ran.ok) {
return;
@@ -107,7 +111,7 @@ describe("cli fork", () => {
const sourceData = join(storageRoot, "logs", hash, `${sourceId}.data.jsonl`);
const sourceRunning = join(storageRoot, "logs", hash, `${sourceId}.running`);
await waitUntilRunningAbsent(sourceRunning);
await waitUntilMinDataLines(sourceData, 4);
await waitUntilMinDataLines(sourceData, 5);
const forked = await cmdFork(storageRoot, sourceId, "planner");
expect(forked.ok).toBe(true);
@@ -118,21 +122,22 @@ describe("cli fork", () => {
const newData = join(storageRoot, "logs", hash, `${newId}.data.jsonl`);
const newRunning = join(storageRoot, "logs", hash, `${newId}.running`);
await waitUntilRunningAbsent(newRunning);
await waitUntilMinDataLines(newData, 4);
await waitUntilMinDataLines(newData, 5);
const text = await readFile(newData, "utf8");
const lines = text
.trim()
.split("\n")
.filter((l) => l !== "");
expect(lines.length).toBe(4);
expect(lines.length).toBe(5);
const start = JSON.parse(lines[0] ?? "{}") as Record<string, unknown>;
expect(start.threadId).toBe(newId);
expect(start.forkFrom).toEqual({ threadId: sourceId });
const last = JSON.parse(lines[lines.length - 1] ?? "{}") as Record<string, unknown>;
expect(last.role).toBe("reviewer");
expect(last.content).toBe("rev-1");
const lastRoleLine = JSON.parse(lines[lines.length - 2] ?? "{}") as Record<string, unknown>;
expect(lastRoleLine.role).toBe("reviewer");
const cas = createCasStore(getGlobalCasDir(storageRoot));
expect(await getContentMerklePayload(cas, String(lastRoleLine.contentHash))).toBe("rev-1");
});
test("fork without --from-role retries last role", async () => {
@@ -148,7 +153,7 @@ describe("cli fork", () => {
}
const hash = added.value.hash;
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
expect(ran.ok).toBe(true);
if (!ran.ok) {
return;
@@ -157,7 +162,7 @@ describe("cli fork", () => {
const sourceData = join(storageRoot, "logs", hash, `${sourceId}.data.jsonl`);
const sourceRunning = join(storageRoot, "logs", hash, `${sourceId}.running`);
await waitUntilRunningAbsent(sourceRunning);
await waitUntilMinDataLines(sourceData, 4);
await waitUntilMinDataLines(sourceData, 5);
const forked = await cmdFork(storageRoot, sourceId, null);
expect(forked.ok).toBe(true);
@@ -168,22 +173,23 @@ describe("cli fork", () => {
const newData = join(storageRoot, "logs", hash, `${newId}.data.jsonl`);
const newRunning = join(storageRoot, "logs", hash, `${newId}.running`);
await waitUntilRunningAbsent(newRunning);
await waitUntilMinDataLines(newData, 4);
await waitUntilMinDataLines(newData, 5);
const text = await readFile(newData, "utf8");
const lines = text
.trim()
.split("\n")
.filter((l) => l !== "");
expect(lines.length).toBe(4);
expect(lines.length).toBe(5);
const replayCoder = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
expect(replayCoder.role).toBe("coder");
expect(replayCoder.content).toBe("c1");
const cas = createCasStore(getGlobalCasDir(storageRoot));
expect(await getContentMerklePayload(cas, String(replayCoder.contentHash))).toBe("c1");
const last = JSON.parse(lines[lines.length - 1] ?? "{}") as Record<string, unknown>;
expect(last.role).toBe("reviewer");
expect(last.content).toBe("rev-2");
const lastRoleLine = JSON.parse(lines[lines.length - 2] ?? "{}") as Record<string, unknown>;
expect(lastRoleLine.role).toBe("reviewer");
expect(await getContentMerklePayload(cas, String(lastRoleLine.contentHash))).toBe("rev-2");
});
test("fork rejects unknown role with available names", async () => {
@@ -198,7 +204,7 @@ describe("cli fork", () => {
return;
}
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
expect(ran.ok).toBe(true);
if (!ran.ok) {
return;
@@ -207,7 +213,7 @@ describe("cli fork", () => {
const sourceData = join(storageRoot, "logs", added.value.hash, `${sourceId}.data.jsonl`);
const sourceRunning = join(storageRoot, "logs", added.value.hash, `${sourceId}.running`);
await waitUntilRunningAbsent(sourceRunning);
await waitUntilMinDataLines(sourceData, 4);
await waitUntilMinDataLines(sourceData, 5);
const bad = await cmdFork(storageRoot, sourceId, "ghost-role");
expect(bad.ok).toBe(false);
@@ -0,0 +1,165 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import {
createCasStore,
garbageCollectCas,
getGlobalCasDir,
putContentMerkleNode,
} from "@uncaged/workflow";
import { cmdThreadRemove } from "../src/cmd-thread.js";
import { pathExists } from "../src/fs-utils.js";
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
async function writeDemoDataJsonl(params: {
path: string;
threadId: string;
bundleHash: string;
cas: ReturnType<typeof createCasStore>;
activeHash: string;
}): Promise<void> {
const bodyHash = await putContentMerkleNode(params.cas, "p");
const text = [
JSON.stringify({
name: "demo",
hash: params.bundleHash,
threadId: params.threadId,
parameters: { prompt: "hi", options: { maxRounds: 5 } },
timestamp: 100,
}),
JSON.stringify({
role: "planner",
contentHash: bodyHash,
meta: {},
refs: [params.activeHash, bodyHash],
timestamp: 101,
}),
"",
].join("\n");
await writeFile(params.path, text, "utf8");
}
describe("gc cli and garbageCollectCas", () => {
let prevEnv: string | undefined;
let storageRoot: string;
beforeEach(async () => {
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-gc-"));
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
});
afterEach(async () => {
if (prevEnv === undefined) {
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
} else {
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = prevEnv;
}
await rm(storageRoot, { recursive: true, force: true });
});
test("garbageCollectCas keeps CAS entries referenced by thread refs", async () => {
const bundleHash = "C9NMV6V2TQT81";
const threadId = "01AAA1111111111111111111";
const logsDir = join(storageRoot, "logs", bundleHash);
await mkdir(logsDir, { recursive: true });
const cas = createCasStore(getGlobalCasDir(storageRoot));
const activeHash = await cas.put("active-blob");
const orphanHash = await cas.put("orphan-blob");
await writeDemoDataJsonl({
path: join(logsDir, `${threadId}.data.jsonl`),
threadId,
bundleHash,
cas,
activeHash,
});
const gc = await garbageCollectCas(storageRoot);
expect(gc.ok).toBe(true);
if (!gc.ok) {
return;
}
expect(gc.value.scannedThreads).toBe(1);
expect(gc.value.activeRefs).toBe(2);
expect(gc.value.deletedEntries).toBe(1);
expect(gc.value.deletedHashes).toEqual([orphanHash]);
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${activeHash}.txt`))).toBe(true);
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${orphanHash}.txt`))).toBe(false);
});
test("garbageCollectCas deletes orphaned CAS when no threads reference them", async () => {
const cas = createCasStore(getGlobalCasDir(storageRoot));
const orphanHash = await cas.put("lonely");
const gc = await garbageCollectCas(storageRoot);
expect(gc.ok).toBe(true);
if (!gc.ok) {
return;
}
expect(gc.value.scannedThreads).toBe(0);
expect(gc.value.activeRefs).toBe(0);
expect(gc.value.deletedEntries).toBe(1);
expect(gc.value.deletedHashes).toEqual([orphanHash]);
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${orphanHash}.txt`))).toBe(false);
});
test("cli gc prints stats", async () => {
const bundleHash = "C9NMV6V2TQT81";
const threadId = "01BBB2222222222222222222";
const logsDir = join(storageRoot, "logs", bundleHash);
await mkdir(logsDir, { recursive: true });
const cas = createCasStore(getGlobalCasDir(storageRoot));
const activeHash = await cas.put("keep-me");
await cas.put("drop-me");
await writeDemoDataJsonl({
path: join(logsDir, `${threadId}.data.jsonl`),
threadId,
bundleHash,
cas,
activeHash,
});
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
const proc = spawnSync(process.execPath, [cliEntryPath, "cas", "gc"], {
env,
encoding: "utf8",
});
expect(proc.status).toBe(0);
expect(String(proc.stdout).trim()).toBe("scanned 1 threads, 2 active refs, deleted 1 entries");
});
test("thread rm triggers gc so unreferenced CAS is removed", async () => {
const bundleHash = "C9NMV6V2TQT81";
const threadId = "01CCC3333333333333333333";
const logsDir = join(storageRoot, "logs", bundleHash);
await mkdir(logsDir, { recursive: true });
const cas = createCasStore(getGlobalCasDir(storageRoot));
const activeHash = await cas.put("pinned-by-ref");
await writeDemoDataJsonl({
path: join(logsDir, `${threadId}.data.jsonl`),
threadId,
bundleHash,
cas,
activeHash,
});
const orphanHash = await cas.put("orphan-after-rm");
const orphanPath = join(getGlobalCasDir(storageRoot), `${orphanHash}.txt`);
const removed = await cmdThreadRemove(storageRoot, threadId);
expect(removed.ok).toBe(true);
expect(await pathExists(orphanPath)).toBe(false);
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${activeHash}.txt`))).toBe(false);
});
});
@@ -0,0 +1,214 @@
import { describe, expect, test } from "bun:test";
import { runCli } from "../src/cli-dispatch.js";
import {
formatSkillDoc,
formatSkillIndex,
formatSkillTopic,
getSkillTopics,
} from "../src/cmd-help.js";
const STORAGE_ROOT = "/tmp/help-test-storage";
describe("help command", () => {
test("help returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["help"]);
expect(code).toBe(0);
});
test("no args prints usage (not red) and returns 1", async () => {
const code = await runCli(STORAGE_ROOT, []);
expect(code).toBe(1);
});
});
describe("skill command", () => {
test("skill (no topic) lists topics and returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["skill"]);
expect(code).toBe(0);
});
test("skill cli returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["skill", "cli"]);
expect(code).toBe(0);
});
test("skill develop returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["skill", "develop"]);
expect(code).toBe(0);
});
test("skill author returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["skill", "author"]);
expect(code).toBe(0);
});
test("skill unknown returns 1", async () => {
const code = await runCli(STORAGE_ROOT, ["skill", "unknown"]);
expect(code).toBe(1);
});
});
describe("--help flag on groups", () => {
test("workflow --help returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["workflow", "--help"]);
expect(code).toBe(0);
});
test("thread --help returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["thread", "--help"]);
expect(code).toBe(0);
});
test("cas --help returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["cas", "--help"]);
expect(code).toBe(0);
});
test("init --help returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["init", "--help"]);
expect(code).toBe(0);
});
});
describe("legacy help --skill compat", () => {
test("help --skill still works (lists topics)", async () => {
const code = await runCli(STORAGE_ROOT, ["help", "--skill"]);
expect(code).toBe(0);
});
});
describe("getSkillTopics", () => {
test("returns all topics", () => {
const topics = getSkillTopics();
const names = topics.map((t) => t.name);
expect(names).toContain("cli");
expect(names).toContain("develop");
expect(names).toContain("author");
});
});
describe("formatSkillIndex", () => {
test("lists all topics", () => {
const idx = formatSkillIndex();
expect(idx).toContain("cli");
expect(idx).toContain("develop");
expect(idx).toContain("author");
expect(idx).toContain("skill <topic>");
});
});
describe("formatSkillTopic('cli') — legacy formatSkillDoc", () => {
const doc = formatSkillDoc();
test("contains title", () => {
expect(doc).toContain("# uncaged-workflow CLI Reference");
});
test("contains all command group headers", () => {
expect(doc).toContain("### workflow");
expect(doc).toContain("### thread");
expect(doc).toContain("### cas");
expect(doc).toContain("### init");
expect(doc).toContain("### Top-level shortcuts");
});
test("contains core concepts", () => {
expect(doc).toContain("## Core Concepts");
expect(doc).toContain("Workflow");
expect(doc).toContain("Bundle");
expect(doc).toContain("Thread");
expect(doc).toContain("CAS");
expect(doc).toContain("Registry");
});
test("mentions all workflow subcommands", () => {
for (const sub of ["add", "list", "show", "rm", "history", "rollback"]) {
expect(doc).toContain(`workflow ${sub}`);
}
});
test("mentions all thread subcommands", () => {
for (const sub of [
"run",
"list",
"show",
"rm",
"fork",
"ps",
"kill",
"live",
"pause",
"resume",
]) {
expect(doc).toContain(`thread ${sub}`);
}
});
test("mentions all cas subcommands", () => {
for (const sub of ["get", "put", "list", "rm", "gc"]) {
expect(doc).toContain(`cas ${sub}`);
}
});
test("contains exit codes section", () => {
expect(doc).toContain("## Exit Codes");
});
test("contains environment variables section", () => {
expect(doc).toContain("## Environment Variables");
expect(doc).toContain("UNCAGED_WORKFLOW_STORAGE_ROOT");
});
test("contains typical workflow section", () => {
expect(doc).toContain("## Typical Workflow");
});
});
describe("formatSkillTopic('develop')", () => {
const doc = formatSkillTopic("develop");
test("returns non-null", () => {
expect(doc).not.toBeNull();
});
test("contains thread ID info", () => {
expect(doc).toContain("Thread ID");
expect(doc).toContain("Crockford Base32");
});
test("contains CAS commands", () => {
expect(doc).toContain("cas put");
expect(doc).toContain("cas get");
});
test("contains meta output section", () => {
expect(doc).toContain("Meta Output");
});
});
describe("formatSkillTopic('author')", () => {
const doc = formatSkillTopic("author");
test("returns non-null", () => {
expect(doc).not.toBeNull();
});
test("contains bundle structure", () => {
expect(doc).toContain("Bundle Structure");
expect(doc).toContain(".esm.js");
});
test("contains descriptor info", () => {
expect(doc).toContain("WorkflowDescriptor");
});
test("contains role definition", () => {
expect(doc).toContain("Role Definition");
});
});
describe("formatSkillTopic unknown", () => {
test("returns null for unknown topic", () => {
expect(formatSkillTopic("nonexistent")).toBeNull();
});
});
@@ -0,0 +1,142 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runCli } from "../src/cli-dispatch.js";
import { cmdInitTemplate, cmdInitWorkspace } from "../src/cmd-init.js";
import { pathExists } from "../src/fs-utils.js";
describe("init template", () => {
let parent: string;
beforeEach(async () => {
parent = join(
tmpdir(),
`wf-init-template-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
await mkdir(parent, { recursive: true });
});
afterEach(async () => {
await rm(parent, { recursive: true, force: true });
});
test("creates templates/<name> with expected files", async () => {
const ws = await cmdInitWorkspace(parent, "my-workflows");
expect(ws.ok).toBe(true);
if (!ws.ok) {
return;
}
const root = ws.value.rootPath;
const created = await cmdInitTemplate(root, "review-pr");
expect(created.ok).toBe(true);
if (!created.ok) {
return;
}
const tdir = join(root, "templates", "review-pr");
expect(created.value.templatePath).toBe(tdir);
expect(await pathExists(join(tdir, "package.json"))).toBe(true);
expect(await pathExists(join(tdir, "tsconfig.json"))).toBe(true);
expect(await pathExists(join(tdir, "src", "roles.ts"))).toBe(true);
expect(await pathExists(join(tdir, "src", "moderator.ts"))).toBe(true);
expect(await pathExists(join(tdir, "src", "index.ts"))).toBe(true);
const pkg = JSON.parse(await readFile(join(tdir, "package.json"), "utf8")) as {
name: string;
type: string;
dependencies: Record<string, string>;
};
expect(pkg.type).toBe("module");
expect(pkg.dependencies["@uncaged/workflow"]).toBeDefined();
expect(pkg.dependencies.zod).toBeDefined();
expect(pkg.name).toContain("review-pr");
const idx = await readFile(join(tdir, "src", "index.ts"), "utf8");
expect(idx).toContain("WorkflowDefinition");
const roles = await readFile(join(tdir, "src", "roles.ts"), "utf8");
expect(roles).not.toContain("interface ");
expect(roles).not.toContain("?:");
expect(roles).not.toContain("export default");
const moder = await readFile(join(tdir, "src", "moderator.ts"), "utf8");
expect(moder).not.toContain("export default");
});
test("finds workspace walking up from nested cwd", async () => {
const ws = await cmdInitWorkspace(parent, "ws");
expect(ws.ok).toBe(true);
if (!ws.ok) {
return;
}
const root = ws.value.rootPath;
const nested = join(root, "a", "b");
await mkdir(nested, { recursive: true });
const created = await cmdInitTemplate(nested, "nested-tpl");
expect(created.ok).toBe(true);
if (!created.ok) {
return;
}
expect(await pathExists(join(root, "templates", "nested-tpl", "src", "index.ts"))).toBe(true);
});
test("errors when not inside a workflow workspace", async () => {
const orphan = join(parent, "nowhere");
await mkdir(orphan, { recursive: true });
const r = await cmdInitTemplate(orphan, "x");
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("templates/*");
}
});
test("errors when template directory already exists", async () => {
const ws = await cmdInitWorkspace(parent, "ws");
expect(ws.ok).toBe(true);
if (!ws.ok) {
return;
}
const root = ws.value.rootPath;
const first = await cmdInitTemplate(root, "dup");
expect(first.ok).toBe(true);
const second = await cmdInitTemplate(root, "dup");
expect(second.ok).toBe(false);
if (!second.ok) {
expect(second.error).toContain("already exists");
}
});
test("errors on invalid template name", async () => {
const ws = await cmdInitWorkspace(parent, "ws");
expect(ws.ok).toBe(true);
if (!ws.ok) {
return;
}
const bad = await cmdInitTemplate(ws.value.rootPath, "a/b");
expect(bad.ok).toBe(false);
});
test.serial("runCli init template uses cwd and succeeds in workspace", async () => {
const ws = await cmdInitWorkspace(parent, "cli-ws");
expect(ws.ok).toBe(true);
if (!ws.ok) {
return;
}
const root = ws.value.rootPath;
const prev = process.cwd();
try {
process.chdir(root);
const code = await runCli(join(parent, "_storage"), ["init", "template", "from-cli"]);
expect(code).toBe(0);
expect(await pathExists(join(root, "templates", "from-cli", "package.json"))).toBe(true);
} finally {
process.chdir(prev);
}
});
});
@@ -0,0 +1,152 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { formatCliUsage, runCli } from "../src/cli-dispatch.js";
import { cmdInitWorkspace } from "../src/cmd-init.js";
import { pathExists } from "../src/fs-utils.js";
describe("init workspace", () => {
let parent: string;
beforeEach(async () => {
parent = join(tmpdir(), `wf-init-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(parent, { recursive: true });
});
afterEach(async () => {
await rm(parent, { recursive: true, force: true });
});
test("creates expected files and directories", async () => {
const created = await cmdInitWorkspace(parent, "my-workflows");
expect(created.ok).toBe(true);
if (!created.ok) {
return;
}
const root = created.value.rootPath;
expect(await pathExists(join(root, "package.json"))).toBe(true);
expect(await pathExists(join(root, "biome.json"))).toBe(true);
expect(await pathExists(join(root, "tsconfig.json"))).toBe(true);
expect(await pathExists(join(root, "AGENTS.md"))).toBe(true);
expect(await pathExists(join(root, "README.md"))).toBe(true);
expect(await pathExists(join(root, "templates"))).toBe(true);
expect(await pathExists(join(root, "templates", ".gitkeep"))).toBe(true);
expect(await pathExists(join(root, "workflows", "package.json"))).toBe(true);
const rootPkg = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as {
workspaces: string[];
};
expect(rootPkg.workspaces).toEqual(["templates/*", "workflows"]);
const wfPkg = JSON.parse(await readFile(join(root, "workflows", "package.json"), "utf8")) as {
type: string;
dependencies: Record<string, string>;
};
expect(wfPkg.type).toBe("module");
expect(wfPkg.dependencies["@uncaged/workflow"]).toBeDefined();
expect(wfPkg.dependencies.zod).toBeDefined();
const tsconfig = JSON.parse(await readFile(join(root, "tsconfig.json"), "utf8")) as {
compilerOptions: { strict: boolean; module: string; target: string };
};
expect(tsconfig.compilerOptions.strict).toBe(true);
expect(tsconfig.compilerOptions.module).toBe("ESNext");
expect(tsconfig.compilerOptions.target).toBe("ESNext");
});
test("AGENTS.md contains coding agent guide sections and terms", async () => {
const created = await cmdInitWorkspace(parent, "my-workflows");
expect(created.ok).toBe(true);
if (!created.ok) {
return;
}
const agentsPath = join(created.value.rootPath, "AGENTS.md");
const body = await readFile(agentsPath, "utf8");
for (const section of [
"项目结构",
"核心概念",
"开发流程",
"编码规范",
"Template",
"Build",
"常见陷阱",
]) {
expect(body).toContain(section);
}
for (const term of [
"RoleDefinition",
"WorkflowDefinition",
"Moderator",
"AgentFn",
"ExtractFn",
"RoleMeta",
]) {
expect(body).toContain(term);
}
expect(body).toMatch(/type[\s\S]*interface/i);
expect(body).toMatch(/function[\s\S]*class/i);
expect(body).toContain("Crockford Base32");
expect(body).toMatch(/no[\s\S]*default export/i);
expect(body).toMatch(/no[\s\S]*console/i);
expect(body).toMatch(/no[\s\S]*dynamic import/i);
expect(body).toContain("bun run check");
expect(body).toContain("bun test");
expect(body).toContain("uncaged-workflow");
expect(body).toContain("bun build");
expect(body).toContain("CLAUDE.md");
expect(body).toContain("docs/architecture.md");
});
test("errors when directory already exists", async () => {
const first = await cmdInitWorkspace(parent, "dup");
expect(first.ok).toBe(true);
const second = await cmdInitWorkspace(parent, "dup");
expect(second.ok).toBe(false);
if (!second.ok) {
expect(second.error).toContain("already exists");
}
});
test("errors on invalid workspace name", async () => {
const slash = await cmdInitWorkspace(parent, "a/b");
expect(slash.ok).toBe(false);
const dots = await cmdInitWorkspace(parent, "..");
expect(dots.ok).toBe(false);
const empty = await cmdInitWorkspace(parent, "");
expect(empty.ok).toBe(false);
});
test("usage lists init subcommands", () => {
const u = formatCliUsage();
expect(u).toContain("uncaged-workflow init workspace <name>");
expect(u).toContain("uncaged-workflow init template <name>");
});
test("runCli rejects unknown init subcommand", async () => {
const code = await runCli(join(parent, "_storage"), ["init", "bogus", "name"]);
expect(code).toBe(1);
});
test.serial("runCli init workspace uses cwd", async () => {
const prev = process.cwd();
try {
process.chdir(parent);
const code = await runCli(join(parent, "_storage"), ["init", "workspace", "from-cli"]);
expect(code).toBe(0);
expect(await pathExists(join(parent, "from-cli", "workflows", "package.json"))).toBe(true);
} finally {
process.chdir(prev);
}
});
});
@@ -0,0 +1,369 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { spawn, spawnSync } from "node:child_process";
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { createCasStore, getGlobalCasDir, putContentMerkleNode } from "@uncaged/workflow";
import {
formatLiveDebugLine,
formatLiveTimeLabel,
LIVE_CONTENT_MAX_LINES,
type LiveRoleRow,
renderLiveRoleStepLines,
} from "../src/cmd-live.js";
import { parseLiveArgv } from "../src/live-argv.js";
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
const fixtureRoot = fileURLToPath(new URL("./fixtures/live", import.meta.url));
/** Bodies for Merkle content nodes; hashes must match `.data.jsonl` fixtures. */
const LIVE_FIXTURE_PLANNER_BODY =
"alpha\nbeta\ngamma\nLINE4\nLINE5\nLINE6\nLINE7\nLINE8\nLINE9\nLINE10\nLINE11";
describe("live helpers", () => {
test("formatLiveTimeLabel pads HH:MM:SS", () => {
const label = formatLiveTimeLabel(new Date("2024-06-01T09:08:07.000Z").getTime());
expect(label).toMatch(/^\d{2}:\d{2}:\d{2}$/);
});
test("formatLiveDebugLine flattens newlines in message", () => {
const line = formatLiveDebugLine(0, "TAG1", "a\nb");
expect(line).toContain("[TAG1]");
expect(line).toContain("a b");
expect(line).not.toContain("\n");
});
test("renderLiveRoleStepLines truncates content to LIVE_CONTENT_MAX_LINES", () => {
const lines = Array.from({ length: LIVE_CONTENT_MAX_LINES + 3 }, (_, i) => `L${i + 1}`);
const row: LiveRoleRow = {
role: "r",
content: lines.join("\n"),
meta: { k: "v" },
timestamp: 0,
};
const out = renderLiveRoleStepLines(row, "r");
const body = out.filter((l) => l.startsWith(" L"));
expect(body.length).toBe(LIVE_CONTENT_MAX_LINES);
expect(out.some((l) => l.includes("more line"))).toBe(true);
expect(out.some((l) => l.startsWith(" meta: "))).toBe(true);
});
});
describe("parseLiveArgv", () => {
test("parses thread id and flags in any order", () => {
const a = parseLiveArgv(["01ABC", "--debug", "--role", "planner"]);
expect(a.ok).toBe(true);
if (a.ok) {
expect(a.value.threadId).toBe("01ABC");
expect(a.value.latest).toBe(false);
expect(a.value.debug).toBe(true);
expect(a.value.role).toBe("planner");
}
const b = parseLiveArgv(["--latest", "--role", "x"]);
expect(b.ok).toBe(true);
if (b.ok) {
expect(b.value.latest).toBe(true);
expect(b.value.threadId).toBe(null);
expect(b.value.role).toBe("x");
}
});
test("rejects --latest with thread id", () => {
const r = parseLiveArgv(["--latest", "01ABC"]);
expect(r.ok).toBe(false);
});
});
describe("live CLI", () => {
let prevEnv: string | undefined;
let storageRoot: string;
beforeEach(async () => {
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-live-"));
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
await mkdir(join(storageRoot, "logs", "C9NMV6V2TQT81"), { recursive: true });
await cp(
join(fixtureRoot, "logs", "C9NMV6V2TQT81", "01LIVECMPLT01DDDDDDDDDDDDG.data.jsonl"),
join(storageRoot, "logs", "C9NMV6V2TQT81", "01LIVECMPLT01DDDDDDDDDDDDG.data.jsonl"),
);
await cp(
join(fixtureRoot, "logs", "C9NMV6V2TQT81", "01LIVECMPLT01DDDDDDDDDDDDG.info.jsonl"),
join(storageRoot, "logs", "C9NMV6V2TQT81", "01LIVECMPLT01DDDDDDDDDDDDG.info.jsonl"),
);
await cp(
join(fixtureRoot, "logs", "C9NMV6V2TQT81", "01LIVEINFLY01DDDDDDDDDDDDG.data.jsonl"),
join(storageRoot, "logs", "C9NMV6V2TQT81", "01LIVEINFLY01DDDDDDDDDDDDG.data.jsonl"),
);
await cp(
join(fixtureRoot, "logs", "C9NMV6V2TQT81", "01LIVEOLDER01DDDDDDDDDDDDG.data.jsonl"),
join(storageRoot, "logs", "C9NMV6V2TQT81", "01LIVEOLDER01DDDDDDDDDDDDG.data.jsonl"),
);
const cas = createCasStore(getGlobalCasDir(storageRoot));
await putContentMerkleNode(cas, LIVE_FIXTURE_PLANNER_BODY);
await putContentMerkleNode(cas, "patch");
await putContentMerkleNode(cas, "still running");
});
afterEach(async () => {
if (prevEnv === undefined) {
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
} else {
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = prevEnv;
}
await rm(storageRoot, { recursive: true, force: true });
});
test("prints role steps and summary for a completed thread", async () => {
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
const proc = spawn(process.execPath, [cliEntryPath, "live", "01LIVECMPLT01DDDDDDDDDDDDG"], {
env,
stdio: ["ignore", "pipe", "pipe"],
});
const stdout = await new Promise<string>((resolve, reject) => {
let buf = "";
proc.stdout?.on("data", (c: Buffer) => {
buf += c.toString("utf8");
});
proc.stderr?.on("data", (c: Buffer) => {
buf += c.toString("utf8");
});
proc.on("error", reject);
proc.on("exit", (code: number | null) => {
if (code === 0) {
resolve(buf);
} else {
reject(new Error(`exit ${code}: ${buf}`));
}
});
});
expect(stdout).toContain("planner");
expect(stdout).toContain("coder");
expect(stdout).toContain("meta:");
expect(stdout).toContain('"phase":"plan"');
expect(stdout).toContain("LINE10");
expect(stdout).not.toContain("LINE11");
expect(stdout).toContain("more line");
expect(stdout).toContain("completed: returnCode=0");
expect(stdout).toContain("fixture completed");
});
test("--latest tails the newest thread by start timestamp", async () => {
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
const proc = spawn(process.execPath, [cliEntryPath, "live", "--latest"], {
env,
stdio: ["ignore", "pipe", "pipe"],
});
const stdout = await new Promise<string>((resolve, reject) => {
let buf = "";
proc.stdout?.on("data", (c: Buffer) => {
buf += c.toString("utf8");
});
proc.stderr?.on("data", (c: Buffer) => {
buf += c.toString("utf8");
});
proc.on("error", reject);
proc.on("exit", (code: number | null) => {
if (code === 0) {
resolve(buf);
} else {
reject(new Error(`exit ${code}: ${buf}`));
}
});
});
expect(stdout).toContain("fixture completed");
expect(stdout).not.toContain("older thread");
});
test("--debug prints .info.jsonl records after data output", async () => {
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
const proc = spawn(
process.execPath,
[cliEntryPath, "live", "01LIVECMPLT01DDDDDDDDDDDDG", "--debug"],
{
env,
stdio: ["ignore", "pipe", "pipe"],
},
);
const stdout = await new Promise<string>((resolve, reject) => {
let buf = "";
proc.stdout?.on("data", (c: Buffer) => {
buf += c.toString("utf8");
});
proc.stderr?.on("data", (c: Buffer) => {
buf += c.toString("utf8");
});
proc.on("error", reject);
proc.on("exit", (code: number | null) => {
if (code === 0) {
resolve(buf);
} else {
reject(new Error(`exit ${code}: ${buf}`));
}
});
});
expect(stdout).toContain("[DEBUGTAG1]");
expect(stdout).toContain("bundle loaded");
expect(stdout).toContain("[DEBUGTAG2]");
expect(stdout).toContain("multi line");
});
test("--role filters out non-matching roles", async () => {
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
const proc = spawn(
process.execPath,
[cliEntryPath, "live", "01LIVECMPLT01DDDDDDDDDDDDG", "--role", "planner"],
{
env,
stdio: ["ignore", "pipe", "pipe"],
},
);
const stdout = await new Promise<string>((resolve, reject) => {
let buf = "";
proc.stdout?.on("data", (c: Buffer) => {
buf += c.toString("utf8");
});
proc.stderr?.on("data", (c: Buffer) => {
buf += c.toString("utf8");
});
proc.on("error", reject);
proc.on("exit", (code: number | null) => {
if (code === 0) {
resolve(buf);
} else {
reject(new Error(`exit ${code}: ${buf}`));
}
});
});
expect(stdout).toContain("planner");
expect(stdout).not.toContain("patch");
expect(stdout).toContain("completed: returnCode=0");
});
test("--latest --debug --role combine", async () => {
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
const proc = spawn(
process.execPath,
[cliEntryPath, "live", "--latest", "--debug", "--role", "planner"],
{
env,
stdio: ["ignore", "pipe", "pipe"],
},
);
const stdout = await new Promise<string>((resolve, reject) => {
let buf = "";
proc.stdout?.on("data", (c: Buffer) => {
buf += c.toString("utf8");
});
proc.stderr?.on("data", (c: Buffer) => {
buf += c.toString("utf8");
});
proc.on("error", reject);
proc.on("exit", (code: number | null) => {
if (code === 0) {
resolve(buf);
} else {
reject(new Error(`exit ${code}: ${buf}`));
}
});
});
expect(stdout).toContain("[DEBUGTAG1]");
expect(stdout).toContain("planner");
expect(stdout).not.toContain("patch");
expect(stdout).toContain("fixture completed");
});
test("unknown thread id exits 1", () => {
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
const r = spawnSync(process.execPath, [cliEntryPath, "live", "01UNKNOWNXXXXXXXXXXXXXXXXX"], {
env,
encoding: "utf8",
});
expect(r.status).toBe(1);
expect(String(r.stderr ?? "")).toContain("thread not found");
});
test("follows file until WorkflowResult is appended", async () => {
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
const dataPath = join(
storageRoot,
"logs",
"C9NMV6V2TQT81",
"01LIVEINFLY01DDDDDDDDDDDDG.data.jsonl",
);
const proc = spawn(process.execPath, [cliEntryPath, "live", "01LIVEINFLY01DDDDDDDDDDDDG"], {
env,
stdio: ["ignore", "pipe", "pipe"],
});
await new Promise((r) => setTimeout(r, 120));
const prior = await readFile(dataPath, "utf8");
await writeFile(
dataPath,
`${prior.replace(/\s*$/, "")}\n${JSON.stringify({ returnCode: 0, summary: "caught up" })}\n`,
"utf8",
);
const stdout = await new Promise<string>((resolve, reject) => {
let buf = "";
proc.stdout?.on("data", (c: Buffer) => {
buf += c.toString("utf8");
});
proc.stderr?.on("data", (c: Buffer) => {
buf += c.toString("utf8");
});
proc.on("error", reject);
proc.on("exit", (code: number | null) => {
if (code === 0) {
resolve(buf);
} else {
reject(new Error(`exit ${code}: ${buf}`));
}
});
});
expect(stdout).toContain("planner");
expect(stdout).toContain("completed: returnCode=0");
expect(stdout).toContain("caught up");
});
});
describe("live --latest with empty storage", () => {
let prevEnv: string | undefined;
let emptyRoot: string;
beforeEach(async () => {
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
emptyRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-live-empty-"));
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = emptyRoot;
});
afterEach(async () => {
if (prevEnv === undefined) {
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
} else {
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = prevEnv;
}
await rm(emptyRoot, { recursive: true, force: true });
});
test("exits 1 when no threads exist", () => {
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: emptyRoot };
const r = spawnSync(process.execPath, [cliEntryPath, "live", "--latest"], {
env,
encoding: "utf8",
});
expect(r.status).toBe(1);
expect(String(r.stderr ?? "")).toContain("no threads");
});
});
@@ -0,0 +1,54 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow";
import { resolveWorkflowStorageRoot } from "../src/storage-env.js";
describe("resolveWorkflowStorageRoot", () => {
let savedInternal: string | undefined;
let savedUser: string | undefined;
beforeEach(() => {
savedInternal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
savedUser = process.env.WORKFLOW_STORAGE_ROOT;
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
delete process.env.WORKFLOW_STORAGE_ROOT;
});
afterEach(() => {
if (savedInternal === undefined) {
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
} else {
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = savedInternal;
}
if (savedUser === undefined) {
delete process.env.WORKFLOW_STORAGE_ROOT;
} else {
process.env.WORKFLOW_STORAGE_ROOT = savedUser;
}
});
test("returns default when no env vars are set", () => {
expect(resolveWorkflowStorageRoot()).toBe(getDefaultWorkflowStorageRoot());
});
test("WORKFLOW_STORAGE_ROOT overrides default", () => {
process.env.WORKFLOW_STORAGE_ROOT = "/tmp/custom-storage";
expect(resolveWorkflowStorageRoot()).toBe("/tmp/custom-storage");
});
test("UNCAGED_WORKFLOW_STORAGE_ROOT takes priority over WORKFLOW_STORAGE_ROOT", () => {
process.env.WORKFLOW_STORAGE_ROOT = "/tmp/user-path";
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = "/tmp/internal-path";
expect(resolveWorkflowStorageRoot()).toBe("/tmp/internal-path");
});
test("ignores empty WORKFLOW_STORAGE_ROOT", () => {
process.env.WORKFLOW_STORAGE_ROOT = "";
expect(resolveWorkflowStorageRoot()).toBe(getDefaultWorkflowStorageRoot());
});
test("ignores empty UNCAGED_WORKFLOW_STORAGE_ROOT and falls through to WORKFLOW_STORAGE_ROOT", () => {
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = "";
process.env.WORKFLOW_STORAGE_ROOT = "/tmp/user-fallback";
expect(resolveWorkflowStorageRoot()).toBe("/tmp/user-fallback");
});
});
@@ -4,7 +4,9 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { getGlobalCasDir } from "@uncaged/workflow";
import { cmdAdd } from "../src/cmd-add.js";
import { cmdCasPut } from "../src/cmd-cas.js";
import { cmdKill } from "../src/cmd-kill.js";
import { cmdPause } from "../src/cmd-pause.js";
import { cmdPs } from "../src/cmd-ps.js";
@@ -12,9 +14,12 @@ import { cmdResume } from "../src/cmd-resume.js";
import { cmdRun } from "../src/cmd-run.js";
import { cmdThreadRemove, cmdThreadShow } from "../src/cmd-thread.js";
import { cmdThreads } from "../src/cmd-threads.js";
import { pathExists } from "../src/fs-utils.js";
import { pathExists, readTextFileIfExists } from "../src/fs-utils.js";
import { addCliArgs } from "./bundle-fixture.js";
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
`;
const threadFixtureDescriptor = `export const descriptor = {
description: "thread-cli",
roles: {
@@ -29,18 +34,26 @@ const threadFixtureDescriptor = `export const descriptor = {
`;
const fastBundleSource = `${threadFixtureDescriptor}
export const run = async function* (input) {
yield { role: "planner", content: "plan", meta: { plan: input.prompt } };
yield { role: "coder", content: "code", meta: { diff: "y" } };
${wfPutImport}
export const run = async function* (input, options) {
const cas = options.cas;
let h = await putContentMerkleNode(cas, "plan");
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
h = await putContentMerkleNode(cas, "code");
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
return { returnCode: 0, summary: "done" };
};
`;
const slowPlannerBundleSource = `${threadFixtureDescriptor}
export const run = async function* (input) {
${wfPutImport}
export const run = async function* (input, options) {
await new Promise((r) => setTimeout(r, 400));
yield { role: "planner", content: "plan", meta: { plan: input.prompt } };
yield { role: "coder", content: "code", meta: { diff: "y" } };
const cas = options.cas;
let h = await putContentMerkleNode(cas, "plan");
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
h = await putContentMerkleNode(cas, "code");
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
return { returnCode: 0, summary: "done" };
};
`;
@@ -48,27 +61,38 @@ export const run = async function* (input) {
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
const abortablePlannerBundleSource = `${threadFixtureDescriptor}
export const run = async function* (input) {
${wfPutImport}
export const run = async function* (input, options) {
await new Promise((r) => setTimeout(r, 600));
yield { role: "planner", content: "plan", meta: { plan: input.prompt } };
yield { role: "coder", content: "code", meta: { diff: "y" } };
const cas = options.cas;
let h = await putContentMerkleNode(cas, "plan");
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
h = await putContentMerkleNode(cas, "code");
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
return { returnCode: 0, summary: "done" };
};
`;
const pauseResumeBundleSource = `${threadFixtureDescriptor}
export const run = async function* (input) {
yield { role: "first", content: "f", meta: {} };
${wfPutImport}
export const run = async function* (_input, options) {
const cas = options.cas;
let h = await putContentMerkleNode(cas, "f");
yield { role: "first", contentHash: h, meta: {}, refs: [h] };
await new Promise((r) => setTimeout(r, 1500));
yield { role: "second", content: "s", meta: {} };
h = await putContentMerkleNode(cas, "s");
yield { role: "second", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "done" };
};
`;
const delayedFirstYieldBundleSource = `${threadFixtureDescriptor}
export const run = async function* (input) {
${wfPutImport}
export const run = async function* (_input, options) {
await new Promise((r) => setTimeout(r, 900));
yield { role: "only", content: "x", meta: {} };
const cas = options.cas;
const h = await putContentMerkleNode(cas, "x");
yield { role: "only", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "done" };
};
`;
@@ -138,7 +162,7 @@ describe("cli thread commands", () => {
return;
}
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
expect(ran.ok).toBe(true);
if (!ran.ok) {
return;
@@ -175,15 +199,67 @@ describe("cli thread commands", () => {
expect(await pathExists(dataPath)).toBe(false);
});
test("thread rm runs GC and removes CAS blobs not referenced by any remaining thread", async () => {
const bundleDir = join(storageRoot, "src");
await mkdir(bundleDir, { recursive: true });
const bundlePath = join(bundleDir, "demo.esm.js");
await writeFile(bundlePath, fastBundleSource, "utf8");
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
expect(added.ok).toBe(true);
if (!added.ok) {
return;
}
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
expect(ran.ok).toBe(true);
if (!ran.ok) {
return;
}
const threadId = ran.value.threadId;
let threads = await cmdThreads(storageRoot, []);
for (
let attempt = 0;
attempt < 50 && threads.ok && !threads.value.some((l) => l.includes(threadId));
attempt++
) {
await new Promise((r) => setTimeout(r, 20));
threads = await cmdThreads(storageRoot, []);
}
const dataPath = join(storageRoot, "logs", added.value.hash, `${threadId}.data.jsonl`);
const runningPath = join(dirname(dataPath), `${threadId}.running`);
await waitUntilRunningFileAbsent(runningPath, 120);
const put = await cmdCasPut(storageRoot, threadId, "keep-after-thread-rm");
expect(put.ok).toBe(true);
if (!put.ok) {
return;
}
const hash = put.value;
const casBlob = join(getGlobalCasDir(storageRoot), `${hash}.txt`);
const removed = await cmdThreadRemove(storageRoot, threadId);
expect(removed.ok).toBe(true);
const stillThere = await readTextFileIfExists(casBlob);
expect(stillThere).toBeNull();
});
test("cli entrypoint dispatches threads / ps (spawn)", () => {
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
const threads = spawnSync(process.execPath, [cliEntryPath, "threads"], {
const threads = spawnSync(process.execPath, [cliEntryPath, "thread", "list"], {
env,
encoding: "utf8",
});
expect(threads.status).toBe(0);
const ps = spawnSync(process.execPath, [cliEntryPath, "ps"], { env, encoding: "utf8" });
const ps = spawnSync(process.execPath, [cliEntryPath, "thread", "ps"], {
env,
encoding: "utf8",
});
expect(ps.status).toBe(0);
});
@@ -199,7 +275,7 @@ describe("cli thread commands", () => {
return;
}
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
expect(ran.ok).toBe(true);
if (!ran.ok) {
return;
@@ -229,7 +305,7 @@ describe("cli thread commands", () => {
return;
}
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
expect(ran.ok).toBe(true);
if (!ran.ok) {
return;
@@ -250,7 +326,7 @@ describe("cli thread commands", () => {
.trim()
.split("\n")
.filter((l) => l !== "");
expect(lines.length).toBe(2);
expect(lines.length).toBe(3);
const runningPath = join(dirname(dataPath), `${threadId}.running`);
expect(await pathExists(runningPath)).toBe(false);
@@ -268,7 +344,7 @@ describe("cli thread commands", () => {
return;
}
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
expect(ran.ok).toBe(true);
if (!ran.ok) {
return;
@@ -289,8 +365,8 @@ describe("cli thread commands", () => {
const resumed = await cmdResume(storageRoot, threadId);
expect(resumed.ok).toBe(true);
await waitUntilMinDataLines(dataPath, 3, 120);
expect(await countDataJsonlLines(dataPath)).toBe(3);
await waitUntilMinDataLines(dataPath, 4, 120);
expect(await countDataJsonlLines(dataPath)).toBe(4);
const runningPath = join(dirname(dataPath), `${threadId}.running`);
await waitUntilRunningFileAbsent(runningPath, 100);
@@ -309,7 +385,7 @@ describe("cli thread commands", () => {
return;
}
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
expect(ran.ok).toBe(true);
if (!ran.ok) {
return;
@@ -338,7 +414,7 @@ describe("cli thread commands", () => {
return;
}
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
expect(ran.ok).toBe(true);
if (!ran.ok) {
return;
+470 -61
View File
@@ -1,9 +1,14 @@
import { printCliError, printCliLine, printCliWarn } from "./cli-output.js";
import { cmdAdd, formatAddSuccess, parseAddArgv } from "./cmd-add.js";
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "./cmd-cas.js";
import { cmdFork, parseForkArgv } from "./cmd-fork.js";
import { cmdGc } from "./cmd-gc.js";
import { formatSkillDoc, formatSkillIndex, formatSkillTopic } from "./cmd-help.js";
import { cmdHistory } from "./cmd-history.js";
import { cmdInitTemplate, cmdInitWorkspace } from "./cmd-init.js";
import { cmdKill } from "./cmd-kill.js";
import { cmdList, formatListLines } from "./cmd-list.js";
import { cmdLive } from "./cmd-live.js";
import { cmdPause } from "./cmd-pause.js";
import { cmdPs } from "./cmd-ps.js";
import { cmdRemove } from "./cmd-remove.js";
@@ -13,33 +18,58 @@ import { cmdRun } from "./cmd-run.js";
import { cmdShow, formatShowYaml } from "./cmd-show.js";
import { cmdThreadRemove, cmdThreadShow } from "./cmd-thread.js";
import { cmdThreads } from "./cmd-threads.js";
import { parseLiveArgv } from "./live-argv.js";
import { parseRunArgv } from "./run-argv.js";
function usage(): string {
return [
"Usage:",
" uncaged-workflow add <name> <file.esm.js> [--types <path>]",
" uncaged-workflow list",
" uncaged-workflow show <name>",
" uncaged-workflow remove <name>",
" uncaged-workflow run <name> [--prompt <text>] [--dry-run] [--max-rounds N]",
" uncaged-workflow ps",
" uncaged-workflow kill <thread-id>",
" uncaged-workflow history <name>",
" uncaged-workflow rollback <name> [hash]",
" uncaged-workflow pause <thread-id>",
" uncaged-workflow resume <thread-id>",
" uncaged-workflow threads [name]",
" uncaged-workflow thread <id>",
" uncaged-workflow thread rm <id>",
" uncaged-workflow fork <thread-id> [--from-role <role>]",
].join("\n");
type DispatchFn = (storageRoot: string, argv: string[]) => Promise<number>;
type CommandEntry = {
handler: DispatchFn;
args: string;
description: string;
};
type CommandGroup = {
name: string;
commands: ReadonlyArray<{ name: string; args: string; description: string }>;
};
// ── Individual dispatch functions ──────────────────────────────────────
async function dispatchInitWorkspace(_storageRoot: string, argv: string[]): Promise<number> {
const name = argv[0];
if (name === undefined || argv.length > 1) {
printCliError(`${formatCliUsage()}\n\nerror: init workspace requires <name>`);
return 1;
}
const result = await cmdInitWorkspace(process.cwd(), name);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`initialized workflow workspace at ${result.value.rootPath}`);
return 0;
}
async function dispatchInitTemplate(_storageRoot: string, argv: string[]): Promise<number> {
const name = argv[0];
if (name === undefined || argv.length > 1) {
printCliError(`${formatCliUsage()}\n\nerror: init template requires <name>`);
return 1;
}
const result = await cmdInitTemplate(process.cwd(), name);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`initialized template at ${result.value.templatePath}`);
return 0;
}
async function dispatchAdd(storageRoot: string, argv: string[]): Promise<number> {
const parsed = parseAddArgv(argv);
if (!parsed.ok) {
printCliError(`${usage()}\n\nerror: ${parsed.error}`);
printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`);
return 1;
}
const result = await cmdAdd(storageRoot, parsed.value);
@@ -56,7 +86,7 @@ async function dispatchAdd(storageRoot: string, argv: string[]): Promise<number>
async function dispatchList(storageRoot: string, argv: string[]): Promise<number> {
if (argv.length > 0) {
printCliError(`${usage()}\n\nerror: list takes no arguments`);
printCliError(`${formatCliUsage()}\n\nerror: list takes no arguments`);
return 1;
}
const result = await cmdList(storageRoot);
@@ -73,7 +103,7 @@ async function dispatchList(storageRoot: string, argv: string[]): Promise<number
async function dispatchShow(storageRoot: string, argv: string[]): Promise<number> {
const name = argv[0];
if (name === undefined || argv.length > 1) {
printCliError(`${usage()}\n\nerror: show requires <name>`);
printCliError(`${formatCliUsage()}\n\nerror: show requires <name>`);
return 1;
}
const result = await cmdShow(storageRoot, name);
@@ -88,7 +118,7 @@ async function dispatchShow(storageRoot: string, argv: string[]): Promise<number
async function dispatchRemove(storageRoot: string, argv: string[]): Promise<number> {
const name = argv[0];
if (name === undefined || argv.length > 1) {
printCliError(`${usage()}\n\nerror: remove requires <name>`);
printCliError(`${formatCliUsage()}\n\nerror: remove requires <name>`);
return 1;
}
const result = await cmdRemove(storageRoot, name);
@@ -103,7 +133,7 @@ async function dispatchRemove(storageRoot: string, argv: string[]): Promise<numb
async function dispatchRun(storageRoot: string, argv: string[]): Promise<number> {
const parsed = parseRunArgv(argv);
if (!parsed.ok) {
printCliError(`${usage()}\n\nerror: ${parsed.error}`);
printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`);
return 1;
}
@@ -111,7 +141,6 @@ async function dispatchRun(storageRoot: string, argv: string[]): Promise<number>
storageRoot,
parsed.value.name,
parsed.value.prompt,
parsed.value.dryRun,
parsed.value.maxRounds,
);
if (!result.ok) {
@@ -125,7 +154,7 @@ async function dispatchRun(storageRoot: string, argv: string[]): Promise<number>
async function dispatchPs(storageRoot: string, argv: string[]): Promise<number> {
if (argv.length > 0) {
printCliError(`${usage()}\n\nerror: ps takes no arguments`);
printCliError(`${formatCliUsage()}\n\nerror: ps takes no arguments`);
return 1;
}
for (const line of await cmdPs(storageRoot)) {
@@ -137,7 +166,7 @@ async function dispatchPs(storageRoot: string, argv: string[]): Promise<number>
async function dispatchKill(storageRoot: string, argv: string[]): Promise<number> {
const threadId = argv[0];
if (threadId === undefined || argv.length > 1) {
printCliError(`${usage()}\n\nerror: kill requires <thread-id>`);
printCliError(`${formatCliUsage()}\n\nerror: kill requires <thread-id>`);
return 1;
}
const result = await cmdKill(storageRoot, threadId);
@@ -149,10 +178,19 @@ async function dispatchKill(storageRoot: string, argv: string[]): Promise<number
return 0;
}
async function dispatchLive(storageRoot: string, argv: string[]): Promise<number> {
const parsed = parseLiveArgv(argv);
if (!parsed.ok) {
printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`);
return 1;
}
return cmdLive(storageRoot, parsed.value);
}
async function dispatchHistory(storageRoot: string, argv: string[]): Promise<number> {
const name = argv[0];
if (name === undefined || argv.length > 1) {
printCliError(`${usage()}\n\nerror: history requires <name>`);
printCliError(`${formatCliUsage()}\n\nerror: history requires <name>`);
return 1;
}
const result = await cmdHistory(storageRoot, name);
@@ -169,7 +207,7 @@ async function dispatchHistory(storageRoot: string, argv: string[]): Promise<num
async function dispatchRollback(storageRoot: string, argv: string[]): Promise<number> {
const name = argv[0];
if (name === undefined || argv.length > 2) {
printCliError(`${usage()}\n\nerror: rollback requires <name> [hash]`);
printCliError(`${formatCliUsage()}\n\nerror: rollback requires <name> [hash]`);
return 1;
}
const hashArg = argv[1];
@@ -185,7 +223,7 @@ async function dispatchRollback(storageRoot: string, argv: string[]): Promise<nu
async function dispatchPause(storageRoot: string, argv: string[]): Promise<number> {
const threadId = argv[0];
if (threadId === undefined || argv.length > 1) {
printCliError(`${usage()}\n\nerror: pause requires <thread-id>`);
printCliError(`${formatCliUsage()}\n\nerror: pause requires <thread-id>`);
return 1;
}
const result = await cmdPause(storageRoot, threadId);
@@ -200,7 +238,7 @@ async function dispatchPause(storageRoot: string, argv: string[]): Promise<numbe
async function dispatchResume(storageRoot: string, argv: string[]): Promise<number> {
const threadId = argv[0];
if (threadId === undefined || argv.length > 1) {
printCliError(`${usage()}\n\nerror: resume requires <thread-id>`);
printCliError(`${formatCliUsage()}\n\nerror: resume requires <thread-id>`);
return 1;
}
const result = await cmdResume(storageRoot, threadId);
@@ -212,7 +250,7 @@ async function dispatchResume(storageRoot: string, argv: string[]): Promise<numb
return 0;
}
async function dispatchThreads(storageRoot: string, argv: string[]): Promise<number> {
async function dispatchThreadList(storageRoot: string, argv: string[]): Promise<number> {
const result = await cmdThreads(storageRoot, argv);
if (!result.ok) {
printCliError(result.error);
@@ -224,10 +262,10 @@ async function dispatchThreads(storageRoot: string, argv: string[]): Promise<num
return 0;
}
async function dispatchThread(storageRoot: string, argv: string[]): Promise<number> {
async function dispatchThreadShow(storageRoot: string, argv: string[]): Promise<number> {
const id = argv[0];
if (id === undefined || argv.length > 1) {
printCliError(`${usage()}\n\nerror: thread requires <id>`);
printCliError(`${formatCliUsage()}\n\nerror: thread show requires <id>`);
return 1;
}
const result = await cmdThreadShow(storageRoot, id);
@@ -242,7 +280,7 @@ async function dispatchThread(storageRoot: string, argv: string[]): Promise<numb
async function dispatchThreadRm(storageRoot: string, argv: string[]): Promise<number> {
const id = argv[0];
if (id === undefined || argv.length > 1) {
printCliError(`${usage()}\n\nerror: thread rm requires <id>`);
printCliError(`${formatCliUsage()}\n\nerror: thread rm requires <id>`);
return 1;
}
const result = await cmdThreadRemove(storageRoot, id);
@@ -254,18 +292,27 @@ async function dispatchThreadRm(storageRoot: string, argv: string[]): Promise<nu
return 0;
}
async function dispatchThreadBranch(storageRoot: string, rest: string[]): Promise<number> {
const sub = rest[0];
if (sub === "rm") {
return dispatchThreadRm(storageRoot, rest.slice(1));
async function dispatchGc(storageRoot: string, argv: string[]): Promise<number> {
if (argv.length > 0) {
printCliError(`${formatCliUsage()}\n\nerror: gc takes no arguments`);
return 1;
}
return dispatchThread(storageRoot, rest);
const result = await cmdGc(storageRoot);
if (!result.ok) {
printCliError(result.error);
return 1;
}
const stats = result.value;
printCliLine(
`scanned ${stats.scannedThreads} threads, ${stats.activeRefs} active refs, deleted ${stats.deletedEntries} entries`,
);
return 0;
}
async function dispatchFork(storageRoot: string, argv: string[]): Promise<number> {
const parsed = parseForkArgv(argv);
if (!parsed.ok) {
printCliError(`${usage()}\n\nerror: ${parsed.error}`);
printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`);
return 1;
}
const result = await cmdFork(storageRoot, parsed.value.threadId, parsed.value.fromRole);
@@ -277,40 +324,402 @@ async function dispatchFork(storageRoot: string, argv: string[]): Promise<number
return 0;
}
type DispatchFn = (storageRoot: string, argv: string[]) => Promise<number>;
// ── CAS subcommand table ───────────────────────────────────────────────
async function dispatchCasGet(storageRoot: string, rest: string[]): Promise<number> {
const threadId = rest[0];
const hash = rest[1];
if (threadId === undefined || hash === undefined || rest.length > 2) {
printCliError(`${formatCliUsage()}\n\nerror: cas get requires <thread-id> <hash>`);
return 1;
}
const result = await cmdCasGet(storageRoot, threadId, hash);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(result.value);
return 0;
}
async function dispatchCasPut(storageRoot: string, rest: string[]): Promise<number> {
const threadId = rest[0];
const content = rest[1];
if (threadId === undefined || content === undefined || rest.length > 2) {
printCliError(`${formatCliUsage()}\n\nerror: cas put requires <thread-id> <content>`);
return 1;
}
const result = await cmdCasPut(storageRoot, threadId, content);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(result.value);
return 0;
}
async function dispatchCasList(storageRoot: string, rest: string[]): Promise<number> {
const threadId = rest[0];
if (threadId === undefined || rest.length > 1) {
printCliError(`${formatCliUsage()}\n\nerror: cas list requires <thread-id>`);
return 1;
}
const result = await cmdCasList(storageRoot, threadId);
if (!result.ok) {
printCliError(result.error);
return 1;
}
for (const hash of result.value) {
printCliLine(hash);
}
return 0;
}
async function dispatchCasRm(storageRoot: string, rest: string[]): Promise<number> {
const threadId = rest[0];
const hash = rest[1];
if (threadId === undefined || hash === undefined || rest.length > 2) {
printCliError(`${formatCliUsage()}\n\nerror: cas rm requires <thread-id> <hash>`);
return 1;
}
const result = await cmdCasRm(storageRoot, threadId, hash);
if (!result.ok) {
printCliError(result.error);
return 1;
}
printCliLine(`removed cas entry ${hash}`);
return 0;
}
// ── Subcommand tables with metadata ────────────────────────────────────
const WORKFLOW_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
add: {
handler: dispatchAdd,
args: "<name> <file.esm.js> [--types <path>]",
description: "Register a workflow bundle in the registry",
},
list: { handler: dispatchList, args: "", description: "List all registered workflows" },
show: {
handler: dispatchShow,
args: "<name>",
description: "Show details of a registered workflow",
},
rm: {
handler: dispatchRemove,
args: "<name>",
description: "Remove a workflow from the registry",
},
history: {
handler: dispatchHistory,
args: "<name>",
description: "Show version history of a workflow",
},
rollback: {
handler: dispatchRollback,
args: "<name> [hash]",
description: "Rollback a workflow to a previous version",
},
};
const THREAD_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
run: {
handler: dispatchRun,
args: "<name> [--prompt <text>] [--max-rounds N]",
description: "Start a new thread executing a workflow",
},
list: {
handler: dispatchThreadList,
args: "[name]",
description: "List threads, optionally filtered by workflow name",
},
show: { handler: dispatchThreadShow, args: "<id>", description: "Show thread details and state" },
rm: { handler: dispatchThreadRm, args: "<id>", description: "Remove a thread" },
fork: {
handler: dispatchFork,
args: "<thread-id> [--from-role <role>]",
description: "Fork a thread, optionally from a specific role",
},
ps: { handler: dispatchPs, args: "", description: "List running threads" },
kill: { handler: dispatchKill, args: "<thread-id>", description: "Kill a running thread" },
live: {
handler: dispatchLive,
args: "<thread-id> | --latest [--debug] [--role <name>]",
description: "Attach to a thread and stream output live",
},
pause: { handler: dispatchPause, args: "<thread-id>", description: "Pause a running thread" },
resume: { handler: dispatchResume, args: "<thread-id>", description: "Resume a paused thread" },
};
const CAS_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
get: {
handler: dispatchCasGet,
args: "<thread-id> <hash>",
description: "Retrieve content by hash from a thread's CAS",
},
put: {
handler: dispatchCasPut,
args: "<thread-id> <content>",
description: "Store content in a thread's CAS, returns hash",
},
list: {
handler: dispatchCasList,
args: "<thread-id>",
description: "List all CAS entries for a thread",
},
rm: { handler: dispatchCasRm, args: "<thread-id> <hash>", description: "Remove a CAS entry" },
gc: { handler: dispatchGc, args: "", description: "Garbage-collect unreferenced CAS entries" },
};
const INIT_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
workspace: {
handler: dispatchInitWorkspace,
args: "<name>",
description: "Initialize a new workflow workspace",
},
template: {
handler: dispatchInitTemplate,
args: "<name>",
description: "Initialize a new workflow template",
},
};
// ── Command registry ───────────────────────────────────────────────────
export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
return [
{
name: "workflow",
commands: Object.entries(WORKFLOW_SUBCOMMAND_TABLE).map(([name, e]) => ({
name,
args: e.args,
description: e.description,
})),
},
{
name: "thread",
commands: Object.entries(THREAD_SUBCOMMAND_TABLE).map(([name, e]) => ({
name,
args: e.args,
description: e.description,
})),
},
{
name: "cas",
commands: Object.entries(CAS_SUBCOMMAND_TABLE).map(([name, e]) => ({
name,
args: e.args,
description: e.description,
})),
},
{
name: "init",
commands: Object.entries(INIT_SUBCOMMAND_TABLE).map(([name, e]) => ({
name,
args: e.args,
description: e.description,
})),
},
];
}
// ── Auto-generated CLI usage ───────────────────────────────────────────
export function formatCliUsage(): string {
const groups = getCommandRegistry();
const lines: string[] = ["Usage:"];
for (const group of groups) {
for (const cmd of group.commands) {
const args = cmd.args ? ` ${cmd.args}` : "";
lines.push(` uncaged-workflow ${group.name} ${cmd.name}${args}`);
}
lines.push("");
}
lines.push(" uncaged-workflow run <name> [...] (shortcut for thread run)");
lines.push(" uncaged-workflow live <thread-id> [...] (shortcut for thread live)");
lines.push("");
lines.push(" uncaged-workflow skill [topic] agent-consumable reference docs");
lines.push(" uncaged-workflow help show this usage");
lines.push("");
lines.push("Environment variables:");
lines.push(
" WORKFLOW_STORAGE_ROOT Override storage directory (default: ~/.uncaged/workflow)",
);
lines.push(
" UNCAGED_WORKFLOW_STORAGE_ROOT Internal override (takes priority over WORKFLOW_STORAGE_ROOT)",
);
return lines.join("\n");
}
function printDeprecation(oldCmd: string, newCmd: string): void {
printCliWarn(`⚠ "${oldCmd}" is deprecated, use "${newCmd}" instead`);
}
// ── Group dispatchers ──────────────────────────────────────────────────
function dispatchGroup(
tableName: string,
table: Record<string, CommandEntry>,
storageRoot: string,
argv: string[],
): Promise<number> | null {
const sub = argv[0];
if (sub === undefined || sub === "--help" || sub === "-h") {
const entries = Object.entries(table);
const lines = [`${tableName} subcommands:\n`];
for (const [name, e] of entries) {
const args = e.args ? ` ${e.args}` : "";
lines.push(` uncaged-workflow ${tableName} ${name}${args}`);
lines.push(` ${e.description}\n`);
}
printCliLine(lines.join("\n"));
return Promise.resolve(sub === undefined ? 1 : 0);
}
const entry = table[sub];
if (entry === undefined) {
return null;
}
return entry.handler(storageRoot, argv.slice(1));
}
async function dispatchInit(storageRoot: string, argv: string[]): Promise<number> {
const result = dispatchGroup("init", INIT_SUBCOMMAND_TABLE, storageRoot, argv);
if (result !== null) {
return result;
}
const sub = argv[0];
printCliError(`${formatCliUsage()}\n\nerror: unknown init subcommand: ${sub}`);
return 1;
}
async function dispatchWorkflow(storageRoot: string, argv: string[]): Promise<number> {
const result = dispatchGroup("workflow", WORKFLOW_SUBCOMMAND_TABLE, storageRoot, argv);
if (result !== null) {
return result;
}
const sub = argv[0];
if (sub === "remove") {
printDeprecation("workflow remove", "workflow rm");
return dispatchRemove(storageRoot, argv.slice(1));
}
printCliError(`${formatCliUsage()}\n\nerror: unknown workflow subcommand: ${sub}`);
return 1;
}
async function dispatchThread(storageRoot: string, argv: string[]): Promise<number> {
const result = dispatchGroup("thread", THREAD_SUBCOMMAND_TABLE, storageRoot, argv);
if (result !== null) {
return result;
}
const sub = argv[0];
printCliError(`${formatCliUsage()}\n\nerror: unknown thread subcommand: ${sub}`);
return 1;
}
async function dispatchCas(storageRoot: string, argv: string[]): Promise<number> {
const result = dispatchGroup("cas", CAS_SUBCOMMAND_TABLE, storageRoot, argv);
if (result !== null) {
return result;
}
const sub = argv[0];
printCliError(`${formatCliUsage()}\n\nerror: unknown cas subcommand: ${sub}`);
return 1;
}
// ── Help ────────────────────────────────────────────────────────────────
async function dispatchSkill(_storageRoot: string, argv: string[]): Promise<number> {
const topic = argv[0];
if (topic === undefined) {
printCliLine(formatSkillIndex());
return 0;
}
const doc = formatSkillTopic(topic);
if (doc === null) {
printCliError(`unknown skill topic: ${topic}\n\n${formatSkillIndex()}`);
return 1;
}
printCliLine(doc);
return 0;
}
async function dispatchHelp(_storageRoot: string, argv: string[]): Promise<number> {
// Legacy compat: help --skill [topic] → skill [topic]
const skillIdx = argv.indexOf("--skill");
if (skillIdx !== -1) {
const topic = argv[skillIdx + 1];
if (topic === undefined) {
printCliLine(formatSkillIndex());
return 0;
}
const doc = formatSkillTopic(topic);
if (doc === null) {
printCliError(`unknown skill topic: ${topic}\n\n${formatSkillIndex()}`);
return 1;
}
printCliLine(doc);
return 0;
}
printCliLine(formatCliUsage());
return 0;
}
// ── Top-level command table (Phase 3) ──────────────────────────────────
const COMMAND_TABLE: Record<string, DispatchFn> = {
add: dispatchAdd,
list: dispatchList,
show: dispatchShow,
remove: dispatchRemove,
// Grouped commands (primary)
workflow: dispatchWorkflow,
thread: dispatchThread,
cas: dispatchCas,
init: dispatchInit,
help: dispatchHelp,
skill: dispatchSkill,
// Top-level shortcuts (no deprecation)
run: dispatchRun,
ps: dispatchPs,
kill: dispatchKill,
history: dispatchHistory,
rollback: dispatchRollback,
pause: dispatchPause,
resume: dispatchResume,
threads: dispatchThreads,
thread: dispatchThreadBranch,
fork: dispatchFork,
live: dispatchLive,
};
// Deprecated flat commands that delegate to grouped commands
const DEPRECATED_ALIASES: Record<string, { newCmd: string; handler: DispatchFn }> = {
add: { newCmd: "workflow add", handler: dispatchAdd },
list: { newCmd: "workflow list", handler: dispatchList },
show: { newCmd: "workflow show", handler: dispatchShow },
remove: { newCmd: "workflow rm", handler: dispatchRemove },
ps: { newCmd: "thread ps", handler: dispatchPs },
kill: { newCmd: "thread kill", handler: dispatchKill },
pause: { newCmd: "thread pause", handler: dispatchPause },
resume: { newCmd: "thread resume", handler: dispatchResume },
threads: { newCmd: "thread list", handler: dispatchThreadList },
fork: { newCmd: "thread fork", handler: dispatchFork },
gc: { newCmd: "cas gc", handler: dispatchGc },
history: { newCmd: "workflow history", handler: dispatchHistory },
rollback: { newCmd: "workflow rollback", handler: dispatchRollback },
};
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
if (argv.length === 0) {
printCliError(usage());
printCliLine(formatCliUsage());
return 1;
}
const command = argv[0];
if (command === undefined) {
printCliError(usage());
printCliLine(formatCliUsage());
return 1;
}
const rest = argv.slice(1);
const dispatch = COMMAND_TABLE[command];
if (dispatch === undefined) {
printCliError(`${usage()}\n\nerror: unknown command ${command}`);
return 1;
if (dispatch !== undefined) {
return dispatch(storageRoot, rest);
}
return dispatch(storageRoot, rest);
const deprecated = DEPRECATED_ALIASES[command];
if (deprecated !== undefined) {
printDeprecation(command, deprecated.newCmd);
return deprecated.handler(storageRoot, rest);
}
printCliError(`${formatCliUsage()}\n\nerror: unknown command ${command}`);
return 1;
}
Regular → Executable
View File
+1 -1
View File
@@ -192,7 +192,7 @@ export async function cmdAdd(
return validated;
}
const extracted = await extractBundleExports(resolvedPath);
const extracted = await extractBundleExports(resolvedPath, { storageRoot });
if (!extracted.ok) {
return extracted;
}
+43
View File
@@ -0,0 +1,43 @@
import { createCasStore, err, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
export async function cmdCasGet(
storageRoot: string,
_threadId: string,
hash: string,
): Promise<Result<string, string>> {
const cas = createCasStore(getGlobalCasDir(storageRoot));
const content = await cas.get(hash);
if (content === null) {
return err(`cas entry not found: ${hash}`);
}
return ok(content);
}
export async function cmdCasPut(
storageRoot: string,
_threadId: string,
content: string,
): Promise<Result<string, string>> {
const cas = createCasStore(getGlobalCasDir(storageRoot));
const hash = await cas.put(content);
return ok(hash);
}
export async function cmdCasList(
storageRoot: string,
_threadId: string,
): Promise<Result<string[], string>> {
const cas = createCasStore(getGlobalCasDir(storageRoot));
const hashes = await cas.list();
return ok(hashes);
}
export async function cmdCasRm(
storageRoot: string,
_threadId: string,
hash: string,
): Promise<Result<void, string>> {
const cas = createCasStore(getGlobalCasDir(storageRoot));
await cas.delete(hash);
return ok(undefined);
}
+2 -1
View File
@@ -65,8 +65,9 @@ export async function cmdFork(
const newThreadId = generateUlid(Date.now());
const stepsOnWire = plan.value.historicalSteps.map((s) => ({
role: s.role,
content: s.content,
contentHash: s.contentHash,
meta: s.meta,
refs: s.refs,
timestamp: s.timestamp,
}));
+5
View File
@@ -0,0 +1,5 @@
import { type GcResult, garbageCollectCas, type Result } from "@uncaged/workflow";
export async function cmdGc(storageRoot: string): Promise<Result<GcResult, string>> {
return garbageCollectCas(storageRoot);
}
+238
View File
@@ -0,0 +1,238 @@
import { getCommandRegistry } from "./cli-dispatch.js";
type SkillTopic = {
name: string;
description: string;
format: () => string;
};
const SKILL_TOPICS: ReadonlyArray<SkillTopic> = [
{ name: "cli", description: "Full CLI command reference", format: formatSkillCli },
{
name: "develop",
description: "Guide for agents executing roles inside a workflow",
format: formatSkillDevelop,
},
{
name: "author",
description: "Guide for building and publishing workflow bundles",
format: formatSkillAuthor,
},
];
export function getSkillTopics(): ReadonlyArray<{ name: string; description: string }> {
return SKILL_TOPICS.map((t) => ({ name: t.name, description: t.description }));
}
export function formatSkillTopic(topic: string): string | null {
const entry = SKILL_TOPICS.find((t) => t.name === topic);
if (entry === undefined) {
return null;
}
return entry.format();
}
export function formatSkillIndex(): string {
const rows = SKILL_TOPICS.map((t) => `| \`${t.name}\` | ${t.description} |`);
return `# uncaged-workflow help --skill
Available topics:
| Topic | Description |
|-------|-------------|
${rows.join("\n")}
Usage: \`uncaged-workflow skill <topic>\`
`;
}
// ── cli topic (existing full reference) ────────────────────────────────
function formatSkillCli(): string {
const groups = getCommandRegistry();
const commandSections: string[] = [];
for (const group of groups) {
const rows = group.commands.map((cmd) => {
const args = cmd.args ? `\`${cmd.args}\`` : "(none)";
return `| \`${group.name} ${cmd.name}\` | ${args} | ${cmd.description} |`;
});
commandSections.push(
`### ${group.name}\n\n| Command | Args | Description |\n|---------|------|-------------|\n${rows.join("\n")}`,
);
}
return `# uncaged-workflow CLI Reference
## Core Concepts
| Concept | Description |
|---------|-------------|
| **Workflow** | A single-file ESM bundle (\`.esm.js\`) that exports \`run\` and \`descriptor\`. Identified by name and XXH64 hash. |
| **Bundle** | The physical \`.esm.js\` file stored in the bundles directory. Immutable once written. |
| **Thread** | A single execution of a workflow, identified by a ULID. Persists state as JSONL files. |
| **CAS** | Content-Addressable Storage. Per-thread key-value store keyed by content hash. |
| **Registry** | \`workflow.yaml\` — maps workflow names to their current and historical bundle hashes. |
## Commands
${commandSections.join("\n\n")}
### Top-level shortcuts
| Command | Equivalent | Description |
|---------|------------|-------------|
| \`run\` | \`thread run\` | Shortcut to start a thread |
| \`live\` | \`thread live\` | Shortcut to attach to a thread |
## Typical Workflow
1. \`uncaged-workflow workflow add my-wf ./my-wf.esm.js\` — register a workflow
2. \`uncaged-workflow run my-wf --prompt "do the thing"\` — start a thread
3. \`uncaged-workflow live --latest\` — attach and watch output
4. \`uncaged-workflow thread show <thread-id>\` — inspect completed thread
## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | Error |
## Environment Variables
| Variable | Description |
|----------|-------------|
| \`UNCAGED_WORKFLOW_STORAGE_ROOT\` | Override the default storage directory for all workflow data |
`;
}
// ── develop topic (for agents inside a workflow) ───────────────────────
function formatSkillDevelop(): string {
return `# Workflow Role Guide
Reference for agents executing roles (planner, coder, reviewer, etc.) inside a running workflow thread.
## Thread ID
Every thread has a 26-character Crockford Base32 ULID (e.g. \`06F03H5V6JTMDST6P3TVH42RWM\`).
It appears in the **first message** of the conversation. If unsure:
\`\`\`
uncaged-workflow thread list
\`\`\`
## CAS (Content-Addressable Storage)
Store and retrieve content by hash, scoped to the current thread.
| Operation | Command |
|-----------|---------|
| **Store** | \`uncaged-workflow cas put <THREAD_ID> '<content>'\` → prints hash |
| **Read** | \`uncaged-workflow cas get <THREAD_ID> <HASH>\` → prints content |
| **List** | \`uncaged-workflow cas list <THREAD_ID>\` |
CAS is the **only** supported way to persist structured data (phase plans, review notes, etc.) within a thread. Do not use temp files.
## Meta Output
Each role must produce structured output that the moderator extracts. The exact schema depends on the role, but the pattern is:
1. Do your work (write code, run tests, etc.)
2. Output a compact JSON object matching the role's schema
3. The moderator extracts and validates it automatically
## Thread Context
The conversation history contains outputs from previous roles. Read it to understand:
- What task was requested (from the initial prompt)
- What previous roles produced (plans, code changes, review results)
- What the moderator decided (which phase to work on, whether to retry)
`;
}
// ── author topic (for workflow developers) ─────────────────────────────
function formatSkillAuthor(): string {
return `# Workflow Authoring Guide
How to build, test, and publish workflow bundles for uncaged-workflow.
## Bundle Structure
A workflow bundle is a single ESM file (\`.esm.js\`) that exports:
\`\`\`typescript
// Required exports
export const descriptor: WorkflowDescriptor;
export const run: WorkflowRun;
\`\`\`
## WorkflowDescriptor
Defines the workflow's metadata and role sequence:
\`\`\`typescript
type WorkflowDescriptor = {
name: string; // verb-first kebab-case, e.g. "solve-issue"
description: string; // one-line summary
roles: string[]; // ordered role names, e.g. ["planner", "coder", "reviewer"]
};
\`\`\`
## WorkflowRun
The main function that creates and returns a moderator:
\`\`\`typescript
type WorkflowRun = (ctx: WorkflowContext) => Moderator;
\`\`\`
The **Moderator** controls the flow — it decides which role runs next, handles retries, and determines when the workflow is complete.
## Role Definition
Each role has:
| Field | Type | Purpose |
|-------|------|---------|
| \`description\` | string | What the role does |
| \`systemPrompt\` | string | System prompt for the agent |
| \`extractPrompt\` | string | Instruction for extracting structured meta |
| \`schema\` | ZodSchema | Validates the extracted meta |
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
| \`extractMode\` | "single" | Extraction mode |
## Development Workflow
\`\`\`bash
# 1. Initialize a workspace
uncaged-workflow init workspace my-workflow
# 2. Write your template (roles + moderator + descriptor)
# 3. Build the ESM bundle
bun run build
# 4. Register locally
uncaged-workflow workflow add my-workflow ./dist/my-workflow.esm.js
# 5. Test
uncaged-workflow run my-workflow --prompt "test task"
uncaged-workflow live --latest
\`\`\`
## Versioning
Bundles are immutable and identified by XXH64 hash. Re-registering a workflow with a new bundle creates a new version. Use \`workflow history\` and \`workflow rollback\` to manage versions.
`;
}
// ── Legacy compat ──────────────────────────────────────────────────────
/** @deprecated Use formatSkillTopic("cli") instead */
export function formatSkillDoc(): string {
return formatSkillCli();
}
+415
View File
@@ -0,0 +1,415 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow";
import { pathExists } from "./fs-utils.js";
export type CmdInitWorkspaceSuccess = {
rootPath: string;
};
export type CmdInitTemplateSuccess = {
templatePath: string;
};
function validateWorkspaceSegment(name: string): Result<void, string> {
if (name.length === 0) {
return err("workspace name must not be empty");
}
if (name === "." || name === "..") {
return err("invalid workspace name");
}
if (name.includes("/") || name.includes("\\")) {
return err("workspace name must not contain path separators");
}
return ok(undefined);
}
function rootPackageJson(workspaceName: string): string {
return `${JSON.stringify(
{
name: workspaceName,
private: true,
type: "module",
workspaces: ["templates/*", "workflows"],
},
null,
2,
)}\n`;
}
function workflowsPackageJson(): string {
return `${JSON.stringify(
{
name: "workflows",
version: "0.0.0",
private: true,
type: "module",
dependencies: {
"@uncaged/workflow": "^0.1.0",
zod: "^4.0.0",
},
},
null,
2,
)}\n`;
}
function biomeJson(): string {
return `${JSON.stringify(
{
$schema: "https://biomejs.dev/schemas/2.4.14/schema.json",
files: {
includes: ["**", "!**/node_modules", "!**/dist"],
},
formatter: {
indentWidth: 2,
},
linter: {
enabled: true,
rules: {
recommended: true,
},
},
},
null,
2,
)}\n`;
}
function tsconfigJson(): string {
return `${JSON.stringify(
{
compilerOptions: {
strict: true,
target: "ESNext",
module: "ESNext",
moduleResolution: "Bundler",
skipLibCheck: true,
},
},
null,
2,
)}\n`;
}
function agentsMd(): string {
return `# AGENTS — Workflow 工作区开发指南
面向在本仓库中编写 workflow 的 coding agent。引擎层术语与架构细节与 **@uncaged/workflow** 上游文档一致,编写时可对照 \`CLAUDE.md\`\`docs/architecture.md\`
## 1. 项目结构(workspace / template / workflow instance)
| 层级 | 目录 / 产物 | 职责 |
|------|----------------|------|
| **Workspace** | 仓库根(\`package.json\`\`workspaces: ["templates/*", "workflows"]\`) | Bun monorepo:统一管理本地模板包与 workflow 实例 |
| **Template** | \`templates/<name>/\`(如 \`src/roles.ts\`\`src/moderator.ts\`\`src/index.ts\`) | 纯数据:**WorkflowDefinition**(各 **RoleDefinition** + **Moderator**),**不绑定**具体 Agent |
| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AgentFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下放绑定与打包入口。
## 2. 核心概念
- **RoleMeta**:\`Record<string, Record<string, unknown>>\`,角色名 → 该角色结构化 meta 的形状约定。
- **RoleDefinition<Meta>**:纯数据——\`description\`\`systemPrompt\`\`extractPrompt\`\`schema\`(Zod v4)。不含执行逻辑。
- **WorkflowDefinition<M extends RoleMeta>**:\`description\` + \`roles\`(各角色定义)+ **Moderator**。
- **Moderator**:\`(ctx: ModeratorContext<M>) => (角色名) | END\`。同步、纯函数,只做路由。
- **AgentFn**:\`(ctx: AgentContext) => Promise<string>\`,原始文本输出;从上下文读取当前角色的 \`systemPrompt\`
- **ExtractFn**:从上下文与 prompt 解析结构化数据(引擎与 Agent 都可使用)。
引擎循环简述:**Moderator** → 选角色 → **Agent** 产出文本 → **Extract** 写入 **meta** → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
## 3. 开发流程
1. **定义 RoleMeta**:为每个角色约定 meta 的 TypeScript 类型(与 Zod schema 对齐)。
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`extractPrompt\` / \`description\`
3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\`
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding, extract)\`(或项目约定的封装)绑定 **AgentFn** / **ExtractFn**。
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
## 4. 编码规范
与 **CLAUDE.md** 对齐,摘要如下:
- **Functional-first**:优先 \`function\` + \`type\`,避免面向对象业务模型。
- **type 而非 interface**:类型别名一律用 \`type\`,不要使用 \`interface\`
- **显式可空**:不要用 \`?:\`;可空字段写成 \`T | null\`
- **function 而非 class**:不用 class(第三方库要求或 \`Error\` 子类除外)。
- **Crockford Base32**:日志 tag、bundle hash、thread id 等标识约定(引擎侧);工作区内自定义日志若沿用引擎 logger,tag 为 8 字符 Crockford Base32,且每个调用点唯一。
- **Named exports only**:不要使用 **default export**;workflow bundle 须 **export const run** 与 **export const descriptor**。
- **No console.log**:库代码用结构化 logger;CLI 用户输出可按项目 Biome 规则例外标注。
- **No dynamic import**:业务与 bundle 内禁止 \`import()\`;例外仅限「运行时路径由用户提供的 bundle 加载器」(引擎内部)。
## 5. Template 复用
- **已发布模板**:可通过 npm 依赖 \`@uncaged/workflow-template-*\` 等包,在 workflow 实例中 import 其 **WorkflowDefinition** 再绑定 Agent。
- **本地模板**:放在本仓库 \`templates/<name>/\`,由 workspace 协议引用(如 \`"template-foo": "workspace:*"\` 或相对路径),便于同源修改与版本控制。
选择模板时保持 **definition 与 agent 绑定分离**:模板只描述「做什么、顺序如何」,实例决定「谁执行、如何抽取 meta」。
## 6. Build and Test
日常命令:
\`\`\`sh
bun install
bun run check # Biome:lint + format
bun test
bun build # 若包内配置了 build 脚本则用于产出 dist / bundle
uncaged-workflow add <name> <path/to/bundle.esm.js>
\`\`\`
提交前至少运行 **bun run check** 与 **bun test**;registry 与本地运行流参见 README 与 CLI 文档。
## 7. 常见陷阱
- **No dynamic import**:bundle 须静态可分析;动态 \`import()\` 会破坏哈希与加载约束。
- **No default export**:引擎只接受命名导出 \`run\` / \`descriptor\`
- **No console.log**:避免在可被 Biome \`noConsole\` 规则覆盖的代码路径直接使用 console。
- **Single-file ESM bundle**:交付物是单一 \`.esm.js\`;静态 import 仅限 Node 内置(见 architecture 文档中的 Bundle Contract)。
---
编写新 workflow 时,先对齐 **RoleMeta → RoleDefinition(Zod)→ Moderator → 绑定 → 单文件 bundle**,再对照本节规范自检。
`;
}
function readmeMd(workspaceName: string): string {
return `# ${workspaceName}
Local workflow development workspace (Bun monorepo).
## Layout
- \`templates/\` — reusable workflow definition packages (roles + moderator), no agent binding
- \`workflows/\` — workflow instances that bind templates to agents and export \`run\` + \`descriptor\`
## Commands
\`\`\`sh
bun install
bun run check # after you add scripts / Biome
uncaged-workflow add <name> <bundle.esm.js>
uncaged-workflow run <name>
\`\`\`
Create this skeleton with:
\`\`\`sh
uncaged-workflow init workspace ${workspaceName}
\`\`\`
`;
}
export async function cmdInitWorkspace(
parentDir: string,
workspaceName: string,
): Promise<Result<CmdInitWorkspaceSuccess, string>> {
const validated = validateWorkspaceSegment(workspaceName);
if (!validated.ok) {
return validated;
}
const rootPath = join(parentDir, workspaceName);
if (await pathExists(rootPath)) {
return err(`directory already exists: ${rootPath}`);
}
await mkdir(rootPath, { recursive: false });
await mkdir(join(rootPath, "templates"), { recursive: false });
await mkdir(join(rootPath, "workflows"), { recursive: false });
await Promise.all([
writeFile(join(rootPath, "package.json"), rootPackageJson(workspaceName), "utf8"),
writeFile(join(rootPath, "biome.json"), biomeJson(), "utf8"),
writeFile(join(rootPath, "tsconfig.json"), tsconfigJson(), "utf8"),
writeFile(join(rootPath, "AGENTS.md"), agentsMd(), "utf8"),
writeFile(join(rootPath, "README.md"), readmeMd(workspaceName), "utf8"),
writeFile(join(rootPath, "templates", ".gitkeep"), "", "utf8"),
writeFile(join(rootPath, "workflows", "package.json"), workflowsPackageJson(), "utf8"),
]);
return ok({ rootPath });
}
function hasTemplatesWorkspaceGlob(workspaces: unknown): boolean {
return Array.isArray(workspaces) && workspaces.includes("templates/*");
}
async function readPackageJsonWorkspaces(dir: string): Promise<unknown | null> {
const pkgPath = join(dir, "package.json");
if (!(await pathExists(pkgPath))) {
return null;
}
let raw: string;
try {
raw = await readFile(pkgPath, "utf8");
} catch {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
return null;
}
if (typeof parsed !== "object" || parsed === null || !("workspaces" in parsed)) {
return null;
}
return (parsed as { workspaces: unknown }).workspaces;
}
/** Resolve uncaged-workflow workspace root (package.json with `templates/*` in `workspaces`). */
async function findWorkflowWorkspaceRoot(startDir: string): Promise<Result<string, string>> {
let dir = resolve(startDir);
for (;;) {
const workspaces = await readPackageJsonWorkspaces(dir);
if (workspaces !== null && hasTemplatesWorkspaceGlob(workspaces)) {
return ok(dir);
}
const parent = dirname(dir);
if (parent === dir) {
return err(
'not inside a workflow workspace (no package.json with workspaces containing "templates/*")',
);
}
dir = parent;
}
}
function templatePackageJson(templateName: string): string {
return `${JSON.stringify(
{
name: `template-${templateName}`,
version: "0.0.0",
private: true,
type: "module",
dependencies: {
"@uncaged/workflow": "^0.1.0",
zod: "^4.0.0",
},
},
null,
2,
)}\n`;
}
function templateTsconfigJson(): string {
return `${JSON.stringify(
{
extends: "../../tsconfig.json",
compilerOptions: {
rootDir: "src",
outDir: "dist",
},
include: ["src/**/*.ts"],
},
null,
2,
)}\n`;
}
function templateRolesTs(): string {
return `import type { RoleDefinition } from "@uncaged/workflow";
import * as z from "zod/v4";
export const HELLO_TEMPLATE_DESCRIPTION =
"Minimal starter template: one greeter role, then END.";
export type HelloTemplateMeta = {
greeter: {
message: string;
};
};
const greeterMetaSchema = z.object({
message: z.string(),
});
export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
description: "Says hello — replace with your first role.",
systemPrompt: "You are a helpful assistant. Reply with one short friendly sentence.",
extractPrompt: "Extract the assistant's greeting as message.",
schema: greeterMetaSchema,
extractRefs: null,
};
`;
}
function templateModeratorTs(): string {
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow";
import type { HelloTemplateMeta } from "./roles.js";
export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
ctx: ModeratorContext<HelloTemplateMeta>,
) => {
if (ctx.steps.length === 0) {
return "greeter";
}
return END;
};
`;
}
function templateIndexTs(): string {
return `import type { WorkflowDefinition } from "@uncaged/workflow";
import { helloTemplateModerator } from "./moderator.js";
import {
HELLO_TEMPLATE_DESCRIPTION,
type HelloTemplateMeta,
greeterRole,
} from "./roles.js";
export {
HELLO_TEMPLATE_DESCRIPTION,
type HelloTemplateMeta,
greeterRole,
} from "./roles.js";
export { helloTemplateModerator } from "./moderator.js";
export const helloTemplateWorkflowDefinition: WorkflowDefinition<HelloTemplateMeta> = {
description: HELLO_TEMPLATE_DESCRIPTION,
roles: {
greeter: greeterRole,
},
moderator: helloTemplateModerator,
};
`;
}
export async function cmdInitTemplate(
startDir: string,
templateName: string,
): Promise<Result<CmdInitTemplateSuccess, string>> {
const validated = validateWorkspaceSegment(templateName);
if (!validated.ok) {
return validated;
}
const rootResult = await findWorkflowWorkspaceRoot(startDir);
if (!rootResult.ok) {
return rootResult;
}
const workspaceRoot = rootResult.value;
const templateDir = join(workspaceRoot, "templates", templateName);
if (await pathExists(templateDir)) {
return err(`template already exists: ${templateDir}`);
}
await mkdir(join(templateDir, "src"), { recursive: true });
await Promise.all([
writeFile(join(templateDir, "package.json"), templatePackageJson(templateName), "utf8"),
writeFile(join(templateDir, "tsconfig.json"), templateTsconfigJson(), "utf8"),
writeFile(join(templateDir, "src", "roles.ts"), templateRolesTs(), "utf8"),
writeFile(join(templateDir, "src", "moderator.ts"), templateModeratorTs(), "utf8"),
writeFile(join(templateDir, "src", "index.ts"), templateIndexTs(), "utf8"),
]);
return ok({ templatePath: templateDir });
}
+463
View File
@@ -0,0 +1,463 @@
import { watch } from "node:fs";
import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import {
type CasStore,
createCasStore,
getContentMerklePayload,
getGlobalCasDir,
tryParseRoleStepRecord,
tryParseWorkflowResultRecord,
type WorkflowCompletion,
} from "@uncaged/workflow";
import { printCliError, printCliLine } from "./cli-output.js";
import { pathExists } from "./fs-utils.js";
import type { ParsedLiveArgv } from "./live-argv.js";
import { findLatestThreadDataPath, resolveThreadDataPath } from "./thread-scan.js";
export const LIVE_CONTENT_MAX_LINES = 10;
export type LiveRoleRow = {
role: string;
content: string;
meta: Record<string, unknown>;
timestamp: number;
};
export function formatLiveTimeLabel(timestampMs: number): string {
const d = new Date(timestampMs);
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
const ss = String(d.getSeconds()).padStart(2, "0");
return `${hh}:${mm}:${ss}`;
}
function shouldUseColor(): boolean {
return process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
}
function highlightLiveRole(name: string): string {
if (!shouldUseColor()) {
return name;
}
return `\x1b[1m\x1b[36m${name}\x1b[0m`;
}
function dimGreyLine(line: string): string {
if (!shouldUseColor()) {
return line;
}
return `\x1b[2m\x1b[90m${line}\x1b[0m`;
}
export function formatLiveDebugLine(timestampMs: number, tag: string, message: string): string {
const label = `[${formatLiveTimeLabel(timestampMs)}] [${tag}] ${message.replace(/\n/g, " ")}`;
return dimGreyLine(label);
}
export function renderLiveRoleStepLines(row: LiveRoleRow, roleDisplay: string): string[] {
const header = `[${formatLiveTimeLabel(row.timestamp)}] ▶ ${roleDisplay}`;
const lines: string[] = [header];
const parts = row.content.split("\n");
const shown = parts.slice(0, LIVE_CONTENT_MAX_LINES);
for (const ln of shown) {
lines.push(` ${ln}`);
}
const omitted = parts.length - shown.length;
if (omitted > 0) {
lines.push(` … (${omitted} more line${omitted === 1 ? "" : "s"})`);
}
lines.push(` meta: ${JSON.stringify(row.meta)}`);
return lines;
}
function printSummary(result: WorkflowCompletion): void {
printCliLine(`completed: returnCode=${result.returnCode}${result.summary}`);
}
type LiveSessionState = {
sawStart: boolean;
completed: boolean;
carry: string;
contentOffset: number;
};
type InfoLiveState = {
carry: string;
contentOffset: number;
};
function tryParseInfoRecord(obj: Record<string, unknown>): {
tag: string;
content: string;
timestamp: number;
} | null {
const tag = obj.tag;
const content = obj.content;
const timestamp = obj.timestamp;
if (
typeof tag !== "string" ||
typeof content !== "string" ||
typeof timestamp !== "number" ||
!Number.isFinite(timestamp)
) {
return null;
}
return { tag, content, timestamp };
}
async function handleJsonlLine(
rawLine: string,
state: LiveSessionState,
roleFilter: string | null,
cas: CasStore,
): Promise<{ parseError: string | null; workflowResult: WorkflowCompletion | null }> {
const trimmed = rawLine.trim();
if (trimmed === "") {
return { parseError: null, workflowResult: null };
}
let rec: unknown;
try {
rec = JSON.parse(trimmed) as unknown;
} catch {
return { parseError: "invalid JSON in thread data file", workflowResult: null };
}
if (rec === null || typeof rec !== "object") {
return { parseError: "invalid record in thread data file", workflowResult: null };
}
const obj = rec as Record<string, unknown>;
if (!state.sawStart) {
state.sawStart = true;
return { parseError: null, workflowResult: null };
}
const wf = tryParseWorkflowResultRecord(obj);
if (wf !== null) {
state.completed = true;
return { parseError: null, workflowResult: wf };
}
const roleRow = tryParseRoleStepRecord(obj);
if (roleRow === null) {
return {
parseError: "unrecognized record in thread data (expected role step or result)",
workflowResult: null,
};
}
if (roleFilter !== null && roleRow.role !== roleFilter) {
return { parseError: null, workflowResult: null };
}
const payload = await getContentMerklePayload(cas, roleRow.contentHash);
const content =
payload !== null ? payload : `(content not in CAS; contentHash=${roleRow.contentHash})`;
const row: LiveRoleRow = {
role: roleRow.role,
content,
meta: roleRow.meta,
timestamp: roleRow.timestamp,
};
for (const outLine of renderLiveRoleStepLines(row, highlightLiveRole(row.role))) {
printCliLine(outLine);
}
return { parseError: null, workflowResult: null };
}
async function pumpNewContent(
dataPath: string,
state: LiveSessionState,
roleFilter: string | null,
cas: CasStore,
): Promise<number | null> {
let text: string;
try {
text = await readFile(dataPath, "utf8");
} catch {
return null;
}
if (text.length < state.contentOffset) {
state.contentOffset = 0;
state.carry = "";
}
const chunk = text.slice(state.contentOffset);
state.contentOffset = text.length;
state.carry += chunk;
const parts = state.carry.split("\n");
state.carry = parts.pop() ?? "";
for (const line of parts) {
const { parseError, workflowResult } = await handleJsonlLine(line, state, roleFilter, cas);
if (parseError !== null) {
printCliError(parseError);
return 1;
}
if (workflowResult !== null) {
printSummary(workflowResult);
return 0;
}
}
return null;
}
async function pumpNewInfoContent(infoPath: string, state: InfoLiveState): Promise<void> {
let text: string;
try {
text = await readFile(infoPath, "utf8");
} catch {
return;
}
if (text.length < state.contentOffset) {
state.contentOffset = 0;
state.carry = "";
}
const chunk = text.slice(state.contentOffset);
state.contentOffset = text.length;
state.carry += chunk;
const parts = state.carry.split("\n");
state.carry = parts.pop() ?? "";
for (const line of parts) {
const trimmed = line.trim();
if (trimmed === "") {
continue;
}
let rec: unknown;
try {
rec = JSON.parse(trimmed) as unknown;
} catch {
continue;
}
if (rec === null || typeof rec !== "object") {
continue;
}
const parsed = tryParseInfoRecord(rec as Record<string, unknown>);
if (parsed === null) {
continue;
}
printCliLine(formatLiveDebugLine(parsed.timestamp, parsed.tag, parsed.content));
}
}
type WatchPumpTask = {
path: string;
pump: () => Promise<number | null>;
};
async function runWatchPumpStep(
settled: () => boolean,
pump: () => Promise<number | null>,
closeAll: () => void,
finish: (code: number) => void,
): Promise<void> {
if (settled()) {
return;
}
try {
const code = await pump();
if (code !== null) {
closeAll();
finish(code);
}
} catch (e) {
closeAll();
throw e instanceof Error ? e : new Error(String(e));
}
}
function watchLivePaths(params: { tasks: WatchPumpTask[]; signal: AbortSignal }): Promise<number> {
const { tasks, signal } = params;
return new Promise((resolve, reject) => {
let settled = false;
const finish = (code: number): void => {
if (settled) {
return;
}
settled = true;
resolve(code);
};
const pumpChains = new Map<string, Promise<void>>();
for (const t of tasks) {
pumpChains.set(t.path, Promise.resolve());
}
const watchers: ReturnType<typeof watch>[] = [];
const closeAll = (): void => {
for (const w of watchers) {
w.close();
}
};
function schedulePump(path: string, pump: () => Promise<number | null>): void {
const prev = pumpChains.get(path) ?? Promise.resolve();
const next = (async () => {
await prev;
await runWatchPumpStep(() => settled, pump, closeAll, finish);
})();
pumpChains.set(path, next);
}
for (const { path, pump } of tasks) {
const watcher = watch(path, (eventType) => {
if (eventType === "rename") {
return;
}
schedulePump(path, pump);
});
watchers.push(watcher);
watcher.on("error", (err: Error) => {
closeAll();
reject(err);
});
}
const onAbort = (): void => {
closeAll();
finish(0);
};
signal.addEventListener("abort", onAbort, { once: true });
for (const { path, pump } of tasks) {
schedulePump(path, pump);
}
});
}
type LiveThreadTarget = {
threadId: string;
dataPath: string;
};
async function resolveLiveThreadTarget(
storageRoot: string,
parsed: ParsedLiveArgv,
): Promise<LiveThreadTarget | null> {
if (parsed.latest) {
const found = await findLatestThreadDataPath(storageRoot);
if (found === null) {
printCliError("live: no threads found");
return null;
}
return found;
}
const id = parsed.threadId;
if (id === null) {
printCliError("live: internal error: missing thread id");
return null;
}
const resolved = await resolveThreadDataPath(storageRoot, id);
if (resolved === null) {
printCliError(`thread not found: ${id}`);
return null;
}
return { threadId: id, dataPath: resolved };
}
async function buildLiveWatchTasks(params: {
dataPath: string;
infoPath: string;
debug: boolean;
dataState: LiveSessionState;
infoState: InfoLiveState;
roleFilter: string | null;
cas: CasStore;
}): Promise<WatchPumpTask[]> {
const { dataPath, infoPath, debug, dataState, infoState, roleFilter, cas } = params;
const tasks: WatchPumpTask[] = [
{
path: dataPath,
pump: () => pumpNewContent(dataPath, dataState, roleFilter, cas),
},
];
if (debug && (await pathExists(infoPath))) {
tasks.push({
path: infoPath,
pump: async () => {
await pumpNewInfoContent(infoPath, infoState);
return null;
},
});
}
return tasks;
}
export async function cmdLive(storageRoot: string, parsed: ParsedLiveArgv): Promise<number> {
const target = await resolveLiveThreadTarget(storageRoot, parsed);
if (target === null) {
return 1;
}
const { threadId, dataPath } = target;
const roleFilter = parsed.role;
const infoPath = join(dirname(dataPath), `${threadId}.info.jsonl`);
const cas = createCasStore(getGlobalCasDir(storageRoot));
const dataState: LiveSessionState = {
sawStart: false,
completed: false,
carry: "",
contentOffset: 0,
};
const infoState: InfoLiveState = {
carry: "",
contentOffset: 0,
};
const controller = new AbortController();
const onSigInt = (): void => {
controller.abort();
};
process.on("SIGINT", onSigInt);
try {
const firstData = await pumpNewContent(dataPath, dataState, roleFilter, cas);
if (firstData === 1) {
return 1;
}
if (parsed.debug && (await pathExists(infoPath))) {
await pumpNewInfoContent(infoPath, infoState);
}
if (firstData === 0 || dataState.completed) {
return 0;
}
const tasks = await buildLiveWatchTasks({
dataPath,
infoPath,
debug: parsed.debug,
dataState,
infoState,
roleFilter,
cas,
});
return await watchLivePaths({ tasks, signal: controller.signal });
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
printCliError(`live: ${message}`);
return 1;
} finally {
process.off("SIGINT", onSigInt);
}
}
@@ -44,6 +44,7 @@ export async function cmdRollback(
}
const nextRegistry = {
config: reg.value.config,
workflows: { ...reg.value.workflows, [name]: rolled.value },
};
const written = await writeWorkflowRegistry(storageRoot, nextRegistry);
+1 -2
View File
@@ -15,7 +15,6 @@ export async function cmdRun(
storageRoot: string,
name: string,
prompt: string,
isDryRun: boolean,
maxRounds: number,
): Promise<Result<{ threadId: string }, string>> {
const nameOk = validateCliWorkflowName(name);
@@ -47,7 +46,7 @@ export async function cmdRun(
threadId,
workflowName: name,
prompt,
options: { isDryRun, maxRounds },
options: { maxRounds, depth: 0 },
},
{ awaitResponseLine: false },
);
+3 -1
View File
@@ -1,7 +1,7 @@
import { unlink } from "node:fs/promises";
import { dirname, join } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow";
import { err, garbageCollectCas, ok, type Result } from "@uncaged/workflow";
import { readTextFileIfExists } from "./fs-utils.js";
import { resolveThreadDataPath } from "./thread-scan.js";
@@ -38,5 +38,7 @@ export async function cmdThreadRemove(
await unlink(infoPath).catch(() => {});
await unlink(runningPath).catch(() => {});
await garbageCollectCas(storageRoot);
return ok(undefined);
}
+75
View File
@@ -0,0 +1,75 @@
import { err, ok, type Result } from "@uncaged/workflow";
export type ParsedLiveArgv = {
threadId: string | null;
latest: boolean;
debug: boolean;
role: string | null;
};
type LiveArgvScan = {
latest: boolean;
debug: boolean;
role: string | null;
threadId: string | null;
};
function applyLiveArgvToken(argv: string[], i: number, s: LiveArgvScan): Result<number, string> {
const a = argv[i];
if (a === "--latest") {
s.latest = true;
return ok(i + 1);
}
if (a === "--debug") {
s.debug = true;
return ok(i + 1);
}
if (a === "--role") {
const v = argv[i + 1];
if (v === undefined || v.startsWith("--")) {
return err("missing value for --role");
}
s.role = v;
return ok(i + 2);
}
if (a.startsWith("--")) {
return err(`unknown live flag: ${a}`);
}
if (s.threadId !== null) {
return err("unexpected extra argument");
}
s.threadId = a;
return ok(i + 1);
}
export function parseLiveArgv(argv: string[]): Result<ParsedLiveArgv, string> {
const s: LiveArgvScan = {
latest: false,
debug: false,
role: null,
threadId: null,
};
let i = 0;
while (i < argv.length) {
const step = applyLiveArgvToken(argv, i, s);
if (!step.ok) {
return step;
}
i = step.value;
}
if (s.latest && s.threadId !== null) {
return err("live --latest does not take <thread-id>");
}
if (!s.latest && s.threadId === null) {
return err("live requires <thread-id> or --latest");
}
return ok({
threadId: s.threadId,
latest: s.latest,
debug: s.debug,
role: s.role,
});
}
+2 -15
View File
@@ -3,20 +3,13 @@ import { err, ok, type Result } from "@uncaged/workflow";
export type ParsedRunArgv = {
name: string;
prompt: string;
dryRun: boolean;
maxRounds: number;
};
type FlagOk =
| { kind: "dry-run" }
| { kind: "prompt"; value: string }
| { kind: "max-rounds"; value: number };
type FlagOk = { kind: "prompt"; value: string } | { kind: "max-rounds"; value: number };
function parseFlagAt(argv: string[], index: number): Result<FlagOk, string> | null {
const flag = argv[index];
if (flag === "--dry-run") {
return ok({ kind: "dry-run" });
}
if (flag === "--prompt") {
const value = argv[index + 1];
if (value === undefined) {
@@ -41,7 +34,6 @@ function parseFlagAt(argv: string[], index: number): Result<FlagOk, string> | nu
export function parseRunArgv(argv: string[]): Result<ParsedRunArgv, string> {
let name: string | undefined;
let prompt = "";
let dryRun = false;
let maxRounds = 5;
let i = 0;
@@ -62,11 +54,6 @@ export function parseRunArgv(argv: string[]): Result<ParsedRunArgv, string> {
}
const flag = parsed.value;
if (flag.kind === "dry-run") {
dryRun = true;
i += 1;
continue;
}
if (flag.kind === "prompt") {
prompt = flag.value;
i += 2;
@@ -80,5 +67,5 @@ export function parseRunArgv(argv: string[]): Result<ParsedRunArgv, string> {
return err("run requires <name>");
}
return ok({ name, prompt, dryRun, maxRounds });
return ok({ name, prompt, maxRounds });
}
+15 -4
View File
@@ -1,10 +1,21 @@
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow";
/** Resolve storage root, honoring `UNCAGED_WORKFLOW_STORAGE_ROOT` for tests/tools. */
/**
* Resolve storage root with env var override support.
*
* Priority (highest first):
* 1. `UNCAGED_WORKFLOW_STORAGE_ROOT` — internal/test override
* 2. `WORKFLOW_STORAGE_ROOT` — user-facing override
* 3. Default (`~/.uncaged/workflow`)
*/
export function resolveWorkflowStorageRoot(): string {
const override = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
if (override !== undefined && override !== "") {
return override;
const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
if (internal !== undefined && internal !== "") {
return internal;
}
const userOverride = process.env.WORKFLOW_STORAGE_ROOT;
if (userOverride !== undefined && userOverride !== "") {
return userOverride;
}
return getDefaultWorkflowStorageRoot();
}
+67 -1
View File
@@ -1,4 +1,4 @@
import { readdir } from "node:fs/promises";
import { readdir, stat } from "node:fs/promises";
import { join } from "node:path";
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
@@ -15,6 +15,28 @@ export type HistoricalThreadRow = {
workflowName: string | null;
};
async function readThreadStartTimestampMs(dataPath: string): Promise<number | null> {
const text = await readTextFileIfExists(dataPath);
if (text === null) {
return null;
}
const firstLine = text.split("\n")[0];
if (firstLine === undefined || firstLine.trim() === "") {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(firstLine) as unknown;
} catch {
return null;
}
if (parsed === null || typeof parsed !== "object") {
return null;
}
const ts = (parsed as Record<string, unknown>).timestamp;
return typeof ts === "number" && Number.isFinite(ts) ? ts : null;
}
async function readWorkflowNameFromDataJsonl(dataPath: string): Promise<string | null> {
const text = await readTextFileIfExists(dataPath);
if (text === null) {
@@ -124,6 +146,50 @@ export async function listHistoricalThreads(
return out;
}
/**
* Picks the thread whose `.data.jsonl` is newest by start-record `timestamp`,
* falling back to file `mtime` when the timestamp is missing.
* Tie-breaker: larger `mtime` wins when start timestamps are equal.
*/
export async function findLatestThreadDataPath(
storageRoot: string,
): Promise<{ threadId: string; dataPath: string } | null> {
const threads = await listHistoricalThreads(storageRoot, null);
if (threads.length === 0) {
return null;
}
let best: {
threadId: string;
dataPath: string;
primary: number;
secondary: number;
} | null = null;
for (const t of threads) {
const dataPath = join(storageRoot, "logs", t.hash, `${t.threadId}.data.jsonl`);
let mtimeMs = 0;
try {
const st = await stat(dataPath);
mtimeMs = st.mtimeMs;
} catch {
continue;
}
const startTs = await readThreadStartTimestampMs(dataPath);
const primary = startTs !== null ? startTs : mtimeMs;
const secondary = mtimeMs;
if (
best === null ||
primary > best.primary ||
(primary === best.primary && secondary > best.secondary)
) {
best = { threadId: t.threadId, dataPath, primary, secondary };
}
}
return best === null ? null : { threadId: best.threadId, dataPath: best.dataPath };
}
export async function resolveThreadDataPath(
storageRoot: string,
threadId: string,
+2 -2
View File
@@ -1,4 +1,4 @@
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { type ChildProcess, spawn } from "node:child_process";
import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
import { createConnection } from "node:net";
import { join } from "node:path";
@@ -23,7 +23,7 @@ function isProcessAlive(pid: number): boolean {
async function waitForReadyLine(
childStdout: NodeJS.ReadableStream,
child: ChildProcessWithoutNullStreams,
child: ChildProcess,
): Promise<Result<number, string>> {
return await new Promise((resolve) => {
let buf = "";
+2 -1
View File
@@ -14,7 +14,8 @@
"sourceMap": true,
"composite": true,
"outDir": "dist",
"rootDir": "src"
"rootDir": "src",
"types": ["bun-types"]
},
"references": [{ "path": "../workflow" }],
"include": ["src/**/*.ts"]
File diff suppressed because one or more lines are too long
@@ -1,33 +1,41 @@
import { describe, expect, test } from "bun:test";
import type { ExtractContext, ExtractFn } from "@uncaged/workflow";
import type * as z from "zod/v4";
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
const testExtract: ExtractFn = async <T extends Record<string, unknown>>(
_schema: z.ZodType<T>,
_prompt: string,
_ctx: ExtractContext,
): Promise<T> => ({ workspace: "/tmp" }) as unknown as T;
describe("validateCursorAgentConfig", () => {
test("accepts valid config", () => {
const r = validateCursorAgentConfig({
workdir: "/tmp",
model: null,
timeout: null,
timeout: 0,
extract: testExtract,
});
expect(r.ok).toBe(true);
});
test("rejects empty workdir", () => {
test("rejects non-function extract", () => {
const r = validateCursorAgentConfig({
workdir: " ",
model: null,
timeout: null,
timeout: 0,
extract: null as unknown as ExtractFn,
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("workdir");
expect(r.error).toContain("extract");
}
});
test("rejects negative timeout", () => {
const r = validateCursorAgentConfig({
workdir: "/tmp",
model: null,
timeout: -1,
extract: testExtract,
});
expect(r.ok).toBe(false);
});
@@ -36,9 +44,9 @@ describe("validateCursorAgentConfig", () => {
describe("createCursorAgent", () => {
test("returns an AgentFn", () => {
const agent = createCursorAgent({
workdir: "/tmp",
model: null,
timeout: null,
timeout: 0,
extract: testExtract,
});
expect(typeof agent).toBe("function");
});
@@ -46,9 +54,9 @@ describe("createCursorAgent", () => {
test("throws on invalid config at construction", () => {
expect(() =>
createCursorAgent({
workdir: "",
model: null,
timeout: null,
timeout: -1,
extract: testExtract,
}),
).toThrow();
});
+2 -1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*"
"@uncaged/workflow-util-agent": "workspace:*",
"zod": "^4.0.0"
}
}
+24 -6
View File
@@ -1,5 +1,6 @@
import type { AgentFn } from "@uncaged/workflow";
import type { AgentFn, ExtractContext } from "@uncaged/workflow";
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
import * as z from "zod/v4";
import type { CursorAgentConfig } from "./types.js";
import { validateCursorAgentConfig } from "./validate-config.js";
@@ -8,6 +9,12 @@ export { buildAgentPrompt } from "@uncaged/workflow-util-agent";
export type { CursorAgentConfig } from "./types.js";
export { validateCursorAgentConfig } from "./validate-config.js";
const cursorWorkspaceSchema = z.object({
workspace: z
.string()
.describe("Absolute path to the project/repository directory the agent should work in"),
});
function throwCursorSpawnError(error: SpawnCliError): never {
if (error.kind === "non_zero_exit") {
throw new Error(
@@ -27,7 +34,7 @@ function resolveCursorModel(model: string | null): string {
return model === null ? "auto" : model;
}
/** Runs `cursor-agent` in {@link CursorAgentConfig.workdir} with a prompt built from context + system prompt. */
/** Runs `cursor-agent` with workspace from {@link CursorAgentConfig.extract} and prompt from context. */
export function createCursorAgent(config: CursorAgentConfig): AgentFn {
const validated = validateCursorAgentConfig(config);
if (!validated.ok) {
@@ -35,22 +42,33 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
}
const modelFlag = resolveCursorModel(config.model);
const timeoutMs = config.timeout;
const timeoutMs = config.timeout > 0 ? config.timeout : null;
return async (ctx, systemPrompt) => {
const fullPrompt = buildAgentPrompt(systemPrompt, ctx);
return async (ctx) => {
const extractCtx: ExtractContext = {
...ctx,
agentContent: "",
};
const { workspace } = await config.extract(
cursorWorkspaceSchema,
"From the thread context, determine the absolute filesystem path where the project/repository is located.",
extractCtx,
);
const fullPrompt = await buildAgentPrompt(ctx);
const args = [
"-p",
fullPrompt,
"--model",
modelFlag,
"--workspace",
workspace,
"--output-format",
"text",
"--trust",
"--force",
];
const run = await spawnCli("cursor-agent", args, {
cwd: config.workdir,
cwd: workspace,
timeoutMs,
});
if (!run.ok) {
+4 -2
View File
@@ -1,5 +1,7 @@
import type { ExtractFn } from "@uncaged/workflow";
export type CursorAgentConfig = {
workdir: string;
model: string | null;
timeout: number | null;
timeout: number;
extract: ExtractFn;
};
@@ -3,11 +3,11 @@ import { err, ok, type Result } from "@uncaged/workflow";
import type { CursorAgentConfig } from "./types.js";
export function validateCursorAgentConfig(config: CursorAgentConfig): Result<void, string> {
if (config.workdir.trim() === "") {
return err("workdir must be a non-empty string");
if (typeof config.extract !== "function") {
return err("extract must be a function");
}
if (config.timeout !== null && config.timeout < 0) {
return err("timeout must be null or a non-negative number (milliseconds)");
if (config.timeout < 0) {
return err("timeout must be a non-negative number (milliseconds); use 0 for no limit");
}
return ok(undefined);
}
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -34,8 +34,8 @@ export function createHermesAgent(config: HermesAgentConfig): AgentFn {
const timeoutMs = config.timeout;
return async (ctx, systemPrompt) => {
const fullPrompt = buildAgentPrompt(systemPrompt, ctx);
return async (ctx) => {
const fullPrompt = await buildAgentPrompt(ctx);
const args = [
"chat",
"-q",
File diff suppressed because one or more lines are too long
@@ -1,8 +1,14 @@
import { describe, expect, test } from "bun:test";
import { START, type ThreadContext } from "@uncaged/workflow";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createCasStore, START, type ThreadContext } from "@uncaged/workflow";
import { createLlmAdapter } from "../src/create-llm-adapter.js";
const casDir = mkdtempSync(join(tmpdir(), "wf-llm-adapter-cas-"));
const testCas = createCasStore(casDir);
function makeCtx(userContent: string): ThreadContext {
return {
start: {
@@ -11,7 +17,11 @@ function makeCtx(userContent: string): ThreadContext {
meta: { maxRounds: 10 },
timestamp: 1,
},
depth: 0,
steps: [],
threadId: "01TEST000000000000000000TR",
currentRole: { name: "planner", systemPrompt: "system instructions" },
cas: testCas,
};
}
@@ -19,17 +29,17 @@ describe("createLlmAdapter", () => {
const originalFetch = globalThis.fetch;
test("posts system + user (start.content) and returns assistant text", async () => {
globalThis.fetch = () =>
globalThis.fetch = (() =>
Promise.resolve(
new Response(JSON.stringify({ choices: [{ message: { content: "model reply" } }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
)) as unknown as typeof fetch;
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
const adapter = createLlmAdapter(provider);
const out = await adapter(makeCtx("trigger text"), "system instructions");
const out = await adapter(makeCtx("trigger text"));
globalThis.fetch = originalFetch;
@@ -37,28 +47,28 @@ describe("createLlmAdapter", () => {
});
test("throws on non-ok fetch response", async () => {
globalThis.fetch = () =>
globalThis.fetch = (() =>
Promise.resolve(
new Response("Internal Server Error", {
status: 500,
headers: { "Content-Type": "text/plain" },
}),
);
)) as unknown as typeof fetch;
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
const adapter = createLlmAdapter(provider);
await expect(adapter(makeCtx("hi"), "sys")).rejects.toThrow("llm:");
await expect(adapter(makeCtx("hi"))).rejects.toThrow("llm:");
globalThis.fetch = originalFetch;
});
test("throws on fetch network failure", async () => {
globalThis.fetch = () => Promise.reject(new Error("ECONNREFUSED"));
globalThis.fetch = (() => Promise.reject(new Error("ECONNREFUSED"))) as unknown as typeof fetch;
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
const adapter = createLlmAdapter(provider);
await expect(adapter(makeCtx("hi"), "sys")).rejects.toThrow();
await expect(adapter(makeCtx("hi"))).rejects.toThrow();
globalThis.fetch = originalFetch;
});
});
@@ -1,5 +1,5 @@
{
"name": "@uncaged/workflow-role-committer",
"name": "@uncaged/workflow-agent-llm",
"version": "0.1.0",
"type": "module",
"main": "src/index.ts",
@@ -9,8 +9,6 @@
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-util-role": "workspace:*",
"zod": "^4.0.0"
"@uncaged/workflow": "workspace:*"
}
}
@@ -1,6 +1,14 @@
import { type AgentFn, err, ok, type Result, type ThreadContext } from "@uncaged/workflow";
import {
type AgentContext,
type AgentFn,
err,
type LlmProvider,
ok,
type Result,
} from "@uncaged/workflow";
import type { LlmMessage, LlmProvider } from "@uncaged/workflow-util-role";
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
export type LlmChatError =
| { kind: "http_error"; status: number; body: string }
@@ -89,13 +97,13 @@ export async function chatCompletionText(options: {
return parseAssistantText(res.value);
}
/** Single-turn chat adapter: system comes from `createRole` prompt; user is the thread start frame. */
/** Single-turn chat adapter: system prompt comes from {@link AgentContext.currentRole}. */
export function createLlmAdapter(provider: LlmProvider): AgentFn {
return async (ctx: ThreadContext, systemPrompt: string) => {
return async (ctx: AgentContext) => {
const result = await chatCompletionText({
provider,
messages: [
{ role: "system", content: systemPrompt },
{ role: "system", content: ctx.currentRole.systemPrompt },
{ role: "user", content: ctx.start.content },
],
});
+6
View File
@@ -0,0 +1,6 @@
export {
chatCompletionText,
createLlmAdapter,
type LlmChatError,
type LlmMessage,
} from "./create-llm-adapter.js";
@@ -1,110 +0,0 @@
import { describe, expect, spyOn, test } from "bun:test";
import { execFile } from "node:child_process";
import { appendFile, mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import type { AgentFn, ThreadContext } from "@uncaged/workflow";
import { START } from "@uncaged/workflow";
import * as utilRole from "@uncaged/workflow-util-role";
import { createCommitterRole } from "../src/committer.js";
import { gitExec } from "../src/git-exec.js";
const execFileAsync = promisify(execFile);
async function git(repo: string, args: string[]): Promise<void> {
await gitExec(repo, args);
}
async function setupRepoWithRemote(): Promise<{ repo: string }> {
const base = await mkdtemp(join(tmpdir(), "wf-committer-"));
const bare = join(base, "origin.git");
const repo = join(base, "work");
await mkdir(repo, { recursive: true });
await mkdir(bare, { recursive: true });
await execFileAsync("git", ["init"], { cwd: repo, encoding: "utf8" });
await git(repo, ["config", "user.email", "t@t"]);
await git(repo, ["config", "user.name", "t"]);
await writeFile(join(repo, "README.md"), "# hi\n", "utf8");
await git(repo, ["add", "README.md"]);
await git(repo, ["commit", "-m", "init"]);
await execFileAsync("git", ["init", "--bare"], { cwd: bare, encoding: "utf8" });
await git(repo, ["remote", "add", "origin", bare]);
await git(repo, ["push", "-u", "origin", "HEAD"]);
return { repo };
}
function makeCtx(): ThreadContext {
return {
start: {
role: START,
content: "do thing",
meta: { maxRounds: 10 },
timestamp: Date.now(),
},
steps: [],
};
}
const provider = { baseUrl: "https://example.com/v1", apiKey: "k", model: "m" };
describe("createCommitterRole", () => {
test("returns committed false when working tree clean", async () => {
const { repo } = await setupRepoWithRemote();
const agent: AgentFn = async () => {
throw new Error("agent should not run");
};
const role = createCommitterRole(
agent,
{ provider, dryRun: null, dryRunMeta: { branch: "dry-run", message: "chore: dry run" } },
{ cwd: repo, remote: "origin", threadId: null },
);
const out = await role(makeCtx());
expect(out.meta.committed).toBe(false);
});
test("dry-run skips pipeline", async () => {
const agent: AgentFn = async () => {
throw new Error("agent should not run");
};
const role = createCommitterRole(agent, {
provider,
dryRun: true,
dryRunMeta: { branch: "dry-run", message: "chore: dry run" },
});
const out = await role(makeCtx());
expect(out.content).toBe("[dry-run] committer skipped");
expect(out.meta).toEqual({ committed: true });
});
test("commits and pushes when changes exist", async () => {
const { repo } = await setupRepoWithRemote();
await appendFile(join(repo, "README.md"), "\nmore\n", "utf8");
const spy = spyOn(utilRole, "extractMetaOrThrow").mockResolvedValue({
branch: "feat/test-commit",
message: "feat: add more",
});
const agent: AgentFn = async () => "plan text";
const role = createCommitterRole(
agent,
{ provider, dryRun: null, dryRunMeta: { branch: "dry-run", message: "chore: dry run" } },
{ cwd: repo, remote: "origin", threadId: null },
);
const out = await role(makeCtx());
expect(out.meta.committed).toBe(true);
expect(spy).toHaveBeenCalled();
const branches = await gitExec(repo, ["branch", "--list", "feat/test-commit"]);
expect(branches).toContain("feat/test-commit");
const remoteRefs = await gitExec(repo, ["ls-remote", "--heads", "origin", "feat/test-commit"]);
expect(remoteRefs.trim().length).toBeGreaterThan(0);
spy.mockRestore();
});
});
@@ -1,153 +0,0 @@
import type { AgentFn, Role, RoleResult, ThreadContext } from "@uncaged/workflow";
import {
decorateRole,
extractMetaOrThrow,
type LlmProvider,
onFail,
withDryRun,
} from "@uncaged/workflow-util-role";
import * as z from "zod/v4";
import { gitExec } from "./git-exec.js";
export const committerMetaSchema = z.object({
committed: z
.boolean()
.describe("true if branch created, changes committed, and pushed successfully"),
});
export type CommitterMeta = z.infer<typeof committerMetaSchema>;
const committerPlanSchema = z.object({
branch: z.string().describe("Feature branch name, e.g. feat/slug or fix/slug"),
message: z.string().describe("Single-line conventional commit subject"),
});
export type CommitterPlanMeta = z.infer<typeof committerPlanSchema>;
export type CommitterGitConfig = {
cwd: string;
remote: string;
/** When non-null, prompts mention `uncaged-workflow thread <id>` for extra context. */
threadId: string | null;
};
export const DEFAULT_COMMITTER_GIT_CONFIG: CommitterGitConfig = {
cwd: ".",
remote: "origin",
threadId: null,
};
function resolveExtractDryRun(extractDryRun: boolean | null): boolean {
return extractDryRun === true;
}
function summarizeThreadContext(ctx: ThreadContext): string {
const lines: string[] = [`Initial prompt:\n${ctx.start.content}`];
for (const step of ctx.steps) {
const snippet = step.content.length > 800 ? `${step.content.slice(0, 800)}` : step.content;
lines.push(`\n### ${step.role}\n${snippet}`);
}
return lines.join("\n");
}
function sanitizeBranch(branch: string): string {
const t = branch.trim();
if (
t === "" ||
t.includes("..") ||
t.includes(" ") ||
t.startsWith("-") ||
t.includes("\n") ||
t.includes("\t")
) {
throw new Error(`invalid branch name: ${branch}`);
}
return t;
}
function sanitizeCommitMessage(message: string): string {
const line = message.trim().split(/\r?\n/)[0] ?? "";
if (line === "") {
throw new Error("commit message is empty");
}
return line;
}
function committerPlanPrompt(ctx: ThreadContext, gitConfig: CommitterGitConfig): string {
const threadLine =
gitConfig.threadId !== null
? `Optional CLI context: run \`uncaged-workflow thread ${gitConfig.threadId}\` if available.\n`
: "";
return `You plan a git branch and a single-line conventional commit message for the following workflow thread.
${threadLine}
## Thread context
${summarizeThreadContext(ctx)}
## Your task
Infer a good branch name (\`feat/<slug>\` or \`fix/<slug>\`) and a conventional commit **subject** (one line, no body).
Reply with enough detail that a maintainer understands the change; structured extraction will read \`branch\` and \`message\` from your answer.`;
}
async function runCommitterPipeline(
ctx: ThreadContext,
agent: AgentFn,
extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: CommitterPlanMeta },
gitConfig: CommitterGitConfig,
): Promise<RoleResult<CommitterMeta>> {
const cwd = gitConfig.cwd;
const porcelain = await gitExec(cwd, ["status", "--porcelain"]);
if (porcelain.trim() === "") {
return {
content: "Working tree clean; nothing to commit.",
meta: { committed: false },
};
}
const prompt = committerPlanPrompt(ctx, gitConfig);
const raw = await agent(ctx, prompt);
const plan = await extractMetaOrThrow("committer-plan", raw, committerPlanSchema, {
provider: extract.provider,
dryRun: resolveExtractDryRun(extract.dryRun),
dryRunMeta: extract.dryRunMeta,
});
const branch = sanitizeBranch(plan.branch);
const message = sanitizeCommitMessage(plan.message);
await gitExec(cwd, ["checkout", "-b", branch]);
await gitExec(cwd, ["add", "-A"]);
await gitExec(cwd, ["commit", "-m", message]);
await gitExec(cwd, ["push", "-u", gitConfig.remote, branch]);
return {
content: raw,
meta: { committed: true },
};
}
/**
* Git committer role: LLM proposes branch + message; this package runs git via `child_process`.
* Decorators match nerve semantics: dry-run skips work with `committed: true`; failures yield `committed: false`.
*/
export function createCommitterRole(
adapter: AgentFn,
extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: CommitterPlanMeta },
gitConfig: CommitterGitConfig = DEFAULT_COMMITTER_GIT_CONFIG,
): Role<CommitterMeta> {
const inner: Role<CommitterMeta> = async (ctx) =>
runCommitterPipeline(ctx, adapter, extract, gitConfig);
return decorateRole(inner, [
withDryRun<CommitterMeta>({
label: "committer",
meta: { committed: true },
dryRun: resolveExtractDryRun(extract.dryRun),
}),
onFail<CommitterMeta>({ label: "committer", meta: { committed: false } }),
]);
}
@@ -1,26 +0,0 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
/** Runs `git` with args in `cwd`; throws if git exits non-zero. */
export async function gitExec(cwd: string, args: readonly string[]): Promise<string> {
try {
const r = await execFileAsync("git", [...args], {
cwd,
encoding: "utf8",
maxBuffer: 10 * 1024 * 1024,
});
return r.stdout;
} catch (e) {
const stderr =
typeof e === "object" &&
e !== null &&
"stderr" in e &&
typeof (e as { stderr: unknown }).stderr === "string"
? (e as { stderr: string }).stderr
: "";
const msg = e instanceof Error ? e.message : String(e);
throw new Error(`git ${args.join(" ")} failed: ${msg}${stderr ? ` (${stderr.trim()})` : ""}`);
}
}
@@ -1,9 +0,0 @@
export {
type CommitterGitConfig,
type CommitterMeta,
type CommitterPlanMeta,
committerMetaSchema,
createCommitterRole,
DEFAULT_COMMITTER_GIT_CONFIG,
} from "./committer.js";
export { gitExec } from "./git-exec.js";
File diff suppressed because one or more lines are too long
@@ -1,156 +0,0 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
import type { AgentFn, ThreadContext } from "@uncaged/workflow";
import { START } from "@uncaged/workflow";
import * as extractMetaModule from "@uncaged/workflow-util-role";
import * as z from "zod/v4";
import { createRole } from "../src/create-role.js";
const provider = {
baseUrl: "https://example.com/v1",
apiKey: "k",
model: "m",
};
function toolCallResponse(argsJson: string): Response {
return new Response(
JSON.stringify({
choices: [
{
message: {
tool_calls: [
{
function: {
name: "extract",
arguments: argsJson,
},
},
],
},
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
function makeCtx(): ThreadContext {
return {
start: {
role: START,
content: "",
meta: { maxRounds: 10 },
timestamp: Date.now(),
},
steps: [],
};
}
describe("createRole", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
mock.restore();
});
test("runs AgentFn then structured extract", async () => {
globalThis.fetch = () => Promise.resolve(toolCallResponse(JSON.stringify({ n: 3 })));
const schema = z.object({ n: z.number() });
const agent: AgentFn = async (_ctx, prompt) => prompt;
const role = createRole({
name: "test",
schema,
systemPrompt: "hello",
agent,
extract: { provider, dryRun: null, dryRunMeta: { n: 0 } },
});
const out = await role(makeCtx());
expect(out.content).toBe("hello");
expect(out.meta).toEqual({ n: 3 });
});
test("passes ThreadContext to AgentFn", async () => {
globalThis.fetch = () => Promise.resolve(toolCallResponse(JSON.stringify({ n: 0 })));
const seen: ThreadContext[] = [];
const agent: AgentFn = async (ctx, _prompt) => {
seen.push(ctx);
return "x";
};
const role = createRole({
name: "test",
schema: z.object({ n: z.number() }),
systemPrompt: "p",
agent,
extract: { provider, dryRun: null, dryRunMeta: { n: 0 } },
});
await role(makeCtx());
expect(seen).toHaveLength(1);
expect(seen[0].steps).toEqual([]);
});
test("resolves dynamic systemPrompt functions before AgentFn", async () => {
globalThis.fetch = () => Promise.resolve(toolCallResponse(JSON.stringify({ n: 99 })));
const schema = z.object({ n: z.number() });
const agent: AgentFn = async (_ctx, prompt) => prompt;
const role = createRole({
name: "test",
schema,
systemPrompt: async (ctx) => `rounds=${ctx.steps.length}`,
agent,
extract: { provider, dryRun: null, dryRunMeta: { n: 0 } },
});
const ctx = makeCtx();
const out = await role(ctx);
expect(out.content).toBe("rounds=0");
expect(out.meta).toEqual({ n: 99 });
});
test("extract dryRun null runs live extract path", async () => {
const spy = spyOn(extractMetaModule, "extractMetaOrThrow").mockResolvedValue({ n: 0 });
const agent: AgentFn = async () => "raw";
const role = createRole({
name: "r1",
schema: z.object({ n: z.number() }),
systemPrompt: "p",
agent,
extract: { provider, dryRun: null, dryRunMeta: { n: 0 } },
});
await role(makeCtx());
expect(spy).toHaveBeenCalledWith(
"r1",
"raw",
expect.anything(),
expect.objectContaining({ provider, dryRun: false, dryRunMeta: { n: 0 } }),
);
});
test("extract.dryRun true uses structured extract dry-run", async () => {
const spy = spyOn(extractMetaModule, "extractMetaOrThrow").mockResolvedValue({ n: 0 });
const agent: AgentFn = async () => "raw";
const role = createRole({
name: "r2",
schema: z.object({ n: z.number() }),
systemPrompt: "p",
agent,
extract: { provider, dryRun: true, dryRunMeta: { n: 0 } },
});
await role(makeCtx());
expect(spy).toHaveBeenCalledWith(
"r2",
"raw",
expect.anything(),
expect.objectContaining({ dryRun: true, dryRunMeta: { n: 0 } }),
);
});
});
-19
View File
@@ -1,19 +0,0 @@
{
"name": "@uncaged/workflow-role-llm",
"version": "0.1.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "echo 'TODO'",
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-util-role": "workspace:*",
"zod": "^4.0.0"
}
}
@@ -1,35 +0,0 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
import { extractMetaOrThrow, type LlmProvider } from "@uncaged/workflow-util-role";
import type * as z from "zod/v4";
export type CreateRoleArgs<M extends Record<string, unknown>> = {
name: string;
schema: z.ZodType<M>;
systemPrompt: string | ((ctx: ThreadContext) => Promise<string>);
agent: AgentFn;
extract: {
provider: LlmProvider;
/** When `true`, structured extract returns `dryRunMeta`. When `null`, live API extract. */
dryRun: boolean | null;
dryRunMeta: M;
};
};
function resolveExtractDryRun(extractDryRun: boolean | null): boolean {
return extractDryRun === true;
}
/** Builds a {@link Role} from an {@link AgentFn}, system prompt, Zod meta schema, and extract wiring. */
export function createRole<M extends Record<string, unknown>>(args: CreateRoleArgs<M>): Role<M> {
return async (ctx: ThreadContext) => {
const promptText =
typeof args.systemPrompt === "string" ? args.systemPrompt : await args.systemPrompt(ctx);
const raw = await args.agent(ctx, promptText);
const meta = await extractMetaOrThrow(args.name, raw, args.schema, {
provider: args.extract.provider,
dryRun: resolveExtractDryRun(args.extract.dryRun),
dryRunMeta: args.extract.dryRunMeta,
});
return { content: raw, meta };
};
}
-21
View File
@@ -1,21 +0,0 @@
export {
buildDescriptorFromRoles,
decorateRole,
extractMetaOrThrow,
type LlmError,
type LlmExtractArgs,
type LlmMessage,
type LlmProvider,
llmErrorToCause,
llmExtract,
llmExtractWithRetry,
type MetaExtractConfig,
type OnFailOptions,
onFail,
type RoleDecorator,
type RoleDescriptorInput,
type WithDryRunOptions,
withDryRun,
} from "@uncaged/workflow-util-role";
export { chatCompletionText, createLlmAdapter, type LlmChatError } from "./create-llm-adapter.js";
export { type CreateRoleArgs, createRole } from "./create-role.js";
-10
View File
@@ -1,10 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../workflow" }, { "path": "../workflow-util-role" }]
}
File diff suppressed because one or more lines are too long
@@ -1,92 +0,0 @@
import { afterEach, describe, expect, mock, test } from "bun:test";
import type { AgentFn, ThreadContext } from "@uncaged/workflow";
import { START } from "@uncaged/workflow";
import { createReviewerRole, DEFAULT_REVIEWER_CONFIG } from "../src/reviewer.js";
function toolCallResponse(argsJson: string): Response {
return new Response(
JSON.stringify({
choices: [
{
message: {
tool_calls: [
{
function: {
name: "extract",
arguments: argsJson,
},
},
],
},
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
function makeCtx(): ThreadContext {
return {
start: {
role: START,
content: "task",
meta: { maxRounds: 10 },
timestamp: Date.now(),
},
steps: [],
};
}
const provider = { baseUrl: "https://example.com/v1", apiKey: "k", model: "m" };
describe("createReviewerRole", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
mock.restore();
});
test("runs reviewer extract", async () => {
globalThis.fetch = () => Promise.resolve(toolCallResponse(JSON.stringify({ approved: true })));
const agent: AgentFn = async (_ctx, prompt) => {
expect(prompt).toContain("git diff");
expect(prompt).toContain(DEFAULT_REVIEWER_CONFIG.cwd);
return "review done";
};
const role = createReviewerRole(agent, {
provider,
dryRun: null,
dryRunMeta: { approved: true },
});
const out = await role(makeCtx());
expect(out.meta).toEqual({ approved: true });
});
test("includes uncaged-workflow thread hint when threadId set", async () => {
globalThis.fetch = () => Promise.resolve(toolCallResponse(JSON.stringify({ approved: false })));
let seen = "";
const agent: AgentFn = async (_ctx, prompt) => {
seen = prompt;
return "x";
};
const role = createReviewerRole(
agent,
{ provider, dryRun: null, dryRunMeta: { approved: false } },
{
cwd: "/proj",
conventionsPath: null,
extraChecks: [],
threadId: "01ABCDEF234567890ABCDEFGH",
},
);
await role(makeCtx());
expect(seen).toContain("uncaged-workflow thread 01ABCDEF234567890ABCDEFGH");
});
});
@@ -1,17 +0,0 @@
{
"name": "@uncaged/workflow-role-reviewer",
"version": "0.1.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "echo 'TODO'",
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-role-llm": "workspace:*",
"@uncaged/workflow-util-role": "workspace:*",
"zod": "^4.0.0"
}
}
@@ -1,7 +0,0 @@
export {
createReviewerRole,
DEFAULT_REVIEWER_CONFIG,
type ReviewerConfig,
type ReviewerMeta,
reviewerMetaSchema,
} from "./reviewer.js";
@@ -1,108 +0,0 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
import { createRole } from "@uncaged/workflow-role-llm";
import type { LlmProvider } from "@uncaged/workflow-util-role";
import * as z from "zod/v4";
export const reviewerMetaSchema = z.object({
approved: z.boolean().describe("true if the diff is clean and ready to merge"),
});
export type ReviewerMeta = z.infer<typeof reviewerMetaSchema>;
export type ReviewerConfig = {
cwd: string;
conventionsPath: string | null;
extraChecks: ReadonlyArray<string>;
/** When non-null, prompts reference `uncaged-workflow thread <id>`. */
threadId: string | null;
};
export const DEFAULT_REVIEWER_CONFIG: ReviewerConfig = {
cwd: ".",
conventionsPath: "CONVENTIONS.md",
extraChecks: [],
threadId: null,
};
function summarizeThreadContext(ctx: ThreadContext): string {
const lines: string[] = [`Initial prompt:\n${ctx.start.content}`];
for (const step of ctx.steps) {
const snippet = step.content.length > 600 ? `${step.content.slice(0, 600)}` : step.content;
lines.push(`\n### ${step.role}\n${snippet}`);
}
return lines.join("\n");
}
function reviewerPrompt(config: ReviewerConfig, ctx: ThreadContext): string {
const { cwd, conventionsPath, extraChecks, threadId } = config;
const conventionsBlock =
conventionsPath !== null ? `Read project conventions: \`cat ${cwd}/${conventionsPath}\`\n` : "";
const threadBlock =
threadId !== null
? `Read the workflow thread for context: \`uncaged-workflow thread ${threadId}\`\n`
: `## Thread context (no thread id)\n\n${summarizeThreadContext(ctx)}\n`;
const extraBlock =
extraChecks.length > 0
? `\n### Project-specific checks\n${extraChecks.map((c) => `- ${c}`).join("\n")}\n`
: "";
return `You are a **code reviewer**. You run after the coder and before the tester.
**IMPORTANT: The project is at \`${cwd}\`. Always \`cd ${cwd}\` first.**
${threadBlock}
${conventionsBlock}
## Your job — static analysis of the git diff
Run these commands and analyze the output:
1. **\`cd ${cwd} && git diff --stat\`** — see what files changed
2. **\`cd ${cwd} && git diff\`** — read the actual diff
3. **\`cd ${cwd} && git status --short\`** — check for untracked files
## Checklist
### Reject (approved: false) — tell coder exactly what to fix
- **Garbage files**: build artifacts, lockfiles, IDE config that should not be committed
- **Secrets/credentials**: API keys, tokens, passwords hardcoded in the diff
- **Unrelated changes**: files modified outside the scope of the task
${
conventionsPath !== null
? `- **Convention violations**: patterns that contradict ${conventionsPath}\n`
: ""
}${extraBlock}
### Approve (approved: true) — no comment needed
- Diff is clean, focused, follows project standards
End with:
\`\`\`json
{ "approved": true }
\`\`\`
or
\`\`\`json
{ "approved": false }
\`\`\``;
}
/**
* Code review role: agent inspects git diffs; structured extract yields `approved`.
*/
export function createReviewerRole(
adapter: AgentFn,
extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: ReviewerMeta },
config: ReviewerConfig = DEFAULT_REVIEWER_CONFIG,
): Role<ReviewerMeta> {
return createRole({
name: "reviewer",
schema: reviewerMetaSchema,
systemPrompt: async (ctx) => reviewerPrompt(config, ctx),
agent: adapter,
extract: {
provider: extract.provider,
dryRun: extract.dryRun,
dryRunMeta: extract.dryRunMeta,
},
});
}
@@ -1,14 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../workflow" },
{ "path": "../workflow-role-llm" },
{ "path": "../workflow-util-role" }
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,19 @@
import { describe, expect, test } from "bun:test";
import { committerMetaSchema, committerRole } from "../src/roles/committer.js";
describe("committerRole", () => {
test("committed sample validates against schema", () => {
const parsed = committerMetaSchema.safeParse({
status: "committed" as const,
branch: "feat/example",
commitSha: "abc1234",
});
expect(parsed.success).toBe(true);
});
test("exposes generic committer system prompt", () => {
expect(committerRole.systemPrompt).toContain("git committer");
expect(committerRole.systemPrompt).not.toContain("project is at");
});
});
@@ -0,0 +1,257 @@
import { describe, expect, test } from "bun:test";
import {
END,
type ModeratorContext,
type RoleStep,
START,
validateWorkflowDescriptor,
} from "@uncaged/workflow";
import { buildDevelopDescriptor } from "../src/descriptor.js";
import { developModerator } from "../src/index.js";
import type { CommitterMeta, PlannerMeta } from "../src/roles/index.js";
import type { DevelopMeta } from "../src/roles.js";
const DEFAULT_PHASES: PlannerMeta["phases"] = [
{
hash: "4KNMR2PX",
title: "Do the work",
},
];
function makeStart(maxRounds: number): ModeratorContext<DevelopMeta>["start"] {
return {
role: START,
content: "Implement the feature",
meta: { maxRounds },
timestamp: 0,
};
}
function makeCtx(
maxRounds: number,
steps: ModeratorContext<DevelopMeta>["steps"],
): ModeratorContext<DevelopMeta> {
return {
threadId: "01TEST000000000000000000TR",
depth: 0,
start: makeStart(maxRounds),
steps,
};
}
function plannerStep(phases: PlannerMeta["phases"] = DEFAULT_PHASES): RoleStep<DevelopMeta> {
return {
role: "planner",
contentHash: "STUBHASHPLANNER001",
meta: { phases },
refs: phases.map((p) => p.hash),
timestamp: 1,
};
}
function coderStep(completedPhase = "4KNMR2PX"): RoleStep<DevelopMeta> {
return {
role: "coder",
contentHash: "STUBHASHCODER00001",
meta: { completedPhase, filesChanged: ["a.ts"], summary: "implemented" },
refs: [completedPhase],
timestamp: 2,
};
}
function reviewerStep(approved: boolean): RoleStep<DevelopMeta> {
return {
role: "reviewer",
contentHash: "STUBHASHREVIEWER01",
meta: approved
? { status: "approved" as const }
: { status: "rejected" as const, issues: ["needs fix"] },
refs: [],
timestamp: 3,
};
}
function testerStep(passed: boolean): RoleStep<DevelopMeta> {
return {
role: "tester",
contentHash: "STUBHASHTESTER01",
meta: passed
? { status: "passed" as const, details: "all checks passed" }
: { status: "failed" as const, details: "lint failed" },
refs: [],
timestamp: 4,
};
}
function committerStep(meta: CommitterMeta): RoleStep<DevelopMeta> {
return {
role: "committer",
contentHash: "STUBHASHCOMMITTER1",
meta,
refs: [],
timestamp: 5,
};
}
describe("developModerator", () => {
test("routes initial → planner → coder → reviewer → tester → committer → END", () => {
expect(developModerator(makeCtx(20, []))).toBe("planner");
expect(developModerator(makeCtx(20, [plannerStep()]))).toBe("coder");
expect(developModerator(makeCtx(20, [plannerStep(), coderStep()]))).toBe("reviewer");
expect(developModerator(makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true)]))).toBe(
"tester",
);
expect(
developModerator(
makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true), testerStep(true)]),
),
).toBe("committer");
expect(
developModerator(
makeCtx(20, [
plannerStep(),
coderStep(),
reviewerStep(true),
testerStep(true),
committerStep({ status: "committed", branch: "feat/x", commitSha: "abc1234" }),
]),
),
).toBe(END);
});
test("reviewer rejects → coder retry when budget allows", () => {
const steps: ModeratorContext<DevelopMeta>["steps"] = [
plannerStep(),
coderStep(),
reviewerStep(false),
];
expect(developModerator(makeCtx(20, steps))).toBe("coder");
});
test("reviewer rejects → END when max rounds exhausted", () => {
const steps: ModeratorContext<DevelopMeta>["steps"] = [
plannerStep(),
coderStep(),
reviewerStep(false),
];
expect(developModerator(makeCtx(4, steps))).toBe(END);
});
test("tester failed → coder retry when budget allows", () => {
const steps: ModeratorContext<DevelopMeta>["steps"] = [
plannerStep(),
coderStep(),
reviewerStep(true),
testerStep(false),
];
expect(developModerator(makeCtx(20, steps))).toBe("coder");
});
test("tester failed → END when max rounds exhausted", () => {
const steps: ModeratorContext<DevelopMeta>["steps"] = [
plannerStep(),
coderStep(),
reviewerStep(true),
testerStep(false),
];
expect(developModerator(makeCtx(5, steps))).toBe(END);
});
test("multiple planner phases → coder until all complete, then reviewer", () => {
const phases: PlannerMeta["phases"] = [
{ hash: "AA000001", title: "first phase" },
{ hash: "AA000002", title: "second phase" },
];
expect(developModerator(makeCtx(20, [plannerStep(phases)]))).toBe("coder");
expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("AA000001")]))).toBe(
"coder",
);
expect(
developModerator(
makeCtx(20, [plannerStep(phases), coderStep("AA000001"), coderStep("AA000002")]),
),
).toBe("reviewer");
});
test("one-shot coder reports only last phase hash → reviewer (moderator treats as all phases done)", () => {
const phases: PlannerMeta["phases"] = [
{ hash: "BB000001", title: "setup branch" },
{ hash: "BB000002", title: "write tests" },
{ hash: "BB000003", title: "verify" },
{ hash: "BB000004", title: "polish" },
];
expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("BB000004")]))).toBe(
"reviewer",
);
});
test("unrecognised completedPhase hash → coder retry when budget allows", () => {
const phases: PlannerMeta["phases"] = [
{ hash: "CC000001", title: "first phase" },
{ hash: "CC000002", title: "second phase" },
];
expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("all-done")]))).toBe(
"coder",
);
});
test("incomplete phases → END when max rounds exhausted", () => {
const phases: PlannerMeta["phases"] = [
{ hash: "DD000001", title: "first phase" },
{ hash: "DD000002", title: "second phase" },
];
const steps: ModeratorContext<DevelopMeta>["steps"] = [
plannerStep(phases),
coderStep("DD000001"),
];
expect(developModerator(makeCtx(3, steps))).toBe(END);
});
test("committer → END for any committer meta status", () => {
const committed = committerStep({ status: "committed", branch: "f", commitSha: "x" });
const recoverable = committerStep({
status: "recoverable",
error: "merge conflict",
logRef: null,
});
const unrecoverable = committerStep({
status: "unrecoverable",
error: "repo missing",
logRef: "log1",
});
const base: ModeratorContext<DevelopMeta>["steps"] = [
plannerStep(),
coderStep(),
reviewerStep(true),
testerStep(true),
];
expect(developModerator(makeCtx(20, [...base, committed]))).toBe(END);
expect(developModerator(makeCtx(20, [...base, recoverable]))).toBe(END);
expect(developModerator(makeCtx(20, [...base, unrecoverable]))).toBe(END);
});
});
describe("buildDevelopDescriptor", () => {
test("lists all roles with schemas that validate", () => {
const descriptor = buildDevelopDescriptor();
const validated = validateWorkflowDescriptor(descriptor);
expect(validated.ok).toBe(true);
if (!validated.ok) {
throw new Error(validated.error);
}
expect(Object.keys(validated.value.roles).sort()).toEqual([
"coder",
"committer",
"planner",
"reviewer",
"tester",
]);
for (const key of ["planner", "coder", "reviewer", "tester", "committer"] as const) {
const role = validated.value.roles[key];
expect(role).toBeDefined();
expect(typeof role.schema).toBe("object");
expect(role.schema).not.toBeNull();
expect(Array.isArray(role.schema)).toBe(false);
}
});
});
@@ -0,0 +1,15 @@
import { describe, expect, test } from "bun:test";
import { reviewerMetaSchema, reviewerRole } from "../src/roles/reviewer.js";
describe("reviewerRole", () => {
test("approved sample validates against schema", () => {
const parsed = reviewerMetaSchema.safeParse({ status: "approved" as const });
expect(parsed.success).toBe(true);
});
test("system prompt is generic (no cwd)", () => {
expect(reviewerRole.systemPrompt).toContain("code reviewer");
expect(reviewerRole.systemPrompt).not.toContain("project is at");
});
});
@@ -1,12 +1,9 @@
{
"name": "@uncaged/workflow-util-role",
"name": "@uncaged/workflow-template-develop",
"version": "0.1.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "echo 'TODO'",
"test": "bun test"
@@ -0,0 +1,12 @@
import { buildDescriptor } from "@uncaged/workflow";
import { developModerator } from "./moderator.js";
import { DEVELOP_WORKFLOW_DESCRIPTION, developRoles } from "./roles.js";
export function buildDevelopDescriptor() {
return buildDescriptor({
description: DEVELOP_WORKFLOW_DESCRIPTION,
roles: developRoles,
moderator: developModerator,
});
}
@@ -0,0 +1,52 @@
import {
type AgentBinding,
createWorkflow,
type ExtractFn,
type LlmProvider,
type WorkflowDefinition,
type WorkflowFn,
} from "@uncaged/workflow";
import { developModerator } from "./moderator.js";
import { DEVELOP_WORKFLOW_DESCRIPTION, type DevelopMeta, developRoles } from "./roles.js";
export { buildDevelopDescriptor } from "./descriptor.js";
export { developModerator } from "./moderator.js";
export {
type CoderMeta,
type CommitterMeta,
coderMetaSchema,
coderRole,
committerMetaSchema,
committerRole,
type PlannerMeta,
phaseSchema,
plannerMetaSchema,
plannerRole,
type ReviewerMeta,
reviewerMetaSchema,
reviewerRole,
type TesterMeta,
testerMetaSchema,
testerRole,
} from "./roles/index.js";
export {
DEVELOP_WORKFLOW_DESCRIPTION,
type DevelopMeta,
type DevelopRoles,
developRoles,
} from "./roles.js";
export const developWorkflowDefinition: WorkflowDefinition<DevelopMeta> = {
description: DEVELOP_WORKFLOW_DESCRIPTION,
roles: developRoles,
moderator: developModerator,
};
export function createDevelopRun(
binding: AgentBinding,
extract: ExtractFn,
llmProvider: LlmProvider | null,
): WorkflowFn {
return createWorkflow(developWorkflowDefinition, binding, extract, llmProvider);
}
@@ -0,0 +1,89 @@
import type { Moderator, ModeratorContext } from "@uncaged/workflow";
import { END } from "@uncaged/workflow";
import type { DevelopMeta } from "./roles.js";
function coderFinishedAllPlannedPhases(
phases: ReadonlyArray<{ hash: string }>,
coderCompletedPhases: ReadonlyArray<string>,
): boolean {
if (phases.length === 0) {
return true;
}
const plannedHashes = new Set(phases.map((p) => p.hash));
const lastHash = phases[phases.length - 1].hash;
const explicit = new Set(coderCompletedPhases.filter((h) => plannedHashes.has(h)));
if (phases.every((p) => explicit.has(p.hash))) {
return true;
}
if (coderCompletedPhases.some((h) => h === lastHash)) {
return true;
}
return false;
}
function nextAfterCoder(
ctx: ModeratorContext<DevelopMeta>,
maxRounds: number,
): (keyof DevelopMeta & string) | typeof END {
const plannerStep = ctx.steps.find((s) => s.role === "planner");
if (plannerStep === undefined) {
return "reviewer";
}
const phases = plannerStep.meta.phases;
const coderCompletedPhases = ctx.steps
.filter((s) => s.role === "coder")
.map((s) => s.meta.completedPhase);
const allDone = coderFinishedAllPlannedPhases(phases, coderCompletedPhases);
if (allDone) {
return "reviewer";
}
if (ctx.steps.length < maxRounds - 1) {
return "coder";
}
return END;
}
export const developModerator: Moderator<DevelopMeta> = (ctx) => {
const maxRounds = ctx.start.meta.maxRounds;
if (ctx.steps.length === 0) {
return "planner";
}
const last = ctx.steps[ctx.steps.length - 1];
if (last.role === "planner") {
return "coder";
}
if (last.role === "coder") {
return nextAfterCoder(ctx, maxRounds);
}
if (last.role === "reviewer") {
if (last.meta.status === "approved") {
return "tester";
}
if (ctx.steps.length < maxRounds - 1) {
return "coder";
}
return END;
}
if (last.role === "tester") {
if (last.meta.status === "passed") {
return "committer";
}
if (ctx.steps.length < maxRounds - 1) {
return "coder";
}
return END;
}
if (last.role === "committer") {
return END;
}
return END;
};
@@ -0,0 +1,29 @@
import type { RoleDefinition } from "@uncaged/workflow";
import { type CoderMeta, coderRole } from "./roles/coder.js";
import { type CommitterMeta, committerRole } from "./roles/committer.js";
import { type PlannerMeta, plannerRole } from "./roles/planner.js";
import { type ReviewerMeta, reviewerRole } from "./roles/reviewer.js";
import { type TesterMeta, testerRole } from "./roles/tester.js";
export const DEVELOP_WORKFLOW_DESCRIPTION =
"Plan phases, implement incrementally, review, verify with tests/build/lint, and commit (planner → coder [repeat per phase] → reviewer → tester → committer).";
export type DevelopMeta = {
planner: PlannerMeta;
coder: CoderMeta;
reviewer: ReviewerMeta;
tester: TesterMeta;
committer: CommitterMeta;
};
export type DevelopRoles = {
[K in keyof DevelopMeta]: RoleDefinition<DevelopMeta[K]>;
};
export const developRoles: DevelopRoles = {
planner: plannerRole,
coder: coderRole,
reviewer: reviewerRole,
tester: testerRole,
committer: committerRole,
};
@@ -0,0 +1,35 @@
import type { RoleDefinition } from "@uncaged/workflow";
import * as z from "zod/v4";
export const coderMetaSchema = z.object({
completedPhase: z.string(),
filesChanged: z.array(z.string()),
summary: z.string(),
});
export type CoderMeta = z.infer<typeof coderMetaSchema>;
const CODER_SYSTEM = `You are a **coder**. Read the thread for the plan and work on the NEXT incomplete phase only.
Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and meta output guide.
## Reading phase details
Each planner phase has a content-hash and title. Read full details with \`uncaged-workflow cas get <THREAD_ID> <HASH>\`.
The thread ID (26-char Crockford Base32) appears in the first message. If unsure, run \`uncaged-workflow thread list\`.
## Completing a phase
Report which phase you completed using the phase **hash** (not the title). If you legitimately finish every remaining phase in this single turn, set completedPhase to the **last** phase hash in the plan (the workflow treats that as full completion). List the files you changed and summarize what you did.`;
export const coderRole: RoleDefinition<CoderMeta> = {
description:
"Implements the next incomplete planner phase and reports structured completion metadata.",
systemPrompt: CODER_SYSTEM,
extractPrompt:
"Extract completedPhase: the planner phase hash finished this round (exact hash string from the plan). If multiple phases were finished in one round, use the last finished phase hash. Extract filesChanged and a summary of the work.",
schema: coderMetaSchema,
extractRefs: (meta) => [meta.completedPhase],
extractMode: "single",
};
@@ -0,0 +1,36 @@
import type { RoleDefinition } from "@uncaged/workflow";
import * as z from "zod/v4";
export const committerMetaSchema = z.discriminatedUnion("status", [
z.object({
status: z.literal("committed"),
branch: z.string(),
commitSha: z.string(),
}),
z.object({
status: z.literal("recoverable"),
error: z.string(),
logRef: z.string().nullable(),
}),
z.object({
status: z.literal("unrecoverable"),
error: z.string(),
logRef: z.string().nullable(),
}),
]);
export type CommitterMeta = z.infer<typeof committerMetaSchema>;
const COMMITTER_SYSTEM = `You are the git committer. Create a branch and commit the changes.
Report the branch name and commit SHA. On failure, classify as recoverable or unrecoverable.
Do not attempt to fix failures yourself.`;
export const committerRole: RoleDefinition<CommitterMeta> = {
description: "Creates a branch and commits changes.",
systemPrompt: COMMITTER_SYSTEM,
extractPrompt:
"Extract the commit result: committed (with branch and SHA), recoverable failure, or unrecoverable failure. Include error details and log references if applicable.",
schema: committerMetaSchema,
extractRefs: null,
extractMode: "single",
};
@@ -0,0 +1,10 @@
export { type CoderMeta, coderMetaSchema, coderRole } from "./coder.js";
export { type CommitterMeta, committerMetaSchema, committerRole } from "./committer.js";
export {
type PlannerMeta,
phaseSchema,
plannerMetaSchema,
plannerRole,
} from "./planner.js";
export { type ReviewerMeta, reviewerMetaSchema, reviewerRole } from "./reviewer.js";
export { type TesterMeta, testerMetaSchema, testerRole } from "./tester.js";
@@ -0,0 +1,52 @@
import type { RoleDefinition } from "@uncaged/workflow";
import * as z from "zod/v4";
export const phaseSchema = z.object({
hash: z.string(),
title: z.string(),
});
export const plannerMetaSchema = z.object({
phases: z.array(phaseSchema),
});
export type PlannerMeta = z.infer<typeof plannerMetaSchema>;
const PLANNER_SYSTEM = `You are a **planner** for a software task. Break the work into **sequential phases** the coder will execute one at a time.
Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and meta output guide.
## Storing phase details — MANDATORY
For each phase, store its full detail text in CAS via \`uncaged-workflow cas put <THREAD_ID> '<content>'\`. The command prints a content-hash — use that as the phase identifier.
The thread ID (26-char Crockford Base32) appears in the first message. If unsure, run \`uncaged-workflow thread list\`.
**Do NOT store phase details in any other way** — the CLI is the only supported storage mechanism.
## Phase granularity
Match the number of phases to task complexity:
- Trivial (add a config option, fix a typo, rename): 1 phase
- Small (a new feature touching 2-3 files): 1-2 phases
- Medium (cross-module refactor): 2-3 phases
- Large (new subsystem, architectural change): 3-5 phases
Fewer phases is always better. Each phase must justify its existence — if two phases would be tested together anyway, merge them.
## Output format
After storing all phases via the CLI, output compact JSON only:
{ "phases": [{ "hash": "<hash-from-cas-put>", "title": "<one-line-summary>" }] }
Order phases so earlier steps unblock later ones. Cover root cause, edge cases, and verification across the phases.`;
export const plannerRole: RoleDefinition<PlannerMeta> = {
description: "Breaks the task into sequential phases for the coder.",
systemPrompt: PLANNER_SYSTEM,
extractPrompt:
"Extract the implementation phases from the agent's output. Each phase has a hash (the CAS content-hash returned by the cas put command) and a title (one-line summary).",
schema: plannerMetaSchema,
extractRefs: (meta) => meta.phases.map((p) => p.hash),
extractMode: "single",
};
@@ -0,0 +1,26 @@
import type { RoleDefinition } from "@uncaged/workflow";
import * as z from "zod/v4";
export const reviewerMetaSchema = z.discriminatedUnion("status", [
z.object({
status: z.literal("approved"),
}),
z.object({
status: z.literal("rejected"),
issues: z.array(z.string()).describe("blocking issues that must be fixed"),
}),
]);
export type ReviewerMeta = z.infer<typeof reviewerMetaSchema>;
const REVIEWER_SYSTEM = `You are a code reviewer. Review the current git diff. Give a clear approve or reject verdict.
Only reject for blocking issues. End with your verdict.`;
export const reviewerRole: RoleDefinition<ReviewerMeta> = {
description: "Runs git diff checks and sets approved when the change is ready.",
systemPrompt: REVIEWER_SYSTEM,
extractPrompt:
"Extract the review verdict: approved or rejected. If rejected, list the blocking issues.",
schema: reviewerMetaSchema,
extractRefs: null,
extractMode: "single",
};
@@ -0,0 +1,27 @@
import type { RoleDefinition } from "@uncaged/workflow";
import * as z from "zod/v4";
export const testerMetaSchema = z.discriminatedUnion("status", [
z.object({
status: z.literal("passed"),
details: z.string(),
}),
z.object({
status: z.literal("failed"),
details: z.string(),
}),
]);
export type TesterMeta = z.infer<typeof testerMetaSchema>;
const TESTER_SYSTEM = `You are a tester. Run the project's test suite, build, and lint commands. Check what commands are available from the preparer's output in the thread. Report pass/fail with details of what failed.`;
export const testerRole: RoleDefinition<TesterMeta> = {
description: "Runs test, build, and lint commands and reports pass or fail with details.",
systemPrompt: TESTER_SYSTEM,
extractPrompt:
"Extract the verification result: passed with summary details, or failed with details of what broke.",
schema: testerMetaSchema,
extractRefs: null,
extractMode: "single",
};
@@ -6,5 +6,5 @@
"composite": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../workflow" }, { "path": "../workflow-util-role" }]
"references": [{ "path": "../workflow" }]
}
@@ -1,17 +1,101 @@
import { describe, expect, test } from "bun:test";
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
createCasStore,
createExtract,
END,
type ModeratorContext,
type RoleStep,
START,
type ThreadContext,
validateWorkflowDescriptor,
} from "@uncaged/workflow";
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
import { solveIssueModerator } from "../src/moderator.js";
import { createSolveIssueRoles, type SolveIssueMeta } from "../src/roles.js";
import type { DeveloperMeta } from "../src/developer.js";
import { createSolveIssueRun, solveIssueModerator } from "../src/index.js";
import type { PreparerMeta, SubmitterMeta } from "../src/roles/index.js";
import type { SolveIssueMeta } from "../src/roles.js";
function makeStart(maxRounds: number): ThreadContext<SolveIssueMeta>["start"] {
function jsonResponse(payload: Record<string, unknown>): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function readToolListFromBody(init: RequestInit | undefined): readonly Record<string, unknown>[] {
if (init === undefined || init.body === undefined || init.body === null) {
return [];
}
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
const tools = body.tools;
if (!Array.isArray(tools)) {
return [];
}
return tools.filter((t): t is Record<string, unknown> => t !== null && typeof t === "object");
}
function singleToolName(tools: readonly Record<string, unknown>[]): string {
if (tools.length === 0) {
return "extract";
}
const fn = tools[0].function as Record<string, unknown> | undefined;
return typeof fn?.name === "string" ? fn.name : "extract";
}
function buildSingleModeResponse(args: Record<string, unknown>, toolName: string): Response {
return jsonResponse({
choices: [
{
message: {
tool_calls: [
{
type: "function",
function: { name: toolName, arguments: JSON.stringify(args) },
},
],
},
},
],
});
}
function buildReactModeResponse(args: Record<string, unknown>): Response {
// reactExtract accepts a plain-JSON assistant message and validates it
// directly against the schema, so we skip the cas_get / extract tool dance.
return jsonResponse({
choices: [{ message: { content: JSON.stringify(args) } }],
});
}
function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unknown>>): () => void {
const origFetch = globalThis.fetch;
let i = 0;
const mockFetch = async (
_input: Parameters<typeof fetch>[0],
init?: RequestInit,
): Promise<Response> => {
const args = sequence[i] ?? sequence[sequence.length - 1];
if (args === undefined) {
throw new Error("installMockChatCompletions: empty sequence");
}
i += 1;
const tools = readToolListFromBody(init);
if (tools.length > 1) {
return buildReactModeResponse(args);
}
return buildSingleModeResponse(args, singleToolName(tools));
};
globalThis.fetch = Object.assign(mockFetch, {
preconnect: origFetch.preconnect.bind(origFetch),
}) as typeof fetch;
return () => {
globalThis.fetch = origFetch;
};
}
function makeStart(maxRounds: number): ModeratorContext<SolveIssueMeta>["start"] {
return {
role: START,
content: "Fix the flaky login test",
@@ -22,107 +106,286 @@ function makeStart(maxRounds: number): ThreadContext<SolveIssueMeta>["start"] {
function makeCtx(
maxRounds: number,
steps: ThreadContext<SolveIssueMeta>["steps"],
): ThreadContext<SolveIssueMeta> {
steps: ModeratorContext<SolveIssueMeta>["steps"],
): ModeratorContext<SolveIssueMeta> {
return {
threadId: "01TEST000000000000000000TR",
depth: 0,
start: makeStart(maxRounds),
steps,
};
}
function plannerStep(): RoleStep<SolveIssueMeta> {
function preparerStep(): RoleStep<SolveIssueMeta> {
return {
role: "planner",
content: "plan",
meta: { plan: "do work", files: ["a.ts"], approach: "minimal fix" },
role: "preparer",
contentHash: "STUBHASHPREPARER01",
meta: {
repoPath: "/home/user/repos/test",
defaultBranch: "main",
conventions: null,
toolchain: {
packageManager: "bun",
testCommand: "bun test",
lintCommand: null,
buildCommand: "bun run build",
},
},
refs: [],
timestamp: 0,
};
}
function developerStep(): RoleStep<SolveIssueMeta> {
return {
role: "developer",
contentHash: "STUBHASHDEVELOPER1",
meta: {
branch: "feat/issue-1",
commitSha: "abc1234",
filesChanged: ["src/login.ts"],
summary: "Fixed flaky login test by stabilising async setup.",
},
refs: [],
timestamp: 1,
};
}
function coderStep(): RoleStep<SolveIssueMeta> {
function submitterStep(meta: SubmitterMeta): RoleStep<SolveIssueMeta> {
return {
role: "coder",
content: "code",
meta: { filesChanged: ["a.ts"], summary: "fixed" },
role: "submitter",
contentHash: "STUBHASHSUBMITTER1",
meta,
refs: [],
timestamp: 2,
};
}
function reviewerStep(approved: boolean): RoleStep<SolveIssueMeta> {
return {
role: "reviewer",
content: "rev",
meta: { approved },
timestamp: 3,
};
}
const stubExtract = createExtract({
baseUrl: "http://127.0.0.1:9",
apiKey: "",
model: "test",
});
function committerStep(): RoleStep<SolveIssueMeta> {
return {
role: "committer",
content: "commit",
meta: { committed: true },
timestamp: 4,
};
}
const stubLlmProvider = {
baseUrl: "http://127.0.0.1:9",
apiKey: "",
model: "test",
};
describe("solveIssueModerator", () => {
test("routes planner → coder → reviewer → committer → END", () => {
expect(solveIssueModerator(makeCtx(20, []))).toBe("planner");
expect(solveIssueModerator(makeCtx(20, [plannerStep()]))).toBe("coder");
expect(solveIssueModerator(makeCtx(20, [plannerStep(), coderStep()]))).toBe("reviewer");
expect(solveIssueModerator(makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true)]))).toBe(
"committer",
);
test("routes initial → preparer → developer → submitter → END", () => {
expect(solveIssueModerator(makeCtx(20, []))).toBe("preparer");
expect(solveIssueModerator(makeCtx(20, [preparerStep()]))).toBe("developer");
expect(solveIssueModerator(makeCtx(20, [preparerStep(), developerStep()]))).toBe("submitter");
expect(
solveIssueModerator(
makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true), committerStep()]),
makeCtx(20, [
preparerStep(),
developerStep(),
submitterStep({
status: "submitted",
prUrl: "https://github.com/example/repo/pull/1",
}),
]),
),
).toBe(END);
});
test("reviewer rejects → coder retry when budget allows", () => {
const steps: ThreadContext<SolveIssueMeta>["steps"] = [
plannerStep(),
coderStep(),
reviewerStep(false),
];
expect(solveIssueModerator(makeCtx(20, steps))).toBe("coder");
test("submitter failed → END", () => {
expect(
solveIssueModerator(
makeCtx(20, [
preparerStep(),
developerStep(),
submitterStep({ status: "failed", error: "gh not authenticated" }),
]),
),
).toBe(END);
});
test("reviewer rejects → END when max rounds exhausted", () => {
const steps: ThreadContext<SolveIssueMeta>["steps"] = [
plannerStep(),
coderStep(),
reviewerStep(false),
];
expect(solveIssueModerator(makeCtx(4, steps))).toBe(END);
test("returns END for any unexpected last step (defensive)", () => {
// A submitter step with a pseudo-unknown future status would still be
// routed to END, since the moderator is a closed switch over known roles.
expect(
solveIssueModerator(
makeCtx(20, [
preparerStep(),
developerStep(),
submitterStep({ status: "submitted", prUrl: "https://example.com/pr/1" }),
]),
),
).toBe(END);
});
});
describe("createSolveIssueRoles", () => {
test("returns all four role callables", async () => {
const agent = async () => '{"plan":"x","files":[],"approach":"y"}';
const roles = createSolveIssueRoles({
agent,
workdir: "/tmp/repo",
extract: null,
});
describe("createSolveIssueRun", () => {
let restoreFetch: (() => void) | null = null;
let casDir: string | undefined;
expect(typeof roles.planner).toBe("function");
expect(typeof roles.coder).toBe("function");
expect(typeof roles.reviewer).toBe("function");
expect(typeof roles.committer).toBe("function");
afterEach(async () => {
restoreFetch?.();
restoreFetch = null;
if (casDir !== undefined) {
await rm(casDir, { recursive: true, force: true }).catch(() => {});
casDir = undefined;
}
});
const ctx = makeCtx(10, []);
const plannerOut = await roles.planner(ctx);
expect(plannerOut.meta.plan).toBe("");
expect(Array.isArray(plannerOut.meta.files)).toBe(true);
test("structured extraction yields preparer meta from mocked chat completions", async () => {
const EXPECT_PREPARER_META: PreparerMeta = {
repoPath: "/home/user/repos/test",
defaultBranch: "main",
conventions: null,
toolchain: {
packageManager: "bun",
testCommand: "bun test",
lintCommand: null,
buildCommand: "bun run build",
},
};
restoreFetch = installMockChatCompletions([EXPECT_PREPARER_META]);
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
const cas = createCasStore(casDir);
// Override developer so the test does not spin up a child workflow.
const run = createSolveIssueRun(
{
agent: async () => "",
overrides: { developer: async () => "stub-root-hash" },
},
stubExtract,
stubLlmProvider,
);
const gen = run(
{ prompt: "task", steps: [] },
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
);
const first = await gen.next();
expect(first.done).toBe(false);
if (first.done) {
throw new Error("expected yield");
}
expect(first.value.role).toBe("preparer");
expect(first.value.meta).toEqual(EXPECT_PREPARER_META);
});
test("per-role agent overrides default", async () => {
const PREPARER_META: PreparerMeta = {
repoPath: "/tmp/r",
defaultBranch: "main",
conventions: null,
toolchain: { packageManager: null, testCommand: null, lintCommand: null, buildCommand: null },
};
const DEVELOPER_META: DeveloperMeta = {
branch: "feat/x",
commitSha: "abc1234",
filesChanged: ["a.ts"],
summary: "did the work",
};
const SUBMITTER_META: SubmitterMeta = {
status: "submitted",
prUrl: "https://github.com/example/repo/pull/2",
};
restoreFetch = installMockChatCompletions([PREPARER_META, DEVELOPER_META, SUBMITTER_META]);
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
const cas = createCasStore(casDir);
const calls: string[] = [];
const run = createSolveIssueRun(
{
agent: async () => {
calls.push("default");
return "";
},
overrides: {
preparer: async () => {
calls.push("preparer");
return "";
},
developer: async () => {
calls.push("developer");
return "stub-root-hash";
},
submitter: async () => {
calls.push("submitter");
return "";
},
},
},
stubExtract,
stubLlmProvider,
);
const gen = run(
{ prompt: "task", steps: [] },
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
);
await gen.next();
expect(calls).toEqual(["preparer"]);
calls.length = 0;
await gen.next();
expect(calls).toEqual(["developer"]);
calls.length = 0;
await gen.next();
expect(calls).toEqual(["submitter"]);
});
test("developer defaults to workflowAsAgent override (caller override still wins)", async () => {
const PREPARER_META: PreparerMeta = {
repoPath: "/tmp/r",
defaultBranch: "main",
conventions: null,
toolchain: { packageManager: null, testCommand: null, lintCommand: null, buildCommand: null },
};
const DEVELOPER_META: DeveloperMeta = {
branch: "feat/y",
commitSha: "def5678",
filesChanged: ["b.ts"],
summary: "more work",
};
restoreFetch = installMockChatCompletions([PREPARER_META, DEVELOPER_META]);
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
const cas = createCasStore(casDir);
let developerInvocations = 0;
const run = createSolveIssueRun(
{
agent: async () => "",
overrides: {
developer: async () => {
developerInvocations += 1;
return "stub-root-hash";
},
},
},
stubExtract,
stubLlmProvider,
);
const gen = run(
{ prompt: "task", steps: [] },
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
);
// preparer
await gen.next();
// developer (caller override should be invoked, NOT workflowAsAgent default)
const devYield = await gen.next();
expect(devYield.done).toBe(false);
if (devYield.done) {
throw new Error("expected yield");
}
expect(devYield.value.role).toBe("developer");
expect(devYield.value.meta).toEqual(DEVELOPER_META);
expect(developerInvocations).toBe(1);
});
});
describe("buildSolveIssueDescriptor", () => {
test("lists all roles with schemas that validate", () => {
test("lists preparer, developer, submitter with schemas that validate", () => {
const descriptor = buildSolveIssueDescriptor();
const validated = validateWorkflowDescriptor(descriptor);
expect(validated.ok).toBe(true);
@@ -130,12 +393,11 @@ describe("buildSolveIssueDescriptor", () => {
throw new Error(validated.error);
}
expect(Object.keys(validated.value.roles).sort()).toEqual([
"coder",
"committer",
"planner",
"reviewer",
"developer",
"preparer",
"submitter",
]);
for (const key of ["planner", "coder", "reviewer", "committer"] as const) {
for (const key of ["preparer", "developer", "submitter"] as const) {
const role = validated.value.roles[key];
expect(role).toBeDefined();
expect(typeof role.schema).toBe("object");
@@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test";
import { submitterMetaSchema, submitterRole } from "../src/roles/submitter.js";
describe("submitterRole", () => {
test("submitted sample validates against schema", () => {
const parsed = submitterMetaSchema.safeParse({
status: "submitted" as const,
prUrl: "https://github.com/example/repo/pull/42",
});
expect(parsed.success).toBe(true);
});
test("failed sample validates against schema", () => {
const parsed = submitterMetaSchema.safeParse({
status: "failed" as const,
error: "gh not authenticated",
});
expect(parsed.success).toBe(true);
});
test("rejects unknown status discriminant", () => {
const parsed = submitterMetaSchema.safeParse({
status: "queued",
prUrl: "https://example.com",
});
expect(parsed.success).toBe(false);
});
test("exposes submitter system prompt", () => {
expect(submitterRole.systemPrompt).toContain("submitter");
expect(submitterRole.systemPrompt).toContain("pull request");
});
test("uses single extract mode without refs", () => {
expect(submitterRole.extractMode).toBe("single");
expect(submitterRole.extractRefs).toBeNull();
});
});
@@ -10,11 +10,6 @@
},
"dependencies": {
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-agent-cursor": "workspace:*",
"@uncaged/workflow-role-committer": "workspace:*",
"@uncaged/workflow-role-llm": "workspace:*",
"@uncaged/workflow-role-reviewer": "workspace:*",
"@uncaged/workflow-util-role": "workspace:*",
"zod": "^4.0.0"
}
}
@@ -1,34 +1,12 @@
import { committerMetaSchema } from "@uncaged/workflow-role-committer";
import { reviewerMetaSchema } from "@uncaged/workflow-role-reviewer";
import { buildDescriptorFromRoles } from "@uncaged/workflow-util-role";
import { buildDescriptor } from "@uncaged/workflow";
import { coderMetaSchema, plannerMetaSchema } from "./roles.js";
import { solveIssueModerator } from "./moderator.js";
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, solveIssueRoles } from "./roles.js";
export function buildSolveIssueDescriptor() {
return buildDescriptorFromRoles({
description:
"Plan, implement, review, and commit changes to resolve an issue end-to-end (planner → coder → reviewer → committer).",
roles: {
planner: {
name: "planner",
schema: plannerMetaSchema,
description: "Analyzes the issue and proposes plan, files, and approach.",
},
coder: {
name: "coder",
schema: coderMetaSchema,
description: "Implements the planner output and summarizes touched files.",
},
reviewer: {
name: "reviewer",
schema: reviewerMetaSchema,
description: "Runs git diff checks and sets approved when the change is ready.",
},
committer: {
name: "committer",
schema: committerMetaSchema,
description: "Creates branch, commits, and pushes when review passes.",
},
},
return buildDescriptor({
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
roles: solveIssueRoles,
moderator: solveIssueModerator,
});
}
@@ -0,0 +1,37 @@
import type { RoleDefinition } from "@uncaged/workflow";
import * as z from "zod/v4";
export const developerMetaSchema = z.object({
branch: z.string(),
commitSha: z.string(),
filesChanged: z.array(z.string()),
summary: z.string(),
});
export type DeveloperMeta = z.infer<typeof developerMetaSchema>;
const DEVELOPER_SYSTEM = `You are the **developer**. You delegate the implementation work to the \`develop\` workflow.
The actual implementation (planning → coding → reviewing → testing → committing) is handled by a child workflow that runs in your place. Your output is the Merkle DAG root hash of that child thread.
Pass through the task and let the child workflow do the work.`;
const DEVELOPER_EXTRACT_PROMPT = `The agent output is the root CAS hash of a child workflow thread. Use the cas_get tool to traverse the Merkle DAG and extract the developer summary.
Procedure:
1. cas_get(<rootHash>) — the root node lists all child step hashes (planner, coder, reviewer, tester, committer).
2. Find the committer step. cas_get its hash to read the committer's meta — extract branch and commitSha from there.
3. Find every coder step. cas_get each to read the coder's filesChanged. Union all filesChanged across coder steps.
4. Compose a short human-readable summary describing what the develop child workflow accomplished (drawn from the coder summaries, or a synthesis of them).
Return: { branch, commitSha, filesChanged, summary }.`;
export const developerRole: RoleDefinition<DeveloperMeta> = {
description:
"Delegates the actual implementation to the develop workflow (workflow-as-agent). Produces a summary by traversing the child thread's Merkle DAG.",
systemPrompt: DEVELOPER_SYSTEM,
extractPrompt: DEVELOPER_EXTRACT_PROMPT,
schema: developerMetaSchema,
extractRefs: () => [],
extractMode: "react",
};
@@ -1,29 +1,63 @@
import { createRoleModerator, type WorkflowFn } from "@uncaged/workflow";
import {
type AgentBinding,
createWorkflow,
type ExtractFn,
type LlmProvider,
type WorkflowDefinition,
type WorkflowFn,
workflowAsAgent,
} from "@uncaged/workflow";
import { solveIssueModerator } from "./moderator.js";
import { createSolveIssueRoles, type SolveIssueMeta, type SolveIssueRolesConfig } from "./roles.js";
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, solveIssueRoles } from "./roles.js";
export { type CursorAgentConfig, createCursorAgent } from "@uncaged/workflow-agent-cursor";
export { buildSolveIssueDescriptor } from "./descriptor.js";
export {
type DeveloperMeta,
developerMetaSchema,
developerRole,
} from "./developer.js";
export { solveIssueModerator } from "./moderator.js";
export {
type CoderMeta,
coderMetaSchema,
createSolveIssueRoles,
type PlannerMeta,
plannerMetaSchema,
type PreparerMeta,
preparerMetaSchema,
preparerRole,
type SubmitterMeta,
submitterMetaSchema,
submitterRole,
} from "./roles/index.js";
export {
SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
type SolveIssueMeta,
type SolveIssueRoles,
type SolveIssueRolesConfig,
solveIssueRoles,
} from "./roles.js";
export const solveIssueWorkflowDefinition: WorkflowDefinition<SolveIssueMeta> = {
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
roles: solveIssueRoles,
moderator: solveIssueModerator,
};
/**
* Factory for a {@link WorkflowFn}: supply an agent and repo paths at runtime, then pass the result
* to the bundle `run` export pattern (`createRoleModerator` is already applied).
* Build the solve-issue {@link WorkflowFn}.
*
* The `developer` role always delegates to the registered `develop` workflow via
* {@link workflowAsAgent}; if the caller supplies their own `developer` override in
* `binding.overrides`, it takes precedence so tests and custom hosts can stub it.
*/
export function createSolveIssueRun(config: SolveIssueRolesConfig): WorkflowFn {
return createRoleModerator<SolveIssueMeta>({
roles: createSolveIssueRoles(config),
moderator: solveIssueModerator,
});
export function createSolveIssueRun(
binding: AgentBinding,
extract: ExtractFn,
llmProvider: LlmProvider | null,
): WorkflowFn {
const developerOverride = binding.overrides?.developer ?? workflowAsAgent("develop");
const mergedBinding: AgentBinding = {
agent: binding.agent,
overrides: {
...(binding.overrides ?? {}),
developer: developerOverride,
},
};
return createWorkflow(solveIssueWorkflowDefinition, mergedBinding, extract, llmProvider);
}
@@ -4,33 +4,21 @@ import { END } from "@uncaged/workflow";
import type { SolveIssueMeta } from "./roles.js";
export const solveIssueModerator: Moderator<SolveIssueMeta> = (ctx) => {
const maxRounds = ctx.start.meta.maxRounds;
if (ctx.steps.length === 0) {
return "planner";
return "preparer";
}
const last = ctx.steps[ctx.steps.length - 1];
if (last.role === "planner") {
return "coder";
if (last.role === "preparer") {
return "developer";
}
if (last.role === "coder") {
return "reviewer";
if (last.role === "developer") {
return "submitter";
}
if (last.role === "reviewer") {
if (last.meta.approved === true) {
return "committer";
}
if (ctx.steps.length < maxRounds - 1) {
return "coder";
}
return END;
}
if (last.role === "committer") {
if (last.role === "submitter") {
return END;
}
@@ -1,151 +1,23 @@
import type { AgentFn, Role } from "@uncaged/workflow";
import {
type CommitterMeta,
type CommitterPlanMeta,
createCommitterRole,
} from "@uncaged/workflow-role-committer";
import { createRole } from "@uncaged/workflow-role-llm";
import { createReviewerRole, type ReviewerMeta } from "@uncaged/workflow-role-reviewer";
import type { LlmProvider } from "@uncaged/workflow-util-role";
import * as z from "zod/v4";
import type { RoleDefinition } from "@uncaged/workflow";
import { type DeveloperMeta, developerRole } from "./developer.js";
import { type PreparerMeta, preparerRole } from "./roles/preparer.js";
import { type SubmitterMeta, submitterRole } from "./roles/submitter.js";
const DRY_RUN_PROVIDER: LlmProvider = {
baseUrl: "http://127.0.0.1:9",
apiKey: "",
model: "template-dry-run",
};
const PLANNER_SYSTEM = `You are a **planner** for a software task. Analyze the issue, list relevant files, and produce a clear step-by-step approach.
Focus on: root cause, edge cases, and how the implementation will be verified. Output enough detail for a coding agent to implement without guessing.`;
const CODER_SYSTEM = `You are a **coder**. The previous step produced a plan: read the thread and implement that plan in the repository.
Make focused changes, follow project conventions, and explain what you changed.`;
export const plannerMetaSchema = z.object({
plan: z.string(),
files: z.array(z.string()),
approach: z.string(),
});
export const coderMetaSchema = z.object({
filesChanged: z.array(z.string()),
summary: z.string(),
});
export type PlannerMeta = z.infer<typeof plannerMetaSchema>;
export type CoderMeta = z.infer<typeof coderMetaSchema>;
const PLANNER_DRY_RUN_META: PlannerMeta = {
plan: "",
files: [],
approach: "",
};
const CODER_DRY_RUN_META: CoderMeta = {
filesChanged: [],
summary: "",
};
const REVIEWER_DRY_RUN_META: ReviewerMeta = {
approved: true,
};
const COMMITTER_PLAN_DRY_RUN_META: CommitterPlanMeta = {
branch: "dry-run",
message: "chore: dry run",
};
export const SOLVE_ISSUE_WORKFLOW_DESCRIPTION =
"Resolve an issue end-to-end by preparing the repo, delegating implementation to the develop workflow, and opening a pull request (preparer → developer → submitter).";
export type SolveIssueMeta = {
planner: PlannerMeta;
coder: CoderMeta;
reviewer: ReviewerMeta;
committer: CommitterMeta;
preparer: PreparerMeta;
developer: DeveloperMeta;
submitter: SubmitterMeta;
};
/** Wiring for workflow-role LLM structured extraction. Use `null` for stub extract (dry-run meta from built-in placeholders). */
export type SolveIssueRolesConfig = {
agent: AgentFn;
workdir: string;
extract: { provider: LlmProvider; dryRun: boolean | null } | null;
};
function resolveExtract(config: SolveIssueRolesConfig): {
provider: LlmProvider;
dryRun: boolean | null;
} {
if (config.extract === null) {
return { provider: DRY_RUN_PROVIDER, dryRun: true };
}
return config.extract;
}
export type SolveIssueRoles = {
planner: Role<PlannerMeta>;
coder: Role<CoderMeta>;
reviewer: Role<ReviewerMeta>;
committer: Role<CommitterMeta>;
[K in keyof SolveIssueMeta]: RoleDefinition<SolveIssueMeta[K]>;
};
export function createSolveIssueRoles(config: SolveIssueRolesConfig): SolveIssueRoles {
const extract = resolveExtract(config);
const reviewerGit = {
cwd: config.workdir,
conventionsPath: null,
extraChecks: [],
threadId: null,
};
const committerGit = {
cwd: config.workdir,
remote: "origin",
threadId: null,
};
const planner: Role<PlannerMeta> = createRole({
name: "planner",
schema: plannerMetaSchema,
systemPrompt: PLANNER_SYSTEM,
agent: config.agent,
extract: {
provider: extract.provider,
dryRun: extract.dryRun,
dryRunMeta: PLANNER_DRY_RUN_META,
},
});
const coder: Role<CoderMeta> = createRole({
name: "coder",
schema: coderMetaSchema,
systemPrompt: CODER_SYSTEM,
agent: config.agent,
extract: {
provider: extract.provider,
dryRun: extract.dryRun,
dryRunMeta: CODER_DRY_RUN_META,
},
});
const reviewer: Role<ReviewerMeta> = createReviewerRole(
config.agent,
{
provider: extract.provider,
dryRun: extract.dryRun,
dryRunMeta: REVIEWER_DRY_RUN_META,
},
reviewerGit,
);
const committer: Role<CommitterMeta> = createCommitterRole(
config.agent,
{
provider: extract.provider,
dryRun: extract.dryRun,
dryRunMeta: COMMITTER_PLAN_DRY_RUN_META,
},
committerGit,
);
return { planner, coder, reviewer, committer };
}
export const solveIssueRoles: SolveIssueRoles = {
preparer: preparerRole,
developer: developerRole,
submitter: submitterRole,
};
@@ -0,0 +1,6 @@
export {
type PreparerMeta,
preparerMetaSchema,
preparerRole,
} from "./preparer.js";
export { type SubmitterMeta, submitterMetaSchema, submitterRole } from "./submitter.js";
@@ -0,0 +1,52 @@
import type { RoleDefinition } from "@uncaged/workflow";
import * as z from "zod/v4";
const toolchainSchema = z.object({
packageManager: z.union([z.string(), z.null()]),
testCommand: z.union([z.string(), z.null()]),
lintCommand: z.union([z.string(), z.null()]),
buildCommand: z.union([z.string(), z.null()]),
});
export const preparerMetaSchema = z.object({
repoPath: z.string(),
defaultBranch: z.string(),
conventions: z.union([z.string(), z.null()]),
toolchain: toolchainSchema,
});
export type PreparerMeta = z.infer<typeof preparerMetaSchema>;
const PREPARER_SYSTEM = `You are a **preparer** for a software task. Your job is to locate (or clone) the target repository locally, ensure it is up to date, and gather project context before work begins.
## Responsibilities
1. Parse the issue/task prompt to identify the target repository (URL, org/repo, or name).
2. Search for an existing local clone in these locations (in order):
- ~/Code/<repo-name>/
- ~/repos/<repo-name>/
- ~/Code/<org>/<repo-name>/
- ~/repos/<org>/<repo-name>/
3. If not found locally, \`git clone\` it into ~/repos/<repo-name>/.
4. \`git checkout main && git pull\` (or the default branch) to ensure latest.
5. Read project conventions: \`CLAUDE.md\`, \`CONTRIBUTING.md\`, \`.cursor/rules/*.mdc\`, \`CONVENTIONS.md\`.
6. Detect toolchain: package manager, test runner, linter, build system.
## Output
Report your findings as structured data:
- **repoPath**: absolute path to the local repo
- **defaultBranch**: the default branch name (e.g. "main")
- **conventions**: a summary of project conventions found, or null if none
- **toolchain**: detected commands for packageManager, testCommand, lintCommand, buildCommand (null if not detected)`;
export const preparerRole: RoleDefinition<PreparerMeta> = {
description:
"Locates or clones the target repository, ensures it is up to date, and gathers project context (conventions, toolchain).",
systemPrompt: PREPARER_SYSTEM,
extractPrompt:
"Extract repoPath (absolute path), defaultBranch, conventions (summary string or null), and toolchain (packageManager, testCommand, lintCommand, buildCommand — each string or null).",
schema: preparerMetaSchema,
extractRefs: null,
extractMode: "single",
};
@@ -0,0 +1,44 @@
import type { RoleDefinition } from "@uncaged/workflow";
import * as z from "zod/v4";
export const submitterMetaSchema = z.discriminatedUnion("status", [
z.object({
status: z.literal("submitted"),
prUrl: z.string(),
}),
z.object({
status: z.literal("failed"),
error: z.string(),
}),
]);
export type SubmitterMeta = z.infer<typeof submitterMetaSchema>;
const SUBMITTER_SYSTEM = `You are the **submitter**. Your job is to push the work branch to the remote and open a pull request.
## Inputs
Read the thread for context:
- The **preparer**'s output gives you the absolute repo path and the default branch (and remote URL by inspecting the repo).
- The **developer**'s output gives you the branch name that was committed and a list of files changed plus a summary of the work.
## Procedure
1. \`cd\` into the repo path from the preparer's output.
2. Push the developer's branch to the remote: \`git push -u origin <branch>\`.
3. Open a pull request (e.g. via \`gh pr create\`) targeting the default branch. The PR title should be short and describe the change. The PR description should summarize what changed (drawing from the developer's summary and filesChanged) and reference the original issue/task if applicable.
4. Report the resulting PR URL.
On any failure (push rejected, gh not authenticated, PR creation failed, etc.), report status="failed" with a short error message. Do not retry — surface the error so the moderator can decide.`;
const SUBMITTER_EXTRACT_PROMPT =
"Extract the submission result. status='submitted' with prUrl on success, or status='failed' with a short error message on failure.";
export const submitterRole: RoleDefinition<SubmitterMeta> = {
description: "Pushes the developer's branch to the remote and opens a pull request.",
systemPrompt: SUBMITTER_SYSTEM,
extractPrompt: SUBMITTER_EXTRACT_PROMPT,
schema: submitterMetaSchema,
extractRefs: null,
extractMode: "single",
};
@@ -6,9 +6,5 @@
"composite": true
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../workflow" },
{ "path": "../workflow-role-llm" },
{ "path": "../workflow-util-role" }
]
"references": [{ "path": "../workflow" }]
}
File diff suppressed because one or more lines are too long
@@ -1,5 +1,8 @@
import { describe, expect, test } from "bun:test";
import { START, type ThreadContext } from "@uncaged/workflow";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createCasStore, putContentMerkleNode, START, type ThreadContext } from "@uncaged/workflow";
import { buildAgentPrompt } from "../src/index.js";
@@ -13,59 +16,90 @@ function startTask(content: string): ThreadContext["start"] {
}
describe("buildAgentPrompt", () => {
test("includes system prompt and full task; omits tools when there are no steps", () => {
let casRoot: string;
beforeEach(async () => {
casRoot = await mkdtemp(join(tmpdir(), "wf-build-prompt-cas-"));
});
afterEach(async () => {
await rm(casRoot, { recursive: true, force: true });
});
test("includes system prompt and full task; omits tools when there are no steps", async () => {
const cas = createCasStore(casRoot);
const ctx: ThreadContext = {
start: startTask("fix the bug"),
depth: 0,
steps: [],
threadId: "01TEST000000000000000000TR",
currentRole: { name: START, systemPrompt: "You are an agent." },
cas,
};
const text = buildAgentPrompt("You are an agent.", ctx);
const text = await buildAgentPrompt(ctx);
expect(text).toContain("You are an agent.");
expect(text).toContain("## Task");
expect(text).toContain("fix the bug");
expect(text).not.toContain("## Tools");
});
test("single step shows full content and meta, and includes tools", () => {
test("single step shows full content and meta, and includes tools", async () => {
const cas = createCasStore(casRoot);
const onlyHash = await putContentMerkleNode(cas, "only step full body");
const ctx: ThreadContext = {
start: startTask("user task"),
depth: 0,
threadId: "01TEST000000000000000000TR",
currentRole: { name: "coder", systemPrompt: "Be helpful." },
cas,
steps: [
{
role: "coder",
content: "only step full body",
contentHash: onlyHash,
meta: { files: ["a.ts"] },
refs: [onlyHash],
timestamp: 2,
},
],
};
const text = buildAgentPrompt("Be helpful.", ctx);
const text = await buildAgentPrompt(ctx);
expect(text).toContain("## Task");
expect(text).toContain("user task");
expect(text).toContain("## Step: coder");
expect(text).toContain("only step full body");
expect(text).toContain('Meta: {"files":["a.ts"]}');
expect(text).toContain("## Tools");
expect(text).toContain("uncaged-workflow thread <threadId>");
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
});
test("two or more steps: previous steps are meta-only; latest step is full", () => {
test("two or more steps: previous steps are meta-only; latest step is full", async () => {
const cas = createCasStore(casRoot);
const plannerHash = await putContentMerkleNode(cas, "PLANNER_SECRET_FULL_TEXT");
const coderHash = await putContentMerkleNode(cas, "last step full content");
const ctx: ThreadContext = {
start: startTask("first message full: task content here"),
depth: 0,
threadId: "01TEST000000000000000000TR",
currentRole: { name: "coder", systemPrompt: "System." },
cas,
steps: [
{
role: "planner",
content: "PLANNER_SECRET_FULL_TEXT",
contentHash: plannerHash,
meta: { plan: "short" },
refs: [plannerHash],
timestamp: 2,
},
{
role: "coder",
content: "last step full content",
contentHash: coderHash,
meta: { done: true },
refs: [coderHash],
timestamp: 3,
},
],
};
const text = buildAgentPrompt("System.", ctx);
const text = await buildAgentPrompt(ctx);
expect(text).toContain("first message full: task content here");
expect(text).toContain("## Previous Steps");
expect(text).toContain("### Step 1: planner");
@@ -75,33 +109,45 @@ describe("buildAgentPrompt", () => {
expect(text).toContain("last step full content");
expect(text).toContain('Meta: {"done":true}');
expect(text).toContain("## Tools");
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
});
test("middle steps show meta summary only, not full content", () => {
test("middle steps show meta summary only, not full content", async () => {
const cas = createCasStore(casRoot);
const ha = await putContentMerkleNode(cas, "HIDDEN_A");
const hb = await putContentMerkleNode(cas, "HIDDEN_B_MIDDLE");
const hc = await putContentMerkleNode(cas, "VISIBLE_LAST");
const ctx: ThreadContext = {
start: startTask("start"),
depth: 0,
threadId: "01TEST000000000000000000TR",
currentRole: { name: "c", systemPrompt: "S" },
cas,
steps: [
{
role: "a",
content: "HIDDEN_A",
contentHash: ha,
meta: { n: 1 },
refs: [ha],
timestamp: 2,
},
{
role: "b",
content: "HIDDEN_B_MIDDLE",
contentHash: hb,
meta: { n: 2 },
refs: [hb],
timestamp: 3,
},
{
role: "c",
content: "VISIBLE_LAST",
contentHash: hc,
meta: { n: 3 },
refs: [hc],
timestamp: 4,
},
],
};
const text = buildAgentPrompt("S", ctx);
const text = await buildAgentPrompt(ctx);
expect(text).not.toContain("HIDDEN_A");
expect(text).not.toContain("HIDDEN_B_MIDDLE");
expect(text).toContain('Summary: {"n":1}');
@@ -4,6 +4,12 @@
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"build": "echo 'TODO'",
"test": "bun test"

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