Compare commits

...

132 Commits

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

小橘 <xiaoju@shazhou.work>
2026-05-26 17:30:00 +00:00
xiaoju 577fb27470 feat: add adapter skill + fix commit scope (#549)
CI / test (pull_request) Successful in 1m30s
- Add 'uwf skill adapter' — guide for building agent adapters.
  Covers: createAgent factory, AgentContext/AgentRunResult types,
  prompt building helpers, session detail storage, registration.
- Fix developer skill: agent-kit → util-agent in commit scope.

Refs #542
Fixes #549
2026-05-26 17:24:48 +00:00
xiaomo 5475dd3f5c Merge pull request 'feat: add developer skill — coding conventions + architecture guide' (#548) from feat/541-skill-developer into main
CI / test (push) Successful in 1m28s
2026-05-26 17:19:16 +00:00
xiaoju 09b7ddf6d0 feat: add developer skill — coding conventions + architecture guide
CI / test (pull_request) Successful in 1m26s
Adds 'uwf skill developer' for contributors to the workflow engine.
Covers: monorepo structure, dependency layers, functional-first conventions,
error handling, logging with tagged logger, development workflow,
testing, publishing, key modules (moderator, extract pipeline, createAgent).

Refs #541
2026-05-26 17:11:07 +00:00
xiaomo c4e94bbe56 Merge pull request 'feat: add author skill — workflow YAML design guide' (#547) from feat/539-skill-author into main
CI / test (push) Successful in 1m11s
2026-05-26 17:04:50 +00:00
xiaoju dbefe793f2 feat: add author skill — workflow YAML design guide
CI / test (pull_request) Successful in 1m4s
Adds 'uwf skill author' for agents/humans designing workflow definitions.
Covers: YAML structure, role definition, frontmatter schema design,
graph routing, edge prompts, self-testing, and common pitfalls.

Refs #539
2026-05-26 17:02:53 +00:00
xiaomo 6483bc4861 Merge pull request 'feat: add user skill — CLI guide with quick start' (#546) from feat/538-skill-user into main
CI / test (push) Successful in 1m40s
2026-05-26 16:27:43 +00:00
xiaoju fecb02b115 feat: add user skill — CLI guide with quick start and typical workflows
CI / test (pull_request) Successful in 1m26s
Adds 'uwf skill user' command for agents/humans using the uwf CLI.
Covers setup, workflow management, thread lifecycle, step operations,
CAS queries, logging, and global options with a Quick Start guide.

Refs #538
2026-05-26 16:24:39 +00:00
xiaomo 87938c1886 Merge pull request 'feat: add actor skill — frontmatter protocol + CAS reference' (#545) from feat/540-skill-actor into main
CI / test (push) Failing after 23s
2026-05-26 15:44:31 +00:00
xiaoju 95a130136b feat: add actor skill — frontmatter protocol + CAS reference
CI / test (pull_request) Failing after 8m9s
Adds 'uwf skill actor' command for agents executing workflow roles.
Covers the two things an actor needs to know:
1. Frontmatter output protocol (status field, schema-defined fields)
2. CAS operations (put, get, refs, walk, merkle DAG pattern)

Refs #540
2026-05-26 15:32:03 +00:00
xiaomo aba5642908 Merge pull request 'ci: use test:ci to skip integration tests in CI' (#543) from fix/ci-skip-integration-tests into main
CI / test (push) Successful in 3m32s
2026-05-26 15:26:02 +00:00
xingyue 168e604602 ci: use test:ci to skip integration tests in CI
CI / test (pull_request) Successful in 9m13s
The HermesAcpClient integration tests require a live Hermes agent
process and always timeout (3 × 120s) in CI containers, causing
every CI run to fail for ~6 minutes before reporting failure.

Switch from `bun run test` to `bun run test:ci` which was already
defined in all testable packages — workflow-agent-hermes's test:ci
runs only unit tests (__tests__/*.test.ts), skipping integration/.
2026-05-26 23:08:16 +08:00
xiaoju d50159c5a7 refactor: split e2e-walkthrough into 6 roles with dedicated cleanup
CI / test (push) Failing after 11m29s
- bootstrap: Docker + bun install + bun link + verify
- config-and-registry: config get/set/list + workflow add/show/list
- thread-ops: thread start/list/show/exec
- inspect: step list/show + thread read + CAS get/has/refs/walk
- cancel-and-fork: cancel + fork + logs
- cleanup: docker rm -f (all fail paths route here)

小橘 🍊
2026-05-26 14:47:44 +00:00
xiaoju 9a7ad34e55 chore: move e2e-walkthrough to .workflows/, fix CI, clean .plan/
CI / test (push) Failing after 11m54s
- e2e-walkthrough.yaml: examples/ → .workflows/ (project workflows, not examples)
- .gitea/workflows/ci.yml: bun test → bun run test (avoid legacy-packages)
- .plan/: removed stale test spec from #335

小橘 🍊
2026-05-26 14:37:46 +00:00
xiaoju 4193157124 refactor(hermes): clean up loadHermesSessionFromDb
CI / test (push) Failing after 11m14s
- Remove unnecessary Promise.resolve() wrappers (sync function)
- Use try/finally for db.close() instead of manual close at each exit
- Flatten nested try/catch

Follow-up to #535 review nits.

小橘 🍊
2026-05-26 14:27:31 +00:00
xiaomo 6ff1414cf0 Merge pull request 'fix(hermes): add SQLite fallback for loadHermesSession' (#536) from fix/535-sqlite-fallback into main
CI / test (push) Failing after 9m23s
Merge pull request #536: fix(hermes): add SQLite fallback for loadHermesSession
2026-05-26 14:24:42 +00:00
xiaoju 37f4203b40 fix(hermes): add SQLite fallback for loadHermesSession (#535)
CI / test (pull_request) Failing after 9m52s
When sessions.write_json_snapshots is disabled, Hermes only writes to
state.db (SQLite). loadHermesSession now falls back to reading from
~/.hermes/state.db when the JSON file is missing.

- Add getHermesDbPath() and loadHermesSessionFromDb() functions
- Use bun:sqlite with readonly mode, try-catch for graceful errors
- JSON file still takes priority (fast path)
- Filter messages to user/assistant/tool roles
- Convert unix timestamps to ISO 8601 strings
2026-05-26 14:19:15 +00:00
xiaoju c4ec22bb4f chore: e2e-walkthrough uses bun link for container-internal uwf
CI / test (push) Failing after 8m19s
外层: bun install -g @uncaged/cli-workflow@0.5.0 (+ agents)
内层: bun link 本地 packages,完全隔离

小橘 🍊
2026-05-26 13:14:54 +00:00
xiaoju 427f47d72c fix: release script uses filtered test, publish 0.5.0
CI / test (push) Failing after 10m18s
小橘 🍊
2026-05-26 13:02:45 +00:00
xiaoju 9f25745e1e chore: exit pre mode, clean stale changesets for 0.5.0 release
小橘 🍊
2026-05-26 13:00:49 +00:00
xiaoju 82247c86ce feat: add e2e-walkthrough workflow definition
CI / test (push) Failing after 8m29s
Dogfooding: uwf tests uwf. Replaces the monolithic bash script with a
4-role workflow (bootstrap → setup-and-registry → thread-lifecycle →
cancel-fork-and-logs), each executing inside an isolated Docker container.

小橘 🍊
2026-05-26 12:49:13 +00:00
xiaoju 0ef2d8fec2 feat: add E2E walkthrough script (Docker-based, WIP)
CI / test (push) Failing after 7m41s
Runs full uwf CLI walkthrough inside a Docker container with isolated
storage root. Tests: setup, workflow add/list/show, thread start/exec/
cancel/fork, step list/show, CAS operations, config get/set, logs.

Approach: mount host $HOME into node:22-bookworm container, override
UNCAGED_WORKFLOW_STORAGE_ROOT with tmpdir. No mock LLM — real agents.

Known issues documented in header comments (jq quoting, apt-get
startup time, lockfile conflicts).

小橘 🍊(NEKO Team)
2026-05-26 12:40:47 +00:00
xiaoju aa14fd08e0 chore: add dev environment check script
CI / test (push) Failing after 8m26s
scripts/check-dev-env.sh validates all prerequisites:
- Runtime: bun, node, python3
- Tools: hermes, claude-code
- Workflow: repo, build, uwf/agent symlinks, config
- Docker (optional, for E2E tests)

Non-interactive, actionable fix instructions on failure.
Designed for both humans and agents.
2026-05-26 12:25:25 +00:00
xiaonuo e43d4f3bbf Merge pull request 'fix: config validation and agent name normalization (#531, #532, #533)' (#534) from fix/531-532-533 into main
CI / test (push) Failing after 9m10s
2026-05-26 06:09:56 +00:00
xiaoju b0c73b5439 fix(cli): fix config masking, agent normalization, and add key validation
CI / test (pull_request) Failing after 17m6s
This commit addresses three related issues in the CLI config and setup commands:

1. Issue #531: Fix config list apiKey masking
   - maskApiKeys() now checks for 'apiKey' instead of 'apiKeyEnv'
   - Updated tests to use apiKey field throughout

2. Issue #532: Add config set key validation
   - Reject unknown top-level keys with helpful error messages
   - Reject unknown nested fields in providers/models/agents
   - Reject incomplete paths and nested paths on scalar keys
   - Added VALID_CONFIG_KEYS schema and validateConfigKey() function

3. Issue #533: Fix agent name double-prefix in setup
   - mergeConfig() now uses _agentNameFromBinary() to normalize agent names
   - 'uwf-hermes' input now produces 'hermes' key with 'uwf-hermes' command
   - Added tests for prefixed agent names

All tests passing, no regressions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-26 05:57:55 +00:00
xiaonuo bbbe4651c2 Merge pull request 'refactor: apiKeyEnv → apiKey, store actual secret in config' (#530) from fix/528-refactor-apikey into main
CI / test (push) Failing after 35s
2026-05-26 05:37:51 +00:00
xiaonuo 7dfe0eb6a9 Merge pull request 'feat(cli): add uwf config get/set/list subcommand' (#527) from fix/526-config-subcommand into main
CI / test (push) Has been cancelled
2026-05-26 05:37:32 +00:00
xiaoju 47a4268b9b docs: update all documentation to reflect apiKey refactoring (#528)
CI / test (pull_request) Failing after 33s
Update all documentation files that contained outdated apiKeyEnv
references to use the new apiKey approach.

## Changes

- docs/architecture.md: Update config example to use apiKey field
- docs/wf-stateless-design.md: Update config examples and type
  definitions to use apiKey instead of apiKeyEnv
- docs/builtin-agent-research.md: Update ProviderConfig type
  definition and code examples

All documentation now consistent with the code implementation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-26 05:34:49 +00:00
xiaoju 0c90b88e08 refactor(protocol,cli,agent): replace apiKeyEnv with apiKey (#528)
CI / test (pull_request) Failing after 7m20s
Breaking change: Store API keys directly in config.yaml instead of
environment variable names.

## Changes

### @uncaged/workflow-protocol
- Change ProviderConfig.apiKeyEnv: string → apiKey: string
- Update README to reflect new type

### @uncaged/workflow-util-agent
- extract.ts: Remove dotenv loading, use providerEntry.apiKey directly
- storage.ts: Update normalizeProviders to validate apiKey field
- Update error messages to reference apiKey instead of apiKeyEnv

### @uncaged/cli-workflow
- setup.ts: Write actual API key to config.yaml, not .env
- Remove apiKeyEnvName(), loadEnvFile(), saveEnvFile() functions
- Remove getEnvPath() function
- Update cmdSetup to not return envPath in result
- Update README to reflect config.yaml stores API keys
- Fix setup-validate.test.ts to not expect envPath in result

## Verification
-  bun run build passes
-  All tests pass (260/260 in cli-workflow, 55/55 in util-agent)
-  bun run check passes (only pre-existing warnings)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-26 05:23:33 +00:00
xiaoju 5583a9da00 chore: retrigger CI
CI / test (pull_request) Failing after 1m36s
2026-05-26 05:21:11 +00:00
xiaoju 4a0cb7c615 ci: replace lint+typecheck with unified check step
CI / test (pull_request) Failing after 9m1s
Fixes CI failure — 'lint' script didn't exist in package.json.
bun run check already covers tsc + biome + log-tag lint.
2026-05-26 05:04:47 +00:00
xiaoju fa97a7c92a feat(cli): add uwf config get/set/list subcommand
CI / test (pull_request) Failing after 23m14s
Add configuration management commands to uwf CLI:
- uwf config list: display all config values (masks API keys)
- uwf config get <key>: retrieve specific value using dot notation
- uwf config set <key> <value>: update config value with auto-creation

Implementation:
- New file packages/cli-workflow/src/commands/config.ts with helper functions
- Comprehensive test coverage (32 tests) in config.test.ts
- Supports nested path navigation via dot notation
- Auto-creates intermediate objects when setting new paths
- Masks apiKeyEnv values in list output for security

Resolves #526

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-25 16:21:51 +00:00
xiaomo 0097633a3b Merge pull request 'fix: cancelled threads show distinct "cancelled" status' (#525) from fix/522-cancelled-thread-status into main
CI / test (push) Failing after 5m57s
2026-05-25 15:51:29 +00:00
xiaomo 04591296b2 Merge pull request 'fix: bin entry point to dist/cli.js for node compatibility' (#524) from fix/523-bin-entry-point into main
CI / test (push) Has been cancelled
2026-05-25 15:51:18 +00:00
xiaoju 96039dbbbf fix: cancelled threads show distinct status instead of completed
CI / test (pull_request) Failing after 34s
Fixes #522
2026-05-25 15:39:59 +00:00
xiaoju 5230462b8d fix: bin entry point to dist/cli.js for node compatibility
CI / test (pull_request) Failing after 9m12s
Fixes #523
2026-05-25 15:35:55 +00:00
xiaomo 4a39d3fdef Merge pull request 'feat(skill): expand uwf skill with architecture, yaml, moderator, list subcommands' (#521) from fix/517-expand-skill into main
CI / test (push) Failing after 22m38s
2026-05-25 15:00:34 +00:00
xingyue 4de13cea44 fix: correct skill references and remove hardcoded test path
CI / test (pull_request) Failing after 23m48s
- moderator-reference: use nested map graph format matching evaluate.ts
- yaml-reference: use goal/procedure/output/capabilities/frontmatter fields
  matching actual WorkflowPayload, not fabricated system/outputSchema
- skill.test.ts: replace hardcoded absolute path with __dirname-relative
- skill.test.ts: assert 'frontmatter' instead of 'outputSchema'
2026-05-25 22:59:38 +08:00
xingyue d9d542c570 fix: correct biome suppressions and formatting for #517
CI / test (pull_request) Failing after 9m9s
2026-05-25 22:47:00 +08:00
xingyue cf6115517c fix: auto-fix biome lint violations in skill.test.ts 2026-05-25 22:44:32 +08:00
xingyue 108f134020 feat(skill): add architecture, yaml, moderator, list subcommands (#517) 2026-05-25 22:42:05 +08:00
xiaomo 8123399189 Merge pull request 'fix(uwf-hermes): read turn data from session file instead of ACP stream' (#520) from fix/519-read-session-file into main
CI / test (push) Failing after 17m33s
2026-05-25 14:24:41 +00:00
xingyue 6324122168 fix(uwf-hermes): read turn data from Hermes session file instead of ACP stream
CI / test (pull_request) Failing after 12m19s
Closes #519

The ACP protocol's tool_call updates only carry a display title (not a
structured tool name) and omit rawInput for polished tools, making the
reconstructed messages unusable for step read/show.

Changes:
- hermes.ts: storePromptResult reads ~/.hermes/sessions/session_{id}.json
  via loadHermesSession() instead of using ACP-reconstructed messages
- acp-client.ts: strip message/tool-call collection logic, keep only
  text chunk accumulation for final response extraction
- step.ts: TurnData gains role + toolCalls fields; formatTurnBody
  renders them in step read markdown output
- README: document sessions.write_json_snapshots requirement
2026-05-25 22:21:03 +08:00
xiaoju 25b411f22e Merge pull request 'fix(validate): support enum-based multi-exit frontmatter schemas' (#518) from fix/enum-multi-exit-validation into main
CI / test (push) Failing after 15m56s
2026-05-25 13:23:10 +00:00
xiaoju 54dc8fcb39 fix(validate): support enum-based multi-exit and upgrade json-cas to 0.5.3
CI / test (pull_request) Failing after 5m55s
Two fixes for 'uwf thread start solve-issue' failures:

1. json-cas 0.5.2 (npm) was missing oneOf in ALLOWED_SCHEMA_KEYS.
   Published json-cas 0.5.3 with the fix, bumped all packages to ^0.5.3.

2. Semantic validator only recognized oneOf-based multi-exit schemas.
   Roles using $status with enum (e.g. enum: [approved, rejected]) were
   incorrectly treated as single-exit. Added isEnumMultiExit() support.

Changes:
- validate-semantic.ts: isEnumMultiExit(), getEnumStatuses(), checkSingleExitMustache()
- All package.json: @uncaged/json-cas ^0.5.2 → ^0.5.3
- validate-semantic.test.ts: 5 new enum multi-exit tests (Suite 3b)
- solve-issue-tea-worktree.test.ts: updated for current workflow structure

小橘 🍊
2026-05-25 13:13:51 +00:00
xiaoju a40e1bb847 fix(cli): remove Chinese text from uwf --help description
CI / test (pull_request) Failing after 33s
Remove the annotation line entirely — the layer names are self-explanatory.

小橘 🍊
2026-05-25 12:42:10 +00:00
xiaomo 2c8bcf7996 Merge pull request 'feat(setup): auto-discover and configure agents during uwf setup' (#515) from feat/424-setup-agent-discovery into main
CI / test (push) Failing after 1m34s
2026-05-25 12:39:34 +00:00
xiaoju af2a25bf87 feat(setup): auto-discover and configure agents during uwf setup
CI / test (pull_request) Failing after 14m13s
- Add agent discovery step to cmdSetupInteractive flow
- _promptAgentSelection: discover uwf-* binaries, auto-select if only one,
  prompt user to choose if multiple, show install hints if none found
- mergeConfig: always write selected agent entry, update defaultAgent
- Known agent labels for hermes, claude-code, cursor, builtin
- 10 new tests for _agentNameFromBinary, _printAgentMenu, cmdSetup agent config

Fixes #424
2026-05-25 12:38:40 +00:00
xiaomo 0abc8bcb3e Merge pull request 'fix(test): correct import path in resume-e2e integration test' (#514) from fix/hermes-integration-test-import into main
CI / test (push) Failing after 1m44s
2026-05-25 12:31:27 +00:00
xiaoju 524e00a0a6 fix(test): correct import path in resume-e2e integration test
CI / check (pull_request) Failing after 3m7s
The test file moved to __tests__/integration/ but the import path
was not updated from ../src/ to ../../src/.

小橘 🍊
2026-05-25 12:29:18 +00:00
xingyue eba3c70e76 ci: add gitea actions workflow
CI / test (push) Failing after 6m22s
2026-05-25 19:43:57 +08:00
xiaoju e2d60fa72e fix(test): use valid JSON Schema in workflow-resolution test fixture
CI / check (push) Failing after 32s
The test used a fake CasRef string as frontmatter, which fails
putSchema validation when loading from YAML files. Replace with
a proper JSON Schema object.

Fixes pre-existing failures in workflow-resolution, cas-exit-code,
and thread-step-count tests.
2026-05-25 11:29:05 +00:00
xiaoju dfae96ad45 style: fix biome import ordering after package rename
CI / check (push) Has been cancelled
2026-05-25 11:26:01 +00:00
xiaomo 2f4473f22c Merge pull request 'refactor: rename workflow-agent-kit → workflow-util-agent, merge moderator' (#513) from refactor/512-rename-packages into main
CI / check (push) Has been cancelled
2026-05-25 11:20:39 +00:00
xiaoju ca223a19c6 refactor: rename workflow-agent-kit → workflow-util-agent, merge workflow-moderator into cli-workflow
CI / check (pull_request) Failing after 32s
- Rename packages/workflow-agent-kit → packages/workflow-util-agent
- Update all imports, tsconfig references, docs
- Delete dead file packages/workflow-util-agent/src/build-agent-prompt.ts
- Merge workflow-moderator (62 LOC) into cli-workflow/src/moderator/
- Move workflow-moderator to legacy-packages/
- Add mustache dependency to cli-workflow
- Update publish-all.mjs

Fixes #512
2026-05-25 10:51:16 +00:00
xiaoju 0779ab85ca Merge branch 'chore/510-open-source-readiness'
CI / check (push) Has been cancelled
2026-05-25 10:29:09 +00:00
xiaoju 4d85a2eebb refactor: split test suites — test:ci for unit tests, test for all
- Move hermes ACP integration tests to __tests__/integration/
- Add test:ci script to all packages (excludes integration/)
- CI workflow uses test:ci instead of test
- bun test still runs everything (unit + integration)

Refs #510
2026-05-25 10:27:46 +00:00
xiaoju cef4617956 fix: skip hermes ACP integration tests in CI
These tests require a live Hermes instance which is not available in CI.

Refs #510
2026-05-25 10:22:08 +00:00
xiaomo 813cbfd5c2 Merge pull request 'chore: open-source readiness' (#511) from chore/510-open-source-readiness into main
CI / check (push) Has been cancelled
2026-05-25 10:20:39 +00:00
xiaoju a11d76264a chore: open-source readiness — LICENSE, CONTRIBUTING, templates, package metadata
CI / check (pull_request) Failing after 32s
- Add MIT LICENSE
- Add CONTRIBUTING.md with setup, conventions, PR workflow
- Add GitHub issue/PR templates
- Add repository/homepage/bugs/license to all package.json files
- Add Install section to README before Quick Start

Fixes #510

小橘 🍊(NEKO Team)
2026-05-25 10:13:36 +00:00
xiaomo 6e8dedeb2f docs: move cursor rules to docs/, add project rules to CLAUDE.md
CI / check (push) Has been cancelled
Also bump @uncaged/json-cas* to ^0.5.2
2026-05-25 09:54:45 +00:00
xiaoju 762c457978 chore: add GitHub Actions CI + README badges
CI / check (push) Has been cancelled
小橘 🍊(NEKO Team)
2026-05-25 09:40:49 +00:00
xiaoju 9c26285424 chore: make solve-issue.yaml portable and add developer failed exit
- Remove hardcoded ~/repos/workflow paths from procedure text
- Use .worktrees/ relative to repo root instead of global path
- Add developer failed → $END exit for unrecoverable situations
- Add worktree field to reviewer rejected variant
- Fix test workflowPath to use import.meta.dirname

Refs #506
2026-05-25 09:07:58 +00:00
xiaoju 45f479e60f feat(protocol): add step-level timing (startedAtMs / completedAtMs) (#489)
BREAKING CHANGE: StepRecord now requires startedAtMs and completedAtMs fields.
StepEntry now requires durationMs field. Old CAS data without these fields is invalid.

- Add startedAtMs/completedAtMs to StepRecord and StepNodePayload
- Add durationMs to StepEntry (computed: completedAtMs - startedAtMs)
- Update STEP_NODE_SCHEMA to require timing fields as integers
- Record Date.now() before/after agent execution in createAgent
- Show duration in thread read headers (formatStepHeader)
- Update existing test fixtures with timing fields
2026-05-25 08:01:50 +00:00
xiaoju 3fca67e443 fix: isRoleDefinition accepts oneOf frontmatter
Without this, parseWorkflowPayload rejects workflows with oneOf
frontmatter before semantic validation even runs.
2026-05-25 07:46:12 +00:00
xiaoju 9b2460633c feat(cli): add workflow semantic validation before execution
Implements validateWorkflow() that performs deep semantic checks on
parsed WorkflowPayload before registration or execution:

- Role reference integrity (unknown roles, orphans, reserved names)
- Graph structure (/ constraints, reachability, edge targets)
- Status-edge consistency (single/multi-exit matching)
- Mustache template variable existence
- oneOf discriminant validity ( const check)

All errors collected (not fail-fast). Integrated into:
- uwf workflow add (before CAS registration)
- uwf thread start (local workflow materialization)

Closes #506
2026-05-25 07:25:10 +00:00
xiaoju dfb6fda06d feat(agent-kit): render per-variant output instructions for discriminated oneOf
buildOutputFormatInstruction now detects discriminated union schemas
(oneOf with shared const/ property) and renders separate YAML
example blocks per variant, so agents see exactly which fields belong
to which outcome instead of a flat merge.

Non-discriminated oneOf/anyOf schemas fall back to the existing flat
merge behavior.

Refs #502
2026-05-25 06:54:38 +00:00
xiaoju 827ff13c4a refactor: discriminated union frontmatter for solve-issue workflow
- planner: oneOf ready (plan, repoPath) | insufficient_info
- developer: single exit, plain object (branch, worktree), no $status
- reviewer: oneOf approved (branch, worktree) | rejected (comments)
- tester: oneOf passed (branch, worktree) | fix_code (report) | fix_spec (report)
- committer: oneOf committed (prUrl) | hook_failed (error)
- Edge prompts now use mustache templates with variant-specific fields
- Developer simplified from 2 exits to single exit (unit routing)

Phase 2 of #499 (closes #501)
2026-05-25 06:34:56 +00:00
xiaoju 7a19ceca89 refactor: rename status to $status, default to _ when absent
- evaluate() reads $status instead of status, defaults to _ when missing
- Update all YAML examples and .workflows to use $status
- Update cli-workflow resolveEvaluateArgs to use $status
- 10 moderator tests pass including new default _ test
- Single-exit roles no longer need to declare status field

Phase 1 of #499 (closes #500)
2026-05-25 06:22:53 +00:00
xiaoju 298b944169 docs: update all documentation for status-based routing (#497)
Replace all JSONata/ConditionDefinition/ConditionalEdge references with
status-based routing terminology across 8 files:

- README.md, CLAUDE.md: moderator description, key terms
- docs/architecture.md: dependency jsonata→mustache, evaluate signature
- docs/wf-stateless-design.md: type definitions, routing context
- packages/workflow-moderator/README.md: full rewrite for new API
- packages/workflow-protocol/README.md: Target type, remove Transition
- packages/workflow-dashboard/context.md: StatusEdge, graph type
- docs/builtin-agent-research.md: stale JSONata references
2026-05-25 05:52:27 +00:00
xiaoju e40e41555b refactor: dashboard status-based edge routing
- Rename ConditionalEdge → StatusEdge, condition → status throughout
- Rename conditional.tsx → status.tsx, edge label shows status value
- Update trans-in/trans-out to use status field instead of condition
- Update validate to check status edges
- Align server/workflow.ts with new WorkflowPayload.graph format
- 20 dashboard tests pass

Phase 3 of #490 (closes #493)
2026-05-25 05:05:57 +00:00
xiaoju 5a7f417899 feat: migrate examples to status-based routing + fix mustache HTML escape
- Migrate solve-issue.yaml, analyze-topic.yaml, debate.yaml to new format
- Add status enum field to all role frontmatter schemas
- Use {{{ }}} (triple mustache) for prompt templates with user content
- Disable mustache HTML escaping globally (prompts are plain text, not HTML)
- Add 2 new tests for HTML escape behavior
- 9 moderator tests pass

Phase 2 of #490 (closes #492)
2026-05-25 04:52:53 +00:00
xiaoju d00f9df2dd refactor: status-based graph routing + mustache prompt templates
- Delete ConditionDefinition, Transition types from workflow-protocol
- Add Target type, change graph to Record<string, Record<string, Target>>
- Remove conditions from WorkflowPayload and WORKFLOW_SCHEMA
- Replace jsonata with mustache in workflow-moderator
- Rewrite evaluate() to simple map lookup + mustache render
- Update cli-workflow to use new 3-arg evaluate(graph, role, output)
- 296 tests pass, 0 fail

Phase 1 of #490 (closes #491)
2026-05-25 04:50:06 +00:00
xiaoju ff959be3ef Merge pull request 'refactor(cli-workflow): reduce cmdStepRead cognitive complexity' (#488) from fix/487-refactor-step-read into main 2026-05-25 02:25:32 +00:00
xiaoju f45563ee31 refactor(cli-workflow): reduce cmdStepRead cognitive complexity
Extract four helper functions from cmdStepRead to reduce cognitive
complexity from 27 to ≤15:
- loadStepDetail: Load and validate step detail node
- loadTurnData: Load all turn nodes and extract content
- selectTurnsForQuota: Select turns within quota (≥1 always shown)
- formatStepMarkdown: Assemble final markdown output

All 6 existing tests pass. Zero Biome warnings. CLAUDE.md compliant.

Fixes #487
2026-05-25 02:17:55 +00:00
xiaoju 2b8cd99100 fix(agent-claude-code): use buildContinuationPrompt for step context
Replace custom buildHistorySummary with shared buildContinuationPrompt
from workflow-agent-kit. This aligns claude-code agent with hermes agent:

- First visit: includes step content (within 32k quota)
- Re-entry: shows only steps since last visit (meta only, session has context)

Previously developer could not see reviewer's detailed feedback on
re-entry, only {"approved":false}. Now gets full review text.

Fixes #486
2026-05-25 02:01:57 +00:00
xiaoju 1ca13e02b2 Merge pull request 'feat(cli): implement step read command' (#485) from fix/484-step-read-command into main 2026-05-25 01:43:12 +00:00
xiaoju 3146832d1b fix(cli-workflow): complete step read command implementation
Implements the `uwf step read` command to render a single step's turns
as human-readable markdown with quota enforcement.

Changes:
- Implement cmdStepRead() in step.ts with quota enforcement
  - Renders step metadata (hash, role, agent)
  - Loads and formats turns from detail node
  - Enforces quota by selecting most recent turns
  - Always shows at least one turn even if it exceeds quota
  - Gracefully handles steps with no detail or no turns
- Register `step read` command in cli.ts with --quota flag (default 4000)
- Add comprehensive test suite in step-read.test.ts (6 tests covering
  basic functionality, quota enforcement, edge cases, and special chars)
- Update README.md CLI Reference table to include `step read`
- Update package-level README.md with command documentation and example

Closes #484

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-25 01:36:25 +00:00
xiaoju 64f929c10d Merge pull request 'fix(cli-workflow): fix thread read --quota flag implementation' (#483) from fix/480-thread-read-quota into main 2026-05-25 01:18:21 +00:00
xiaoju 1ec32ae0fd Merge pull request 'fix: cas has now returns exit 1 for non-existent hashes' (#482) from fix/481-cas-has-exit-code into main 2026-05-25 01:18:07 +00:00
xiaoju f851a087f2 fix(cli-workflow): fix thread read --quota flag implementation
Issue #480: The --quota flag on 'uwf thread read' was not properly
limiting output size due to an off-by-one error in selectByQuota().

Root cause:
- Items were added to selected array BEFORE checking if they would
  exceed the quota
- This meant the last item that exceeded quota was still included
- Prompt deduplication tracking was mutated during quota calculation,
  causing prompts to not render in final output

Fix:
- Check quota BEFORE adding items to selected array
- Always include at least one step even if it exceeds quota
- Calculate step lengths using actual rendering format
- Account for start section and separators in quota calculation
- Use temporary Set during length calculation to avoid mutating
  the prompt deduplication tracking

Tests:
- Added comprehensive test suite (thread-read-quota.test.ts)
- Covers quota enforcement, boundary conditions, edge cases
- Tests interaction with --before and --start flags
- All tests pass

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-25 00:58:30 +00:00
xiaoju 984e6ae56d fix: cas has now returns exit 1 for non-existent hashes
Changed the exit behavior of 'uwf cas has' to return exit code 1 when
a hash doesn't exist, while preserving the JSON output {exists:false}.
This enables proper use in shell conditionals like 'if uwf cas has $HASH'.

Fixes #481
2026-05-25 00:47:39 +00:00
xiaoju 92f3b36b10 chore: add uwf script for local dev testing
`bun run uwf -- <args>` runs the local version of uwf CLI,
useful in worktrees to test local changes vs the global install.
2026-05-25 00:03:10 +00:00
xiaoju a4677f8adb docs: sync README files with recent changes 2026-05-24 17:04:09 +00:00
xiaoju 9ab6291a41 fix(workflow): add --repo flag to tea pr create in worktree dirs
Fixes #474
2026-05-24 16:56:19 +00:00
xiaoju 50a4db72b1 fix(workflow): add check step to developer, clarify reviewer hard/soft checks
Developer procedure now requires running lint/build checks before committing.
Reviewer procedure clarified: hard checks (build/lint) must pass, style-only
suggestions should not block approval.

Fixes #477
2026-05-24 16:43:07 +00:00
xiaoju dfdf0ac073 fix(cli-workflow): resolve step/thread commands on completed threads
Fixed issue #469 where `uwf step list`, `uwf step show`, and `uwf thread read`
failed with "thread not active" error when called on completed threads.

The root cause was that resolveHeadHash() in shared.ts only checked threads.yaml
(active threads index) but never fell back to history.jsonl (completed threads log).

Changes:
- Updated resolveHeadHash() in shared.ts to check history.jsonl as fallback
- Changed error message from "thread not active" to "thread not found"
- Added comprehensive test coverage:
  - Unit tests for resolveHeadHash() with active/completed/missing threads
  - Integration tests for cmdStepList() with completed threads
  - Integration tests for cmdStepShow() with completed threads
  - Regression tests for cmdThreadRead() with completed threads

All commands now work identically for active and completed threads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-24 16:18:20 +00:00
xiaoju c2c849df7e fix(agent-kit): provide full thread context to first-time participating roles
When a role participates for the first time (e.g. committer), it previously
only received the system prompt + last step output, missing the full thread
history. This caused hallucination as the role had to guess what happened.

Changes:
- build-continuation-prompt.ts: detect first-time roles and include all
  steps' meta + content for last 2-3 steps (within quota)
- context.ts: add isFirstVisit detection helper
- types.ts: add isFirstVisit field to AgentContext
- hermes.ts: pass isFirstVisit through to prompt builder

Fixes #473
2026-05-24 15:56:39 +00:00
xiaoju 39f6ae692b feat(cli): add filtering and pagination to thread list command
Implements enhanced filtering and pagination for the `uwf thread list` command
to support workflows with large numbers of threads.

Changes:
- Add --page, --page-size parameters for pagination (default: page 1, size 20)
- Add --since, --until time filters supporting multiple formats (ISO8601, relative like "2h", "1d")
- Add --workflow filter to show threads for specific workflow
- Add --sort parameter (newest-first, oldest-first, alphabetical)
- Add pagination metadata in JSON output (page, pageSize, totalThreads, totalPages, hasMore)
- Implement parseRelativeTime() for human-friendly time expressions (1h, 30m, 2d, 1w)
- Add comprehensive unit tests for filters, pagination, and time parsing
- Update CLI help text with new parameters and examples

Fixes #471
2026-05-24 14:44:30 +00:00
xiaomo eb027e70f4 fix: include step content in continuation prompt (closes #466)
- Add `content: string | null` to RoleStep type
- Resolve contentHash → text for the last step when building ThreadContext
- Update buildAgentPrompt to include <output> tag with step content
- Add 16k content quota with truncation
- Update tests
2026-05-24 13:41:00 +00:00
xiaomo 8fbbbce07e Merge pull request 'chore: cleanup dead code and update CLI docs' (#468) from chore/cleanup-cli-docs into main 2026-05-24 11:42:36 +00:00
xiaoju f115718564 chore: cleanup dead code and update CLI docs
- Remove cmdThreadRunning dead code (CLI uses --status running now)
- Remove step read from README (command not registered)
- Update cli-reference.ts to reflect new four-layer commands

Refs #463
2026-05-24 11:41:02 +00:00
xiaomo 5c0eabda8e Merge pull request 'feat: restructure CLI commands (workflow/thread/step/turn)' (#467) from fix/463-http-methods into main 2026-05-24 11:37:50 +00:00
xiaoju 669af841e1 refactor: address review feedback for CLI restructure
- Extract shared module (shared.ts) — walkChain, expandDeep, etc. deduplicated
- Hide step read command (half-baked, not ready for users)
- Remove cmdThreadKill dead code
- Revert unrelated protocol type change
- Revert unrelated package.json change
- Fix unused imports (biome)

Refs #463
2026-05-24 11:32:47 +00:00
xiaoju 650313b1c2 feat(step): expand detail CAS refs by default in step list
Previously step list showed raw CAS refs for detail fields.
Now detail is recursively expanded (like output already was),
since every turn is individually hashed and walkable.

Refs #463
2026-05-24 11:12:22 +00:00
xiaoju c40007eeaf fix(agent-claude-code): add missing workflow-util dependency
The claude-code agent imports createLogger from @uncaged/workflow-util
but was missing the dependency declaration, causing test failures.
2026-05-24 11:04:02 +00:00
xiaoju 1f13b1e79c fix(cli): resolve lint errors and unused imports (#463)
Fix all lint errors flagged by biome check to ensure clean codebase.

## Changes

### Removed Unused Imports
- `packages/cli-workflow/src/commands/thread.ts`:
  - Removed `StartEntry` (moved to step.ts)
  - Removed `StepEntry` (moved to step.ts)
  - Removed `ThreadForkOutput` (moved to step.ts)
  - Removed `ThreadStepsOutput` (moved to step.ts)

- `packages/cli-workflow/src/cli.ts`:
  - Removed unused `yamlStringify` import from yaml package

### Fixed Unused Parameter
- `packages/cli-workflow/src/commands/step.ts`:
  - Prefixed unused `before` parameter with underscore in `cmdStepRead`
  - Parameter is part of the function signature for future use (awaiting #462)

### Fixed Import Order
- `packages/cli-workflow/src/__tests__/thread.test.ts`:
  - Reordered imports to follow biome's organization rules
  - Moved cmdStepShow import before cmdThreadRead imports

## Test Results
-  `bun run check` passes (typecheck + lint + log tags)
-  All 124 tests passing
-  Build completes successfully

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-24 10:50:49 +00:00
xiaoju 031c3aa632 docs(cli): add deprecation handlers and update documentation (#463)
Complete the CLI refactoring with deprecation error handlers, updated
help text, and comprehensive migration guide.

## Changes

### Deprecation Handlers
Add error handlers for all removed commands with helpful migration messages:
- `workflow put` → suggests `workflow add`
- `thread step` → suggests `thread exec`
- `thread steps` → suggests `step list`
- `thread step-details` → suggests `step show`
- `thread fork` → suggests `step fork`
- `thread kill` → suggests `thread stop` or `thread cancel`
- `thread running` → suggests `thread list --status running`

Error messages follow the format:
```
Error: Command 'X' has been removed.
Use 'Y' instead.

For more information, see: uwf help Y
```

### Help Documentation
Updated CLI help text to explain four-layer architecture:
- Main help shows architecture diagram with Chinese labels
- Command group descriptions reference layers:
  - `workflow` → "Workflow definitions (layer 1: templates)"
  - `thread` → "Thread execution (layer 2: instances)"
  - `step` → "Step results (layer 3: single cycle)"
- Deprecated commands appear in help with [DEPRECATED] tag

### README Updates
Comprehensive documentation updates:
- Added "Four-Layer Architecture" section with diagram
- Updated all command tables with new command names
- Added complete migration guide with:
  - Renamed commands table
  - Merged commands table
  - Split commands table
  - Moved commands table
  - Example deprecation error output
- Updated "Internal Structure" to show new step.ts module

## Testing
-  All 124 tests pass
-  Build completes successfully
-  Deprecation handlers tested manually
-  Help output verified for main, thread, and step commands

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-24 10:46:31 +00:00
xiaoju 7b50969307 refactor(cli): reorganize CLI commands into four-layer model (#463)
Implement comprehensive CLI refactoring to clarify the four-layer model:
workflow → thread → step → turn

## Breaking Changes

### Renamed Commands
- `uwf workflow put` → `uwf workflow add`
- `uwf thread step` → `uwf thread exec`

### Removed Commands
- `uwf thread running` (merged into `thread list --status running`)
- `uwf thread kill` (split into `thread stop` and `thread cancel`)

### Moved Commands
- `uwf thread steps` → `uwf step list`
- `uwf thread step-details` → `uwf step show`
- `uwf thread fork` → `uwf step fork`

## New Commands

### Thread Commands
- `uwf thread list --status <idle|running|completed>` - Filter threads by status
- `uwf thread stop <thread-id>` - Stop background execution (keep thread active)
- `uwf thread cancel <thread-id>` - Cancel thread (stop + archive to history)

### Step Command Group (New)
- `uwf step list <thread-id>` - List all steps in a thread
- `uwf step show <step-hash>` - Show step details
- `uwf step read <step-hash> [--before N]` - Read step output as markdown
- `uwf step fork <step-hash>` - Fork thread from a step

## Implementation Details

### Files Modified
- `packages/cli-workflow/src/commands/workflow.ts` - Renamed cmdWorkflowPut → cmdWorkflowAdd
- `packages/cli-workflow/src/commands/thread.ts`:
  - Renamed cmdThreadStep → cmdThreadExec
  - Added cmdThreadStop and cmdThreadCancel (split from cmdThreadKill)
  - Updated cmdThreadList to support --status filter with idle/running/completed
  - Removed cmdThreadSteps, cmdThreadStepDetails, cmdThreadFork
- `packages/cli-workflow/src/commands/step.ts` - New module with:
  - cmdStepList (moved from cmdThreadSteps)
  - cmdStepShow (moved from cmdThreadStepDetails)
  - cmdStepFork (moved from cmdThreadFork)
  - cmdStepRead (new, stub implementation pending #462)
- `packages/cli-workflow/src/cli.ts` - Updated all CLI command registrations

### Tests Updated
- `packages/cli-workflow/src/__tests__/thread-step-count.test.ts` - Updated references from "thread step" to "thread exec"
- `packages/cli-workflow/src/__tests__/thread.test.ts` - Updated imports to use cmdStepShow from step.ts

## Test Results
All 124 tests pass in cli-workflow package.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-24 10:40:32 +00:00
xiaoju fc6072c28c Merge pull request 'feat: use git worktree for isolated development in solve-issue workflow' (#465) from fix/464-worktree-isolation into main 2026-05-24 10:27:51 +00:00
xiaoju b0e3f4a363 feat: use git worktree for isolated development in solve-issue workflow
All roles (developer, reviewer, tester, committer) now work in
~/repos/workflow-worktrees/fix/<issue>-<slug> instead of modifying
the main working directory. Prevents self-destructive edits.

Fixes #464
2026-05-24 10:22:25 +00:00
xiaoju 38112053a0 Merge pull request 'fix(agent-kit): separate session cache per agent' (#462) from fix/461-per-agent-session-cache into main 2026-05-24 09:19:50 +00:00
xiaoju 1d174ee5c9 fix(agent-kit): separate session cache per agent
Each agent now maintains its own session cache file instead of sharing
a single agent-sessions.json. This prevents session ID conflicts when
multiple agents operate on the same thread+role pair.

Changes:
- getCachePath() now takes agentName parameter
- getCachedSessionId/setCachedSessionId require agentName as first param
- Cache files named <agent>-sessions.json (e.g., hermes-sessions.json)
- Agent wrappers inject their agent name into cache calls
- Add comprehensive tests for session cache isolation
- Handle malformed JSON gracefully (treat as empty cache)

Fixes #461
2026-05-24 09:16:06 +00:00
xiaoju 6e3b32ca34 Merge pull request 'fix(cli): replace markdown headings with XML tags in thread read output' (#460) from fix/459-xml-tag-isolation into main 2026-05-24 08:44:47 +00:00
xiaoju 932bbe5c41 fix(cli): replace markdown headings with XML tags in thread read output
Changed uwf thread read to wrap role prompts and agent outputs in XML tags
(<prompt> and <output>) instead of markdown headings (### Prompt, ### Content).
This prevents Claude Code from treating step outputs as structural headings.

- Updated formatStepPrompt to use <prompt>...</prompt> tags
- Updated formatStepContent to use <output>...</output> tags
- Added comprehensive test suite in thread-read-xml-tags.test.ts
- Updated existing tests to verify XML tag behavior

Fixes #459

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-24 08:04:34 +00:00
xiaomo 9440b9af82 Merge pull request 'chore: fix biome noExcessiveCognitiveComplexity warnings' (#458) from fix/444-biome-complexity-warnings into main 2026-05-24 07:30:41 +00:00
xiaoju f96d6eb7c4 refactor(agent-builtin): reduce cognitive complexity in loop.ts
Refactored runBuiltinLoop function to reduce cognitive complexity from 30 to below 15 by extracting helper functions:

- shouldInjectDeadlineWarning: checks if deadline warning should be shown
- shouldProcessToolCalls: determines if tool calls should be processed
- extractFinalText: extracts last assistant message content
- injectDeadlineWarning: injects deadline warning message
- handleTextOnlyTurn: handles text-only turn logic
- handleToolCallTurn: handles tool call turn logic
- processLoopIteration: processes a single loop iteration

Added 24 new unit tests for the extracted helper functions, bringing total test count to 41 (all passing). All existing behavior is preserved.

Fixes #444

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-24 05:53:55 +00:00
xiaomo 95102941f1 Merge pull request 'feat(cli): thread step --background + thread running' (#457) from fix/456-thread-step-background into main 2026-05-24 05:33:56 +00:00
xiaoju 521d908719 feat(cli): add background thread execution and running threads query
This commit implements issue #456, adding two related capabilities to the uwf CLI:

1. **Background execution mode** for `uwf thread step` (via `--background` flag)
   - Spawns agent execution in a detached background process
   - Returns immediately with thread ID and background status
   - Maintains marker files to track running processes
   - Supports `--count` option to run multiple steps in background
   - Prevents concurrent execution of the same thread

2. **Running threads query** command (`uwf thread running`)
   - Lists all threads currently executing in background
   - Returns thread ID, workflow, current role, PID, and start time
   - Automatically filters out stale markers (dead processes)
   - Empty list when no threads are running

**Key changes:**

- **workflow-protocol**: Added `RunningThreadItem`, `RunningThreadsOutput` types
  Updated `StepOutput` to include `background: boolean | null` field

- **cli-workflow/background**: New module for process management
  - Marker file creation/deletion (atomic operations)
  - PID liveness checking
  - Stale marker cleanup
  - Running threads query

- **cli-workflow/commands/thread**:
  - Updated `cmdThreadStep` to support `--background` and `--_background-worker` flags
  - Added `cmdThreadStepBackground` for spawning detached processes
  - Added `cmdThreadRunning` to list running threads
  - Updated `cmdThreadKill` to terminate background processes

- **cli-workflow/cli**: Added CLI routing for new commands and flags

**Integration:**
- `uwf thread kill` now terminates background processes before archiving
- Foreground execution checks for existing background process and fails if found
- Background worker creates/cleans up marker files automatically
- Marker files stored in `~/.uncaged/workflow/running/*.json`

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-24 05:28:29 +00:00
xiaoju 02a2c00175 refactor: replace UWF_EDGE_PROMPT env var with named CLI args
Agent adapters now use named parameters:
  uwf-<agent> --thread <id> --role <role> --prompt <text>

Instead of positional args + env var:
  UWF_EDGE_PROMPT=... uwf-<agent> <thread-id> <role>

Changes:
- workflow-agent-kit/src/run.ts: parseArgv uses named --thread/--role/--prompt
- workflow-agent-kit/src/context.ts: edgePrompt passed as parameter, not read from env
- cli-workflow/src/commands/thread.ts: spawnAgent passes named args

小橘 <xiaoju@shazhou.work>
2026-05-24 04:31:44 +00:00
xiaoju 8ca7708a12 fix: add cas_ref format to claude-code-detail turns schema
The turns array items in CLAUDE_CODE_DETAIL_SCHEMA were missing
format: 'cas_ref', so expandDeep in step-details couldn't resolve
turn hashes to their payloads. Hermes schema already had this.

小橘 <xiaoju@shazhou.work>
2026-05-24 04:17:29 +00:00
xiaomo 0fdc0fdec3 Merge pull request 'refactor(workflow-dashboard): reduce cyclomatic complexity in editor' (#455) from fix/449-reduce-dashboard-complexity into main 2026-05-24 03:44:08 +00:00
xiaomo d6eaf3fdc7 Merge pull request 'refactor: reduce cognitive complexity in session-detail and acp-client' (#454) from fix/448-reduce-complexity into main 2026-05-24 03:44:06 +00:00
xingyue 5dc2352ac5 fix(workflow-dashboard): replace optional properties with T | null in handlers.ts
Per CLAUDE.md convention, use `string | null` instead of `?:` in the
isFirstConditionalSibling helper function parameter types.

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
2026-05-24 00:52:54 +08:00
xingyue 39e2ab7f0d refactor(workflow-dashboard): reduce cyclomatic complexity in editor (#449)
- Extract helpers in assignLayers (bfsLayers, processTarget, placeIsolatedNodes, maxLayerExcludingEnd) to reduce complexity from 26 → ≤15
- Extract isProtectedNode and isFirstConditionalSibling helpers in onBeforeDelete (20 → ≤15)
- Extract handleEscape and handleUndoRedo in handleKeyDown (23 → ≤15)
- Extract buildNodeMap, sortTransitions, buildStepEdges, pushStepEdges, assignTargetHandles in transIn (33 → ≤15)
- Extract validateRoleNodeEdges and hasEmptyConditionOnIfEdge in validateRoleNodes (22 → ≤15)
- Remove unused state parameter from Form component in add-node.tsx
- Add vitest + 19 tests covering all refactored functions

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
2026-05-24 00:50:15 +08:00
xingyue 221919448e refactor: reduce cognitive complexity in session-detail and acp-client
Extract helper functions to bring parseClaudeCodeStreamOutput (37→≤15)
and handleSessionUpdate (24→≤15) within complexity limits. Add tests.

Fixes #448
2026-05-24 00:41:39 +08:00
xingyue 68b82c9574 style: use dot notation for process.env.CLAUDE_MODEL 2026-05-24 00:25:08 +08:00
xiaomo 335b8a4ae6 Merge pull request 'refactor(cli): reduce cognitive complexity in setup.ts' (#453) from fix/445-reduce-setup-complexity into main 2026-05-23 16:18:10 +00:00
xingyue bf31fa0d03 refactor(cli): reduce cognitive complexity in setup.ts
Extracts inline logic into focused helper functions to bring
each function under the complexity threshold.

Fixes #445
2026-05-24 00:14:15 +08:00
xiaomo c39f2f3e63 Merge pull request 'refactor(cli): reduce cognitive complexity in thread.ts' (#452) from fix/446-reduce-thread-complexity into main 2026-05-23 15:55:03 +00:00
xingyue 6481fc0cc5 refactor(cli): reduce cognitive complexity in thread.ts
Extract helper functions (resolveThreadId, getThreadHead, listThreadSteps,
displayStepDetails, displayThreadRead) to reduce nesting and improve
readability. Also adds test coverage for the refactored functions.

Fixes #446
2026-05-23 23:47:54 +08:00
xiaoju 3190e06ebe docs: add sync-readme rule for consistent README updates
小橘 🍊(NEKO Team)
2026-05-23 15:09:25 +00:00
xiaomo f8ae2fe25b Merge pull request 'docs: sync all README.md files with current codebase' (#451) from docs/sync-readme into main 2026-05-23 15:03:56 +00:00
xiaoju ffc31a8c19 docs: sync all README.md files with current codebase
- Root README: add all 9 packages to table, update architecture diagram,
  refresh CLI reference from uwf --help
- New READMEs for 8 packages (cli-workflow, workflow-protocol,
  workflow-moderator, workflow-agent-kit, workflow-agent-hermes,
  workflow-agent-builtin, workflow-agent-claude-code, workflow-dashboard)
- Updated workflow-util README to match current exports
- All API sections verified against src/index.ts exports

小橘 🍊(NEKO Team)
2026-05-23 15:00:05 +00:00
xingyue 48a274685b fix(builtin): nudge budget + deadline warning
- Nudge turns don't consume turn budget (up to MAX_NUDGES=3), prevents
  wasting agent work capacity on bookkeeping
- Inject deadline warning when 3 turns remain, telling agent to wrap up
- Agent can use status:failed to gracefully exit if it can't finish
2026-05-23 22:58:09 +08:00
xingyue 5b68359dfc fix #447: extract shouldNudge and export executeTurnTools from loop.ts, add tests 2026-05-23 22:45:09 +08:00
xingyue c2ddfb8558 fix(builtin): deadline warning + graceful exit on turn limit
- Inject user message when 3 turns remain, telling agent to wrap up
- Prompt tells agent to use status:failed if it can't finish in time
- Prevents wasting all turns without producing any frontmatter output
- Remove stale test file from dogfood agent run
2026-05-23 22:44:42 +08:00
xingyue 603018caf2 fix(builtin): force-strip tool_calls when noTools is set
copilot-api returns tool_calls even when tools field is omitted from
the request (infers from message history). Now the loop explicitly
nullifies tool_calls when noTools=true.
2026-05-23 22:35:20 +08:00
xiaomo aff0ee6fea Merge pull request 'fix(thread-read): remove ### Output section and deduplicate ### Prompt globally' (#442) from fix/440-thread-read-prompt-dedup into main 2026-05-23 14:15:40 +00:00
xiaomo d37fa1393a Merge pull request 'fix: preserve primary detail hash across frontmatter retries' (#443) from fix/439-detail-merge-and-acp into main 2026-05-23 14:14:53 +00:00
xiaoju 759c784267 fix: preserve primary detail hash across frontmatter retries
When the agent's first run output fails frontmatter extraction, the
retry loop (via options.continue) would replace agentResult entirely,
causing the 1-turn continuation detail to overwrite the original
multi-turn detail containing all tool-call history.

Now we capture primaryDetailHash from the first run and always use it
for the persisted StepNode, regardless of how many retries occur.

Fixes #439
2026-05-23 14:02:51 +00:00
192 changed files with 15807 additions and 2880 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@uncaged/workflow-util": patch
---
Replace optionalEnv/requireEnv with unified env(name, fallback) API
-5
View File
@@ -1,5 +0,0 @@
---
"@uncaged/workflow-protocol": patch
---
fix: correct internal dependency versions for prerelease
-5
View File
@@ -1,5 +0,0 @@
---
"@uncaged/workflow-util-agent": patch
---
fix: include create-agent-adapter.ts in published src
-5
View File
@@ -1,5 +0,0 @@
---
"@uncaged/workflow-protocol": patch
---
fix: use npm publish with pinned deps instead of bun publish (workspace:^ resolution bug)
-30
View File
@@ -1,30 +0,0 @@
{
"mode": "pre",
"tag": "alpha",
"initialVersions": {
"@uncaged/cli-workflow": "0.4.5",
"@uncaged/workflow-agent-cursor": "0.4.5",
"@uncaged/workflow-agent-hermes": "0.4.5",
"@uncaged/workflow-agent-llm": "0.4.5",
"@uncaged/workflow-agent-react": "0.4.5",
"@uncaged/workflow-cas": "0.4.5",
"@uncaged/workflow-dashboard": "0.1.0",
"@uncaged/workflow-execute": "0.4.5",
"@uncaged/workflow-gateway": "0.4.5",
"@uncaged/workflow-protocol": "0.4.5",
"@uncaged/workflow-reactor": "0.4.5",
"@uncaged/workflow-register": "0.4.5",
"@uncaged/workflow-runtime": "0.4.5",
"@uncaged/workflow-template-develop": "0.4.5",
"@uncaged/workflow-template-solve-issue": "0.4.5",
"@uncaged/workflow-util": "0.4.5",
"@uncaged/workflow-util-agent": "0.4.5"
},
"changesets": [
"env-api-unify",
"fix-internal-deps",
"fix-publish-src",
"fix-workspace-deps",
"rfc-252-agent-fn"
]
}
-5
View File
@@ -1,5 +0,0 @@
---
"@uncaged/workflow-protocol": minor
---
feat: AgentFn<Opt> type boundary and createAgentAdapter bridging function (RFC #252)
+2 -26
View File
@@ -1,27 +1,3 @@
---
description: Ban dynamic import() in production code — use static imports instead
globs: packages/*/src/**/*.ts
alwaysApply: true
---
# No Dynamic Import
# No Dynamic Import in Production Code
## Rule
Do NOT use `await import()` or dynamic `import()` expressions in production source code.
Always use static top-level `import` statements.
## Exception (must include a comment explaining why)
1. **Bundle loader** — loads user-authored workflow bundles whose paths are only known at runtime
When suppressing, add a comment directly above:
```ts
// Dynamic import required: user bundle path resolved at runtime
const mod = await import(bundlePath);
```
## Test Files
Test files (`__tests__/**`) are exempt.
See [docs/no-dynamic-import.md](../../docs/no-dynamic-import.md) for full rules.
+3
View File
@@ -0,0 +1,3 @@
# Sync Readme
See [docs/sync-readme.md](../../docs/sync-readme.md) for full rules.
+25
View File
@@ -0,0 +1,25 @@
name: CI
on:
push:
branches: ['*']
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install
- name: Check
run: bun run check
- name: Test
run: bun run test:ci
+31
View File
@@ -0,0 +1,31 @@
---
name: Bug Report
about: Report a bug or unexpected behavior
labels: bug
---
## Describe the bug
A clear description of what the bug is.
## To reproduce
Steps or commands to reproduce:
```bash
uwf ...
```
## Expected behavior
What you expected to happen.
## Actual behavior
What actually happened. Include error messages or logs.
## Environment
- OS:
- Bun version:
- uwf version (`uwf --version`):
+17
View File
@@ -0,0 +1,17 @@
---
name: Feature Request
about: Suggest a new feature or improvement
labels: enhancement
---
## What
Describe the feature or improvement.
## Why
Why is this needed? What problem does it solve?
## Proposed solution
How should it work? Include API sketches, CLI examples, or workflow YAML snippets if applicable.
+15
View File
@@ -0,0 +1,15 @@
## What
What this PR does.
## Why
Why the change is needed.
## Changes
- `path/to/file` — what changed and why
## Ref
Fixes #
+28
View File
@@ -0,0 +1,28 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- run: bun install --frozen-lockfile
- name: Build
run: bun run build
- name: Lint
run: bunx biome check .
- name: Test
run: bun run test:ci
+2 -1
View File
@@ -12,4 +12,5 @@ packages/workflow-template-develop/develop.esm.js
.DS_Store
*.py
.claude
tmp
tmp.worktrees/
.worktrees/
-83
View File
@@ -1,83 +0,0 @@
# Test Spec: uwf setup model connectivity validation (#335)
## Context
File: `packages/cli-workflow/src/commands/setup.ts`
Test file: `packages/cli-workflow/src/__tests__/setup-validate.test.ts`
After `cmdSetup` writes config, it should send a test chat completion request to verify the configured model is reachable. If validation fails, warn the user (don't abort — config is already saved).
## Implementation Notes
- Add a `validateModel(baseUrl, apiKey, model)` function that sends a minimal chat completion request (`POST /chat/completions` with `messages: [{role:"user",content:"hi"}]`, `max_tokens: 1`)
- Returns `Result<void, string>` — ok if 2xx response, error with reason string otherwise
- Use `AbortSignal.timeout(15_000)` for the request
- Both `cmdSetup` and `cmdSetupInteractive` should call it after saving config
- `cmdSetup` returns validation result in its return object: `{ ...existing, validation: { ok: true } | { ok: false, error: string } }`
- `cmdSetupInteractive` prints a warning to console if validation fails, success message if it passes
- Use the project logger (`createLogger`) — no raw `console.log` except in interactive CLI output (per CLAUDE.md)
## Test Cases (vitest)
### 1. `validateModel` — success path
- Mock `fetch` to return `{ status: 200, ok: true, json: () => ({}) }`
- Call `validateModel(baseUrl, apiKey, model)`
- Assert returns `{ ok: true, value: undefined }`
- Assert fetch was called with correct URL (`${baseUrl}/chat/completions`), correct headers (`Authorization: Bearer ${apiKey}`), correct body (model, messages, max_tokens: 1)
### 2. `validateModel` — HTTP error (401 unauthorized)
- Mock `fetch` to return `{ status: 401, ok: false, statusText: "Unauthorized" }`
- Call `validateModel(baseUrl, apiKey, model)`
- Assert returns `{ ok: false, error: <string containing "401"> }`
### 3. `validateModel` — HTTP error (404 model not found)
- Mock `fetch` to return `{ status: 404, ok: false, statusText: "Not Found" }`
- Assert returns `{ ok: false, error: <string containing "404"> }`
### 4. `validateModel` — network timeout
- Mock `fetch` to throw `DOMException` with name `AbortError`
- Assert returns `{ ok: false, error: <string containing "timeout" or "unreachable"> }`
### 5. `validateModel` — network error (DNS failure, connection refused)
- Mock `fetch` to throw `TypeError("fetch failed")`
- Assert returns `{ ok: false, error: <string mentioning connectivity> }`
### 6. `cmdSetup` — includes validation result on success
- Mock global `fetch` for `/chat/completions` to succeed
- Call `cmdSetup({ provider, baseUrl, apiKey, model, storageRoot })`
- Assert returned object has `validation: { ok: true, value: undefined }`
- Assert config files are still written (existing behavior preserved)
### 7. `cmdSetup` — includes validation result on failure (config still saved)
- Mock global `fetch` for `/chat/completions` to return 401
- Call `cmdSetup({ ... })`
- Assert returned object has `validation: { ok: false, error: ... }`
- Assert `config.yaml` and `.env` are still written (validation failure doesn't prevent saving)
### 8. `cmdSetupInteractive` — prints success message on validation pass
- Mock `fetch` for both `/models` and `/chat/completions` to succeed
- Mock stdin to provide valid selections
- Capture console output
- Assert output contains a success message like "Model verified" or "✓"
### 9. `cmdSetupInteractive` — prints warning on validation failure
- Mock `fetch`: `/models` succeeds, `/chat/completions` returns 401
- Mock stdin for valid selections
- Capture console output
- Assert output contains a warning about model not being reachable and suggests trying a different model
### 10. `validateModel` — request body correctness
- Mock `fetch` to capture the request body
- Call `validateModel(baseUrl, apiKey, "test-model")`
- Assert body is `{ model: "test-model", messages: [{role: "user", content: "hi"}], max_tokens: 1 }`
## Export Requirements
- `validateModel` must be exported (for direct unit testing)
- Signature: `async function validateModel(baseUrl: string, apiKey: string, model: string): Promise<Result<void, string>>`
- `Result` type: `{ ok: true; value: T } | { ok: false; error: E }` (project convention)
## Files to Create/Modify
- **New**: `packages/cli-workflow/src/__tests__/setup-validate.test.ts` — all test cases above
- **Modify**: `packages/cli-workflow/src/commands/setup.ts` — add `validateModel`, integrate into `cmdSetup` and `cmdSetupInteractive`
+269
View File
@@ -0,0 +1,269 @@
name: "e2e-walkthrough"
description: "End-to-end walkthrough of uwf CLI. Dogfooding: uwf tests uwf. Each role validates a phase of the CLI surface inside an isolated Docker container."
roles:
bootstrap:
description: "Start Docker container with isolated storage, verify uwf is runnable"
goal: "You are an E2E test runner. Set up an isolated Docker environment and verify basic uwf functionality."
capabilities:
- docker
- shell
procedure: |
1. Start a Docker container with isolated storage:
```
docker run -d --name uwf-e2e-$$ \
-v $HOME:$HOME \
-e HOME=$HOME \
-e UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage \
-w ~/repos/workflow \
node:22-bookworm \
sleep infinity
```
2. Inside the container, install bun, install deps, then `bun link` all packages
so that `uwf`, `uwf-hermes`, `uwf-builtin` are on PATH (from source):
```
docker exec uwf-e2e-$$ bash -c '
# Install bun
curl -fsSL https://bun.sh/install | bash
export PATH="$HOME/.bun/bin:$PATH"
# Isolated storage
mkdir -p $UNCAGED_WORKFLOW_STORAGE_ROOT
# Install workspace deps
cd ~/repos/workflow && bun install --frozen-lockfile
# bun link each package that has a bin entry
cd packages/cli-workflow && bun link && cd ../..
cd packages/workflow-agent-hermes && bun link && cd ../..
cd packages/workflow-agent-builtin && bun link && cd ../..
'
```
3. Verify all three commands are available inside the container:
```
docker exec uwf-e2e-$$ bash -c 'export PATH="$HOME/.bun/bin:$PATH" && uwf --version'
docker exec uwf-e2e-$$ bash -c 'export PATH="$HOME/.bun/bin:$PATH" && uwf-hermes --help'
docker exec uwf-e2e-$$ bash -c 'export PATH="$HOME/.bun/bin:$PATH" && uwf-builtin --help'
```
4. Copy host config if it exists:
```
docker exec uwf-e2e-$$ bash -c '
if [ -f $HOME/.uncaged/workflow/config.yaml ]; then
cp $HOME/.uncaged/workflow/config.yaml $UNCAGED_WORKFLOW_STORAGE_ROOT/config.yaml
fi
'
```
Report the container name and confirm uwf + agents are working.
Set containerName to the Docker container name for subsequent roles.
output: "Report uwf version and container readiness. Set $status to pass with containerName, or fail with error."
frontmatter:
oneOf:
- properties:
$status: { const: "pass" }
containerName: { type: string }
required: [$status, containerName]
- properties:
$status: { const: "fail" }
error: { type: string }
required: [$status, error]
config-and-registry:
description: "Validate uwf config commands and workflow registration"
goal: "You are an E2E test runner. Validate uwf config operations and workflow registration inside the Docker container."
capabilities:
- docker
- shell
procedure: |
Use the container from the previous step (containerName is in your prompt).
All commands run via: `docker exec <containerName> bash -c '...'`
All commands use `uwf` (installed via `bun link` inside the container).
Remember to set env vars in each exec:
export PATH="$HOME/.bun/bin:$PATH"
export UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage
Config tests:
1. `uwf config list` — verify it returns valid JSON
2. `uwf config set models.test.name test-model` — set a test key
3. `uwf config get models.test.name` — verify it returns "test-model"
Workflow registration tests:
4. `uwf workflow add ~/repos/workflow/examples/solve-issue.yaml` — register workflow
5. Verify the output contains a hash
6. `uwf workflow list` — verify non-empty array
7. Capture the workflow name from the list
8. `uwf workflow show <name>` — verify it returns roles
Report all test results with pass/fail counts.
output: "Report test results. Set $status to pass (with workflowName and containerName) or fail."
frontmatter:
oneOf:
- properties:
$status: { const: "pass" }
workflowName: { type: string }
containerName: { type: string }
required: [$status, workflowName, containerName]
- properties:
$status: { const: "fail" }
error: { type: string }
containerName: { type: string }
required: [$status, error, containerName]
thread-ops:
description: "Test thread start, list, show, and exec"
goal: "You are an E2E test runner. Validate thread creation and execution inside the Docker container."
capabilities:
- docker
- shell
procedure: |
Use the container (containerName) and workflow (workflowName) from your prompt.
All commands via: `docker exec <containerName> bash -c '...'`
Set env: PATH="$HOME/.bun/bin:$PATH" UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage
1. `uwf thread start <workflowName> -p 'E2E test: what is 2+2?'` — capture thread ID from JSON output
2. `uwf thread list` — verify the thread appears in the list
3. `uwf thread show <threadId>` — verify head pointer exists
4. `uwf thread exec <threadId> --agent uwf-builtin` — execute one step
5. Verify exec returns JSON with a head field
Report results. Pass threadId and containerName forward.
output: "Report test results. Set $status to pass (with threadId, workflowName, containerName) or fail."
frontmatter:
oneOf:
- properties:
$status: { const: "pass" }
threadId: { type: string }
workflowName: { type: string }
containerName: { type: string }
required: [$status, threadId, workflowName, containerName]
- properties:
$status: { const: "fail" }
error: { type: string }
containerName: { type: string }
required: [$status, error, containerName]
inspect:
description: "Test step list/show, thread read, and CAS operations"
goal: "You are an E2E test runner. Validate read and inspect operations inside the Docker container."
capabilities:
- docker
- shell
procedure: |
Use the container (containerName) and threadId from your prompt.
All commands via: `docker exec <containerName> bash -c '...'`
Set env: PATH="$HOME/.bun/bin:$PATH" UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage
Step inspection:
1. `uwf step list <threadId>` — verify steps array has length > 1
2. Capture the last step hash from the output
3. `uwf step show <lastStepHash>` — verify it returns a role field
Thread read:
4. `uwf thread read <threadId>` — verify non-empty output
CAS operations:
5. `uwf cas get <lastStepHash>` — verify returns a type field
6. `uwf cas has <lastStepHash>` — verify exits 0
7. `uwf cas refs <lastStepHash>` — list refs (may be empty)
8. `uwf cas walk <lastStepHash>` — verify returns non-empty array
Report results. Pass threadId, lastStepHash, workflowName, containerName forward.
output: "Report test results. Set $status to pass (with threadId, lastStepHash, workflowName, containerName) or fail."
frontmatter:
oneOf:
- properties:
$status: { const: "pass" }
threadId: { type: string }
lastStepHash: { type: string }
workflowName: { type: string }
containerName: { type: string }
required: [$status, threadId, lastStepHash, workflowName, containerName]
- properties:
$status: { const: "fail" }
error: { type: string }
containerName: { type: string }
required: [$status, error, containerName]
cancel-and-fork:
description: "Test thread cancel, step fork, and log inspection"
goal: "You are an E2E test runner. Validate cancel, fork, and log operations inside the Docker container."
capabilities:
- docker
- shell
procedure: |
Use containerName, threadId, lastStepHash, and workflowName from your prompt.
All commands via: `docker exec <containerName> bash -c '...'`
Set env: PATH="$HOME/.bun/bin:$PATH" UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage
Cancel:
1. Start a second thread: `uwf thread start <workflowName> -p 'E2E cancel test'`
2. Cancel it: `uwf thread cancel <secondThreadId>`
3. Verify it appears in completed list: `uwf thread list --status completed`
Fork:
4. Fork from the first thread's last step: `uwf step fork <lastStepHash>`
5. Verify fork creates a new thread with a different ID
Logs:
6. `uwf log list` — verify output (may be empty)
7. `uwf log show --thread <threadId>` — verify runs without error
Report results with summary.
output: "Report test results with summary. Set $status to pass or fail."
frontmatter:
oneOf:
- properties:
$status: { const: "pass" }
containerName: { type: string }
summary: { type: string }
required: [$status, containerName, summary]
- properties:
$status: { const: "fail" }
error: { type: string }
containerName: { type: string }
required: [$status, error, containerName]
cleanup:
description: "Remove Docker container"
goal: "You are an E2E test runner. Clean up the Docker container used for testing."
capabilities:
- docker
- shell
procedure: |
Remove the Docker container (containerName is in your prompt):
1. `docker rm -f <containerName>`
2. Verify the container is gone: `docker ps -a --filter name=<containerName> --format '{{.Names}}'` should return empty
Report cleanup result.
output: "Report cleanup result. Set $status to pass or fail."
frontmatter:
oneOf:
- properties:
$status: { const: "pass" }
summary: { type: string }
required: [$status, summary]
- properties:
$status: { const: "fail" }
error: { type: string }
required: [$status, error]
graph:
$START:
_: { role: "bootstrap", prompt: "Set up the Docker container and verify uwf is runnable." }
bootstrap:
pass: { role: "config-and-registry", prompt: "Container {{{containerName}}} is ready. Validate config and workflow registration." }
fail: { role: "$END", prompt: "Bootstrap failed: {{{error}}}. No container was created." }
config-and-registry:
pass: { role: "thread-ops", prompt: "Config and registry OK. Workflow '{{{workflowName}}}' registered. Container: {{{containerName}}}. Now test thread operations." }
fail: { role: "cleanup", prompt: "Config/registry failed: {{{error}}}. Clean up container {{{containerName}}}." }
thread-ops:
pass: { role: "inspect", prompt: "Thread ops OK. threadId={{{threadId}}}, workflowName={{{workflowName}}}, containerName={{{containerName}}}. Now test inspect operations." }
fail: { role: "cleanup", prompt: "Thread ops failed: {{{error}}}. Clean up container {{{containerName}}}." }
inspect:
pass: { role: "cancel-and-fork", prompt: "Inspect OK. threadId={{{threadId}}}, lastStepHash={{{lastStepHash}}}, workflowName={{{workflowName}}}, containerName={{{containerName}}}. Now test cancel, fork, and logs." }
fail: { role: "cleanup", prompt: "Inspect failed: {{{error}}}. Clean up container {{{containerName}}}." }
cancel-and-fork:
pass: { role: "cleanup", prompt: "All tests passed! {{{summary}}}. Clean up container {{{containerName}}}." }
fail: { role: "cleanup", prompt: "Cancel/fork failed: {{{error}}}. Clean up container {{{containerName}}}." }
cleanup:
pass: { role: "$END", prompt: "E2E walkthrough complete. {{{summary}}}" }
fail: { role: "$END", prompt: "Cleanup failed: {{{error}}}. Manual cleanup may be needed." }
+114 -113
View File
@@ -10,9 +10,9 @@ roles:
procedure: |
On first run (no previous steps):
1. Read the issue and all comments from Gitea using `tea issues <number> -r <owner/repo>`
2. Read CLAUDE.md (or equivalent project conventions file) to understand coding standards
2. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
3. Assess whether the issue has enough information to produce a test spec
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output status=insufficient_info and terminate
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output $status=insufficient_info
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
On subsequent runs (bounced back by tester with fix_spec):
@@ -21,44 +21,61 @@ roles:
After producing the test spec:
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
2. Put the hash in frontmatter.plan (required when status=ready)
output: "Output a brief summary of the test spec. Frontmatter must include: status (ready or insufficient_info) and plan (CAS hash of the test spec, required when status=ready)."
2. Put the hash in frontmatter.plan (required when $status=ready)
3. Set repoPath to the absolute path of the repository root
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
frontmatter:
type: object
properties:
status:
type: string
enum: [ready, insufficient_info]
plan:
type: string
required: [status]
oneOf:
- properties:
$status: { const: "ready" }
plan: { type: string }
repoPath: { type: string }
required: [$status, plan, repoPath]
- properties:
$status: { const: "insufficient_info" }
required: [$status]
developer:
description: "TDD implementation per test spec"
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
capabilities:
- coding
procedure: |
Before starting any work, ensure a clean worktree:
1. `git checkout main && git pull` to get the latest code
2. `git checkout -b fix/<issue-number>-<short-description>` to create a fresh branch
- If bounced back from reviewer or tester, reuse the existing branch and rebase onto latest main:
`git checkout main && git pull && git checkout <branch> && git rebase main`
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
The repo path and other details are provided in your task prompt.
Before starting any work, set up an isolated worktree:
1. cd into the repo path provided in your task prompt
2. `git fetch origin` to get latest refs
3. First time (no existing branch):
- `git worktree add .worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
- `cd .worktrees/fix/<issue-number>-<short-slug> && bun install`
4. If bounced back from reviewer or tester (branch already exists):
- cd into the existing worktree under `.worktrees/fix/<issue-number>-<short-slug>`
- `git fetch origin && git rebase origin/main`
5. ALL subsequent work must happen inside the worktree directory.
Then implement TDD:
3. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
4. If bounced back from reviewer or tester: read the previous role's output to understand what needs fixing
5. Write tests first based on the spec
6. Implement the code to make tests pass
7. Ensure `bun run build` passes with no errors
8. Run `bun test` to verify all tests pass
output: "List all files changed and provide a summary. Frontmatter must include: status (done or failed)."
6. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner's output in your task prompt)
7. If bounced back from reviewer or tester: read the previous role's feedback in your task prompt
8. Write tests first based on the spec
9. Implement the code to make tests pass
10. Ensure `bun run build` passes with no errors
11. Run `bun test` to verify all tests pass
If you cannot complete the implementation (e.g. the issue is too complex, blocked by external factors,
or repeated attempts fail), set $status=failed with a reason.
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
frontmatter:
type: object
properties:
status:
type: string
enum: [done, failed]
required: [status]
oneOf:
- properties:
$status: { const: "done" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "failed" }
reason: { type: string }
required: [$status, reason]
reviewer:
description: "Code standards compliance check"
goal: "You are a code reviewer. You verify code standards compliance — NOT functionality (that's the tester's job)."
@@ -66,6 +83,8 @@ roles:
- code-review
- static-analysis
procedure: |
The worktree path is provided in your task prompt. cd into it first.
Before reviewing, verify the git branch:
1. Run `git branch --show-current` — confirm the branch name references the issue number being worked on
2. If the branch doesn't correspond to the issue, flag it in your output and reject
@@ -76,122 +95,104 @@ roles:
4. `bunx biome check` — no lint violations
5. TypeScript strict mode — no type errors
Soft checks (review against CLAUDE.md conventions):
- Functional-first: `function` + `type`, not `class` + `interface`
- No optional properties (`?:`) — use `T | null`
- Naming conventions (kebab-case files, PascalCase types, camelCase functions)
- Module boundary discipline (folder exports via index.ts)
- No `console.log` (use structured logger)
Soft checks (review against project conventions if CLAUDE.md / .cursor/rules exist):
- Naming conventions, module boundaries, code style
- No `console.log` in production code
- No dynamic imports in production code
Only review standards compliance. Do NOT test functionality.
If rejecting, you MUST explain the specific reason in your output.
output: "Explain your decision with specific file/line references. Frontmatter must include: approved (true or false)."
output: "Explain your decision with specific file/line references. Set $status to approved (with branch/worktree) or rejected (with comments)."
frontmatter:
type: object
properties:
approved:
type: boolean
required: [approved]
oneOf:
- properties:
$status: { const: "approved" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "rejected" }
comments: { type: string }
worktree: { type: string }
required: [$status, comments, worktree]
tester:
description: "Functional correctness verification"
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
capabilities:
- testing
procedure: |
The worktree path is provided in your task prompt. cd into it first.
1. Run `bun test` for automated test verification
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner step in the thread history)
3. Verify each scenario in the spec is covered and passing
4. Determine outcome:
- passed: all scenarios verified, tests pass
- fix_code: tests fail or implementation doesn't match spec → send back to developer
- fix_spec: the spec itself is wrong or incomplete → send back to planner
output: "Report test results per scenario. Frontmatter must include: status (passed, fix_code, or fix_spec)."
output: "Report test results per scenario. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
frontmatter:
type: object
properties:
status:
type: string
enum: [passed, fix_code, fix_spec]
required: [status]
oneOf:
- properties:
$status: { const: "passed" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "fix_code" }
report: { type: string }
required: [$status, report]
- properties:
$status: { const: "fix_spec" }
report: { type: string }
required: [$status, report]
committer:
description: "Commits and creates PR"
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
capabilities: []
procedure: |
The worktree path, branch name, and repo info are provided in your task prompt.
cd into the worktree first.
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
1. Stage all changes: `git add -A`
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
3. Push the branch: `git push -u origin <branch-name>`
- If push hook fails: capture the error log in your output, mark hook_failed
4. On push success: create a PR via `tea pr create --title "..." --description "..."`
- PR description must follow the project template: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
output: "Include PR URL on success or error log on failure. Frontmatter must include: success (true or false)."
4. On push success: create a PR via `tea pr create --repo <owner/repo> --title "..." --description "..."`
- Extract owner/repo from: `git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/'`
- PR description must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
- On tea failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed
5. After PR creation, clean up the worktree:
- cd to the repo root (parent of .worktrees)
- `git worktree remove <worktree-path>`
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
frontmatter:
type: object
properties:
success:
type: boolean
required: [success]
conditions:
insufficientInfo:
description: "Planner determined there's not enough info to proceed"
expression: "$last('planner').status = 'insufficient_info'"
devFailed:
description: "Developer failed to implement"
expression: "$last('developer').status = 'failed'"
rejected:
description: "Reviewer rejected the implementation"
expression: "$last('reviewer').approved = false"
fixCode:
description: "Tester found code issues"
expression: "$last('tester').status = 'fix_code'"
fixSpec:
description: "Tester found spec issues"
expression: "$last('tester').status = 'fix_spec'"
hookFailed:
description: "Push hook failed"
expression: "$last('committer').success = false"
oneOf:
- properties:
$status: { const: "committed" }
prUrl: { type: string }
required: [$status, prUrl]
- properties:
$status: { const: "hook_failed" }
error: { type: string }
required: [$status, error]
graph:
$START:
- role: "planner"
condition: null
prompt: "Analyze the issue and produce an implementation plan."
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
planner:
- role: "$END"
condition: "insufficientInfo"
prompt: "Insufficient information to proceed; end the workflow."
- role: "developer"
condition: null
prompt: "Implement the plan from the planner."
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}." }
developer:
- role: "$END"
condition: "devFailed"
prompt: "Development failed; end the workflow."
- role: "reviewer"
condition: null
prompt: "Send the implementation to the reviewer."
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
reviewer:
- role: "developer"
condition: "rejected"
prompt: "Reviewer rejected the implementation; fix the issues."
- role: "tester"
condition: null
prompt: "Review passed; run tests on the implementation."
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in repo {{{worktree}}}." }
approved: { role: "tester", prompt: "Review passed. Run tests on branch {{{branch}}} at {{{worktree}}}." }
tester:
- role: "developer"
condition: "fixCode"
prompt: "Tests found code issues; return to developer."
- role: "planner"
condition: "fixSpec"
prompt: "Tests found spec issues; return to planner."
- role: "committer"
condition: null
prompt: "Tests passed; commit and push the changes."
fix_code: { role: "developer", prompt: "Tests found code issues: {{{report}}}. Fix and re-submit." }
fix_spec: { role: "planner", prompt: "Tests found spec issues: {{{report}}}. Revise the test spec." }
passed: { role: "committer", prompt: "All tests passed. Commit and push branch {{{branch}}} from {{{worktree}}}." }
committer:
- role: "developer"
condition: "hookFailed"
prompt: "Push hook failed; return to developer to fix."
- role: "$END"
condition: null
prompt: "Commit succeeded; complete the workflow."
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow complete." }
+10 -6
View File
@@ -8,10 +8,10 @@ This monorepo implements a stateless workflow engine driven by a single-step CLI
| Concept | What it is |
|---------|-----------|
| **Workflow** | A YAML definition (`WorkflowPayload`) with roles, conditions, and a routing graph. Stored as a CAS node, identified by its XXH64 hash. |
| **Workflow** | A YAML definition (`WorkflowPayload`) with roles, status-based routing, and a directed graph. Stored as a CAS node, identified by its XXH64 hash. |
| **Thread** | A single execution of a workflow, identified by a ULID. State is an immutable CAS chain; active threads indexed in `threads.yaml`; completed threads in `history.jsonl`. |
| **Role** | A named actor within a workflow. Each role has a system prompt and a JSON Schema `outputSchema`. |
| **Moderator** | JSONata-based graph evaluator — determines the next role (or `$END`) with zero LLM cost. |
| **Moderator** | Status-based graph evaluator — determines the next role (or `$END`) with zero LLM cost. |
| **Agent** | An external CLI command (`uwf-hermes`, etc.) spawned by `uwf thread step`. Produces frontmatter markdown output. |
| **CAS** | Content-Addressed Storage via `@uncaged/json-cas` — all workflow definitions, thread nodes, and outputs are immutable CAS nodes. |
| **Registry** | `~/.uncaged/workflow/registry.yaml` — maps workflow names to current CAS hashes. |
@@ -23,10 +23,9 @@ workflow/
packages/
workflow-protocol/ # @uncaged/workflow-protocol — shared types (WorkflowPayload, StepNodePayload, WorkflowConfig, etc.)
workflow-util/ # @uncaged/workflow-util — Crockford Base32, ULID, logger, frontmatter parsing/validation
workflow-moderator/ # @uncaged/workflow-moderator — JSONata graph evaluator
workflow-agent-kit/ # @uncaged/workflow-agent-kit — createAgent factory, context builder, extract pipeline
workflow-util-agent/ # @uncaged/workflow-util-agent — createAgent factory, context builder, extract pipeline
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI binary (spawns hermes chat)
cli-workflow/ # @uncaged/cli-workflow — uwf CLI binary
cli-workflow/ # @uncaged/cli-workflow — uwf CLI binary (includes status-based moderator in src/moderator/)
legacy-packages/ # Archived packages (preserved for reference, not active)
examples/ # Workflow YAML examples (solve-issue.yaml)
docs/ # Architecture docs
@@ -34,7 +33,7 @@ workflow/
tsconfig.json # root TypeScript config
```
- Dependency layers: `workflow-protocol`(`workflow-util`, `workflow-moderator`) → `workflow-agent-kit``workflow-agent-hermes` / `cli-workflow`
- Dependency layers: `workflow-protocol``workflow-util` `workflow-util-agent``workflow-agent-hermes` / `cli-workflow`
- Packages use `workspace:^` protocol (resolves to `^x.y.z` on publish)
- External CAS: `@uncaged/json-cas` (store API, hashing, schema validation) + `@uncaged/json-cas-fs` (filesystem backend)
@@ -285,6 +284,11 @@ moderator → agent → extract — one step per invocation, repeat until $
2. **Register**`uwf workflow put <file.yaml>` parses YAML, registers output schemas, stores `WorkflowPayload` in CAS
3. **Run**`uwf thread start` creates a thread, `uwf thread step` executes one cycle per invocation
## Project Rules
- [docs/sync-readme.md](docs/sync-readme.md) — README sync conventions
- [docs/no-dynamic-import.md](docs/no-dynamic-import.md) — no dynamic import in production code
## Commit Convention
```
+109
View File
@@ -0,0 +1,109 @@
# Contributing to @uncaged/workflow
Thank you for your interest in contributing! This guide covers setup, conventions, and the PR workflow.
## Prerequisites
- [Bun](https://bun.sh/) (latest)
- [Node.js](https://nodejs.org/) 20+
- Git
## Setup
```bash
git clone https://github.com/shazhou-ww/uncaged-workflow.git
cd uncaged-workflow
bun install
bun run build
bun test
```
## Development Workflow
```bash
bun run build # TypeScript compilation (all packages)
bun run check # tsc + biome lint + log tag validation
bun run format # Auto-format with Biome
bun test # Run all tests
```
All three (`build`, `check`, `test`) must pass before submitting a PR. A pre-push hook runs `check` + `test` automatically.
## Coding Conventions
See [CLAUDE.md](CLAUDE.md) for the full coding standard. Key points:
- **Functional-first** — `function` + `type`, not `class` + `interface`
- **No optional properties** — use `T | null` instead of `?:`
- **Named exports only** — no default exports
- **No `console.log`** — use the structured logger from `@uncaged/workflow-util`
- **Static imports only** — no `await import()` in production code
- **Biome** for lint + format — run `bun run check` before committing
## Commit Messages
```
<type>(<scope>): <description>
type: feat | fix | refactor | docs | chore | test
scope: cli | moderator | agent-kit | hermes | builtin | claude-code | util | protocol | dashboard
```
Examples:
- `feat(moderator): add cycle detection to graph evaluator`
- `fix(cli): handle missing config file gracefully`
- `docs(protocol): update StepNode field descriptions`
## Pull Request Process
1. **Branch** from `main`: `git checkout -b feat/123-short-description`
2. **Implement** your change with tests
3. **Run checks**: `bun run check && bun test`
4. **Commit** with a descriptive message referencing the issue: `Fixes #123`
5. **Push** and open a PR
### PR Description Template
```
## What
What this PR does.
## Why
Why the change is needed.
## Changes
- `path/to/file.ts` — what changed and why
## Ref
Fixes #N
```
## Adding a Changeset
For any user-facing change (feat, fix, breaking change), add a changeset:
```bash
bun changeset
```
This creates a markdown file in `.changeset/` describing the change. It will be consumed on the next release to bump versions and generate CHANGELOG entries.
## Project Structure
```
packages/
workflow-protocol/ # Shared types and JSON Schema
workflow-util/ # Encoding, IDs, logging, frontmatter
workflow-util-agent/ # createAgent factory, extract pipeline
workflow-agent-hermes/ # Hermes ACP agent
workflow-agent-builtin/ # Built-in LLM agent
workflow-agent-claude-code/ # Claude Code agent
cli-workflow/ # uwf CLI binary
workflow-dashboard/ # Web UI (private, alpha)
```
Dependency flows downward — lower layers have no dependency on higher layers. See [CLAUDE.md](CLAUDE.md) for the full architecture.
## License
By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Uncaged
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+77 -55
View File
@@ -1,93 +1,115 @@
# @uncaged/workflow
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions with roles, JSONata routing conditions, and a directed graph. Threads are immutable CAS-linked chains — each `uwf thread step` runs one moderator→agent→extract cycle and exits.
[![CI](https://github.com/shazhou-ww/uncaged-workflow/actions/workflows/ci.yml/badge.svg)](https://github.com/shazhou-ww/uncaged-workflow/actions/workflows/ci.yml)
[![npm](https://img.shields.io/npm/v/@uncaged/cli-workflow?label=%40uncaged%2Fcli-workflow)](https://www.npmjs.com/package/@uncaged/cli-workflow)
[![npm](https://img.shields.io/npm/v/@uncaged/workflow-protocol?label=%40uncaged%2Fworkflow-protocol)](https://www.npmjs.com/package/@uncaged/workflow-protocol)
[![npm](https://img.shields.io/npm/v/@uncaged/workflow-util-agent?label=%40uncaged%2Fworkflow-util-agent)](https://www.npmjs.com/package/@uncaged/workflow-util-agent)
## Package Map
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions with roles, status-based routing, and a directed graph. Threads are immutable CAS-linked chains — each `uwf thread step` runs one moderator→agent→extract cycle and exits.
| Package | npm | Role |
|---------|-----|------|
| `cli-workflow` | `@uncaged/cli-workflow` | `uwf` CLI binary — thread lifecycle, workflow registry, CAS inspection, setup |
| `workflow-protocol` | `@uncaged/workflow-protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `WorkflowConfig`, etc.) |
| `workflow-moderator` | `@uncaged/workflow-moderator` | JSONata graph evaluator — determines next role or `$END` |
| `workflow-agent-kit` | `@uncaged/workflow-agent-kit` | `createAgent` factory, context builder, two-layer extract pipeline |
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | `uwf-hermes` agent — spawns Hermes chat, captures session |
| `workflow-util` | `@uncaged/workflow-util` | Crockford Base32, ULID, logger, frontmatter parsing |
## Overview
External: [`@uncaged/json-cas`](https://www.npmjs.com/package/@uncaged/json-cas) (CAS store + JSON Schema validation) + `@uncaged/json-cas-fs` (filesystem backend).
This monorepo implements **uwf**, a workflow engine with no long-running daemon. You register YAML workflow definitions in a content-addressed store (CAS), start a thread with an initial prompt, then invoke `uwf thread step` repeatedly until the moderator routes to `$END`. Each step is a complete process: the moderator evaluates status-based routing to pick the next role, an external agent CLI produces frontmatter markdown output, and an extract pipeline validates or structures that output against the role's JSON Schema.
Workflow state lives entirely on disk under `~/.uncaged/workflow/`: CAS nodes for definitions and step payloads, `registry.yaml` for workflow name→hash mappings, and `threads.yaml` for active thread head pointers. Completed threads are archived to `history.jsonl`. Because there is no server process, workflows are easy to debug, fork, and inspect with ordinary CLI tools.
Agents are pluggable CLI binaries (`uwf-hermes`, `uwf-builtin`, `uwf-claude-code`, or custom commands). The engine spawns the configured agent with `<thread-id>` and `<role>`, sets `UWF_EDGE_PROMPT` from the graph transition, and captures both the agent's markdown output and a detail CAS node for session replay.
## Install
```bash
npm install -g @uncaged/cli-workflow
```
Requires [Bun](https://bun.sh/) runtime (used internally for TypeScript execution).
## Quick Start
```bash
# 1. Configure provider and model
# 1. Configure provider, model, and default agent
uwf setup
# 2. Register a workflow from YAML
uwf workflow put examples/solve-issue.yaml
uwf workflow add examples/solve-issue.yaml
# 3. Start a thread
# 3. Start a thread (creates head pointer; does not execute)
uwf thread start solve-issue -p "Fix the login redirect bug"
# 4. Execute steps (one at a time, until done)
uwf thread step <thread-id>
uwf thread exec <thread-id>
```
## CLI Commands
Use `-c, --count <number>` on `thread exec` to run multiple steps in one invocation. Override the agent with `--agent <cmd>`.
### Thread
## Architecture
| Command | Description |
|---------|-------------|
| `uwf thread start <workflow> -p <prompt>` | Create a thread (no execution) |
| `uwf thread step <thread-id> [--agent <cmd>]` | Execute one moderator→agent→extract cycle |
| `uwf thread show <thread-id>` | Show head pointer and done status |
| `uwf thread list [--all]` | List threads (`--all` includes archived) |
| `uwf thread steps <thread-id>` | List all steps chronologically |
| `uwf thread read <thread-id> [--quota N]` | Render thread as readable markdown |
| `uwf thread fork <step-hash>` | Fork from a specific step |
| `uwf thread step-details <step-hash>` | Dump full detail node |
| `uwf thread kill <thread-id>` | Terminate and archive |
Dependency layers (lower layers have no dependency on higher layers):
### Workflow
```
Layer 0 — Contract
workflow-protocol Shared types and JSON Schema definitions
| Command | Description |
|---------|-------------|
| `uwf workflow put <file.yaml>` | Register a workflow from YAML |
| `uwf workflow show <name-or-hash>` | Show workflow definition |
| `uwf workflow list` | List registered workflows |
Layer 1 — Shared infra
workflow-util Encoding, IDs, logging, frontmatter, paths
### CAS
Layer 2 — Agent framework
workflow-util-agent createAgent factory, context builder, extract pipeline
| Command | Description |
|---------|-------------|
| `uwf cas get <hash>` | Read a CAS node |
| `uwf cas put <type-hash> <data>` | Store a node |
| `uwf cas has <hash>` | Check existence |
| `uwf cas refs <hash>` | List direct references |
| `uwf cas walk <hash>` | Recursive traversal |
| `uwf cas reindex` | Rebuild type index |
| `uwf cas schema list` | List schemas |
| `uwf cas schema get <hash>` | Show a schema |
Layer 3 — Agent implementations
workflow-agent-hermes Hermes ACP agent (uwf-hermes)
workflow-agent-builtin Built-in LLM + tools agent (uwf-builtin)
workflow-agent-claude-code Claude Code agent (uwf-claude-code)
### Setup
Layer 4 — CLI
cli-workflow uwf binary — thread lifecycle, registry, CAS, setup (includes status-based moderator)
| Command | Description |
|---------|-------------|
| `uwf setup` | Interactive provider/model/agent configuration |
| `uwf setup --provider ... --base-url ... --api-key ... --model ...` | Non-interactive setup |
App (uses protocol; not in the runtime engine stack)
workflow-dashboard Web UI for visual workflow editing
```
Config stored in `~/.uncaged/workflow/config.yaml`. API keys in `~/.uncaged/workflow/.env`.
External CAS: [`@uncaged/json-cas`](https://www.npmjs.com/package/@uncaged/json-cas) (store API, hashing, schema validation) + `@uncaged/json-cas-fs` (filesystem backend).
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, CAS node types, storage layout, agent CLI protocol, and design decisions.
## Packages
| Package | npm | Description | Type | README |
|---------|-----|-------------|------|--------|
| `cli-workflow` | `@uncaged/cli-workflow` | `uwf` CLI — thread lifecycle, workflow registry, CAS inspection, setup | cli | [README](packages/cli-workflow/README.md) |
| `workflow-protocol` | `@uncaged/workflow-protocol` | Shared TypeScript types and JSON Schema constants | lib | [README](packages/workflow-protocol/README.md) |
| `workflow-util-agent` | `@uncaged/workflow-util-agent` | `createAgent` factory, context builder, extract pipeline | lib | [README](packages/workflow-util-agent/README.md) |
| `workflow-util` | `@uncaged/workflow-util` | Crockford Base32, ULID, logger, frontmatter parsing, storage paths | lib | [README](packages/workflow-util/README.md) |
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | `uwf-hermes` — spawns Hermes chat via ACP | agent | [README](packages/workflow-agent-hermes/README.md) |
| `workflow-agent-builtin` | `@uncaged/workflow-agent-builtin` | `uwf-builtin` — built-in LLM agent with file/shell tools | agent | [README](packages/workflow-agent-builtin/README.md) |
| `workflow-agent-claude-code` | `@uncaged/workflow-agent-claude-code` | `uwf-claude-code` — spawns Claude Code CLI | agent | [README](packages/workflow-agent-claude-code/README.md) |
| `workflow-dashboard` | `@uncaged/workflow-dashboard` | Web graph editor for workflow YAML (private, alpha) | app | [README](packages/workflow-dashboard/README.md) |
## CLI Reference
Global options: `-V, --version`, `--format <json|yaml>`, `-h, --help`.
| Group | Commands |
|-------|----------|
| **thread** | `start`, `exec`, `show`, `list`, `stop`, `cancel`, `read` |
| **step** | `list`, `show`, `read`, `fork` |
| **workflow** | `add`, `show`, `list` |
| **cas** | `get`, `put`, `put-text`, `has`, `refs`, `walk`, `reindex`, `schema list`, `schema get` |
| **setup** | Interactive or `--provider`, `--base-url`, `--api-key`, `--model`, `--agent` |
| **skill** | `cli` — print markdown reference of all uwf commands |
| **log** | `list`, `show`, `clean` — process-level debug logs |
Config is stored in `~/.uncaged/workflow/config.yaml`. API keys go in `~/.uncaged/workflow/.env`.
Detailed command usage, options, and examples: [packages/cli-workflow/README.md](packages/cli-workflow/README.md).
## Development
```bash
bun install --no-cache # Install dependencies
bun run build # tsc --build (all packages)
bun run check # tsc + biome + lint-log-tags
bun run format # Auto-format with Biome
bun test # Run all tests
```
Managed with **bun workspace**. See [CLAUDE.md](CLAUDE.md) for coding conventions.
## Architecture
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, CAS node types, storage layout, agent CLI protocol, and design decisions.
+1
View File
@@ -4,6 +4,7 @@
"includes": [
"**",
"!**/dist",
"!.worktrees",
"!**/node_modules",
"!**/legacy-packages",
"!scripts",
+15 -20
View File
@@ -8,7 +8,7 @@
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions stored as CAS nodes; threads are immutable chains of CAS-linked step nodes. No daemon — each `uwf thread step` invocation runs one moderator→agent→extract cycle and exits.
The implementation lives in **6** active packages under `packages/`, plus two external CAS packages (`@uncaged/json-cas`, `@uncaged/json-cas-fs`). Legacy packages reside in `legacy-packages/` and are not part of the active stack.
The implementation lives in **5** active packages under `packages/`, plus two external CAS packages (`@uncaged/json-cas`, `@uncaged/json-cas-fs`). Legacy packages reside in `legacy-packages/` and are not part of the active stack.
## Package map
@@ -16,10 +16,9 @@ The implementation lives in **6** active packages under `packages/`, plus two ex
|-------|---------|---------------|
| Contract | `@uncaged/workflow-protocol``workflow-protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `ModeratorContext`, `WorkflowConfig`, etc.). No runtime deps beyond `@uncaged/json-cas-fs`. |
| Shared infra | `@uncaged/workflow-util``workflow-util` | Crockford Base32, ULID generation, `createLogger`, frontmatter parsing/validation. |
| Moderator | `@uncaged/workflow-moderator``workflow-moderator` | JSONata-based graph evaluator: given a `WorkflowPayload` and `ModeratorContext`, returns the next role or `$END`. |
| Agent framework | `@uncaged/workflow-agent-kit``workflow-agent-kit` | `createAgent` entrypoint factory, context builder, frontmatter fast-path extractor, LLM extract fallback, output format instruction builder. |
| Agent framework | `@uncaged/workflow-util-agent``workflow-util-agent` | `createAgent` entrypoint factory, context builder, frontmatter fast-path extractor, LLM extract fallback, output format instruction builder. |
| Agent: Hermes | `@uncaged/workflow-agent-hermes``workflow-agent-hermes` | `uwf-hermes` CLI binary — spawns `hermes chat`, pipes prompt, captures session detail. |
| CLI | `@uncaged/cli-workflow``cli-workflow` | `uwf` binary — thread lifecycle, workflow registry, CAS inspection, setup. |
| CLI | `@uncaged/cli-workflow``cli-workflow` | `uwf` binary — thread lifecycle, workflow registry, CAS inspection, setup. Includes status-based graph evaluator in `src/moderator/` (next role or `$END`). |
### External dependencies
@@ -27,7 +26,7 @@ The implementation lives in **6** active packages under `packages/`, plus two ex
|---------|------|
| `@uncaged/json-cas` | Content-addressed store API, XXH64 hashing, JSON Schema registration and validation. |
| `@uncaged/json-cas-fs` | Filesystem backend for `json-cas`. |
| `jsonata` | JSONata expression evaluator (used by `workflow-moderator`). |
| `mustache` | Template renderer for edge prompts (used by `cli-workflow` moderator). |
| `commander` | CLI argument parsing (used by `cli-workflow`). |
| `dotenv` | Loads `.env` files for API keys. |
| `yaml` | YAML parse/stringify. |
@@ -45,10 +44,9 @@ flowchart BT
end
subgraph L1["Layer 1 — shared"]
util["@uncaged/workflow-util"]
moderator["@uncaged/workflow-moderator"]
end
subgraph L2["Layer 2 — agent framework"]
kit["@uncaged/workflow-agent-kit"]
kit["@uncaged/workflow-util-agent"]
end
subgraph L3["Layer 3 — agent implementations"]
hermes["@uncaged/workflow-agent-hermes"]
@@ -58,7 +56,6 @@ flowchart BT
end
protocol --> jcasfs
util --> protocol
moderator --> protocol
kit --> protocol
kit --> util
kit --> jcas
@@ -68,7 +65,6 @@ flowchart BT
cli --> protocol
cli --> util
cli --> kit
cli --> moderator
cli --> jcas
cli --> jcasfs
```
@@ -148,8 +144,7 @@ graph:
Key properties:
- **`roles`** — inline role definitions; each `meta` is a JSON Schema (stored as its own CAS node on registration)
- **`conditions`** — named JSONata expressions evaluated against the `ModeratorContext`
- **`graph`** — `Record<Role | "$START", Transition[]>` — first matching transition wins; `condition: null` = fallback
- **`graph`** — `Record<Role | "$START", Record<Status, Target>>` — status-based routing; each role maps statuses to targets
- **No agent binding** — agent selection is a deployment concern, configured in `config.yaml`
- **No Zod** — all schemas are JSON Schema, validated through `@uncaged/json-cas`
@@ -159,8 +154,8 @@ Each `uwf thread step` runs exactly one cycle: moderator → agent → extract.
```
┌─→ Phase 1: MODERATOR
│ Input: WorkflowPayload + ModeratorContext { start, steps[] }
│ Engine: JSONata conditions evaluated against the graph
│ Input: graph + lastRole + lastOutput
│ Engine: Status-based map lookup against lastOutput.status
│ Output: next role name | $END
│ Phase 2: AGENT
@@ -207,7 +202,7 @@ type AgentContext = ModeratorContext & {
### Key properties
- **Moderator** — pure JSONata evaluation; no LLM call, no I/O beyond CAS reads. Evaluates `workflow.graph[currentRole]` transitions in order, returns first match.
- **Moderator** — pure status-based map lookup; no LLM call, no I/O beyond CAS reads. Looks up `graph[lastRole][lastOutput.status]` to get the next target.
- **Agent** — receives `AgentContext` with thread history + role system prompt + output format instruction. Raw output is frontmatter markdown.
- **Extractor** — two-layer: tries frontmatter fast-path first (zero LLM cost), falls back to LLM extract if frontmatter is absent or invalid.
- **Stateless** — each `uwf thread step` is an atomic, self-contained operation. No in-memory state between steps.
@@ -223,7 +218,7 @@ Each agent is an external command invoked by `uwf thread step`:
Contract:
1. `uwf thread step` determines the next role via the moderator
2. Agent CLI is spawned with `(thread-id, role)` as positional args
3. `workflow-agent-kit` (`createAgent`) handles the boilerplate:
3. `workflow-util-agent` (`createAgent`) handles the boilerplate:
- Parses argv
- Loads `.env` from storage root
- Builds `AgentContext` by walking the CAS chain from `threads.yaml` head
@@ -256,11 +251,11 @@ scope: role
Fixed the login redirect by updating the auth middleware...
```
The `outputFormatInstruction` (built by `buildOutputFormatInstruction` in `workflow-agent-kit`) is prepended to the role's system prompt, so the deliverable format is the first thing the agent sees. It lists the expected frontmatter fields derived from the role's `meta` JSON Schema.
The `outputFormatInstruction` (built by `buildOutputFormatInstruction` in `workflow-util-agent`) is prepended to the role's system prompt, so the deliverable format is the first thing the agent sees. It lists the expected frontmatter fields derived from the role's `meta` JSON Schema.
## Two-layer extract
Structured output extraction uses a two-layer strategy (`workflow-agent-kit`):
Structured output extraction uses a two-layer strategy (`workflow-util-agent`):
### Layer 1: frontmatter fast path (`frontmatter.ts`)
@@ -284,7 +279,7 @@ If the fast path returns `null` (no frontmatter, invalid, or doesn't satisfy sch
## Prompt injection
`workflow-agent-kit` prepends two pieces of context to the agent's system prompt:
`workflow-util-agent` prepends two pieces of context to the agent's system prompt:
1. **Deliverable format instruction** — generated from the role's `meta` schema, tells the agent exactly what frontmatter fields to produce and the expected format
2. **Scope constraint** — "Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope."
@@ -396,7 +391,7 @@ Everything else is immutable CAS content.
providers:
openrouter:
baseUrl: "https://openrouter.ai/api/v1"
apiKeyEnv: "OPENROUTER_API_KEY"
apiKey: "sk-..."
models:
sonnet:
@@ -485,7 +480,7 @@ Binary: `uwf`
| **YAML workflow definitions** | Human-readable, versionable, no build step required. JSON Schema inline in YAML, registered as CAS nodes on `workflow put`. |
| **Stateless single-step CLI** | Each `uwf thread step` is atomic — no in-memory state, no daemon, no long-running process. OS handles lifecycle. |
| **CAS-backed thread state** | Immutable linked nodes enable fork, replay, and GC without copying data. Content-addressed deduplication across threads. |
| **JSONata moderator** | Declarative condition expressions evaluated against thread history. No LLM cost for routing decisions. |
| **Status-based moderator** | Status-based map routing — `graph[role][status]` lookup against last output. No LLM cost for routing decisions. |
| **Frontmatter markdown output** | Agents produce structured meta (YAML frontmatter) alongside free-form content (markdown body). Enables zero-cost extraction when frontmatter is well-formed. |
| **Two-layer extract** | Fast path avoids LLM calls when agents follow the format; LLM fallback handles messy output gracefully. |
| **Prompt injection for format** | Output format instruction prepended to system prompt ensures agents produce parseable output without per-agent configuration. |
+20 -20
View File
@@ -78,9 +78,9 @@ Agent 解析优先级(`resolveAgentConfig`):
#### 环境变量:Storage Root
文档中写的 `UWF_STORAGE_ROOT` **在当前代码中不存在**。实际优先级(`workflow-agent-kit` / `cli-workflow` 一致):
文档中写的 `UWF_STORAGE_ROOT` **在当前代码中不存在**。实际优先级(`workflow-util-agent` / `cli-workflow` 一致):
```33:43:packages/workflow-agent-kit/src/storage.ts
```33:43:packages/workflow-util-agent/src/storage.ts
export function resolveStorageRoot(): string {
const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
if (internal !== undefined && internal !== "") {
@@ -107,7 +107,7 @@ Agent 子进程通过继承的 `process.env` 与父 CLI 共享同一 storage roo
### Q2: createAgent 工厂
workflow-agent-kit 的 `createAgent` 做了什么?它的完整生命周期是什么?
workflow-util-agent 的 `createAgent` 做了什么?它的完整生命周期是什么?
**调研要点:**
- `AgentOptions` 类型的 `run` 和 `continue` 回调签名
@@ -119,7 +119,7 @@ workflow-agent-kit 的 `createAgent` 做了什么?它的完整生命周期是
#### 类型定义
```4:35:packages/workflow-agent-kit/src/types.ts
```4:35:packages/workflow-util-agent/src/types.ts
export type AgentContext = ModeratorContext & {
threadId: ThreadId;
role: string;
@@ -156,7 +156,7 @@ export type AgentOptions = {
#### 生命周期(按执行顺序)
```101:152:packages/workflow-agent-kit/src/run.ts
```101:152:packages/workflow-util-agent/src/run.ts
export function createAgent(options: AgentOptions): () => Promise<void> {
return async function main(): Promise<void> {
const { threadId, role } = parseArgv(process.argv);
@@ -197,7 +197,7 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
#### StepNode 写入结构
```44:68:packages/workflow-agent-kit/src/run.ts
```44:68:packages/workflow-util-agent/src/run.ts
async function writeStepNode(options: {
store: AgentStore["store"];
schemas: AgentStore["schemas"];
@@ -274,7 +274,7 @@ export type StepContext = Omit<StepRecord, "output"> & {
`buildContextWithMeta` 还返回 `meta`:
```148:154:packages/workflow-agent-kit/src/context.ts
```148:154:packages/workflow-util-agent/src/context.ts
export type BuildContextMeta = {
storageRoot: string;
store: Store;
@@ -288,7 +288,7 @@ export type BuildContextMeta = {
1. 从 `threads.yaml[threadId]` 取 `headHash`
2. `walkChain`:若 head 是 `StartNode`,`stepsNewestFirst=[]`;否则沿 `prev` 收集所有 `StepNode`, newest-first
3. `buildHistory`:反转为时间序,`expandOutput` 把每步 `output` CasRef 展开为 JSON payload(供 prompt / JSONata 使用)
3. `buildHistory`:反转为时间序,`expandOutput` 把每步 `output` CasRef 展开为 JSON payload(供 prompt / moderator 使用)
4. `loadWorkflow`:从 `start.workflow` CasRef 加载 `WorkflowPayload`
#### Role definition 来源
@@ -337,7 +337,7 @@ async function resolveFrontmatterRef(..., frontmatter: unknown): Promise<CasRef>
#### Frontmatter fast-path(createAgent 实际使用的路径)
```148:195:packages/workflow-agent-kit/src/frontmatter.ts
```148:195:packages/workflow-util-agent/src/frontmatter.ts
export async function tryFrontmatterFastPath(
raw: string,
outputSchema: CasRef,
@@ -357,7 +357,7 @@ export async function tryFrontmatterFastPath(
#### LLM extract fallback(已实现但未接入 createAgent)
```135:181:packages/workflow-agent-kit/src/extract.ts
```135:181:packages/workflow-util-agent/src/extract.ts
export async function extract(
rawOutput: string,
outputSchema: CasRef,
@@ -374,7 +374,7 @@ export async function extract(
#### Correction prompt(retry)
```125:128:packages/workflow-agent-kit/src/run.ts
```125:128:packages/workflow-util-agent/src/run.ts
const correctionMessage =
"Your previous response did not contain valid YAML frontmatter matching the role schema.\n" +
"You MUST begin your response with a YAML frontmatter block (--- delimited).\n" +
@@ -402,7 +402,7 @@ workflow 怎么配置和使用 model?
```136:160:packages/workflow-protocol/src/types.ts
export type ProviderConfig = {
baseUrl: string;
apiKeyEnv: string;
apiKey: string;
};
export type ModelConfig = {
@@ -425,11 +425,11 @@ export type WorkflowConfig = {
#### resolveModel
```32:50:packages/workflow-agent-kit/src/extract.ts
```32:50:packages/workflow-util-agent/src/extract.ts
export function resolveModel(config: WorkflowConfig, alias: ModelAlias): ResolvedLlmProvider {
const modelEntry = config.models[alias];
const providerEntry = config.providers[modelEntry.provider];
const apiKey = process.env[providerEntry.apiKeyEnv];
const apiKey = providerEntry.apiKey;
return { baseUrl: providerEntry.baseUrl, apiKey, model: modelEntry.name };
}
```
@@ -438,7 +438,7 @@ export function resolveModel(config: WorkflowConfig, alias: ModelAlias): Resolve
Extract 专用别名解析:
```18:30:packages/workflow-agent-kit/src/extract.ts
```18:30:packages/workflow-util-agent/src/extract.ts
export function resolveExtractModelAlias(config: WorkflowConfig): ModelAlias {
return config.modelOverrides?.extract ?? (config.models.extract ? "extract" : config.models.default ? "default" : config.defaultModel);
}
@@ -448,7 +448,7 @@ export function resolveExtractModelAlias(config: WorkflowConfig): ModelAlias {
#### chatCompletionText
```87:124:packages/workflow-agent-kit/src/extract.ts
```87:124:packages/workflow-util-agent/src/extract.ts
async function chatCompletionText(
provider: ResolvedLlmProvider,
messages: Array<{ role: "system" | "user"; content: string }>,
@@ -463,7 +463,7 @@ async function chatCompletionText(
| 多模态 | **无**(仅 text `content`) |
| Extract 专用 | `response_format: { type: "json_object" }` |
builtin agent 的 run loop 需要**新写**带 `tools` 的 completion 客户端(可放在 `workflow-agent-builtin` 或扩展 `workflow-agent-kit` 的 `llm/` 模块),不能复用当前 `chatCompletionText` 而不改。
builtin agent 的 run loop 需要**新写**带 `tools` 的 completion 客户端(可放在 `workflow-agent-builtin` 或扩展 `workflow-util-agent` 的 `llm/` 模块),不能复用当前 `chatCompletionText` 而不改。
---
@@ -572,7 +572,7 @@ Hermes 自带完整 agent runtime(`--yolo`、max-turns),tool 集由 Hermes
| P1 | `grep` | 搜索符号/引用 |
| P2 | `fetch_url` | 查文档(planner 偶尔需要) |
**不需要**在 builtin 里实现 moderator / workflow 路由工具——仍由 `uwf thread step` + JSONata 负责。
**不需要**在 builtin 里实现 moderator / workflow 路由工具——仍由 `uwf thread step` + status-based moderator 负责。
#### Agent loop 必须能力
@@ -609,7 +609,7 @@ flowchart TB
Loop --> Detail
end
subgraph kit ["workflow-agent-kit"]
subgraph kit ["workflow-util-agent"]
Ctx["buildContextWithMeta"]
FM["tryFrontmatterFastPath"]
Persist["persistStep"]
@@ -630,7 +630,7 @@ flowchart TB
Spawn -->|"stdout: step hash"| Step
```
**新包**:`packages/workflow-agent-builtin`,bin `uwf-builtin`,仅依赖 `workflow-agent-kit`、`workflow-protocol`、`workflow-util`(可选 `@uncaged/json-cas` 写 detail schema)。
**新包**:`packages/workflow-agent-builtin`,bin `uwf-builtin`,仅依赖 `workflow-util-agent`、`workflow-protocol`、`workflow-util`(可选 `@uncaged/json-cas` 写 detail schema)。
**分层**:
+27
View File
@@ -0,0 +1,27 @@
---
description: Ban dynamic import() in production code — use static imports instead
globs: packages/*/src/**/*.ts
alwaysApply: true
---
# No Dynamic Import in Production Code
## Rule
Do NOT use `await import()` or dynamic `import()` expressions in production source code.
Always use static top-level `import` statements.
## Exception (must include a comment explaining why)
1. **Bundle loader** — loads user-authored workflow bundles whose paths are only known at runtime
When suppressing, add a comment directly above:
```ts
// Dynamic import required: user bundle path resolved at runtime
const mod = await import(bundlePath);
```
## Test Files
Test files (`__tests__/**`) are exempt.
+67
View File
@@ -0,0 +1,67 @@
# Sync README
When updating README.md files in this monorepo, follow these conventions.
## Scope
- Root `README.md` — project overview and navigation hub
- Per-package `packages/*/README.md` — each package self-contained
## Root README Structure
The root README should have these sections in order:
1. **Title and one-liner** — stateless workflow engine driven by single-step CLI
2. **Overview** — 2-3 paragraphs explaining what it does and key concepts
3. **Architecture** — dependency layer diagram (text-based)
4. **Packages** — table with ALL packages from packages/ directory, columns: Package, Description, Type (cli/lib/agent/app)
5. **Quick Start** — install, build, register workflow, start thread, run step
6. **CLI Reference** — brief command list, detailed usage in cli-workflow README
7. **Development** — bun install / build / check / test
## Per-Package README Structure
Each package README should have:
1. **Title** — package name
2. **One-line description** — matching package.json
3. **Overview** — what it does, where it sits in the architecture, dependencies
4. **Installation** — bun add (for libs) or "included as binary" (for cli/agents)
5. **API** (lib packages) — all exports from src/index.ts with type signatures, grouped by category, minimal usage examples
6. **CLI Usage** (cli/agent packages) — command reference with examples
7. **Internal Structure** — brief src/ file organization
8. **Configuration** (if applicable)
## Execution Steps
### Step 1: Gather current state
For each package read:
- package.json (name, version, description, dependencies, bin)
- src/index.ts (public API exports)
- Existing README.md (preserve hand-written content worth keeping)
### Step 2: Update root README
- Ensure ALL packages in packages/ directory are listed in the table
- Update CLI command reference from uwf --help output
- Keep Quick Start examples valid
### Step 3: Write/update each package README
- Follow the per-package structure
- API section MUST match actual src/index.ts exports — never invent
- For agent packages: document CLI binary name, how it is invoked
- For lib packages: document exported types and functions
- Internal structure: list actual files in src/
### Step 4: Verify
- All relative links work
- Package names match package.json
- No references to removed/renamed packages
- bun run build still passes
## Guidelines
- Only document what src/index.ts actually exports
- Root README summarizes, package READMEs go into detail
- Verify CLI examples against actual commands
- Preserve existing good prose when updating
- English for all README content
+28 -50
View File
@@ -75,7 +75,7 @@ uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T --agent "bunx uwf-cursor"
**做的事:**
1. 读链头 → 当前 StepNode(或 StartNode)
2. 收集 thread 历史(遍历链)
3. 调 moderator:评估 JSONata conditions → 得到下一个 role(或 END)
3. 调 moderator:status-based map lookup → 得到下一个 role(或 END)
4. 若 END → 归档 thread,输出最后链头,退出
5. 确定 agent command(`--agent` override > config.yaml per-workflow/role > config.yaml defaultAgent)
6. 调用:`<agent-cmd> <thread-id> <role>`,捕获 stdout 得到新 StepNode hash
@@ -199,29 +199,21 @@ payload:
```
- `roles` — 内联定义,每个 role 的 `meta` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
- `conditions``Record<Name, JSONata>`,命名条件,方便画图描述
- `graph``Record<Role | "$START", Transition[]>`,每个 Transition = `{ role, condition }`
- `condition` 引用 conditions 中的 key,`null` = fallback
- 按数组顺序求值,第一个匹配的 transition 胜出
- `graph``Record<Role | "$START", Record<Status, Target>>`,每个 Target = `{ role, prompt }`
- Status 来自上一个 role 输出的 `status` 字段,`$START``_` 作为初始 status
- Prompt 模板使用 Mustache 渲染,变量来自 lastOutput
- 不含 agent binding — agent 配置在 `~/.uncaged/workflow/config.yaml` 中管理
JSONata 表达式的求值上下文
Moderator 的求值逻辑
```jsonc
{
"start": { // StartNode 信息
"workflow": "4KNM2PXR3B1QW",
"prompt": "Fix the login bug..."
},
"steps": [ // 所有已完成 steps,从旧到新
{ "role": "planner", "output": { "phases": [...] }, "detail": "7BQST3VW9F2MA", "agent": "uwf-hermes" },
{ "role": "developer", "output": { "filesChanged": ["src/auth.ts"], "summary": "Fixed redirect" }, "detail": "9KRVW3TN5F1QA", "agent": "uwf-cursor" },
{ "role": "reviewer", "output": { "approved": false }, "detail": "2MXBG6PN4A8JR", "agent": "uwf-hermes" }
]
}
```typescript
evaluate(graph, lastRole, lastOutput) { role, prompt }
// 1. status = lastRole === "$START" ? "_" : lastOutput.status
// 2. target = graph[lastRole][status]
// 3. prompt = mustache.render(target.prompt, lastOutput)
```
注:`output` 在上下文中会被自动展开为实际的 CAS 节点内容(而非 hash),方便 JSONata 表达式直接访问字段
注:routing 基于 `lastOutput.status` 字段的值,直接在 graph map 中查找对应的 Target
#### `StartNode`(Thread 起点)
@@ -288,13 +280,13 @@ threads.yaml: { "01J7K9M2XNPQR5VWBCDF8G3H4T": "8FWKR3TN5V1QA" }
providers:
openai:
baseUrl: "https://api.openai.com/v1"
apiKeyEnv: "OPENAI_API_KEY"
apiKey: "sk-..."
anthropic:
baseUrl: "https://api.anthropic.com/v1"
apiKeyEnv: "ANTHROPIC_API_KEY"
apiKey: "sk-ant-..."
openrouter:
baseUrl: "https://openrouter.ai/api/v1"
apiKeyEnv: "OPENROUTER_API_KEY"
apiKey: "sk-or-..."
models:
sonnet:
@@ -349,9 +341,8 @@ OPENROUTER_API_KEY=sk-or-...
```
packages/
├── cli-workflow/ # @uncaged/cli-workflow — uwf CLI(thread/workflow 命令)
├── workflow-moderator/ # @uncaged/workflow-moderator — JSONata moderator 引擎
├── workflow-agent-kit/ # @uncaged/workflow-agent-kit — Agent CLI 框架(含 extractor)
├── cli-workflow/ # @uncaged/cli-workflow — uwf CLI(thread/workflow 命令,含 src/moderator/
├── workflow-util-agent/ # @uncaged/workflow-util-agent — Agent CLI 框架(含 extractor
├── workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI
├── workflow-agent-cursor/ # @uncaged/workflow-agent-cursor — uwf-cursor CLI
└── workflow-protocol/ # @uncaged/workflow-protocol — 共享类型定义
@@ -367,7 +358,7 @@ packages/
## 4. 关键数据类型
JSONata 求值上下文本质上是 thread 链表的线性化表达。StepNode payload 和上下文中的 step 共享大量字段,提取为公共类型。
Moderator 通过 status-based map lookup 进行路由。StepNode payload 和上下文中的 step 共享大量字段,提取为公共类型。
### 4.1 公共类型
@@ -378,7 +369,7 @@ type CasRef = string;
/** Thread ID — ULID, 26-char Crockford Base32 */
type ThreadId = string;
/** 一个 step 的核心数据,被 StepNode payload 和 JSONata 上下文共享 */
/** 一个 step 的核心数据,被 StepNode payload 和 moderator 上下文共享 */
type StepRecord = {
role: string;
output: CasRef; // cas_ref → 结构化输出节点(符合 role meta schema)
@@ -399,22 +390,16 @@ type RoleDefinition = {
meta: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
};
type Transition = {
type Target = {
role: string; // 目标 role 名 或 "$END"
condition: string | null; // 引用 conditions 中的 key,null = fallback
};
type ConditionDefinition = {
description: string;
expression: string; // JSONata expression
prompt: string; // Mustache 模板,渲染时注入 lastOutput
};
type WorkflowPayload = {
name: string;
description: string;
roles: Record<string, RoleDefinition>;
conditions: Record<string, ConditionDefinition>;
graph: Record<string, Transition[]>; // Record<Role | "$START", Transition[]>
graph: Record<string, Record<string, Target>>; // Record<Role | "$START", Record<Status, Target>>
};
```
@@ -432,20 +417,14 @@ type StepNodePayload = StepRecord & {
};
```
### 4.4 JSONata 求值上下文
### 4.4 Moderator 求值
Thread 链表的线性化。`steps[n]` 的字段和 `StepRecord` 一致,但 `output` 被展开为实际内容。
Moderator 使用 `evaluate(graph, lastRole, lastOutput)` 进行同步 status-based routing:
```typescript
/** JSONata 上下文中的 step — output 被展开 */
type StepContext = Omit<StepRecord, "output"> & {
output: unknown; // 展开后的 CAS 节点内容,非 hash
};
type ModeratorContext = {
start: StartNodePayload;
steps: StepContext[]; // 从旧到新
};
// graph[lastRole][lastOutput.status] → Target { role, prompt }
// $START 角色使用 "_" 作为初始 status
// prompt 通过 Mustache 模板渲染,变量来自 lastOutput
```
### 4.5 CLI 输出
@@ -486,7 +465,7 @@ type Scenario = string; // e.g. "extract"
type ProviderConfig = {
baseUrl: string;
apiKeyEnv: string; // env var name to read API key from
apiKey: string; // API key stored directly
};
type ModelConfig = {
@@ -534,6 +513,5 @@ StepNodePayload ──extends──→ StepRecord ←──maps to──→ Step
└── start.workflow → WorkflowPayload
├── roles: Record<name, RoleDefinition>
── conditions: Record<name, JSONata>
└── graph: Record<role, Transition[]>
── graph: Record<role, Record<status, Target>>
```
+5 -8
View File
@@ -22,6 +22,8 @@ roles:
frontmatter:
type: object
properties:
$status:
enum: ["_"]
thesis:
type: string
keyPoints:
@@ -30,14 +32,9 @@ roles:
type: string
caveats:
type: string
required: [thesis, keyPoints]
conditions: {}
required: [$status, thesis, keyPoints]
graph:
$START:
- role: "analyst"
condition: null
prompt: "Analyze the topic in the task and produce a structured summary with key points."
_: { role: "analyst", prompt: "Analyze the topic in the task and produce a structured summary with key points." }
analyst:
- role: "$END"
condition: null
prompt: "Analysis complete. Finish the workflow."
_: { role: "$END", prompt: "Analysis complete. Finish the workflow." }
+15 -30
View File
@@ -16,15 +16,16 @@ roles:
3. If you find yourself genuinely convinced by the other side, you may concede.
output: |
Provide your argument in the frontmatter.
Set conceded to true ONLY if you are genuinely convinced and wish to stop debating.
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
Otherwise set status to "continue".
frontmatter:
type: object
properties:
$status:
enum: ["continue", "conceded"]
argument:
type: string
conceded:
type: boolean
required: [argument, conceded]
required: [$status, argument]
for:
description: "Argues for the proposition"
goal: |
@@ -40,38 +41,22 @@ roles:
3. If you find yourself genuinely convinced by the other side, you may concede.
output: |
Provide your argument in the frontmatter.
Set conceded to true ONLY if you are genuinely convinced and wish to stop debating.
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
Otherwise set status to "continue".
frontmatter:
type: object
properties:
$status:
enum: ["continue", "conceded"]
argument:
type: string
conceded:
type: boolean
required: [argument, conceded]
conditions:
againstConceded:
description: "The against side conceded"
expression: "$last('against').conceded = true"
forConceded:
description: "The for side conceded"
expression: "$last('for').conceded = true"
required: [$status, argument]
graph:
$START:
- role: "against"
condition: null
prompt: "Present your opening argument against the proposition."
_: { role: "against", prompt: "Present your opening argument against the proposition." }
against:
- role: "$END"
condition: "againstConceded"
prompt: "The against side conceded. Debate over."
- role: "for"
condition: null
prompt: "Counter the opposing argument. Address their points directly."
conceded: { role: "$END", prompt: "The against side conceded. Debate over." }
continue: { role: "for", prompt: "Counter the opposing argument: {{{argument}}}" }
for:
- role: "$END"
condition: "forConceded"
prompt: "The for side conceded. Debate over."
- role: "against"
condition: null
prompt: "Counter the opposing argument. Address their points directly."
conceded: { role: "$END", prompt: "The for side conceded. Debate over." }
continue: { role: "against", prompt: "Counter the opposing argument: {{{argument}}}" }
+176 -76
View File
@@ -1,98 +1,198 @@
name: "solve-issue"
description: "End-to-end issue resolution"
description: "TDD-driven issue resolution for small, focused changes. Loop protection relies on engine maxRounds."
roles:
planner:
description: "Creates implementation plan"
goal: "You are a planning agent. You analyze issues and create implementation plans grounded in the actual codebase."
description: "Analyzes issue and outputs a TDD test spec"
goal: "You are a planning agent. You analyze Gitea issues and produce a TDD test specification that downstream roles will implement and verify."
capabilities:
- issue-analysis
- planning
- file-read
- shell
procedure: |
1. Locate the code repository:
- Check if the current working directory is the repo (look for package.json, .git, etc.)
- If the task mentions a repo URL, clone it first.
- If this is a new project, create the repo and note the path.
2. Explore the codebase — read the relevant source files mentioned in the issue. Understand the current architecture, types, and conventions (check CLAUDE.md, CONTRIBUTING.md, .cursor/rules/).
3. Identify which files need changes and what the changes should be, with specific code references.
4. Output the plan with:
- `repoPath`: absolute path to the repository root
- `plan`: detailed implementation plan with file paths and code references
- `steps`: concrete action items for the developer
output: |
Provide repoPath, plan summary, and steps in the frontmatter.
The plan MUST reference actual file paths and code structures you found by reading the source.
Do NOT guess — if you haven't read a file, read it before referencing it.
On first run (no previous steps):
1. Read the issue and all comments from Gitea using `tea issues <number> -r <owner/repo>`
2. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
3. Assess whether the issue has enough information to produce a test spec
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output $status=insufficient_info
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
On subsequent runs (bounced back by tester with fix_spec):
1. Read the tester's output from the previous step to understand what's wrong with the spec
2. Revise the test spec accordingly
After producing the test spec:
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
2. Put the hash in frontmatter.plan (required when $status=ready)
3. Set repoPath to the absolute path of the repository root
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
frontmatter:
type: object
properties:
repoPath:
type: string
plan:
type: string
required: [repoPath, plan]
oneOf:
- properties:
$status: { const: "ready" }
plan: { type: string }
repoPath: { type: string }
required: [$status, plan, repoPath]
- properties:
$status: { const: "insufficient_info" }
required: [$status]
developer:
description: "Implements code changes"
goal: "You are a developer agent. You implement code changes according to plans."
description: "TDD implementation per test spec"
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
capabilities:
- file-edit
- shell
- testing
- coding
procedure: |
1. Read the planner's output to get the repoPath and implementation plan.
2. cd to the repoPath before making any changes.
3. Create a feature branch from the default branch.
4. Implement the plan — write code, tests, and ensure existing tests pass.
5. Commit your changes with a descriptive message referencing the issue.
output: "List all files changed and provide a summary of the implementation."
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
The repo path and other details are provided in your task prompt.
Before starting any work, set up an isolated worktree:
1. cd into the repo path provided in your task prompt
2. `git fetch origin` to get latest refs
3. First time (no existing branch):
- `git worktree add .worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
- `cd .worktrees/fix/<issue-number>-<short-slug> && bun install`
4. If bounced back from reviewer or tester (branch already exists):
- cd into the existing worktree under `.worktrees/fix/<issue-number>-<short-slug>`
- `git fetch origin && git rebase origin/main`
5. ALL subsequent work must happen inside the worktree directory.
Then implement TDD:
6. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner's output in your task prompt)
7. If bounced back from reviewer or tester: read the previous role's feedback in your task prompt
8. Write tests first based on the spec
9. Implement the code to make tests pass
10. Ensure `bun run build` passes with no errors
11. Run `bun test` to verify all tests pass
If you cannot complete the implementation (e.g. the issue is too complex, blocked by external factors,
or repeated attempts fail), set $status=failed with a reason.
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
frontmatter:
type: object
properties:
filesChanged:
type: array
items:
type: string
summary:
type: string
required: [filesChanged, summary]
oneOf:
- properties:
$status: { const: "done" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "failed" }
reason: { type: string }
required: [$status, reason]
reviewer:
description: "Reviews code changes"
goal: "You are a code reviewer. You review implementations for correctness and quality."
description: "Code standards compliance check"
goal: "You are a code reviewer. You verify code standards compliance — NOT functionality (that's the tester's job)."
capabilities:
- code-review
- static-analysis
procedure: "Review the implementation against the plan. Check for bugs, edge cases, and style."
output: "Approve or reject with detailed comments explaining your decision."
procedure: |
The worktree path is provided in your task prompt. cd into it first.
Before reviewing, verify the git branch:
1. Run `git branch --show-current` — confirm the branch name references the issue number being worked on
2. If the branch doesn't correspond to the issue, flag it in your output and reject
Then perform code review:
Hard checks (must all pass):
3. `bun run build` — no build errors
4. `bunx biome check` — no lint violations
5. TypeScript strict mode — no type errors
Soft checks (review against project conventions if CLAUDE.md / .cursor/rules exist):
- Naming conventions, module boundaries, code style
- No `console.log` in production code
- No dynamic imports in production code
Only review standards compliance. Do NOT test functionality.
If rejecting, you MUST explain the specific reason in your output.
output: "Explain your decision with specific file/line references. Set $status to approved (with branch/worktree) or rejected (with comments)."
frontmatter:
type: object
properties:
approved:
type: boolean
comments:
type: string
required: [approved, comments]
conditions:
notApproved:
description: "Reviewer rejected the implementation"
expression: "$last('reviewer').approved = false"
oneOf:
- properties:
$status: { const: "approved" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "rejected" }
comments: { type: string }
worktree: { type: string }
required: [$status, comments, worktree]
tester:
description: "Functional correctness verification"
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
capabilities:
- testing
procedure: |
The worktree path is provided in your task prompt. cd into it first.
1. Run `bun test` for automated test verification
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner step in the thread history)
3. Verify each scenario in the spec is covered and passing
4. Determine outcome:
- passed: all scenarios verified, tests pass
- fix_code: tests fail or implementation doesn't match spec → send back to developer
- fix_spec: the spec itself is wrong or incomplete → send back to planner
output: "Report test results per scenario. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
frontmatter:
oneOf:
- properties:
$status: { const: "passed" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "fix_code" }
report: { type: string }
required: [$status, report]
- properties:
$status: { const: "fix_spec" }
report: { type: string }
required: [$status, report]
committer:
description: "Commits and creates PR"
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
capabilities: []
procedure: |
The worktree path, branch name, and repo info are provided in your task prompt.
cd into the worktree first.
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
1. Stage all changes: `git add -A`
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
3. Push the branch: `git push -u origin <branch-name>`
- If push hook fails: capture the error log in your output, mark hook_failed
4. On push success: create a PR via `tea pr create --repo <owner/repo> --title "..." --description "..."`
- Extract owner/repo from: `git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/'`
- PR description must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
- On tea failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed
5. After PR creation, clean up the worktree:
- cd to the repo root (parent of .worktrees)
- `git worktree remove <worktree-path>`
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
frontmatter:
oneOf:
- properties:
$status: { const: "committed" }
prUrl: { type: string }
required: [$status, prUrl]
- properties:
$status: { const: "hook_failed" }
error: { type: string }
required: [$status, error]
graph:
$START:
- role: "planner"
condition: null
prompt: "Analyze the issue described in the task and produce a detailed implementation plan."
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
planner:
- role: "developer"
condition: null
prompt: "Implement the plan from the planner. Write code, tests, and ensure existing tests pass."
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}." }
developer:
- role: "reviewer"
condition: null
prompt: "Review the developer's implementation against the plan for correctness and quality."
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
reviewer:
- role: "developer"
condition: "notApproved"
prompt: "The reviewer rejected your implementation. Read their feedback and fix the issues."
- role: "$END"
condition: null
prompt: "The review passed. Complete the workflow."
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in repo {{{worktree}}}." }
approved: { role: "tester", prompt: "Review passed. Run tests on branch {{{branch}}} at {{{worktree}}}." }
tester:
fix_code: { role: "developer", prompt: "Tests found code issues: {{{report}}}. Fix and re-submit." }
fix_spec: { role: "planner", prompt: "Tests found spec issues: {{{report}}}. Revise the test spec." }
passed: { role: "committer", prompt: "All tests passed. Commit and push branch {{{branch}}} from {{{worktree}}}." }
committer:
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow complete." }
@@ -531,13 +531,25 @@ export async function executeThread(
timestamp: nowMs,
parentState: options.parentStateHash,
},
steps: input.steps.map((out, i) => ({
role: out.role,
contentHash: out.contentHash,
meta: out.meta,
refs: out.refs,
timestamp: replayTs?.[i] ?? prefilled?.[i]?.timestamp ?? nowMs + i,
})),
steps: await Promise.all(
input.steps.map(async (out, i) => {
// Resolve content for the last step (most relevant for the next agent).
// Earlier steps only carry meta summaries to avoid bloating the prompt.
const isLast = i === input.steps.length - 1;
let content: string | null = null;
if (isLast) {
content = await getContentMerklePayload(io.cas, out.contentHash);
}
return {
role: out.role,
contentHash: out.contentHash,
content,
meta: out.meta,
refs: out.refs,
timestamp: replayTs?.[i] ?? prefilled?.[i]?.timestamp ?? nowMs + i,
};
}),
),
};
const runtime: WorkflowRuntime = {
@@ -0,0 +1,61 @@
# @uncaged/workflow-moderator
Status-based graph evaluator — determines the next role or `$END` with zero LLM cost.
## Overview
The moderator (Layer 1) performs a status-based map lookup on the workflow graph. Given the last role and its output, it looks up `graph[lastRole][lastOutput.status]` to find the next `Target` (role + prompt template). The prompt is rendered via Mustache with `lastOutput` as the template context. For `$START`, the unit status `_` is used.
**Dependencies:** `@uncaged/workflow-protocol`, `mustache`
## Installation
```bash
bun add @uncaged/workflow-moderator
```
## API
### Functions
```typescript
function evaluate(
graph: Record<string, Record<string, Target>>,
lastRole: string,
lastOutput: Record<string, unknown> & { status: string },
): Result<EvaluateResult, Error>
```
Returns `{ ok: true, value: { role, prompt } }` where `role` is the next role name or `"$END"`, and `prompt` is the rendered edge instruction for the agent.
### Types
```typescript
type EvaluateResult = {
role: string;
prompt: string;
};
```
The `Result<T, E>` type is local to this package (`{ ok: true; value: T } | { ok: false; error: E }`), not re-exported from `index.ts`.
## Usage
```typescript
import { evaluate } from "@uncaged/workflow-moderator";
import type { Target } from "@uncaged/workflow-protocol";
const result = evaluate(graph, lastRole, lastOutput);
if (result.ok && result.value.role !== "$END") {
console.log(`Next role: ${result.value.role}, prompt: ${result.value.prompt}`);
}
```
## Internal Structure
```
src/
├── index.ts Public exports
├── evaluate.ts Status-based map lookup + Mustache prompt rendering
└── types.ts EvaluateResult, Result
```
@@ -0,0 +1,132 @@
import { describe, expect, test } from "bun:test";
import type { Target, WorkflowPayload } from "@uncaged/workflow-protocol";
import { evaluate } from "../src/evaluate.js";
const solveIssueGraph: WorkflowPayload["graph"] = {
$START: {
_: { role: "planner", prompt: "Start planning from the issue in the task." },
},
planner: {
_: { role: "developer", prompt: "Implement the plan: {{plan}}" },
},
developer: {
_: { role: "reviewer", prompt: "Review the changes: {{summary}}" },
},
reviewer: {
approved: { role: "$END", prompt: "Done." },
rejected: { role: "developer", prompt: "Fix: {{comments}}" },
},
};
describe("evaluate", () => {
test("$START → first role (unit status _)", () => {
const result = evaluate(solveIssueGraph, "$START", { $status: "_" });
expect(result).toEqual({
ok: true,
value: { role: "planner", prompt: "Start planning from the issue in the task." },
});
});
test("status-based routing (reviewer rejected → developer)", () => {
const result = evaluate(solveIssueGraph, "reviewer", {
$status: "rejected",
comments: "missing tests",
});
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: "Fix: missing tests" },
});
});
test("status-based routing (reviewer approved → $END)", () => {
const result = evaluate(solveIssueGraph, "reviewer", { $status: "approved" });
expect(result).toEqual({
ok: true,
value: { role: "$END", prompt: "Done." },
});
});
test("missing role in graph → error", () => {
const result = evaluate(solveIssueGraph, "unknown-role", { $status: "_" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toBe('no transitions defined for role "unknown-role"');
}
});
test("missing status in graph → error", () => {
const result = evaluate(solveIssueGraph, "reviewer", { $status: "pending" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toBe('no transition for role "reviewer" with status "pending"');
}
});
test("mustache template rendering with simple fields", () => {
const result = evaluate(solveIssueGraph, "planner", {
$status: "_",
plan: "Add auth middleware",
});
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: "Implement the plan: Add auth middleware" },
});
});
test("mustache does not HTML-escape prompt content", () => {
const result = evaluate(solveIssueGraph, "reviewer", {
$status: "rejected",
comments: 'use <T> & "Result<T, E>" types',
});
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: 'Fix: use <T> & "Result<T, E>" types' },
});
});
test("triple mustache also works for unescaped output", () => {
const graph: Record<string, Record<string, Target>> = {
reviewer: {
_: { role: "developer", prompt: "Fix: {{{comments}}}" },
},
};
const result = evaluate(graph, "reviewer", {
$status: "_",
comments: "<script>alert(1)</script>",
});
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: "Fix: <script>alert(1)</script>" },
});
});
test("missing $status defaults to _ (unit routing)", () => {
const result = evaluate(solveIssueGraph, "planner", {
plan: "Add auth middleware",
});
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: "Implement the plan: Add auth middleware" },
});
});
test("mustache template with nested object paths", () => {
const graph: Record<string, Record<string, Target>> = {
reviewer: {
_: {
role: "developer",
prompt: "Address: {{review.comments}}",
},
},
};
const result = evaluate(graph, "reviewer", {
$status: "_",
review: { comments: "refactor the handler" },
});
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: "Address: refactor the handler" },
});
});
});
@@ -15,16 +15,28 @@
}
},
"scripts": {
"test": "bun test"
"test": "bun test",
"test:ci": "bun test"
},
"dependencies": {
"@uncaged/workflow-protocol": "workspace:^",
"jsonata": "^1.8.7"
"mustache": "^4.2.0"
},
"devDependencies": {
"@types/mustache": "^4.2.6",
"typescript": "^5.8.3"
},
"publishConfig": {
"access": "public"
}
},
"repository": {
"type": "git",
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
"directory": "legacy-packages/workflow-moderator"
},
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
"bugs": {
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
},
"license": "MIT"
}
@@ -0,0 +1,53 @@
import type { Target } from "@uncaged/workflow-protocol";
import mustache from "mustache";
import type { EvaluateResult, Result } from "./types.js";
// Disable HTML escaping — prompts are plain text, not HTML.
mustache.escape = (text: string) => text;
const START_ROLE = "$START";
const UNIT_STATUS = "_";
type LastOutput = Record<string, unknown>;
const STATUS_KEY = "$status";
export function evaluate(
graph: Record<string, Record<string, Target>>,
lastRole: string,
lastOutput: LastOutput,
): Result<EvaluateResult, Error> {
const status =
lastRole === START_ROLE
? UNIT_STATUS
: typeof lastOutput[STATUS_KEY] === "string"
? (lastOutput[STATUS_KEY] as string)
: UNIT_STATUS;
const roleTargets = graph[lastRole];
if (roleTargets === undefined) {
return {
ok: false,
error: new Error(`no transitions defined for role "${lastRole}"`),
};
}
const target = roleTargets[status];
if (target === undefined) {
return {
ok: false,
error: new Error(`no transition for role "${lastRole}" with status "${status}"`),
};
}
try {
const prompt = mustache.render(target.prompt, lastOutput);
return { ok: true, value: { role: target.role, prompt } };
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error : new Error(String(error)),
};
}
}
@@ -71,6 +71,7 @@ export type RoleStep<M extends RoleMeta> = {
role: K;
meta: M[K];
contentHash: string;
content: string | null;
refs: string[];
timestamp: number;
};
@@ -71,7 +71,8 @@ async function buildRoleStepsFromStates<M extends RoleMeta>(
cas: CasStore,
): Promise<RoleStep<M>[]> {
const steps: RoleStep<M>[] = [];
for (const st of chronologicalStates) {
for (let idx = 0; idx < chronologicalStates.length; idx++) {
const st = chronologicalStates[idx];
if (st.payload.role === END) {
continue;
}
@@ -79,10 +80,13 @@ async function buildRoleStepsFromStates<M extends RoleMeta>(
if (contentParsed === null || contentParsed.kind !== "content") {
throw new Error(`buildThreadContext: expected content node at ${st.payload.content}`);
}
// Resolve full text content for the last step only
const isLast = idx === chronologicalStates.length - 1;
steps.push({
role: st.payload.role,
meta: st.payload.meta,
contentHash: st.payload.content,
content: isLast ? contentParsed.node.payload : null,
refs: [...contentParsed.node.refs],
timestamp: st.payload.timestamp,
} as RoleStep<M>);
@@ -88,6 +88,7 @@ async function advanceOneRound<M extends RoleMeta>(
const step = {
role: next,
contentHash,
content: contentPayload,
meta,
refs,
timestamp: Date.now(),
@@ -30,7 +30,7 @@ describe("buildAgentPrompt", () => {
expect(text).not.toContain("## Tools");
});
test("single step shows hash and meta, and includes tools", async () => {
test("single step shows meta and content, and includes tools", async () => {
const onlyHash = "01HASHSINGLESTEP0000000001";
const ctx: AgentContext = {
start: startTask("user task"),
@@ -42,6 +42,7 @@ describe("buildAgentPrompt", () => {
{
role: "coder",
contentHash: onlyHash,
content: "Here is my implementation of the feature.",
meta: { files: ["a.ts"] },
refs: [onlyHash],
timestamp: 2,
@@ -52,13 +53,39 @@ describe("buildAgentPrompt", () => {
expect(text).toContain("## Task");
expect(text).toContain("user task");
expect(text).toContain("## Step: coder");
expect(text).toContain(`ContentHash: ${onlyHash}`);
expect(text).toContain('Meta: {"files":["a.ts"]}');
expect(text).toContain("<output>");
expect(text).toContain("Here is my implementation of the feature.");
expect(text).toContain("</output>");
expect(text).toContain("## Tools");
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
});
test("two or more steps: previous steps are meta-only; latest step includes hash", async () => {
test("single step with null content omits output tag", async () => {
const onlyHash = "01HASHSINGLESTEP0000000001";
const ctx: AgentContext = {
start: startTask("user task"),
depth: 0,
bundleHash: "TESTHASH00001",
threadId: "01TEST000000000000000000TR",
currentRole: { name: "coder", systemPrompt: "Be helpful." },
steps: [
{
role: "coder",
contentHash: onlyHash,
content: null,
meta: { files: ["a.ts"] },
refs: [onlyHash],
timestamp: 2,
},
],
};
const text = await buildAgentPrompt(ctx);
expect(text).not.toContain("<output>");
expect(text).toContain('Meta: {"files":["a.ts"]}');
});
test("two or more steps: previous steps are meta-only; latest step includes content", async () => {
const plannerHash = "01HASHPLANNER0000000000001";
const coderHash = "01HASHCODER0000000000000001";
const ctx: AgentContext = {
@@ -71,6 +98,7 @@ describe("buildAgentPrompt", () => {
{
role: "planner",
contentHash: plannerHash,
content: null,
meta: { plan: "short" },
refs: [plannerHash],
timestamp: 2,
@@ -78,6 +106,7 @@ describe("buildAgentPrompt", () => {
{
role: "coder",
contentHash: coderHash,
content: "I reviewed the code and found 4 lint issues:\n1. Missing semicolon on line 42\n2. Unused import on line 3",
meta: { done: true },
refs: [coderHash],
timestamp: 3,
@@ -90,10 +119,11 @@ describe("buildAgentPrompt", () => {
expect(text).toContain("### Step 1: planner");
expect(text).toContain('Summary: {"plan":"short"}');
expect(text).toContain("## Latest Step: coder");
expect(text).toContain(`ContentHash: ${coderHash}`);
expect(text).toContain('Meta: {"done":true}');
expect(text).toContain("<output>");
expect(text).toContain("I reviewed the code and found 4 lint issues:");
expect(text).toContain("</output>");
expect(text).toContain("## Tools");
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
});
test("parentState null omits Parent Context section", async () => {
@@ -125,7 +155,7 @@ describe("buildAgentPrompt", () => {
expect(text).toContain(`uncaged-workflow cas get ${parentHash}`);
});
test("middle steps show meta summary only and latest shows hash", async () => {
test("middle steps show meta summary only and latest shows content", async () => {
const ha = "01HASHA00000000000000000001";
const hb = "01HASHB00000000000000000001";
const hc = "01HASHC00000000000000000001";
@@ -139,6 +169,7 @@ describe("buildAgentPrompt", () => {
{
role: "a",
contentHash: ha,
content: null,
meta: { n: 1 },
refs: [ha],
timestamp: 2,
@@ -146,6 +177,7 @@ describe("buildAgentPrompt", () => {
{
role: "b",
contentHash: hb,
content: null,
meta: { n: 2 },
refs: [hb],
timestamp: 3,
@@ -153,6 +185,7 @@ describe("buildAgentPrompt", () => {
{
role: "c",
contentHash: hc,
content: "Final output from role c",
meta: { n: 3 },
refs: [hc],
timestamp: 4,
@@ -162,7 +195,35 @@ describe("buildAgentPrompt", () => {
const text = await buildAgentPrompt(ctx);
expect(text).toContain('Summary: {"n":1}');
expect(text).toContain('Summary: {"n":2}');
expect(text).toContain(`ContentHash: ${hc}`);
expect(text).toContain("## Latest Step: c");
expect(text).toContain("<output>");
expect(text).toContain("Final output from role c");
expect(text).toContain("</output>");
});
test("content is truncated when exceeding quota", async () => {
const longContent = "x".repeat(20_000);
const hash = "01HASHLONG000000000000000001";
const ctx: AgentContext = {
start: startTask("task"),
depth: 0,
bundleHash: "TESTHASH00001",
threadId: "01TEST000000000000000000TR",
currentRole: { name: "r", systemPrompt: "S" },
steps: [
{
role: "r",
contentHash: hash,
content: longContent,
meta: {},
refs: [],
timestamp: 2,
},
],
};
const text = await buildAgentPrompt(ctx);
expect(text).toContain("<output>");
expect(text).toContain("... (truncated)");
expect(text.length).toBeLessThan(20_000);
});
});
+13 -2
View File
@@ -5,14 +5,16 @@
"packages/*"
],
"scripts": {
"uwf": "bun packages/cli-workflow/src/cli.ts",
"build": "bunx tsc --build",
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
"typecheck": "bunx tsc --build",
"format": "biome format --write .",
"test": "bun run --filter './packages/*' test",
"test:ci": "bun run --filter './packages/*' test:ci",
"changeset": "bunx changeset",
"version": "bunx changeset version",
"release": "bun run build && bun test && node scripts/publish-all.mjs"
"release": "bun run build && bun run test && node scripts/publish-all.mjs"
},
"devDependencies": {
"@agentclientprotocol/sdk": "^0.22.1",
@@ -22,5 +24,14 @@
"@types/xxhashjs": "^0.2.4",
"@uncaged/workflow-agent-hermes": "workspace:*",
"bun-types": "^1.3.13"
}
},
"repository": {
"type": "git",
"url": "https://github.com/shazhou-ww/uncaged-workflow.git"
},
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
"bugs": {
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
},
"license": "MIT"
}
+11
View File
@@ -0,0 +1,11 @@
# @uncaged/cli-workflow
## 0.5.1
### Patch Changes
- Add 5 persona-based skills (actor, user, author, developer, adapter) and fix skill CLI description truncation
- Updated dependencies
- @uncaged/workflow-util@0.5.1
- @uncaged/workflow-protocol@0.5.1
- @uncaged/workflow-util-agent@0.5.1
+212
View File
@@ -0,0 +1,212 @@
# @uncaged/cli-workflow
`uwf` CLI — thread lifecycle, workflow registry, CAS inspection, and setup.
## Overview
Layer 4 entry point for the workflow engine. The `uwf` binary orchestrates one step per invocation: load thread head from `threads.yaml`, run the moderator, spawn the configured agent CLI, run extract, append a CAS step node, and update the head pointer (or archive when `$END`).
### Four-Layer Architecture
```
workflow → thread → step → turn
模板定义 执行实例 单步结果 agent内部交互
```
- **Workflow** (layer 1): YAML template with roles and routing graph
- **Thread** (layer 2): Single workflow execution instance
- **Step** (layer 3): One moderator→agent→extract cycle
- **Turn** (layer 4): Agent-internal interactions (use `step show` or CAS to inspect)
This package has no library `src/index.ts` — it is consumed as a CLI binary only.
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`, `@uncaged/workflow-util-agent`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`, `commander`, `dotenv`, `mustache`, `yaml`
## Installation
Included as the `uwf` binary when you install `@uncaged/cli-workflow`:
```bash
bun add -g @uncaged/cli-workflow
# or from the monorepo:
bun link packages/cli-workflow
```
## CLI Usage
### Global options
```
-V, --version Show version
--format <json|yaml> Output format (default: json)
-h, --help Show help
```
### Thread (Layer 2: Execution Instances)
| Command | Description |
|---------|-------------|
| `uwf thread start <workflow> -p <prompt>` | Create a thread without executing |
| `uwf thread exec <thread-id> [--agent <cmd>] [-c <count>] [--background]` | Execute one or more moderator→agent→extract cycles |
| `uwf thread show <thread-id>` | Show thread head pointer |
| `uwf thread list [--status <status>] [--after <date>] [--before <date>] [--skip <n>] [--take <n>]` | List threads filtered by status (idle, running, completed, active, or comma-separated), time range (ISO or relative like '7d'), with pagination |
| `uwf thread read <thread-id> [--quota N] [--before <hash>] [--start]` | Render thread as readable markdown |
`thread read`, `step list`, and `step show` work on both active and completed threads.
| `uwf thread stop <thread-id>` | Stop background execution (keep thread active) |
| `uwf thread cancel <thread-id>` | Cancel thread (stop + archive to history) |
Examples:
```bash
uwf thread start solve-issue -p "Fix the login redirect bug"
uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV
uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV -c 3 --agent uwf-builtin
uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV --background
uwf thread list --status running
uwf thread list --status active
uwf thread list --status idle,completed
uwf thread list --after 7d --take 10
uwf thread read 01ARZ3NDEKTSV4RRFFQ69G5FAV --quota 8000
uwf thread stop 01ARZ3NDEKTSV4RRFFQ69G5FAV
```
### Step (Layer 3: Single Cycle Results)
| Command | Description |
|---------|-------------|
| `uwf step list <thread-id>` | List all steps in a thread chronologically |
| `uwf step show <step-hash>` | Show step metadata and frontmatter |
| `uwf step read <step-hash> [--quota <chars>]` | Read a step's turns as human-readable markdown |
| `uwf step fork <step-hash>` | Fork a thread from a specific step |
Examples:
```bash
uwf step list 01ARZ3NDEKTSV4RRFFQ69G5FAV
uwf step show 32GCDE899RRQ3
uwf step read 32GCDE899RRQ3 --quota 2000
uwf step fork 32GCDE899RRQ3
```
### Workflow (Layer 1: Templates)
| Command | Description |
|---------|-------------|
| `uwf workflow add <file.yaml>` | Register a workflow from YAML |
| `uwf workflow show <name-or-hash>` | Show workflow definition |
| `uwf workflow list` | List registered workflows |
### CAS
| Command | Description |
|---------|-------------|
| `uwf cas get <hash> [--timestamp]` | Read a CAS node |
| `uwf cas put <type-hash> <data>` | Store a node, print hash |
| `uwf cas put-text <text>` | Store plain text, print hash |
| `uwf cas has <hash>` | Check existence |
| `uwf cas refs <hash>` | List direct references |
| `uwf cas walk <hash>` | Recursive traversal |
| `uwf cas reindex` | Rebuild type index |
| `uwf cas schema list` | List registered schemas |
| `uwf cas schema get <hash>` | Show a schema |
### Setup
```bash
uwf setup
uwf setup --provider openai --base-url https://api.openai.com/v1 \
--api-key sk-... --model gpt-4o --agent hermes
```
Config: `~/.uncaged/workflow/config.yaml` (includes API keys).
### Skill
| Command | Description |
|---------|-------------|
| `uwf skill cli` | Print markdown reference of all uwf commands (for agent skills) |
### Log
| Command | Description |
|---------|-------------|
| `uwf log list` | List log files with sizes |
| `uwf log show [--thread <id>] [--process <pid>] [--date YYYY-MM-DD]` | Show filtered log entries |
| `uwf log clean [--before YYYY-MM-DD]` | Delete old log files |
## Migration Guide
### Breaking Changes (v0.x → v1.x)
The CLI was reorganized to clarify the four-layer architecture. **No backward compatibility** — old commands have been removed.
#### Renamed Commands
| Old Command | New Command | Notes |
|------------|-------------|-------|
| `workflow put` | `workflow add` | More intuitive verb |
| `thread step` | `thread exec` | Eliminates ambiguity with "step" noun |
| `thread list --all` | `thread list --status completed` | Unified status filtering |
#### Removed Commands (Merged)
| Old Command | New Command | Notes |
|------------|-------------|-------|
| `thread running` | `thread list --status running` | Merged into unified list |
#### Removed Commands (Split)
| Old Command | New Commands | Notes |
|------------|-------------|-------|
| `thread kill` | `thread stop` or `thread cancel` | `stop` keeps thread active, `cancel` archives it |
#### Moved Commands
| Old Command | New Command | Notes |
|------------|-------------|-------|
| `thread steps` | `step list` | Moved to step layer |
| `thread step-details` | `step show` | Moved to step layer |
| `thread fork` | `step fork` | Moved to step layer (forks are step-based) |
#### Deprecation Errors
Old commands now show helpful error messages:
```bash
$ uwf thread step 01ARZ3NDEKTSV4RRFFQ69G5FAV
Error: Command 'thread step' has been removed.
Use 'thread exec' instead.
For more information, see: uwf help thread exec
```
## Internal Structure
```
src/
├── cli.ts Commander entrypoint, command registration
├── format.ts JSON/YAML output formatting
├── store.ts CAS store + registry initialization
├── validate.ts Workflow YAML validation
├── schemas.ts CLI-local schema registration
├── moderator/ Status-based graph evaluator (next role or $END)
└── commands/
├── thread.ts Thread lifecycle and exec
├── step.ts Step operations (list/show/read/fork)
├── workflow.ts Workflow registry (add/show/list)
├── cas.ts CAS inspection and schema ops
├── setup.ts Interactive/non-interactive setup
├── skill.ts Built-in skill references
└── log.ts Process debug log management
```
## Configuration
| File | Purpose |
|------|---------|
| `~/.uncaged/workflow/config.yaml` | Providers, models, default agent |
| `~/.uncaged/workflow/.env` | API keys (referenced by `apiKeyEnv` in config) |
| `~/.uncaged/workflow/registry.yaml` | Workflow name → CAS hash |
| `~/.uncaged/workflow/threads.yaml` | Active thread head pointers |
| `~/.uncaged/workflow/cas/` | Content-addressed node storage |
+20 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/cli-workflow",
"version": "0.5.0",
"version": "0.5.1",
"files": [
"src",
"dist",
@@ -8,26 +8,38 @@
],
"type": "module",
"bin": {
"uwf": "./src/cli.ts"
"uwf": "./dist/cli.js"
},
"dependencies": {
"@uncaged/json-cas": "^0.4.0",
"@uncaged/json-cas-fs": "^0.4.0",
"@uncaged/workflow-agent-kit": "workspace:^",
"@uncaged/workflow-moderator": "workspace:^",
"@uncaged/json-cas": "^0.5.3",
"@uncaged/json-cas-fs": "^0.5.3",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^",
"commander": "^14.0.3",
"dotenv": "^16.6.1",
"mustache": "^4.2.0",
"yaml": "^2.8.4"
},
"scripts": {
"test": "vitest run"
"test": "vitest run",
"test:ci": "vitest run"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/mustache": "^4.2.6",
"vitest": "^4.1.6"
}
},
"repository": {
"type": "git",
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
"directory": "packages/cli-workflow"
},
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
"bugs": {
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
},
"license": "MIT"
}
@@ -0,0 +1,152 @@
import { execSync } from "node:child_process";
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdCasPutText } from "../commands/cas.js";
let storageRoot: string;
let uwfPath: string;
beforeEach(async () => {
storageRoot = join(
tmpdir(),
`uwf-cas-exit-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
await mkdir(storageRoot, { recursive: true });
// Find the uwf CLI path
uwfPath = join(__dirname, "../../src/cli.ts");
});
afterEach(async () => {
await rm(storageRoot, { recursive: true, force: true });
});
type ExecResult = {
stdout: string;
stderr: string;
exitCode: number;
};
function execUwf(args: string[]): ExecResult {
try {
const stdout = execSync(`bun ${uwfPath} ${args.join(" ")}`, {
env: { ...process.env, WORKFLOW_STORAGE_ROOT: storageRoot },
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});
return { stdout, stderr: "", exitCode: 0 };
} catch (error: unknown) {
if (
error &&
typeof error === "object" &&
"stdout" in error &&
"stderr" in error &&
"status" in error
) {
return {
stdout: (error.stdout as Buffer | string).toString(),
stderr: (error.stderr as Buffer | string).toString(),
exitCode: error.status as number,
};
}
throw error;
}
}
describe("uwf cas has CLI exit codes", () => {
test("exits 0 when hash exists", async () => {
// Setup: Create a temp storage root, put a text node, capture hash
const putResult = await cmdCasPutText(storageRoot, "test content");
const hash = putResult.hash;
// Execute: uwf cas has <hash>
const result = execUwf(["cas", "has", hash]);
// Assert: stdout contains {"exists":true}, exit code === 0
expect(result.stdout).toContain('"exists":true');
expect(result.exitCode).toBe(0);
});
test("exits 1 when hash does not exist", () => {
// Setup: Create a temp storage root (empty CAS store)
// Execute: uwf cas has NOSUCHHASH123
const result = execUwf(["cas", "has", "NOSUCHHASH123"]);
// Assert: stdout contains {"exists":false}, exit code === 1
expect(result.stdout).toContain('"exists":false');
expect(result.exitCode).toBe(1);
});
test("JSON output format unchanged for exists=true", async () => {
// Setup: Create store, put node
const putResult = await cmdCasPutText(storageRoot, "test");
const hash = putResult.hash;
// Execute: uwf cas has <hash>
const result = execUwf(["cas", "has", hash]);
// Assert: stdout JSON parses correctly to {exists: true}
const parsed = JSON.parse(result.stdout.trim());
expect(parsed).toEqual({ exists: true });
});
test("JSON output format unchanged for exists=false", () => {
// Setup: Create empty store
// Execute: uwf cas has INVALID
const result = execUwf(["cas", "has", "INVALID"]);
// Assert: stdout JSON parses correctly to {exists: false}
const parsed = JSON.parse(result.stdout.trim());
expect(parsed).toEqual({ exists: false });
});
test("YAML output format preserves exit code behavior for exists=true", async () => {
// Setup: Create store with node
const putResult = await cmdCasPutText(storageRoot, "test");
const hash = putResult.hash;
// Execute: uwf --format yaml cas has <hash>
const result = execUwf(["--format", "yaml", "cas", "has", hash]);
// Assert: exit code === 0, output is YAML format
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("exists:");
expect(result.stdout).toContain("true");
});
test("YAML output format preserves exit code behavior for exists=false", () => {
// Setup: Create empty store
// Execute: uwf --format yaml cas has INVALID
const result = execUwf(["--format", "yaml", "cas", "has", "INVALID"]);
// Assert: exit code === 1, output is YAML format
expect(result.exitCode).toBe(1);
expect(result.stdout).toContain("exists:");
expect(result.stdout).toContain("false");
});
});
describe("regression: other cas commands unaffected", () => {
test("uwf cas get still exits 1 on not-found with error message", () => {
// Execute: uwf cas get NOSUCHHASH
const result = execUwf(["cas", "get", "NOSUCHHASH"]);
// Assert: exit code === 1, stderr contains "Node not found"
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("Node not found");
});
test("uwf cas put-text behavior unchanged", () => {
// Execute: uwf cas put-text "hello"
const result = execUwf(["cas", "put-text", "hello"]);
// Assert: exit code === 0, returns hash
expect(result.exitCode).toBe(0);
const parsed = JSON.parse(result.stdout.trim());
expect(parsed).toHaveProperty("hash");
expect(typeof parsed.hash).toBe("string");
expect(parsed.hash.length).toBe(13); // Crockford Base32 XXH64 hash length
});
});
@@ -0,0 +1,74 @@
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdCasHas, cmdCasPutText } from "../commands/cas.js";
let storageRoot: string;
beforeEach(async () => {
storageRoot = join(tmpdir(), `uwf-cas-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(storageRoot, { recursive: true });
});
afterEach(async () => {
await rm(storageRoot, { recursive: true, force: true });
});
describe("cmdCasHas", () => {
test("returns {exists: true} for existing hash", async () => {
// Setup: Create a test store, put a node, get its hash
const putResult = await cmdCasPutText(storageRoot, "test content");
const hash = putResult.hash;
// Execute: Call cmdCasHas with the valid hash
const result = await cmdCasHas(storageRoot, hash);
// Assert: Result equals {exists: true}
expect(result).toEqual({ exists: true });
});
test("returns {exists: false} for non-existent hash", async () => {
// Setup: Create an empty test store
// (storageRoot already created in beforeEach)
// Execute: Call cmdCasHas with an invalid hash
const result = await cmdCasHas(storageRoot, "INVALIDHASH12");
// Assert: Result equals {exists: false}
expect(result).toEqual({ exists: false });
});
test("does not throw for non-existent hash", async () => {
// Setup: Create an empty test store
// Execute & Assert: Does not throw, returns {exists: false}
await expect(cmdCasHas(storageRoot, "NOSUCHHASH123")).resolves.toEqual({
exists: false,
});
});
test("handles malformed hash gracefully", async () => {
// Setup: Create a test store
// Execute: Call cmdCasHas with a too-short hash
const result = await cmdCasHas(storageRoot, "xyz");
// Assert: Returns {exists: false} (store.has() returns false)
expect(result).toEqual({ exists: false });
});
test("handles empty hash string", async () => {
// Execute: Call cmdCasHas with an empty string
const result = await cmdCasHas(storageRoot, "");
// Assert: Returns {exists: false}
expect(result).toEqual({ exists: false });
});
test("handles hash with special characters", async () => {
// Execute: Call cmdCasHas with special characters
const result = await cmdCasHas(storageRoot, "HASH!@#");
// Assert: Returns {exists: false}
expect(result).toEqual({ exists: false });
});
});
@@ -0,0 +1,622 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import {
cmdConfigGet,
cmdConfigList,
cmdConfigSet,
getConfigPath,
getNestedValue,
maskApiKeys,
parseDotPath,
setNestedValue,
} from "../commands/config.js";
describe("config command", () => {
// Helper function to create a test config
function createTestConfig(tempDir: string, content: string): string {
const configPath = getConfigPath(tempDir);
writeFileSync(configPath, content, "utf8");
return configPath;
}
// Sample test config
const sampleConfig = `providers:
dashscope:
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
apiKey: sk-test-dashscope-key
openai:
baseUrl: https://api.openai.com/v1
apiKey: sk-test-openai-key
models:
default:
provider: dashscope
name: qwen-max
gpt4:
provider: openai
name: gpt-4
agents:
hermes:
command: uwf-hermes
args:
- --provider
- dashscope
claude-code:
command: claude-code
args:
- --profile
- work
defaultAgent: hermes
defaultModel: default
`;
describe("helper functions", () => {
describe("parseDotPath", () => {
test("splits dot notation correctly", () => {
expect(parseDotPath("a.b.c")).toEqual(["a", "b", "c"]);
expect(parseDotPath("defaultAgent")).toEqual(["defaultAgent"]);
expect(parseDotPath("providers.dashscope.baseUrl")).toEqual([
"providers",
"dashscope",
"baseUrl",
]);
});
});
describe("getNestedValue", () => {
test("traverses nested objects", () => {
const obj = {
a: { b: { c: "value" } },
x: "simple",
};
expect(getNestedValue(obj, ["a", "b", "c"])).toBe("value");
expect(getNestedValue(obj, ["x"])).toBe("simple");
});
test("returns undefined for non-existent paths", () => {
const obj = { a: { b: "value" } };
expect(getNestedValue(obj, ["a", "c"])).toBeUndefined();
expect(getNestedValue(obj, ["x", "y"])).toBeUndefined();
});
});
describe("setNestedValue", () => {
test("creates intermediate objects and sets value", () => {
const obj: Record<string, unknown> = {};
setNestedValue(obj, ["a", "b", "c"], "value");
expect(obj).toEqual({ a: { b: { c: "value" } } });
});
test("preserves existing values", () => {
const obj: Record<string, unknown> = { a: { x: "keep" } };
setNestedValue(obj, ["a", "b"], "new");
expect(obj).toEqual({ a: { x: "keep", b: "new" } });
});
test("overwrites existing value at path", () => {
const obj: Record<string, unknown> = { a: { b: "old" } };
setNestedValue(obj, ["a", "b"], "new");
expect(obj).toEqual({ a: { b: "new" } });
});
});
describe("maskApiKeys", () => {
test("deep clones and masks all apiKey values in providers", () => {
const config = {
providers: {
dashscope: {
baseUrl: "https://example.com",
apiKey: "sk-test-key-12345",
},
openai: {
baseUrl: "https://api.openai.com",
apiKey: "sk-another-secret",
},
},
models: {
default: { provider: "dashscope" },
},
};
const masked = maskApiKeys(config);
expect(masked).toEqual({
providers: {
dashscope: {
baseUrl: "https://example.com",
apiKey: "***MASKED***",
},
openai: {
baseUrl: "https://api.openai.com",
apiKey: "***MASKED***",
},
},
models: {
default: { provider: "dashscope" },
},
});
// Ensure it's a deep clone
expect(masked).not.toBe(config);
});
test("handles config without providers", () => {
const config = { models: { default: { provider: "test" } } };
const masked = maskApiKeys(config);
expect(masked).toEqual(config);
});
});
});
describe("cmdConfigList", () => {
test("returns full config when file exists", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const result = await cmdConfigList(tempDir);
expect(result).toBeDefined();
expect(typeof result).toBe("object");
expect(result).toHaveProperty("providers");
expect(result).toHaveProperty("models");
expect(result).toHaveProperty("agents");
expect(result).toHaveProperty("defaultAgent");
expect(result).toHaveProperty("defaultModel");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("masks all apiKey values in providers section", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const result = (await cmdConfigList(tempDir)) as Record<string, unknown>;
const providers = result.providers as Record<string, unknown>;
const dashscope = providers.dashscope as Record<string, unknown>;
const openai = providers.openai as Record<string, unknown>;
expect(dashscope.apiKey).toBe("***MASKED***");
expect(openai.apiKey).toBe("***MASKED***");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("throws error when config file doesn't exist", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
await expect(cmdConfigList(tempDir)).rejects.toThrow();
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("returns empty object when config file is empty", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, "");
const result = await cmdConfigList(tempDir);
expect(result).toEqual({});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("throws error when config file is invalid YAML", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, "invalid: yaml: [broken");
await expect(cmdConfigList(tempDir)).rejects.toThrow();
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
describe("cmdConfigGet", () => {
test("retrieves top-level string value (defaultAgent)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const result = await cmdConfigGet(tempDir, "defaultAgent");
expect(result).toBe("hermes");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("retrieves top-level string value (defaultModel)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const result = await cmdConfigGet(tempDir, "defaultModel");
expect(result).toBe("default");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("retrieves nested object (providers.dashscope)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const result = await cmdConfigGet(tempDir, "providers.dashscope");
expect(result).toEqual({
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: "sk-test-dashscope-key",
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("retrieves deeply nested string (providers.dashscope.baseUrl)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const result = await cmdConfigGet(tempDir, "providers.dashscope.baseUrl");
expect(result).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("retrieves nested string in models (models.default.provider)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const result = await cmdConfigGet(tempDir, "models.default.provider");
expect(result).toBe("dashscope");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("retrieves array value (agents.hermes.args)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const result = await cmdConfigGet(tempDir, "agents.hermes.args");
expect(result).toEqual(["--provider", "dashscope"]);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("throws error when key doesn't exist", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigGet(tempDir, "nonexistent.key")).rejects.toThrow(/Key not found/);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("throws error when config file doesn't exist", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
await expect(cmdConfigGet(tempDir, "defaultAgent")).rejects.toThrow();
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("throws error when accessing property on non-object", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigGet(tempDir, "defaultAgent.foo")).rejects.toThrow();
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
describe("cmdConfigSet", () => {
test("sets top-level string value (defaultAgent)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const result = await cmdConfigSet(tempDir, "defaultAgent", "claude-code");
expect(result).toEqual({ key: "defaultAgent", value: "claude-code" });
// Verify it was written
const updated = await cmdConfigGet(tempDir, "defaultAgent");
expect(updated).toBe("claude-code");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("sets nested string value (providers.dashscope.baseUrl)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const newUrl = "https://new-api.example.com/v1";
const result = await cmdConfigSet(tempDir, "providers.dashscope.baseUrl", newUrl);
expect(result).toEqual({
key: "providers.dashscope.baseUrl",
value: newUrl,
});
// Verify it was written
const updated = await cmdConfigGet(tempDir, "providers.dashscope.baseUrl");
expect(updated).toBe(newUrl);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("creates new nested path (providers.newprovider.baseUrl)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const newUrl = "https://new-provider.com/v1";
const result = await cmdConfigSet(tempDir, "providers.newprovider.baseUrl", newUrl);
expect(result).toEqual({
key: "providers.newprovider.baseUrl",
value: newUrl,
});
// Verify it was created
const updated = await cmdConfigGet(tempDir, "providers.newprovider.baseUrl");
expect(updated).toBe(newUrl);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("sets array value for args key with valid JSON array", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const newArgs = '["--new", "--flags"]';
const result = await cmdConfigSet(tempDir, "agents.hermes.args", newArgs);
expect(result).toEqual({
key: "agents.hermes.args",
value: ["--new", "--flags"],
});
// Verify it was written
const updated = await cmdConfigGet(tempDir, "agents.hermes.args");
expect(updated).toEqual(["--new", "--flags"]);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("preserves existing config values when updating one key", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await cmdConfigSet(tempDir, "defaultAgent", "claude-code");
// Verify other values are preserved
const defaultModel = await cmdConfigGet(tempDir, "defaultModel");
expect(defaultModel).toBe("default");
const dashscopeUrl = await cmdConfigGet(tempDir, "providers.dashscope.baseUrl");
expect(dashscopeUrl).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("creates config file if it doesn't exist", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
const result = await cmdConfigSet(tempDir, "defaultAgent", "hermes");
expect(result).toEqual({ key: "defaultAgent", value: "hermes" });
// Verify file was created
const configPath = getConfigPath(tempDir);
const content = readFileSync(configPath, "utf8");
expect(content).toContain("defaultAgent: hermes");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("throws error when setting property on non-object", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "defaultAgent.foo", "bar")).rejects.toThrow();
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("throws error when array value is invalid JSON for args key", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(
cmdConfigSet(tempDir, "agents.hermes.args", "[invalid json"),
).rejects.toThrow();
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("sets deeply nested model config (models.gpt4.provider)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const result = await cmdConfigSet(tempDir, "models.gpt4.provider", "new-provider");
expect(result).toEqual({
key: "models.gpt4.provider",
value: "new-provider",
});
// Verify it was written
const updated = await cmdConfigGet(tempDir, "models.gpt4.provider");
expect(updated).toBe("new-provider");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("sets agent command (agents.claude-code.command)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const result = await cmdConfigSet(tempDir, "agents.claude-code.command", "new-command");
expect(result).toEqual({
key: "agents.claude-code.command",
value: "new-command",
});
// Verify it was written
const updated = await cmdConfigGet(tempDir, "agents.claude-code.command");
expect(updated).toBe("new-command");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
describe("cmdConfigSet validation", () => {
test("rejects unknown top-level key", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "unknownKey", "value")).rejects.toThrow(
/Unknown config key.*unknownKey/,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects unknown nested key in providers", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(
cmdConfigSet(tempDir, "providers.myProvider.unknownField", "value"),
).rejects.toThrow(/Unknown field.*unknownField.*providers/);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects unknown nested key in models", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "models.default.invalidField", "value")).rejects.toThrow(
/Unknown field.*invalidField.*models/,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects unknown nested key in agents", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "agents.hermes.badField", "value")).rejects.toThrow(
/Unknown field.*badField.*agents/,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects nested path on scalar key (defaultAgent)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "defaultAgent.foo", "value")).rejects.toThrow(
/defaultAgent.*scalar|Cannot set property/i,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects nested path on scalar key (defaultModel)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "defaultModel.bar", "value")).rejects.toThrow(
/defaultModel.*scalar|Cannot set property/i,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects incomplete nested path (providers without field)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "providers.myProvider", "value")).rejects.toThrow(
/incomplete path|must specify a field/i,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects incomplete nested path (models without field)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "models.myModel", "value")).rejects.toThrow(
/incomplete path|must specify a field/i,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects incomplete nested path (agents without field)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "agents.myAgent", "value")).rejects.toThrow(
/incomplete path|must specify a field/i,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("allows valid nested keys in providers", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await cmdConfigSet(tempDir, "providers.newprovider.baseUrl", "https://example.com");
await cmdConfigSet(tempDir, "providers.newprovider.apiKey", "sk-test");
const baseUrl = await cmdConfigGet(tempDir, "providers.newprovider.baseUrl");
const apiKey = await cmdConfigGet(tempDir, "providers.newprovider.apiKey");
expect(baseUrl).toBe("https://example.com");
expect(apiKey).toBe("sk-test");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("allows valid nested keys in models", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await cmdConfigSet(tempDir, "models.gpt4.provider", "openai");
await cmdConfigSet(tempDir, "models.gpt4.name", "gpt-4o");
const provider = await cmdConfigGet(tempDir, "models.gpt4.provider");
const name = await cmdConfigGet(tempDir, "models.gpt4.name");
expect(provider).toBe("openai");
expect(name).toBe("gpt-4o");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("allows valid nested keys in agents", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await cmdConfigSet(tempDir, "agents.hermes.command", "uwf-hermes");
await cmdConfigSet(tempDir, "agents.hermes.args", '["--flag"]');
const command = await cmdConfigGet(tempDir, "agents.hermes.command");
const args = await cmdConfigGet(tempDir, "agents.hermes.args");
expect(command).toBe("uwf-hermes");
expect(args).toEqual(["--flag"]);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
});
@@ -0,0 +1,132 @@
import type { Target, WorkflowPayload } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { evaluate } from "../moderator/evaluate.js";
const solveIssueGraph: WorkflowPayload["graph"] = {
$START: {
_: { role: "planner", prompt: "Start planning from the issue in the task." },
},
planner: {
_: { role: "developer", prompt: "Implement the plan: {{plan}}" },
},
developer: {
_: { role: "reviewer", prompt: "Review the changes: {{summary}}" },
},
reviewer: {
approved: { role: "$END", prompt: "Done." },
rejected: { role: "developer", prompt: "Fix: {{comments}}" },
},
};
describe("evaluate", () => {
test("$START → first role (unit status _)", () => {
const result = evaluate(solveIssueGraph, "$START", { $status: "_" });
expect(result).toEqual({
ok: true,
value: { role: "planner", prompt: "Start planning from the issue in the task." },
});
});
test("status-based routing (reviewer rejected → developer)", () => {
const result = evaluate(solveIssueGraph, "reviewer", {
$status: "rejected",
comments: "missing tests",
});
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: "Fix: missing tests" },
});
});
test("status-based routing (reviewer approved → $END)", () => {
const result = evaluate(solveIssueGraph, "reviewer", { $status: "approved" });
expect(result).toEqual({
ok: true,
value: { role: "$END", prompt: "Done." },
});
});
test("missing role in graph → error", () => {
const result = evaluate(solveIssueGraph, "unknown-role", { $status: "_" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toBe('no transitions defined for role "unknown-role"');
}
});
test("missing status in graph → error", () => {
const result = evaluate(solveIssueGraph, "reviewer", { $status: "pending" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toBe('no transition for role "reviewer" with status "pending"');
}
});
test("mustache template rendering with simple fields", () => {
const result = evaluate(solveIssueGraph, "planner", {
$status: "_",
plan: "Add auth middleware",
});
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: "Implement the plan: Add auth middleware" },
});
});
test("mustache does not HTML-escape prompt content", () => {
const result = evaluate(solveIssueGraph, "reviewer", {
$status: "rejected",
comments: 'use <T> & "Result<T, E>" types',
});
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: 'Fix: use <T> & "Result<T, E>" types' },
});
});
test("triple mustache also works for unescaped output", () => {
const graph: Record<string, Record<string, Target>> = {
reviewer: {
_: { role: "developer", prompt: "Fix: {{{comments}}}" },
},
};
const result = evaluate(graph, "reviewer", {
$status: "_",
comments: "<script>alert(1)</script>",
});
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: "Fix: <script>alert(1)</script>" },
});
});
test("missing $status defaults to _ (unit routing)", () => {
const result = evaluate(solveIssueGraph, "planner", {
plan: "Add auth middleware",
});
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: "Implement the plan: Add auth middleware" },
});
});
test("mustache template with nested object paths", () => {
const graph: Record<string, Record<string, Target>> = {
reviewer: {
_: {
role: "developer",
prompt: "Address: {{review.comments}}",
},
},
};
const result = evaluate(graph, "reviewer", {
$status: "_",
review: { comments: "refactor the handler" },
});
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: "Address: refactor the handler" },
});
});
});
@@ -0,0 +1,113 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { resolveHeadHash } from "../commands/shared.js";
import { appendThreadHistory, saveThreadsIndex } from "../store.js";
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-resolve-head-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
describe("resolveHeadHash", () => {
test("returns head hash from threads.yaml for active thread", async () => {
const threadId = "01JTEST0000000000000000001" as ThreadId;
const headHash = "active_hash_123" as CasRef;
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
const result = await resolveHeadHash(tmpDir, threadId);
expect(result).toBe(headHash);
});
test("falls back to history.jsonl when thread not in threads.yaml", async () => {
const threadId = "01JTEST0000000000000000002" as ThreadId;
const headHash = "completed_hash_456" as CasRef;
const workflowHash = "workflow_hash_789" as CasRef;
// No entry in threads.yaml, only in history.jsonl
await saveThreadsIndex(tmpDir, {});
await appendThreadHistory(tmpDir, {
thread: threadId,
workflow: workflowHash,
head: headHash,
completedAt: Date.now(),
reason: null,
});
const result = await resolveHeadHash(tmpDir, threadId);
expect(result).toBe(headHash);
});
// Note: Testing the error case requires CLI-level testing because resolveHeadHash
// calls fail() which does process.exit(1), terminating the test runner.
// The error behavior is tested in integration tests below via CLI invocation.
test("prioritizes active thread over history when thread exists in both", async () => {
const threadId = "01JTEST0000000000000000004" as ThreadId;
const activeHash = "active_hash_v2" as CasRef;
const historicalHash = "historical_hash_v1" as CasRef;
const workflowHash = "workflow_hash_xyz" as CasRef;
// Thread exists in both locations (should not happen normally, but test the precedence)
await saveThreadsIndex(tmpDir, { [threadId]: activeHash });
await appendThreadHistory(tmpDir, {
thread: threadId,
workflow: workflowHash,
head: historicalHash,
completedAt: Date.now(),
reason: null,
});
const result = await resolveHeadHash(tmpDir, threadId);
// Should return the active head, not the historical one
expect(result).toBe(activeHash);
});
test("finds thread from multiple history entries", async () => {
const threadId1 = "01JTEST0000000000000000005" as ThreadId;
const threadId2 = "01JTEST0000000000000000006" as ThreadId;
const threadId3 = "01JTEST0000000000000000007" as ThreadId;
const hash1 = "hash_thread1" as CasRef;
const hash2 = "hash_thread2" as CasRef;
const hash3 = "hash_thread3" as CasRef;
const workflowHash = "workflow_hash_abc" as CasRef;
await saveThreadsIndex(tmpDir, {});
await appendThreadHistory(tmpDir, {
thread: threadId1,
workflow: workflowHash,
head: hash1,
completedAt: Date.now() - 2000,
reason: null,
});
await appendThreadHistory(tmpDir, {
thread: threadId2,
workflow: workflowHash,
head: hash2,
completedAt: Date.now() - 1000,
reason: null,
});
await appendThreadHistory(tmpDir, {
thread: threadId3,
workflow: workflowHash,
head: hash3,
completedAt: Date.now(),
reason: null,
});
const result = await resolveHeadHash(tmpDir, threadId2);
expect(result).toBe(hash2);
});
});
@@ -0,0 +1,167 @@
import { readFileSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { parse } from "yaml";
import { _agentNameFromBinary, _printAgentMenu, cmdSetup } from "../commands/setup.js";
// ─── _agentNameFromBinary ────────────────────────────────────────────────────
describe("_agentNameFromBinary", () => {
test("strips uwf- prefix", () => {
expect(_agentNameFromBinary("uwf-hermes")).toBe("hermes");
});
test("strips uwf- prefix for compound names", () => {
expect(_agentNameFromBinary("uwf-claude-code")).toBe("claude-code");
});
test("returns as-is when no uwf- prefix", () => {
expect(_agentNameFromBinary("hermes")).toBe("hermes");
});
test("handles uwf-builtin", () => {
expect(_agentNameFromBinary("uwf-builtin")).toBe("builtin");
});
});
// ─── _printAgentMenu ─────────────────────────────────────────────────────────
describe("_printAgentMenu", () => {
test("prints known agents with labels", () => {
const logs: string[] = [];
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
logs.push(args.join(" "));
});
_printAgentMenu(["uwf-hermes", "uwf-claude-code"]);
expect(logs.some((l) => l.includes("Hermes"))).toBe(true);
expect(logs.some((l) => l.includes("Claude Code"))).toBe(true);
vi.restoreAllMocks();
});
test("prints unknown agents with binary name as label", () => {
const logs: string[] = [];
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
logs.push(args.join(" "));
});
_printAgentMenu(["uwf-custom-agent"]);
expect(logs.some((l) => l.includes("uwf-custom-agent"))).toBe(true);
vi.restoreAllMocks();
});
});
// ─── cmdSetup agent config ───────────────────────────────────────────────────
describe("cmdSetup agent configuration", () => {
let storageRoot: string;
beforeEach(async () => {
storageRoot = await mkdtemp(join(tmpdir(), "uwf-setup-agent-"));
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(storageRoot, { recursive: true, force: true });
});
const baseArgs = () => ({
provider: "testprovider",
baseUrl: "https://api.test.com/v1",
apiKey: "sk-test",
model: "test-model",
storageRoot,
});
test("defaults to hermes agent when no agent specified", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
const result = await cmdSetup(baseArgs());
expect(result.defaultAgent).toBe("hermes");
const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
expect(config.agents.hermes).toEqual({ command: "uwf-hermes", args: [] });
expect(config.defaultAgent).toBe("hermes");
});
test("writes specified agent as default", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
const result = await cmdSetup({ ...baseArgs(), agent: "claude-code" });
expect(result.defaultAgent).toBe("claude-code");
const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
expect(config.agents["claude-code"]).toEqual({ command: "uwf-claude-code", args: [] });
expect(config.defaultAgent).toBe("claude-code");
});
test("preserves existing agents when adding new one", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
// First setup with hermes
await cmdSetup(baseArgs());
// Second setup with claude-code
await cmdSetup({ ...baseArgs(), agent: "claude-code" });
const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
expect(config.agents.hermes).toBeDefined();
expect(config.agents["claude-code"]).toBeDefined();
expect(config.defaultAgent).toBe("claude-code");
});
test("updates defaultAgent on re-run with different agent", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
await cmdSetup(baseArgs());
const config1 = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
expect(config1.defaultAgent).toBe("hermes");
await cmdSetup({ ...baseArgs(), agent: "builtin" });
const config2 = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
expect(config2.defaultAgent).toBe("builtin");
});
test("normalizes agent name with uwf- prefix to bare name", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
const result = await cmdSetup({ ...baseArgs(), agent: "uwf-hermes" });
expect(result.defaultAgent).toBe("hermes");
const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
expect(config.agents.hermes).toEqual({ command: "uwf-hermes", args: [] });
expect(config.defaultAgent).toBe("hermes");
// Verify no duplicate uwf- prefix
expect(config.agents["uwf-hermes"]).toBeUndefined();
});
test("normalizes uwf-claude-code to claude-code", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
const result = await cmdSetup({ ...baseArgs(), agent: "uwf-claude-code" });
expect(result.defaultAgent).toBe("claude-code");
const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
expect(config.agents["claude-code"]).toEqual({ command: "uwf-claude-code", args: [] });
expect(config.defaultAgent).toBe("claude-code");
// Verify no duplicate uwf- prefix
expect(config.agents["uwf-claude-code"]).toBeUndefined();
});
});
@@ -0,0 +1,381 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
_discoverAgents,
_isBackspace,
_isTerminator,
_parseWhichOutput,
_printModelMenu,
_printProviderMenu,
_printValidationResult,
_resolveModelChoice,
_resolveProviderChoice,
_searchPathDirs,
} from "../commands/setup.js";
// ──────────────────────────────────────────────────────────────────────────────
// 1a. _searchPathDirs
// ──────────────────────────────────────────────────────────────────────────────
describe("_searchPathDirs", () => {
test("returns empty array for empty PATH", async () => {
const result = await _searchPathDirs("");
expect(result).toEqual([]);
});
test("finds uwf-hermes in a single dir", async () => {
const dir = mkdirSync(join(tmpdir(), `uwf-test-${Date.now()}`), { recursive: true }) as
| string
| undefined;
const actualDir = dir ?? join(tmpdir(), `uwf-test-${Date.now()}`);
mkdirSync(actualDir, { recursive: true });
const filePath = join(actualDir, "uwf-hermes");
writeFileSync(filePath, "#!/bin/sh\n", { mode: 0o755 });
const result = await _searchPathDirs(actualDir);
expect(result).toContain("uwf-hermes");
});
test("skips non-uwf- prefixed binaries", async () => {
const dir = join(tmpdir(), `uwf-test-${Date.now()}-2`);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "hermes"), "#!/bin/sh\n", { mode: 0o755 });
writeFileSync(join(dir, "uwf-hermes"), "#!/bin/sh\n", { mode: 0o755 });
const result = await _searchPathDirs(dir);
expect(result).toEqual(["uwf-hermes"]);
});
test("skips entry named exactly 'uwf'", async () => {
const dir = join(tmpdir(), `uwf-test-${Date.now()}-3`);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "uwf"), "#!/bin/sh\n", { mode: 0o755 });
writeFileSync(join(dir, "uwf-hermes"), "#!/bin/sh\n", { mode: 0o755 });
const result = await _searchPathDirs(dir);
expect(result).toEqual(["uwf-hermes"]);
});
test("skips non-executable files", async () => {
const dir = join(tmpdir(), `uwf-test-${Date.now()}-4`);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "uwf-foo"), "#!/bin/sh\n", { mode: 0o644 });
const result = await _searchPathDirs(dir);
expect(result).toEqual([]);
});
test("deduplicates across PATH dirs", async () => {
const dir1 = join(tmpdir(), `uwf-test-${Date.now()}-5a`);
const dir2 = join(tmpdir(), `uwf-test-${Date.now()}-5b`);
mkdirSync(dir1, { recursive: true });
mkdirSync(dir2, { recursive: true });
writeFileSync(join(dir1, "uwf-hermes"), "#!/bin/sh\n", { mode: 0o755 });
writeFileSync(join(dir2, "uwf-hermes"), "#!/bin/sh\n", { mode: 0o755 });
const result = await _searchPathDirs(`${dir1}:${dir2}`);
expect(result).toEqual(["uwf-hermes"]);
});
test("returns sorted array", async () => {
const dir = join(tmpdir(), `uwf-test-${Date.now()}-6`);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "uwf-zoo"), "#!/bin/sh\n", { mode: 0o755 });
writeFileSync(join(dir, "uwf-alpha"), "#!/bin/sh\n", { mode: 0o755 });
writeFileSync(join(dir, "uwf-mid"), "#!/bin/sh\n", { mode: 0o755 });
const result = await _searchPathDirs(dir);
expect(result).toEqual(["uwf-alpha", "uwf-mid", "uwf-zoo"]);
});
test("skips inaccessible/nonexistent directories silently", async () => {
const result = await _searchPathDirs("/nonexistent-dir-xyz-abc-12345");
expect(result).toEqual([]);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// 1b. _parseWhichOutput
// ──────────────────────────────────────────────────────────────────────────────
describe("_parseWhichOutput", () => {
test("returns empty array for empty string", () => {
expect(_parseWhichOutput("")).toEqual([]);
});
test("parses single path", () => {
expect(_parseWhichOutput("/usr/local/bin/uwf-hermes")).toEqual(["uwf-hermes"]);
});
test("parses multiple paths", () => {
expect(_parseWhichOutput("/usr/local/bin/uwf-hermes\n/usr/bin/uwf-claude-code")).toEqual([
"uwf-claude-code",
"uwf-hermes",
]);
});
test("deduplicates identical basenames from different dirs", () => {
expect(_parseWhichOutput("/a/uwf-hermes\n/b/uwf-hermes")).toEqual(["uwf-hermes"]);
});
test("skips blank lines", () => {
expect(_parseWhichOutput("/a/uwf-hermes\n\n/b/uwf-cursor")).toEqual([
"uwf-cursor",
"uwf-hermes",
]);
});
test("skips entry named exactly 'uwf'", () => {
expect(_parseWhichOutput("/usr/bin/uwf")).toEqual([]);
});
test("skips basenames not starting with uwf-", () => {
expect(_parseWhichOutput("/usr/bin/node")).toEqual([]);
});
test("returns sorted array", () => {
expect(_parseWhichOutput("/a/uwf-zoo\n/a/uwf-alpha")).toEqual(["uwf-alpha", "uwf-zoo"]);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// 2a. _isTerminator
// ──────────────────────────────────────────────────────────────────────────────
describe("_isTerminator", () => {
test("\\n is a terminator", () => {
expect(_isTerminator("\n")).toBe(true);
});
test("\\r is a terminator", () => {
expect(_isTerminator("\r")).toBe(true);
});
test("\\u0004 (EOT) is a terminator", () => {
expect(_isTerminator("")).toBe(true);
});
test("regular char is not a terminator", () => {
expect(_isTerminator("a")).toBe(false);
});
test("empty string is not a terminator", () => {
expect(_isTerminator("")).toBe(false);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// 2b. _isBackspace
// ──────────────────────────────────────────────────────────────────────────────
describe("_isBackspace", () => {
test("\\u007F is a backspace", () => {
expect(_isBackspace("")).toBe(true);
});
test("\\b is a backspace", () => {
expect(_isBackspace("\b")).toBe(true);
});
test("regular char is not a backspace", () => {
expect(_isBackspace("x")).toBe(false);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// 3a. _printProviderMenu
// ──────────────────────────────────────────────────────────────────────────────
describe("_printProviderMenu", () => {
afterEach(() => {
vi.restoreAllMocks();
});
const providers = [
{ name: "openai", label: "OpenAI", baseUrl: "https://api.openai.com/v1" },
{ name: "xai", label: "xAI", baseUrl: "https://api.x.ai/v1" },
] as const;
test("prints correct number of lines (one per provider + custom)", () => {
const lines: string[] = [];
vi.spyOn(console, "log").mockImplementation((msg: string) => {
lines.push(msg);
});
_printProviderMenu(providers);
// 2 providers + 1 custom = 3 lines
expect(lines.length).toBe(3);
});
test("custom option number = providers.length + 1", () => {
const lines: string[] = [];
vi.spyOn(console, "log").mockImplementation((msg: string) => {
lines.push(msg);
});
_printProviderMenu(providers);
const lastLine = lines[lines.length - 1] ?? "";
expect(lastLine).toMatch(/3\)/);
});
test("each provider line contains its label and baseUrl", () => {
const lines: string[] = [];
vi.spyOn(console, "log").mockImplementation((msg: string) => {
lines.push(msg);
});
_printProviderMenu(providers);
expect(lines[0]).toContain("OpenAI");
expect(lines[0]).toContain("https://api.openai.com/v1");
expect(lines[1]).toContain("xAI");
expect(lines[1]).toContain("https://api.x.ai/v1");
});
});
// ──────────────────────────────────────────────────────────────────────────────
// 3b. _resolveProviderChoice
// ──────────────────────────────────────────────────────────────────────────────
describe("_resolveProviderChoice", () => {
const providers = [
{ name: "openai", label: "OpenAI", baseUrl: "https://api.openai.com/v1" },
{ name: "xai", label: "xAI", baseUrl: "https://api.x.ai/v1" },
{ name: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/v1" },
] as const;
test("valid index 1 returns first provider", () => {
const result = _resolveProviderChoice("1", providers);
expect(result).toEqual({ providerName: "openai", baseUrl: "https://api.openai.com/v1" });
});
test("valid index N (last preset) returns last provider", () => {
const result = _resolveProviderChoice("3", providers);
expect(result).toEqual({ providerName: "deepseek", baseUrl: "https://api.deepseek.com/v1" });
});
test("index providers.length+1 (custom) returns null", () => {
const result = _resolveProviderChoice("4", providers);
expect(result).toBeNull();
});
test("non-numeric string returns null", () => {
expect(_resolveProviderChoice("abc", providers)).toBeNull();
});
test("0 returns null (out of range)", () => {
expect(_resolveProviderChoice("0", providers)).toBeNull();
});
test("N+2 returns null (out of range)", () => {
expect(_resolveProviderChoice("5", providers)).toBeNull();
});
test("negative number returns null", () => {
expect(_resolveProviderChoice("-1", providers)).toBeNull();
});
});
// ──────────────────────────────────────────────────────────────────────────────
// 3c. _resolveModelChoice
// ──────────────────────────────────────────────────────────────────────────────
describe("_resolveModelChoice", () => {
test("numeric input within range returns model at that index", () => {
expect(_resolveModelChoice("2", ["a", "b", "c"])).toBe("b");
});
test("numeric input out of range returns input as-is", () => {
expect(_resolveModelChoice("5", ["a"])).toBe("5");
});
test("non-numeric input returns input as-is", () => {
expect(_resolveModelChoice("gpt-4o", ["a", "b"])).toBe("gpt-4o");
});
test("numeric input 1 returns first model", () => {
expect(_resolveModelChoice("1", ["alpha", "beta"])).toBe("alpha");
});
test("empty models list with numeric input returns input as-is", () => {
expect(_resolveModelChoice("1", [])).toBe("1");
});
});
// ──────────────────────────────────────────────────────────────────────────────
// 3d. _printModelMenu
// ──────────────────────────────────────────────────────────────────────────────
describe("_printModelMenu", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("prints all models — each model name appears in output", () => {
const output: string[] = [];
vi.spyOn(console, "log").mockImplementation((msg: string) => {
output.push(msg);
});
const models = ["model-a", "model-b", "model-c"];
_printModelMenu(models, 100);
const combined = output.join("\n");
for (const m of models) {
expect(combined).toContain(m);
}
});
test("single column when termCols is very small", () => {
const output: string[] = [];
vi.spyOn(console, "log").mockImplementation((msg: string) => {
output.push(msg);
});
_printModelMenu(["a", "b", "c"], 1);
// Each model on its own row → 3 lines
expect(output.length).toBe(3);
});
test("wide terminal fits multiple columns", () => {
const output: string[] = [];
vi.spyOn(console, "log").mockImplementation((msg: string) => {
output.push(msg);
});
const models = Array.from({ length: 6 }, (_, i) => `m${i}`);
_printModelMenu(models, 200);
// With wide terminal and short names, should fit in fewer than 6 rows
expect(output.length).toBeLessThan(6);
});
});
// ──────────────────────────────────────────────────────────────────────────────
// 3e. _printValidationResult
// ──────────────────────────────────────────────────────────────────────────────
describe("_printValidationResult", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("ok=true prints success message containing '✓'", () => {
const lines: string[] = [];
vi.spyOn(console, "log").mockImplementation((msg: string) => {
lines.push(msg);
});
_printValidationResult({ ok: true, error: null });
expect(lines.join("\n")).toContain("✓");
});
test("ok=false prints warning message containing '⚠'", () => {
const lines: string[] = [];
vi.spyOn(console, "log").mockImplementation((msg: string) => {
lines.push(msg);
});
_printValidationResult({ ok: false, error: "HTTP 401" });
expect(lines.join("\n")).toContain("⚠");
});
test("ok=false includes the error string in output", () => {
const lines: string[] = [];
vi.spyOn(console, "log").mockImplementation((msg: string) => {
lines.push(msg);
});
_printValidationResult({ ok: false, error: "HTTP 401" });
expect(lines.join("\n")).toContain("HTTP 401");
});
});
// ──────────────────────────────────────────────────────────────────────────────
// 4. Regression
// ──────────────────────────────────────────────────────────────────────────────
describe("_discoverAgents regression", () => {
test("returns an array (may be empty) — never throws", async () => {
const result = await _discoverAgents();
expect(Array.isArray(result)).toBe(true);
});
});
@@ -129,9 +129,8 @@ describe("cmdSetup with validation", () => {
const result = await cmdSetup(setupArgs());
expect(result.validation).toEqual({ ok: true, value: undefined });
// Config files should still be written
// Config file should still be written
expect(result.configPath).toBeTruthy();
expect(result.envPath).toBeTruthy();
});
test("includes validation failure — config still saved", async () => {
@@ -143,8 +142,7 @@ describe("cmdSetup with validation", () => {
expect(result.validation).toBeDefined();
expect((result.validation as { ok: boolean }).ok).toBe(false);
// Config files should still be written despite validation failure
// Config file should still be written despite validation failure
expect(result.configPath).toBeTruthy();
expect(result.envPath).toBeTruthy();
});
});
@@ -0,0 +1,140 @@
import { execFileSync } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, test } from "vitest";
const __dirname = dirname(fileURLToPath(import.meta.url));
import {
cmdSkillActor,
cmdSkillAdapter,
cmdSkillArchitecture,
cmdSkillAuthor,
cmdSkillCli,
cmdSkillDeveloper,
cmdSkillList,
cmdSkillModerator,
cmdSkillUser,
cmdSkillYaml,
} from "../commands/skill.js";
describe("skill commands", () => {
test("skill list returns all skill names", () => {
const result = cmdSkillList();
expect(result).toBeInstanceOf(Array);
expect(result).toContain("cli");
expect(result).toContain("architecture");
expect(result).toContain("yaml");
expect(result).toContain("moderator");
expect(result).toContain("actor");
expect(result).toContain("user");
expect(result).toContain("author");
expect(result).toContain("developer");
expect(result).toContain("adapter");
for (const name of result) {
expect(name).toMatch(/^\S+$/);
}
});
test("skill architecture returns non-empty markdown string", () => {
const result = cmdSkillArchitecture();
expect(typeof result).toBe("string");
expect(result).toContain("CAS");
expect(result).toContain("Thread");
expect(result).toContain("Workflow");
expect(result).toContain("Step");
expect(result.length).toBeGreaterThan(200);
});
test("skill yaml returns non-empty markdown string", () => {
const result = cmdSkillYaml();
expect(typeof result).toBe("string");
expect(result).toContain("roles");
expect(result).toContain("graph");
expect(result).toContain("frontmatter");
expect(result.length).toBeGreaterThan(200);
});
test("skill moderator returns non-empty markdown string", () => {
const result = cmdSkillModerator();
expect(typeof result).toBe("string");
expect(result).toContain("routing");
expect(result).toContain("status");
expect(result.length).toBeGreaterThan(200);
// Check for edge or graph
expect(result).toMatch(/edge|graph/i);
});
test("skill cli returns CLI reference markdown", () => {
const result = cmdSkillCli();
expect(typeof result).toBe("string");
expect(result).toContain("uwf");
});
test("skill actor returns non-empty markdown string", () => {
const result = cmdSkillActor();
expect(typeof result).toBe("string");
expect(result).toContain("frontmatter");
expect(result).toContain("CAS");
expect(result).toContain("status");
expect(result.length).toBeGreaterThan(200);
});
test("skill user returns non-empty markdown string", () => {
const result = cmdSkillUser();
expect(typeof result).toBe("string");
expect(result).toContain("uwf");
expect(result).toContain("thread");
expect(result).toContain("workflow");
expect(result).toContain("Quick Start");
expect(result.length).toBeGreaterThan(500);
});
test("skill author returns non-empty markdown string", () => {
const result = cmdSkillAuthor();
expect(typeof result).toBe("string");
expect(result).toContain("frontmatter");
expect(result).toContain("graph");
expect(result).toContain("$START");
expect(result).toContain("$END");
expect(result).toContain("$status");
expect(result.length).toBeGreaterThan(500);
});
test("skill developer returns non-empty markdown string", () => {
const result = cmdSkillDeveloper();
expect(typeof result).toBe("string");
expect(result).toContain("Monorepo");
expect(result).toContain("CAS");
expect(result).toContain("Biome");
expect(result.length).toBeGreaterThan(500);
});
test("skill adapter returns non-empty markdown string", () => {
const result = cmdSkillAdapter();
expect(typeof result).toBe("string");
expect(result).toContain("createAgent");
expect(result).toContain("AgentContext");
expect(result).toContain("frontmatter");
expect(result.length).toBeGreaterThan(500);
});
test("skill help subcommand is suppressed", () => {
const output = execFileSync("bun", ["src/cli.ts", "skill", "--help"], {
cwd: join(__dirname, "..", ".."),
encoding: "utf-8",
env: { ...process.env, PATH: `/opt/homebrew/bin:${process.env.PATH}` },
});
expect(output).not.toMatch(/help\s+\[command\]/i);
expect(output).toContain("cli");
expect(output).toContain("architecture");
expect(output).toContain("yaml");
expect(output).toContain("moderator");
expect(output).toContain("actor");
expect(output).toContain("user");
expect(output).toContain("author");
expect(output).toContain("developer");
expect(output).toContain("adapter");
expect(output).toContain("list");
});
});
@@ -0,0 +1,106 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { WorkflowPayload } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { parse } from "yaml";
/**
* Test: Issue #474 - tea pr create fails in git worktree directories
*
* This test verifies that the solve-issue workflow's committer role
* includes the --repo flag when running tea pr create, which fixes
* the "path segment [0] is empty" error in worktree directories.
*/
describe("solve-issue workflow: tea pr create worktree fix", () => {
// Navigate up from packages/cli-workflow/src/__tests__ to repo root
const workflowPath = join(
import.meta.dirname,
"..",
"..",
"..",
"..",
".workflows",
"solve-issue.yaml",
);
test("committer procedure should include --repo flag in tea pr create command", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
const workflow = parse(yamlContent) as WorkflowPayload;
expect(workflow.roles.committer).toBeDefined();
const committerProcedure = workflow.roles.committer?.procedure;
expect(committerProcedure).toBeDefined();
// Verify the procedure includes tea pr create with --repo flag
expect(committerProcedure).toContain("tea pr create");
expect(committerProcedure).toContain("--repo");
// Verify the --repo flag appears before or together with tea pr create
// This ensures the command is: tea pr create --repo <owner/repo> ...
const teaPrCreateMatch = committerProcedure?.match(/tea pr create[^\n]*/);
expect(teaPrCreateMatch).not.toBeNull();
if (teaPrCreateMatch) {
const teaCommandLine = teaPrCreateMatch[0];
expect(teaCommandLine).toContain("--repo");
}
});
test("committer procedure should mention repo extraction from git remote", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
const workflow = parse(yamlContent) as WorkflowPayload;
const committerProcedure = workflow.roles.committer?.procedure;
expect(committerProcedure).toBeDefined();
// Verify the procedure mentions extracting repo info from git remote
// This ensures fallback logic is documented
expect(committerProcedure).toMatch(/git remote/i);
});
test("committer procedure should include error handling for tea failures", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
const workflow = parse(yamlContent) as WorkflowPayload;
const committerProcedure = workflow.roles.committer?.procedure;
expect(committerProcedure).toBeDefined();
// Verify the procedure includes error handling guidance
// This ensures we capture failures and provide actionable output
expect(committerProcedure).toMatch(/error|fail/i);
});
test("workflow should be parseable as valid WorkflowPayload", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
const workflow = parse(yamlContent) as WorkflowPayload;
// Basic structure validation
expect(workflow.name).toBe("solve-issue");
expect(workflow.roles).toBeDefined();
expect(workflow.graph).toBeDefined();
// Verify committer role exists with required fields
expect(workflow.roles.committer).toBeDefined();
expect(workflow.roles.committer?.description).toBeDefined();
expect(workflow.roles.committer?.goal).toBeDefined();
expect(workflow.roles.committer?.procedure).toBeDefined();
expect(workflow.roles.committer?.output).toBeDefined();
expect(workflow.roles.committer?.frontmatter).toBeDefined();
});
test("committer frontmatter schema should be oneOf with $status discriminant", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
// Parse as any to access the raw YAML structure (frontmatter is inline JSON Schema in YAML)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const workflow = parse(yamlContent) as any;
const frontmatter = workflow.roles.committer?.frontmatter;
expect(frontmatter).toBeDefined();
expect(frontmatter?.oneOf).toBeDefined();
const committedVariant = frontmatter.oneOf.find(
(v: any) => v.properties?.["$status"]?.const === "committed",
);
expect(committedVariant).toBeDefined();
expect(committedVariant.required).toContain("$status");
});
});
@@ -0,0 +1,602 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdStepRead } from "../commands/step.js";
import { registerUwfSchemas } from "../schemas.js";
// ── schemas used in tests ────────────────────────────────────────────────────
const TURN_SCHEMA = {
title: "hermes-turn",
type: "object" as const,
required: ["index", "role", "content"],
properties: {
index: { type: "integer" as const },
role: { type: "string" as const },
content: { type: "string" as const },
toolCalls: {
anyOf: [
{ type: "array" as const, items: { type: "object" as const } },
{ type: "null" as const },
],
},
reasoning: { anyOf: [{ type: "string" as const }, { type: "null" as const }] },
},
additionalProperties: false,
};
const DETAIL_SCHEMA = {
title: "hermes-detail",
type: "object" as const,
required: ["sessionId", "model", "duration", "turnCount", "turns"],
properties: {
sessionId: { type: "string" as const },
model: { type: "string" as const },
duration: { type: "integer" as const },
turnCount: { type: "integer" as const },
turns: {
type: "array" as const,
items: { type: "string" as const, format: "cas_ref" },
},
},
additionalProperties: false,
};
// ── helpers ───────────────────────────────────────────────────────────────────
async function registerDetailSchemas(store: ReturnType<typeof createFsStore>) {
await bootstrap(store);
const [turn, detail] = await Promise.all([
putSchema(store, TURN_SCHEMA),
putSchema(store, DETAIL_SCHEMA),
]);
return { turn, detail };
}
function generateContent(size: number, prefix = "Content"): string {
const base = `${prefix} `;
const repeat = Math.ceil(size / base.length);
return base.repeat(repeat).slice(0, size);
}
// ── fixture ───────────────────────────────────────────────────────────────────
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-step-read-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ── step read tests ───────────────────────────────────────────────────────────
describe("step read", () => {
test("test 1: basic single-step read with 3 turns", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do the work.",
output: "Summarize the work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test task",
});
const outputHash = await store.put(schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
// Create 3 turns
const turnHashes: CasRef[] = [];
for (let i = 1; i <= 3; i++) {
const content = `Turn ${i} content with some text to make it readable.`;
const turnHash = await store.put(detailSchemas.turn, {
index: i - 1,
role: "assistant",
content,
toolCalls: null,
reasoning: null,
});
turnHashes.push(turnHash);
}
const detailHash = await store.put(detailSchemas.detail, {
sessionId: "session-1",
model: "test-model",
duration: 1000,
turnCount: 3,
turns: turnHashes,
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
// Read step with large quota
const markdown = await cmdStepRead(tmpDir, stepHash, 10000);
// Assert structure
expect(markdown).toContain(`# Step ${stepHash}`);
expect(markdown).toContain("**Role:** worker");
expect(markdown).toContain("**Agent:** uwf-test");
expect(markdown).toContain("## Turn 1");
expect(markdown).toContain("## Turn 2");
expect(markdown).toContain("## Turn 3");
expect(markdown).toContain("Turn 1 content with some text to make it readable.");
expect(markdown).toContain("Turn 2 content with some text to make it readable.");
expect(markdown).toContain("Turn 3 content with some text to make it readable.");
});
test("test 2: quota enforcement - multiple turns", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do the work.",
output: "Summarize the work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test task",
});
const outputHash = await store.put(schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
// Create 4 turns of ~300 chars each
const turnHashes: CasRef[] = [];
for (let i = 1; i <= 4; i++) {
const content = generateContent(300, `Turn${i}`);
const turnHash = await store.put(detailSchemas.turn, {
index: i - 1,
role: "assistant",
content,
toolCalls: null,
reasoning: null,
});
turnHashes.push(turnHash);
}
const detailHash = await store.put(detailSchemas.detail, {
sessionId: "session-1",
model: "test-model",
duration: 1000,
turnCount: 4,
turns: turnHashes,
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
// Read step with limited quota (700 chars)
const markdown = await cmdStepRead(tmpDir, stepHash, 700);
// Assert only most recent turns fit
expect(markdown).toContain(`# Step ${stepHash}`);
// Should have skip hint
expect(markdown).toContain("Earlier turns omitted");
// Should include at least Turn 4 (most recent)
expect(markdown).toContain("Turn4");
// Total length should respect quota (with tolerance for structural overhead)
expect(markdown.length).toBeLessThanOrEqual(900); // 700 quota + 200 buffer tolerance
});
test("test 3: minimal quota edge case - always show at least one turn", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do the work.",
output: "Summarize the work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test task",
});
const outputHash = await store.put(schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
// Create 1 turn of 500 chars
const content = generateContent(500, "LongTurn");
const turnHash = await store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content,
toolCalls: null,
reasoning: null,
});
const detailHash = await store.put(detailSchemas.detail, {
sessionId: "session-1",
model: "test-model",
duration: 1000,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
// Read step with minimal quota (1 char)
const markdown = await cmdStepRead(tmpDir, stepHash, 1);
// Assert at least one turn is always shown
expect(markdown).toContain("LongTurn");
expect(markdown.length).toBeGreaterThan(1);
});
test("test 4: step with no detail field", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do the work.",
output: "Summarize the work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test task",
});
const outputHash = await store.put(schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
// Read step - should return metadata only (no error)
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
// Assert metadata is present
expect(markdown).toContain(`# Step ${stepHash}`);
expect(markdown).toContain("**Role:** worker");
expect(markdown).toContain("**Agent:** uwf-test");
// Should not have turn sections
expect(markdown).not.toContain("## Turn");
});
test("test 5: step with detail but no turns array", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do the work.",
output: "Summarize the work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test task",
});
const outputHash = await store.put(schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
// Create detail with different schema (no turns)
const SIMPLE_DETAIL_SCHEMA = {
title: "simple-detail",
type: "object" as const,
required: ["sessionId"],
properties: {
sessionId: { type: "string" as const },
},
additionalProperties: false,
};
await bootstrap(store);
const simpleDetailType = await putSchema(store, SIMPLE_DETAIL_SCHEMA);
const detailHash = await store.put(simpleDetailType, {
sessionId: "session-1",
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
// Read step - should return metadata only (no error)
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
// Assert metadata is present
expect(markdown).toContain(`# Step ${stepHash}`);
expect(markdown).toContain("**Role:** worker");
// Should not have turn sections
expect(markdown).not.toContain("## Turn");
});
test("test 6: displays role and tool calls in turn body", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do the work.",
output: "Summarize the work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test task",
});
const outputHash = await store.put(schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const turnHash = await store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content: "",
toolCalls: [{ name: "terminal", args: '{"command":"echo hi"}' }],
reasoning: null,
});
const detailHash = await store.put(detailSchemas.detail, {
sessionId: "session-1",
model: "test-model",
duration: 1000,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-hermes",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
expect(markdown).toContain("**Turn role:** assistant");
expect(markdown).toContain("**terminal**");
expect(markdown).toContain('{"command":"echo hi"}');
});
test("test 7: turn content with special characters", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do the work.",
output: "Summarize the work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test task",
});
const outputHash = await store.put(schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
// Create turn with special markdown characters
const content = "This has `backticks`, **bold**, *italic*, and [links](http://example.com)";
const turnHash = await store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content,
toolCalls: null,
reasoning: null,
});
const detailHash = await store.put(detailSchemas.detail, {
sessionId: "session-1",
model: "test-model",
duration: 1000,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
// Read step
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
// Assert content is rendered correctly without corruption
expect(markdown).toContain("`backticks`");
expect(markdown).toContain("**bold**");
expect(markdown).toContain("*italic*");
expect(markdown).toContain("[links](http://example.com)");
});
});
@@ -0,0 +1,378 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { STEP_NODE_SCHEMA } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdStepList } from "../commands/step.js";
import { cmdThreadRead } from "../commands/thread.js";
import { registerUwfSchemas } from "../schemas.js";
import { saveThreadsIndex } from "../store.js";
// ── schemas ──────────────────────────────────────────────────────────────────
const TURN_SCHEMA = {
title: "hermes-turn",
type: "object" as const,
required: ["index", "role", "content"],
properties: {
index: { type: "integer" as const },
role: { type: "string" as const },
content: { type: "string" as const },
toolCalls: {
anyOf: [
{ type: "array" as const, items: { type: "object" as const } },
{ type: "null" as const },
],
},
reasoning: { anyOf: [{ type: "string" as const }, { type: "null" as const }] },
},
additionalProperties: false,
};
const DETAIL_SCHEMA = {
title: "hermes-detail",
type: "object" as const,
required: ["sessionId", "model", "duration", "turnCount", "turns"],
properties: {
sessionId: { type: "string" as const },
model: { type: "string" as const },
duration: { type: "integer" as const },
turnCount: { type: "integer" as const },
turns: {
type: "array" as const,
items: { type: "string" as const, format: "cas_ref" },
},
},
additionalProperties: false,
};
// ── helpers ──────────────────────────────────────────────────────────────────
async function registerDetailSchemas(store: ReturnType<typeof createFsStore>) {
await bootstrap(store);
const [turn, detail] = await Promise.all([
putSchema(store, TURN_SCHEMA),
putSchema(store, DETAIL_SCHEMA),
]);
return { turn, detail };
}
// ── fixture ──────────────────────────────────────────────────────────────────
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-step-timing-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ── 1. Protocol types (compile-time) ─────────────────────────────────────────
describe("protocol types", () => {
test("StepRecord has startedAtMs and completedAtMs as required fields", () => {
// Type-level test: this block compiles only if fields exist and are number
const record: import("@uncaged/workflow-protocol").StepRecord = {
role: "test",
output: "hash1" as CasRef,
detail: "hash2" as CasRef,
agent: "uwf-test",
edgePrompt: "",
startedAtMs: 1000,
completedAtMs: 2000,
};
expect(record.startedAtMs).toBe(1000);
expect(record.completedAtMs).toBe(2000);
});
test("StepEntry has durationMs as required field", () => {
const entry: import("@uncaged/workflow-protocol").StepEntry = {
hash: "hash" as CasRef,
role: "test",
output: {},
detail: "hash2" as CasRef,
agent: "uwf-test",
timestamp: 123,
durationMs: 5000,
};
expect(entry.durationMs).toBe(5000);
});
});
// ── 2. JSON Schema ───────────────────────────────────────────────────────────
describe("StepNode JSON schema", () => {
test("schema requires startedAtMs and completedAtMs", () => {
const required = STEP_NODE_SCHEMA.required as string[];
expect(required).toContain("startedAtMs");
expect(required).toContain("completedAtMs");
});
test("schema defines timing fields as integer", () => {
const props = STEP_NODE_SCHEMA.properties as Record<string, { type: string }>;
expect(props.startedAtMs.type).toBe("integer");
expect(props.completedAtMs.type).toBe("integer");
});
test("StepNode with timing fields passes CAS validation", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const startHash = await store.put(schemas.startNode, {
workflow: "placeholder0000" as CasRef,
prompt: "test",
});
const outputHash = await store.put(schemas.text, "output text");
const detailSchemas = await registerDetailSchemas(store);
const detailHash = await store.put(detailSchemas.detail, {
sessionId: "s1",
model: "m1",
duration: 100,
turnCount: 0,
turns: [],
});
// Should succeed — valid timing fields
const hash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
edgePrompt: "",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
expect(hash).toBeTruthy();
});
});
// ── 3. step list — durationMs computed ───────────────────────────────────────
describe("step list timing", () => {
test("step list includes durationMs = completedAtMs - startedAtMs", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "test",
});
const outputHash = await store.put(schemas.text, "output");
const detailHash = await store.put(detailSchemas.detail, {
sessionId: "s1",
model: "m1",
duration: 100,
turnCount: 0,
turns: [],
});
const startedAt = 1716600000000;
const completedAt = 1716600003500;
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
edgePrompt: "",
startedAtMs: startedAt,
completedAtMs: completedAt,
});
const threadId = "01HX2Q3R4S5T6V7W8X9YZ1" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const result = await cmdStepList(tmpDir, threadId);
const stepEntries = result.steps.slice(1); // skip start entry
expect(stepEntries).toHaveLength(1);
const step = stepEntries[0] as import("@uncaged/workflow-protocol").StepEntry;
expect(step.durationMs).toBe(3500);
});
});
// ── 4. thread read — duration in header ──────────────────────────────────────
describe("thread read timing", () => {
test("thread read header includes Duration", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "Do work",
capabilities: [],
procedure: "work",
output: "result",
frontmatter: "placeholder0000" as CasRef,
},
},
graph: {
$START: { _: { role: "worker", prompt: "go" } },
worker: { _: { role: "$END", prompt: "" } },
},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "test task",
});
const turnHash = await store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content: "Done.",
toolCalls: null,
reasoning: null,
});
const detailHash = await store.put(detailSchemas.detail, {
sessionId: "s1",
model: "m1",
duration: 100,
turnCount: 1,
turns: [turnHash],
});
const outputHash = await store.put(schemas.text, "output");
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
edgePrompt: "",
startedAtMs: 1716600000000,
completedAtMs: 1716600042000,
});
const threadId = "01HX2Q3R4S5T6V7W8X9YZ3" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const markdown = await cmdThreadRead(tmpDir, threadId, 10000, null, false);
expect(markdown).toContain("**Duration:** 42.0s");
});
test("thread read shows sub-second duration as ms", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "Do work",
capabilities: [],
procedure: "work",
output: "result",
frontmatter: "placeholder0000" as CasRef,
},
},
graph: {
$START: { _: { role: "worker", prompt: "go" } },
worker: { _: { role: "$END", prompt: "" } },
},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "test",
});
const turnHash = await store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content: "Done.",
toolCalls: null,
reasoning: null,
});
const detailHash = await store.put(detailSchemas.detail, {
sessionId: "s1",
model: "m1",
duration: 100,
turnCount: 1,
turns: [turnHash],
});
const outputHash = await store.put(schemas.text, "output");
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
edgePrompt: "",
startedAtMs: 1716600000000,
completedAtMs: 1716600000350,
});
const threadId = "01HX2Q3R4S5T6V7W8X9YZ4" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const markdown = await cmdThreadRead(tmpDir, threadId, 10000, null, false);
expect(markdown).toContain("**Duration:** 350ms");
});
});
// ── 6. Breaking change — old data without timing fails ───────────────────────
describe("breaking change", () => {
test("StepNode schema rejects payload without timing fields", () => {
const required = STEP_NODE_SCHEMA.required as string[];
// Both fields must be in the required array
expect(required).toContain("startedAtMs");
expect(required).toContain("completedAtMs");
// Payload without timing fields would fail schema validation
// because the schema marks them as required
const payloadWithoutTiming = {
start: "hash1",
prev: null,
role: "worker",
output: "hash2",
detail: "hash3",
agent: "uwf-test",
edgePrompt: "",
};
// Verify the payload is missing required fields
expect(payloadWithoutTiming).not.toHaveProperty("startedAtMs");
expect(payloadWithoutTiming).not.toHaveProperty("completedAtMs");
});
});
@@ -0,0 +1,85 @@
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { appendThreadHistory, loadThreadHistory } from "../store.js";
describe("thread cancel status", () => {
test("cancelled history entry has reason 'cancelled'", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-"));
const threadId = "01JTEST000000000000CANCEL1" as ThreadId;
await appendThreadHistory(tmpDir, {
thread: threadId,
workflow: "test-workflow",
head: "test-head-hash" as CasRef,
completedAt: Date.now(),
reason: "cancelled",
});
const history = await loadThreadHistory(tmpDir);
expect(history).toHaveLength(1);
expect(history[0]?.reason).toBe("cancelled");
});
test("completed history entry has reason 'completed'", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-"));
const threadId = "01JTEST000000000000CANCEL2" as ThreadId;
await appendThreadHistory(tmpDir, {
thread: threadId,
workflow: "test-workflow",
head: "test-head-hash" as CasRef,
completedAt: Date.now(),
reason: "completed",
});
const history = await loadThreadHistory(tmpDir);
expect(history).toHaveLength(1);
expect(history[0]?.reason).toBe("completed");
});
test("legacy history entry without reason parses as null", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-"));
const threadId = "01JTEST000000000000CANCEL3" as ThreadId;
// Simulate legacy entry without reason field
await appendThreadHistory(tmpDir, {
thread: threadId,
workflow: "test-workflow",
head: "test-head-hash" as CasRef,
completedAt: Date.now(),
reason: null,
});
const history = await loadThreadHistory(tmpDir);
expect(history).toHaveLength(1);
expect(history[0]?.reason).toBeNull();
});
test("mixed completed and cancelled entries preserve distinct reasons", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-"));
await appendThreadHistory(tmpDir, {
thread: "01JTEST000000000000CANCEL4" as ThreadId,
workflow: "test-workflow",
head: "head1" as CasRef,
completedAt: Date.now(),
reason: "completed",
});
await appendThreadHistory(tmpDir, {
thread: "01JTEST000000000000CANCEL5" as ThreadId,
workflow: "test-workflow",
head: "head2" as CasRef,
completedAt: Date.now(),
reason: "cancelled",
});
const history = await loadThreadHistory(tmpDir);
expect(history).toHaveLength(2);
expect(history[0]?.reason).toBe("completed");
expect(history[1]?.reason).toBe("cancelled");
});
});
@@ -0,0 +1,551 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { extractUlidTimestamp, generateUlid } from "@uncaged/workflow-util";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createMarker, deleteMarker } from "../background/index.js";
import { cmdThreadList } from "../commands/thread.js";
import { parseTimeInput } from "../commands/thread-time-parser.js";
import type { UwfStore } from "../store.js";
import { appendThreadHistory, createUwfStore, saveThreadsIndex } from "../store.js";
// ── helpers ───────────────────────────────────────────────────────────────────
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = join(storageRoot, "cas");
await mkdir(casDir, { recursive: true });
return createUwfStore(storageRoot);
}
async function createTestWorkflow(uwf: UwfStore): Promise<CasRef> {
const workflowPayload = {
name: "test-workflow",
roles: {
role1: {
goal: "test goal",
outputSchema: { type: "object" as const, properties: {} },
},
},
graph: { start: "role1" },
conditions: {},
};
return await uwf.store.put(uwf.schemas.workflow, workflowPayload);
}
async function createTestThread(
uwf: UwfStore,
storageRoot: string,
workflowHash: CasRef,
timestamp: number,
): Promise<ThreadId> {
const threadId = generateUlid(timestamp) as ThreadId;
const startPayload = {
workflow: workflowHash,
prompt: "test prompt",
};
const headHash = await uwf.store.put(uwf.schemas.startNode, startPayload);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
index[threadId] = headHash;
await saveThreadsIndex(storageRoot, index);
return threadId;
}
async function markThreadRunning(storageRoot: string, threadId: ThreadId, workflow: CasRef) {
await createMarker(storageRoot, {
thread: threadId,
workflow,
pid: process.pid, // Use current process PID so isPidAlive returns true
startedAt: Date.now(),
});
}
async function completeThread(
storageRoot: string,
threadId: ThreadId,
workflowHash: CasRef,
headHash: CasRef,
) {
const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
delete index[threadId];
await saveThreadsIndex(storageRoot, index);
await appendThreadHistory(storageRoot, {
thread: threadId,
workflow: workflowHash,
head: headHash,
completedAt: Date.now(),
reason: null,
});
}
// ── test setup ────────────────────────────────────────────────────────────────
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "thread-list-filters-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ── status filter tests ───────────────────────────────────────────────────────
describe("cmdThreadList status filter", () => {
test("should return idle and running threads when status=active", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
await markThreadRunning(tmpDir, thread2, workflowHash);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
const thread3Head = index[thread3];
if (thread3Head === undefined) throw new Error("thread3 head not found");
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
const result = await cmdThreadList(tmpDir, ["idle", "running"], null, null, null, null);
expect(result).toHaveLength(2);
expect(result.map((r) => r.thread).sort()).toEqual([thread1, thread2].sort());
// Clean up marker after test
await deleteMarker(tmpDir, thread2);
});
test("should support comma-separated status values", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
await markThreadRunning(tmpDir, thread2, workflowHash);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
const thread3Head = index[thread3];
if (thread3Head === undefined) throw new Error("thread3 head not found");
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
const result = await cmdThreadList(tmpDir, ["idle", "completed"], null, null, null, null);
// Clean up marker
await deleteMarker(tmpDir, thread2);
// thread2 is running (not idle), so should not be included
// Expected: thread1 (idle) and thread3 (completed)
expect(result).toHaveLength(2);
expect(result.map((r) => r.thread).sort()).toEqual([thread1, thread3].sort());
});
test("should support single status filter (backward compat)", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const _thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
const _thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
const thread3Head = index[thread3];
if (thread3Head === undefined) throw new Error("thread3 head not found");
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
const result = await cmdThreadList(tmpDir, ["completed"], null, null, null, null);
expect(result).toHaveLength(1);
expect(result[0]?.thread).toBe(thread3);
expect(result[0]?.status).toBe("completed");
});
test("should return all threads when no status filter provided", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
await markThreadRunning(tmpDir, thread2, workflowHash);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
const thread3Head = index[thread3];
if (thread3Head === undefined) throw new Error("thread3 head not found");
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
const result = await cmdThreadList(tmpDir, null, null, null, null, null);
expect(result).toHaveLength(3);
expect(result.map((r) => r.thread).sort()).toEqual([thread1, thread2, thread3].sort());
});
});
// ── time range filtering tests ────────────────────────────────────────────────
describe("cmdThreadList time filters", () => {
test("should filter threads created after given timestamp", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const ts1 = Date.UTC(2026, 4, 20, 0, 0, 0);
const ts2 = Date.UTC(2026, 4, 21, 0, 0, 0);
const ts3 = Date.UTC(2026, 4, 22, 0, 0, 0);
const _threadA = await createTestThread(uwf, tmpDir, workflowHash, ts1);
const threadB = await createTestThread(uwf, tmpDir, workflowHash, ts2);
const threadC = await createTestThread(uwf, tmpDir, workflowHash, ts3);
// Use a timestamp slightly before ts2 to include threadB
const afterMs = Date.UTC(2026, 4, 20, 12, 0, 0);
const result = await cmdThreadList(tmpDir, null, afterMs, null, null, null);
expect(result).toHaveLength(2);
expect(result.map((r) => r.thread).sort()).toEqual([threadB, threadC].sort());
});
test("should filter threads created before given timestamp", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const ts1 = Date.UTC(2026, 4, 20, 0, 0, 0);
const ts2 = Date.UTC(2026, 4, 21, 0, 0, 0);
const ts3 = Date.UTC(2026, 4, 22, 0, 0, 0);
const threadA = await createTestThread(uwf, tmpDir, workflowHash, ts1);
const threadB = await createTestThread(uwf, tmpDir, workflowHash, ts2);
const _threadC = await createTestThread(uwf, tmpDir, workflowHash, ts3);
const beforeMs = Date.UTC(2026, 4, 22, 0, 0, 0);
const result = await cmdThreadList(tmpDir, null, null, beforeMs, null, null);
expect(result).toHaveLength(2);
expect(result.map((r) => r.thread).sort()).toEqual([threadA, threadB].sort());
});
test("should support both after and before filters (time range)", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const ts1 = Date.UTC(2026, 4, 20, 0, 0, 0);
const ts2 = Date.UTC(2026, 4, 21, 0, 0, 0);
const ts3 = Date.UTC(2026, 4, 22, 0, 0, 0);
const _threadA = await createTestThread(uwf, tmpDir, workflowHash, ts1);
const threadB = await createTestThread(uwf, tmpDir, workflowHash, ts2);
const _threadC = await createTestThread(uwf, tmpDir, workflowHash, ts3);
const afterMs = Date.UTC(2026, 4, 20, 12, 0, 0);
const beforeMs = Date.UTC(2026, 4, 22, 0, 0, 0);
const result = await cmdThreadList(tmpDir, null, afterMs, beforeMs, null, null);
expect(result).toHaveLength(1);
expect(result[0]?.thread).toBe(threadB);
});
});
// ── pagination tests ──────────────────────────────────────────────────────────
describe("cmdThreadList pagination", () => {
test("should limit results with --take", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const threads: ThreadId[] = [];
for (let i = 0; i < 10; i++) {
threads.push(await createTestThread(uwf, tmpDir, workflowHash, Date.now() - i * 1000));
}
const result = await cmdThreadList(tmpDir, null, null, null, null, 5);
expect(result).toHaveLength(5);
});
test("should skip first N threads with --skip", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const threads: ThreadId[] = [];
// Create threads in chronological order, but they'll be sorted newest first
for (let i = 0; i < 10; i++) {
threads.push(await createTestThread(uwf, tmpDir, workflowHash, Date.now() + i * 100));
// Small delay to ensure distinct timestamps
await new Promise((resolve) => setTimeout(resolve, 10));
}
const result = await cmdThreadList(tmpDir, null, null, null, 3, null);
expect(result).toHaveLength(7);
// The 3 newest threads should be skipped, so we should get the 7 oldest
});
test("should support skip + take for pagination", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const threads: ThreadId[] = [];
for (let i = 0; i < 10; i++) {
threads.push(await createTestThread(uwf, tmpDir, workflowHash, Date.now() + i * 100));
await new Promise((resolve) => setTimeout(resolve, 10));
}
const result = await cmdThreadList(tmpDir, null, null, null, 5, 3);
expect(result).toHaveLength(3);
// Should skip first 5 (newest), then take 3
});
test("should handle take > available threads", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const _thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
const _thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
const _thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
const result = await cmdThreadList(tmpDir, null, null, null, null, 10);
expect(result).toHaveLength(3);
});
test("should return empty array when skip >= thread count", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
const result = await cmdThreadList(tmpDir, null, null, null, 5, null);
expect(result).toHaveLength(0);
});
});
// ── combined filters tests ────────────────────────────────────────────────────
describe("combined filters", () => {
test("should combine status and time range filters", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const ts1 = Date.UTC(2026, 4, 20, 0, 0, 0);
const ts2 = Date.UTC(2026, 4, 21, 0, 0, 0);
const ts3 = Date.UTC(2026, 4, 22, 0, 0, 0);
const ts4 = Date.UTC(2026, 4, 23, 0, 0, 0);
const _thread1 = await createTestThread(uwf, tmpDir, workflowHash, ts1);
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, ts2);
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, ts3);
const thread4 = await createTestThread(uwf, tmpDir, workflowHash, ts4);
await markThreadRunning(tmpDir, thread2, workflowHash);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
const thread3Head = index[thread3];
if (thread3Head === undefined) throw new Error("thread3 head not found");
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
const afterMs = Date.UTC(2026, 4, 20, 12, 0, 0);
const result = await cmdThreadList(tmpDir, ["idle"], afterMs, null, null, null);
expect(result).toHaveLength(1);
expect(result[0]?.thread).toBe(thread4);
expect(result[0]?.status).toBe("idle");
// Clean up marker
await deleteMarker(tmpDir, thread2);
});
test("should combine status filter and pagination", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const threads: ThreadId[] = [];
for (let i = 9; i >= 0; i--) {
const thread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() + i * 1000);
threads.push(thread);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
const headHash = index[thread];
if (headHash === undefined) throw new Error("head not found");
await completeThread(tmpDir, thread, workflowHash, headHash);
}
const result = await cmdThreadList(tmpDir, ["completed"], null, null, 3, 5);
expect(result).toHaveLength(5);
for (const r of result) {
expect(r.status).toBe("completed");
}
});
test("should combine time range and pagination", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const threads: ThreadId[] = [];
for (let i = 0; i < 20; i++) {
const ts = Date.UTC(2026, 4, 1 + i, 0, 0, 0);
threads.push(await createTestThread(uwf, tmpDir, workflowHash, ts));
}
const afterMs = Date.UTC(2026, 4, 10, 0, 0, 0);
const result = await cmdThreadList(tmpDir, null, afterMs, null, 2, 5);
expect(result).toHaveLength(5);
for (const r of result) {
const ts = extractUlidTimestamp(r.thread);
expect(ts).not.toBeNull();
if (ts !== null) {
expect(ts).toBeGreaterThan(afterMs);
}
}
});
async function setupMixedStatusThreads(
uwf: UwfStore,
workflowHash: string,
count: number,
): Promise<ThreadId[]> {
const threads: ThreadId[] = [];
for (let i = 0; i < count; i++) {
const ts = Date.UTC(2026, 4, 10 + i, 0, 0, 0);
const thread = await createTestThread(uwf, tmpDir, workflowHash, ts);
threads.push(thread);
if (i % 2 === 0) {
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
const headHash = index[thread];
if (headHash === undefined) throw new Error("head not found");
await completeThread(tmpDir, thread, workflowHash, headHash);
} else {
await markThreadRunning(tmpDir, thread, workflowHash);
}
}
return threads;
}
async function cleanupRunningMarkers(threads: ThreadId[]): Promise<void> {
for (let i = 0; i < threads.length; i++) {
if (i % 2 !== 0) {
await deleteMarker(tmpDir, threads[i] as ThreadId);
}
}
}
test("should combine all filters (status + time + pagination)", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const threads = await setupMixedStatusThreads(uwf, workflowHash, 15);
const afterMs = Date.UTC(2026, 4, 14, 12, 0, 0);
const beforeMs = Date.UTC(2026, 4, 20, 0, 0, 0);
const result = await cmdThreadList(tmpDir, ["idle", "running"], afterMs, beforeMs, 1, 3);
expect(result.length).toBeLessThanOrEqual(3);
for (const r of result) {
expect(["idle", "running"]).toContain(r.status);
const ts = extractUlidTimestamp(r.thread);
if (ts !== null) {
expect(ts).toBeGreaterThan(afterMs);
expect(ts).toBeLessThan(beforeMs);
}
}
await cleanupRunningMarkers(threads);
});
});
// ── edge cases tests ──────────────────────────────────────────────────────────
describe("edge cases", () => {
test("should handle empty thread list", async () => {
await makeUwfStore(tmpDir);
const result = await cmdThreadList(tmpDir, null, null, null, null, null);
expect(result).toHaveLength(0);
});
test("should skip threads with invalid ULID when time filtering", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
index["INVALID_ULID_FORMAT_HERE" as ThreadId] = "01J6HMVRNQKJV2";
await saveThreadsIndex(tmpDir, index);
const afterMs = Date.now() - 3000;
const result = await cmdThreadList(tmpDir, null, afterMs, null, null, null);
expect(result).toHaveLength(2);
expect(result.map((r) => r.thread).sort()).toEqual([thread1, thread2].sort());
});
});
// ── time parsing tests ────────────────────────────────────────────────────────
describe("relative time parsing", () => {
test("should parse '7d' as 7 days ago", () => {
const nowMs = Date.UTC(2026, 4, 24, 12, 0, 0);
const result = parseTimeInput("7d", nowMs);
const expected = Date.UTC(2026, 4, 17, 12, 0, 0);
expect(result).toBe(expected);
});
test("should parse '24h' as 24 hours ago", () => {
const nowMs = Date.UTC(2026, 4, 24, 12, 0, 0);
const result = parseTimeInput("24h", nowMs);
const expected = Date.UTC(2026, 4, 23, 12, 0, 0);
expect(result).toBe(expected);
});
test("should parse '30m' as 30 minutes ago", () => {
const nowMs = Date.UTC(2026, 4, 24, 12, 30, 0);
const result = parseTimeInput("30m", nowMs);
const expected = Date.UTC(2026, 4, 24, 12, 0, 0);
expect(result).toBe(expected);
});
test("should parse '1d' as 1 day ago", () => {
const nowMs = Date.UTC(2026, 4, 24, 0, 0, 0);
const result = parseTimeInput("1d", nowMs);
const expected = Date.UTC(2026, 4, 23, 0, 0, 0);
expect(result).toBe(expected);
});
});
describe("ISO date parsing", () => {
test("should parse ISO date (YYYY-MM-DD)", () => {
const nowMs = Date.now();
const result = parseTimeInput("2026-05-20", nowMs);
const expected = Date.UTC(2026, 4, 20, 0, 0, 0);
expect(result).toBe(expected);
});
test("should parse ISO datetime (YYYY-MM-DDTHH:MM:SS)", () => {
const nowMs = Date.now();
const result = parseTimeInput("2026-05-20T14:30:00", nowMs);
const expected = Date.parse("2026-05-20T14:30:00");
expect(result).toBe(expected);
});
test("should parse ISO datetime with Z suffix", () => {
const nowMs = Date.now();
const result = parseTimeInput("2026-05-20T14:30:00Z", nowMs);
const expected = Date.UTC(2026, 4, 20, 14, 30, 0);
expect(result).toBe(expected);
});
test("should reject invalid date formats", () => {
const nowMs = Date.now();
expect(() => parseTimeInput("not-a-date", nowMs)).toThrow();
expect(() => parseTimeInput("2026-13-01", nowMs)).toThrow();
expect(() => parseTimeInput("invalid", nowMs)).toThrow();
});
});
@@ -0,0 +1,597 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdThreadRead } from "../commands/thread.js";
import { registerUwfSchemas } from "../schemas.js";
import { saveThreadsIndex } from "../store.js";
// ── schemas used in tests ────────────────────────────────────────────────────
const TURN_SCHEMA = {
title: "hermes-turn",
type: "object" as const,
required: ["index", "role", "content"],
properties: {
index: { type: "integer" as const },
role: { type: "string" as const },
content: { type: "string" as const },
toolCalls: {
anyOf: [
{ type: "array" as const, items: { type: "object" as const } },
{ type: "null" as const },
],
},
reasoning: { anyOf: [{ type: "string" as const }, { type: "null" as const }] },
},
additionalProperties: false,
};
const DETAIL_SCHEMA = {
title: "hermes-detail",
type: "object" as const,
required: ["sessionId", "model", "duration", "turnCount", "turns"],
properties: {
sessionId: { type: "string" as const },
model: { type: "string" as const },
duration: { type: "integer" as const },
turnCount: { type: "integer" as const },
turns: {
type: "array" as const,
items: { type: "string" as const, format: "cas_ref" },
},
},
additionalProperties: false,
};
// ── helpers ───────────────────────────────────────────────────────────────────
async function registerDetailSchemas(store: ReturnType<typeof createFsStore>) {
await bootstrap(store);
const [turn, detail] = await Promise.all([
putSchema(store, TURN_SCHEMA),
putSchema(store, DETAIL_SCHEMA),
]);
return { turn, detail };
}
function generateContent(size: number, prefix = "Content"): string {
const base = `${prefix} `;
const repeat = Math.ceil(size / base.length);
return base.repeat(repeat).slice(0, size);
}
// ── fixture ───────────────────────────────────────────────────────────────────
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-quota-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ── thread read quota enforcement ─────────────────────────────────────────────
describe("thread read --quota flag", () => {
test("test 1: basic quota enforcement with 3 steps", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do the work.",
output: "Summarize the work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test task",
});
const outputHash = await store.put(schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
// Create 3 steps with ~500 chars each
const steps: CasRef[] = [];
for (let i = 1; i <= 3; i++) {
const content = generateContent(500, `Step${i}`);
const turnHash = await store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content,
toolCalls: null,
reasoning: null,
});
const detailHash = await store.put(detailSchemas.detail, {
sessionId: `session-${i}`,
model: "test-model",
duration: 1000,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: steps[i - 2] ?? null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
steps.push(stepHash);
}
const threadId = "01HX2Q3R4S5T6V7W8X9YZ0" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: steps[2] as CasRef });
// Set quota to 800 chars - should only fit most recent steps
const markdown = await cmdThreadRead(tmpDir, threadId, 800, null, false);
// Quota must be reasonably enforced (allow ~200 char tolerance for skip hint)
expect(markdown.length).toBeLessThanOrEqual(1000);
// Should contain skip hint since not all steps fit
expect(markdown).toMatch(/earlier step/);
// Most recent step should be included
expect(markdown).toMatch(/Step3/);
});
test("test 2: quota check order - verifies bug is fixed", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do the work.",
output: "Summarize the work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test task",
});
const outputHash = await store.put(schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
// Create 2 steps: first=300 chars, second=600 chars
const step1Content = generateContent(300, "First");
const step1TurnHash = await store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content: step1Content,
toolCalls: null,
reasoning: null,
});
const step1DetailHash = await store.put(detailSchemas.detail, {
sessionId: "session-1",
model: "test-model",
duration: 1000,
turnCount: 1,
turns: [step1TurnHash],
});
const step1Hash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: step1DetailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const step2Content = generateContent(600, "Second");
const step2TurnHash = await store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content: step2Content,
toolCalls: null,
reasoning: null,
});
const step2DetailHash = await store.put(detailSchemas.detail, {
sessionId: "session-2",
model: "test-model",
duration: 1000,
turnCount: 1,
turns: [step2TurnHash],
});
const step2Hash = await store.put(schemas.stepNode, {
start: startHash,
prev: step1Hash,
role: "worker",
output: outputHash,
detail: step2DetailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01HX2Q3R4S5T6V7W8X9YZ1" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: step2Hash });
// Set quota to 500 chars
const markdown = await cmdThreadRead(tmpDir, threadId, 500, null, false);
// Bug fix verification: output must be limited (allow ~200 char tolerance)
expect(markdown.length).toBeLessThanOrEqual(1100);
// Should contain "Second" (most recent step)
expect(markdown).toMatch(/Second/);
// Should skip first step
expect(markdown).toMatch(/earlier step/);
// Verify improvement: before fix would be ~1264, now should be much closer to 500
expect(markdown.length).toBeLessThan(1200);
});
test("test 3: quota with --start section", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do the work.",
output: "Summarize the work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test task with a moderately long prompt to test quota accounting",
});
const outputHash = await store.put(schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
// Create 2 steps
const steps: CasRef[] = [];
for (let i = 1; i <= 2; i++) {
const content = generateContent(400, `Step${i}`);
const turnHash = await store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content,
toolCalls: null,
reasoning: null,
});
const detailHash = await store.put(detailSchemas.detail, {
sessionId: `session-${i}`,
model: "test-model",
duration: 1000,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: steps[i - 2] ?? null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
steps.push(stepHash);
}
const threadId = "01HX2Q3R4S5T6V7W8X9YZ2" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: steps[1] as CasRef });
// Set tight quota with --start flag
const markdown = await cmdThreadRead(tmpDir, threadId, 600, null, true);
// Quota must be reasonably enforced (allow ~260 char tolerance for structure)
expect(markdown.length).toBeLessThanOrEqual(860);
// Should contain thread header
expect(markdown).toMatch(/# Thread/);
expect(markdown).toMatch(/test-wf/);
});
test("test 5a: quota edge case - minimal quota", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do the work.",
output: "Summarize the work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test task",
});
const outputHash = await store.put(schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const content = generateContent(500, "Test");
const turnHash = await store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content,
toolCalls: null,
reasoning: null,
});
const detailHash = await store.put(detailSchemas.detail, {
sessionId: "session-1",
model: "test-model",
duration: 1000,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01HX2Q3R4S5T6V7W8X9YZ4" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
// Minimal quota
const markdown = await cmdThreadRead(tmpDir, threadId, 1, null, false);
// Should handle gracefully - always shows at least one step
expect(markdown.length).toBeGreaterThan(1);
expect(markdown).toMatch(/Test/);
});
test("test 5b: quota edge case - very large quota", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do the work.",
output: "Summarize the work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test task",
});
const outputHash = await store.put(schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
// Create 3 steps
const steps: CasRef[] = [];
for (let i = 1; i <= 3; i++) {
const content = generateContent(300, `Step${i}`);
const turnHash = await store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content,
toolCalls: null,
reasoning: null,
});
const detailHash = await store.put(detailSchemas.detail, {
sessionId: `session-${i}`,
model: "test-model",
duration: 1000,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: steps[i - 2] ?? null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
steps.push(stepHash);
}
const threadId = "01HX2Q3R4S5T6V7W8X9YZ5" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: steps[2] as CasRef });
// Very large quota
const markdown = await cmdThreadRead(tmpDir, threadId, 1000000, null, false);
// Should show all steps (no skipping)
expect(markdown).not.toMatch(/earlier step/);
expect(markdown).toMatch(/Step1/);
expect(markdown).toMatch(/Step2/);
expect(markdown).toMatch(/Step3/);
});
test("test 6: quota with --before parameter", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do the work.",
output: "Summarize the work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test task",
});
const outputHash = await store.put(schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
// Create 5 steps
const steps: CasRef[] = [];
for (let i = 1; i <= 5; i++) {
const content = generateContent(300, `Step${i}`);
const turnHash = await store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content,
toolCalls: null,
reasoning: null,
});
const detailHash = await store.put(detailSchemas.detail, {
sessionId: `session-${i}`,
model: "test-model",
duration: 1000,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: steps[i - 2] ?? null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
steps.push(stepHash);
}
const threadId = "01HX2Q3R4S5T6V7W8X9YZ6" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: steps[4] as CasRef });
// Use --before to limit to steps 1-2, then set quota that allows only 1
const markdown = await cmdThreadRead(tmpDir, threadId, 500, steps[2] as CasRef, false);
// Should not contain Step3 or later
expect(markdown).not.toMatch(/Step3/);
expect(markdown).not.toMatch(/Step4/);
expect(markdown).not.toMatch(/Step5/);
// Quota should select most recent of candidates (Step2)
expect(markdown).toMatch(/Step2/);
// Quota enforcement (allow ~200 char tolerance)
expect(markdown.length).toBeLessThanOrEqual(700);
});
});
@@ -0,0 +1,707 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdThreadRead, THREAD_READ_DEFAULT_QUOTA } from "../commands/thread.js";
import { registerUwfSchemas } from "../schemas.js";
import type { UwfStore } from "../store.js";
import { saveThreadsIndex } from "../store.js";
// ── schemas used in tests ────────────────────────────────────────────────────
const TURN_SCHEMA = {
title: "hermes-turn",
type: "object" as const,
required: ["index", "role", "content"],
properties: {
index: { type: "integer" as const },
role: { type: "string" as const },
content: { type: "string" as const },
toolCalls: {
anyOf: [
{ type: "array" as const, items: { type: "object" as const } },
{ type: "null" as const },
],
},
reasoning: { anyOf: [{ type: "string" as const }, { type: "null" as const }] },
},
additionalProperties: false,
};
const DETAIL_SCHEMA = {
title: "hermes-detail",
type: "object" as const,
required: ["sessionId", "model", "duration", "turnCount", "turns"],
properties: {
sessionId: { type: "string" as const },
model: { type: "string" as const },
duration: { type: "integer" as const },
turnCount: { type: "integer" as const },
turns: {
type: "array" as const,
items: { type: "string" as const, format: "cas_ref" },
},
},
additionalProperties: false,
};
// ── helpers ───────────────────────────────────────────────────────────────────
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = join(storageRoot, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
return { storageRoot, store, schemas };
}
async function registerDetailSchemas(store: ReturnType<typeof createFsStore>) {
await bootstrap(store);
const [turn, detail] = await Promise.all([
putSchema(store, TURN_SCHEMA),
putSchema(store, DETAIL_SCHEMA),
]);
return { turn, detail };
}
// ── fixture ───────────────────────────────────────────────────────────────────
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ── thread read XML tag isolation ─────────────────────────────────────────────
describe("thread read XML tag isolation", () => {
test("scenario 1: wraps output in XML tags instead of heading", async () => {
const uwf = await makeUwfStore(tmpDir);
const detailSchemas = await registerDetailSchemas(uwf.store);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
planner: {
description: "Planner",
goal: "You are a planning agent. Your task is to...",
capabilities: [],
procedure: "Plan the work.",
output: "Summarize the plan.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Fix issue #459",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const turnHash = await uwf.store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content:
"---\nstatus: ready\nplan: CMWGHQKT58RY4\n---\n\n# Analysis Complete\n## Issue Summary\nThe issue requires XML tag isolation.",
toolCalls: null,
reasoning: null,
});
const detailHash = await uwf.store.put(detailSchemas.detail, {
sessionId: "sx",
model: "mx",
duration: 500,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "planner",
output: outputHash,
detail: detailHash,
agent: "uwf-claude-code",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01JTEST0000000000000001" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
// Should wrap output in XML tags
expect(markdown).toContain("<output>");
expect(markdown).toContain("</output>");
// Should not have ### Content heading
expect(markdown).not.toContain("### Content");
// Should preserve markdown headings inside output tags
expect(markdown).toContain("# Analysis Complete");
expect(markdown).toContain("## Issue Summary");
});
test("scenario 2: wraps prompt in XML tags", async () => {
const uwf = await makeUwfStore(tmpDir);
const detailSchemas = await registerDetailSchemas(uwf.store);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
planner: {
description: "Planner",
goal: "You are a planning agent. Your task is to analyze and plan.",
capabilities: [],
procedure: "Plan the work.",
output: "Summarize the plan.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Fix issue",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const turnHash = await uwf.store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content: "---\nstatus: ready\n---\n\nContent here...",
toolCalls: null,
reasoning: null,
});
const detailHash = await uwf.store.put(detailSchemas.detail, {
sessionId: "sx",
model: "mx",
duration: 500,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "planner",
output: outputHash,
detail: detailHash,
agent: "uwf-claude-code",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01JTEST0000000000000002" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
// Should wrap prompt in XML tags
expect(markdown).toContain("<prompt>");
expect(markdown).toContain("</prompt>");
expect(markdown).toContain("You are a planning agent. Your task is to analyze and plan.");
// Should not have ### Prompt heading
expect(markdown).not.toContain("### Prompt");
// Should wrap output in XML tags
expect(markdown).toContain("<output>");
expect(markdown).toContain("</output>");
});
test("scenario 3: same role repeated does not show prompt twice", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
writer: {
description: "Writer",
goal: "You are a writer agent.",
capabilities: [],
procedure: "Write content.",
output: "Summarize writing.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Write something",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const step1 = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "writer",
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: step1 as CasRef,
role: "writer",
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01JTEST0000000000000003" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: step2 });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
// Should only show prompt tags once
const promptCount = (markdown.match(/<prompt>/g) ?? []).length;
expect(promptCount).toBe(1);
});
test("scenario 4: step with no detail shows no output tags", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do work.",
output: "Summarize work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Do stuff",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01JTEST0000000000000004" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
// Should not have output tags
expect(markdown).not.toContain("<output>");
expect(markdown).not.toContain("</output>");
// Step header should still be displayed
expect(markdown).toContain("## Step 1: worker");
// Prompt should still be shown
expect(markdown).toContain("<prompt>");
});
test("scenario 5: empty content shows no output tags", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Do stuff",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
// A detail ref that doesn't exist → extractLastAssistantContent returns null
const missingDetailRef = "missingdetail0" as CasRef;
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: missingDetailRef,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01JTEST0000000000000005" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
// Should not have output tags
expect(markdown).not.toContain("<output>");
expect(markdown).not.toContain("</output>");
});
test("scenario 6: thread read with --start flag shows task section", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
roleA: {
description: "Role A",
goal: "Goal for roleA",
capabilities: [],
procedure: "Do stuff.",
output: "Output.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Initial prompt",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "roleA",
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01JTEST0000000000000006" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, true);
// Should include task section
expect(markdown).toContain("# Thread");
expect(markdown).toContain("## Task");
expect(markdown).toContain("Initial prompt");
// Prompts should use XML tags
expect(markdown).toContain("<prompt>");
});
test("scenario 7: thread read with --before parameter", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
roleA: {
description: "Role A",
goal: "Goal for roleA",
capabilities: [],
procedure: "Do stuff.",
output: "Output.",
meta: "placeholder00" as CasRef,
},
roleB: {
description: "Role B",
goal: "Goal for roleB",
capabilities: [],
procedure: "Do stuff.",
output: "Output.",
meta: "placeholder00" as CasRef,
},
roleC: {
description: "Role C",
goal: "Goal for roleC",
capabilities: [],
procedure: "Do stuff.",
output: "Output.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Initial prompt",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const step1 = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "roleA",
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: step1 as CasRef,
role: "roleB",
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const step3 = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: step2 as CasRef,
role: "roleC",
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01JTEST0000000000000007" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: step3 });
const markdown = await cmdThreadRead(
tmpDir,
threadId,
THREAD_READ_DEFAULT_QUOTA,
step2 as CasRef,
false,
);
// Should only show roleA
expect(markdown).toContain("roleA");
expect(markdown).not.toContain("roleB");
expect(markdown).not.toContain("roleC");
// Should use XML tags
expect(markdown).toContain("<prompt>");
});
test("scenario 9: special characters in content are preserved", async () => {
const uwf = await makeUwfStore(tmpDir);
const detailSchemas = await registerDetailSchemas(uwf.store);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
writer: {
description: "Writer",
goal: "You are a writer.",
capabilities: [],
procedure: "Write content.",
output: "Summarize.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Write something",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const turnHash = await uwf.store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content: "Content with <special> & characters > like <this>",
toolCalls: null,
reasoning: null,
});
const detailHash = await uwf.store.put(detailSchemas.detail, {
sessionId: "sx",
model: "mx",
duration: 500,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "writer",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const threadId = "01JTEST0000000000000008" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
// Special characters should be preserved as-is
expect(markdown).toContain("Content with <special> & characters > like <this>");
});
test("scenario 10: quota limit with XML tags", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
roleA: {
description: "Role A",
goal: "Goal for roleA",
capabilities: [],
procedure: "Do stuff.",
output: "Output.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Initial prompt",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const steps: CasRef[] = [];
let prev: CasRef | null = null;
for (let i = 0; i < 5; i++) {
const step = (await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev,
role: "roleA",
output: outputHash,
detail: null,
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
})) as CasRef;
steps.push(step);
prev = step;
}
const threadId = "01JTEST0000000000000009" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: steps[steps.length - 1]! });
// Use very small quota
const markdown = await cmdThreadRead(tmpDir, threadId, 1, null, false);
// Should have skip hint
expect(markdown).toContain("earlier step");
// Should have XML tags for displayed steps
if (markdown.includes("<prompt>")) {
expect(markdown).toContain("</prompt>");
}
});
});
@@ -22,48 +22,48 @@ function runCli(args: string[]): { stdout: string; stderr: string; exitCode: num
}
}
describe("thread step --count CLI parsing", () => {
describe("thread exec --count CLI parsing", () => {
test("--help shows -c/--count option", () => {
const result = runCli(["thread", "step", "--help"]);
const result = runCli(["thread", "exec", "--help"]);
expect(result.stdout).toContain("--count");
expect(result.stdout).toContain("-c");
});
test("description says 'one or more steps'", () => {
const result = runCli(["thread", "step", "--help"]);
const result = runCli(["thread", "exec", "--help"]);
expect(result.stdout).toContain("one or more steps");
});
});
describe("cmdThreadStep count logic", () => {
describe("cmdThreadExec count logic", () => {
test("count=0 fails with validation error", () => {
const result = runCli(["thread", "step", "FAKE_THREAD_ID", "-c", "0"]);
const result = runCli(["thread", "exec", "FAKE_THREAD_ID", "-c", "0"]);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("positive integer");
});
test("negative count fails with validation error", () => {
const result = runCli(["thread", "step", "FAKE_THREAD_ID", "-c", "-1"]);
const result = runCli(["thread", "exec", "FAKE_THREAD_ID", "-c", "-1"]);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("positive integer");
});
test("non-integer count fails with validation error", () => {
const result = runCli(["thread", "step", "FAKE_THREAD_ID", "-c", "1.5"]);
const result = runCli(["thread", "exec", "FAKE_THREAD_ID", "-c", "1.5"]);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("positive integer");
});
test("count=1 is the default (no -c flag)", () => {
// Without -c, it should attempt to run 1 step (failing on missing thread, not on count validation)
const result = runCli(["thread", "step", "FAKE_THREAD_ID"]);
const result = runCli(["thread", "exec", "FAKE_THREAD_ID"]);
expect(result.exitCode).not.toBe(0);
// Should NOT contain "positive integer" error — should fail on thread lookup instead
expect(result.stderr).not.toContain("positive integer");
});
test("count=3 passes validation (fails on thread lookup)", () => {
const result = runCli(["thread", "step", "FAKE_THREAD_ID", "-c", "3"]);
const result = runCli(["thread", "exec", "FAKE_THREAD_ID", "-c", "3"]);
expect(result.exitCode).not.toBe(0);
// Should NOT contain "positive integer" error — should fail on thread/storage lookup
expect(result.stderr).not.toContain("positive integer");
@@ -5,15 +5,15 @@ import { bootstrap, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdStepList, cmdStepShow } from "../commands/step.js";
import {
cmdThreadRead,
cmdThreadStepDetails,
extractLastAssistantContent,
THREAD_READ_DEFAULT_QUOTA,
} from "../commands/thread.js";
import { registerUwfSchemas } from "../schemas.js";
import type { UwfStore } from "../store.js";
import { saveThreadsIndex } from "../store.js";
import { appendThreadHistory, saveThreadsIndex } from "../store.js";
// ── schemas used in tests ────────────────────────────────────────────────────
@@ -198,10 +198,10 @@ describe("extractLastAssistantContent", () => {
});
});
// ── cmdThreadRead: ### Content section ───────────────────────────────────────
// ── cmdThreadRead: <output> section ──────────────────────────────────────────
describe("cmdThreadRead ### Content section", () => {
test("includes ### Content before ### Output when detail has assistant turns", async () => {
describe("cmdThreadRead <output> section", () => {
test("includes <output> tags when detail has assistant turns", async () => {
const uwf = await makeUwfStore(tmpDir);
const detailSchemas = await registerDetailSchemas(uwf.store);
@@ -264,12 +264,13 @@ describe("cmdThreadRead ### Content section", () => {
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
expect(markdown).toContain("### Content");
expect(markdown).toContain("<output>");
expect(markdown).toContain("</output>");
expect(markdown).toContain("The assistant response text");
expect(markdown).not.toContain("### Output");
expect(markdown).not.toContain("### Content");
});
test("omits ### Content when detail has no matching assistant turns", async () => {
test("omits <output> tags when detail has no matching assistant turns", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
@@ -308,14 +309,15 @@ describe("cmdThreadRead ### Content section", () => {
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
expect(markdown).not.toContain("<output>");
expect(markdown).not.toContain("</output>");
expect(markdown).not.toContain("### Content");
expect(markdown).not.toContain("### Output");
});
});
// ── cmdThreadStepDetails ──────────────────────────────────────────────────────
// ── cmdStepShow ───────────────────────────────────────────────────────────────
describe("cmdThreadStepDetails", () => {
describe("cmdStepShow", () => {
test("returns expanded detail node with turns inlined", async () => {
const uwf = await makeUwfStore(tmpDir);
const detailSchemas = await registerDetailSchemas(uwf.store);
@@ -363,7 +365,7 @@ describe("cmdThreadStepDetails", () => {
agent: "uwf-hermes",
});
const result = await cmdThreadStepDetails(tmpDir, stepHash);
const result = await cmdStepShow(tmpDir, stepHash);
expect(result).toMatchObject({
sessionId: "sess42",
@@ -382,15 +384,11 @@ describe("cmdThreadStepDetails", () => {
content: "done",
});
});
test("throws when step hash does not exist", async () => {
await expect(cmdThreadStepDetails(tmpDir, "nonexistenth0" as CasRef)).rejects.toThrow();
});
});
// ── cmdThreadRead: ### Prompt deduplication ───────────────────────────────────
// ── cmdThreadRead: <prompt> deduplication ───────────────────────────────────
describe("cmdThreadRead ### Prompt deduplication", () => {
describe("cmdThreadRead <prompt> deduplication", () => {
async function makeThreadWithRoles(uwf: UwfStore, roles: string[]): Promise<string> {
const roleMap: Record<string, unknown> = {};
for (const r of [...new Set(roles)]) {
@@ -438,36 +436,598 @@ describe("cmdThreadRead ### Prompt deduplication", () => {
return stepHash;
}
test("same consecutive role shows ### Prompt once", async () => {
test("same consecutive role shows <prompt> once", async () => {
const uwf = await makeUwfStore(tmpDir);
const headHash = await makeThreadWithRoles(uwf, ["writer", "writer"]);
const threadId = "01JTEST0000000000000003" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
const count = (markdown.match(/### Prompt/g) ?? []).length;
const count = (markdown.match(/<prompt>/g) ?? []).length;
expect(count).toBe(1);
});
test("different consecutive roles each show ### Prompt", async () => {
test("different consecutive roles each show <prompt>", async () => {
const uwf = await makeUwfStore(tmpDir);
const headHash = await makeThreadWithRoles(uwf, ["planner", "coder"]);
const threadId = "01JTEST0000000000000004" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
const count = (markdown.match(/### Prompt/g) ?? []).length;
const count = (markdown.match(/<prompt>/g) ?? []).length;
expect(count).toBe(2);
});
test("non-consecutive same role shows ### Prompt twice", async () => {
test("non-consecutive same role shows <prompt> twice", async () => {
const uwf = await makeUwfStore(tmpDir);
const headHash = await makeThreadWithRoles(uwf, ["roleA", "roleB", "roleA"]);
const threadId = "01JTEST0000000000000005" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
const count = (markdown.match(/### Prompt/g) ?? []).length;
const count = (markdown.match(/<prompt>/g) ?? []).length;
expect(count).toBe(2);
});
});
// ── cmdThreadRead: showStart / before / quota ─────────────────────────────────
describe("cmdThreadRead start section / before / quota", () => {
async function makeSimpleThread(
uwf: UwfStore,
roles: string[],
): Promise<{ startHash: CasRef; stepHashes: CasRef[] }> {
const uniqueRoles = [...new Set(roles)];
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "simple-wf",
description: "desc",
roles: Object.fromEntries(
uniqueRoles.map((r) => [
r,
{
description: r,
goal: `Goal for ${r}`,
capabilities: [],
procedure: "Do stuff.",
output: "Output.",
meta: "placeholder00" as CasRef,
},
]),
),
conditions: {},
graph: {},
});
const startHash = (await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Initial prompt",
})) as CasRef;
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const stepHashes: CasRef[] = [];
let prev: CasRef | null = null;
for (const role of roles) {
const stepHash = (await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev,
role,
output: outputHash,
detail: null,
agent: "uwf-test",
})) as CasRef;
stepHashes.push(stepHash);
prev = stepHash;
}
return { startHash, stepHashes };
}
test("showStart=true includes # Thread header and ## Task section", async () => {
const uwf = await makeUwfStore(tmpDir);
const { stepHashes } = await makeSimpleThread(uwf, ["roleA"]);
const threadId = "01JTEST0000000000000006" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHashes[stepHashes.length - 1]! });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, true);
expect(markdown).toContain("# Thread");
expect(markdown).toContain("## Task");
expect(markdown).toContain("Initial prompt");
});
test("showStart=false with before=null still shows # Thread header (default behavior)", async () => {
const uwf = await makeUwfStore(tmpDir);
const { stepHashes } = await makeSimpleThread(uwf, ["roleA"]);
const threadId = "01JTEST0000000000000007" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHashes[stepHashes.length - 1]! });
// When before=null, the start section is always shown regardless of showStart
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
expect(markdown).toContain("# Thread");
expect(markdown).toContain("## Task");
});
test("before filter: only steps before the given hash appear", async () => {
const uwf = await makeUwfStore(tmpDir);
const { stepHashes } = await makeSimpleThread(uwf, ["roleA", "roleB", "roleC"]);
const [_hashA, hashB, hashC] = stepHashes as [CasRef, CasRef, CasRef];
const threadId = "01JTEST0000000000000008" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: hashC });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, hashB, false);
expect(markdown).toContain("roleA");
expect(markdown).not.toContain("roleB");
expect(markdown).not.toContain("roleC");
});
test("quota=1 limits output and includes skip hint", async () => {
const uwf = await makeUwfStore(tmpDir);
const { stepHashes } = await makeSimpleThread(uwf, ["roleA", "roleB", "roleC"]);
const threadId = "01JTEST000000000000000A" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHashes[stepHashes.length - 1]! });
const markdown = await cmdThreadRead(tmpDir, threadId, 1, null, false);
expect(markdown).toContain("earlier step");
});
test("all steps fit in quota: no skip hint", async () => {
const uwf = await makeUwfStore(tmpDir);
const { stepHashes } = await makeSimpleThread(uwf, ["roleA"]);
const threadId = "01JTEST000000000000000B" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHashes[0]! });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
expect(markdown).not.toContain("earlier step");
});
});
// ── Tests that call process.exit must be last ─────────────────────────────────
describe("cmdStepShow (process.exit tests - must be last)", () => {
test("throws when step hash does not exist", async () => {
await expect(cmdStepShow(tmpDir, "nonexistenth0" as CasRef)).rejects.toThrow();
});
test("before with unknown hash rejects", async () => {
const _uwf = await makeUwfStore(tmpDir);
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const uwfStore: UwfStore = { storageRoot: tmpDir, store, schemas };
const workflowHash = await uwfStore.store.put(uwfStore.schemas.workflow, {
name: "wf2",
description: "",
roles: {
roleA: {
description: "r",
goal: "g",
capabilities: [],
procedure: "p",
output: "o",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwfStore.store.put(uwfStore.schemas.startNode, {
workflow: workflowHash,
prompt: "p",
});
const outputHash = await uwfStore.store.put(uwfStore.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const stepHash = await uwfStore.store.put(uwfStore.schemas.stepNode, {
start: startHash,
prev: null,
role: "roleA",
output: outputHash,
detail: null,
agent: "uwf-test",
});
await saveThreadsIndex(tmpDir, { ["01JTEST000000000000000C" as ThreadId]: stepHash as CasRef });
await expect(
cmdThreadRead(
tmpDir,
"01JTEST000000000000000C" as ThreadId,
THREAD_READ_DEFAULT_QUOTA,
"unknownhash0" as CasRef,
false,
),
).rejects.toThrow();
});
});
// ── cmdStepList / cmdStepShow: completed threads ──────────────────────────────
describe("cmdStepList with completed threads", () => {
test("lists steps from active thread", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf-active",
description: "desc",
roles: {},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Start prompt",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const step1Hash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "role1",
output: outputHash,
detail: null,
agent: "uwf-test",
});
const step2Hash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: step1Hash,
role: "role2",
output: outputHash,
detail: null,
agent: "uwf-test",
});
const step3Hash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: step2Hash,
role: "role3",
output: outputHash,
detail: null,
agent: "uwf-test",
});
const threadId = "01JTEST0000000000000000A1" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: step3Hash });
const result = await cmdStepList(tmpDir, threadId);
expect(result.thread).toBe(threadId);
expect(result.steps).toHaveLength(4); // start + 3 steps
expect(result.steps[1].role).toBe("role1");
expect(result.steps[2].role).toBe("role2");
expect(result.steps[3].role).toBe("role3");
});
test("lists steps from completed thread", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf-completed",
description: "desc",
roles: {},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Start prompt",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const step1Hash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "roleA",
output: outputHash,
detail: null,
agent: "uwf-test",
});
const step2Hash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: step1Hash,
role: "roleB",
output: outputHash,
detail: null,
agent: "uwf-test",
});
const threadId = "01JTEST0000000000000000A2" as ThreadId;
// Thread is NOT in threads.yaml (simulating completed thread)
await saveThreadsIndex(tmpDir, {});
// But it IS in history.jsonl
await appendThreadHistory(tmpDir, {
thread: threadId,
workflow: workflowHash,
head: step2Hash,
completedAt: Date.now(),
reason: null,
});
const result = await cmdStepList(tmpDir, threadId);
expect(result.thread).toBe(threadId);
expect(result.steps).toHaveLength(3); // start + 2 steps
expect(result.steps[1].role).toBe("roleA");
expect(result.steps[2].role).toBe("roleB");
});
});
describe("cmdStepShow with completed threads", () => {
test("shows step detail from active thread", async () => {
const uwf = await makeUwfStore(tmpDir);
const detailSchemas = await registerDetailSchemas(uwf.store);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf-step-active",
description: "desc",
roles: {},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "p",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const turnHash = await uwf.store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content: "Active thread response",
toolCalls: null,
reasoning: null,
});
const detailHash = await uwf.store.put(detailSchemas.detail, {
sessionId: "sess-active",
model: "model-x",
duration: 1234,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "coder",
output: outputHash,
detail: detailHash,
agent: "uwf-hermes",
});
const threadId = "01JTEST0000000000000000B1" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const result = await cmdStepShow(tmpDir, stepHash);
expect(result).toMatchObject({
sessionId: "sess-active",
model: "model-x",
duration: 1234,
turnCount: 1,
});
});
test("shows step detail from completed thread", async () => {
const uwf = await makeUwfStore(tmpDir);
const detailSchemas = await registerDetailSchemas(uwf.store);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf-step-completed",
description: "desc",
roles: {},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "p",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const turnHash = await uwf.store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content: "Completed thread response",
toolCalls: null,
reasoning: null,
});
const detailHash = await uwf.store.put(detailSchemas.detail, {
sessionId: "sess-completed",
model: "model-y",
duration: 5678,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "reviewer",
output: outputHash,
detail: detailHash,
agent: "uwf-hermes",
});
const threadId = "01JTEST0000000000000000B2" as ThreadId;
// Thread is NOT in threads.yaml
await saveThreadsIndex(tmpDir, {});
// But it IS in history.jsonl
await appendThreadHistory(tmpDir, {
thread: threadId,
workflow: workflowHash,
head: stepHash,
completedAt: Date.now(),
reason: null,
});
const result = await cmdStepShow(tmpDir, stepHash);
expect(result).toMatchObject({
sessionId: "sess-completed",
model: "model-y",
duration: 5678,
turnCount: 1,
});
});
});
describe("cmdThreadRead with completed threads", () => {
test("reads completed thread context", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf-read-completed",
description: "desc",
roles: {
writer: {
description: "Write",
goal: "You are a writer.",
capabilities: [],
procedure: "Write content.",
output: "Summary.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Write something",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "writer",
output: outputHash,
detail: null,
agent: "uwf-hermes",
});
const threadId = "01JTEST0000000000000000C1" as ThreadId;
// Thread is NOT in threads.yaml
await saveThreadsIndex(tmpDir, {});
// But it IS in history.jsonl
await appendThreadHistory(tmpDir, {
thread: threadId,
workflow: workflowHash,
head: stepHash,
completedAt: Date.now(),
reason: null,
});
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
expect(markdown).toContain("writer");
expect(markdown).toContain("Write something");
});
test("reads completed thread with before filter", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf-read-before",
description: "desc",
roles: {},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Do task",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const step1Hash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "roleX",
output: outputHash,
detail: null,
agent: "uwf-test",
});
const step2Hash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: step1Hash,
role: "roleY",
output: outputHash,
detail: null,
agent: "uwf-test",
});
const step3Hash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: step2Hash,
role: "roleZ",
output: outputHash,
detail: null,
agent: "uwf-test",
});
const threadId = "01JTEST0000000000000000C2" as ThreadId;
await saveThreadsIndex(tmpDir, {});
await appendThreadHistory(tmpDir, {
thread: threadId,
workflow: workflowHash,
head: step3Hash,
completedAt: Date.now(),
reason: null,
});
const markdown = await cmdThreadRead(
tmpDir,
threadId,
THREAD_READ_DEFAULT_QUOTA,
step2Hash,
false,
);
// Should contain step1 (roleX) but not step2 (roleY) or step3 (roleZ)
expect(markdown).toContain("roleX");
expect(markdown).not.toContain("roleY");
expect(markdown).not.toContain("roleZ");
});
});
@@ -0,0 +1,470 @@
import type { WorkflowPayload } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { validateWorkflow } from "../validate-semantic.js";
/** Build a valid two-role workflow that passes all checks. */
function makeWorkflow(overrides?: Partial<WorkflowPayload>): WorkflowPayload {
const base: WorkflowPayload = {
name: "test-workflow",
description: "A test workflow",
roles: {
writer: {
description: "Writes content",
goal: "Write content",
capabilities: ["writing"],
procedure: "Write it",
output: "The content",
frontmatter: {
type: "object",
properties: {
$status: { enum: ["_"] },
plan: { type: "string" },
},
required: ["$status", "plan"],
} as unknown as string,
},
reviewer: {
description: "Reviews content",
goal: "Review content",
capabilities: ["reviewing"],
procedure: "Review it",
output: "The review",
frontmatter: {
type: "object",
oneOf: [
{
properties: {
$status: { const: "approved" },
summary: { type: "string" },
},
required: ["$status", "summary"],
},
{
properties: {
$status: { const: "rejected" },
reason: { type: "string" },
},
required: ["$status", "reason"],
},
],
} as unknown as string,
},
},
graph: {
$START: { _: { role: "writer", prompt: "Begin writing" } },
writer: { _: { role: "reviewer", prompt: "Review this: {{{plan}}}" } },
reviewer: {
approved: { role: "$END", prompt: "Done: {{{summary}}}" },
rejected: { role: "writer", prompt: "Fix: {{{reason}}}" },
},
},
};
if (!overrides) return base;
return { ...base, ...overrides };
}
describe("Suite 1: Role Reference Integrity", () => {
test("1.1 graph references unknown role", () => {
const wf = makeWorkflow();
wf.graph.nonexistent = { _: { role: "$END", prompt: "done" } };
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes('unknown role "nonexistent"'))).toBe(true);
});
test("1.2 orphan role not in graph", () => {
const wf = makeWorkflow();
wf.roles.orphan = {
description: "Orphan",
goal: "Nothing",
capabilities: [],
procedure: "None",
output: "None",
frontmatter: {
type: "object",
properties: { $status: { enum: ["_"] } },
required: ["$status"],
} as unknown as string,
};
const errors = validateWorkflow(wf);
expect(
errors.some((e) => e.includes('role "orphan" is defined but not referenced in graph')),
).toBe(true);
});
test("1.3 $START in roles", () => {
const wf = makeWorkflow();
(wf.roles as Record<string, unknown>).$START = {
description: "Bad",
goal: "Bad",
capabilities: [],
procedure: "Bad",
output: "Bad",
frontmatter: { type: "object", properties: {}, required: [] },
};
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes('reserved name "$START"'))).toBe(true);
});
test("1.4 $END in roles", () => {
const wf = makeWorkflow();
(wf.roles as Record<string, unknown>).$END = {
description: "Bad",
goal: "Bad",
capabilities: [],
procedure: "Bad",
output: "Bad",
frontmatter: { type: "object", properties: {}, required: [] },
};
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes('reserved name "$END"'))).toBe(true);
});
test("1.5 valid workflow returns no errors", () => {
const wf = makeWorkflow();
const errors = validateWorkflow(wf);
expect(errors).toEqual([]);
});
});
describe("Suite 2: Graph Structure", () => {
test("2.1 $START missing from graph", () => {
const wf = makeWorkflow();
delete wf.graph.$START;
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("$START must be defined in graph"))).toBe(true);
});
test("2.2 $START has multiple status keys", () => {
const wf = makeWorkflow();
wf.graph.$START = {
_: { role: "writer", prompt: "Begin" },
other: { role: "reviewer", prompt: "Also" },
};
const errors = validateWorkflow(wf);
expect(
errors.some((e) => e.includes('$START must have exactly one edge with status "_"')),
).toBe(true);
});
test("2.3 $START edge uses non-_ status", () => {
const wf = makeWorkflow();
wf.graph.$START = { ready: { role: "writer", prompt: "Begin" } };
const errors = validateWorkflow(wf);
expect(
errors.some((e) => e.includes('$START must have exactly one edge with status "_"')),
).toBe(true);
});
test("2.4 $END has outgoing edges", () => {
const wf = makeWorkflow();
wf.graph.$END = { _: { role: "writer", prompt: "Loop" } };
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("$END must not have outgoing edges"))).toBe(true);
});
test("2.5 unreachable role", () => {
const wf = makeWorkflow();
wf.roles.isolated = {
description: "Isolated",
goal: "Isolated",
capabilities: [],
procedure: "Isolated",
output: "Isolated",
frontmatter: {
type: "object",
properties: { $status: { enum: ["_"] } },
required: ["$status"],
} as unknown as string,
};
wf.graph.isolated = { _: { role: "$END", prompt: "done" } };
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes('role "isolated" is not reachable from $START'))).toBe(
true,
);
});
test("2.6 edge target references invalid role", () => {
const wf = makeWorkflow();
wf.graph.writer = { _: { role: "ghost", prompt: "Go to ghost" } };
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes('unknown target role "ghost"'))).toBe(true);
});
});
describe("Suite 3: Status-Edge Consistency", () => {
test("3.1 single-exit role with multiple graph keys", () => {
const wf = makeWorkflow();
wf.graph.writer = {
_: { role: "reviewer", prompt: "Review" },
extra: { role: "$END", prompt: "Done" },
};
const errors = validateWorkflow(wf);
expect(
errors.some((e) =>
e.includes('role "writer" is single-exit but has status keys other than "_"'),
),
).toBe(true);
});
test("3.2 single-exit role missing _ key", () => {
const wf = makeWorkflow();
wf.graph.writer = { done: { role: "reviewer", prompt: "Review" } };
const errors = validateWorkflow(wf);
expect(
errors.some((e) => e.includes('role "writer" is single-exit but graph has no "_" key')),
).toBe(true);
});
test("3.3 multi-exit role with extra statuses", () => {
const wf = makeWorkflow();
wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done" },
rejected: { role: "writer", prompt: "Fix" },
timeout: { role: "$END", prompt: "Timed out" },
};
const errors = validateWorkflow(wf);
expect(
errors.some((e) => e.includes('role "reviewer" graph has extra status keys: timeout')),
).toBe(true);
});
test("3.4 multi-exit role missing a status", () => {
const wf = makeWorkflow();
wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done" },
};
const errors = validateWorkflow(wf);
expect(
errors.some((e) => e.includes('role "reviewer" graph is missing status keys: rejected')),
).toBe(true);
});
test("3.5 multi-exit role with _ key", () => {
const wf = makeWorkflow();
wf.graph.reviewer = { _: { role: "$END", prompt: "Done" } };
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes('role "reviewer" is multi-exit but graph uses "_"'))).toBe(
true,
);
});
});
describe("Suite 3b: Enum-Based Multi-Exit", () => {
test("3b.1 enum multi-exit passes with matching graph keys", () => {
const wf = makeWorkflow();
wf.roles.reviewer = {
...wf.roles.reviewer,
frontmatter: {
type: "object",
properties: {
$status: { enum: ["approved", "rejected"] },
comments: { type: "string" },
},
required: ["$status", "comments"],
} as unknown as string,
};
wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done" },
rejected: { role: "writer", prompt: "Fix: {{{comments}}}" },
};
const errors = validateWorkflow(wf);
expect(errors).toEqual([]);
});
test("3b.2 enum multi-exit with extra graph key", () => {
const wf = makeWorkflow();
wf.roles.reviewer = {
...wf.roles.reviewer,
frontmatter: {
type: "object",
properties: {
$status: { enum: ["approved", "rejected"] },
comments: { type: "string" },
},
required: ["$status", "comments"],
} as unknown as string,
};
wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done" },
rejected: { role: "writer", prompt: "Fix" },
timeout: { role: "$END", prompt: "Timed out" },
};
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("extra status keys: timeout"))).toBe(true);
});
test("3b.3 enum multi-exit with missing graph key", () => {
const wf = makeWorkflow();
wf.roles.reviewer = {
...wf.roles.reviewer,
frontmatter: {
type: "object",
properties: {
$status: { enum: ["approved", "rejected"] },
comments: { type: "string" },
},
required: ["$status", "comments"],
} as unknown as string,
};
wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done" },
};
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("missing status keys: rejected"))).toBe(true);
});
test("3b.4 enum with single value (not multi-exit) treated as single-exit", () => {
const wf = makeWorkflow();
wf.roles.writer = {
...wf.roles.writer,
frontmatter: {
type: "object",
properties: {
$status: { enum: ["_"] },
plan: { type: "string" },
},
required: ["$status", "plan"],
} as unknown as string,
};
wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{plan}}}" } };
const errors = validateWorkflow(wf);
expect(errors).toEqual([]);
});
test("3b.5 enum multi-exit mustache var not in frontmatter", () => {
const wf = makeWorkflow();
wf.roles.reviewer = {
...wf.roles.reviewer,
frontmatter: {
type: "object",
properties: {
$status: { enum: ["approved", "rejected"] },
comments: { type: "string" },
},
required: ["$status", "comments"],
} as unknown as string,
};
wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done: {{{nonexistent}}}" },
rejected: { role: "writer", prompt: "Fix: {{{comments}}}" },
};
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("nonexistent") && e.includes("not found"))).toBe(true);
});
});
describe("Suite 4: Mustache Template Variable Existence", () => {
test("4.1 prompt references nonexistent variable (single-exit)", () => {
const wf = makeWorkflow();
wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{branch}}}" } };
const errors = validateWorkflow(wf);
expect(
errors.some((e) =>
e.includes('prompt variable "branch" not found in role "writer" frontmatter'),
),
).toBe(true);
});
test("4.2 prompt references nonexistent variable (multi-exit)", () => {
const wf = makeWorkflow();
wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done: {{{branch}}}" },
rejected: { role: "writer", prompt: "Fix: {{{reason}}}" },
};
const errors = validateWorkflow(wf);
expect(
errors.some((e) =>
e.includes('prompt variable "branch" not found in role "reviewer" variant "approved"'),
),
).toBe(true);
});
test("4.3 valid mustache variables pass", () => {
const wf = makeWorkflow();
const errors = validateWorkflow(wf);
expect(errors).toEqual([]);
});
test("4.4 $status variable is always valid", () => {
const wf = makeWorkflow();
wf.graph.writer = { _: { role: "reviewer", prompt: "Status: {{$status}}" } };
const errors = validateWorkflow(wf);
expect(errors).toEqual([]);
});
});
describe("Suite 5: oneOf Discriminant Validity", () => {
test("5.1 oneOf without $status const", () => {
const wf = makeWorkflow();
wf.roles.reviewer = {
...wf.roles.reviewer,
frontmatter: {
type: "object",
oneOf: [
{ properties: { summary: { type: "string" } }, required: ["summary"] },
{ properties: { reason: { type: "string" } }, required: ["reason"] },
],
} as unknown as string,
};
const errors = validateWorkflow(wf);
expect(
errors.some((e) => e.includes('oneOf variants must have "$status" as const discriminant')),
).toBe(true);
});
test("5.2 oneOf with non-const $status", () => {
const wf = makeWorkflow();
wf.roles.reviewer = {
...wf.roles.reviewer,
frontmatter: {
type: "object",
oneOf: [
{
properties: { $status: { type: "string" }, summary: { type: "string" } },
required: ["$status", "summary"],
},
{
properties: { $status: { type: "string" }, reason: { type: "string" } },
required: ["$status", "reason"],
},
],
} as unknown as string,
};
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("oneOf variant $status must be a const value"))).toBe(
true,
);
});
test("5.3 valid oneOf passes", () => {
const wf = makeWorkflow();
const errors = validateWorkflow(wf);
expect(errors).toEqual([]);
});
});
describe("Suite 6: Multiple Errors Collection", () => {
test("6.1 multiple errors collected", () => {
const wf = makeWorkflow();
// orphan role
wf.roles.orphan = {
description: "Orphan",
goal: "Nothing",
capabilities: [],
procedure: "None",
output: "None",
frontmatter: {
type: "object",
properties: { $status: { enum: ["_"] } },
required: ["$status"],
} as unknown as string,
};
// unknown graph reference
wf.graph.nonexistent = { _: { role: "$END", prompt: "done" } };
// bad mustache var
wf.graph.writer = { _: { role: "reviewer", prompt: "{{{badvar}}}" } };
const errors = validateWorkflow(wf);
expect(errors.length).toBeGreaterThanOrEqual(3);
});
});
@@ -20,25 +20,43 @@ async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
return { storageRoot, store, schemas };
}
async function storeWorkflow(uwf: UwfStore, name: string): Promise<CasRef> {
const payload: WorkflowPayload = {
function makeMinimalPayload(name: string, description: string): WorkflowPayload {
return {
name,
description: "Test workflow",
roles: {},
conditions: {},
graph: {},
description,
roles: {
worker: {
description: "worker role",
goal: "do work",
capabilities: [],
procedure: "",
output: "",
frontmatter: {
type: "object",
properties: {
$status: { type: "string" },
},
required: ["$status"],
} as unknown as CasRef,
},
},
graph: {
$START: { _: { role: "worker", prompt: "start working" } },
worker: { _: { role: "$END", prompt: "done" } },
},
};
}
async function storeWorkflow(uwf: UwfStore, name: string): Promise<CasRef> {
const payload = makeMinimalPayload(name, "Test workflow");
return await uwf.store.put(uwf.schemas.workflow, payload);
}
async function createWorkflowYaml(name: string, version: string | null = null): Promise<string> {
const payload: WorkflowPayload = {
const payload = makeMinimalPayload(
name,
description: version !== null ? `Test workflow (${version})` : "Test workflow",
roles: {},
conditions: {},
graph: {},
};
version !== null ? `Test workflow (${version})` : "Test workflow",
);
const yaml = stringify(payload);
return yaml;
}
@@ -145,7 +163,7 @@ describe("Strategy 2: File Path Resolution", () => {
test("should fail on valid YAML with invalid WorkflowPayload shape", async () => {
await makeUwfStore(storageRoot);
const yamlPath = join(tmpDir, "invalid-workflow.yaml");
await writeFile(yamlPath, "name: test\n# missing roles, conditions, and graph");
await writeFile(yamlPath, "name: test\n# missing roles and graph");
await expect(cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot)).rejects.toThrow();
});
@@ -0,0 +1,147 @@
import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { RunningThreadItem, ThreadId } from "@uncaged/workflow-protocol";
import type { RunningMarker } from "./types.js";
/**
* Get the path to the running markers directory.
*/
export function getRunningDir(storageRoot: string): string {
return join(storageRoot, "running");
}
/**
* Get the path to a specific thread's marker file.
*/
export function getMarkerPath(storageRoot: string, threadId: ThreadId): string {
return join(getRunningDir(storageRoot), `${threadId}.json`);
}
/**
* Check if a PID is still running.
* Returns true if the process exists, false otherwise.
*/
export function isPidAlive(pid: number): boolean {
try {
// process.kill with signal 0 checks existence without killing
process.kill(pid, 0);
return true;
} catch {
// ESRCH means process doesn't exist
return false;
}
}
/**
* Create a marker file for a running thread.
* Writes to a temp file in the same directory, then atomically renames.
*/
export async function createMarker(storageRoot: string, marker: RunningMarker): Promise<void> {
const runningDir = getRunningDir(storageRoot);
await mkdir(runningDir, { recursive: true });
const markerPath = getMarkerPath(storageRoot, marker.thread);
const tempPath = join(runningDir, `.${marker.thread}-${process.pid}.tmp`);
const content = JSON.stringify(marker, null, 2);
await writeFile(tempPath, content, "utf8");
await rename(tempPath, markerPath);
}
/**
* Delete a marker file for a thread.
*/
export async function deleteMarker(storageRoot: string, threadId: ThreadId): Promise<void> {
const markerPath = getMarkerPath(storageRoot, threadId);
try {
await rm(markerPath);
} catch {
// Ignore errors if file doesn't exist
}
}
/**
* Read a marker file. Returns null if file doesn't exist or is invalid.
*/
export async function readMarker(
storageRoot: string,
threadId: ThreadId,
): Promise<RunningMarker | null> {
const markerPath = getMarkerPath(storageRoot, threadId);
try {
const content = await readFile(markerPath, "utf8");
const marker = JSON.parse(content) as RunningMarker;
return marker;
} catch {
return null;
}
}
/**
* List all running threads, filtering out stale markers.
*/
export async function listRunningThreads(storageRoot: string): Promise<RunningThreadItem[]> {
const runningDir = getRunningDir(storageRoot);
let files: string[];
try {
files = await readdir(runningDir);
} catch {
// Directory doesn't exist or can't be read
return [];
}
const results: RunningThreadItem[] = [];
for (const filename of files) {
if (!filename.endsWith(".json")) {
continue;
}
const threadId = filename.slice(0, -5) as ThreadId;
const marker = await readMarker(storageRoot, threadId);
if (marker === null) {
// Invalid marker file
continue;
}
if (!isPidAlive(marker.pid)) {
// Stale marker - process no longer exists
await deleteMarker(storageRoot, threadId);
continue;
}
results.push({
thread: marker.thread,
workflow: marker.workflow,
pid: marker.pid,
startedAt: marker.startedAt,
});
}
return results;
}
/**
* Check if a thread is currently executing in the background.
* Returns the marker if running, null otherwise.
*/
export async function isThreadRunning(
storageRoot: string,
threadId: ThreadId,
): Promise<RunningMarker | null> {
const marker = await readMarker(storageRoot, threadId);
if (marker === null) {
return null;
}
if (!isPidAlive(marker.pid)) {
// Stale marker
await deleteMarker(storageRoot, threadId);
return null;
}
return marker;
}
@@ -0,0 +1,11 @@
export {
createMarker,
deleteMarker,
getMarkerPath,
getRunningDir,
isPidAlive,
isThreadRunning,
listRunningThreads,
readMarker,
} from "./background.js";
export type { RunningMarker } from "./types.js";
@@ -0,0 +1,9 @@
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
/** Marker file stored at ~/.uncaged/workflow/running/<thread-id>.json */
export type RunningMarker = {
thread: ThreadId;
workflow: CasRef;
pid: number;
startedAt: number;
};
+423 -59
View File
@@ -1,8 +1,7 @@
#!/usr/bin/env bun
#!/usr/bin/env node
import type { ThreadId } from "@uncaged/workflow-protocol";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { Command } from "commander";
import { stringify as yamlStringify } from "yaml";
import {
cmdCasGet,
cmdCasHas,
@@ -14,22 +13,35 @@ import {
cmdCasSchemaList,
cmdCasWalk,
} from "./commands/cas.js";
import { cmdConfigGet, cmdConfigList, cmdConfigSet } from "./commands/config.js";
import { cmdLogClean, cmdLogList, cmdLogShow } from "./commands/log.js";
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
import { cmdSkillCli } from "./commands/skill.js";
import {
cmdThreadFork,
cmdThreadKill,
cmdSkillActor,
cmdSkillAdapter,
cmdSkillArchitecture,
cmdSkillAuthor,
cmdSkillCli,
cmdSkillDeveloper,
cmdSkillList,
cmdSkillModerator,
cmdSkillUser,
cmdSkillYaml,
} from "./commands/skill.js";
import { cmdStepFork, cmdStepList, cmdStepRead, cmdStepShow } from "./commands/step.js";
import {
cmdThreadCancel,
cmdThreadExec,
cmdThreadList,
cmdThreadRead,
cmdThreadShow,
cmdThreadStart,
cmdThreadStep,
cmdThreadStepDetails,
cmdThreadSteps,
cmdThreadStop,
THREAD_READ_DEFAULT_QUOTA,
type ThreadStatus,
} from "./commands/thread.js";
import { cmdWorkflowList, cmdWorkflowPut, cmdWorkflowShow } from "./commands/workflow.js";
import { parseTimeInput } from "./commands/thread-time-parser.js";
import { cmdWorkflowAdd, cmdWorkflowList, cmdWorkflowShow } from "./commands/workflow.js";
import { formatOutput, type OutputFormat } from "./format.js";
import { resolveStorageRoot } from "./store.js";
@@ -52,20 +64,26 @@ const program = new Command();
const pkg = await import("../package.json", { with: { type: "json" } });
program
.name("uwf")
.description("Stateless workflow CLI")
.description(
"Stateless workflow CLI\n\n" +
"Four-layer architecture:\n" +
" workflow → thread → step → turn",
)
.version(pkg.default.version, "-V, --version");
program.option("--format <fmt>", "Output format: json or yaml", "json");
const workflow = program.command("workflow").description("Workflow registry and CAS");
const workflow = program
.command("workflow")
.description("Workflow definitions (layer 1: templates)");
workflow
.command("put")
.command("add")
.description("Register a workflow from YAML")
.argument("<file>", "Workflow YAML file")
.action((file: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdWorkflowPut(storageRoot, file);
const result = await cmdWorkflowAdd(storageRoot, file);
writeOutput(result);
});
});
@@ -93,7 +111,7 @@ workflow
});
});
const thread = program.command("thread").description("Thread lifecycle and execution");
const thread = program.command("thread").description("Thread execution (layer 2: instances)");
thread
.command("start")
@@ -109,24 +127,46 @@ thread
});
thread
.command("step")
.command("exec")
.description("Execute one or more steps")
.argument("<thread-id>", "Thread ULID")
.option("--agent <cmd>", "Override agent command")
.option("-c, --count <number>", "Number of steps to run (default: 1)")
.action((threadId: string, opts: { agent: string | undefined; count: string | undefined }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const agentOverride = opts.agent ?? null;
const count = opts.count !== undefined ? Number(opts.count) : 1;
const results = await cmdThreadStep(storageRoot, threadId, agentOverride, count);
if (results.length === 1) {
writeOutput(results[0]);
} else {
writeOutput(results);
}
});
});
.option("--background", "Run in background and return immediately")
.option("--_background-worker", "Internal flag for background worker process", false)
.action(
(
threadId: string,
opts: {
agent: string | undefined;
count: string | undefined;
background: boolean;
_backgroundWorker: boolean;
},
) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const agentOverride = opts.agent ?? null;
const count = opts.count !== undefined ? Number(opts.count) : 1;
const background = opts.background ?? false;
const backgroundWorker = opts._backgroundWorker ?? false;
const results = await cmdThreadExec(
storageRoot,
threadId,
agentOverride,
count,
background,
backgroundWorker,
);
if (results.length === 1) {
writeOutput(results[0]);
} else {
writeOutput(results);
}
});
},
);
thread
.command("show")
@@ -140,38 +180,124 @@ thread
});
});
// Helper functions for thread list command parsing
function parseStatusFilter(status: string | undefined): ThreadStatus[] | null {
if (status === undefined) return null;
const raw = status.trim();
if (raw === "active") return ["idle", "running"];
const parts = raw.split(",").map((s) => s.trim());
const validStatuses: ThreadStatus[] = ["idle", "running", "completed", "cancelled"];
for (const part of parts) {
if (!validStatuses.includes(part as ThreadStatus)) {
process.stderr.write(
`Invalid status: ${part}. Must be one of: idle, running, completed, cancelled, active\n`,
);
process.exit(1);
}
}
return parts as ThreadStatus[];
}
function parseTimeFilters(
after: string | undefined,
before: string | undefined,
nowMs: number,
): { afterMs: number | null; beforeMs: number | null } {
try {
const afterMs = after !== undefined ? parseTimeInput(after, nowMs) : null;
const beforeMs = before !== undefined ? parseTimeInput(before, nowMs) : null;
return { afterMs, beforeMs };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
process.stderr.write(`${message}\n`);
process.exit(1);
}
}
function parsePaginationOptions(
skip: string | undefined,
take: string | undefined,
): { skip: number | null; take: number | null } {
let skipVal: number | null = null;
let takeVal: number | null = null;
if (skip !== undefined) {
skipVal = Number.parseInt(skip, 10);
if (!Number.isInteger(skipVal) || skipVal < 0) {
process.stderr.write("--skip must be a non-negative integer\n");
process.exit(1);
}
}
if (take !== undefined) {
takeVal = Number.parseInt(take, 10);
if (!Number.isInteger(takeVal) || takeVal < 1) {
process.stderr.write("--take must be a positive integer\n");
process.exit(1);
}
}
return { skip: skipVal, take: takeVal };
}
thread
.command("list")
.description("List active threads")
.option("--all", "Include archived threads")
.action((opts: { all: boolean }) => {
.description("List threads")
.option(
"--status <status>",
"Filter by status: idle, running, completed, cancelled, active (idle+running), or comma-separated values",
)
.option("--after <date>", "Filter threads created after this date (ISO or relative like '7d')")
.option("--before <date>", "Filter threads created before this date (ISO or relative like '7d')")
.option("--skip <n>", "Skip first n threads")
.option("--take <n>", "Return at most n threads")
.action(
(opts: {
status: string | undefined;
after: string | undefined;
before: string | undefined;
skip: string | undefined;
take: string | undefined;
}) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const statusFilter = parseStatusFilter(opts.status);
const nowMs = Date.now();
const { afterMs, beforeMs } = parseTimeFilters(opts.after, opts.before, nowMs);
const { skip, take } = parsePaginationOptions(opts.skip, opts.take);
const result = await cmdThreadList(
storageRoot,
statusFilter,
afterMs,
beforeMs,
skip,
take,
);
writeOutput(result);
});
},
);
thread
.command("stop")
.description("Stop background execution of a thread (keep thread active)")
.argument("<thread-id>", "Thread ULID")
.action((threadId: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadList(storageRoot, opts.all);
const result = await cmdThreadStop(storageRoot, threadId);
writeOutput(result);
});
});
thread
.command("kill")
.description("Terminate and archive a thread")
.command("cancel")
.description("Cancel a thread (stop execution and move to history)")
.argument("<thread-id>", "Thread ULID")
.action((threadId: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadKill(storageRoot, threadId);
writeOutput(result);
});
});
thread
.command("steps")
.description("List all steps in a thread")
.argument("<thread-id>", "Thread ULID")
.action((threadId: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadSteps(storageRoot, threadId);
const result = await cmdThreadCancel(storageRoot, threadId);
writeOutput(result);
});
});
@@ -205,31 +331,161 @@ thread
},
);
thread
const step = program.command("step").description("Step results (layer 3: single cycle)");
step
.command("list")
.description("List all steps in a thread")
.argument("<thread-id>", "Thread ULID")
.action((threadId: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdStepList(storageRoot, threadId);
writeOutput(result);
});
});
step
.command("show")
.description("Show details of a specific step")
.argument("<step-hash>", "CAS hash of the StepNode")
.action((stepHash: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const detail = await cmdStepShow(storageRoot, stepHash as CasRef);
writeOutput(detail);
});
});
step
.command("read")
.description("Read a step's turns as human-readable markdown")
.argument("<step-hash>", "CAS hash of the StepNode")
.option("--quota <chars>", "Max output characters", "4000")
.action((stepHash: string, opts: { quota: string }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const quota = Number.parseInt(opts.quota, 10);
if (!Number.isFinite(quota) || quota < 1) {
process.stderr.write("invalid --quota: must be a positive integer\n");
process.exit(1);
}
const markdown = await cmdStepRead(storageRoot, stepHash as CasRef, quota);
process.stdout.write(markdown.endsWith("\n") ? markdown : `${markdown}\n`);
});
});
step
.command("fork")
.description("Fork a thread from a specific step")
.argument("<step-hash>", "CAS hash of the StartNode or StepNode to fork from")
.action((stepHash: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadFork(storageRoot, stepHash);
const result = await cmdStepFork(storageRoot, stepHash as CasRef);
writeOutput(result);
});
});
// ── Deprecation Handlers ──────────────────────────────────────────────────────
// These commands have been removed. Show helpful error messages.
workflow
.command("put")
.description("[DEPRECATED] Use 'workflow add' instead")
.argument("<file>", "Workflow YAML file")
.action(() => {
process.stderr.write(`Error: Command 'workflow put' has been removed.
Use 'workflow add' instead.
For more information, see: uwf help workflow add
`);
process.exit(1);
});
thread
.command("step")
.description("[DEPRECATED] Use 'thread exec' instead")
.argument("<thread-id>", "Thread ULID")
.allowUnknownOption()
.action(() => {
process.stderr.write(`Error: Command 'thread step' has been removed.
Use 'thread exec' instead.
For more information, see: uwf help thread exec
`);
process.exit(1);
});
thread
.command("steps")
.description("[DEPRECATED] Use 'step list' instead")
.argument("<thread-id>", "Thread ULID")
.action(() => {
process.stderr.write(`Error: Command 'thread steps' has been removed.
Use 'step list' instead.
For more information, see: uwf help step list
`);
process.exit(1);
});
thread
.command("step-details")
.description("Dump the full detail node of a step as YAML")
.argument("<step-hash>", "CAS hash of the StepNode")
.action((stepHash: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const detail = await cmdThreadStepDetails(storageRoot, stepHash);
process.stdout.write(yamlStringify(detail));
});
.description("[DEPRECATED] Use 'step show' instead")
.argument("<step-hash>", "Step hash")
.action(() => {
process.stderr.write(`Error: Command 'thread step-details' has been removed.
Use 'step show' instead.
For more information, see: uwf help step show
`);
process.exit(1);
});
thread
.command("fork")
.description("[DEPRECATED] Use 'step fork' instead")
.argument("<step-hash>", "Step hash")
.action(() => {
process.stderr.write(`Error: Command 'thread fork' has been removed.
Use 'step fork' instead.
For more information, see: uwf help step fork
`);
process.exit(1);
});
thread
.command("kill")
.description("[DEPRECATED] Use 'thread stop' or 'thread cancel' instead")
.argument("<thread-id>", "Thread ULID")
.action(() => {
process.stderr.write(`Error: Command 'thread kill' has been removed.
Use 'thread stop' to stop background execution (keep thread active),
or 'thread cancel' to cancel and archive the thread.
For more information, see:
uwf help thread stop
uwf help thread cancel
`);
process.exit(1);
});
thread
.command("running")
.description("[DEPRECATED] Use 'thread list --status running' instead")
.action(() => {
process.stderr.write(`Error: Command 'thread running' has been removed.
Use 'thread list --status running' instead.
For more information, see: uwf help thread list
`);
process.exit(1);
});
const skill = program.command("skill").description("Built-in skill references for agents");
skill.addHelpCommand(false);
skill
.command("cli")
@@ -238,6 +494,69 @@ skill
console.log(cmdSkillCli());
});
skill
.command("architecture")
.description("Print the architecture reference")
.action(() => {
console.log(cmdSkillArchitecture());
});
skill
.command("yaml")
.description("Print the workflow YAML schema reference")
.action(() => {
console.log(cmdSkillYaml());
});
skill
.command("actor")
.description("Print the actor reference (frontmatter protocol + CAS)")
.action(() => {
console.log(cmdSkillActor());
});
skill
.command("adapter")
.description("Print the adapter reference (building agent adapters)")
.action(() => {
console.log(cmdSkillAdapter());
});
skill
.command("author")
.description("Print the author reference (workflow YAML design guide)")
.action(() => {
console.log(cmdSkillAuthor());
});
skill
.command("developer")
.description("Print the developer reference (coding conventions + architecture)")
.action(() => {
console.log(cmdSkillDeveloper());
});
skill
.command("moderator")
.description("Print the moderator reference")
.action(() => {
console.log(cmdSkillModerator());
});
skill
.command("user")
.description("Print the user reference (CLI guide + typical workflows)")
.action(() => {
console.log(cmdSkillUser());
});
skill
.command("list")
.description("List all available skill names")
.action(() => {
console.log(cmdSkillList().join("\n"));
});
program
.command("setup")
.description("Configure provider, model, and agent")
@@ -321,7 +640,11 @@ cas
.action((hash: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasHas(storageRoot, hash));
const result = await cmdCasHas(storageRoot, hash);
writeOutput(result);
if (!result.exists) {
process.exit(1);
}
});
});
@@ -429,6 +752,47 @@ log
});
});
const config = program.command("config").description("Configuration management");
config
.command("list")
.description("Display all configuration values (masks API keys)")
.action(() => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdConfigList(storageRoot);
writeOutput(result);
});
});
config
.command("get")
.description("Get a specific configuration value")
.argument(
"<key>",
"Dot-notation path to config value (e.g., defaultAgent, providers.dashscope.baseUrl)",
)
.action((key: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdConfigGet(storageRoot, key);
writeOutput({ value: result });
});
});
config
.command("set")
.description("Set a specific configuration value")
.argument("<key>", "Dot-notation path to config value")
.argument("<value>", "New value (use JSON array for 'args' key, e.g., '[\"--flag\"]')")
.action((key: string, value: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdConfigSet(storageRoot, key, value);
writeOutput(result);
});
});
program.parseAsync(process.argv).catch((e: unknown) => {
const message = e instanceof Error ? e.message : String(e);
process.stderr.write(`${message}\n`);
@@ -0,0 +1,289 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { parse, stringify } from "yaml";
/**
* Valid configuration key schema
*/
const VALID_CONFIG_KEYS: Record<string, { nested: boolean; knownFields?: string[] }> = {
providers: {
nested: true,
knownFields: ["baseUrl", "apiKey"],
},
models: {
nested: true,
knownFields: ["provider", "name"],
},
agents: {
nested: true,
knownFields: ["command", "args"],
},
defaultAgent: { nested: false },
defaultModel: { nested: false },
};
/**
* Validate a config key path against the known schema
*/
function validateConfigKey(path: string[]): void {
if (path.length === 0) {
throw new Error("Path cannot be empty");
}
const topLevel = path[0];
const schema = VALID_CONFIG_KEYS[topLevel];
if (!schema) {
const validKeys = Object.keys(VALID_CONFIG_KEYS).join(", ");
throw new Error(`Unknown config key: ${topLevel}. Valid top-level keys are: ${validKeys}`);
}
// Scalar keys cannot have nested paths
if (!schema.nested && path.length > 1) {
throw new Error(`${topLevel} is a scalar key and cannot have nested properties`);
}
// Nested keys must have at least 3 segments (e.g., providers.myProvider.baseUrl)
if (schema.nested && path.length < 3) {
const fields = schema.knownFields?.join(", ") ?? "";
throw new Error(
`Incomplete path for ${topLevel}. Must specify a field (e.g., ${topLevel}.<name>.<field>). Valid fields: ${fields}`,
);
}
// Validate the field name for nested keys
if (schema.nested && path.length >= 3 && schema.knownFields) {
const field = path[path.length - 1];
if (!schema.knownFields.includes(field)) {
throw new Error(
`Unknown field '${field}' in ${topLevel}. Valid fields are: ${schema.knownFields.join(", ")}`,
);
}
}
}
/**
* Returns the path to the config.yaml file
*/
export function getConfigPath(storageRoot: string): string {
return join(storageRoot, "config.yaml");
}
/**
* Load and parse YAML config file
*/
export function loadConfig(configPath: string): Record<string, unknown> {
if (!existsSync(configPath)) {
throw new Error(`Config file not found: ${configPath}`);
}
const content = readFileSync(configPath, "utf8");
if (!content.trim()) {
return {};
}
try {
const parsed = parse(content);
return (parsed ?? {}) as Record<string, unknown>;
} catch (error) {
throw new Error(
`Invalid YAML in config file: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Save config as YAML
*/
export function saveConfig(configPath: string, config: Record<string, unknown>): void {
const dir = join(configPath, "..");
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const yaml = stringify(config);
writeFileSync(configPath, yaml, "utf8");
}
/**
* Parse dot-notation key into path segments
*/
export function parseDotPath(key: string): string[] {
return key.split(".");
}
/**
* Get nested value from object using path array
*/
export function getNestedValue(obj: Record<string, unknown>, path: string[]): unknown {
let current: unknown = obj;
for (const segment of path) {
if (current === null || current === undefined || typeof current !== "object") {
return undefined;
}
current = (current as Record<string, unknown>)[segment];
}
return current;
}
/**
* Set nested value in object using path array (mutates obj)
*/
export function setNestedValue(obj: Record<string, unknown>, path: string[], value: unknown): void {
if (path.length === 0) {
throw new Error("Path cannot be empty");
}
let current: Record<string, unknown> = obj;
// Navigate/create to the parent of the target
for (let i = 0; i < path.length - 1; i++) {
const segment = path[i];
const next = current[segment];
if (next === null || next === undefined) {
// Create intermediate object
const newObj: Record<string, unknown> = {};
current[segment] = newObj;
current = newObj;
} else if (typeof next === "object" && !Array.isArray(next)) {
// Navigate into existing object
current = next as Record<string, unknown>;
} else {
// Cannot navigate into non-object
throw new Error(
`Cannot set property '${path[i + 1]}' on non-object at path '${path.slice(0, i + 1).join(".")}'`,
);
}
}
// Set the final value
const lastSegment = path[path.length - 1];
current[lastSegment] = value;
}
/**
* Deep clone and mask all apiKey values in providers section
*/
export function maskApiKeys(config: Record<string, unknown>): Record<string, unknown> {
// Deep clone
const cloned = JSON.parse(JSON.stringify(config)) as Record<string, unknown>;
// Mask apiKey values in providers
if (cloned.providers && typeof cloned.providers === "object") {
const providers = cloned.providers as Record<string, unknown>;
for (const providerName of Object.keys(providers)) {
const provider = providers[providerName];
if (provider && typeof provider === "object") {
const providerObj = provider as Record<string, unknown>;
if ("apiKey" in providerObj) {
providerObj.apiKey = "***MASKED***";
}
}
}
}
return cloned;
}
/**
* List all configuration values (masks API keys)
*/
export async function cmdConfigList(storageRoot: string): Promise<unknown> {
const configPath = getConfigPath(storageRoot);
const config = loadConfig(configPath);
const masked = maskApiKeys(config);
return masked;
}
/**
* Get a specific configuration value
*/
export async function cmdConfigGet(storageRoot: string, key: string): Promise<unknown> {
const configPath = getConfigPath(storageRoot);
const config = loadConfig(configPath);
const path = parseDotPath(key);
const value = getNestedValue(config, path);
if (value === undefined) {
throw new Error(`Key not found: ${key}`);
}
return value;
}
/**
* Parse value for args key (must be JSON array)
*/
function parseArgsValue(value: string): unknown {
if (value.startsWith("[")) {
try {
const parsed = JSON.parse(value);
if (!Array.isArray(parsed)) {
throw new Error("Value must be an array");
}
return parsed;
} catch (error) {
throw new Error(
`Invalid JSON array for args key: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
throw new Error("Value for 'args' key must be a JSON array starting with '['");
}
/**
* Validate that we're not setting a property on a non-object
*/
function validateParentPath(
config: Record<string, unknown>,
path: string[],
lastSegment: string,
): void {
if (path.length > 1) {
const parentPath = path.slice(0, -1);
const parent = getNestedValue(config, parentPath);
if (parent !== null && parent !== undefined && typeof parent !== "object") {
throw new Error(
`Cannot set property '${lastSegment}' on non-object at path '${parentPath.join(".")}'`,
);
}
}
}
/**
* Set a specific configuration value
*/
export async function cmdConfigSet(
storageRoot: string,
key: string,
value: string,
): Promise<unknown> {
const configPath = getConfigPath(storageRoot);
// Load existing config or create empty one
let config: Record<string, unknown>;
if (existsSync(configPath)) {
config = loadConfig(configPath);
} else {
config = {};
}
const path = parseDotPath(key);
// Validate the key path
validateConfigKey(path);
const lastSegment = path[path.length - 1];
// Parse value if it's for an array key (args)
let parsedValue: unknown = value;
if (lastSegment === "args") {
parsedValue = parseArgsValue(value);
}
// Validate we're not setting a property on a non-object
validateParentPath(config, path, lastSegment);
setNestedValue(config, path, parsedValue);
saveConfig(configPath, config);
return { key, value: parsedValue };
}
+332 -198
View File
@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { stdin as input, stdout as output } from "node:process";
import { createInterface } from "node:readline/promises";
@@ -85,10 +85,6 @@ function getConfigPath(root: string): string {
return join(root, "config.yaml");
}
function getEnvPath(root: string): string {
return join(root, ".env");
}
/**
* Load existing config.yaml or return empty structure.
*/
@@ -106,106 +102,256 @@ function loadExistingConfig(configPath: string): Record<string, unknown> {
return {};
}
// ──────────────────────────────────────────────────────────────────────────────
// Extracted helpers — _discoverAgents
// ──────────────────────────────────────────────────────────────────────────────
/**
* Load existing .env as key=value map.
* Scans directories from a PATH string for uwf-* executables.
*/
function loadEnvFile(envPath: string): Record<string, string> {
const env: Record<string, string> = {};
export async function _searchPathDirs(pathEnv: string): Promise<string[]> {
if (!pathEnv) return [];
const dirs = pathEnv.split(":").filter((d) => d.length > 0);
const agents = new Set<string>();
for (const dir of dirs) {
_scanDirForAgents(dir, agents);
}
return Array.from(agents).sort();
}
function _scanDirForAgents(dir: string, agents: Set<string>): void {
try {
if (existsSync(envPath)) {
for (const line of readFileSync(envPath, "utf8").split("\n")) {
const trimmed = line.trim();
if (trimmed === "" || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq > 0) {
env[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
}
if (!existsSync(dir)) return;
const entries = readdirSync(dir);
for (const entry of entries) {
if (!entry.startsWith("uwf-") || entry === "uwf") continue;
if (_isExecutableFile(join(dir, entry))) {
agents.add(entry);
}
}
} catch {
// ignore
// Skip inaccessible directories
}
return env;
}
function saveEnvFile(envPath: string, env: Record<string, string>): void {
const lines = Object.entries(env).map(([k, v]) => `${k}=${v}`);
writeFileSync(envPath, `${lines.join("\n")}\n`, "utf8");
function _isExecutableFile(fullPath: string): boolean {
try {
const s = statSync(fullPath);
return s.isFile() && (s.mode & 0o111) !== 0;
} catch {
return false;
}
}
function apiKeyEnvName(providerName: string): string {
return `${providerName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
/**
* Parses the stdout of `which -a` into sorted unique basenames.
*/
export function _parseWhichOutput(text: string): string[] {
if (!text) return [];
const agents = new Set<string>();
for (const line of text.trim().split("\n")) {
if (!line) continue;
const basename = line.split("/").pop() ?? "";
if (basename.startsWith("uwf-") && basename !== "uwf") {
agents.add(basename);
}
}
return Array.from(agents).sort();
}
/**
* Discover uwf-* agent binaries in PATH.
* Returns sorted list of binary names (e.g., ["uwf-hermes", "uwf-claude-code"]).
*/
async function _discoverAgents(): Promise<string[]> {
export async function _discoverAgents(): Promise<string[]> {
try {
const agents = await _tryWhichDiscovery();
if (agents !== null) return agents;
return await _searchPathDirs(process.env.PATH ?? "");
} catch {
return [];
}
}
async function _tryWhichDiscovery(): Promise<string[] | null> {
try {
// Use which -a to find all uwf-* binaries in PATH
const proc = Bun.spawn(["which", "-a", "uwf-hermes", "uwf-claude-code", "uwf-cursor"], {
stdout: "pipe",
stderr: "pipe",
});
const text = await new Response(proc.stdout).text();
await proc.exited;
if (proc.exitCode !== 0) {
// Try alternative approach: search PATH directories manually
const pathEnv = process.env.PATH || "";
const pathDirs = pathEnv.split(":").filter((d) => d.length > 0);
const agents = new Set<string>();
for (const dir of pathDirs) {
try {
if (!existsSync(dir)) continue;
const { readdirSync, statSync } = await import("node:fs");
const entries = readdirSync(dir);
for (const entry of entries) {
if (!entry.startsWith("uwf-") || entry === "uwf") continue;
const fullPath = join(dir, entry);
try {
const stat = statSync(fullPath);
// Check if executable (owner, group, or other has execute bit)
if (stat.isFile() && (stat.mode & 0o111) !== 0) {
agents.add(entry);
}
} catch {
// Skip if can't stat
}
}
} catch {
// Skip inaccessible directories
}
}
return Array.from(agents).sort();
}
// Parse which output - each line is a path to a binary
const paths = text
.trim()
.split("\n")
.filter((line) => line.length > 0);
const agents = new Set<string>();
for (const path of paths) {
const basename = path.split("/").pop();
if (basename?.startsWith("uwf-") && basename !== "uwf") {
agents.add(basename);
}
}
return Array.from(agents).sort();
if (proc.exitCode !== 0) return null;
return _parseWhichOutput(text);
} catch {
// If all fails, return empty array
return [];
return null;
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Extracted helpers — onData closure (promptSecret)
// ──────────────────────────────────────────────────────────────────────────────
/** Returns true for newline, carriage return, or EOF (EOT). */
export function _isTerminator(c: string): boolean {
return c === "\n" || c === "\r" || c === "";
}
/** Returns true for DEL or backspace. */
export function _isBackspace(c: string): boolean {
return c === "" || c === "\b";
}
// ──────────────────────────────────────────────────────────────────────────────
// Extracted helpers — cmdSetupInteractive
// ──────────────────────────────────────────────────────────────────────────────
type ProviderEntry = { name: string; label: string; baseUrl: string };
/** Prints the numbered provider list and custom option to stdout. */
export function _printProviderMenu(providers: readonly ProviderEntry[]): void {
const numWidth = String(providers.length + 1).length;
for (let i = 0; i < providers.length; i++) {
const p = providers[i];
if (!p) continue;
const num = String(i + 1).padStart(numWidth);
console.log(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
}
const customNum = String(providers.length + 1).padStart(numWidth);
console.log(` ${customNum}) Custom (enter name and URL manually)\n`);
}
/** Resolves a numeric choice string to a preset provider, or null for custom/invalid. */
export function _resolveProviderChoice(
choice: string,
providers: readonly ProviderEntry[],
): { providerName: string; baseUrl: string } | null {
const n = Number.parseInt(choice, 10);
if (Number.isNaN(n) || n < 1 || n > providers.length) return null;
const p = providers[n - 1];
if (!p) return null;
return { providerName: p.name, baseUrl: p.baseUrl };
}
/** Resolves numeric index or literal model name to a model string. */
export function _resolveModelChoice(input: string, models: string[]): string {
const n = Number.parseInt(input, 10);
if (!Number.isNaN(n) && n >= 1 && n <= models.length) {
return models[n - 1] ?? input;
}
return input;
}
/** Prints the multi-column model list to stdout. */
export function _printModelMenu(models: string[], termCols: number): void {
const nw = String(models.length).length;
const maxLen = models.reduce((m, s) => Math.max(m, s.length), 0);
const colWidth = nw + 2 + maxLen + 4;
const cols = Math.max(1, Math.floor(termCols / colWidth));
const rows = Math.ceil(models.length / cols);
for (let r = 0; r < rows; r++) {
let line = "";
for (let c = 0; c < cols; c++) {
const idx = c * rows + r;
if (idx >= models.length) break;
const num = String(idx + 1).padStart(nw);
const name = (models[idx] ?? "").padEnd(maxLen);
line += ` ${num}) ${name} `;
}
console.log(line.trimEnd());
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Agent selection prompt
// ──────────────────────────────────────────────────────────────────────────────
/** Known agent binary → display label mapping. */
const KNOWN_AGENTS: Record<string, string> = {
"uwf-hermes": "Hermes (hermes-agent)",
"uwf-claude-code": "Claude Code",
"uwf-cursor": "Cursor",
"uwf-builtin": "Built-in (lightweight, no external agent)",
};
/** Extract short agent name from binary name: uwf-claude-code → claude-code */
export function _agentNameFromBinary(binary: string): string {
return binary.replace(/^uwf-/, "");
}
/** Prints numbered agent list to stdout. */
export function _printAgentMenu(agents: string[]): void {
const numWidth = String(agents.length).length;
for (let i = 0; i < agents.length; i++) {
const bin = agents[i] ?? "";
const label = KNOWN_AGENTS[bin] ?? bin;
const num = String(i + 1).padStart(numWidth);
console.log(` ${num}) ${label} (${bin})`);
}
console.log("");
}
/**
* Interactive agent selection. Discovers uwf-* binaries, lets user pick default.
* Returns short agent name (e.g. "hermes", "claude-code").
*/
export async function _promptAgentSelection(
rl: ReturnType<typeof createInterface>,
): Promise<string> {
console.log("Discovering installed agents...\n");
const agents = await _discoverAgents();
if (agents.length === 0) {
console.log(" No uwf-* agent binaries found in PATH.\n");
console.log(" Install one first, for example:");
console.log(" npm i -g @uncaged/workflow-agent-hermes");
console.log(" npm i -g @uncaged/workflow-agent-claude-code\n");
const manual = (
await rl.question("Agent binary name (e.g. uwf-hermes), or press Enter to skip: ")
).trim();
if (!manual) return "hermes";
return _agentNameFromBinary(manual.startsWith("uwf-") ? manual : `uwf-${manual}`);
}
if (agents.length === 1) {
const name = _agentNameFromBinary(agents[0] ?? "uwf-hermes");
const label = KNOWN_AGENTS[agents[0] ?? ""] ?? agents[0];
console.log(` Found 1 agent: ${label} — auto-selected.\n`);
return name;
}
console.log(` Found ${agents.length} agents:\n`);
_printAgentMenu(agents);
const choice = (await rl.question(`Choose default agent [1-${agents.length}]: `)).trim();
const n = Number.parseInt(choice, 10);
if (!Number.isNaN(n) && n >= 1 && n <= agents.length) {
const selected = agents[n - 1] ?? "uwf-hermes";
const name = _agentNameFromBinary(selected);
console.log(`${name}\n`);
return name;
}
// Treat as literal name
const name = _agentNameFromBinary(choice.startsWith("uwf-") ? choice : `uwf-${choice}`);
console.log(`${name}\n`);
return name;
}
type ValidationResult = { ok: boolean; error: string | null };
/** Prints the model validation result to stdout. */
export function _printValidationResult(validation: ValidationResult): void {
if (validation.ok) {
console.log("✓ Model verified — connection successful.\n");
} else {
console.log(`\n⚠ Warning: Could not reach model — ${validation.error}`);
console.log(
" Config saved, but you may want to try a different model or check your API key.\n",
);
}
}
// ──────────────────────────────────────────────────────────────────────────────
/**
* Merge setup args into config.yaml structure. Non-destructive preserves existing entries.
*/
@@ -216,8 +362,7 @@ function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record
: {}
) as Record<string, unknown>;
const envName = apiKeyEnvName(args.provider);
providers[args.provider] = { baseUrl: args.baseUrl, apiKeyEnv: envName };
providers[args.provider] = { baseUrl: args.baseUrl, apiKey: args.apiKey };
const models = (
typeof existing.models === "object" && existing.models !== null
@@ -232,9 +377,10 @@ function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record
: {}
) as Record<string, unknown>;
const agentName = args.agent ?? "hermes";
if (Object.keys(agents).length === 0) {
agents.hermes = { command: "uwf-hermes", args: [] };
const agentName = _agentNameFromBinary(args.agent ?? "hermes");
// Ensure the selected agent has an entry
if (!agents[agentName]) {
agents[agentName] = { command: `uwf-${agentName}`, args: [] };
}
return {
@@ -242,7 +388,7 @@ function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record
providers,
models,
agents,
defaultAgent: existing.defaultAgent ?? agentName,
defaultAgent: agentName,
defaultModel: existing.defaultModel ?? "default",
};
}
@@ -255,25 +401,17 @@ export async function cmdSetup(args: SetupArgs): Promise<Record<string, unknown>
mkdirSync(storageRoot, { recursive: true });
const configPath = getConfigPath(storageRoot);
const envPath = getEnvPath(storageRoot);
const existing = loadExistingConfig(configPath);
const merged = mergeConfig(existing, args);
writeFileSync(configPath, stringify(merged, { indent: 2 }), "utf8");
// Write API key to .env
const envName = apiKeyEnvName(args.provider);
const envData = loadEnvFile(envPath);
envData[envName] = args.apiKey;
saveEnvFile(envPath, envData);
// Validate model connectivity
const validation = await validateModel(args.baseUrl, args.apiKey, args.model);
return {
configPath,
envPath,
provider: args.provider,
model: args.model,
defaultAgent: merged.defaultAgent,
@@ -281,6 +419,46 @@ export async function cmdSetup(args: SetupArgs): Promise<Record<string, unknown>
};
}
type SecretState = {
buf: string;
rawWasSet: boolean;
resolve: (value: string) => void;
onData: (chunk: string) => void;
};
function _handleSecretTerminator(state: SecretState): void {
if (process.stdin.isTTY) process.stdin.setRawMode(state.rawWasSet);
process.stdin.pause();
process.stdin.removeListener("data", state.onData);
process.stdout.write("\n");
state.resolve(state.buf.trim());
}
function _handleSecretBackspace(state: SecretState): void {
if (state.buf.length > 0) {
state.buf = state.buf.slice(0, -1);
process.stdout.write("\b \b");
}
}
function _handleSecretChar(c: string, state: SecretState): boolean {
if (_isTerminator(c)) {
_handleSecretTerminator(state);
return true;
}
if (_isBackspace(c)) {
_handleSecretBackspace(state);
return false;
}
if (c === "") {
if (process.stdin.isTTY) process.stdin.setRawMode(state.rawWasSet);
process.exit(130);
}
state.buf += c;
process.stdout.write("*");
return false;
}
/** Read a line with terminal echo disabled (for secrets). */
async function promptSecret(label: string): Promise<string> {
process.stdout.write(label);
@@ -292,33 +470,13 @@ async function promptSecret(label: string): Promise<string> {
process.stdin.resume();
process.stdin.setEncoding("utf8");
let buf = "";
const onData = (chunk: string) => {
const state: SecretState = { buf: "", rawWasSet, resolve, onData: () => {} };
state.onData = (chunk: string) => {
for (const c of chunk.toString()) {
if (c === "\n" || c === "\r" || c === "\u0004") {
if (process.stdin.isTTY) process.stdin.setRawMode(rawWasSet);
process.stdin.pause();
process.stdin.removeListener("data", onData);
process.stdout.write("\n");
resolve(buf.trim());
return;
}
if (c === "\u007F" || c === "\b") {
if (buf.length > 0) {
buf = buf.slice(0, -1);
process.stdout.write("\b \b");
}
continue;
}
if (c === "\u0003") {
if (process.stdin.isTTY) process.stdin.setRawMode(rawWasSet);
process.exit(130);
}
buf += c;
process.stdout.write("*");
if (_handleSecretChar(c, state)) return;
}
};
process.stdin.on("data", onData);
process.stdin.on("data", state.onData);
});
}
@@ -344,6 +502,56 @@ async function fetchModels(baseUrl: string, apiKey: string): Promise<string[]> {
}
}
async function _promptProviderSelection(
rl: ReturnType<typeof createInterface>,
): Promise<{ providerName: string; baseUrl: string }> {
console.log("Select a provider:\n");
_printProviderMenu(PRESET_PROVIDERS);
const choice = (await rl.question(`Choose [1-${PRESET_PROVIDERS.length + 1}]: `)).trim();
const choiceNum = Number.parseInt(choice, 10);
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > PRESET_PROVIDERS.length + 1) {
throw new Error(`Invalid choice: ${choice}`);
}
const preset = _resolveProviderChoice(choice, PRESET_PROVIDERS);
if (preset) {
const selected = PRESET_PROVIDERS[choiceNum - 1];
if (selected) {
console.log(`\n → ${selected.label} (${selected.baseUrl})\n`);
}
return preset;
}
const providerName = (await rl.question("Provider name (e.g. my-proxy): ")).trim();
if (!providerName) throw new Error("Provider name required");
const baseUrl = (await rl.question("OpenAI-compatible API base URL: ")).trim();
if (!baseUrl) throw new Error("Base URL required");
return { providerName, baseUrl };
}
async function _promptModelSelection(
rl: ReturnType<typeof createInterface>,
baseUrl: string,
apiKey: string,
): Promise<string> {
console.log("\nFetching available models...");
const models = await fetchModels(baseUrl, apiKey);
if (models.length === 0) {
console.log("Could not fetch models. Enter model name manually.");
const model = (await rl.question("Default model (e.g. qwen-plus, gpt-4o): ")).trim();
if (!model) throw new Error("Model required");
return model;
}
console.log(`\nAvailable models (${models.length}):\n`);
_printModelMenu(models, process.stdout.columns || 100);
console.log(`\nChoose a number, or type a model name directly.`);
const modelInput = (await rl.question(`Default model [1-${models.length}]: `)).trim();
if (!modelInput) throw new Error("Model required");
return _resolveModelChoice(modelInput, models);
}
/**
* Interactive setup prompts user for provider, API key, model.
*/
@@ -353,39 +561,7 @@ export async function cmdSetupInteractive(storageRoot: string): Promise<Record<s
try {
console.log("Configure LLM provider for uwf workflow agents.\n");
// 1. Provider selection
const numWidth = String(PRESET_PROVIDERS.length + 1).length;
console.log("Select a provider:\n");
for (let i = 0; i < PRESET_PROVIDERS.length; i++) {
const p = PRESET_PROVIDERS[i];
if (!p) continue;
const num = String(i + 1).padStart(numWidth);
console.log(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
}
const customNum = String(PRESET_PROVIDERS.length + 1).padStart(numWidth);
console.log(` ${customNum}) Custom (enter name and URL manually)\n`);
const choice = (await rl.question(`Choose [1-${PRESET_PROVIDERS.length + 1}]: `)).trim();
const choiceNum = Number.parseInt(choice, 10);
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > PRESET_PROVIDERS.length + 1) {
throw new Error(`Invalid choice: ${choice}`);
}
let providerName: string;
let baseUrl: string;
if (choiceNum <= PRESET_PROVIDERS.length) {
const selected = PRESET_PROVIDERS[choiceNum - 1];
if (!selected) throw new Error("Invalid selection");
providerName = selected.name;
baseUrl = selected.baseUrl;
console.log(`\n → ${selected.label} (${selected.baseUrl})\n`);
} else {
providerName = (await rl.question("Provider name (e.g. my-proxy): ")).trim();
if (!providerName) throw new Error("Provider name required");
baseUrl = (await rl.question("OpenAI-compatible API base URL: ")).trim();
if (!baseUrl) throw new Error("Base URL required");
}
const { providerName, baseUrl } = await _promptProviderSelection(rl);
// 2. API key
rl.close();
@@ -394,70 +570,28 @@ export async function cmdSetupInteractive(storageRoot: string): Promise<Record<s
// 3. Model selection
const rl2 = createInterface({ input, output });
console.log("\nFetching available models...");
const models = await fetchModels(baseUrl, apiKey);
let model: string;
if (models.length > 0) {
console.log(`\nAvailable models (${models.length}):\n`);
const nw = String(models.length).length;
// Multi-column layout
const maxLen = models.reduce((m, s) => Math.max(m, s.length), 0);
const colWidth = nw + 2 + maxLen + 4; // " N) name "
const termCols = process.stdout.columns || 100;
const cols = Math.max(1, Math.floor(termCols / colWidth));
const rows = Math.ceil(models.length / cols);
for (let r = 0; r < rows; r++) {
let line = "";
for (let c = 0; c < cols; c++) {
const idx = c * rows + r;
if (idx >= models.length) break;
const num = String(idx + 1).padStart(nw);
const name = (models[idx] ?? "").padEnd(maxLen);
line += ` ${num}) ${name} `;
}
console.log(line.trimEnd());
}
console.log(`\nChoose a number, or type a model name directly.`);
const modelInput = (await rl2.question(`Default model [1-${models.length}]: `)).trim();
if (!modelInput) throw new Error("Model required");
const modelNum = Number.parseInt(modelInput, 10);
if (!Number.isNaN(modelNum) && modelNum >= 1 && modelNum <= models.length) {
model = models[modelNum - 1] ?? modelInput;
} else {
model = modelInput;
}
} else {
console.log("Could not fetch models. Enter model name manually.");
model = (await rl2.question("Default model (e.g. qwen-plus, gpt-4o): ")).trim();
if (!model) throw new Error("Model required");
}
const model = await _promptModelSelection(rl2, baseUrl, apiKey);
rl2.close();
console.log(`${providerName}/${model}\n`);
// 4. Agent discovery & selection
const rl3 = createInterface({ input, output });
const agentName = await _promptAgentSelection(rl3);
rl3.close();
const setupResult = await cmdSetup({
provider: providerName,
baseUrl,
apiKey,
model,
agent: agentName,
storageRoot,
});
// Show validation result
if (setupResult.validation && typeof setupResult.validation === "object") {
const v = setupResult.validation as { ok: boolean; error?: string };
if (v.ok) {
console.log("✓ Model verified — connection successful.\n");
} else {
console.log(`\n⚠ Warning: Could not reach model — ${v.error}`);
console.log(
" Config saved, but you may want to try a different model or check your API key.\n",
);
}
_printValidationResult(setupResult.validation as ValidationResult);
}
console.log("Setup complete! Get started:\n");
console.log(" uwf workflow put <workflow.yaml> Register a workflow");
console.log(' uwf thread start <name> -p "..." Start a thread');
@@ -0,0 +1,231 @@
import type { Store as CasStore, JSONSchema } from "@uncaged/json-cas";
import { getSchema } from "@uncaged/json-cas";
import type {
CasRef,
StartNodePayload,
StepNodePayload,
ThreadId,
} from "@uncaged/workflow-protocol";
import { findThreadInHistory, loadThreadsIndex, type UwfStore } from "../store.js";
type ChainState = {
startHash: CasRef;
start: StartNodePayload;
stepsNewestFirst: StepNodePayload[];
headIsStart: boolean;
};
type OrderedStepItem = {
hash: CasRef;
payload: StepNodePayload;
timestamp: number;
};
function fail(message: string): never {
process.stderr.write(`${message}\n`);
process.exit(1);
}
function walkChain(uwf: UwfStore, headHash: CasRef): ChainState {
const headNode = uwf.store.get(headHash);
if (headNode === null) {
fail(`CAS node not found: ${headHash}`);
}
if (headNode.type === uwf.schemas.startNode) {
return {
startHash: headHash,
start: headNode.payload as StartNodePayload,
stepsNewestFirst: [],
headIsStart: true,
};
}
if (headNode.type !== uwf.schemas.stepNode) {
fail(`head ${headHash} is not a StartNode or StepNode`);
}
const stepsNewestFirst: StepNodePayload[] = [];
let hash: CasRef | null = headHash;
while (hash !== null) {
const node = uwf.store.get(hash);
if (node === null) {
fail(`CAS node not found while walking chain: ${hash}`);
}
if (node.type !== uwf.schemas.stepNode) {
break;
}
const payload = node.payload as StepNodePayload;
stepsNewestFirst.push(payload);
hash = payload.prev;
}
const newest = stepsNewestFirst[0];
if (newest === undefined) {
fail(`empty step chain at head ${headHash}`);
}
const startNode = uwf.store.get(newest.start);
if (startNode === null || startNode.type !== uwf.schemas.startNode) {
fail(`StartNode not found: ${newest.start}`);
}
return {
startHash: newest.start,
start: startNode.payload as StartNodePayload,
stepsNewestFirst,
headIsStart: false,
};
}
function expandOutput(uwf: UwfStore, outputRef: CasRef): unknown {
const node = uwf.store.get(outputRef);
if (node === null) {
return {};
}
return node.payload;
}
/**
* Recursively expand all cas_ref fields in a CAS node's payload,
* replacing hash strings with the referenced node's expanded payload.
*/
function expandDeep(store: CasStore, hash: CasRef, visited?: Set<string>): unknown {
const seen = visited ?? new Set<string>();
if (seen.has(hash)) return hash; // cycle guard
seen.add(hash);
const node = store.get(hash);
if (node === null) return hash;
const schema = getSchema(store, node.type);
if (schema === null) return node.payload;
return expandValue(store, schema, node.payload, seen);
}
function expandCasRefField(store: CasStore, value: unknown, visited: Set<string>): unknown {
if (typeof value === "string") {
return expandDeep(store, value as CasRef, visited);
}
return value;
}
function expandAnyOfField(
store: CasStore,
schema: JSONSchema,
value: unknown,
visited: Set<string>,
): unknown {
if (!Array.isArray(schema.anyOf)) return value;
for (const sub of schema.anyOf as JSONSchema[]) {
if (sub.format === "cas_ref" && typeof value === "string") {
return expandDeep(store, value as CasRef, visited);
}
}
return value;
}
function expandArrayField(
store: CasStore,
schema: JSONSchema,
value: unknown,
visited: Set<string>,
): unknown {
if (!schema.items || !Array.isArray(value)) return value;
const itemSchema = schema.items as JSONSchema;
return (value as unknown[]).map((item) => expandValue(store, itemSchema, item, visited));
}
function expandObjectField(
store: CasStore,
schema: JSONSchema,
value: unknown,
visited: Set<string>,
): unknown {
if (value === null || typeof value !== "object" || Array.isArray(value) || !schema.properties) {
return value;
}
const props = schema.properties as Record<string, JSONSchema>;
const obj = value as Record<string, unknown>;
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(obj)) {
const propSchema = props[key];
result[key] = propSchema ? expandValue(store, propSchema, val, visited) : val;
}
return result;
}
function expandValue(
store: CasStore,
schema: JSONSchema,
value: unknown,
visited: Set<string>,
): unknown {
if (schema.format === "cas_ref") return expandCasRefField(store, value, visited);
if (Array.isArray(schema.anyOf)) return expandAnyOfField(store, schema, value, visited);
if (schema.type === "array") return expandArrayField(store, schema, value, visited);
return expandObjectField(store, schema, value, visited);
}
function collectOrderedSteps(
uwf: UwfStore,
headHash: CasRef,
chain: ChainState,
): OrderedStepItem[] {
let hash: CasRef | null = headHash;
const hashToNode = new Map<string, { payload: StepNodePayload; timestamp: number }>();
while (hash !== null) {
const node = uwf.store.get(hash);
if (node === null || node.type !== uwf.schemas.stepNode) {
break;
}
const payload = node.payload as StepNodePayload;
hashToNode.set(hash, { payload, timestamp: node.timestamp });
hash = payload.prev;
}
let cur: CasRef | null = chain.headIsStart ? null : headHash;
const ordered: OrderedStepItem[] = [];
while (cur !== null) {
const entry = hashToNode.get(cur);
if (entry === undefined) {
break;
}
ordered.push({ hash: cur, ...entry });
cur = entry.payload.prev;
}
ordered.reverse();
return ordered;
}
async function resolveHeadHash(storageRoot: string, threadId: ThreadId): Promise<CasRef> {
const index = await loadThreadsIndex(storageRoot);
const activeHead = index[threadId];
if (activeHead !== undefined) {
return activeHead;
}
const hist = await findThreadInHistory(storageRoot, threadId);
if (hist !== null) {
return hist.head;
}
fail(`thread not found: ${threadId}`);
}
export {
type ChainState,
collectOrderedSteps,
expandAnyOfField,
expandArrayField,
expandCasRefField,
expandDeep,
expandObjectField,
expandOutput,
expandValue,
fail,
type OrderedStepItem,
resolveHeadHash,
walkChain,
};
+27 -1
View File
@@ -1 +1,27 @@
export { generateCliReference as cmdSkillCli } from "@uncaged/workflow-util";
export {
generateActorReference as cmdSkillActor,
generateAdapterReference as cmdSkillAdapter,
generateArchitectureReference as cmdSkillArchitecture,
generateAuthorReference as cmdSkillAuthor,
generateCliReference as cmdSkillCli,
generateDeveloperReference as cmdSkillDeveloper,
generateModeratorReference as cmdSkillModerator,
generateUserReference as cmdSkillUser,
generateYamlReference as cmdSkillYaml,
} from "@uncaged/workflow-util";
const SKILL_NAMES = [
"cli",
"architecture",
"yaml",
"moderator",
"actor",
"user",
"author",
"developer",
"adapter",
] as const;
export function cmdSkillList(): ReadonlyArray<string> {
return [...SKILL_NAMES];
}
+320
View File
@@ -0,0 +1,320 @@
import type { BootstrapCapableStore } from "@uncaged/json-cas";
import type {
CasRef,
StartEntry,
StepEntry,
StepNodePayload,
ThreadForkOutput,
ThreadId,
ThreadStepsOutput,
} from "@uncaged/workflow-protocol";
import { generateUlid } from "@uncaged/workflow-util";
import { createUwfStore, loadThreadsIndex, saveThreadsIndex } from "../store.js";
import {
collectOrderedSteps,
expandDeep,
expandOutput,
fail,
resolveHeadHash,
walkChain,
} from "./shared.js";
type TurnToolCall = {
name: string;
args: string;
};
type TurnData = {
index: number;
role: string;
content: string;
toolCalls: TurnToolCall[] | null;
};
/**
* List all steps in a thread (previously: thread steps)
*/
export async function cmdStepList(
storageRoot: string,
threadId: ThreadId,
): Promise<ThreadStepsOutput> {
const headHash = await resolveHeadHash(storageRoot, threadId);
const uwf = await createUwfStore(storageRoot);
const chain = walkChain(uwf, headHash);
const startNode = uwf.store.get(chain.startHash);
if (startNode === null) {
fail(`StartNode not found: ${chain.startHash}`);
}
const startEntry: StartEntry = {
hash: chain.startHash,
workflow: chain.start.workflow,
prompt: chain.start.prompt,
timestamp: startNode.timestamp,
};
const stepEntries: StepEntry[] = [];
const ordered = collectOrderedSteps(uwf, headHash, chain);
for (const item of ordered) {
stepEntries.push({
hash: item.hash,
role: item.payload.role,
output: expandOutput(uwf, item.payload.output),
detail: item.payload.detail ?? null,
agent: item.payload.agent,
timestamp: item.timestamp,
durationMs: item.payload.completedAtMs - item.payload.startedAtMs,
});
}
return {
thread: threadId,
workflow: chain.start.workflow,
steps: [startEntry, ...stepEntries],
};
}
/**
* Show details of a specific step (previously: thread step-details)
*/
export async function cmdStepShow(storageRoot: string, stepHash: CasRef): Promise<unknown> {
const uwf = await createUwfStore(storageRoot);
const node = uwf.store.get(stepHash);
if (node === null) {
fail(`CAS node not found: ${stepHash}`);
}
if (node.type !== uwf.schemas.stepNode) {
fail(`node ${stepHash} is not a StepNode`);
}
const payload = node.payload as StepNodePayload;
if (!payload.detail) {
fail(`step ${stepHash} has no detail`);
}
return expandDeep(uwf.store, payload.detail);
}
/**
* Fork a thread from a specific step (previously: thread fork)
*/
export async function cmdStepFork(
storageRoot: string,
stepHash: CasRef,
): Promise<ThreadForkOutput> {
const uwf = await createUwfStore(storageRoot);
const node = uwf.store.get(stepHash);
if (node === null) {
fail(`CAS node not found: ${stepHash}`);
}
if (node.type !== uwf.schemas.startNode && node.type !== uwf.schemas.stepNode) {
fail(`node ${stepHash} is not a StartNode or StepNode`);
}
const newThreadId = generateUlid(Date.now()) as ThreadId;
const index = await loadThreadsIndex(storageRoot);
index[newThreadId] = stepHash;
await saveThreadsIndex(storageRoot, index);
return {
thread: newThreadId,
forkedFrom: {
step: stepHash,
},
};
}
/**
* Load and validate step detail node from CAS store
*/
function loadStepDetail(store: BootstrapCapableStore, detailRef: CasRef): Record<string, unknown> {
const detailNode = store.get(detailRef);
if (detailNode === null) {
fail(`detail node not found: ${detailRef}`);
}
return detailNode.payload as Record<string, unknown>;
}
function parseTurnToolCalls(raw: unknown): TurnToolCall[] | null {
if (!Array.isArray(raw) || raw.length === 0) {
return null;
}
const calls: TurnToolCall[] = [];
for (const entry of raw) {
if (typeof entry !== "object" || entry === null) {
continue;
}
const record = entry as Record<string, unknown>;
const name = record.name;
const args = record.args;
if (typeof name === "string") {
calls.push({ name, args: typeof args === "string" ? args : "" });
}
}
return calls.length > 0 ? calls : null;
}
function formatTurnBody(turn: TurnData): string {
const parts: string[] = [];
parts.push(`**Turn role:** ${turn.role}`);
if (turn.toolCalls !== null) {
for (const call of turn.toolCalls) {
const argsSuffix = call.args !== "" ? `\`${call.args}\`` : "";
parts.push(`- **${call.name}**${argsSuffix}`);
}
}
if (turn.content !== "") {
if (parts.length > 0) {
parts.push("");
}
parts.push(turn.content);
}
return parts.join("\n");
}
function parseSingleTurn(
store: BootstrapCapableStore,
turnRef: unknown,
fallbackIndex: number,
): TurnData | null {
if (typeof turnRef !== "string") {
return null;
}
const turnNode = store.get(turnRef as CasRef);
if (turnNode === null) {
return null;
}
const turn = turnNode.payload as Record<string, unknown>;
const content = typeof turn.content === "string" ? turn.content : "";
const toolCalls = parseTurnToolCalls(turn.toolCalls);
if (content === "" && toolCalls === null) {
return null;
}
return {
index: typeof turn.index === "number" ? turn.index : fallbackIndex,
role: typeof turn.role === "string" ? turn.role : "assistant",
content,
toolCalls,
};
}
/**
* Load all turn nodes from CAS store and extract display fields
*/
function loadTurnData(store: BootstrapCapableStore, turns: unknown): TurnData[] {
if (!Array.isArray(turns) || turns.length === 0) {
return [];
}
const turnData: TurnData[] = [];
for (const turnRef of turns) {
const parsed = parseSingleTurn(store, turnRef, turnData.length);
if (parsed !== null) {
turnData.push(parsed);
}
}
return turnData;
}
/**
* Select turns that fit within quota, working backwards from most recent
*/
function selectTurnsForQuota(turnData: TurnData[], availableQuota: number): TurnData[] {
const selectedTurns: TurnData[] = [];
let totalChars = 0;
for (let i = turnData.length - 1; i >= 0; i--) {
const turn = turnData[i];
if (turn === undefined) continue;
const turnHeader = `## Turn ${turn.index + 1}\n\n`;
const turnBlock = turnHeader + formatTurnBody(turn);
const separatorCost = selectedTurns.length > 0 ? 2 : 0;
const addCost = turnBlock.length + separatorCost;
if (totalChars + addCost > availableQuota && selectedTurns.length > 0) {
break;
}
selectedTurns.unshift(turn);
totalChars += addCost;
}
return selectedTurns;
}
/**
* Assemble final markdown output from header and selected turns
*/
function formatStepMarkdown(
stepHash: CasRef,
role: string,
agent: string,
turnData: TurnData[],
selectedTurns: TurnData[],
): string {
const parts: string[] = [];
parts.push(`# Step ${stepHash}`);
parts.push("");
parts.push(`**Role:** ${role}`);
parts.push(`**Agent:** ${agent}`);
if (selectedTurns.length === 0) {
return parts.join("\n");
}
const skippedCount = turnData.length - selectedTurns.length;
if (skippedCount > 0) {
parts.push("");
parts.push(`_[Earlier turns omitted due to quota. Use --quota to increase.]_`);
}
for (const turn of selectedTurns) {
parts.push("");
parts.push(`## Turn ${turn.index + 1}`);
parts.push("");
parts.push(formatTurnBody(turn));
}
return parts.join("\n");
}
/**
* Read a step's agent turns as human-readable markdown with quota enforcement
*/
export async function cmdStepRead(
storageRoot: string,
stepHash: CasRef,
quota: number,
): Promise<string> {
const uwf = await createUwfStore(storageRoot);
const node = uwf.store.get(stepHash);
if (node === null) {
fail(`CAS node not found: ${stepHash}`);
}
if (node.type !== uwf.schemas.stepNode) {
fail(`node ${stepHash} is not a StepNode`);
}
const payload = node.payload as StepNodePayload;
if (payload.detail === null) {
return formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
}
const detail = loadStepDetail(uwf.store, payload.detail);
const turnData = loadTurnData(uwf.store, detail.turns);
if (turnData.length === 0) {
return formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
}
const headerSection = formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
const BUFFER = 200;
const availableQuota = quota - headerSection.length - BUFFER;
const selectedTurns = selectTurnsForQuota(turnData, availableQuota);
return formatStepMarkdown(stepHash, payload.role, payload.agent, turnData, selectedTurns);
}
@@ -0,0 +1,23 @@
/**
* Parse time input: ISO date (YYYY-MM-DD, YYYY-MM-DDTHH:MM:SS) or relative (7d, 24h, 30m)
* Returns Unix timestamp in milliseconds.
*/
export function parseTimeInput(input: string, nowMs: number): number {
const trimmed = input.trim();
// Relative time: 7d, 24h, 30m
const relativeMatch = /^(\d+)(d|h|m)$/.exec(trimmed);
if (relativeMatch !== null) {
const value = Number.parseInt(relativeMatch[1], 10);
const unit = relativeMatch[2];
const multiplier = unit === "d" ? 86400000 : unit === "h" ? 3600000 : 60000;
return nowMs - value * multiplier;
}
// ISO date: try parsing
const parsed = Date.parse(trimmed);
if (Number.isNaN(parsed)) {
throw new Error(`invalid time format: ${trimmed} (expected ISO date or relative like '7d')`);
}
return parsed;
}
File diff suppressed because it is too large Load Diff
+25 -22
View File
@@ -2,12 +2,7 @@ import { readFile } from "node:fs/promises";
import type { JSONSchema } from "@uncaged/json-cas";
import { putSchema, validate } from "@uncaged/json-cas";
import type {
CasRef,
RoleDefinition,
Transition,
WorkflowPayload,
} from "@uncaged/workflow-protocol";
import type { CasRef, RoleDefinition, Target, WorkflowPayload } from "@uncaged/workflow-protocol";
import { parse } from "yaml";
import {
@@ -20,6 +15,7 @@ import {
type UwfStore,
} from "../store.js";
import { checkWorkflowFilenameConsistency, parseWorkflowPayload } from "../validate.js";
import { validateWorkflow } from "../validate-semantic.js";
export type WorkflowOrigin = "local" | "global";
@@ -29,7 +25,7 @@ export type WorkflowListEntry = {
origin: WorkflowOrigin;
};
export type WorkflowPutOutput = {
export type WorkflowAddOutput = {
name: string;
hash: CasRef;
};
@@ -51,20 +47,23 @@ function isJsonSchema(value: unknown): value is JSONSchema {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Normalize graph transitions: ensure condition is null (not undefined) for fallback entries. */
function normalizeGraph(graph: Record<string, Transition[]>): Record<string, Transition[]> {
const result: Record<string, Transition[]> = {};
for (const [node, transitions] of Object.entries(graph)) {
result[node] = transitions.map((t) => {
if (typeof t.prompt !== "string" || t.prompt.trim() === "") {
fail(`graph[${node}] transition to "${t.role}": prompt is required (non-empty string)`);
/** Normalize graph: validate each status → target mapping. */
function normalizeGraph(
graph: Record<string, Record<string, Target>>,
): Record<string, Record<string, Target>> {
const result: Record<string, Record<string, Target>> = {};
for (const [node, statusMap] of Object.entries(graph)) {
const normalized: Record<string, Target> = {};
for (const [status, target] of Object.entries(statusMap)) {
if (typeof target.prompt !== "string" || target.prompt.trim() === "") {
fail(`graph[${node}][${status}] → "${target.role}": prompt is required (non-empty string)`);
}
return {
role: t.role,
condition: t.condition ?? null,
prompt: t.prompt,
normalized[status] = {
role: target.role,
prompt: target.prompt,
};
});
}
result[node] = normalized;
}
return result;
}
@@ -106,15 +105,14 @@ export async function materializeWorkflowPayload(
name: raw.name,
description: raw.description,
roles,
conditions: raw.conditions,
graph: normalizeGraph(raw.graph),
};
}
export async function cmdWorkflowPut(
export async function cmdWorkflowAdd(
storageRoot: string,
filePath: string,
): Promise<WorkflowPutOutput> {
): Promise<WorkflowAddOutput> {
let text: string;
try {
text = await readFile(filePath, "utf8");
@@ -139,6 +137,11 @@ export async function cmdWorkflowPut(
fail(filenameError);
}
const semanticErrors = validateWorkflow(payload);
if (semanticErrors.length > 0) {
fail(`workflow validation failed:\n${semanticErrors.map((e) => ` - ${e}`).join("\n")}`);
}
const uwf = await createUwfStore(storageRoot);
const materialized = await materializeWorkflowPayload(uwf, payload);
@@ -0,0 +1,53 @@
import type { Target } from "@uncaged/workflow-protocol";
import mustache from "mustache";
import type { EvaluateResult, Result } from "./types.js";
// Disable HTML escaping — prompts are plain text, not HTML.
mustache.escape = (text: string) => text;
const START_ROLE = "$START";
const UNIT_STATUS = "_";
type LastOutput = Record<string, unknown>;
const STATUS_KEY = "$status";
export function evaluate(
graph: Record<string, Record<string, Target>>,
lastRole: string,
lastOutput: LastOutput,
): Result<EvaluateResult, Error> {
const status =
lastRole === START_ROLE
? UNIT_STATUS
: typeof lastOutput[STATUS_KEY] === "string"
? (lastOutput[STATUS_KEY] as string)
: UNIT_STATUS;
const roleTargets = graph[lastRole];
if (roleTargets === undefined) {
return {
ok: false,
error: new Error(`no transitions defined for role "${lastRole}"`),
};
}
const target = roleTargets[status];
if (target === undefined) {
return {
ok: false,
error: new Error(`no transition for role "${lastRole}" with status "${status}"`),
};
}
try {
const prompt = mustache.render(target.prompt, lastOutput);
return { ok: true, value: { role: target.role, prompt } };
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error : new Error(String(error)),
};
}
}
@@ -0,0 +1,2 @@
export { evaluate } from "./evaluate.js";
export type { EvaluateResult } from "./types.js";
@@ -0,0 +1,7 @@
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
/** The result of moderator evaluation — which role to go to, and the edge prompt. */
export type EvaluateResult = {
role: string;
prompt: string;
};
+10 -1
View File
@@ -88,6 +88,7 @@ export function getHistoryPath(storageRoot: string): string {
export type ThreadHistoryLine = ThreadListItem & {
completedAt: number;
reason: "completed" | "cancelled" | null;
};
export type UwfStore = {
@@ -228,7 +229,15 @@ export async function loadThreadHistory(storageRoot: string): Promise<ThreadHist
typeof head === "string" &&
typeof completedAt === "number"
) {
lines.push({ thread: thread as ThreadId, workflow, head, completedAt });
const reason = rec.reason;
const parsedReason = reason === "completed" || reason === "cancelled" ? reason : null;
lines.push({
thread: thread as ThreadId,
workflow,
head,
completedAt,
reason: parsedReason,
});
}
}
return lines;
@@ -0,0 +1,326 @@
import type { WorkflowPayload } from "@uncaged/workflow-protocol";
type SchemaObj = Record<string, unknown>;
const RESERVED_NAMES = new Set(["$START", "$END"]);
/** Extract mustache variable names from a prompt string. */
function extractMustacheVars(prompt: string): string[] {
const vars: string[] = [];
const re = /\{\{\{?([^}]+)\}\}\}?/g;
let m: RegExpExecArray | null = re.exec(prompt);
while (m !== null) {
vars.push(m[1]);
m = re.exec(prompt);
}
return vars;
}
/** Check if a frontmatter schema is a oneOf (multi-exit) type. */
function isOneOfSchema(fm: unknown): fm is SchemaObj & { oneOf: SchemaObj[] } {
if (typeof fm !== "object" || fm === null) return false;
const obj = fm as SchemaObj;
return Array.isArray(obj.oneOf);
}
/** Check if a frontmatter schema uses enum-based multi-exit ($status with multiple enum values). */
function isEnumMultiExit(fm: unknown): boolean {
if (typeof fm !== "object" || fm === null) return false;
const obj = fm as SchemaObj;
const props = obj.properties as Record<string, SchemaObj> | undefined;
if (!props?.$status) return false;
const statusDef = props.$status;
if (!Array.isArray(statusDef.enum)) return false;
// Filter out "_" (wildcard) — if remaining values > 1, it's multi-exit
const statuses = (statusDef.enum as string[]).filter((s) => s !== "_");
return statuses.length > 1;
}
/** Extract status values from an enum-based $status field. */
function getEnumStatuses(fm: SchemaObj): string[] {
const props = fm.properties as Record<string, SchemaObj> | undefined;
if (!props?.$status) return [];
const statusDef = props.$status;
if (!Array.isArray(statusDef.enum)) return [];
return (statusDef.enum as string[]).filter((s) => s !== "_");
}
/** Get property names from a schema object. */
function getPropertyNames(schema: SchemaObj): Set<string> {
const props = schema.properties;
if (typeof props !== "object" || props === null) return new Set();
return new Set(Object.keys(props as Record<string, unknown>));
}
/** Extract $status const values from oneOf variants. */
function getOneOfStatuses(variants: SchemaObj[]): string[] {
const statuses: string[] = [];
for (const variant of variants) {
const props = variant.properties as Record<string, SchemaObj> | undefined;
if (props?.$status) {
const statusDef = props.$status;
if (typeof statusDef.const === "string") {
statuses.push(statusDef.const);
}
}
}
return statuses;
}
/** Check reserved names and role/graph reference integrity. */
function checkRoleReferences(payload: WorkflowPayload, errors: string[]): void {
const roleNames = new Set(Object.keys(payload.roles));
const graphNodes = new Set(Object.keys(payload.graph));
for (const name of roleNames) {
if (RESERVED_NAMES.has(name)) {
errors.push(`reserved name "${name}" must not appear in roles`);
}
}
for (const node of graphNodes) {
if (!RESERVED_NAMES.has(node) && !roleNames.has(node)) {
errors.push(`graph references unknown role "${node}"`);
}
}
for (const name of roleNames) {
if (RESERVED_NAMES.has(name)) continue;
if (!graphNodes.has(name)) {
errors.push(`role "${name}" is defined but not referenced in graph`);
}
}
}
/** Check $START/$END constraints, edge targets, and reachability. */
function checkGraphStructure(payload: WorkflowPayload, errors: string[]): void {
const roleNames = new Set(Object.keys(payload.roles));
const graphNodes = new Set(Object.keys(payload.graph));
if (!graphNodes.has("$START")) {
errors.push("$START must be defined in graph");
} else {
const startKeys = Object.keys(payload.graph.$START);
if (startKeys.length !== 1 || startKeys[0] !== "_") {
errors.push('$START must have exactly one edge with status "_"');
}
}
if (graphNodes.has("$END")) {
errors.push("$END must not have outgoing edges");
}
for (const [node, statusMap] of Object.entries(payload.graph)) {
for (const [status, target] of Object.entries(statusMap)) {
if (target.role !== "$END" && !roleNames.has(target.role)) {
errors.push(`edge ${node}${status}: unknown target role "${target.role}"`);
}
}
}
checkReachability(roleNames, collectReachableRoles(payload.graph), errors);
}
/** BFS to collect all roles reachable from $START. */
function collectReachableRoles(graph: WorkflowPayload["graph"]): Set<string> {
const reachable = new Set<string>();
const startEdges = graph.$START;
if (!startEdges) return reachable;
const queue: string[] = [];
for (const target of Object.values(startEdges)) {
if (target.role !== "$END" && !reachable.has(target.role)) {
reachable.add(target.role);
queue.push(target.role);
}
}
while (queue.length > 0) {
const current = queue.shift() as string;
const edges = graph[current];
if (!edges) continue;
for (const target of Object.values(edges)) {
if (target.role !== "$END" && !reachable.has(target.role)) {
reachable.add(target.role);
queue.push(target.role);
}
}
}
return reachable;
}
/** Check that all defined roles are reachable from $START. */
function checkReachability(roleNames: Set<string>, reachable: Set<string>, errors: string[]): void {
for (const name of roleNames) {
if (RESERVED_NAMES.has(name)) continue;
if (!reachable.has(name)) {
errors.push(`role "${name}" is not reachable from $START`);
}
}
}
/** Check oneOf discriminant validity for a role. */
function checkOneOfDiscriminant(
roleName: string,
variants: SchemaObj[],
statuses: string[],
errors: string[],
): void {
if (statuses.length === variants.length) return;
let foundMissing = false;
for (const variant of variants) {
const props = variant.properties as Record<string, SchemaObj> | undefined;
if (!props?.$status) {
errors.push(`role "${roleName}": oneOf variants must have "$status" as const discriminant`);
foundMissing = true;
break;
}
if (typeof props.$status.const !== "string") {
errors.push(`role "${roleName}": oneOf variant $status must be a const value`);
foundMissing = true;
break;
}
}
if (!foundMissing) {
errors.push(`role "${roleName}": oneOf variant $status must be a const value`);
}
}
/** Check status-edge consistency for a multi-exit role. */
function checkMultiExitEdges(
roleName: string,
graphKeys: Set<string>,
statusSet: Set<string>,
errors: string[],
): void {
if (graphKeys.has("_")) {
errors.push(`role "${roleName}" is multi-exit but graph uses "_"`);
return;
}
const extraKeys = [...graphKeys].filter((k) => !statusSet.has(k));
const missingKeys = [...statusSet].filter((k) => !graphKeys.has(k));
if (extraKeys.length > 0) {
errors.push(`role "${roleName}" graph has extra status keys: ${extraKeys.join(", ")}`);
}
if (missingKeys.length > 0) {
errors.push(`role "${roleName}" graph is missing status keys: ${missingKeys.join(", ")}`);
}
}
/** Check mustache variables for multi-exit role. */
function checkMultiExitMustache(
roleName: string,
graphEntry: Record<string, { role: string; prompt: string }>,
variants: SchemaObj[],
errors: string[],
): void {
for (const [status, target] of Object.entries(graphEntry)) {
const vars = extractMustacheVars(target.prompt);
const variant = variants.find((v) => {
const props = v.properties as Record<string, SchemaObj> | undefined;
return props?.$status?.const === status;
});
if (!variant) continue;
const propNames = getPropertyNames(variant);
for (const v of vars) {
if (v === "$status") continue;
if (!propNames.has(v)) {
errors.push(`prompt variable "${v}" not found in role "${roleName}" variant "${status}"`);
}
}
}
}
/** Check status-edge consistency and mustache for each role. */
function checkRoleConsistency(payload: WorkflowPayload, errors: string[]): void {
for (const [roleName, role] of Object.entries(payload.roles)) {
if (RESERVED_NAMES.has(roleName)) continue;
const graphEntry = payload.graph[roleName];
if (!graphEntry) continue;
const fm = role.frontmatter as unknown;
const graphKeys = new Set(Object.keys(graphEntry));
if (isOneOfSchema(fm)) {
const variants = fm.oneOf as SchemaObj[];
const statuses = getOneOfStatuses(variants);
checkOneOfDiscriminant(roleName, variants, statuses, errors);
checkMultiExitEdges(roleName, graphKeys, new Set(statuses), errors);
checkMultiExitMustache(roleName, graphEntry, variants, errors);
} else if (isEnumMultiExit(fm)) {
const statuses = getEnumStatuses(fm as SchemaObj);
checkMultiExitEdges(roleName, graphKeys, new Set(statuses), errors);
// For enum-based schemas, mustache vars come from the flat properties
checkSingleExitMustache(roleName, graphEntry, fm as SchemaObj, errors);
} else {
checkSingleExitRole(roleName, graphKeys, graphEntry, fm as SchemaObj | null, errors);
}
}
}
/** Check single-exit role status and mustache. */
function checkSingleExitRole(
roleName: string,
graphKeys: Set<string>,
graphEntry: Record<string, { role: string; prompt: string }>,
fm: SchemaObj | null,
errors: string[],
): void {
if (graphKeys.size > 1 || (graphKeys.size === 1 && !graphKeys.has("_"))) {
if (!graphKeys.has("_")) {
errors.push(`role "${roleName}" is single-exit but graph has no "_" key`);
} else {
errors.push(`role "${roleName}" is single-exit but has status keys other than "_"`);
}
}
const singleTarget = graphEntry._;
if (!singleTarget) return;
const vars = extractMustacheVars(singleTarget.prompt);
const propNames = fm ? getPropertyNames(fm) : new Set<string>();
for (const v of vars) {
if (v === "$status") continue;
if (!propNames.has(v)) {
errors.push(`prompt variable "${v}" not found in role "${roleName}" frontmatter`);
}
}
}
/** Check mustache vars in all edge prompts against flat schema properties. */
function checkSingleExitMustache(
roleName: string,
graphEntry: Record<string, { role: string; prompt: string }>,
fm: SchemaObj,
errors: string[],
): void {
const propNames = getPropertyNames(fm);
for (const [status, target] of Object.entries(graphEntry)) {
const vars = extractMustacheVars(target.prompt);
for (const v of vars) {
if (v === "$status") continue;
if (!propNames.has(v)) {
errors.push(
`prompt variable "${v}" in graph[${roleName}][${status}] not found in role "${roleName}" frontmatter`,
);
}
}
}
}
/**
* Validate a parsed WorkflowPayload for semantic correctness.
* Returns an array of error messages. Empty array = valid.
*/
export function validateWorkflow(payload: WorkflowPayload): string[] {
const errors: string[] = [];
checkRoleReferences(payload, errors);
checkGraphStructure(payload, errors);
checkRoleConsistency(payload, errors);
return errors;
}
+7 -20
View File
@@ -16,7 +16,9 @@ function isRoleDefinition(value: unknown): boolean {
return false;
}
const frontmatter = value.frontmatter;
const frontmatterOk = isRecord(frontmatter) && typeof frontmatter.type === "string";
const frontmatterOk =
isRecord(frontmatter) &&
(typeof frontmatter.type === "string" || Array.isArray(frontmatter.oneOf));
const capabilities = value.capabilities;
const capabilitiesOk =
Array.isArray(capabilities) && capabilities.every((c) => typeof c === "string");
@@ -30,23 +32,12 @@ function isRoleDefinition(value: unknown): boolean {
);
}
function isConditionDefinition(value: unknown): boolean {
function isTarget(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
return typeof value.description === "string" && typeof value.expression === "string";
}
function isTransition(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
const condition = value.condition;
return (
typeof value.role === "string" &&
typeof value.prompt === "string" &&
value.prompt.trim() !== "" &&
(condition === null || condition === undefined || typeof condition === "string")
typeof value.role === "string" && typeof value.prompt === "string" && value.prompt.trim() !== ""
);
}
@@ -62,7 +53,7 @@ function isGraph(value: unknown): boolean {
return false;
}
return Object.values(value).every(
(transitions) => Array.isArray(transitions) && transitions.every((t) => isTransition(t)),
(statusMap) => isRecord(statusMap) && Object.values(statusMap).every((t) => isTarget(t)),
);
}
@@ -101,11 +92,7 @@ export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null {
if (typeof raw.name !== "string" || typeof raw.description !== "string") {
return null;
}
if (
!isStringRecord(raw.roles, isRoleDefinition) ||
!isStringRecord(raw.conditions, isConditionDefinition) ||
!isGraph(raw.graph)
) {
if (!isStringRecord(raw.roles, isRoleDefinition) || !isGraph(raw.graph)) {
return null;
}
return raw as WorkflowPayload;
+1 -5
View File
@@ -5,9 +5,5 @@
"outDir": "dist"
},
"include": ["src"],
"references": [
{ "path": "../workflow-protocol" },
{ "path": "../workflow-moderator" },
{ "path": "../workflow-agent-kit" }
]
"references": [{ "path": "../workflow-protocol" }, { "path": "../workflow-util-agent" }]
}
@@ -0,0 +1,9 @@
# @uncaged/workflow-agent-builtin
## 0.5.1
### Patch Changes
- Updated dependencies
- @uncaged/workflow-util@0.5.1
- @uncaged/workflow-util-agent@0.5.1
+141
View File
@@ -0,0 +1,141 @@
# @uncaged/workflow-agent-builtin
`uwf-builtin` agent — built-in LLM agent with file read/write and shell tools.
## Overview
Layer 3 agent implementation. Runs an OpenAI-compatible chat completion loop with built-in tools (`read_file`, `write_file`, `run_command`). Uses the configured provider/model from `config.yaml`. Produces frontmatter markdown output and stores turn-by-turn session detail in CAS.
Useful when you want a self-contained agent without an external CLI like Hermes or Claude Code.
**Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-util-agent`, `@uncaged/workflow-util`
## Installation
Included as the `uwf-builtin` binary when you install `@uncaged/workflow-agent-builtin`:
```bash
bun add -g @uncaged/workflow-agent-builtin
```
## CLI Usage
Invoked by `uwf thread step`:
```bash
uwf-builtin <thread-id> <role>
```
Configure as default agent:
```bash
uwf setup --agent builtin
```
Override per step:
```bash
uwf thread step <thread-id> --agent uwf-builtin
```
Environment variables set by the engine:
| Variable | Purpose |
|----------|---------|
| `UWF_EDGE_PROMPT` | Moderator edge instruction for this step |
## API
All exports come from `src/index.ts`.
### Agent factory
```typescript
function createBuiltinAgent(): () => Promise<void>
function buildBuiltinMessages(ctx: AgentContext): ChatMessage[]
```
### LLM loop
```typescript
const BUILTIN_MAX_TURNS = 30;
const BUILTIN_CONTINUE_MAX_TURNS = 5;
function runBuiltinLoop(/* options: RunBuiltinLoopOptions */): Promise<RunBuiltinLoopResult>
function chatCompletionWithTools(
provider: ResolvedLlmProvider,
messages: ChatMessage[],
tools: OpenAiToolDefinition[],
): Promise<LlmAssistantResponse>
```
`RunBuiltinLoopOptions` and `RunBuiltinLoopResult` are internal to `loop.ts` and not re-exported from `index.ts`.
### Tools
```typescript
function getBuiltinTools(): readonly BuiltinTool[]
function executeBuiltinTool(
name: string,
args: Record<string, unknown>,
ctx: ToolContext,
): Promise<string>
```
### Session and detail
```typescript
function initSessionDir(storageRoot: string): Promise<void>
function appendSessionTurn(storageRoot: string, sessionId: string, turn: BuiltinTurnPayload): Promise<void>
function readSessionTurns(storageRoot: string, sessionId: string): Promise<BuiltinTurnPayload[]>
function removeSession(storageRoot: string, sessionId: string): Promise<void>
function registerBuiltinSchemas(store: Store): Promise<BuiltinSchemaHashes>
function storeBuiltinDetail(store: Store, payload: BuiltinDetailPayload): Promise<string>
```
### Types
```typescript
type ChatMessage = /* system | user | assistant | tool */;
type LlmAssistantResponse = { content: string | null; toolCalls: LlmToolCall[] | null };
type LlmToolCall = { id: string; name: string; arguments: string };
type BuiltinTool = { name: string; description: string; parameters: Record<string, unknown> };
type ToolContext = { cwd: string; storageRoot: string };
type BuiltinDetailPayload = { /* session turns, model, timestamps */ };
type BuiltinLoopTurn = { /* single loop iteration record */ };
type BuiltinToolCallRecord = { /* tool call audit */ };
type BuiltinToolResultRecord = { /* tool result audit */ };
type BuiltinTurnPayload = { /* persisted turn */ };
```
## Internal Structure
```
src/
├── index.ts
├── cli.ts Binary entrypoint
├── agent.ts createBuiltinAgent
├── loop.ts Multi-turn LLM + tool loop
├── prompt.ts buildBuiltinMessages
├── session.ts Session directory persistence
├── detail.ts CAS detail node storage
├── schemas.ts Builtin CAS schemas
├── types.ts Detail and turn payload types
├── llm/
│ ├── index.ts
│ ├── llm.ts chatCompletionWithTools
│ └── types.ts ChatMessage, LlmToolCall, etc.
└── tools/
├── index.ts getBuiltinTools, executeBuiltinTool
├── read-file.ts
├── write-file.ts
├── run-command.ts
├── path.ts
└── types.ts
```
## Configuration
Requires a configured OpenAI-compatible provider and model in `~/.uncaged/workflow/config.yaml` (via `uwf setup`). API keys are loaded from `~/.uncaged/workflow/.env`.
Tools run with the current working directory as `ToolContext.cwd` (typically the directory where `uwf thread step` was invoked).
@@ -0,0 +1,256 @@
import { beforeEach, describe, expect, mock, test } from "bun:test";
const mockChatCompletionWithTools = mock(async () => ({
content: "---\nstatus: done\n---",
toolCalls: [],
}));
const mockAppendSessionTurn = mock(async () => {});
const mockExecuteBuiltinTool = mock(async () => "tool-result");
mock.module("../src/llm/index.js", () => ({
chatCompletionWithTools: mockChatCompletionWithTools,
}));
mock.module("../src/session.js", () => ({
appendSessionTurn: mockAppendSessionTurn,
}));
mock.module("../src/tools/index.js", () => ({
builtinToolsToOpenAi: () => [],
executeBuiltinTool: mockExecuteBuiltinTool,
getBuiltinTools: () => [],
}));
import {
executeTurnTools,
extractFinalText,
runBuiltinLoop,
shouldInjectDeadlineWarning,
shouldNudge,
shouldProcessToolCalls,
} from "../src/loop.js";
const fakeProvider = {} as any;
const fakeToolCtx = {} as any;
function makeOptions(overrides: Partial<Parameters<typeof runBuiltinLoop>[0]> = {}) {
return {
provider: fakeProvider,
messages: [{ role: "system" as const, content: "sys" }],
toolCtx: fakeToolCtx,
maxTurns: 5,
storageRoot: "/tmp",
sessionId: "sess",
noTools: false,
...overrides,
};
}
beforeEach(() => {
mockChatCompletionWithTools.mockReset();
mockAppendSessionTurn.mockReset();
mockExecuteBuiltinTool.mockReset();
});
describe("shouldNudge", () => {
test("2.1 returns true when all conditions met", () => {
expect(shouldNudge({ noTools: false, text: "some text", turn: 0, maxTurns: 5 })).toBe(true);
});
test("2.2 returns false when noTools=true", () => {
expect(shouldNudge({ noTools: true, text: "some text", turn: 0, maxTurns: 5 })).toBe(false);
});
test("2.3 returns false when text starts with ---", () => {
expect(shouldNudge({ noTools: false, text: "---\nstatus: done", turn: 0, maxTurns: 5 })).toBe(
false,
);
});
test("2.4 returns false on last turn", () => {
expect(shouldNudge({ noTools: false, text: "some text", turn: 4, maxTurns: 5 })).toBe(false);
});
test("2.5 returns true on second-to-last turn", () => {
expect(shouldNudge({ noTools: false, text: "some text", turn: 3, maxTurns: 5 })).toBe(true);
});
test("2.6 leading whitespace before --- suppresses nudge", () => {
expect(shouldNudge({ noTools: false, text: " ---\nstatus: done", turn: 0, maxTurns: 5 })).toBe(
false,
);
});
});
describe("executeTurnTools", () => {
test("4.1 executes each tool call and pushes tool result messages", async () => {
mockExecuteBuiltinTool.mockResolvedValue("result");
const messages: any[] = [];
const calls = [
{ id: "c1", name: "tool_a", arguments: "{}" },
{ id: "c2", name: "tool_b", arguments: "{}" },
];
const count = await executeTurnTools(calls, fakeToolCtx, messages, "/tmp", "sess");
expect(messages.length).toBe(2);
expect(messages[0].role).toBe("tool");
expect(messages[1].role).toBe("tool");
expect(count).toBe(2);
});
test("4.2 tool result content matches executeBuiltinTool return value", async () => {
mockExecuteBuiltinTool.mockResolvedValue("result-A");
const messages: any[] = [];
await executeTurnTools(
[{ id: "c1", name: "read_file", arguments: "{}" }],
fakeToolCtx,
messages,
"/tmp",
"sess",
);
expect(messages[0].content).toBe("result-A");
});
});
describe("runBuiltinLoop integration", () => {
test("3.1 single text-only response returns finalText immediately", async () => {
mockChatCompletionWithTools.mockResolvedValue({
content: "---\nstatus: done\n---",
toolCalls: [],
});
const result = await runBuiltinLoop(makeOptions());
expect(result.finalText).toBe("---\nstatus: done\n---");
expect(result.turnCount).toBe(1);
});
test("3.2 noTools=true suppresses tool calls", async () => {
mockChatCompletionWithTools.mockResolvedValue({
content: "ok",
toolCalls: [{ id: "c1", name: "read_file", arguments: "{}" }],
});
const result = await runBuiltinLoop(makeOptions({ noTools: true }));
expect(result.finalText).toBe("ok");
expect(result.turnCount).toBe(1);
});
test("3.3 tool call followed by text response", async () => {
mockChatCompletionWithTools
.mockResolvedValueOnce({
content: null,
toolCalls: [{ id: "c1", name: "read_file", arguments: "{}" }],
})
.mockResolvedValueOnce({ content: "---\nstatus: done\n---", toolCalls: [] });
mockExecuteBuiltinTool.mockResolvedValue("file contents");
const result = await runBuiltinLoop(makeOptions());
expect(result.finalText).toBe("---\nstatus: done\n---");
expect(result.turnCount).toBe(3);
});
test("3.4 nudge cycle inserts nudge message", async () => {
mockChatCompletionWithTools
.mockResolvedValueOnce({ content: "I am thinking", toolCalls: [] })
.mockResolvedValueOnce({ content: "---\nstatus: done\n---", toolCalls: [] });
const result = await runBuiltinLoop(makeOptions());
expect(result.finalText).toBe("---\nstatus: done\n---");
const nudgeMsg = result.messages.find(
(m) =>
m.role === "user" && typeof m.content === "string" && m.content.includes("frontmatter"),
);
expect(nudgeMsg).toBeDefined();
});
test("3.5 maxTurns exhaustion falls back to last assistant content", async () => {
mockChatCompletionWithTools.mockResolvedValue({ content: "still thinking", toolCalls: [] });
const result = await runBuiltinLoop(makeOptions({ maxTurns: 3 }));
expect(result.finalText).toBe("still thinking");
});
test("3.6 original messages array is not mutated", async () => {
mockChatCompletionWithTools.mockResolvedValue({
content: "---\nstatus: done\n---",
toolCalls: [],
});
const original = [{ role: "system" as const, content: "sys" }];
await runBuiltinLoop(makeOptions({ messages: original }));
expect(original.length).toBe(1);
});
});
describe("shouldInjectDeadlineWarning", () => {
test("5.1 returns true when turn count reaches warning threshold and not yet warned", () => {
expect(shouldInjectDeadlineWarning(7, 10, false, false)).toBe(true);
});
test("5.2 returns false when already warned", () => {
expect(shouldInjectDeadlineWarning(7, 10, true, false)).toBe(false);
});
test("5.3 returns false when noTools is true", () => {
expect(shouldInjectDeadlineWarning(7, 10, false, true)).toBe(false);
});
test("5.4 returns false when turns remaining > DEADLINE_WARNING_TURNS", () => {
expect(shouldInjectDeadlineWarning(5, 10, false, false)).toBe(false);
});
test("5.5 returns true when exactly at warning threshold", () => {
expect(shouldInjectDeadlineWarning(7, 10, false, false)).toBe(true);
});
test("5.6 returns false when turns remaining is 0", () => {
expect(shouldInjectDeadlineWarning(10, 10, false, false)).toBe(false);
});
});
describe("shouldProcessToolCalls", () => {
test("6.1 returns true when toolCalls present and noTools=false", () => {
expect(shouldProcessToolCalls([{ id: "x", name: "read", arguments: "{}" }], false)).toBe(true);
});
test("6.2 returns false when toolCalls is null", () => {
expect(shouldProcessToolCalls(null, false)).toBe(false);
});
test("6.3 returns false when toolCalls is empty array", () => {
expect(shouldProcessToolCalls([], false)).toBe(false);
});
test("6.4 returns false when noTools=true", () => {
expect(shouldProcessToolCalls([{ id: "x", name: "read", arguments: "{}" }], true)).toBe(false);
});
test("6.5 returns true when multiple tool calls present", () => {
expect(
shouldProcessToolCalls(
[
{ id: "x1", name: "read", arguments: "{}" },
{ id: "x2", name: "write", arguments: "{}" },
],
false,
),
).toBe(true);
});
});
describe("extractFinalText", () => {
test("7.1 returns last assistant message content", () => {
const messages = [
{ role: "system" as const, content: "sys", tool_calls: null },
{ role: "assistant" as const, content: "first", tool_calls: null },
{ role: "assistant" as const, content: "last", tool_calls: null },
];
expect(extractFinalText(messages)).toBe("last");
});
test("7.2 returns empty string when no assistant messages", () => {
expect(extractFinalText([{ role: "system" as const, content: "sys", tool_calls: null }])).toBe(
"",
);
});
test("7.3 skips assistant messages with null content", () => {
const messages = [
{ role: "assistant" as const, content: "first", tool_calls: null },
{
role: "assistant" as const,
content: null,
tool_calls: [{ id: "x", name: "t", arguments: "{}" }],
},
{ role: "assistant" as const, content: "second", tool_calls: null },
];
expect(extractFinalText(messages)).toBe("second");
});
test("7.4 skips assistant messages with empty content", () => {
const messages = [
{ role: "assistant" as const, content: "first", tool_calls: null },
{ role: "assistant" as const, content: "", tool_calls: null },
{ role: "user" as const, content: "nudge", tool_calls: null },
];
expect(extractFinalText(messages)).toBe("first");
});
test("7.5 handles empty messages array", () => {
expect(extractFinalText([])).toBe("");
});
test("7.6 handles messages with only user and system roles", () => {
const messages = [
{ role: "system" as const, content: "sys", tool_calls: null },
{ role: "user" as const, content: "query", tool_calls: null },
];
expect(extractFinalText(messages)).toBe("");
});
});
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import type { AgentContext } from "@uncaged/workflow-agent-kit";
import type { AgentContext } from "@uncaged/workflow-util-agent";
import { buildBuiltinMessages } from "../src/prompt.js";
+16 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-agent-builtin",
"version": "0.5.0",
"version": "0.5.1",
"files": [
"src",
"dist",
@@ -18,11 +18,12 @@
}
},
"scripts": {
"test": "bun test"
"test": "bun test",
"test:ci": "bun test"
},
"dependencies": {
"@uncaged/json-cas": "^0.4.0",
"@uncaged/workflow-agent-kit": "workspace:^",
"@uncaged/json-cas": "^0.5.3",
"@uncaged/workflow-util-agent": "workspace:^",
"@uncaged/workflow-util": "workspace:^"
},
"devDependencies": {
@@ -30,5 +31,15 @@
},
"publishConfig": {
"access": "public"
}
},
"repository": {
"type": "git",
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
"directory": "packages/workflow-agent-builtin"
},
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
"bugs": {
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
},
"license": "MIT"
}
+2 -2
View File
@@ -1,4 +1,5 @@
import type { Store } from "@uncaged/json-cas";
import { createLogger, generateUlid } from "@uncaged/workflow-util";
import {
type AgentContext,
type AgentRunResult,
@@ -6,8 +7,7 @@ import {
loadWorkflowConfig,
resolveModel,
resolveStorageRoot,
} from "@uncaged/workflow-agent-kit";
import { createLogger, generateUlid } from "@uncaged/workflow-util";
} from "@uncaged/workflow-util-agent";
import { storeBuiltinDetail } from "./detail.js";
import type { ChatMessage } from "./llm/index.js";
@@ -1,4 +1,4 @@
import type { ResolvedLlmProvider } from "@uncaged/workflow-agent-kit";
import type { ResolvedLlmProvider } from "@uncaged/workflow-util-agent";
import type {
ChatMessage,
+212 -66
View File
@@ -1,7 +1,12 @@
import type { ResolvedLlmProvider } from "@uncaged/workflow-agent-kit";
import { createLogger } from "@uncaged/workflow-util";
import type { ResolvedLlmProvider } from "@uncaged/workflow-util-agent";
import { type ChatMessage, chatCompletionWithTools, type LlmToolCall } from "./llm/index.js";
import {
type ChatMessage,
chatCompletionWithTools,
type LlmToolCall,
type OpenAiToolDefinition,
} from "./llm/index.js";
import { appendSessionTurn } from "./session.js";
import {
builtinToolsToOpenAi,
@@ -48,7 +53,7 @@ async function appendTurn(
await appendSessionTurn(storageRoot, sessionId, payload);
}
async function executeTurnTools(
export async function executeTurnTools(
calls: Array<{ id: string; name: string; arguments: string }>,
toolCtx: ToolContext,
messages: ChatMessage[],
@@ -70,6 +75,194 @@ async function executeTurnTools(
return turnCount;
}
export type ShouldNudgeOptions = {
noTools: boolean;
text: string;
turn: number;
maxTurns: number;
};
const MAX_NUDGES = 3;
const DEADLINE_WARNING_TURNS = 3;
export function shouldInjectDeadlineWarning(
turn: number,
maxTurns: number,
alreadyWarned: boolean,
noTools: boolean,
): boolean {
const turnsRemaining = maxTurns - turn;
return (
!noTools && !alreadyWarned && turnsRemaining > 0 && turnsRemaining <= DEADLINE_WARNING_TURNS
);
}
export function shouldProcessToolCalls(toolCalls: LlmToolCall[] | null, noTools: boolean): boolean {
return !noTools && toolCalls !== null && toolCalls.length > 0;
}
export function extractFinalText(messages: ChatMessage[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (
msg !== undefined &&
msg.role === "assistant" &&
msg.content !== null &&
msg.content.trim() !== ""
) {
return msg.content;
}
}
return "";
}
function injectDeadlineWarning(messages: ChatMessage[], turnsRemaining: number): void {
log("4NRXW6KT", `${turnsRemaining} turns remaining, injecting deadline warning`);
messages.push({
role: "user",
content:
`⚠️ You have ${turnsRemaining} turns remaining. ` +
"Wrap up your work and output the YAML frontmatter starting with `---`. " +
"If you cannot finish in time, output frontmatter with `status: failed` and describe what remains.",
});
}
type HandleTextOnlyTurnResult = {
shouldBreak: boolean;
finalText: string;
turnCount: number;
nudgeCount: number;
turnAdjustment: number;
};
async function handleTextOnlyTurn(
text: string,
messages: ChatMessage[],
storageRoot: string,
sessionId: string,
noTools: boolean,
turn: number,
maxTurns: number,
currentNudgeCount: number,
): Promise<HandleTextOnlyTurnResult> {
await appendTurn(storageRoot, sessionId, {
role: "assistant",
content: text,
toolCalls: null,
reasoning: null,
});
const turnCount = 1;
let nudgeCount = currentNudgeCount;
let turnAdjustment = 0;
if (shouldNudge({ noTools, text, turn, maxTurns })) {
nudgeCount += 1;
log("7FXQM2KN", `text-only turn without frontmatter, nudge ${nudgeCount}/${MAX_NUDGES}`);
const nudge =
"You stopped calling tools but your response does not start with the required `---` YAML frontmatter. " +
"Either continue using tools to complete your work, or output your final response starting with `---`.";
messages.push({ role: "user", content: nudge });
// Nudge doesn't consume turn budget (up to MAX_NUDGES)
if (nudgeCount <= MAX_NUDGES) {
turnAdjustment = -1;
}
return { shouldBreak: false, finalText: "", turnCount, nudgeCount, turnAdjustment };
}
return { shouldBreak: true, finalText: text, turnCount, nudgeCount, turnAdjustment };
}
async function handleToolCallTurn(
content: string,
toolCalls: LlmToolCall[],
messages: ChatMessage[],
storageRoot: string,
sessionId: string,
toolCtx: ToolContext,
): Promise<number> {
await appendTurn(storageRoot, sessionId, {
role: "assistant",
content,
toolCalls: mapToolCallsForPayload(toolCalls),
reasoning: null,
});
let turnCount = 1;
// Execute tools
turnCount += await executeTurnTools(toolCalls, toolCtx, messages, storageRoot, sessionId);
return turnCount;
}
export function shouldNudge({ noTools, text, turn, maxTurns }: ShouldNudgeOptions): boolean {
return !noTools && !text.trimStart().startsWith("---") && turn < maxTurns - 1;
}
type ProcessLoopIterationResult = {
shouldBreak: boolean;
finalText: string;
turnCount: number;
nudgeCount: number;
turnAdjustment: number;
};
async function processLoopIteration(
options: RunBuiltinLoopOptions,
messages: ChatMessage[],
openAiTools: OpenAiToolDefinition[],
turn: number,
nudgeCount: number,
): Promise<ProcessLoopIterationResult> {
const response = await chatCompletionWithTools(
options.provider,
messages,
openAiTools.length > 0 ? openAiTools : null,
);
// When noTools is set, ignore any tool_calls the LLM might still return
const effectiveToolCalls = options.noTools ? null : (response.toolCalls ?? null);
const assistantMessage: ChatMessage = {
role: "assistant",
content: response.content,
tool_calls: effectiveToolCalls,
};
messages.push(assistantMessage);
if (!shouldProcessToolCalls(effectiveToolCalls, options.noTools)) {
const text = response.content ?? "";
const result = await handleTextOnlyTurn(
text,
messages,
options.storageRoot,
options.sessionId,
options.noTools,
turn,
options.maxTurns,
nudgeCount,
);
return result;
}
// At this point, effectiveToolCalls is guaranteed to be non-null and non-empty
const turnCount = await handleToolCallTurn(
response.content ?? "",
effectiveToolCalls as LlmToolCall[],
messages,
options.storageRoot,
options.sessionId,
options.toolCtx,
);
return {
shouldBreak: false,
finalText: "",
turnCount,
nudgeCount,
turnAdjustment: 0,
};
}
/** Agent run loop: LLM ↔ tools until no tool_calls or maxTurns. */
export async function runBuiltinLoop(
options: RunBuiltinLoopOptions,
@@ -78,79 +271,32 @@ export async function runBuiltinLoop(
const openAiTools = options.noTools ? [] : builtinToolsToOpenAi(getBuiltinTools());
let finalText = "";
let turnCount = 0;
let nudgeCount = 0;
let deadlineWarned = false;
for (let turn = 0; turn < options.maxTurns; turn++) {
log("8K2M4N7P", `builtin loop turn ${turn + 1}/${options.maxTurns}`);
const response = await chatCompletionWithTools(
options.provider,
messages,
openAiTools.length > 0 ? openAiTools : null,
);
const assistantMessage: ChatMessage = {
role: "assistant",
content: response.content,
tool_calls: response.toolCalls,
};
messages.push(assistantMessage);
// Warn agent when approaching turn limit
if (shouldInjectDeadlineWarning(turn, options.maxTurns, deadlineWarned, options.noTools)) {
deadlineWarned = true;
const turnsRemaining = options.maxTurns - turn;
injectDeadlineWarning(messages, turnsRemaining);
}
if (response.toolCalls === null || response.toolCalls.length === 0) {
const text = response.content ?? "";
await appendTurn(options.storageRoot, options.sessionId, {
role: "assistant",
content: text,
toolCalls: null,
reasoning: null,
});
turnCount += 1;
const result = await processLoopIteration(options, messages, openAiTools, turn, nudgeCount);
turnCount += result.turnCount;
nudgeCount = result.nudgeCount;
turn += result.turnAdjustment;
// If tools are available but LLM stopped calling them without producing
// frontmatter, nudge it to continue working or output frontmatter.
if (!options.noTools && !text.trimStart().startsWith("---") && turn < options.maxTurns - 1) {
log("7FXQM2KN", "text-only turn without frontmatter, nudging LLM to continue");
const nudge =
"You stopped calling tools but your response does not start with the required `---` YAML frontmatter. " +
"Either continue using tools to complete your work, or output your final response starting with `---`.";
messages.push({ role: "user", content: nudge });
continue;
}
finalText = text;
if (result.shouldBreak) {
finalText = result.finalText;
break;
}
// Assistant turn with tool calls
await appendTurn(options.storageRoot, options.sessionId, {
role: "assistant",
content: response.content ?? "",
toolCalls: mapToolCallsForPayload(response.toolCalls),
reasoning: null,
});
turnCount += 1;
// Execute tools
turnCount += await executeTurnTools(
response.toolCalls,
options.toolCtx,
messages,
options.storageRoot,
options.sessionId,
);
}
if (finalText === "" && messages.length > 0) {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (
msg !== undefined &&
msg.role === "assistant" &&
msg.content !== null &&
msg.content.trim() !== ""
) {
finalText = msg.content;
break;
}
}
if (finalText === "") {
finalText = extractFinalText(messages);
}
return { finalText, messages, turnCount };
@@ -1,4 +1,4 @@
import { type AgentContext, buildRolePrompt } from "@uncaged/workflow-agent-kit";
import { type AgentContext, buildRolePrompt } from "@uncaged/workflow-util-agent";
import type { ChatMessage } from "./llm/index.js";
@@ -70,6 +70,7 @@ export function buildBuiltinMessages(ctx: AgentContext): ChatMessage[] {
"Your task is described in the user message below — do NOT use uwf or workflow CLI commands to discover your task. " +
"When you are done, output your final response with the YAML frontmatter block as specified above. " +
"Do NOT output the frontmatter until you have completed all necessary work. " +
"If you are running low on turns and cannot finish, output the frontmatter with `status: failed` and explain what remains in the body. " +
"CRITICAL: Your final output MUST start with the `---` fence on the very first line — " +
"no preamble text, no explanation before it. The parser requires `---` at position 0.",
);
@@ -5,5 +5,5 @@
"outDir": "dist"
},
"include": ["src"],
"references": [{ "path": "../workflow-agent-kit" }, { "path": "../workflow-util" }]
"references": [{ "path": "../workflow-util-agent" }, { "path": "../workflow-util" }]
}
@@ -0,0 +1,9 @@
# @uncaged/workflow-agent-claude-code
## 0.5.1
### Patch Changes
- Updated dependencies
- @uncaged/workflow-util@0.5.1
- @uncaged/workflow-util-agent@0.5.1
@@ -0,0 +1,91 @@
# @uncaged/workflow-agent-claude-code
`uwf-claude-code` agent — spawns the Claude Code CLI and captures session detail.
## Overview
Layer 3 agent implementation. Spawns the `claude` CLI with a composed system prompt (role definition, task, prior steps, edge prompt). Parses stream or JSON stdout, caches session IDs for multi-turn continuation, and stores raw output plus structured detail in CAS.
**Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-util-agent`
## Installation
Included as the `uwf-claude-code` binary when you install `@uncaged/workflow-agent-claude-code`:
```bash
bun add -g @uncaged/workflow-agent-claude-code
```
Requires the `claude` CLI on `PATH`.
## CLI Usage
Invoked by `uwf thread step`:
```bash
uwf-claude-code <thread-id> <role>
```
Configure or override the agent:
```bash
uwf setup --agent claude-code
uwf thread step <thread-id> --agent uwf-claude-code
```
Environment variables set by the engine:
| Variable | Purpose |
|----------|---------|
| `UWF_EDGE_PROMPT` | Moderator edge instruction for this step |
## API
All exports come from `src/index.ts`.
### Agent factory
```typescript
function createClaudeCodeAgent(): () => Promise<void>
function buildClaudeCodePrompt(ctx: AgentContext): string
```
### Session detail
```typescript
function parseClaudeCodeStreamOutput(stdout: string): ClaudeCodeParsedResult | null
function parseClaudeCodeJsonOutput(stdout: string): ClaudeCodeParsedResult | null
function storeClaudeCodeDetail(
store: Store,
parsed: ClaudeCodeParsedResult,
sessionId: string,
): Promise<string>
function storeClaudeCodeRawOutput(store: Store, rawOutput: string): Promise<string>
```
## Usage (library)
```typescript
import { createClaudeCodeAgent, buildClaudeCodePrompt } from "@uncaged/workflow-agent-claude-code";
const main = createClaudeCodeAgent();
void main();
```
## Internal Structure
```
src/
├── index.ts
├── cli.ts Binary entrypoint
├── claude-code.ts createClaudeCodeAgent, buildClaudeCodePrompt, spawn logic
├── session-detail.ts Parse stdout, store CAS detail nodes
├── schemas.ts Claude Code detail CAS schemas
└── types.ts ClaudeCodeParsedResult, message shapes
```
## Configuration
Uses session caching from `@uncaged/workflow-util-agent` (`getCachedSessionId` / `setCachedSessionId`). No separate config file — relies on the Claude Code CLI's own authentication.
Maximum turns per invocation: 90 (constant in `claude-code.ts`).
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import type { AgentContext } from "@uncaged/workflow-agent-kit";
import type { ThreadId } from "@uncaged/workflow-protocol";
import type { AgentContext } from "@uncaged/workflow-util-agent";
import { buildClaudeCodePrompt } from "../src/claude-code.js";
function makeCtx(overrides: Partial<AgentContext> = {}): AgentContext {
@@ -39,7 +39,7 @@ describe("buildClaudeCodePrompt", () => {
expect(result).toContain("## Task\nFix the bug");
});
test("includes previous steps as history summary", () => {
test("includes previous steps with content on first visit", () => {
const ctx = makeCtx({
steps: [
{
@@ -48,18 +48,50 @@ describe("buildClaudeCodePrompt", () => {
agent: "hermes",
detail: "detail-1",
edgePrompt: "Create a plan.",
content: "Here is my detailed plan for doing X.",
},
],
});
const result = buildClaudeCodePrompt(ctx);
expect(result).toContain("## Previous Steps");
expect(result).toContain("## What Happened Since Your Last Turn");
expect(result).toContain("Step 1: planner");
expect(result).toContain("do X");
// First visit should include step content
expect(result).toContain("Here is my detailed plan for doing X.");
});
test("re-entry shows steps since last visit without content", () => {
const ctx = makeCtx({
isFirstVisit: false,
steps: [
{
role: "developer",
output: '{"status":"done"}',
agent: "claude-code",
detail: "detail-1",
edgePrompt: "Implement.",
content: "I implemented everything.",
},
{
role: "reviewer",
output: '{"approved":false}',
agent: "claude-code",
detail: "detail-2",
edgePrompt: "Review.",
content: "Rejected: complexity too high, refactor cmdStepRead.",
},
],
});
const result = buildClaudeCodePrompt(ctx);
expect(result).toContain("## What Happened Since Your Last Turn");
expect(result).toContain("reviewer");
expect(result).toContain("approved");
});
test("omits history section when steps array is empty", () => {
const result = buildClaudeCodePrompt(makeCtx({ steps: [] }));
expect(result).not.toContain("## Previous Steps");
expect(result).not.toContain("## What Happened Since Your Last Turn");
expect(result).toContain("## Current Instruction");
});
test("works without outputFormatInstruction", () => {
@@ -154,6 +154,99 @@ describe("parseClaudeCodeStreamOutput", () => {
});
});
describe("parseClaudeCodeStreamOutput — helper extraction", () => {
test("processSystemLine sets model from system message", () => {
const lines = [
JSON.stringify({ type: "system", model: "claude-opus-4" }),
JSON.stringify({
type: "result",
subtype: "success",
result: "ok",
session_id: "s1",
num_turns: 0,
total_cost_usd: 0,
duration_ms: 0,
stop_reason: "end_turn",
}),
];
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
expect(parsed).not.toBeNull();
expect(parsed!.model).toBe("claude-opus-4");
});
test("processAssistantLine skips empty content", () => {
const lines = [
JSON.stringify({ type: "assistant", message: { role: "assistant", content: [] } }),
JSON.stringify({
type: "result",
subtype: "success",
result: "ok",
session_id: "s1",
num_turns: 0,
total_cost_usd: 0,
duration_ms: 0,
stop_reason: "end_turn",
}),
];
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
expect(parsed).not.toBeNull();
expect(parsed!.turns).toHaveLength(0);
});
test("processUserLine skips when no tool_result items", () => {
const lines = [
JSON.stringify({
type: "user",
message: { role: "user", content: [{ type: "text", text: "hi" }] },
}),
JSON.stringify({
type: "result",
subtype: "success",
result: "ok",
session_id: "s1",
num_turns: 0,
total_cost_usd: 0,
duration_ms: 0,
stop_reason: "end_turn",
}),
];
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
expect(parsed).not.toBeNull();
expect(parsed!.turns).toHaveLength(0);
});
test("turn indices are sequential across mixed assistant and user lines", () => {
const lines = [
JSON.stringify({
type: "assistant",
message: { role: "assistant", content: [{ type: "text", text: "A" }] },
}),
JSON.stringify({
type: "user",
message: { role: "user", content: [{ type: "tool_result", content: "R" }] },
}),
JSON.stringify({
type: "assistant",
message: { role: "assistant", content: [{ type: "text", text: "B" }] },
}),
JSON.stringify({
type: "result",
subtype: "success",
result: "ok",
session_id: "s1",
num_turns: 3,
total_cost_usd: 0,
duration_ms: 0,
stop_reason: "end_turn",
}),
];
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
expect(parsed).not.toBeNull();
expect(parsed!.turns).toHaveLength(3);
expect(parsed!.turns.map((t) => t.index)).toEqual([0, 1, 2]);
});
});
describe("storeClaudeCodeDetail", () => {
const baseParsed: ClaudeCodeParsedResult = {
type: "result",
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-agent-claude-code",
"version": "0.1.0",
"version": "0.5.1",
"files": [
"src",
"dist",
@@ -18,16 +18,28 @@
}
},
"scripts": {
"test": "bun test"
"test": "bun test",
"test:ci": "bun test"
},
"dependencies": {
"@uncaged/json-cas": "^0.4.0",
"@uncaged/workflow-agent-kit": "workspace:^"
"@uncaged/json-cas": "^0.5.3",
"@uncaged/workflow-util-agent": "workspace:^",
"@uncaged/workflow-util": "workspace:^"
},
"devDependencies": {
"typescript": "^5.8.3"
},
"publishConfig": {
"access": "public"
}
},
"repository": {
"type": "git",
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
"directory": "packages/workflow-agent-claude-code"
},
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
"bugs": {
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
},
"license": "MIT"
}

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