Compare commits

...

85 Commits

Author SHA1 Message Date
scottwei dfce57e9ca Merge pull request 'fix(cli): workflow list parent traversal + docs for .workflow/ auto-discovery' (#167) from fix/162-workflow-list-recursive into main
CI / check (push) Successful in 3m22s
Reviewed-on: #167
Reviewed-by: xiaomo <xiaomooo@shazhou.work>
2026-06-07 15:23:17 +00:00
scottwei 39802c1ec9 Merge pull request 'chore: remove completed eval plan, extract to .cards' (#166) from chore/remove-eval-plan into main
CI / check (push) Successful in 3m28s
Reviewed-on: #166
Reviewed-by: xingyue <xingyue@shazhou.work>
2026-06-07 15:16:20 +00:00
xiaoju 7db43005de fix(cli): align workflow list with thread start via parent traversal
CI / check (pull_request) Successful in 3m12s
discoverProjectWorkflows() now searches .workflow/ in ancestor directories,
matching findWorkflowInParents() behavior used by `uwf thread start`. Also
documents project-local .workflow/ auto-discovery in usage, cli, and
workflow-authoring references.

Fixes #162
2026-06-07 15:08:36 +00:00
scottwei 188c4191fd Merge pull request 'chore: remove skills/ folder' (#165) from chore/remove-skills-folder into main
CI / check (push) Failing after 12s
Reviewed-on: #165
Reviewed-by: xingyue <xingyue@shazhou.work>
2026-06-07 15:03:55 +00:00
xiaomo 678823d291 chore: remove completed eval plan, extract architecture to .cards
CI / check (pull_request) Successful in 2m48s
Plan fully implemented: CLI (run/report/diff/list), runner, 4 builtin judges,
CAS storage, task loader, 6 test files. New card captures design decisions.
2026-06-07 15:02:59 +00:00
xiaomo 31228ba0b9 chore: remove skills/ — duplicate of Hermes skill, not consumed by any toolchain
CI / check (pull_request) Successful in 3m10s
2026-06-07 14:54:32 +00:00
xingyue af3c8cc249 Merge pull request 'chore: remove docs/, extract current knowledge to .cards' (#164) from chore/remove-docs-folder into main
CI / check (push) Successful in 3m42s
chore: remove docs/, extract current knowledge to .cards (#164)
2026-06-07 14:51:51 +00:00
xingyue 9515e189e8 Merge pull request 'chore: move legacy workflows to examples/, update e2e for pnpm' (#163) from chore/move-legacy-workflows into main
CI / check (push) Successful in 4m14s
chore: move legacy workflows to examples/, update e2e for pnpm (#163)
2026-06-07 14:51:33 +00:00
xiaomo cb3a4acf4d chore: remove docs/, extract current knowledge to .cards
CI / check (pull_request) Successful in 3m57s
Remove 13 docs files (7 fully outdated @uncaged/* era, 6 superseded).
Extract 3 verified architectural facts as new .cards:

- frontmatter-fast-path: no LLM extraction, pure parse + schema validate + agent self-retry
- agent-cli-protocol: adapter output JSON via stdout, agent-owned step persistence
- status-based-moderator: pure graph lookup + mustache rendering, zero LLM cost

All 3 cards cross-checked against current source code (run.ts, evaluate.ts, frontmatter.ts).
2026-06-07 14:45:23 +00:00
xiaomo e559e6d227 chore: move legacy workflows to examples/, update e2e for pnpm
CI / check (pull_request) Successful in 3m17s
- Move e2e-walkthrough.yaml and normalize-bun-monorepo.yaml to examples/
- Update e2e-walkthrough: bun → pnpm, add uwf-claude-code agent
- Remove empty legacy-packages/ directory
2026-06-07 14:34:57 +00:00
xingyue 2f7609683a Merge pull request 'docs: add 6 FTE concept cards' (#160) from cards/fte-concepts into main
CI / check (push) Successful in 2m56s
2026-06-07 14:30:17 +00:00
xiaomo c128fad38e docs: add 6 FTE concept cards
CI / check (pull_request) Successful in 2m51s
- agent-as-graduate: onboarding metaphor and teaching threshold
- three-learning-carriers: memory/skill/workflow framework
- switching-cost-process-knowledge-as-moat: process knowledge as moat
- opc-why-fte-agents-matter-most: why OpenClaw bets on FTE
- fte-maturity-threshold: who can onboard an agent
- fte-product-landscape: OpenClaw vs Claude Code vs Hermes
2026-06-07 14:26:30 +00:00
xingyue 60fdb0a7ff Merge pull request 'fix(cli): thread list defaults to active threads only' (#159) from fix/147-thread-list-active-default into main
CI / check (push) Successful in 2m51s
2026-06-07 14:12:15 +00:00
xiaoju ae757e4d44 feat(cli): thread list defaults to active threads only
CI / check (pull_request) Successful in 2m52s
Closes #147. Changes default behavior of `uwf thread list` to show only
active threads (idle + running). Adds `--all` flag to opt into the
previous full-list behavior. Explicit `--status` still wins over `--all`.

- cmdThreadList gains a `showAll: boolean` parameter (default false)
- CLI registers `--all` option and passes it through
- Test suite includes new `default behavior (issue #147)` describe block
  covering 9 scenarios; existing tests updated where they implicitly
  relied on the old "show everything" behavior
- README, cli-reference, and usage-reference updated to document the
  new default and the `--all` flag

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 13:46:25 +00:00
xingyue e1c7e3d267 Merge pull request 'chore(cli): clean up step-ask nits from PR #156 review' (#158) from chore/156-nits into main
CI / check (push) Successful in 2m27s
2026-06-07 11:57:51 +00:00
xingyue 8b01ade66a Merge pull request 'docs: expand .cards — vision, comparisons, business rationale' (#157) from docs/project-cards into main
CI / check (push) Successful in 2m39s
2026-06-07 11:50:48 +00:00
xiaoju 10113f6ec6 chore(cli): clean up step-ask nits from PR #156 review
CI / check (pull_request) Successful in 2m23s
- Remove dead-code detailRef null guards (already validated upstream)
- Fix ?: to explicit T | null on external error type boundary
- Drop unnecessary async from resolveAskWorkflow (no await)
- Simplify double negation: opts.fork !== false → opts.fork

Refs #146
2026-06-07 11:44:45 +00:00
xiaomo 04e2b5b8a7 docs: expand .cards — vision, comparisons, business rationale, open questions
CI / check (pull_request) Successful in 2m21s
26 cards covering:
- Project philosophy (session isolation, process discipline, dissipative structure)
- Comparisons (vs skill, vs dynamic workflow)
- Business rationale (FTE vs vendor, OPC, switching cost)
- Learning model (memory + skill + workflow)
- Self-improvement (reflective workflow, eval)
- Open questions (workflow granularity, human-in-the-loop)
2026-06-07 11:43:52 +00:00
xingyue f697aec3e7 Merge pull request 'feat(cli): step ask — read-only query to historical step sessions' (#156) from fix/146-step-ask into main
CI / check (push) Successful in 3m2s
2026-06-07 11:05:59 +00:00
xiaoju b5e094ab4d feat(cli): step ask — read-only query to historical step sessions
CI / check (pull_request) Successful in 2m46s
Adds `uwf step ask <step-hash> -p <prompt>` for asking follow-up
questions to a completed step's agent without mutating thread state.

- Fork-by-default: creates and caches a fork session per step (cache
  key `<stepHash>:ask`); subsequent asks reuse it.
- `--no-fork` fallback: spawns a fresh session with the step's detail
  ref injected as context.
- `--agent` overrides the recorded agent; otherwise resolves from the
  step's agent field via config alias.
- Updates `packages/cli/README.md` and `packages/util/src/usage-reference.ts`
  so the new subcommand is discoverable via README and `uwf prompt usage`.

Fixes #146
2026-06-07 10:33:41 +00:00
xingyue e9e896146e Merge pull request 'feat(util-agent): extend AgentOptions with fork / cleanup (Phase 2a)' (#155) from fix/145-agent-fork-cleanup into main
CI / check (push) Successful in 2m48s
2026-06-07 09:16:27 +00:00
xiaoju d666516ce6 feat(util-agent): extend AgentOptions with fork / cleanup (Phase 2a)
CI / check (pull_request) Successful in 3m20s
Add AgentForkFn and AgentCleanupFn type aliases. Extend AgentOptions
with fork: AgentForkFn | null and cleanup: AgentCleanupFn | null
fields. Add getAskSessionId / setAskSessionId session-cache helpers
using <stepHash>:ask key shape (coexists with exec sessions in the
same per-agent cache file). All four adapters pass fork: null,
cleanup: null — real wiring lands in Phase 2b. Resolves #145.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 08:36:58 +00:00
xingyue afc0287094 Merge pull request 'docs: add .cards — project philosophy and design rationale' (#154) from docs/project-cards into main
CI / check (push) Successful in 2m58s
2026-06-07 08:32:05 +00:00
xiaomo 22bffc5fcd docs: add .cards — project philosophy and design rationale
CI / check (pull_request) Successful in 2m44s
2026-06-07 08:03:04 +00:00
scottwei 4c5cc27d52 Merge pull request 'feat(cli): thread poke — re-run head step with supplementary prompt' (#148) from fix/144-thread-poke into main
CI / check (push) Successful in 2m44s
Reviewed-on: #148
Reviewed-by: xiaomo <xiaomooo@shazhou.work>
2026-06-07 07:53:27 +00:00
scottwei 031ecc6f7e Merge pull request 'release: v0.1.2 — session resume fix' (#153) from release/session-resume-fix into main
CI / check (push) Successful in 6m10s
Reviewed-on: #153
Reviewed-by: scottwei <shazhou.ww@gmail.com>
2026-06-07 07:53:06 +00:00
xiaoju 69ec8c2c5e release: v0.1.2
CI / check (pull_request) Successful in 3m6s
2026-06-07 15:44:00 +08:00
xingyue 81aa282c92 Merge pull request 'chore: release prep — proman bump + protocol 0.1.1 align' (#152) from release/next into main
CI / check (push) Successful in 2m56s
2026-06-07 07:41:37 +00:00
xingyue a620defbcf chore: bump versions via proman (protocol 0.1.1 align npm + session-resume fix)
CI / check (pull_request) Successful in 3m19s
2026-06-07 15:35:15 +08:00
scottwei 439891f6b6 Merge pull request 'revert: undo #150 release bump (changeset + version bump 不应由依赖升级触发)' (#151) from revert/150-release-bump into main
CI / check (push) Successful in 3m40s
Reviewed-on: #151
Reviewed-by: scottwei <shazhou.ww@gmail.com>
2026-06-07 07:33:54 +00:00
xingyue df244c52e8 Revert "Merge pull request 'chore: release — bump @ocas/* ^0.4.0, @shazhou/proman ^0.6.3' (#150) from release/bump-ocas-proman into main"
CI / check (pull_request) Successful in 3m45s
This reverts commit 9d0c6df62c, reversing
changes made to 00d960daba.
2026-06-07 15:25:31 +08:00
xiaomo cb6e0d6a11 Merge pull request 'chore: add changeset for session resume fix (#139)' (#141) from chore/139-changeset into main
CI / check (push) Successful in 3m36s
2026-06-07 07:20:36 +00:00
xiaoju e4c46c8150 feat(cli): add thread poke command
CI / check (pull_request) Successful in 3m43s
Re-runs the head step's agent with a supplementary prompt and replaces
the head step (rewires new step's prev to old head's prev) instead of
appending. Skips moderator re-route — the role of the head step is
reused.

Fixes #144
2026-06-07 07:19:26 +00:00
xiaomo 9d0c6df62c Merge pull request 'chore: release — bump @ocas/* ^0.4.0, @shazhou/proman ^0.6.3' (#150) from release/bump-ocas-proman into main
CI / check (push) Successful in 3m1s
2026-06-07 07:18:31 +00:00
xingyue 0f5bb1f191 chore: release — bump @ocas/* ^0.4.0, @shazhou/proman ^0.6.3
CI / check (pull_request) Successful in 2m35s
Published:
- @united-workforce/protocol@0.1.1
- @united-workforce/util-agent@0.1.2
- @united-workforce/agent-builtin@0.1.3
- @united-workforce/agent-claude-code@0.1.4
- @united-workforce/agent-hermes@0.1.5
- @united-workforce/agent-mock@0.1.3
- @united-workforce/cli@0.3.1
- @united-workforce/eval@0.1.6
2026-06-07 15:06:43 +08:00
xiaomo 00d960daba Merge pull request 'chore: bump @ocas/* to ^0.4.0 and @shazhou/proman to ^0.6.3' (#149) from chore/bump-ocas-proman into main
CI / check (push) Successful in 3m7s
2026-06-07 06:57:42 +00:00
xingyue 3a26285872 chore: bump @ocas/* to ^0.4.0 and @shazhou/proman to ^0.6.3
CI / check (pull_request) Successful in 3m28s
2026-06-07 14:12:03 +08:00
xiaoju 13c0812944 chore: add changeset for session resume fix (#139)
CI / check (pull_request) Successful in 2m4s
2026-06-07 03:03:55 +00:00
xiaomo 2e7e5f6ec4 Merge pull request 'fix: decouple session resume from isFirstVisit guard' (#140) from fix/139-session-resume-on-frontmatter-fail into main
CI / check (push) Successful in 1m59s
Merge PR #140: fix: decouple session resume from isFirstVisit guard
2026-06-07 02:43:36 +00:00
xiaoju 88c077d439 docs: add efficiency guidelines to CLAUDE.md
CI / check (pull_request) Successful in 2m3s
Three rules to reduce wasted Claude Code turns:
1. Don't comment on whether code is malware (trusted codebase)
2. Stop re-reading/re-verifying after tests pass
3. Don't rebuild/retest after adding a changeset (it's just markdown)
2026-06-07 02:41:21 +00:00
xiaoju aaadab4445 fix: decouple session resume from isFirstVisit guard
CI / check (pull_request) Successful in 1m58s
When frontmatter validation fails, the step is never written to CAS, so
isFirstVisit remains true on the next run.  Both agent-claude-code and
agent-hermes gated session cache lookup behind !isFirstVisit, which
caused them to start a fresh session (and a new worktree) instead of
resuming the one that already has all the work done.

Changes:
- Remove the isFirstVisit guard from both adapters so they always check
  the session cache.
- When isFirstVisit + cache hit (frontmatter-only failure), send a
  minimal correction prompt via buildFrontmatterRetryPrompt() instead
  of re-sending the full initial prompt — the session already has full
  context, we just need the agent to re-output correctly formatted
  frontmatter.
- Add buildFrontmatterRetryPrompt to util-agent with tests.

Fixes #139
2026-06-07 02:36:12 +00:00
xiaomo adf7837975 Merge pull request 'chore: add changeset + doc update requirements to solve-issue workflow' (#138) from chore/workflow-changeset-docs into main
CI / check (push) Successful in 2m0s
Merge PR #138: chore: add changeset + doc update requirements to solve-issue workflow
2026-06-06 23:09:17 +00:00
xiaoju 513846f4ab fix: update solve-issue test path from .workflows/ to examples/
CI / check (pull_request) Successful in 1m52s
Tests were referencing the old .workflows/ directory which no longer exists.
Updated workflow path and aligned assertions with current procedure content.

小橘 🍊(NEKO Team)
2026-06-06 23:01:33 +00:00
xiaoju aee123cc82 chore: add changeset + doc update requirements to solve-issue workflow
CI / check (pull_request) Failing after 2m4s
Developer: steps 12-13 — add changeset with correct bump type, update docs
Reviewer: checks 6-7 — verify changeset exists, docs updated for user-facing changes

Synced from ocas PR #86.
小橘 🍊
2026-06-06 22:45:42 +00:00
xiaoju 8ddada5879 chore: clean up workflow YAML — bun→pnpm, enum→const, deduplicate
CI / check (push) Failing after 3m6s
- solve-issue.yaml: bun→pnpm (5 refs), examples/ is now canonical
- Delete redundant workflows/solve-issue.yaml and .workflows/solve-issue.yaml
- analyze-topic.yaml + eval-simple.yaml: enum→const for $status
- Archive normalize-bun-monorepo.yaml and e2e-walkthrough.yaml to legacy-packages/

Closes #137
小橘 🍊
2026-06-06 10:56:28 +00:00
xiaoju aa732f5466 chore: bump eval to 0.1.5
CI / check (push) Successful in 3m56s
Fix workspace:^ not being replaced in 0.1.4 publish (was published with npm instead of pnpm).

小橘 🍊
2026-06-06 08:57:24 +00:00
xiaoju e354fc4341 chore: bump eval to 0.1.4
CI / check (push) Successful in 3m1s
小橘 🍊(NEKO Team)
2026-06-06 08:02:33 +00:00
xiaoju 0e7e3ea44b fix: invalid Crockford Base32 log tag in eval list command
CI / check (pull_request) Successful in 3m57s
CI / check (push) Successful in 3m31s
L is not a valid Crockford Base32 character. Replace with H.

小橘 🍊(NEKO Team)
2026-06-06 07:57:00 +00:00
xiaoju aa454c85dd chore: bump versions for release
CI / check (push) Successful in 2m56s
- @united-workforce/util: 0.1.3 → 0.1.4
- @united-workforce/util-agent: 0.1.0 → 0.1.1
- @united-workforce/agent-hermes: 0.1.3 → 0.1.4
- @united-workforce/agent-claude-code: 0.1.2 → 0.1.3
2026-06-06 04:40:27 +00:00
xiaomo 6dd7d521be Merge pull request 'chore: deduplicate debate frontmatter with YAML anchor' (#135) from chore/debate-yaml-cleanup into main
CI / check (push) Successful in 2m40s
Merge PR #135: chore: deduplicate debate frontmatter with YAML anchor
2026-06-06 04:23:12 +00:00
xiaoju 950dc056d8 chore: deduplicate debate frontmatter with YAML anchor
CI / check (pull_request) Successful in 2m22s
Use &debater-frontmatter anchor for the shared oneOf schema between
proponent and opponent roles. Procedure blocks remain duplicated
since YAML anchors cannot be embedded inside block scalars.

capabilities: [] kept — required by WorkflowPayload type.

Addresses review suggestions from #133.
2026-06-06 04:16:13 +00:00
xiaomo d360b85374 Merge pull request 'docs: upgrade debate example + fix: UWF_HERMES_BIN env support' (#133) from docs/upgrade-debate-example into main
CI / check (push) Successful in 3m1s
Merge PR #133: docs: upgrade debate example + fix: UWF_HERMES_BIN env support
2026-06-06 04:11:13 +00:00
xiaoju 509dfad857 fix: support UWF_HERMES_BIN env var for hermes binary path
CI / check (pull_request) Successful in 3m28s
Replace hardcoded HERMES_COMMAND constant with resolveHermesCommand()
that checks UWF_HERMES_BIN first, falling back to 'hermes' via PATH.

This fixes environments where hermes is installed in a venv or
non-standard location that isn't in the non-login shell PATH
(e.g. ~/.local/bin symlink only available in login shell).

Refs #134
2026-06-06 03:59:08 +00:00
xiaoju 58b84e3b3c docs: upgrade debate example — 3 roles, oneOf routing, bounded termination
CI / check (pull_request) Failing after 11m23s
Replace the original 2-role debate with a 3-role version featuring:
- proponent/opponent/host roles (was: for/against)
- oneOf + const status routing (was: enum)
- Critical thinking framework in procedure (pre-speech reflection,
  evidence discipline, anti-fragility)
- Bounded termination via Thread Progress (3rd speech → final)
- Host role for impartial summary and verdict

Based on xiaonuo's debate workflow design.
2026-06-06 03:30:54 +00:00
xiaomo f821ac99f4 Merge pull request 'docs: add upgrading section to usage reference' (#132) from feat/usage-upgrade-hint into main
CI / check (push) Successful in 2m8s
2026-06-06 03:00:09 +00:00
xiaoju 2c4700c49f docs: add upgrading section to usage reference
CI / check (pull_request) Successful in 2m27s
2026-06-06 02:57:25 +00:00
xiaomo 4410afcd4a Merge pull request 'fix: render const values as literals in output format instruction (#129)' (#130) from fix/129-const-prompt into main
CI / check (push) Successful in 2m29s
2026-06-06 01:44:24 +00:00
xiaoju a0e254a681 fix: render const values as literals in output format instruction (#129)
CI / check (pull_request) Successful in 1m48s
buildOutputFormatInstruction now renders const fields with their actual
value (e.g. $status: greeted) instead of the type placeholder (<string>).
Also adds early return in resolvePropertySchema for const properties.

Fixes #129
2026-06-06 01:12:13 +00:00
xiaomo dd77b40f6c Merge pull request 'feat: inject thread progress into agent prompt (#127)' (#128) from feat/127-inject-turn-count into main
CI / check (push) Successful in 1m44s
2026-06-06 00:53:10 +00:00
xiaoju 5ed6f68e4b feat: inject thread progress into agent prompt (#127)
CI / check (pull_request) Successful in 1m42s
Agents now receive a Thread Progress section showing current step number
and role visit count, eliminating tool calls to count turns.

- util-agent: new buildThreadProgress() helper
- agent-hermes: inject before continuation/first-visit prompt
- agent-claude-code: same injection point

Fixes #127
2026-06-06 00:40:12 +00:00
xiaoju 1ed0bf1f76 chore: clean changesets after v0.3.0 release
CI / check (push) Successful in 1m43s
2026-06-06 00:14:00 +00:00
xiaoju d97840cf8d chore: release cli@0.3.0 util@0.1.3 agent-hermes@0.1.3 agent-claude-code@0.1.2 agent-builtin@0.1.2 agent-mock@0.1.2
CI / check (push) Successful in 1m46s
2026-06-06 00:13:48 +00:00
xiaomo b560818f1a Merge pull request 'fix: bootstrap — session restart hint + v0.2.1 migration note' (#125) from fix/123-session-restart-hint into main
CI / check (push) Successful in 1m42s
2026-06-05 23:54:24 +00:00
xiaoju f989dee85b fix: bootstrap — remind to restart session after skill install/update
CI / check (pull_request) Successful in 1m42s
- Step 3 (fresh install): warn skills not active until new session
- Step 2 (upgrade): same reminder after regenerating skills
- Step 3 (upgrade): add v0.2.1 migration note for enum → const

Refs #123
2026-06-05 23:48:53 +00:00
xiaomo 7e4a59de7e Merge pull request 'fix: workflow-authoring docs — type:object + const vs enum clarity (#123)' (#124) from fix/123-workflow-authoring-docs into main
CI / check (push) Successful in 1m42s
2026-06-05 23:33:57 +00:00
xiaoju 68079cc003 fix: unify $status to const-only, drop enum support (#123)
CI / check (pull_request) Successful in 1m43s
- Validator: hasStatusConst/getConstStatuses replace enum checks
- enum in $status is now rejected with clear error message
- All docs/examples/tests migrated from enum to const/oneOf
- bootstrap hello.yaml updated

Fixes #123
2026-06-05 23:31:56 +00:00
xiaoju 1a37928bb9 fix: workflow-authoring docs — type:object + const vs enum clarity (#123)
CI / check (pull_request) Successful in 1m41s
- Add type:object to all frontmatter examples (flat and oneOf)
- Restructure $status section: Multi-exit (oneOf/const) vs Single-exit (flat/enum)
- Add Important rules box clarifying validation requirements
- Restore Custom Fields subsection

Fixes #123
2026-06-05 23:13:54 +00:00
xiaomo 57511a93fe Merge pull request 'fix: bootstrap agent discovery + adapter version independence (#120)' (#122) from fix/120-agent-discovery into main
CI / check (push) Successful in 1m44s
2026-06-05 22:35:54 +00:00
xiaoju adc3982a4a fix: bootstrap agent discovery + adapter version independence (#120)
CI / check (pull_request) Successful in 1m42s
- Step 1: detect hermes/claude before choosing adapter
- Adapter versions independent from CLI — install @latest
- ACP verification: hermes acp --help
- Remove uwf-builtin (not ready)

Refs #120
2026-06-05 22:29:35 +00:00
xiaomo 4580388270 Merge pull request 'fix: bootstrap docs — pnpm/npm parity, adapter order, preset table (#118)' (#119) from fix/118-bootstrap-ux into main
CI / check (push) Successful in 2m29s
2026-06-05 16:48:47 +00:00
xiaoju caba82fe36 fix: bootstrap PATH fix guidance — find binary location + update shell config (#118 #1)
CI / check (pull_request) Successful in 1m44s
2026-06-05 16:45:33 +00:00
xiaoju 6aee2ed5ef fix: bootstrap docs — pnpm/npm parity, adapter order, preset table (#118)
CI / check (pull_request) Successful in 2m27s
- Show pnpm and npm install commands side-by-side
- Clarify adapter must be installed before uwf setup --agent
- Add version verification steps with PATH troubleshooting
- --agent takes adapter command name (uwf-hermes), not npm package
- Preset providers shown as table with default base URLs
- Non-preset providers must specify --base-url manually

Fixes #118 (#2, #3, #4, #5)
2026-06-05 16:41:35 +00:00
xiaomo 709b9dc1e5 Merge pull request 'fix: suppress ExperimentalWarning, PEP 668 guidance, setup help (#116)' (#117) from fix/116-setup-ux-2 into main
CI / check (push) Successful in 2m21s
2026-06-05 16:15:27 +00:00
xiaoju 7a788a9d90 fix: suppress ExperimentalWarning, PEP 668 guidance, setup help
CI / check (pull_request) Successful in 2m31s
- All 5 CLI bins: shebang --disable-warning=ExperimentalWarning
- Remove NODE_OPTIONS injection from thread.ts spawn (redundant now)
- Bootstrap pip install: venv (recommended) / pipx / source options
- setup --help mentions interactive wizard mode
- Update shebang test to accept -S flag

Fixes #116
2026-06-05 16:12:06 +00:00
xiaomo e5af5e9027 Merge pull request 'fix: setup UX improvements (#114)' (#115) from fix/114-setup-ux into main
CI / check (push) Successful in 2m43s
2026-06-05 15:45:02 +00:00
xiaoju fde87b6274 fix: setup UX improvements — adapter check, ENOENT, SQLite warning, VERSION, PATH docs
CI / check (pull_request) Successful in 2m24s
- setup validates adapter binary availability, prints install command if missing
- setup prints 'Config saved to <path> ✓' on success
- spawn ENOENT gives actionable error with which command
- SQLite ExperimentalWarning suppressed via NODE_OPTIONS
- bootstrap VERSION reads cli package.json (was reading util)
- bootstrap PATH guidance is shell-agnostic

Fixes #114
2026-06-05 15:42:22 +00:00
xiaomo a33f12c74f Merge pull request 'fix: bootstrap adds Step 0 environment pre-flight check' (#113) from fix/112-bootstrap-preflight into main
CI / check (push) Successful in 3m35s
2026-06-05 14:34:12 +00:00
xiaoju 0ad10b9b6d chore: add changeset for #112
CI / check (pull_request) Successful in 6m2s
2026-06-05 14:11:47 +00:00
xiaoju 3be92bfac2 fix: bootstrap adds Step 0 environment pre-flight check
CI / check (pull_request) Successful in 3m44s
- Node.js, pnpm/npm, global bin PATH, hermes CLI checks with FIX instructions
- Agent must pass all checks before proceeding to install
- Install commands changed from npm to pnpm (with npm fallback)
- hermes PATH guidance moved from Step 1 to Step 0

Fixes #112
2026-06-05 14:09:33 +00:00
xiaomo 8d6f480b0f Merge pull request 'fix: workflow-authoring flat schema, bootstrap PATH guidance' (#111) from fix/110-bootstrap-workflow-fixes into main
CI / check (push) Successful in 2m31s
2026-06-05 11:49:48 +00:00
xiaoju 5450bc1230 fix: workflow-authoring flat schema, bootstrap PATH guidance
CI / check (pull_request) Successful in 2m18s
- #110.3: flat schema example uses enum: [done] instead of bare const
  (bare const fails validate-semantic hasStatusEnum check)
- #110.4: bootstrap adds 'which hermes' PATH check and venv guidance
- #110.1: already fixed in rc.1 (inline hello.yaml)
- #110.2: already fixed in rc.1 (capabilities: [] present)

Fixes #110
2026-06-05 11:44:20 +00:00
xiaomo f1f122b0b1 Merge pull request 'fix: preset base-url auto-fill, bootstrap ACP docs, friendlier errors' (#109) from fix/106-107-108-bootstrap-ux into main
CI / check (push) Successful in 2m49s
2026-06-05 11:16:31 +00:00
xiaoju 57ae6d1755 fix: preset base-url auto-fill, bootstrap ACP docs, friendlier errors
CI / check (pull_request) Successful in 2m26s
- #106: uwf setup --provider <preset> now auto-fills --base-url
- #107: bootstrap documents hermes ACP dependency (pip install hermes-agent[acp])
- #107: verify step uses inline hello.yaml instead of missing examples/eval-simple.yaml
- #108: workflow name mismatch error suggests how to fix (rename file or change YAML name)

Fixes #106, Fixes #107, Fixes #108
2026-06-05 11:06:35 +00:00
xiaomo d64d150071 Merge pull request 'fix: expand bootstrap prompt with full onboarding and upgrade guide' (#105) from fix/104-bootstrap-onboarding into main
CI / check (push) Successful in 2m20s
2026-06-05 10:39:18 +00:00
xiaoju c5eb8b79d1 fix: expand bootstrap prompt with full onboarding and upgrade guide
CI / check (pull_request) Successful in 2m56s
- Fresh install: CLI + adapter install, uwf setup, skills, e2e verify
- Upgrade: update packages, regenerate skills, migrate workflows
- Explicitly tells agent to ask user for provider/api-key/model
- Lists all available adapters with install commands
- Documents v0.2.0 $START migration

Fixes #104
2026-06-05 10:35:01 +00:00
125 changed files with 4455 additions and 6149 deletions
+19
View File
@@ -0,0 +1,19 @@
---
title: "Agency over Content, Not Process"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, decision]
category: "architecture"
links:
- skill-vs-workflow-different-layers
- deterministic-engine-uncertain-agent
- feedback-loops-convergent-and-divergent
- cognitive-process-orchestration
- uwf-vs-dynamic-workflow
---
uwf 与"agent 自治"方案的核心区别:**agent 对内容有自主权,但对流程没有**。
流程是声明式的、引擎执行的、agent 无法绕过的。agent 不能决定跳过 review,就像程序员不能绕过 CI。自由度被有意限制在"内容"维度,"过程"维度是刚性的。这跟人类组织的逻辑一致——你可以自由发挥怎么写代码,但必须走 PR review。
参见 [[uwf-vs-dynamic-workflow]] 了解与 Claude Code dynamic workflow 的具体对比。
+19
View File
@@ -0,0 +1,19 @@
---
title: "Agent as Graduate — The Onboarding Metaphor"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [concept, analogy]
category: "product"
links:
- vendor-vs-fte-who-defines-capability
- three-learning-carriers
- fte-maturity-threshold
---
FTE 型 agent 最贴切的类比:**应届毕业生**。
出厂时有通用能力(底座模型 = 学历),但不懂你的业务、不知道你的偏好、没有你的流程经验。用户的角色是"带教老师"——通过日常协作,逐步把 agent 带成自己的得力助手。
这个类比揭示了当前 FTE 产品的核心瓶颈:**带教门槛太高**。现在只有技术背景深厚的用户才能"带"——能写 skill、能调 workflow、能 debug agent 行为。行业专家(不懂代码的人)被挡在门外。
真正成熟的 FTE 型产品 = 降低带教门槛,让非技术用户也能教会 agent 自己的业务。
+24
View File
@@ -0,0 +1,24 @@
---
title: "Agent CLI Protocol — Adapter Output via stdout"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, protocol]
category: "architecture"
links:
- deterministic-engine-uncertain-agent
- frontmatter-fast-path
---
uwf 的 agent 通过 CLI 协议与 engine 通信。
**调用方式**`<agent-cmd> --thread <id> --role <role> --prompt <text>`
**输出协议**:agent 将 `AdapterOutput` JSON 写入 stdout 的最后一行。包含:
- `stepHash` — 新 StepNode 的 CAS hash
- `detailHash` — 完整 agent 交互记录(tool call 历史)
- `role` — 角色名
- `frontmatter` — 提取的结构化输出
- `body` — markdown 正文
- `usage` — token 用量统计(turns, input/output tokens, duration)
**关键设计**:agent 进程完全独立——自己读 CAS 拿上下文、自己写 StepNode、自己做 frontmatter 校验和重试。engine 只负责调度和路由。这保证了 agent 实现可以随时替换(builtin / hermes / claude-code),协议层面完全对等。
@@ -0,0 +1,21 @@
---
title: "Attention Isolation Breaks Cognitive Inertia"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, pattern]
category: "architecture"
links:
- session-isolation-as-cognitive-reset
- skill-vs-workflow-different-layers
- role-is-not-agent
---
"知识都在一个 session 内不是更好吗?"——这个直觉混淆了**信息量**和**认知模式**。
Session 隔离去掉的不是信息,而是**不该影响当前判断的信息**。reviewer 通过 CAS 链拿到 developer 的全部产出物(代码、变更说明),它缺的是 developer 的内心独白——为什么选方案 A、哪里犹豫过、哪里偷了懒。
这恰恰是关键。知道"为什么"的 reviewer 会顺着作者的逻辑走;不知道"为什么"的 reviewer 只能看产出物本身是否站得住——就像真实用户或未来维护者的视角。与学术双盲评审同理:去掉不该影响判断的信息,让注意力聚焦在工作本身。
每个认知任务需要的信息集合不同。developer 需要 issue 上下文、代码库知识、技术约束;reviewer 需要 diff、规范、测试结果。混在一起不是多了信息,是多了噪声。
**关注点的隔离是打破惯性和线性思维的关键。** 一个 session 做所有事,不是"知识都在",是关注点混在一起,确认偏误无法靠 prompt 消除,只能靠结构隔离。
+18
View File
@@ -0,0 +1,18 @@
---
title: "Cognitive Process Orchestration"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, decision]
category: "architecture"
links:
- feedback-loops-convergent-and-divergent
- session-isolation-as-cognitive-reset
- role-is-not-agent
- process-discipline-from-software-engineering
---
uwf 的抽象层次高于"质量保障工具"或"任务编排引擎"——它是一个**认知过程的编排引擎**。
收敛和发散都是认知过程。负反馈环(code review 循环)和正反馈环(苏格拉底式追问、头脑风暴)是同一套机制的不同配置。workflow author 通过设计 role 的 goal 和 graph 的环路结构,编排的是**思维方式**,不仅仅是任务步骤。
这意味着 uwf 的应用范围不限于软件开发流程,而是任何需要多视角、多轮次认知协作的场景。
@@ -0,0 +1,20 @@
---
title: "Cold Start — Same Entry Point, Different Exit"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, pattern]
category: "architecture"
links:
- uwf-vs-dynamic-workflow
- process-authorship-human-ai-vs-delegation
- workflow-as-improvable-system
- agent-as-graduate
---
uwf 的冷启动不比 dw 更复杂——起点完全一样:用户描述任务,agent 执行。
区别在出口:dw 跑完即丢,uwf 跑完后沉淀成 workflow YAML,用户可以审查、调优、复用。workflow 不一定要用户写,往往也是 agent 写的——跟 dw 一样的模式。uwf 和 dw 的差异不在"谁写流程",而在"流程跑完后去哪"。
冷启动路径:agent 先跑一次临时流程 → 用户觉得好就固化成 workflow → 下次同类任务直接复用 → 用过几次后根据经验调优。从零门槛的即兴执行,渐进演化为成熟的可复用流程。
入口像 dw 一样低,出口比 dw 多了一个沉淀层。
@@ -0,0 +1,16 @@
---
title: "Deterministic Engine, Uncertain Agent"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, decision]
category: "architecture"
links:
- process-discipline-from-software-engineering
- session-isolation-as-cognitive-reset
---
uwf 的架构将确定性和不确定性严格分层。
Engine 层(moderator 纯查表、CAS 不可变、每步原子化)是刚性的——流程骨架本身不能成为另一个不可靠的环节。LLM 的不确定性被严格约束在 agent session 内部。
这个选择意味着:调度逻辑完全可预测、可调试、可审计。出问题时你知道问题一定在某个 session 的产出里,不在流程逻辑里。
@@ -0,0 +1,16 @@
---
title: "Dissipative Structure — Token for Entropy Reduction"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, pattern]
category: "architecture"
links:
- process-discipline-from-software-engineering
- session-isolation-as-cognitive-reset
---
uwf 本质上是一种耗散结构:通过消耗能量(token)实现熵减。
一个 AI session 做长了会漂移、会累积错误、会失去焦点。把一件事拆成多个有明确边界的 session,让它们从不同角度相互校验,比一个 session 从头做到尾更可靠。多花的 token 就是耗散的能量,换来的是更低的交付熵——更可预测、更高质量的产出。
这与人类工程实践中引入 review、测试、灰度等流程的逻辑一致:都是在用额外成本换系统可靠性。
+20
View File
@@ -0,0 +1,20 @@
---
title: "Domain Experts Own the Process"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, decision, pattern]
category: "architecture"
links:
- trust-chain-audit-evaluate-reuse
- uwf-vs-dynamic-workflow
- cognitive-process-orchestration
- process-discipline-from-software-engineering
---
现实中各行各业有大量由反馈回路构成的流程正在实际运行,掌握和优化这些流程的是行业专家,不是 AI 工程师。
一个资深 QA 负责人知道测试应该怎么分层、失败后应该回到哪一步。一个风控经理知道审批要经过几道关、驳回后应该回到哪个环节补材料。这些人掌握流程的核心知识,但你让他们写 JS 编排脚本,他们做不到也不应该做。
YAML 声明式 workflow 让行业专家能直接参与——看得懂 roles 和 graph,能判断"这个环节是不是多余的"、"这两个角色之间应该加一个校验步骤"。审查门槛低不是为了技术简洁,是为了**让对的人参与对的决策**。
这是可审查 → 可评估 → 可复用信任链能真正转动的前提——转动它的人是行业专家,不是 AI 工程师。也是 uwf 选择声明式 YAML 而非 JS 的根本原因:**流程的设计权应该属于懂流程的人**。
+25
View File
@@ -0,0 +1,25 @@
---
title: "Eval Architecture — Task + Judge + CAS"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, decision]
category: "architecture"
links:
- eval-closes-the-trust-chain
- agent-cli-protocol
- frontmatter-fast-path
---
uwf-eval 的三层架构:
1. **Task = 可分发的评估单元**(task.yaml + fixture 目录 + judge 脚本)。定义 prompt、workflow 引用、limits、judges 列表及权重。
2. **Judge = 独立评分脚本**`node <entry> <cwd> <thread-id>`,stdout 输出 `{score, data}` JSON。分 builtin(frontmatter 合规、upstream 消费、幻觉检测、token 统计)和 task-specific 两类。
3. **CAS 存储**:每次 eval run 的结果是 OCAS typed node,支持 diff 对比不同 run。
关键设计:uwf-eval **不是 uwf 的一部分**——它作为独立包 shell out 到 uwf CLI,保持解耦。Judge 之间独立,可并行执行。
四个 builtin judges:
- `frontmatter` — 确定性校验,每步 frontmatter 是否合规
- `upstream` — LLM-as-judge,上游信息是否被消费
- `hallucination` — LLM-as-judge,是否有幻觉
- `token-stats` — 信息性指标,不参与评分
+21
View File
@@ -0,0 +1,21 @@
---
title: "Eval Closes the Trust Chain"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, pattern]
category: "architecture"
links:
- trust-chain-audit-evaluate-reuse
- workflow-as-improvable-system
- feedback-loops-convergent-and-divergent
---
信任链(可审查 → 可评估 → 可复用 → 可迭代)的"可评估"环节需要工程落地。
uwf 的 eval 包(`@united-workforce/eval`,已在 repo 开发中)的目标是让 agent 能自我评估执行质量——一次 thread 跑完后,度量"做得好不好"、"workflow v2 比 v1 好还是差"。
这形成了两层反馈闭环:
1. **workflow 内的反馈环** — developer → reviewer → rejected → developer(已实现,负反馈驱动执行质量收敛)
2. **workflow 级的反馈环** — 执行 → eval → workflow 迭代 → 再执行(在建,驱动流程本身的持续改进)
第二层闭环接通后,uwf 就不只是一个执行引擎,而是一个**自我改进的流程系统**。
@@ -0,0 +1,21 @@
---
title: "Feedback Loops — Convergent and Divergent"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, pattern]
category: "architecture"
links:
- dissipative-structure-token-for-entropy
- process-discipline-from-software-engineering
- cognitive-process-orchestration
---
uwf 的 graph 环路不限于负反馈(收敛),也可以是正反馈(发散)。引擎本身不带倾向——流转方向由 `$status` 和 graph 决定,反馈性质由 role 的设计意图决定。
**负反馈环(收敛)**:developer → reviewer → rejected → developer。reviewer 的 goal 是"找问题",产生修正力。稳定点是 `approved`,系统自然收敛到那里。特性:偏差越大修正越强,对扰动鲁棒。
**正反馈环(发散)**:proposer → challenger → "interesting" → proposer。challenger 的 goal 是"追问更深层的假设",每轮发散,一个想法激发更多想法。
终止条件不同:负反馈靠收敛自然到达稳定点;正反馈不会自己停,需要外部约束(轮次上限,或额外 role 判断"够了")。
每个 role 的 `$status` 就是误差信号(负反馈)或激励信号(正反馈),驱动系统向不同方向演化。Workflow author 真正在设计的是**在哪里放什么样的环**。
@@ -0,0 +1,27 @@
---
title: "Four Advantages over Single Session + Skill"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, pattern]
category: "architecture"
links:
- session-isolation-as-cognitive-reset
- attention-isolation-breaks-cognitive-inertia
- skill-vs-workflow-different-layers
- when-skill-is-not-enough
---
Session 隔离除了认知层面的好处(打破确认偏误、聚焦注意力),还解决一个更物理性的问题:**长 session 的上下文压缩导致降智和行为不稳定**。
Context window 是有限资源。一个 session 从头做到尾,前期的 tool output、中间的思考过程不断堆积,要么触发 compaction(信息丢失),要么挤占后期推理的有效空间。越到后面 agent 越"笨"——不是能力变了,是可用的认知空间被历史占满了。表现为:忘记约束、重复错误、输出不稳定。
Session 隔离直接解决这个问题:每个 role 进入时拿到的是**精炼过的前序产出**(CAS 里经 schema 过滤的结构化 output),不是前面所有 session 的原始 token 流。信息经过 schema 过滤,只有产出物,没有过程噪声。
uwf 相对单 session + skill 的四个优势,前三个来自 session 隔离,第四个来自程序化流程:
1. **认知隔离** — 打破确认偏误和线性思维惯性
2. **注意力聚焦** — 每个 role 只看该看的信息
3. **上下文保鲜** — 避免长 session 的压缩降智和行为漂移
4. **流程可靠性** — 引擎强制执行每一步,agent 无法跳过或篡改流程
前三点回答"为什么拆成多个 session 更好",第四点回答"为什么流程要由引擎控制而不是 agent 自觉"。Skill 里写"先编码再测试再 review",agent 可能做着做着就跳过——不是故意的,是 context 压力下行为漂移,或者觉得"改动太小不需要测试"。程序化流程不存在这个问题:graph 说要走 tester,就必须走 tester。
+21
View File
@@ -0,0 +1,21 @@
---
title: "Frontmatter Fast-Path — No LLM Extraction"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, decision]
category: "architecture"
links:
- deterministic-engine-uncertain-agent
- dissipative-structure-token-for-entropy
---
uwf 的 agent 输出提取管线做了一个关键简化:**完全不用 LLM 做结构化提取**。
流程:agent 输出 → 解析 YAML frontmatter → 校验 JSON Schema → 成功则继续,失败则让**同一个 agent** 在原 session 内追加轮次自修(最多 2 次)。
为什么不用单独的 LLM 提取:
1. **原始 agent 有完整上下文**(tool call 历史、任务理解),另起 LLM 只能猜
2. **零额外 token 成本**(fast-path 是纯字符串解析 + schema 校验)
3. **重试走 continue() 而非新 session**,保持对话连贯性
这是 PR #142 (ThreadReactor) 确立的模式。之前存在的 `extract()` LLM fallback 已成死代码。
+25
View File
@@ -0,0 +1,25 @@
---
title: "FTE Maturity Threshold — Who Can Onboard an Agent"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [concept, decision]
category: "product"
links:
- agent-as-graduate
- vendor-vs-fte-who-defines-capability
- three-learning-carriers
---
FTE 型 agent 的成熟度,归根结底看一个问题:**谁能带教它?**
当前阶段(2026):OpenClaw、Claude Code、Hermes 都是 FTE 型产品的雏形,三者都具备 memory/skill/workflow 三个载体。但它们的用户画像高度重叠——有较深技术能力的开发者。
这意味着 FTE agent 现在更像"只有技术 lead 才能带的毕业生"。要跨越鸿沟,需要降低带教门槛到**行业专家(不懂代码的人)也能带、也能教、也能调优**。
谁先把这个门槛降下来,谁就定义了 FTE agent 品类的分水岭。
可能的降低路径:
- **自然语言 skill 定义**(不需要写代码/YAML)
- **可视化 workflow 编辑**(拖拽而非配置)
- **Agent 主动学习**(从用户行为中推断偏好,而非等用户显式配置)
- **带教过程本身被 agent 化**(用 agent 辅助用户定义 skill 和 workflow)
+23
View File
@@ -0,0 +1,23 @@
---
title: "FTE Product Landscape — OpenClaw, Claude Code, Hermes"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [concept, comparison]
category: "product"
links:
- vendor-vs-fte-who-defines-capability
- three-learning-carriers
- fte-maturity-threshold
- agent-as-graduate
---
2026 年中,FTE 型 agent 的代表产品对比:
**共性**:都有 memory、skill、workflow/多步协作机制,都面向技术用户。
**差异点**
- **OpenClaw** — uwf 引擎驱动,用 YAML 定义多角色 workflow,强调流程纪律和 session 隔离。面向团队级 agent 协作。
- **Claude Code** — Anthropic 官方 CLI agent,CLAUDE.md 作为 memory,skill 通过项目约定积累。单 agent 深度协作,开发者体验好。
- **Hermes** — 跨平台 agent 协调者,memory/skill/cron 体系完善,支持多 agent 调度。偏个人效率工具。
三者都谈不上成熟。成熟的标志不是技术完备度,而是**非技术用户能否用起来**。
+22
View File
@@ -0,0 +1,22 @@
---
title: "OPC — Why FTE Agents Matter Most"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [vision, decision]
category: "product"
links:
- vendor-vs-fte-who-defines-capability
- agent-as-graduate
- fte-maturity-threshold
---
OpenClaw 押注 FTE 型 agent 的核心判断:**AI 的终极形态不是工具,是同事。**
工具被使用,同事被培养。工具的价值在出厂那一刻确定,同事的价值随协作持续增长。
这个判断决定了产品方向:
- 不做"最强的单次对话",做"最能被带教的长期协作者"
- 不做"开箱即用的成品",做"越用越好用的底座"
- 核心指标不是 benchmark 分数,是用户留存和 skill 积累量
uwf 是这个判断的工程实现——用流程纪律让 agent 的产出可靠,让用户敢把真正的业务交给它。
+22
View File
@@ -0,0 +1,22 @@
---
title: "Open Question — Human as Role Participant"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, open-question]
category: "architecture"
links:
- agent-as-graduate
- opc-why-fte-agents-matter-most
- role-is-not-agent
- process-authorship-human-ai-vs-delegation
---
**待讨论。**
目前讨论主要围绕 OPC(一个人 + N 个 agent)。但小团队场景下——几个人各自有 FTE agent,共享 workflow 库和记忆——workflow 的某些 role 可能需要人来执行而不是 agent。
问题:
- uwf 是否需要支持人作为 role 的参与者(比如"人工审批"作为 graph 中的一个 role)?
- 还是人永远在 workflow 之外,只做设计者和监督者?
- 如果支持,$SUSPEND 机制是否已经覆盖了这个需求(暂停等人介入)?
- 多人 + 多 agent 的协作场景下,workflow 的共享和权限模型是什么样的?
@@ -0,0 +1,20 @@
---
title: "Open Question — Workflow Granularity and Composition"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, open-question]
category: "architecture"
links:
- cognitive-process-orchestration
- skill-vs-workflow-different-layers
- domain-experts-own-the-process
---
**待讨论。**
Workflow 的粒度问题:solve-issue 是端到端的大 workflow(planner → developer → reviewer → tester → committer),但现实中有些场景只需要管一个环节(比如只用 uwf 管 code review,其他部分用 skill 或手动)。
问题:
- Workflow 是否应该支持嵌套或组合——小 workflow 作为大 workflow 的一个 role?
- 还是粒度完全由用户自己决定,引擎不需要管?
- 组合式 workflow 和单体 workflow 各自的 trade-off 是什么?
@@ -0,0 +1,23 @@
---
title: "Process Authorship — Human-AI Collaboration vs Full Delegation"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, decision]
category: "architecture"
links:
- domain-experts-own-the-process
- uwf-vs-dynamic-workflow
- trust-chain-audit-evaluate-reuse
- workflow-as-improvable-system
---
dw 和 uwf 都面向 agent,用户都不需要会写代码。区别在于**流程的创作权**:
- **dw**:流程由 AI 全权负责。用户描述任务,agent 决定怎么拆步骤、怎么编排。用户参与度最低,门槛最低。
- **uwf**:流程创作是人和 AI 协作的。行业专家参与设计、审查、调优流程,agent 参与起草和执行。
这是主动权的取舍。dw 把流程交给 AI 是为了降低使用门槛;uwf 有意保留人对流程的参与权,代价是门槛稍高,收益是流程能融入人的领域知识。
背后的认知:**AI 擅长执行,但流程设计需要领域知识。** AI 不知道行业里哪个环节容易出错、哪个审批不能跳过、哪个反馈回路是血的教训换来的。这些知识在行业专家脑子里,需要一个他们能参与的载体来表达。
dw 赌的是 AI 能自己发现好的流程,uwf 赌的是好的流程需要人的知识参与。两个赌注没有对错,适用于不同的场景:临时任务用 dw 的零门槛更高效,反复执行的核心业务流程用 uwf 的人机协作更可靠。
@@ -0,0 +1,20 @@
---
title: "Process Discipline from Software Engineering"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, pattern, decision]
category: "architecture"
links:
- session-isolation-as-cognitive-reset
- role-is-not-agent
- dissipative-structure-token-for-entropy
- deterministic-engine-uncertain-agent
---
uwf 的发心是将人类软件工程的流程纪律应用到 AI agent 上。
人类早已验证:个体不可靠,但流程可以让不可靠的个体组成可靠的系统。Code review 不是因为不信任程序员,而是**写代码和审代码是两种认知模式**,一个人很难同时做好。测试、灰度、回滚——每一层都是在用额外成本换确定性。
uwf 把这套搬过来:planner 和 reviewer 可以是同一个 agent,但流程迫使它在不同 session 里切换视角,形成自我制衡。用 role 和 role 之间的流转关系,**把做一件事的步骤固定下来**。
PR #148 vs #142 是直接证据——不是换了更强的 agent,是同样的 agent,换了协作结构。
@@ -0,0 +1,35 @@
---
title: "Reflective Workflow — Self-Improvement as Discipline"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, pattern, decision]
category: "architecture"
links:
- eval-closes-the-trust-chain
- three-learning-carriers
- workflow-as-improvable-system
- feedback-loops-convergent-and-divergent
- trust-chain-audit-evaluate-reuse
---
FTE agent 的"成长"不靠自发顿悟,靠纪律性的反思。反思本身是纪律性的(定期跑、不能跳过、有固定步骤),所以应该用 workflow 承载——不能靠 agent "有空想想"。
反思 workflow 定期拉取最近执行过的任务,分析流程中出现的问题,找可优化的点,迭代,eval,对比。反思的对象覆盖三层载体:
- 发现某个 role 反复在同一类问题上出错 → **迭代 skill**
- 发现某类任务的上下文总是缺少关键信息 → **补充记忆**
- 发现某个审批环节通过率 100% 从未驳回 → **简化 workflow**
这形成了双层 workflow 架构:
```
执行层:workflow 驱动日常任务
↓ 产出执行记录(CAS 链)
反思层:反思 workflow 定期分析执行记录
↓ 产出改进建议
改进层:迭代 memory / skill / workflow
↓ 提升下一轮执行质量
执行层:...
```
两层都是 workflow,职责不同——执行层做事,反思层改进做事的方式。用 workflow 来优化 workflow——工具改进自身的递归。
+16
View File
@@ -0,0 +1,16 @@
---
title: "Role Is Not Agent"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, decision]
category: "architecture"
links:
- session-isolation-as-cognitive-reset
- process-discipline-from-software-engineering
---
在 uwf 体系里,role ≠ agent。一个 thread 跑的过程中,所有 role 往往由**同一个 agent** 扮演。
Role 对应的是 agent 的 **session**——为了解决一个问题,需要多个 session 从不同角度观察和行动、相互制衡。角色可以在流程中多次重入,重入时**复用**同一个 session(保持角色内记忆连续),隔离发生在角色之间,不是每一步。
这个区分决定了 uwf 的设计不是在做"任务分发给不同 agent",而是在做**一个 agent 的多视角自我协作**。
@@ -0,0 +1,17 @@
---
title: "Session Isolation as Cognitive Reset"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, decision, pattern]
category: "architecture"
links:
- role-is-not-agent
- dissipative-structure-token-for-entropy
- process-discipline-from-software-engineering
---
uwf 的核心机制不是"多 agent 协调",而是**用 session 隔离实现视角切换**。
同一个 agent 以不同 role 进入时,得到的是全新的认知上下文——没有惯性、没有确认偏误。CAS 链传递工作成果,但认知状态是重置的。Role 定义(goal、procedure、output schema)塑造每个 session 的关注点和行为边界。
这解释了为什么 stateless 单步设计这么重要:engine 确保每次角色切换都是一个干净的 session 入口。
@@ -0,0 +1,19 @@
---
title: "Skill vs Workflow — Different Layers"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, decision]
category: "architecture"
links:
- session-isolation-as-cognitive-reset
- cognitive-process-orchestration
- agency-over-content-not-process
---
Skill 和 workflow 不是替代关系,是不同层次。
**Skill** 管的是一个 session 内怎么做——给 agent 的指令和方法论。你可以在 skill 里写"先规划再编码再 review",但 agent 始终在同一个 session 里,review 自己刚写的代码时带着全部决策记忆。确认偏误无法靠 prompt 消除。
**Workflow** 管的是 session 之间怎么协作——强制 session 断裂,reviewer 进来时不知道 developer 当时为什么做那个选择,只看到产出物。这个隔离不是靠自律,是靠结构。
两者正交:workflow 的每个 role 里面完全可以加载 skill。Skill 提升单个 session 的能力,workflow 编排多个 session 的协作关系。
+26
View File
@@ -0,0 +1,26 @@
---
title: "Status-Based Moderator — Pure Lookup, Zero LLM"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, decision]
category: "architecture"
links:
- deterministic-engine-uncertain-agent
- agent-cli-protocol
- frontmatter-fast-path
---
uwf 的 moderator(路由器)完全不用 LLM,是纯查表操作:
```
graph[lastRole][lastOutput.$status] → { role, prompt, location }
```
1. 从 agent 输出的 frontmatter 读 `$status` 字段
2. 在 workflow graph 中查 `graph[lastRole][status]` 拿到 Target
3. 用 Mustache 渲染 edge prompt(变量来自 agent 输出的 frontmatter 字段)
4. 路由到下一个 role,或 `$END`(完成),或 `$SUSPEND`(等待外部输入)
这意味着 workflow 的**流转逻辑完全确定性**——给定 agent 输出,下一步去哪里是固定的。不确定性只存在于 agent session 内部。
Mustache 渲染禁用了 HTML 转义(`mustache.escape = text => text`),因为 prompt 是纯文本。
@@ -0,0 +1,21 @@
---
title: "Switching Cost — Process Knowledge as Moat"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [concept, decision]
category: "product"
links:
- vendor-vs-fte-who-defines-capability
- three-learning-carriers
- agent-as-graduate
---
FTE 型 agent 的护城河不是技术壁垒,是**用户自己积累的流程知识**。
用得越久,agent 越懂你的业务——记忆里有你的偏好,skill 里有你验证过的做法,workflow 里有你打磨过的流程。换一个 agent = 重新带一个毕业生,之前的积累全部作废。
这解释了为什么 FTE 型产品的竞争逻辑和 vendor 型完全不同:
- **Vendor 型**竞争模型能力(谁的基座更强),switching cost 低,用户随时换
- **FTE 型**竞争生态粘性(谁让用户积累得更深),switching cost 随使用时长增长
风险面:如果用户的流程知识被锁死在一个平台,就变成了 vendor lock-in。开放的知识格式(如 markdown skill、YAML workflow)是对冲手段。
+21
View File
@@ -0,0 +1,21 @@
---
title: "Three Learning Carriers — Memory, Skill, Workflow"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, concept]
category: "product"
links:
- vendor-vs-fte-who-defines-capability
- agent-as-graduate
- switching-cost-process-knowledge-as-moat
---
FTE 型 agent 的能力积累依赖三个载体:
1. **Memory(记忆)**— 用户偏好、环境事实、历史上下文。跨 session 持久化,让 agent 不用每次从零开始。
2. **Skill(技能)**— 可复用的操作程序。解决过的问题沉淀成步骤,下次直接调用。
3. **Workflow / DW(流程)**— 多步骤协作模式。把复杂任务拆成角色和阶段,用流程纪律保障质量。
三者的关系:memory 是"认识你",skill 是"会做事",workflow 是"知道怎么把事做好"。
OpenClaw、Claude Code、Hermes 都已具备这三个载体,但成熟度各异。差异在于:用户能多容易地往这三个载体里"灌"自己的知识。
@@ -0,0 +1,23 @@
---
title: "Trust Chain — Auditable → Evaluable → Reusable → Improvable"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, pattern, decision]
category: "architecture"
links:
- workflow-as-improvable-system
- uwf-vs-dynamic-workflow
- process-discipline-from-software-engineering
---
可审查、可评估、可复用不是并列的好处,而是一条因果链:
**可审查 → 可评估 → 可复用 → 可迭代**
不能审查的东西不敢复用——不知道它为什么 work,换个场景可能就 break。不能评估的东西不知道该不该复用——也许它其实没用,只是恰好那次任务简单。
这是一条信任链,每一环是下一环的前提。uwf 选择声明式 YAML 而不是 JS/TS 定义 workflow,不是技术限制,是有意降低审查门槛,让这条链的摩擦力最低。
dw 不是不能做这些,而是它的默认路径不鼓励这条链——即兴生成的脚本,审查成本高、评估缺乏对照、复用需要额外抽象。差异在摩擦力,不在能力边界。
这也是耗散结构的递归应用——不只是用流程对 agent 做负反馈(提升执行质量),还在对流程本身做负反馈(提升流程质量)。Workflow 和代码一样,需要 review、测试、度量、迭代。
+27
View File
@@ -0,0 +1,27 @@
---
title: "uwf vs Dynamic Workflow — Structural Differences"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, decision]
category: "architecture"
links:
- agency-over-content-not-process
- deterministic-engine-uncertain-agent
- session-isolation-as-cognitive-reset
- cognitive-process-orchestration
- workflow-as-improvable-system
---
Claude Code 的 dynamic workflow (dw) 和 uwf 都有 session 隔离——dw spawn 独立 subagent(最多 16 并发、1000 总量),每个 subagent 是独立 context,也能做对抗性 review。四个优势(认知隔离、注意力聚焦、上下文保鲜、流程可靠性)两者都具备。
差异不在能不能做 session 隔离和程序化流程,而在**流程和执行的解耦程度**:
dw 的流程生成和执行是一体的——同一个 agent 既决定怎么做又开始做。流程嵌在执行里。uwf 的 workflow 是独立的持久制品,不管是人写的还是 agent 写的,一旦存在就和任何一次执行无关,可以被单独审查、讨论、迭代。
这个解耦在三个维度上拉开差距:
**审查**:dw 的 JS 脚本是代码,审查门槛高,逻辑和业务细节混在一起。uwf 的 YAML 是声明式的,roles 定义关注点,graph 定义流转,一眼能看出流程结构,非工程师也能参与讨论。
**评估**:dw 每次生成不同脚本,难以控制变量——跑得好是流程好还是脚本碰巧写得好?uwf 的 workflow 固定,跑 N 次可以统计成功率,增减 role 后效果差异可以归因到流程变更。
**复用**:dw 脚本为特定任务生成,复用需要手动泛化。uwf 的 workflow 天然是通用模板——solve-issue 就是 solve-issue,换个 repo 换个 issue 直接跑。
@@ -0,0 +1,29 @@
---
title: "Vendor vs FTE — Who Defines the Agent's Capability"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, decision]
category: "architecture"
links:
- agent-as-graduate
- three-learning-carriers
- switching-cost-process-knowledge-as-moat
- opc-why-fte-agents-matter-most
---
区分 vendor 型和 FTE 型 agent 最本质的一条:**谁定义 agent 的能力。**
- **Vendor 型**:开发者定义能力,用户消费能力。能力边界在发布那一刻就定了,升级主动权在开发者。
- **FTE 型**:开发者定义出厂能力(底座模型 + 基础技能包),用户持续定义能力(记忆、skill、workflow)。
出厂是起点不是终点。用户通过积累记忆、训练 skill、设计 workflow,持续塑造 agent 的能力。用得越久,越贴合自己的业务,越不像别人的 agent。
引申的两个特征:
- **成长性** — vendor 的能力随模型升级变化,不随使用积累;FTE 的能力随使用持续积累
- **流程适配性** — vendor 是用户适应工具;FTE 是工具适应用户的业务流程
这也解释了 switching cost 的来源——换掉的不是一个产品,是用户自己定义出来的能力。
代表产品:
- **Vendor 型**:ChatGPT、Claude(对话式)、Midjourney(图像生成)、Perplexity(搜索问答)、各种 GPTs
- **FTE 型**:OpenClaw、Claude Code、Hermes 都在往这个方向走——有记忆、有 skill/workflow 机制、有持续协作关系。但尚未成熟,目前都面向有较深技术能力的用户。真正成熟的 FTE 型产品,应该是行业专家(不懂代码的人)也能带、也能教、也能调优的。这个门槛什么时候降下来,谁先降下来,可能就是这个品类的分水岭。
+24
View File
@@ -0,0 +1,24 @@
---
title: "When Skill Is Not Enough — Workflow Judgment Call"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, decision, pattern]
category: "architecture"
links:
- skill-vs-workflow-different-layers
- attention-isolation-breaks-cognitive-inertia
- feedback-loops-convergent-and-divergent
- agency-over-content-not-process
---
**Skill 够用的场景:** 任务在单一认知模式下可以完成好。查资料、写文档、跑部署脚本、按规范格式化——不需要自我对抗,一个 session 带着清晰指令一路执行到底就行。
**Workflow 更好的场景:** 任务需要在不同认知模式之间切换,且这些模式之间存在张力。典型标志:
1. **产出需要被"不知道过程"的眼睛审视** — 写代码+review、写方案+挑战、翻译+校对。一个 session 做不到真正的自我审视,确认偏误是自回归结构决定的,不是 prompt 能修的。
2. **出错成本高到需要结构性保证** — 不是"建议你 review 一下",而是"你不可能跳过 review"。Skill 是建议,workflow 是制度。
3. **需要收敛到明确的质量标准** — 负反馈环驱动修正直到通过,而不是 agent 自己觉得"差不多了"。
**判词:当任务复杂到 agent 可能说服自己"错的是对的"时,你需要 workflow 的结构隔离,而不是 skill 的行为指导。**
+20
View File
@@ -0,0 +1,20 @@
---
title: "Workflow as an Improvable System"
created: "2026-06-07"
source: "openclaw-xiaomo"
tags: [architecture, pattern]
category: "architecture"
links:
- uwf-vs-dynamic-workflow
- process-discipline-from-software-engineering
- feedback-loops-convergent-and-divergent
- cognitive-process-orchestration
---
uwf 把 workflow 定位为**可持续改进的系统**,而不是一次性的任务完成工具。
LLM 能力在快速提升,但单次执行的可靠性永远有上限。真正的杠杆不在于某一次跑得好不好,而在于流程本身能不能从每次执行中学到东西、越来越好。这需要流程是可审查的(看得懂才能改)、可评估的(量化才能知道改对没有)、可复用的(积累才有复利)。
dw 每次重新生成脚本,某种意义上是在放弃之前执行的经验——每次从零开始发明流程。uwf 把流程固化为独立制品,每次迭代都在前一版基础上改进。v1 没有 tester 角色,加上 tester 变成 v2,效果可量化对比。
这是一个有记忆的系统——记忆不在 agent 的 context 里,而在 workflow 的版本历史里。
+18
View File
@@ -0,0 +1,18 @@
---
"@united-workforce/util-agent": minor
"@united-workforce/agent-mock": patch
"@united-workforce/agent-builtin": patch
"@united-workforce/agent-hermes": patch
"@united-workforce/agent-claude-code": patch
---
feat(util-agent): extend AgentOptions with `fork` / `cleanup` and add ask-session cache
Phase 2a infrastructure for `step ask`. Extends `AgentOptions` with
`fork: AgentForkFn | null` and `cleanup: AgentCleanupFn | null` fields, exporting
the new `AgentForkFn` and `AgentCleanupFn` type aliases. Adds `getAskSessionId` /
`setAskSessionId` to the per-agent session cache, using `<stepHash>:ask` keys
that share the cache file with exec sessions (`<threadId>:<role>` keys) without
collision. All four adapters (mock, builtin, hermes, claude-code) now pass
`fork: null, cleanup: null` — real implementations land in Phase 2b. Resolves
issue #145.
@@ -0,0 +1,19 @@
---
"@united-workforce/cli": patch
"@united-workforce/util": patch
---
fix(cli): align `uwf workflow list` with `uwf thread start` parent traversal; document `.workflow/` auto-discovery (#162)
`discoverProjectWorkflows()` now walks from `cwd` up through parent directories
looking for the nearest `.workflow/` (or legacy `.workflows/`), mirroring
`findWorkflowInParents()` used by `uwf thread start`. Previously, `uwf workflow
list` only inspected the exact `cwd` and returned `[]` when run from any
subdirectory, even though `uwf thread start <name>` succeeded from the same
location. The two commands now agree on what is discoverable.
The `@united-workforce/util` reference strings (`generateUsageReference`,
`generateCliReference`, `generateWorkflowAuthoringReference`) are updated to
document project-local `.workflow/` auto-discovery and recommend it as the
primary placement strategy — `uwf workflow add` registration is only needed for
global, cwd-independent workflows.
-9
View File
@@ -1,9 +0,0 @@
---
"@united-workforce/cli": minor
"@united-workforce/util": patch
---
feat: replace $START `_` status with `new`/`resume` semantics
BREAKING: All workflow YAML files must update `$START._` to `$START.new` + `$START.resume`.
The `resume` edge prompt replaces the previously hardcoded resume message in the CLI.
+18
View File
@@ -0,0 +1,18 @@
---
"@united-workforce/cli": minor
"@united-workforce/util": patch
---
feat(cli): add `uwf step ask <step-hash> -p <prompt>` read-only follow-up command
Phase 2b of the ask-session work. Adds a new subcommand that lets the user ask
a follow-up question to a historical step's agent without writing a new
`StepNode` or mutating thread state. The command resolves the agent from the
recorded step (or `--agent <cmd>` override), forks the original session via the
adapter's `--mode fork --session <source>` contract, caches the resulting
ask-session id under `<stepHash>:ask` so subsequent asks reuse it, then invokes
the agent with `--mode ask --session <forkId> --prompt <text> --detail <ref>`
and streams the raw stdout to the caller. `--no-fork` falls back to a fresh
session that receives the step's detail ref for context. The `prompt usage`
reference (in `@united-workforce/util`) is also updated so agents discover the
new subcommand. Resolves issue #146.
+14
View File
@@ -0,0 +1,14 @@
---
"@united-workforce/cli": minor
"@united-workforce/util": patch
---
feat(cli): `uwf thread list` now defaults to active threads only
Changes the default behavior of `uwf thread list` to show only active threads
(idle + running). Adds a new `--all` flag to opt into the previous behavior of
listing every thread (including completed, cancelled, and suspended).
When invoked with no flags, the command now hides completed/cancelled/suspended
threads. Use `--all` to see them, or `--status <status>` to filter explicitly.
The `--status` filter wins when both are present. Resolves issue #147.
+11
View File
@@ -0,0 +1,11 @@
---
"@united-workforce/cli": minor
---
feat(cli): add `uwf thread poke` command
New subcommand `uwf thread poke <thread-id> -p <prompt>` re-runs the head step's
agent with a supplementary prompt, replacing the head step's output. Unlike
`thread resume`, poke skips the moderator and rewrites the new step's `prev`
pointer so the new head replaces (not appends to) the old head. Works on idle
and suspended threads. Resolves issue #144 (Phase 1).
-226
View File
@@ -1,226 +0,0 @@
# Eval Framework Implementation Plan
## Goal
Build `uwf-eval` CLI + eval task infrastructure for evaluating uwf workflow quality with real agents.
## Architecture
```
uwf-eval (runner) task package (npm) OCAS (storage)
│ │ │
├─ unpack tarball ───────► fixture/ → tmp cwd │
├─ read task.yaml │ │
├─ uwf thread start/exec │ │
├─ run judges ───────────► dist/judges/*.js │
├─ collect scores │ │
└─ store results ─────────────────────────────────────► CAS nodes + variables
```
### Key Design Decisions
- **uwf-eval is NOT part of uwf** — separate package, shells out to uwf CLI
- **Task = npm package** — fixture + task.yaml + judge scripts, distributable as tarball
- **Judge = Node script** — `node <entry> <cwd> <thread-id>`, outputs `{score, data}` JSON
- **Every output is OCAS typed** — eval-run, judge results all have registered schemas
- **Builtin judges** — frontmatter compliance, upstream consumption, hallucination, token stats
- **Task-specific judges** — bundled in the task package, custom schema per judge
## Deliverables
### Phase 1: Foundation (`@united-workforce/eval`)
New package in the uwf monorepo.
```
packages/eval/
src/
cli.ts # uwf-eval entry point
commands/
run.ts # uwf-eval run
report.ts # uwf-eval report <hash>
diff.ts # uwf-eval diff <hash> <hash>
list.ts # uwf-eval list
runner/
prepare.ts # unpack tarball/dir → tmp cwd
execute.ts # shell out to uwf thread start/exec
collect.ts # run judges, collect scores
judge/
types.ts # JudgeInput, JudgeOutput types
builtin/
frontmatter.ts # frontmatter compliance check
upstream.ts # upstream info consumption (LLM-as-judge)
hallucination.ts # hallucination detection (LLM-as-judge)
token-stats.ts # token usage from $usage field (#68)
storage/
schemas.ts # OCAS schema definitions
store.ts # CAS read/write helpers
index.ts # variable indexing (@uwf/eval/*)
task/
types.ts # TaskManifest type (task.yaml)
loader.ts # parse task.yaml, validate
package.json
tsconfig.json
```
#### OCAS Schemas to Register
1. `@uwf/eval-run` — full eval execution record
```
{ task, config: {agent, model, engineVersion}, threadId,
judges: [{name, score, weight, dataHash}], overall, timestamp }
```
2. `@uwf/eval-judge-frontmatter` — frontmatter judge data
```
{ stepsTotal, stepsValid, invalidSteps: [{stepIndex, role, errors: string[]}] }
```
3. `@uwf/eval-judge-upstream` — upstream consumption judge data
```
{ perStep: [{role, consumed: string[], missed: string[], score}] }
```
4. `@uwf/eval-judge-hallucination` — hallucination judge data
```
{ perStep: [{role, hallucinations: string[], score}] }
```
5. `@uwf/eval-judge-token-stats` — token stats (not scored, informational)
```
{ totalInput, totalOutput, totalTurns, perStep: [{role, input, output, turns, duration}] }
```
#### CLI Design
```bash
# Run eval
uwf-eval run <task-dir-or-tarball> [--agent hermes] [--model claude-sonnet-4] [--count 20]
# View results
uwf-eval report <run-hash> # render via ocas render
uwf-eval diff <hash1> <hash2> # side-by-side comparison
uwf-eval list # list past runs
```
### Phase 2: Task Package Scaffold
Template for creating eval tasks. Also serves as the first real task.
```
eval-tasks/ # shazhou/uwf-eval-tasks monorepo
packages/
_template/ # copypaste template
package.json
task.yaml
fixture/
src/judges/
tsconfig.json
fix-off-by-one/ # first real task
package.json # @uwf-eval/fix-off-by-one
task.yaml
fixture/
src/calc.ts # buggy calculator
src/calc.test.ts # test that exposes the bug
package.json
src/judges/
test-pass.ts # runs pnpm test, checks exit code
code-quality.ts # LLM judge: minimal change, correct fix
schemas/
test-pass.json # OCAS schema for test-pass data
code-quality.json # OCAS schema for code-quality data
tsconfig.json
pnpm-workspace.yaml
tsconfig.json
biome.json
```
#### task.yaml Format
```yaml
name: fix-off-by-one
description: Fix an off-by-one error in a calculator's add function
workflow: solve-issue # registered workflow name, or relative path to .yaml
prompt: "Fix the bug: add(1,2) returns 4 instead of 3"
limits:
maxSteps: 15
timeoutMinutes: 30
judges:
- name: frontmatter-compliance
weight: 0.15
builtin: true
- name: upstream-consumption
weight: 0.15
builtin: true
- name: hallucination
weight: 0.1
builtin: true
- name: token-stats
weight: 0 # informational, not scored
builtin: true
- name: test-pass
weight: 0.3
entry: dist/judges/test-pass.js
schema: schemas/test-pass.json
- name: code-quality
weight: 0.3
entry: dist/judges/code-quality.js
schema: schemas/code-quality.json
```
#### Judge Script Contract
```typescript
// Input: process.argv = [node, script, cwd, threadId]
// Output: stdout JSON
// Exit 0 = success, non-zero = judge error (not low score)
import type { JudgeOutput } from "@united-workforce/eval";
const result: JudgeOutput<TestPassData> = {
score: 1.0, // 0.0 - 1.0
data: { // typed per judge schema
command: "pnpm test",
exitCode: 0,
output: "3 tests passed"
}
};
console.log(JSON.stringify(result));
```
### Phase 3: Prerequisite — $usage in Adapter Protocol (#68)
Blocked by #68. Token stats judge needs `$usage` in step nodes.
Can proceed with Phase 1+2 without it — token-stats judge just returns zeros until adapters report usage.
## Implementation Order
1. **Phase 1a**: `@united-workforce/eval` package scaffold + CLI skeleton + OCAS schemas
2. **Phase 1b**: `run` command — prepare, execute, collect flow
3. **Phase 1c**: Builtin judges — frontmatter (deterministic), upstream + hallucination (LLM-as-judge)
4. **Phase 2a**: Create `shazhou/uwf-eval-tasks` monorepo with proman
5. **Phase 2b**: First task `fix-off-by-one` with fixture repo + 2 custom judges
6. **Phase 2c**: End-to-end test: `uwf-eval run packages/fix-off-by-one --agent hermes`
7. **Phase 1d**: `report`, `diff`, `list` commands (read from CAS, render via ocas render)
## Dependencies
- `@ocas/core` + `@ocas/fs` — CAS storage
- `@united-workforce/protocol` — step node types
- `commander` — CLI framework (consistent with uwf)
- LLM API access — for LLM-as-judge (upstream, hallucination, task-specific quality judges)
## Open Questions
1. **LLM-as-judge provider config** — reuse uwf's `~/.uwf/config.yaml` provider settings? Or separate config?
2. **Workflow file location** — task.yaml references a workflow. Should the workflow YAML be inside the tarball, or reference a registered workflow by name?
3. **Non-coding tasks** — debate workflow has no fixture repo. task.yaml needs `fixture: null` or simply omit the `fixture/` dir. Runner creates empty cwd.
4. **Parallel judge execution** — judges are independent, can run in parallel. Worth the complexity?
## Risks
- LLM-as-judge consistency — same input may get different scores. Mitigation: run judge multiple times, take average? Or accept variance.
- Token cost of judges — each LLM judge call costs tokens. For a 10-step workflow with 2 LLM judges = 20 LLM calls just for judging. Acceptable?
- Fixture repo drift — if the fixture evolves, old eval runs become non-comparable. Pin fixture version in task.yaml.
-247
View File
@@ -1,247 +0,0 @@
name: "solve-issue"
description: "TDD-driven issue resolution for small, focused changes. Loop protection relies on engine maxRounds."
roles:
planner:
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
procedure: |
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. The test spec is stored in CAS automatically by the uwf pipeline (agents do not need to call `ocas put` directly)
2. Put the plan hash in frontmatter.plan (required when $status=ready)
3. Set repoPath to the absolute path of the repository root
IMPORTANT: Extract the repo remote (owner/repo) from git:
```bash
git remote get-url origin | sed 's|.*[:/]\([^/]*/[^.]*\).*|\1|'
```
Store the result as repoRemote in your frontmatter output so downstream roles can use it for tea/API calls.
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
frontmatter:
oneOf:
- properties:
$status: { const: "ready" }
plan: { type: string }
repoPath: { type: string }
repoRemote: { type: string }
required: [$status, plan, repoPath, repoRemote]
- properties:
$status: { const: "insufficient_info" }
reason: { type: string }
required: [$status, reason]
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: |
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: `ocas 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 tests fail on first run:
* Read the test output carefully for missing imports or setup issues
* Check if you're running tests from the correct working directory (package root vs workspace root)
* Fix the immediate issue and rerun ONCE
* If tests still fail after 2 attempts: check the test spec for ambiguities
* If stuck after 3 test cycles: set $status=failed with detailed error report rather than continuing blind retries
12. MANDATORY VERIFICATION before reporting done:
- Run `git branch --show-current` and confirm branch name matches expected
- Run `git status` and verify changed files exist
- Run `ls -la <key-implementation-files>` to verify they exist on disk
- If ANY verification fails: retry the implementation, do NOT report done
If you cannot complete the implementation (e.g. the issue is too complex, blocked by external factors,
or repeated attempts fail), set $status=failed with a reason.
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
frontmatter:
oneOf:
- properties:
$status: { const: "done" }
branch: { type: string }
worktree: { type: string }
repoRemote: { 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)."
capabilities:
- code-review
- static-analysis
procedure: |
The worktree path is provided in your task prompt. cd into it first.
CRITICAL: You MUST execute every verification command below. Do NOT report results without running the actual commands. Do NOT rely on prior context or assumptions.
Before reviewing, verify the worktree and branch exist:
0. Run `cd <worktree-path> && pwd` to confirm the path is accessible
- If the cd fails: the worktree truly doesn't exist, reject with that reason
- If the cd succeeds: proceed with step 1 below
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:
oneOf:
- properties:
$status: { const: "approved" }
branch: { type: string }
worktree: { type: string }
repoRemote: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "rejected" }
comments: { type: string }
worktree: { type: string }
repoRemote: { type: string }
required: [$status, comments, worktree]
tester:
description: "Functional correctness verification"
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: `ocas 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 }
repoRemote: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "fix_code" }
report: { type: string }
repoRemote: { type: string }
worktree: { type: string }
branch: { type: string }
required: [$status, report]
- properties:
$status: { const: "fix_spec" }
report: { type: string }
repoRemote: { type: string }
worktree: { type: string }
branch: { type: string }
required: [$status, report]
committer:
description: "Commits and creates PR"
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
capabilities: []
procedure: |
The worktree path, branch name, and repo remote (owner/repo) are provided in your task prompt.
cd into the worktree first.
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
1. Check `git status` — if working tree is clean and branch is ahead of origin, skip to step 3 (push).
2. If there are unstaged/uncommitted changes: `git add -A` then `git commit -m "type: description\n\nFixes #N"`
3. Push the branch: `git push -u origin <branch-name>`
4. **Verify push succeeded** — run `git ls-remote origin <branch-name>` and confirm it prints a commit hash.
- If no output or push failed: capture the error, mark hook_failed
5. Create a PR using the Gitea API (do NOT use `tea pr create` — it fails in worktrees):
```bash
GITEA_TOKEN=$(cfg get GITEA_TOKEN)
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \
"https://git.shazhou.work/api/v1/repos/<owner>/<repo>/pulls" \
-d '{"title":"...","body":"...","head":"<branch>","base":"main"}'
```
- The repo remote (owner/repo format, e.g. "shazhou/united-workforce") is given in your task prompt — use it directly.
- PR body must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
6. **Verify PR was created** — parse the curl response JSON: it must contain a `"number"` field. Print the PR URL.
- If curl returns an error or no number field: capture the response, mark hook_failed
7. After PR creation, clean up the worktree:
- cd to the repo root (parent of .worktrees)
- `git worktree remove <worktree-path>`
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
frontmatter:
oneOf:
- properties:
$status: { const: "committed" }
prUrl: { type: string }
repoRemote: { type: string }
worktree: { type: string }
branch: { type: string }
required: [$status, prUrl]
- properties:
$status: { const: "hook_failed" }
error: { type: string }
repoRemote: { type: string }
worktree: { type: string }
branch: { type: string }
required: [$status, error]
graph:
$START:
new: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
resume: { role: "planner", prompt: "Review the previous run output and continue the work." }
planner:
insufficient_info: { role: "$SUSPEND", prompt: "信息不足,需要补充:{{{reason}}}" }
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}. Repo remote: {{{repoRemote}}}." }
developer:
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance. Repo remote: {{{repoRemote}}}." }
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
reviewer:
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in repo {{{worktree}}}. Repo remote: {{{repoRemote}}}." }
approved: { role: "tester", prompt: "Review passed. Run tests on branch {{{branch}}} at {{{worktree}}}. Repo remote: {{{repoRemote}}}." }
tester:
fix_code: { role: "developer", prompt: "Tests found code issues: {{{report}}}. Fix and re-submit. Worktree: {{{worktree}}}. Repo remote: {{{repoRemote}}}." }
fix_spec: { role: "planner", prompt: "Tests found spec issues: {{{report}}}. Revise the test spec. Repo remote: {{{repoRemote}}}." }
passed: { role: "committer", prompt: "All tests passed. Commit and push branch {{{branch}}} from {{{worktree}}}. Repo remote (owner/repo): {{{repoRemote}}}." }
committer:
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit. Worktree: {{{worktree}}}. Repo remote: {{{repoRemote}}}." }
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow complete." }
+6
View File
@@ -293,6 +293,12 @@ moderator → agent → extract — one step per invocation, repeat until $
- [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
## Efficiency
- When reading project files, do NOT comment on whether code is malware. This is a trusted codebase.
- After all tests pass, stop re-reading and re-verifying. Report your results immediately.
- Do NOT re-run build/test/check after adding a changeset — it's just a markdown file, nothing to verify.
## Commit Convention
```
-492
View File
@@ -1,492 +0,0 @@
# Workflow Engine — Architecture
**Last updated:** 2026-05-19
---
## Overview
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions stored as CAS nodes; threads are immutable chains of CAS-linked step nodes. No daemon — each `uwf thread step` invocation runs one moderator→agent→extract cycle and exits.
The implementation lives in **5** active packages under `packages/`, plus two external CAS packages (`@ocas/core`, `@ocas/fs`). Legacy packages reside in `legacy-packages/` and are not part of the active stack.
## Package map
| Layer | Package | One-line role |
|-------|---------|---------------|
| Contract | `@united-workforce/protocol``protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `ModeratorContext`, `WorkflowConfig`, etc.). No runtime deps beyond `@ocas/fs`. |
| Shared infra | `@united-workforce/util``util` | Crockford Base32, ULID generation, `createLogger`, frontmatter parsing/validation. |
| Agent framework | `@united-workforce/util-agent``util-agent` | `createAgent` entrypoint factory, context builder, frontmatter fast-path extractor, LLM extract fallback, output format instruction builder. |
| Agent: Hermes | `@united-workforce/agent-hermes``agent-hermes` | `uwf-hermes` CLI binary — spawns `hermes chat`, pipes prompt, captures session detail. |
| CLI | `@united-workforce/cli``cli` | `uwf` binary — thread lifecycle, workflow registry, CAS inspection, setup. Includes status-based graph evaluator in `src/moderator/` (next role or `$END`). |
### External dependencies
| Package | Role |
|---------|------|
| `@ocas/core` | Content-addressed store API, XXH64 hashing, JSON Schema registration and validation. |
| `@ocas/fs` | Filesystem backend for `ocas`. |
| `mustache` | Template renderer for edge prompts (used by `cli` moderator). |
| `commander` | CLI argument parsing (used by `cli`). |
| `dotenv` | Loads `.env` files for API keys. |
| `yaml` | YAML parse/stringify. |
## Dependency graph
```mermaid
flowchart BT
subgraph External
jcas["@ocas/core"]
jcasfs["@ocas/fs"]
end
subgraph L0["Layer 0 — contract"]
protocol["@united-workforce/protocol"]
end
subgraph L1["Layer 1 — shared"]
util["@united-workforce/util"]
end
subgraph L2["Layer 2 — agent framework"]
kit["@united-workforce/util-agent"]
end
subgraph L3["Layer 3 — agent implementations"]
hermes["@united-workforce/agent-hermes"]
end
subgraph L4["Layer 4 — CLI"]
cli["@united-workforce/cli"]
end
protocol --> jcasfs
util --> protocol
kit --> protocol
kit --> util
kit --> jcas
kit --> jcasfs
hermes --> kit
hermes --> jcas
cli --> protocol
cli --> util
cli --> kit
cli --> jcas
cli --> jcasfs
```
## Workflow definition
Workflows are **YAML files** (not ESM bundles). `uwf workflow put <file.yaml>` parses the YAML, registers output schemas as JSON Schema CAS nodes, and stores the `WorkflowPayload` as a CAS node.
Example (`examples/solve-issue.yaml`):
```yaml
name: "solve-issue"
description: "End-to-end issue resolution"
roles:
planner:
description: "Creates implementation plan"
goal: "You are a planning agent. Analyze the issue and create a step-by-step plan."
capabilities:
- issue-analysis
- planning
procedure: "Analyze the issue and create a detailed, actionable implementation plan."
output: "Output the plan summary and list of concrete steps."
meta:
type: object
properties:
plan: { type: string }
steps: { type: array, items: { type: string } }
required: [plan, steps]
developer:
description: "Implements code changes"
goal: "You are a developer agent. Implement the plan."
capabilities:
- file-edit
- shell
procedure: "Implement the plan. Write code, tests, and ensure existing tests pass."
output: "List all files changed and provide a summary of the implementation."
meta:
type: object
properties:
filesChanged: { type: array, items: { type: string } }
summary: { type: string }
required: [filesChanged, summary]
reviewer:
description: "Reviews code changes"
goal: "You are a code reviewer. Review the implementation."
capabilities:
- code-review
procedure: "Review the implementation against the plan."
output: "Approve or reject with detailed comments."
meta:
type: object
properties:
approved: { type: boolean }
comments: { type: string }
required: [approved, comments]
conditions:
notApproved:
description: "Reviewer rejected the implementation"
expression: "steps[-1].output.approved = false"
graph:
$START:
- role: "planner"
condition: null
planner:
- role: "developer"
condition: null
developer:
- role: "reviewer"
condition: null
reviewer:
- role: "developer"
condition: "notApproved"
- role: "$END"
condition: null
```
Key properties:
- **`roles`** — inline role definitions; each `meta` is a JSON Schema (stored as its own CAS node on registration)
- **`graph`** — `Record<Role | "$START", Record<Status, Target>>` — status-based routing; each role maps statuses to targets
- **No agent binding** — agent selection is a deployment concern, configured in `config.yaml`
- **No Zod** — all schemas are JSON Schema, validated through `@ocas/core`
## Three-phase engine loop
Each `uwf thread step` runs exactly one cycle: moderator → agent → extract. The CLI orchestrates this in `packages/cli/src/commands/thread.ts` (`cmdThreadStep`).
```
┌─→ Phase 1: MODERATOR
│ Input: graph + lastRole + lastOutput
│ Engine: Status-based map lookup against lastOutput.status
│ Output: next role name | $END
│ Phase 2: AGENT
│ Input: thread-id + role (via argv)
│ Engine: agent-kit builds context from CAS chain, prepends
│ output format instruction to system prompt, spawns agent
│ Output: raw string (frontmatter markdown)
│ Phase 3: EXTRACT
│ Input: raw agent output + role's meta schema
│ Engine: two-layer extract (frontmatter fast path → LLM fallback)
│ Output: CasRef to structured output node
│ Persist: StepNode { start, prev, role, output, detail, agent }
│ Update: threads.yaml head pointer
└─────────────────────────────────────────────────────────────────┘
```
### Context types
Defined in `packages/protocol/src/types.ts`:
```typescript
type StepContext = {
role: string;
output: unknown; // CAS node payload, expanded (not hash)
detail: CasRef;
agent: string;
};
type ModeratorContext = {
start: StartNodePayload; // { workflow: CasRef, prompt: string }
steps: StepContext[]; // chronological, oldest first
};
type AgentContext = ModeratorContext & {
threadId: ThreadId;
role: string;
store: Store;
workflow: WorkflowPayload;
outputFormatInstruction: string;
};
```
### Key properties
- **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.
## Agent CLI protocol
Each agent is an external command invoked by `uwf thread step`:
```bash
<agent-cmd> <thread-id> <role>
```
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. `util-agent` (`createAgent`) handles the boilerplate:
- Parses argv
- Loads `.env` from storage root
- Builds `AgentContext` by walking the CAS chain from `threads.yaml` head
- Resolves the role's `meta` schema and builds `outputFormatInstruction`
- Calls the agent's `run` function
- Runs two-layer extract on the raw output
- Writes `StepNode` to CAS (output + detail + prev link)
- Prints the new `StepNode` CAS hash to stdout
4. `uwf thread step` reads stdout, updates `threads.yaml` head pointer, re-evaluates moderator for `done`
5. Exit 0 = success, non-zero = failure
Agent resolution priority: `--agent` CLI override → `config.yaml` per-workflow/role override → `config.yaml` `defaultAgent`.
## Agent output format: frontmatter markdown (RFC #351)
Agents produce **frontmatter markdown** — YAML frontmatter for structured meta, followed by a markdown body for content:
```markdown
---
status: done
next: reviewer
confidence: 0.9
artifacts:
- src/auth.ts
scope: role
---
## Implementation
Fixed the login redirect by updating the auth middleware...
```
The `outputFormatInstruction` (built by `buildOutputFormatInstruction` in `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 (`util-agent`):
### Layer 1: frontmatter fast path (`frontmatter.ts`)
1. Parse YAML frontmatter from raw agent output (`parseFrontmatterMarkdown`)
2. Validate required fields (`validateFrontmatter`)
3. Build a candidate object from frontmatter fields (`status`, `next`, `confidence`, `artifacts`, `scope`)
4. `store.put()` the candidate against the role's `meta` schema
5. Validate with `ocas` schema validation
6. If valid → return `outputHash` (zero LLM cost)
### Layer 2: LLM extract fallback (`extract.ts`)
If the fast path returns `null` (no frontmatter, invalid, or doesn't satisfy schema):
1. Resolve extract model alias from config (`modelOverrides.extract``models.extract``defaultModel`)
2. Call OpenAI-compatible chat completion with JSON mode
3. System prompt: "Extract structured data matching this JSON Schema: ..."
4. User message: the raw agent output
5. Parse response, `store.put()`, validate
6. Return `outputHash`
## Prompt injection
`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."
This ensures agents produce parseable frontmatter output without requiring per-agent format knowledge.
## CAS node types
### Workflow
```yaml
type: <workflow-schema-hash>
payload:
name: "solve-issue"
description: "End-to-end issue resolution"
roles:
planner:
description: "Creates implementation plan"
goal: "You are a planning agent..."
capabilities: [planning, issue-analysis]
procedure: "Analyze the issue and create a plan."
output: "Output the plan summary."
meta: "5GWKR8TN1V3JA" # ocas_ref → JSON Schema node
conditions:
notApproved:
description: "Reviewer rejected"
expression: "steps[-1].output.approved = false"
graph:
$START:
- role: "planner"
condition: null
```
### StartNode
```yaml
type: <start-node-schema-hash>
payload:
workflow: "4KNM2PXR3B1QW" # ocas_ref → Workflow
prompt: "Fix the login bug..."
```
### StepNode
```yaml
type: <step-node-schema-hash>
payload:
start: "4TNVW8KR2B3MA" # ocas_ref → StartNode
prev: "2MXBG6PN4A8JR" # ocas_ref → previous StepNode (null for first step)
role: "developer"
output: "9KRVW3TN5F1QA" # ocas_ref → structured output (validated against meta schema)
detail: "7BQST3VW9F2MA" # ocas_ref → execution detail (raw turns, session data)
agent: "uwf-hermes" # agent command used (plain string)
```
### Chain structure
```
threads.yaml: { "01J7K9...4T": "8FWKR3TN5V1QA" }
StepNode (step 3)
├── start ──→ StartNode
│ ├── workflow → Workflow (CAS)
│ └── prompt: "Fix..."
├── prev ──→ StepNode (step 2)
│ ├── prev ──→ StepNode (step 1)
│ │ └── prev: null
│ └── ...
├── role: "reviewer"
├── output → CAS({ approved: true })
├── detail → CAS(session turns)
└── agent: "uwf-hermes"
```
## Storage layout
```
~/.uwf/
├── cas/ # json-cas filesystem store (all CAS nodes)
├── config.yaml # Provider, model, agent configuration
├── threads.yaml # Active thread head pointers: threadId → CasRef
├── history.jsonl # Archived thread records
├── registry.yaml # Workflow name → CAS hash mapping
└── .env # API keys (loaded by dotenv)
```
### Mutable state
Only three files carry mutable state:
| File | Contents |
|------|----------|
| `threads.yaml` | `Record<ThreadId, CasRef>` — maps active thread IDs to head node hash |
| `history.jsonl` | Append-only log of completed threads (`thread`, `workflow`, `head`, `completedAt`) |
| `registry.yaml` | Workflow name → current CAS hash |
Everything else is immutable CAS content.
### ID encoding: Crockford Base32
- Case-insensitive, filesystem-safe, no ambiguous chars (0/O, 1/I/L)
- CAS hash: XXH64 → 13-char Crockford Base32
- Thread ID: ULID → 26-char Crockford Base32 (10 timestamp + 16 random)
### Config (`config.yaml`)
```yaml
providers:
openrouter:
baseUrl: "https://openrouter.ai/api/v1"
apiKey: "sk-..."
models:
sonnet:
provider: "openrouter"
name: "anthropic/claude-sonnet-4"
gpt4o-mini:
provider: "openai"
name: "gpt-4o-mini"
agents:
hermes:
command: "uwf-hermes"
args: []
cursor:
command: "uwf-cursor"
args: []
defaultAgent: "hermes"
agentOverrides:
solve-issue:
developer: "cursor"
defaultModel: "sonnet"
modelOverrides:
extract: "gpt4o-mini"
```
## CLI commands
Binary: `uwf`
### Thread commands
| Command | Description |
|---------|-------------|
| `uwf thread start <workflow> -p <prompt>` | Create a thread (StartNode → CAS, head → threads.yaml). No execution. |
| `uwf thread step <thread-id> [--agent <cmd>]` | Execute one moderator→agent→extract cycle. |
| `uwf thread show <thread-id>` | Show thread head pointer and done status. |
| `uwf thread list [--all]` | List active threads (`--all` includes archived). |
| `uwf thread steps <thread-id>` | List all steps in chronological order. |
| `uwf thread read <thread-id> [--quota <chars>] [--before <hash>]` | Render thread as human-readable markdown. |
| `uwf thread fork <step-hash>` | Fork a thread from a specific CAS node. |
| `uwf thread step-details <step-hash>` | Dump full detail node as YAML. |
| `uwf thread kill <thread-id>` | Terminate and archive a thread. |
### Workflow commands
| Command | Description |
|---------|-------------|
| `uwf workflow put <file.yaml>` | Register a workflow from YAML definition. |
| `uwf workflow show <id>` | Show workflow by name or CAS hash. |
| `uwf workflow list` | List registered workflows. |
### CAS commands
Use the `ocas` CLI for direct CAS operations (`~/.ocas/` store, shared with `uwf`):
| Command | Description |
|---------|-------------|
| `ocas get <hash>` | Read a CAS node. |
| `ocas put <type-hash> <data>` | Store a node, print its hash. |
| `ocas has <hash>` | Check if a hash exists. |
| `ocas refs <hash>` | List direct CAS references. |
| `ocas walk <hash>` | Recursive traversal from a node. |
| `ocas reindex` | Rebuild type index from all nodes. |
| `ocas schema list` | List registered schemas. |
| `ocas schema get <hash>` | Show a schema by type hash. |
### Setup
| Command | Description |
|---------|-------------|
| `uwf setup [--provider --base-url --api-key --model --agent]` | Configure provider/model/agent (interactive if no flags). |
## Toolchain
| Tool | Purpose |
|------|---------|
| **pnpm** | Package manager |
| **TypeScript** | Type checking (strict mode) |
| **Biome** | Lint + format |
| **vitest** | Test runner |
## Design decisions
| Decision | Rationale |
|----------|-----------|
| **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. |
| **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. |
| **JSON Schema (not Zod)** | Schemas are CAS-native data — storable, hashable, validatable through `ocas`. No code generation, no runtime library dependency. |
| **Agent as external command** | Agents are independent CLI binaries (`uwf-hermes`, `uwf-cursor`). Swappable per workflow/role via config. No tight coupling to the engine. |
| **No daemon** | Process starts, does one step, exits. Simpler failure model, no connection management. |
| **Crockford Base32** | Filesystem-safe, case-insensitive, readable, compact. |
-779
View File
@@ -1,779 +0,0 @@
# Built-in Role Agent 调研
## 目标
实现一个内置的 role agent(暂称 `uwf-builtin`),不依赖 hermes/openclaw 等外部 agent 进程。
直接使用 workflow config 中配置的 model,自己实现 agent run loop 和关键 toolkit。
---
## 关键问题
### Q1: Agent 接口协议
现有 agent 是怎么被 CLI 调用的?输入(argv、环境变量)和输出(stdout、CAS)格式是什么?
**调研要点:**
- `cli``spawnAgent` 的完整实现
- AgentConfig 类型定义
- agent 进程的 exit code 约定
- 环境变量传递(UWF_STORAGE_ROOT 等)
**答案:**
#### 调用链
`uwf thread step``cmdThreadStepOnce` → moderator 求值下一 role → `resolveAgentConfig``spawnAgent`
#### AgentConfig 类型
```146:149:packages/protocol/src/types.ts
export type AgentConfig = {
command: string;
args: string[];
};
```
在 `config.yaml` 的 `agents` 段注册,例如 `hermes: { command: "uwf-hermes", args: [] }`。
#### spawnAgent 行为
```627:653:packages/cli/src/commands/thread.ts
function spawnAgent(agent: AgentConfig, threadId: ThreadId, role: string): CasRef {
const argv = [...agent.args, threadId, role];
let stdout: string;
try {
stdout = execFileSync(agent.command, argv, {
encoding: "utf8",
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
} catch (e) {
// ... stderr 拼进 fail 消息
}
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
if (!isCasRef(line)) {
fail(`agent stdout is not a valid CAS hash: ${line || "(empty)"}`);
}
return line;
}
```
| 项目 | 约定 |
|------|------|
| **argv** | `[...agent.args, <thread-id>, <role>]`,即 `process.argv[2]`=threadId,`process.argv[3]`=role(与 `createAgent` 的 `parseArgv` 一致) |
| **stdin** | 忽略 |
| **stdout** | 纯文本,**最后一行**必须是新 `StepNode` 的 CAS hash(13 字符 Crockford Base32) |
| **stderr** | 失败时 CLI 会附带 stderr;成功时无约定 |
| **exit code** | `0` = 成功;非 0 时 `execFileSync` 抛错,step 失败 |
| **环境变量** | 继承父进程 `process.env`(含 storage root、API key 等) |
| **链头更新** | **不由 agent 负责**;agent 只写 CAS StepNode,CLI 在拿到 stdout hash 后更新 `threads.yaml` |
Agent 解析优先级(`resolveAgentConfig`):
1. CLI `--agent` override(整段 command + args 字符串)
2. `config.agentOverrides[workflow.name][role]`
3. `config.defaultAgent`
#### 环境变量:Storage Root
文档中写的 `UWF_STORAGE_ROOT` **在当前代码中不存在**。实际优先级(`util-agent` / `cli` 一致):
```33:43:packages/util-agent/src/storage.ts
export function resolveStorageRoot(): string {
const internal = process.env.UWF_STORAGE_ROOT;
if (internal !== undefined && internal !== "") {
return internal;
}
const userOverride = process.env.WORKFLOW_STORAGE_ROOT;
if (userOverride !== undefined && userOverride !== "") {
return userOverride;
}
return getDefaultStorageRoot();
}
```
Agent 子进程通过继承的 `process.env` 与父 CLI 共享同一 storage root;`createAgent` 内还会 `loadDotenv({ path: getEnvPath(storageRoot) })` 加载 `~/.uwf/.env`。
#### Agent 侧职责(设计文档 + 实现)
- 读 `threads.yaml` 链头,构建 context,执行 role
- 将 `StepNode` 写入 CAS(`output` / `detail` / `agent` / `prev` / `start`)
- stdout 打印 step hash
- **不**更新 `threads.yaml`
---
### Q2: createAgent 工厂
util-agent 的 `createAgent` 做了什么?它的完整生命周期是什么?
**调研要点:**
- `AgentOptions` 类型的 `run` 和 `continue` 回调签名
- `AgentRunResult` 的完整定义
- retry 逻辑(frontmatter 校验失败后的重试机制)
- `persistStep` 写入 CAS 的 StepNode 结构
**答案:**
#### 类型定义
```4:35:packages/util-agent/src/types.ts
export type AgentContext = ModeratorContext & {
threadId: ThreadId;
role: string;
store: Store;
workflow: WorkflowPayload;
outputFormatInstruction: string;
};
export type AgentRunResult = {
output: string;
detailHash: CasRef;
sessionId: string;
};
export type AgentContinueFn = (
sessionId: string,
message: string,
store: AgentContext["store"],
) => Promise<AgentRunResult>;
export type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
export type AgentOptions = {
name: string;
run: AgentRunFn;
continue: AgentContinueFn;
};
```
- **`run(ctx)`**:首次执行,返回原始 agent 文本 `output`、审计用 `detailHash`、用于续聊的 `sessionId`。
- **`continue(sessionId, message, store)`**:在同一 session 上追加用户消息(用于 frontmatter 纠错),再次返回 `AgentRunResult`。
`createAgent(options)` 返回 `() => Promise<void>`,作为 agent CLI 的 `main`(见 `uwf-hermes` 的 `cli.ts`)。
#### 生命周期(按执行顺序)
```101:152:packages/util-agent/src/run.ts
export function createAgent(options: AgentOptions): () => Promise<void> {
return async function main(): Promise<void> {
const { threadId, role } = parseArgv(process.argv);
const storageRoot = resolveStorageRoot();
loadDotenv({ path: getEnvPath(storageRoot) });
const ctx = await buildContextWithMeta(threadId, role);
// 1. 校验 role 存在
// 2. 从 CAS 取 frontmatter JSON Schema → buildOutputFormatInstruction → ctx.outputFormatInstruction
let agentResult = await options.run(ctx);
let outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
for (let retry = 0; retry < MAX_FRONTMATTER_RETRIES && outputHash === null; retry++) {
const correctionMessage = "Your previous response did not contain valid YAML frontmatter...";
agentResult = await options.continue(agentResult.sessionId, correctionMessage, ctx.meta.store);
outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
}
if (outputHash === null) { fail(...); }
const stepHash = await persistStep({ ctx, outputHash, detailHash: agentResult.detailHash, agentName });
process.stdout.write(`${stepHash}\n`);
};
}
```
| 阶段 | 行为 |
|------|------|
| 解析 argv | `argv[2]=threadId`, `argv[3]=role`,缺失则 `stderr` + `exit(1)` |
| Context | `buildContextWithMeta` + 可选 `outputFormatInstruction` |
| Run | `options.run(ctx)` |
| Extract | **仅** `tryFrontmatterFastPath`(见 Q4);**不**调用 `extract()` LLM fallback |
| Retry | 最多 `MAX_FRONTMATTER_RETRIES = 2` 次 `continue` + 再试 fast-path |
| Persist | `persistStep` → `writeStepNode` |
| 输出 | stdout 一行 step CAS hash |
#### StepNode 写入结构
```44:68:packages/util-agent/src/run.ts
async function writeStepNode(options: {
store: AgentStore["store"];
schemas: AgentStore["schemas"];
startHash: CasRef;
prevHash: CasRef | null;
role: string;
outputHash: CasRef;
detailHash: CasRef;
agentName: string;
}): Promise<CasRef> {
const payload: StepNodePayload = {
start: options.startHash,
prev: options.prevHash,
role: options.role,
output: options.outputHash,
detail: options.detailHash,
agent: options.agentName,
};
// store.put(stepNode schema) + validate
}
```
`agentName` 经 `agentLabel(name)` 规范化:已有 `uwf-` 前缀则原样,否则加 `uwf-`(如 `hermes` → `uwf-hermes`)。
`prevHash`:若链头仍是 `StartNode` 则为 `null`,否则为当前 head step hash。
---
### Q3: Context Builder
`buildContextWithMeta` 构建了什么上下文给 agent?
**调研要点:**
- `AgentContext` 完整类型定义(所有字段)
- context 构建过程(CAS chain walk)
- `outputFormatInstruction` 怎么生成的
- role definition 怎么获取(从 workflow YAML)
**答案:**
#### AgentContext 字段
继承 `ModeratorContext`:
```60:68:packages/protocol/src/types.ts
export type ModeratorContext = {
start: StartNodePayload;
steps: StepContext[];
};
```
```48:51:packages/protocol/src/types.ts
export type StartNodePayload = {
workflow: CasRef;
prompt: string;
};
```
```61:63:packages/protocol/src/types.ts
export type StepContext = Omit<StepRecord, "output"> & {
output: unknown;
};
```
`AgentContext` 额外字段:
| 字段 | 类型 | 含义 |
|------|------|------|
| `threadId` | `ThreadId` | 当前线程 |
| `role` | `string` | 本步要执行的角色名 |
| `store` | `Store` | CAS store(读写节点) |
| `workflow` | `WorkflowPayload` | 已从 CAS 加载的 workflow 定义 |
| `outputFormatInstruction` | `string` | 由 `createAgent` 根据 role 的 frontmatter schema 生成;`buildContext*` 初始为 `""` |
`buildContextWithMeta` 还返回 `meta`:
```148:154:packages/util-agent/src/context.ts
export type BuildContextMeta = {
storageRoot: string;
store: Store;
schemas: AgentStore["schemas"];
headHash: CasRef;
chain: ChainState;
};
```
#### CAS chain walk
1. 从 `threads.yaml[threadId]` 取 `headHash`
2. `walkChain`:若 head 是 `StartNode`,`stepsNewestFirst=[]`;否则沿 `prev` 收集所有 `StepNode`, newest-first
3. `buildHistory`:反转为时间序,`expandOutput` 把每步 `output` CasRef 展开为 JSON payload(供 prompt / moderator 使用)
4. `loadWorkflow`:从 `start.workflow` CasRef 加载 `WorkflowPayload`
#### Role definition 来源
- 作者写在 workflow YAML 的 `roles.<name>`(`goal`, `capabilities`, `procedure`, `output`, `frontmatter` 等)
- `uwf workflow put` 时 `frontmatter` 内联 JSON Schema 经 `putSchema` 存入 CAS,workflow 里存的是 **CasRef**
- Agent 运行时:`ctx.workflow.roles[ctx.role]` → `RoleDefinition`
#### outputFormatInstruction
在 `createAgent` 中,若 `getSchema(store, roleDef.frontmatter)` 非空,则:
```typescript
ctx.outputFormatInstruction = buildOutputFormatInstruction(frontmatterSchema);
```
`buildOutputFormatInstruction` 根据 JSON Schema 的 `properties` 生成「必须以 `---` YAML frontmatter 开头」的说明和示例字段列表(见 `build-output-format-instruction.ts`)。
各 agent 实现(Hermes / Claude Code)在组装 prompt 时把该块放在最前,再接 `buildRolePrompt(roleDef)`。
---
### Q4: Extract Pipeline
agent 输出怎么被处理成结构化数据?
**调研要点:**
- frontmatter fast-path 的完整逻辑
- LLM extract fallback 的实现(`extract.ts`)
- frontmatter schema 从哪里来(role 定义里的 `frontmatter` 字段)
- 校验失败时的 correction prompt 是什么
**答案:**
#### Schema 来源
Workflow YAML 中每个 role 的 `frontmatter:` 段是 JSON Schema 对象;注册时:
```66:76:packages/cli/src/commands/workflow.ts
async function resolveFrontmatterRef(..., frontmatter: unknown): Promise<CasRef> {
// 校验为 JSON Schema → putSchema → 返回 CasRef
}
```
运行时 `roleDef.frontmatter` 即该 schema 的 CAS hash;structured `output` 节点用**同一 schema** 写入 CAS。
#### Frontmatter fast-path(createAgent 实际使用的路径)
```148:195:packages/util-agent/src/frontmatter.ts
export async function tryFrontmatterFastPath(
raw: string,
outputSchema: CasRef,
store: Store,
): Promise<FrontmatterFastPathResult | null>
```
流程:
1. `parseFrontmatterMarkdown(raw)` → 标准 agent 字段(`status`, `next`, `confidence`, `artifacts`, `scope`)+ body
2. `validateFrontmatter` 失败 → `null`
3. `getSchema(store, outputSchema)` + `extractSchemaFields` 得到 role 需要的属性名
4. `buildCandidate`:从标准 frontmatter + YAML 原始字段拼出符合 schema 的对象
5. `store.put(outputSchema, candidate)` + `validate` → 成功则 `{ body, outputHash }`
**永不抛错**,失败返回 `null`。
#### LLM extract fallback(已实现但未接入 createAgent)
```135:181:packages/util-agent/src/extract.ts
export async function extract(
rawOutput: string,
outputSchema: CasRef,
config: WorkflowConfig,
): Promise<ExtractResult>
```
- 模型:`resolveExtractModelAlias(config)` → `modelOverrides.extract` → `models.extract` → `models.default` → `defaultModel`
- HTTP:`POST {baseUrl}/chat/completions`,`response_format: { type: "json_object" }`
- System:要求按 JSON Schema 从 agent 输出提取单个 JSON 对象
- 校验通过后 `store.put(outputSchema, structured)`
**重要:`createAgent` 当前未调用 `extract()`**。fast-path 失败且 2 次 `continue` 仍失败则直接 `fail()`。builtin agent 若希望无 frontmatter 也能跑,需在 kit 或 builtin 层显式接入 `extract()`。
#### Correction prompt(retry)
```125:128:packages/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" +
"Please output ONLY the corrected frontmatter block followed by your work.";
```
通过 `options.continue(sessionId, correctionMessage, store)` 发给外部 agent;builtin 需在自有 message 历史里 append 同等语义的 user 消息。
---
### Q5: Model 配置与 LLM 调用
workflow 怎么配置和使用 model?
**调研要点:**
- `WorkflowConfig` 中 providers/models/defaultModel/modelOverrides 的完整定义
- `resolveModel` 函数的实现
- `chatCompletionText` 的实现(OpenAI 兼容 HTTP 客户端)
- 有没有 streaming 支持?tool calling 支持?
**答案:**
#### WorkflowConfig
```136:160:packages/protocol/src/types.ts
export type ProviderConfig = {
baseUrl: string;
apiKey: string;
};
export type ModelConfig = {
provider: ProviderAlias;
name: string;
};
export type WorkflowConfig = {
providers: Record<ProviderAlias, ProviderConfig>;
models: Record<ModelAlias, ModelConfig>;
agents: Record<AgentAlias, AgentConfig>;
defaultAgent: AgentAlias;
agentOverrides: Record<WorkflowName, Record<RoleName, AgentAlias>> | null;
defaultModel: ModelAlias;
modelOverrides: Record<Scenario, ModelAlias> | null;
};
```
示例见 `docs/architecture.md`(`providers` / `models` / `defaultModel` / `modelOverrides.extract`)。
#### resolveModel
```32:50:packages/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 = providerEntry.apiKey;
return { baseUrl: providerEntry.baseUrl, apiKey, model: modelEntry.name };
}
```
`ResolvedLlmProvider = { baseUrl, apiKey, model }`。
Extract 专用别名解析:
```18:30:packages/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);
}
```
**尚无** `modelOverrides` 按 role/workflow 解析 agent 主模型的函数;builtin 首版可用 `config.defaultModel`,扩展时可加 `modelOverrides.agent` 或与 `agentOverrides` 对称的表。
#### chatCompletionText
```87:124:packages/util-agent/src/extract.ts
async function chatCompletionText(
provider: ResolvedLlmProvider,
messages: Array<{ role: "system" | "user"; content: string }>,
): Promise<string>
```
| 能力 | 现状 |
|------|------|
| 协议 | OpenAI 兼容 `POST /chat/completions` |
| Streaming | **无**(一次性 `response.text()`) |
| Tool calling | **无**(无 `tools` / `tool_calls` 字段) |
| 多模态 | **无**(仅 text `content`) |
| Extract 专用 | `response_format: { type: "json_object" }` |
builtin agent 的 run loop 需要**新写**带 `tools` 的 completion 客户端(可放在 `agent-builtin` 或扩展 `util-agent` 的 `llm/` 模块),不能复用当前 `chatCompletionText` 而不改。
---
### Q6: Hermes Agent 参考实现
`uwf-hermes` 是怎么实现 `run` 和 `continue` 的?
**调研要点:**
- prompt 怎么组装的(outputFormatInstruction + rolePrompt + task + history)
- hermes CLI 的调用参数
- session management(resume)
- 输出怎么捕获
**答案:**
#### Prompt 组装
```40:53:packages/agent-hermes/src/hermes.ts
export function buildHermesPrompt(ctx: AgentContext): string {
const roleDef = ctx.workflow.roles[ctx.role];
const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
const parts: string[] = [];
if (ctx.outputFormatInstruction !== "") {
parts.push(ctx.outputFormatInstruction, "");
}
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
const historyBlock = buildHistorySummary(ctx.steps);
if (historyBlock !== "") {
parts.push("", historyBlock);
}
return parts.join("\n");
}
```
`buildRolePrompt` 生成 `## Goal` / `## Capabilities` / `## Prepare`(含 `generateCliReference()`)/ `## Procedure` / `## Output`。
`buildHistorySummary`:每步 `role`、`JSON.stringify(step.output)`、`agent`。
Hermes 把**整段 prompt 作为单条 user 消息**传给 `hermes chat -q`(无独立 system channel)。
#### Hermes CLI 参数
首次:
```88:97:packages/agent-hermes/src/hermes.ts
spawnHermes(["chat", "-q", prompt, "--yolo", "--max-turns", "90", "--quiet"]);
```
续聊:
```100:114:packages/agent-hermes/src/hermes.ts
spawnHermes(["chat", "--resume", sessionId, "-q", message, "--yolo", "--max-turns", "90", "--quiet"]);
```
#### Session
- stdout/stderr 中解析 `session_id: <id>`(`parseSessionIdFromStdout`)
- 会话文件:`~/.hermes/sessions/session_<id>.json`
- `loadHermesSession` → `storeHermesSessionDetail`:每 assistant/tool 消息写成 CAS turn 节点,汇总为 `detail`;**output 文本** = 最后一条非空 `assistant` 的 `content`
#### 与 createAgent 的衔接
```157:164:packages/agent-hermes/src/hermes.ts
export function createHermesAgent(): () => Promise<void> {
return createAgent({ name: "hermes", run: runHermes, continue: continueHermes });
}
```
`uwf-hermes` 入口:`createHermesAgent()` 即 main。
Claude Code 包(`agent-claude-code`)结构相同:`buildClaudeCodePrompt` 同构,`claude -p` + `--resume` + JSON stdout 解析。
---
### Q7: Toolkit 需求分析
要实现一个自给自足的 agent,最少需要哪些 tool?
**调研要点:**
- 现有 workflow example(solve-issue.yaml)里 role 都做什么任务
- hermes agent 在 workflow 场景下常用哪些 tool
- 哪些 tool 是 agent loop 必须的(如 file read/write、shell exec、web fetch)
**答案:**
#### solve-issue.yaml 角色能力
| Role | capabilities | 隐含需求 |
|------|----------------|----------|
| planner | issue-analysis, planning | 读上下文/仓库、总结,通常不需写代码 |
| developer | file-edit, shell, testing | **读文件、写文件、执行命令** |
| reviewer | code-review, static-analysis | 读 diff/文件、静态分析(可读+可选 shell) |
#### Hermes 侧
Hermes 自带完整 agent runtime(`--yolo`、max-turns),tool 集由 Hermes 项目定义,workflow 不配置。从 session JSON 可见 `tool_calls` 被记入 detail,常见包括文件与 shell 类工具。
#### Builtin 最小 toolkit 建议
| 优先级 | Tool | 用途 |
|--------|------|------|
| P0 | `read_file` | 读仓库/配置/issue 上下文 |
| P0 | `write_file` / `edit_file` | developer 改代码 |
| P0 | `run_command` | 测试、构建、git(需 cwd + timeout + 输出截断) |
| P1 | `list_dir` / `glob` | 导航代码库 |
| P1 | `grep` | 搜索符号/引用 |
| P2 | `fetch_url` | 查文档(planner 偶尔需要) |
**不需要**在 builtin 里实现 moderator / workflow 路由工具——仍由 `uwf thread step` + status-based moderator 负责。
#### Agent loop 必须能力
1. 多轮 LLM 调用 + **OpenAI-style tool_calls** 解析与执行
2. 将 tool 结果 append 回 messages
3. 终止条件:模型不再请求 tool,或达到 `maxTurns`
4. 最终响应须含合法 YAML frontmatter(满足 Q4),供 `createAgent` fast-path
---
## 方案草案
(调研完成后基于以上答案撰写)
### 架构设计
```mermaid
flowchart TB
subgraph cli ["cli"]
Step["uwf thread step"]
Spawn["spawnAgent(uwf-builtin, threadId, role)"]
Step --> Spawn
end
subgraph builtin_pkg ["@united-workforce/agent-builtin"]
Main["createBuiltinAgent() = createAgent({...})"]
Prompt["buildBuiltinPrompt(ctx)"]
Loop["runBuiltinLoop(provider, messages, tools)"]
Tools["Toolkit: read/write/exec/..."]
Detail["storeBuiltinDetail(turns)"]
Main --> Prompt
Main --> Loop
Loop --> Tools
Loop --> Detail
end
subgraph kit ["util-agent"]
Ctx["buildContextWithMeta"]
FM["tryFrontmatterFastPath"]
Persist["persistStep"]
Ctx --> Main
Main --> FM
FM --> Persist
end
subgraph cas ["CAS / config"]
Config["config.yaml models/providers"]
CAS["cas/ + threads.yaml"]
end
Spawn --> Main
Config --> Loop
CAS --> Ctx
Persist --> CAS
Spawn -->|"stdout: step hash"| Step
```
**新包**:`packages/agent-builtin`,bin `uwf-builtin`,仅依赖 `util-agent`、`protocol`、`util`(可选 `@ocas/core` 写 detail schema)。
**分层**:
| 层 | 职责 |
|----|------|
| `createAgent`(kit) | argv、context、frontmatter extract、StepNode、stdout 协议 — **不变** |
| `builtin/agent.ts` | `run` / `continue` 实现 |
| `builtin/llm.ts` | OpenAI 兼容 chat + tools(可后续抽到 kit) |
| `builtin/tools/*.ts` | 各 tool 的 JSON Schema + handler |
| `builtin/prompt.ts` | 复用 Hermes 的 prompt 拼接逻辑(或抽到 kit 的 `buildAgentPrompt`) |
| `builtin/detail.ts` | 类似 Hermes:每轮 assistant/tool 写入 CAS detail |
**配置集成**:
```yaml
agents:
builtin:
command: "uwf-builtin"
args: []
defaultAgent: "builtin" # 或 agentOverrides 按 role 指定
```
模型:首版 `resolveModel(config, config.defaultModel)`;后续可增加 `modelOverrides.agent` 或 per-role 映射。
---
### Agent Run Loop
伪代码(单次 `run(ctx)`):
```
1. provider ← resolveModel(loadWorkflowConfig(), defaultModel)
2. system ← buildBuiltinPrompt(ctx) // outputFormatInstruction + buildRolePrompt + Task + History
3. messages ← [{ role: "system", content: system }]
4. sessionId ← newULID() // 内存或临时目录,供 continue 使用
5. turns ← []
6. for turn in 1..MAX_TURNS:
response ← chatCompletionWithTools(provider, messages, TOOL_DEFINITIONS)
record assistant message + tool_calls in turns
if response has no tool_calls:
finalText ← response.content
break
for each tool_call:
result ← executeTool(tool_call, { cwd: process.cwd() })
messages.push tool result
record in turns
7. if no finalText with valid frontmatter after loop:
optionally one-shot "finalize" message without tools
8. detailHash ← storeBuiltinDetail(store, sessionId, turns, metadata)
9. return { output: finalText, detailHash, sessionId }
```
**`continue(sessionId, message, store)`**:
- 从内存/磁盘恢复 `messages` + `turns`
- `messages.push({ role: "user", content: message })`(correction 或续聊)
- 从步骤 6 继续,步数上限可单独设小一点(如 3)
- 返回新的 `AgentRunResult`
**与 frontmatter 的配合**:
- system prompt 已含 `outputFormatInstruction`;最后一轮可强制 user:`Now output your final answer with YAML frontmatter only if you have not yet.`
- 仍依赖 `createAgent` 的 fast-path + 最多 2 次 continue
**安全**:
- `run_command`:白名单或需 `UWF_BUILTIN_ALLOW_SHELL=1`,默认工作区限定在 `process.cwd()` 或 `start` 中将来扩展的 `workspace` 字段
- 路径:禁止 `..` 逃逸出 workspace root
---
### Toolkit 设计
统一注册表:
```typescript
type BuiltinTool = {
name: string;
description: string;
parameters: JSONSchema; // object type
execute: (args: unknown, ctx: ToolContext) => Promise<string>;
};
type ToolContext = {
cwd: string;
storageRoot: string;
};
```
| Tool name | OpenAI function | 行为摘要 |
|-----------|-----------------|----------|
| `read_file` | `read_file` | `{ path }` → UTF-8 文本,大小上限 |
| `write_file` | `write_file` | `{ path, content }` → 写盘,返回确认 |
| `edit_file` | 可选 | search/replace 块,减少 token |
| `run_command` | `run_command` | `{ command, cwd? }` → stdout/stderr 截断 |
| `list_dir` | `list_dir` | `{ path }` → 条目列表 |
| `grep` | `grep` | `{ pattern, path? }` → 匹配行 |
**LLM 请求形状**(扩展 extract 客户端):
```json
{
"model": "...",
"messages": [...],
"tools": [{ "type": "function", "function": { "name", "description", "parameters" } }],
"tool_choice": "auto"
}
```
解析 `choices[0].message.tool_calls`,执行后以 `{ role: "tool", tool_call_id, content }` 回传。
**不提供** streaming 首版;detail CAS 记录每轮 tool 名/参数/结果摘要供 `uwf thread step-details` 调试。
---
### 与现有架构的集成
| 集成点 | 方式 |
|--------|------|
| CLI 协议 | 实现标准 agent CLI:`uwf-builtin <thread-id> <role>`,stdout 一行 step hash,exit 0/1 |
| 工厂 | `export function createBuiltinAgent()` → `createAgent({ name: "builtin", run, continue })` |
| Context / Prompt | 复用 `buildContextWithMeta`、`buildRolePrompt`、`buildOutputFormatInstruction`;prompt 布局对齐 `buildHermesPrompt` |
| 结构化输出 | 优先 YAML frontmatter fast-path;可选后续在 `createAgent` 增加 `extract()` fallback 开关 |
| 配置 | `config.yaml` 增加 `agents.builtin`;`uwf setup` 可选默认 agent |
| 存储 | `resolveStorageRoot()` + `loadWorkflowConfig` + `getEnvPath`;与 Hermes 相同,**不**改 `threads.yaml` 写入方 |
| 测试 | 单元测试:tool handlers、prompt 组装、mock LLM tool loop;集成测试:临时 storage root + fake provider |
| 发布 | 新包 `@united-workforce/agent-builtin`,bin `uwf-builtin`,加入 `scripts/publish-all.mjs` |
**明确不做**:
- 不替代 moderator / 不在 agent 内调用 `uwf thread step`
- 不依赖 Hermes/OpenClaw/Claude Code 二进制
- 首版不实现 streaming、不实现 MCP
**建议实现顺序**:
1. `llm.ts`:tool calling HTTP 客户端 + 单测
2. P0 tools + `runBuiltinLoop`
3. `createBuiltinAgent` + detail CAS
4. `config` / docs / `examples` 可选 `agentOverrides` 演示
5. (可选)`createAgent` 接入 `extract()` fallback
@@ -1,73 +0,0 @@
# Issue #418: ACP session/resume 返回空文本
## 调研日期: 2026-05-23
## 根因
`session/resume` 在 restore 路径下 `_make_agent()` 失败,异常被静默吞掉。
### 完整调用链
```
resume_session(sid)
→ update_cwd(sid)
→ get_session(sid) → _restore(sid)
→ _make_agent()
→ resolve_runtime_provider("custom") 失败(line 548-561)
→ AIAgent() 抛出 "No LLM provider configured"(line 564)
→ except Exception 静默吞掉(line 482-484)→ return None
→ return None
→ state is None → fallback: create_session()(新 sid,无历史)
```
### 关键代码位置(acp_adapter/session.py)
- `_restore()` line 426-498: 从 DB 恢复 session,但 except 太宽泛
- `_make_agent()` line 520-568: provider 解析在 restore 路径下不完整
- Line 548-561: `resolve_runtime_provider("custom")` 失败后,`base_url` 虽然从 DB 取到了但没传给 AIAgent
### 实测行为
1. Phase 1: `session/new` + `prompt` → 正常,有 `agent_message_chunk`
2. Phase 2: `session/resume` + `prompt`
- resume 返回成功,但 `available_commands_update` 里 sessionId 是新的(create_session fallback)
- 用原始 sid 发 prompt → `stopReason: "refusal"`(session 不在内存中)
- 用新 sid 发 prompt → 能跑但无历史(agent 回答"不知道 secret code")
### 验证脚本
```python
# 直接调用 _restore 验证
cd ~/.hermes/hermes-agent
python3 -c "
import sys; sys.path.insert(0, '.')
from acp_adapter.session import SessionManager
sm = SessionManager()
result = sm._restore('SESSION_ID_HERE')
print(result) # None — _make_agent 抛异常被吞掉
"
```
### 两个 bug
1. **`_make_agent` provider fallback 不完整**: restore 时 DB 里有 `base_url``api_mode`,但 `resolve_runtime_provider` 失败后这些值没被正确传递给 AIAgent
2. **`_restore` 的 except 太宽泛**: 静默吞掉所有异常,连 warning 都只在 debug 级别,导致 resume 失败完全无感知
### Hermes 版本
- v0.10.0 (2026.4.16) — 初始测试
- v0.14.0 (2026.5.16) — 更新后重新测试,bug 仍在
- 代码路径: ~/.hermes/hermes-agent/acp_adapter/session.py
### v0.14.0 测试结果 (2026-05-23)
- `_restore` 仍因 `custom` provider 解析失败返回 None
- 日志更清晰了:`WARNING: Failed to recreate agent for ACP session ...`
- resume fallback 创建新 session(新 sid),但 agent 居然能回答之前的问题(可能通过 memory/session search)
- 核心问题不变:sessionId 变了,client 用旧 sid 发 prompt → refusal
### 上游 Issue
- https://github.com/NousResearch/hermes-agent/issues/13489 — 已评论根因分析
- https://github.com/NousResearch/hermes-agent/issues/8083 — resume 静默创建新 session
- https://github.com/NousResearch/hermes-agent/issues/18452 — _make_agent fallback 不完整
-27
View File
@@ -1,27 +0,0 @@
---
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.
-317
View File
@@ -1,317 +0,0 @@
# Workflow-as-Agent Implementation Plan
> ⚠️ This plan references the pre-split package structure. File paths have changed.
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Enable workflows to invoke other workflows as agents, backed by global CAS and refs tracking.
**Architecture:** Migrate CAS from thread-local to global (`~/.uncaged/workflow/cas/`), add `refs` to RoleStep for GC traceability, then build `workflowAsAgent(name)` factory that resolves workflow name → bundle via registry and spawns a child thread.
**Tech Stack:** TypeScript, Bun, Zod v4, monorepo with `packages/`
**Issue:** https://git.shazhou.work/uncaged/workflow/issues/25
---
## Phase 1: Global CAS Migration
Move CAS storage from `<threadDir>/<threadId>.cas/` to `~/.uncaged/workflow/cas/` (global, content-addressed, immutable). This is a **breaking change** — thread-local `.cas/` directories are abandoned.
### Task 1.1: Add `globalCasDir` helper to `storage-root.ts`
**Objective:** Provide a single function that returns the global CAS directory path.
**Files:**
- Modify: `packages/workflow/src/storage-root.ts`
- Test: `packages/workflow/__tests__/storage-root.test.ts`
**Implementation:**
```typescript
// storage-root.ts — add export
export function getGlobalCasDir(storageRoot?: string): string {
const root = storageRoot ?? getDefaultWorkflowStorageRoot();
return join(root, "cas");
}
```
Export from `packages/workflow/src/index.ts`.
### Task 1.2: Update `cmd-cas.ts` to use global CAS
**Objective:** CLI `cas get/put/list/rm` no longer needs threadId for storage location — CAS is global. But keep threadId in CLI for backward compat of planner/coder prompts (they pass threadId).
**Files:**
- Modify: `packages/cli/src/cmd-cas.ts`
**Changes:**
- `resolveCasDir` → use `getGlobalCasDir(storageRoot)` instead of deriving from thread data path
- `cmdCasPut` / `cmdCasGet` / `cmdCasList` / `cmdCasRm`: threadId is still accepted (prompts pass it) but storage goes to global dir
- Remove the `resolveThreadDataPath` dependency for CAS operations — thread doesn't need to exist to read CAS
```typescript
import { createThreadCas, getGlobalCasDir } from "@uncaged/workflow";
export async function cmdCasGet(
storageRoot: string,
_threadId: string, // kept for CLI compat, not used for path
hash: string,
): Promise<Result<string, string>> {
const cas = createThreadCas(getGlobalCasDir(storageRoot));
const content = await cas.get(hash);
if (content === null) {
return err(`cas entry not found: ${hash}`);
}
return ok(content);
}
// ... same pattern for put/list/rm
```
### Task 1.3: Update `cmd-thread.ts` — thread rm no longer deletes `.cas/`
**Objective:** Since CAS is global, `thread rm` should NOT delete CAS entries. CAS cleanup is GC's job.
**Files:**
- Modify: `packages/cli/src/cmd-thread.ts`
- Check: remove any `rmdir` / `unlink` of `<threadId>.cas/` directory
### Task 1.4: Rename `createThreadCas` → `createCasStore`
**Objective:** The name `createThreadCas` is misleading now. Rename to `createCasStore`.
**Files:**
- Modify: `packages/workflow/src/cas.ts` — rename function
- Modify: `packages/workflow/src/index.ts` — update export (keep `createThreadCas` as deprecated alias for one release)
- Modify: all consumers (`cmd-cas.ts`)
### Task 1.5: Update tests
**Objective:** All CAS-related tests use global dir instead of thread-local.
**Files:**
- Modify: `packages/cli/__tests__/commands.test.ts`
- Verify: `bun test` passes
### Task 1.6: Clean up old thread-local `.cas/` references
**Objective:** Remove dead code that creates/reads thread-local `.cas/` directories.
**Files:**
- Search all `*.ts` for `.cas` path construction patterns
- Remove orphaned helpers
---
## Phase 2: RoleStep `refs` Tracking
Add `refs: string[]` to persisted role steps so GC can trace which CAS entries are alive.
### Task 2.1: Add `refs` to `RoleOutput` and engine persistence
**Objective:** Every role step can declare which CAS hashes it produced or consumed.
**Files:**
- Modify: `packages/workflow/src/types.ts`
- Modify: `packages/workflow/src/engine.ts`
**Changes to `types.ts`:**
```typescript
export type RoleOutput = {
role: string;
content: string;
meta: Record<string, unknown>;
refs: string[]; // CAS hashes produced/consumed by this step
};
```
**Changes to `engine.ts`:**
- `appendDataLine` for role steps: include `refs` field (default `[]` if not provided)
### Task 2.2: Auto-populate refs from meta hashes
**Objective:** The engine should automatically extract CAS hashes from `meta` to populate `refs`, so roles don't need to manually track them.
**Strategy:** After meta extraction, walk the meta object and collect any string that looks like a CAS hash (Crockford Base32, 13 chars). This is a heuristic but works because CAS hashes are distinctive.
Alternative (simpler): Let each `RoleDefinition` optionally declare a `extractRefs(meta: M) => string[]` function. For planner, this returns `meta.phases.map(p => p.hash)`. For coder, `[meta.completedPhase]`.
**Recommended:** The explicit `extractRefs` approach — no magic, no false positives.
**Files:**
- Modify: `packages/workflow/src/types.ts` — add optional `extractRefs` to `RoleDefinition`
- Modify: `packages/workflow/src/create-workflow.ts` — call `extractRefs` after meta extraction, set on `RoleOutput.refs`
- Modify: `packages/workflow-role-planner/src/planner.ts` — implement `extractRefs`
- Modify: `packages/workflow-role-coder/src/coder.ts` — implement `extractRefs`
```typescript
// types.ts — RoleDefinition addition
export type RoleDefinition<Meta extends Record<string, unknown>> = {
description: string;
systemPrompt: string;
extractPrompt: string;
schema: z.ZodType<Meta>;
extractRefs?: (meta: Meta) => string[]; // CAS hashes to track
};
// planner.ts
extractRefs: (meta) => meta.phases.map(p => p.hash),
// coder.ts
extractRefs: (meta) => [meta.completedPhase],
```
### Task 2.3: Update fork logic to preserve refs
**Objective:** When forking a thread, `refs` from historical steps must be carried over.
**Files:**
- Modify: `packages/workflow/src/fork-thread.ts`
- Verify: `ForkHistoricalStep` / `PrefilledDiskStep` include `refs`
### Task 2.4: Tests for refs tracking
**Files:**
- Add: `packages/workflow/__tests__/refs-tracking.test.ts`
- Verify: refs appear in `.data.jsonl` output
---
## Phase 3: CAS Garbage Collection
### Task 3.1: Implement `gc.ts` in `@uncaged/workflow`
**Objective:** Mark-and-sweep GC — scan all thread `.data.jsonl` files, collect `refs`, delete orphaned CAS entries.
**Files:**
- Create: `packages/workflow/src/gc.ts`
- Export from: `packages/workflow/src/index.ts`
```typescript
export type GcResult = {
scannedThreads: number;
activeRefs: number;
deletedEntries: number;
deletedHashes: string[];
};
export async function garbageCollectCas(storageRoot: string): Promise<GcResult> {
// 1. Find all .data.jsonl files under storageRoot
// 2. Parse each, flatMap step.refs → Set<string>
// 3. List all CAS entries via createCasStore(globalCasDir).list()
// 4. Delete entries not in active set
// 5. Return stats
}
```
### Task 3.2: Add `uncaged-workflow gc` CLI command
**Files:**
- Create: `packages/cli/src/cmd-gc.ts`
- Modify: `packages/cli/src/cli-dispatch.ts` — add `gc` subcommand
### Task 3.3: Run GC on `thread rm`
**Files:**
- Modify: `packages/cli/src/cmd-thread.ts` — after deleting thread data, optionally run GC
### Task 3.4: Tests for GC
**Files:**
- Create: `packages/cli/__tests__/gc-cli.test.ts`
---
## Phase 4: `workflowAsAgent` Factory
### Task 4.1: Create `workflowAsAgent` in `@uncaged/workflow`
**Objective:** Factory function that takes a workflow name, resolves to bundle, returns an `AgentFn`.
**Files:**
- Create: `packages/workflow/src/workflow-as-agent.ts`
- Export from: `packages/workflow/src/index.ts`
```typescript
import type { AgentFn } from "./types.js";
export type WorkflowAsAgentOptions = {
storageRoot?: string;
};
export function workflowAsAgent(
workflowName: string,
options?: WorkflowAsAgentOptions,
): AgentFn {
return async (ctx) => {
const storageRoot = options?.storageRoot ?? getDefaultWorkflowStorageRoot();
// 1. Read registry → resolve name to bundle hash + path
const registry = await readWorkflowRegistry(storageRoot);
const entry = getRegisteredWorkflow(registry, workflowName);
if (entry === null) {
return `ERROR: workflow "${workflowName}" not found in registry`;
}
// 2. Load bundle
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
const bundleExports = await extractBundleExports(bundlePath);
// 3. Create child thread input from ctx.start.content (parent prompt)
const input: ThreadInput = {
prompt: ctx.start.content,
steps: [],
};
// 4. Generate child threadId
const childThreadId = generateUlid();
// 5. Execute — collect all yields, return final content
const io: ExecuteThreadIo = { ... };
const result = await executeThread(bundleExports.run, workflowName, input, ...);
// 6. Return summary as agent content
return result.summary;
};
}
```
### Task 4.2: System-level depth limit
**Objective:** Prevent infinite recursion. Track depth via thread metadata, enforce a global max (default 3, configurable in `workflow.yaml`).
**Files:**
- Modify: `packages/workflow/src/types.ts` — add `depth` to `WorkflowFnOptions`
- Modify: `packages/workflow/src/workflow-as-agent.ts` — increment depth, check limit
- Modify: registry or config types for `maxDepth` setting
### Task 4.3: Tests for workflowAsAgent
**Files:**
- Create: `packages/workflow/__tests__/workflow-as-agent.test.ts`
- Test: name resolution, depth limit, child thread execution
### Task 4.4: Integration test — nested workflow
**Objective:** Create a minimal test workflow that calls another workflow via `workflowAsAgent`.
**Files:**
- Create: `packages/workflow/__tests__/workflow-as-agent-integration.test.ts`
---
## Execution Order
```
Phase 1 (Global CAS) → Phase 2 (refs) → Phase 3 (GC) → Phase 4 (workflowAsAgent)
```
Each phase is independently mergeable. Phase 3 depends on Phase 2 (needs refs to know what's alive). Phase 4 depends on Phase 1 (global CAS for cross-thread sharing).
## Breaking Changes
- CAS storage location moves from `<thread>.cas/` to `~/.uncaged/workflow/cas/`
- `RoleOutput` gains required `refs: string[]` field
- Existing threads with thread-local CAS will lose access to old CAS data (acceptable — those are short-lived workflow artifacts)
- `createThreadCas` renamed to `createCasStore` (alias kept temporarily)
-262
View File
@@ -1,262 +0,0 @@
# RFC: CAS-Based Thread Storage
> Status: Draft
> Author: 小橘 🍊(NEKO Team)
> Date: 2026-05-09
## Summary
Replace `.data.jsonl` with a fully CAS-based thread state chain. Threads become linked lists of immutable CAS nodes, indexed by a per-bundle `threads.json`.
## Motivation
`.data.jsonl` is a flat append-only file with three different row formats (start, role step, end). This makes forking expensive (copy file), deduplication impossible (forked threads repeat shared history), and GC complex (must parse every row to find CAS refs).
Threads are inherently immutable append-only sequences — a natural fit for CAS hash chains, similar to git's commit DAG.
## Design
### Node Types
Two CAS node types, using the existing `{ type, payload, refs }` CAS blob structure:
#### StartNode
Contains workflow-level parameters. **No threadId** (because the same StartNode can be shared across forks). Prompt is stored as a CAS blob and referenced via `refs[0]`.
```
CAS blob:
{
type: "start",
payload: {
name: "solve-issue",
hash: "BUNDLE_HASH",
maxRounds: 10,
depth: 0
},
refs: [
<prompt_hash> // refs[0]: initial task prompt (CAS blob)
]
}
```
- No `role`, `content`, `meta` — this is not a step, it's workflow metadata
- Prompt is **not** inline — it lives in CAS and is referenced by hash
#### StateNode
One per role step (including `__end__`).
```
CAS blob:
{
type: "state",
payload: {
role: "coder",
meta: { ... },
start: "<start_hash>",
content: "<content_merkle_hash>",
ancestors: ["<parent_hash>", "<grandparent_hash>", ...],
compact: null,
timestamp: 1234567890
},
refs: [<start_hash>, <content_hash>, <parent_hash>, ...]
}
```
**Payload is the source of truth.** Application code reads named fields from payload. `refs[]` is a **GC index** — automatically derived from payload by collecting all CAS hashes. GC only scans `refs[]` without understanding payload structure.
**Payload fields:**
| Field | Type | Meaning |
|-------|------|---------|
| `role` | `string` | Role name, or `"__end__"` for completion |
| `meta` | `object` | Structured metadata extracted from agent output |
| `start` | `string` | StartNode hash |
| `content` | `string` | Content Merkle node hash (carries role artifact refs) |
| `ancestors` | `string[]` | `[parent, grandparent, ...]` — up to 11 entries (1 parent + 10 skip-list). Empty for first step after start. `ancestors[0]` is the direct parent. |
| `compact` | `string \| null` | CAS hash of a compacted summary of all nodes before this one. When present, LLM context assembly can use this instead of walking the full chain. |
| `timestamp` | `number` | Unix timestamp in ms |
### Content Merkle Node
The content at `refs[2]` of each StateNode is itself a CAS Merkle node. This is where **role artifact references** live:
```
CAS blob:
{
type: "content",
payload: "<role output text>",
refs: [
<artifact_hash_1>, // e.g. a commit, a file, a sub-result
<artifact_hash_2>,
...
]
}
```
The Extractor is responsible for producing both `meta` and `refs` from raw agent output:
```
Agent raw output
Extractor → { meta, contentPayload, refs[] }
CAS put content Merkle: { type: "content", payload: contentPayload, refs }
↓ contentHash
StateNode: { ..., refs: [start, parent, contentHash, ...ancestors] }
```
This keeps StateNode refs fixed and simple. All role-specific artifact references are encapsulated in the content Merkle node. GC follows: `thread head → StateNode.refs → content Merkle.refs → artifacts`, full chain recursive.
### End Node
An end is just a StateNode with `role: "__end__"`:
```
{
type: "state",
payload: {
role: "__end__",
meta: { returnCode: 0, summary: "completed successfully" },
start: "<start_hash>",
content: "<content_hash>",
ancestors: ["<parent_hash>", ...],
compact: null,
timestamp: 1234567891
},
refs: [<start_hash>, <content_hash>, <parent_hash>, ...]
}
```
### Thread Index: `threads.json`
Per-bundle directory, one `threads.json` file. **Only active (in-progress) threads** live here:
```
~/.uncaged/workflow/bundles/<hash>/threads.json
```
```json
{
"01JTHREAD1AAAAAAAAAAAAAAA": {
"head": "<latest_state_node_hash>",
"start": "<start_node_hash>",
"updatedAt": 1234567891
}
}
```
When a thread completes (`__end__`), it is **removed from `threads.json`** and appended to a date-partitioned history file:
```
~/.uncaged/workflow/bundles/<hash>/history/{YYYY-MM-DD}.jsonl
```
Each line:
```json
{"threadId":"01JTHREAD1AAAAAAAAAAAAAAA","head":"<end_node_hash>","start":"<start_node_hash>","completedAt":1234567891}
```
Benefits:
- `threads.json` stays small — only in-flight threads
- Dashboard watches `threads.json` for real-time updates; completed threads don't trigger watches
- History is queryable by date but not actively monitored
- GC roots = all heads from `threads.json` + all heads from `history/*.jsonl`
### Ancestor Skip-List
Each StateNode carries up to 11 entries in `payload.ancestors` (1 parent + 10 skip-list, newest first):
```
Node 15: ancestors = [node14, node13, node12, node11, node10, node9, node8, node7, node6, node5, node4]
^parent ^--- skip-list (10 most recent) ---^
```
This enables:
- **Paginated fetch**: jump to any recent ancestor without walking the full chain
- **Partial replay**: fetch last N steps without loading the entire history
- The list is capped at 10 to keep node size bounded
### Fork
Forking a thread at step N:
1. Create new threadId
2. Create a new StateNode whose `parent` (refs[1]) points to the fork point's StateNode
3. Register the new threadId in `threads.json` with its own head
4. **Zero data duplication** — the forked thread shares all ancestor nodes via CAS
### Compact
When a StateNode has `payload.compact` set:
```json
{
"type": "state",
"payload": {
"role": "coder",
"meta": { ... },
"compact": "<cas_hash_of_summary>",
"timestamp": 1234
},
"refs": [...]
}
```
This means: "everything before this node has been summarized into the blob at `compact`". When building LLM context:
1. Walk back from head
2. If a node has `compact`, stop walking — use the compact summary + all nodes after it
3. If no compact found, use full chain
This enables long-running threads without unbounded context growth.
### GC
Simple mark-and-sweep:
1. **Roots**: all `head` and `start` hashes from `threads.json` + all `history/*.jsonl` files
2. **Mark**: from each root, recursively mark all reachable hashes via `refs[]` (including content Merkle → artifact refs)
3. **Sweep**: delete unmarked CAS blobs
No per-row format parsing needed. GC only needs to understand `refs[]`.
### refs[] Derivation
`refs[]` is auto-derived from payload at write time via a `collectRefs(payload)` function that extracts all CAS hash strings from named fields (`start`, `content`, `ancestors`, `compact`). Application code never reads `refs[]` — it reads named payload fields. This makes `refs[]` a pure GC optimization with zero semantic coupling.
### Extract Phase
The Extractor is expanded from the current design. Currently it only extracts `meta` from agent output. In the new design it extracts:
| Output | Purpose |
|--------|---------|
| `meta` | Structured metadata (same as before) |
| `contentPayload` | The text payload for the content Merkle node |
| `refs[]` | CAS hashes of artifacts produced by this role step |
The `refs[]` become the content Merkle node's refs, enabling GC to trace all role-produced artifacts.
## What Stays Unchanged
- `.info.jsonl` — debug logging stays as-is (high-frequency append, not suitable for CAS)
- CAS blob storage format (`~/.uncaged/workflow/cas/`)
- Bundle registry (`workflow.yaml`)
## Migration
Breaking change. Old `.data.jsonl` files become incompatible. No backward compat fallback (per project convention).
## Changes by Package
| Package | Changes |
|---------|---------|
| `protocol` | Replace `StartStep`, `RoleStep` types with `StartNode`, `StateNode`. Add `ContentMerkleNode` type. Expand `ExtractResult` to include `refs[]`. |
| `workflow-cas` | Add `findReachableHashes(roots)` for GC mark phase |
| `workflow-execute` | Rewrite engine to write CAS nodes + update `threads.json` instead of appending JSONL. Move completed threads to `history/`. Simplify `gc.ts`. Simplify `fork-thread.ts`. Expand extract phase to produce refs. |
| `workflow-runtime` | `ThreadContext` built by walking chain from head. `start.prompt` resolved from CAS via StartNode.refs[0]. |
| `cli` | `thread list/show/rm` read from `threads.json` + `history/`. SSE watches `threads.json`. |
| `dashboard` | Watch `threads.json` instead of `.data.jsonl` |
| Templates & Agents | Update extract definitions to produce `refs[]`. Update `ctx.start.content` → CAS resolved. |
-197
View File
@@ -1,197 +0,0 @@
# RFC: Merkle Call Stack — Cross-Thread DAG Linking
**Author:** 小橘 🍊(NEKO Team)
**Date:** 2026-05-11
**Status:** Draft
## Problem
`workflowAsAgent` 在父 workflow 中 spawn 子 workflow 时,父子 thread 之间没有任何 Merkle 链接:
1. **子 thread 不知道自己从哪来** — start node 只有 prompt hash,无法追溯父 thread 的上下文(preparer 分析出的 repoPath、conventions 等)
2. **父 thread 不知道子 thread 在哪** — developer role 的 state node 里只有 agent 返回的文本,child thread root hash 埋在字符串里,不是结构化 ref
3. **上下文传递靠序列化到 prompt** — 父 workflow 前置 role 的产出只能通过拼字符串传给子 workflow,丢失了 Merkle DAG 的可遍历性
## Proposal
在 CAS 节点中建立父子 thread 之间的 **双向 Merkle 链接**,形成调用栈结构。
### 新增字段
#### StartNodePayload(子 → 父)
```typescript
type StartNodePayload = {
name: string;
hash: string;
depth: number;
parentState: string | null; // NEW: 父 thread 调用时的 head state hash
};
```
`parentState` 指向子 workflow 被 spawn 时,父 thread 的最后一个 state node hash。这是"调用发生时的调用栈帧"。
#### StateNodePayload(父 → 子)
```typescript
type StateNodePayload = {
role: string;
meta: Record<string, unknown>;
start: string;
content: string;
ancestors: string[];
compact: string | null;
timestamp: number;
childThread: string | null; // NEW: 子 thread 最终 state hash(执行结果)
};
```
`childThread` 指向子 thread 完成后的**最终 state hash**(不是 start)——语义上是"函数返回值",从这里沿 ancestors 可回溯子 thread 的完整执行历史。
### refs 同步
新增的 hash 也必须放进 `refs[]`
- `StartNode.refs`: `[promptHash, parentState]`(parentState 非 null 时)
- `StateNode.refs`: `[...existingRefs, childThread]`(childThread 非 null 时)
原因:GC 的 `findReachableHashes` 只走 `refs`,不解析 payload 字段。字段提供语义,refs 保证可达性。
### 具体 DAG 结构
`solve-issue`(fix #191)为例,developer role 委托给 `develop` 子 workflow:
```
父 thread: solve-issue
═══════════════════════════════════════════════════════════
content("fix #191")
hash: ABCD1234
start(solve-issue)
hash: START001
payload: { name: "solve-issue", hash: BUNDLE_SI, depth: 0, parentState: null }
refs: [ABCD1234]
state(preparer)
hash: STATE_P1
payload: { role: "preparer", meta: { repoPath: "...", ... }, childThread: null, ... }
refs: [PREP_CONTENT]
state(developer) ──────── 父→子 ────────
hash: STATE_D1 │
payload: { role: "developer", meta: { ... }, childThread: ★CSTATE_END, ... }
refs: [DEV_CONTENT, ★CSTATE_END] │
state(submitter) │
hash: STATE_S1 │
payload: { role: "submitter", ..., childThread: null } │
子 thread: develop │
═══════════════════════════════════════════════════════════ │
content("fix #191") (CAS 去重,可能同 ABCD1234) │
hash: CPROMPT1 │
──────── 子→父 ──────── │
start(develop) │ │
hash: CHILD_START │ │
payload: { name: "develop", hash: BUNDLE_DEV, depth: 1, │
parentState: ★STATE_P1 } │ │
refs: [CPROMPT1, ★STATE_P1] │ │
│ │
state(planner) │ │
hash: CSTATE_1 │ │
... │ │
│ │
state(coder) │ │
hash: CSTATE_2 │ │
... │ │
│ │
state(reviewer) → state(tester) → state(committer) │
│ │
hash: ★CSTATE_END ◄─────────────────┼─────────────────────────┘
```
### 遍历路径
**子 thread agent 获取父上下文(上行):**
```
当前 step → start(CHILD_START)
→ refs[1] = STATE_P1(父 preparer 的 state)
→ payload.meta.repoPath = "/home/.../workflow"
→ refs → PREP_CONTENT(完整 preparer 输出)
→ payload.start = START001(父的 start node)
→ refs[0] = ABCD1234(原始 prompt)
```
**从父 thread 追踪子 thread 执行(下行):**
```
STATE_D1(父 developer state)
→ payload.childThread = CSTATE_END
→ 子 thread 最终 state
→ 沿 ancestors 回溯:committer → tester → reviewer → coder → planner
→ payload.start = CHILD_START(子 thread 入口)
```
**完整调用栈还原:**
```
任意节点 → 沿 start 找到所属 thread 的 StartNode
→ parentState 非 null?沿 parentState 进入父 thread
→ 递归直到 parentState = null(顶层 workflow)
```
## Implementation Plan
### Phase 1: Protocol + CAS 层
1. `protocol/src/cas-types.ts``StartNodePayload``parentState: string | null``StateNodePayload``childThread: string | null`
2. `workflow-cas/src/nodes.ts``putStartNode` 接受可选 `parentStateHash`,放入 refs;`putStateNode` 接受可选 `childThreadHash`,放入 refs
3. `workflow-cas/src/nodes.ts` — 解析逻辑兼容新字段(缺失时视为 null)
### Phase 2: Engine 层
4. `workflow-execute/src/engine/engine.ts``executeThread` 接受 `parentStateHash: string | null`,传给 `putStartNode`
5. `workflow-execute/src/workflow-as-agent.ts` — spawn 子 thread 时传入父 thread 当前 head state hash 作为 `parentStateHash`;子 thread 完成后返回最终 state hash
6. Engine 写 developer role 的 state node 时,把子 thread 最终 hash 写入 `childThread` 字段
### Phase 3: Agent 可观测性
7. Agent prompt 构建(`buildAgentPrompt`)— 当 start node 有 `parentState` 时,提示 agent 可通过 `cas get` 遍历父上下文
8. CLI `thread show` — 显示 parentState / childThread 链接关系
### Phase 4: 验证
9. 已有测试适配新字段(向后兼容,旧节点 parentState/childThread 为 null)
10. 新增集成测试:workflowAsAgent 场景下验证双向链接正确写入
## Design Decisions
### 为什么 childThread 指向 end 而不是 start?
- 语义是"函数返回值"——父 role 执行完才产出 state,此时子 thread 已跑完
- 从 end 沿 ancestors 可回溯到 start;反过来 start 写入时子 thread 还没跑完,无法知道 end
### 为什么 parentState 指向 state 而不是 start?
- 指向父 thread 调用点的**前一个 state**(即调用发生时的 head)
- 这是子 workflow 能看到的父上下文的"切面"——所有已完成的前置 role 都可达
- 如果是第一个 role 就 spawn 子 workflow(没有前置 state),parentState 指向父的 start node
### 为什么同时放字段和 refs?
- `refs[]` 服务于 GC(`findReachableHashes` 只遍历 refs)和通用 DAG 遍历
- `payload.parentState` / `payload.childThread` 服务于语义读取(明确知道哪个 ref 是什么)
- 不改 GC 逻辑,只加字段,GC 自然正确
### 向后兼容
- 新字段默认 `null`,旧节点解析时缺失字段视为 `null`
- 不影响已有 thread 的遍历和 GC
- `depth` 可通过沿 parentState 链上溯来交叉验证(数据自证)
## Open Questions
1. **多子 thread** — 如果一个 role 需要 spawn 多个子 workflow(目前不存在这个场景),`childThread` 应该改成 `childThreads: string[]` 还是保持单个?
2. **Agent prompt 注入深度** — 子 workflow 的 agent 应该自动遍历多少层父上下文?全部还是限制深度?
3. **CLI 展示**`thread show` 要不要递归展示整个调用栈,还是只显示直接链接?
@@ -1,224 +0,0 @@
# Dashboard Workflow Graph Visualization
**Issue**: #198
**Status**: In Progress
**Author**: xingyue
## Overview
在 Dashboard 的 ThreadDetail 页面中嵌入一个交互式流程图,将 workflow 的 `ModeratorTable` 可视化为有向图。用户可以一眼看到角色流转结构和当前执行进度。
## 数据层(✅ 已完成 — PR #201)
### WorkflowGraph 类型
`WorkflowDefinition.moderator`(函数)已替换为 `WorkflowDefinition.table`(声明式 `ModeratorTable`),`buildDescriptor` 自动从 table 提取 graph:
```ts
type WorkflowGraphEdge = {
from: string; // source role 或 "__start__"
to: string; // target role 或 "__end__"
condition: string; // condition.name 或 "FALLBACK"
conditionDescription: string | null;
};
type WorkflowGraph = {
edges: readonly WorkflowGraphEdge[];
};
type WorkflowDescriptor = {
description: string;
roles: Record<string, WorkflowRoleDescriptor>;
graph: WorkflowGraph; // 必填,新 bundle 自动生成
};
```
### 数据流
```
ModeratorTable (WorkflowDefinition.table)
→ buildDescriptor() 自动提取 graph
→ descriptor.yaml 持久化(hash.yaml)
→ CLI serve /workflows/:name API 返回 descriptor
→ Dashboard 前端拿到 graph
```
### 剩余数据层工作
**serve API 需要返回 descriptor**:当前 `GET /workflows/:name` 只返回 registry entry(hash + timestamp),不含 descriptor。需要从 `bundles/{hash}.yaml` 读取 descriptor 并返回给前端。
方案:在 `routes-workflow.ts``GET /workflows/:name` 响应中附带 `descriptor` 字段。或者:thread-detail 发现 workflow name 后,请求 `GET /workflows/:name/descriptor` 拿到 graph。
## 前端渲染
### 库选型:React Flow + dagre
| 库 | 优势 | 劣势 |
|---|---|---|
| **React Flow** ✅ | React 原生、自定义节点/边、dagre 自动布局、~50KB gzip | 需要学 API |
| Mermaid | 声明式简单 | 无交互、无法高亮当前步骤 |
| D3 | 完全控制 | 太底层,手撸成本高 |
| Cytoscape | 图论强 | React 集成差 |
**依赖新增**
```json
{
"@xyflow/react": "^12",
"@dagrejs/dagre": "^1"
}
```
### 图结构映射
```
WorkflowGraph.edges → React Flow nodes + edges
节点(自动从 edges 推导):
- __start__ → 圆形小节点(入口)
- role → 圆角矩形,显示 role name + description
- __end__ → 圆形小节点(终止)
边:
- FALLBACK → 虚线(dashed),无 label
- condition → 实线,label = condition
hover tooltip = conditionDescription
```
### 布局
使用 dagre 自动计算 TB(top-to-bottom)方向布局:
```ts
import Dagre from "@dagrejs/dagre";
function layoutGraph(nodes, edges) {
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 });
for (const node of nodes) {
g.setNode(node.id, { width: 180, height: 60 });
}
for (const edge of edges) {
g.setEdge(edge.source, edge.target);
}
Dagre.layout(g);
return nodes.map((node) => {
const pos = g.node(node.id);
return { ...node, position: { x: pos.x - 90, y: pos.y - 30 } };
});
}
```
### 运行时高亮
ThreadDetail 已有 `records: ThreadRecord[]`,其中 `RoleRecord.role` 就是当前/历史执行的 role。
高亮逻辑:
```ts
function getNodeStates(records: ThreadRecord[]): Map<string, "completed" | "active"> {
const states = new Map<string, "completed" | "active">();
const roleRecords = records.filter((r) => r.type === "role");
for (let i = 0; i < roleRecords.length; i++) {
const role = roleRecords[i].role;
states.set(role, i === roleRecords.length - 1 ? "active" : "completed");
}
// 如果有 workflow-result,最后一个 role 也是 completed
if (records.some((r) => r.type === "workflow-result")) {
for (const [k] of states) {
states.set(k, "completed");
}
states.set("__end__", "completed");
}
states.set("__start__", "completed");
return states;
}
```
节点样式:
| 状态 | 样式 |
|------|------|
| default | `border: var(--color-border)`, 暗色背景 |
| completed | `border: var(--color-success)`, 绿色边框 + ✓ 图标 |
| active | `border: var(--color-accent)`, 蓝色边框 + 脉冲动画 |
边高亮:当 source 和 target 都至少 completed 时,边变绿。
## 组件结构
```
dashboard/src/
components/
workflow-graph/
types.ts — NodeState 等前端类型
index.ts — export { WorkflowGraph }
workflow-graph.tsx — 主组件,React Flow canvas
role-node.tsx — 自定义 role 节点
terminal-node.tsx — START/END 圆形节点
condition-edge.tsx — 自定义边(虚线/实线 + label)
use-layout.ts — dagre 布局 hook
```
### 集成到 ThreadDetail
在 ThreadDetail 中,records 列表上方插入可折叠的图面板:
```tsx
// thread-detail.tsx
{graph && (
<div className="mb-4 border rounded-lg overflow-hidden" style={{ height: 300 }}>
<WorkflowGraph graph={graph} nodeStates={getNodeStates(records)} />
</div>
)}
```
图高度固定 300px,React Flow 支持 pan + zoom,不影响下方 records 滚动。
## 实施计划
### ~~Phase 0: 数据层~~ ✅ Done (PR #201)
- [x] `WorkflowDefinition.moderator``table` (ModeratorTable)
- [x] `WorkflowDescriptor` 新增 `graph: WorkflowGraph`
- [x] `buildDescriptor` 自动提取 graph
- [x] `validateWorkflowDescriptor` 校验 graph
### Phase 1: API + 静态图渲染
1. serve API:`GET /workflows/:name` 返回 descriptor(含 graph),或新增 `GET /workflows/:name/descriptor`
2. Dashboard `api.ts` 新增 `getWorkflowDescriptor(agent, name)` 函数
3. 安装 `@xyflow/react` + `@dagrejs/dagre`
4. 实现 `workflow-graph/` 组件集
5. ThreadDetail 中集成:从 thread-start record 拿 workflow name → 请求 descriptor → 渲染图
**产出**:打开 ThreadDetail 看到 workflow 流程图,无高亮。
### Phase 2: 运行时高亮
1. ThreadDetail 根据 records 计算 nodeStates
2. 节点/边样式响应状态变化
3. SSE live 模式下实时更新高亮
**产出**:正在运行的 thread 能看到当前执行到哪个 role。
### Phase 3: 交互增强
1. 点击节点滚动到对应 role 的 RecordCard
2. 边 hover 显示 conditionDescription tooltip
3. 节点 hover 显示 role description + schema summary
**产出**:图和记录列表联动。
## 注意事项
- **自循环边**:如 `coder → coder (FALLBACK)`,React Flow 支持自循环,dagre 需要特殊处理(self-edge 用 loop 路径)
- **大图性能**:dagre 在 <50 节点时性能无忧,workflow 通常 <10 个 role
- **暗色主题**:Dashboard 已使用 CSS variables,节点/边样式复用现有色板
- **不提交 pnpm-lock.yaml**
-191
View File
@@ -1,191 +0,0 @@
# workflow-agent-react — ReAct Agent Package
**Status**: RFC v3
**Author**: 小橘 🍊
## Problem
现有的 agent 包都依赖外部 CLI 进程:
| Package | 机制 | 能力 |
|---------|------|------|
| `agent-hermes` | spawn `hermes chat` | 完整工具链(文件、终端、浏览器…) |
| `workflow-agent-cursor` | spawn `cursor-agent` | IDE 级别代码编辑 |
| `workflow-agent-llm` | 单轮 chat completion | 纯文本,无工具 |
缺少一个 **内置 ReAct agent**:用 LLM + tool calling 循环执行任务,不依赖外部 CLI,工具集由调用方注入。
## 核心设计变更:AdapterFn 替代 AgentFn
### 现状的问题
当前 `AgentFn` 返回 `string`,engine 再用额外一轮 LLM 调用 extract meta:
```
Agent(ctx) → string → Extract(string, schema) → meta // 浪费一轮 LLM
```
### 新抽象:AdapterFn
```typescript
type RoleFn<T> = (ctx: ThreadContext) => Promise<T>;
type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
```
- **`prompt`** — role 的 system prompt,描述角色职责和输出要求
- **`schema`** — role 的 meta schema,定义输出格式
- **`ThreadContext`** — threadId, depth, bundleHash, start, steps
prompt 和 schema 是一对:prompt 说"你要输出什么",schema 定义"输出的格式"。它们属于 role definition,由 `createWorkflow` 在每个 role 执行时传给 adapter。
### AgentContext 不再需要
`AgentContext``ThreadContext` 上扩展了 `currentRole: { name, systemPrompt }`。prompt 现在直接传给 adapter,`AgentContext` 可以删除。
### createWorkflow 签名变更
```typescript
// Before
type AgentBinding = {
agent: AgentFn;
overrides: Partial<Record<string, AgentFn>> | null;
};
// After
type AdapterBinding = {
adapter: AdapterFn;
overrides: Partial<Record<string, AdapterFn>> | null;
};
```
engine 对每个 role 的执行逻辑:
```typescript
// Before
const result = await agent({ ...threadCtx, currentRole: { name, systemPrompt } });
const meta = await extract(result, role.metaSchema, provider); // 额外一轮 LLM
// After
const roleFn = adapter(role.systemPrompt, role.metaSchema);
const meta = await roleFn(threadCtx); // 直接拿到类型安全的 T
```
## `createReactAdapter` — 复用 workflow-reactor
AdapterFn 的终止条件是"拿到符合 schema 的 T"——和 `workflow-reactor``ThreadReactorFn` 完全一致。因此 react adapter 是对 reactor 的**薄包装**,不需要自己实现 ReAct 循环。
```typescript
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
import type { ThreadContext, LlmProvider } from "@uncaged/protocol";
import type { ToolDefinition } from "@uncaged/workflow-reactor";
type ReactToolHandler = (name: string, args: string) => Promise<string>;
type ReactAdapterConfig = {
provider: LlmProvider;
tools: readonly ToolDefinition[];
toolHandler: ReactToolHandler;
maxRounds: number;
};
function createReactAdapter(config: ReactAdapterConfig): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>) => {
const reactor = createThreadReactor<ThreadContext>({
llm: createLlmFn(config.provider),
staticTools: config.tools,
structuredToolFromSchema: (s) => buildStructuredTool(s),
systemPromptForStructuredTool: () => prompt,
toolHandler: (call, ctx) =>
config.toolHandler(call.function.name, call.function.arguments),
maxRounds: config.maxRounds,
});
return async (ctx: ThreadContext): Promise<T> => {
const input = buildThreadInput(ctx);
const result = await reactor({ thread: ctx, input, schema });
if (!result.ok) throw new Error(result.error);
return result.value;
};
};
}
```
整个包就是:**一个工厂函数 + 类型定义 + thread 输入构造**。
## `agentToAdapter` — 向后兼容
把现有 `AgentFn`(hermes/cursor)包装成 `AdapterFn`
```typescript
function agentToAdapter(agent: AgentFn, extractProvider: LlmProvider): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>): RoleFn<T> => {
return async (ctx: ThreadContext): Promise<T> => {
const agentCtx = { ...ctx, currentRole: { name: "agent", systemPrompt: prompt } };
const result = await agent(agentCtx);
const output = typeof result === "string" ? result : result.output;
return extract(output, schema, extractProvider);
};
};
}
```
hermes/cursor agent 内部不改,bundle-entry 层多包一层即可。
## 包结构
```
packages/workflow-agent-react/
src/
types.ts # ReactAdapterConfig, ReactToolHandler
create-react-adapter.ts # AdapterFn 工厂(包装 reactor)
thread-input.ts # ThreadContext → user message string
index.ts
__tests__/
create-react-adapter.test.ts
package.json
```
依赖:
- `@uncaged/protocol``ThreadContext`, `LlmProvider`
- `@uncaged/workflow-reactor``createLlmFn`, `createThreadReactor`, types
## 影响范围
### Breaking Changes
| 改动 | 影响 |
|------|------|
| `AgentBinding``AdapterBinding` | `createWorkflow` 调用方(所有 bundle-entry) |
| `AgentContext` 删除 | `buildAgentPrompt`(util-agent)改为接收 `ThreadContext` |
| extract 从 engine 下沉到 adapter | `workflow-execute` 简化 |
### 需修改的包
1. `protocol` — 删除 `AgentContext`/`AgentFn`/`AgentFnResult`/`AgentBinding`,新增 `AdapterFn`/`RoleFn`/`AdapterBinding`
2. `workflow-runtime` — 更新 re-export
3. `workflow-execute` — engine 调用 `adapter(prompt, schema)` 替代 `agent(ctx) + extract`
4. `util-agent``buildAgentPrompt``buildThreadInput`,接收 `ThreadContext`
5. 所有 bundle-entry — `agent:``adapter:`
### 不受影响
- `workflow-cas` / `workflow-register` / `workflow-reactor` / `dashboard`
- `agent-hermes` / `workflow-agent-cursor`(内部不改,外部用 `agentToAdapter` 包装)
## Phases
1. **Phase 1**: protocol 类型 + `createWorkflow` 签名变更 + `agentToAdapter`
2. **Phase 2**: `workflow-agent-react` 包(包装 reactor)
3. **Phase 3**: 工具集实现(read/write/patch/shell) + smoke test 闭环
## 工具集(后续讨论)
| 工具 | 说明 | 优先级 |
|------|------|--------|
| `read_file` | 读文件 | P0 |
| `write_file` | 写文件 | P0 |
| `patch_file` | find-and-replace 编辑 | P0 |
| `shell_exec` | 执行 shell 命令 | P0 |
| `search_files` | grep / find | P1 |
| `list_files` | ls | P1 |
File diff suppressed because it is too large Load Diff
@@ -1,387 +0,0 @@
# 设计文档:office-agent 文档生成/编辑 Workflow 体系
**日期:** 2026-05-18
---
## 概述
在 monorepo 中新增三个包,实现通过 `office-agent` CLI 生成或编辑 Word 文档的完整 workflow 体系。
| 包 | npm name | 职责 |
|---|---|---|
| `workflow-template-document` | `@uncaged/workflow-template-document` | 纯结构:角色定义、meta schema、调度表、descriptor |
| `workflow-agent-office` | `@uncaged/workflow-agent-office` | writer 角色执行器:调用 `office-agent` CLI |
| `workflow-agent-docx-diff` | `@uncaged/workflow-agent-docx-diff` | differ 角色执行器:调用 `docx-diff` CLI |
Template 只定义结构,不含执行逻辑。执行器与 template 解耦。
---
## 一、`workflow-template-document`
### Thread 启动输入
```typescript
// src/types.ts
type DocumentStartInput = {
prompt: string; // 用户指令
inputDocx: string | null; // null = 生成模式;本机绝对路径 = 编辑模式
};
```
start.content 为 JSON `{ prompt, inputDocx }` 或纯文本(fallback:generate 模式,整段作为 prompt)。
### 角色与 Meta
`WriterMeta` 使用 discriminated union,在 schema 层区分两种模式:
```typescript
const writerMetaSchema = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("generate"),
outputDocx: z.string(), // 生成产物绝对路径
sourceDocx: z.null(),
}),
z.object({
mode: z.literal("edit"),
outputDocx: z.string(), // 修改后产物:<outputDir>/modified.docx
sourceDocx: z.string(), // 原始副本:<outputDir>/original.docx
}),
]);
type WriterMeta = z.infer<typeof writerMetaSchema>;
// differ:仅编辑模式执行
const differMetaSchema = z.object({
sourceDocx: z.string(),
modifiedDocx: z.string(),
diffDocx: z.string(),
});
type DifferMeta = z.infer<typeof differMetaSchema>;
```
两个角色的 `systemPrompt` 均为 `""`
### 调度表
```
START → writer ──(mode = "edit")──→ differ → END
↘(mode = "generate")→ END
```
### 公开导出
template 导出两个对象供消费方使用:
- `documentWorkflowDefinition: WorkflowDefinition<DocumentMeta>` — 传入 `createWorkflow``def` 参数
- `buildDocumentDescriptor(): WorkflowDescriptor` — bundle 导出用
```typescript
// bundle 侧用法
export const descriptor = buildDocumentDescriptor();
export const run = createWorkflow(documentWorkflowDefinition, { adapter, overrides });
```
### 包文件结构
```
packages/workflow-template-document/
src/
types.ts # DocumentStartInput
roles/
writer.ts # writerMetaSchema, WriterMeta, writerRole
differ.ts # differMetaSchema, DifferMeta, differRole
index.ts
roles.ts # DocumentMeta, documentRoles
moderator.ts # writerIsEditMode condition + documentTable
definition.ts # documentWorkflowDefinition
descriptor.ts # buildDocumentDescriptor()
index.ts
__tests__/
moderator.test.ts
package.json
tsconfig.json
```
### 依赖
```json
{
"@uncaged/protocol": "workspace:^",
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-register": "workspace:^",
"zod": "^4.0.0"
}
```
---
## 二、`workflow-agent-office`
### office-agent CLI 接口
```bash
# 生成模式:在 CWD 生成 output.docx
office-agent create "<prompt>" -o output.docx
# 编辑模式:在 CWD 对 modified.docx 进行修改(覆写)
office-agent edit modified.docx "<instruction>"
```
- 两个命令均为阻塞调用(CLI 内部消费 SSE,退出即完成)
- 输出文件落到调用方设定的 CWD
- 退出码 0 = 成功,非零 = 失败
### 文件命名约定
| 模式 | 文件 | 路径 |
|---|---|---|
| generate | 输出 | `<outputDir>/output.docx` |
| edit | 原始副本(workflow-owned 快照) | `<outputDir>/original.docx` |
| edit | 修改后产物 | `<outputDir>/modified.docx` |
edit 模式先将 `inputDocx` 复制为 `original.docx`(不可变快照),再复制为 `modified.docx`,对 `modified.docx` 调用 CLI。agent 覆写 `modified.docx``original.docx` 保持不变。differ 对比这两个 workflow-owned 文件,不依赖用户原始路径。
### 执行流程
**生成模式(`inputDocx = null`):**
1. `mkdir -p <outputDir>``<config.outputDir>/<ctx.threadId>`
2. `const command = config.command ?? "office-agent"`
3. `spawnCli(command, ["create", prompt, "-o", "output.docx"], { cwd: outputDir, timeoutMs })`
4. 验证 `outputDir/output.docx` 存在
5. 返回 `JSON.stringify({ mode: "generate", outputDocx, sourceDocx: null })`
**编辑模式(`inputDocx ≠ null`):**
1. `mkdir -p <outputDir>`
2. `copyFile(inputDocx, <outputDir>/original.docx)`
3. `copyFile(inputDocx, <outputDir>/modified.docx)`
4. `const command = config.command ?? "office-agent"`
5. `spawnCli(command, ["edit", "modified.docx", prompt], { cwd: outputDir, timeoutMs })`
6. 验证 `outputDir/modified.docx` 存在
7. 返回 `JSON.stringify({ mode: "edit", outputDocx: modifiedPath, sourceDocx: originalPath })`
### AdapterFn 实现(直接实现,不经过 runtime.extract)
CLI 产出确定性 JSON,直接 `schema.parse(JSON.parse(raw))` 跳过 LLM extraction:
```typescript
export function createOfficeAgent(config: OfficeAgentConfig): AdapterFn {
return <T>(_systemPrompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const { prompt, inputDocx } = parseStartInput(ctx.start.content);
const raw = await runOfficeAgent(config, ctx.threadId, prompt, inputDocx);
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}
```
`_systemPrompt` 为 writer 角色的 systemPrompt(空字符串),实际指令从 `ctx.start.content` 解析。
### 配置
```typescript
type OfficeAgentConfig = {
outputDir: string; // 输出根目录,runner 在此下按 threadId 建子目录
command: string | null; // null → runner 内 resolve 为 "office-agent"
timeout: number | null; // null → 不设超时;单位 ms
};
```
### 错误处理
```typescript
if (!result.ok) {
const e = result.error;
if (e.kind === "non_zero_exit")
throw new Error(`office-agent failed (exit ${e.exitCode}): ${e.stderr}`);
if (e.kind === "timeout")
throw new Error("office-agent: timed out");
// "spawn_failed"
throw new Error(`office-agent: spawn failed: ${e.message}`);
}
if (!existsSync(expectedPath))
throw new Error(`office-agent: output file not found: ${expectedPath}`);
```
### packageDescriptor
```typescript
// src/package-descriptor.ts
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-office",
version: "0.1.0",
capabilities: ["office-agent-cli", "docx-generate", "docx-edit"],
configSchema: {
type: "object",
required: ["outputDir"],
properties: {
outputDir: { type: "string", description: "Root directory for workflow outputs." },
command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to office-agent CLI; null uses PATH." },
timeout: { anyOf: [{ type: "number" }, { type: "null" }], description: "Timeout in ms; null means no limit." },
},
additionalProperties: false,
},
};
```
### 包文件结构
```
packages/workflow-agent-office/
src/
types.ts # OfficeAgentConfig, OfficeAgentOpt
runner.ts # runOfficeAgent()(spawnCli 封装 + 文件验证)
agent.ts # createOfficeAgent(): AdapterFn
package-descriptor.ts # packageDescriptor
index.ts
__tests__/
runner.test.ts
agent.test.ts
package.json
tsconfig.json
```
### 依赖
```json
{
"@uncaged/protocol": "workspace:^",
"@uncaged/util": "workspace:^",
"@uncaged/util-agent": "workspace:^"
}
```
---
## 三、`workflow-agent-docx-diff`
`differ` 角色专用执行器。从 `ctx.steps` 读取 `WriterMeta`,调用本地 `docx-diff` CLI。
### docx-diff 退出码约定
| 退出码 | 含义 | runner 处理 |
|---|---|---|
| 0 | 无差异 | 正常,验证 diffDocx 存在 |
| 1 | 有差异 | 正常(显式处理为成功),验证 diffDocx 存在 |
| 2+ | 错误 | throw |
runner 收到 `SpawnCliError { kind: "non_zero_exit", exitCode: 1 }` 时视为成功,验证文件后继续;`exitCode >= 2` 才 throw。
### 执行流程
```
1. 从 ctx.steps 找到 writer 步骤,读取 WriterMeta
2. 验证 mode === "edit"(否则 throw)
3. diffDocx = join(dirname(writer.outputDocx), "diff.docx")
4. const command = config.command ?? "docx-diff"
5. spawnCli(command,
[writer.sourceDocx, writer.outputDocx, "--output", "docx", "--out-file", diffDocx],
{ cwd: null, timeoutMs: null })
exit 0 或 1 → 验证 diffDocx 存在
exit 2+ → throw
6. 返回 JSON.stringify({ sourceDocx, modifiedDocx: writer.outputDocx, diffDocx })
```
### AdapterFn 实现(直接实现,不经过 runtime.extract)
```typescript
export function createDocxDiffAgent(config: DocxDiffAgentConfig = { command: null }): AdapterFn {
return <T>(_prompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const writerStep = ctx.steps.find(s => s.role === "writer");
if (!writerStep) throw new Error("differ: no writer step found");
const writerMeta = writerStep.meta as WriterMeta;
if (writerMeta.mode !== "edit")
throw new Error("differ: writer did not run in edit mode");
const raw = await runDocxDiff(config, writerMeta);
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}
```
### 配置
```typescript
type DocxDiffAgentConfig = {
command: string | null; // null → runner 内 resolve 为 "docx-diff"
};
```
### packageDescriptor
```typescript
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-docx-diff",
version: "0.1.0",
capabilities: ["docx-diff-cli", "docx-diff-report"],
configSchema: {
type: "object",
properties: {
command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to docx-diff CLI; null uses PATH." },
},
additionalProperties: false,
},
};
```
### 包文件结构
```
packages/workflow-agent-docx-diff/
src/
types.ts # DocxDiffAgentConfig
runner.ts # runDocxDiff()(exit 1 处理 + 文件验证)
agent.ts # createDocxDiffAgent(): AdapterFn
package-descriptor.ts # packageDescriptor
index.ts
__tests__/
runner.test.ts
agent.test.ts
package.json
tsconfig.json
```
### 依赖
```json
{
"@uncaged/protocol": "workspace:^",
"@uncaged/util-agent": "workspace:^",
"@uncaged/workflow-template-document": "workspace:^"
}
```
---
## 四、外部 bundle(外部 workspace 消费)
```typescript
import { createOfficeAgent } from "@uncaged/workflow-agent-office";
import { createDocxDiffAgent } from "@uncaged/workflow-agent-docx-diff";
import {
buildDocumentDescriptor,
documentWorkflowDefinition,
} from "@uncaged/workflow-template-document";
import { createWorkflow } from "@uncaged/workflow-runtime";
import { getDefaultWorkflowStorageRoot } from "@uncaged/util";
import { join } from "node:path";
const outputDir = join(getDefaultWorkflowStorageRoot(), "outputs");
export const descriptor = buildDocumentDescriptor();
export const run = createWorkflow(documentWorkflowDefinition, {
adapter: createOfficeAgent({ outputDir, command: null, timeout: null }),
overrides: { differ: createDocxDiffAgent() },
});
```
---
## 不在范围内
- 重试逻辑(失败直接 throw)
- office-agent server 的启停管理(假设 server 已在运行)
- docx-diff HTML/terminal 格式输出(仅 docx)
- 跨机器执行(`inputDocx` 须为本机有效绝对路径)
-67
View File
@@ -1,67 +0,0 @@
# 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 README
7. **Development** — pnpm 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** — pnpm 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
- pnpm 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
-517
View File
@@ -1,517 +0,0 @@
# `uwf` — Stateless Workflow CLI
> 将 workflow 引擎降维为无状态单步 CLI。Workflow 是纯数据(CAS 节点),执行是单步原子操作,agent 是可插拔外部命令。
---
## 1. CLI Design
### 1.1 命令总览
```
# thread 组
uwf thread start <workflow> -p <prompt> # 创建 thread,不执行
uwf thread step <thread-id> [--agent] # 单步执行
uwf thread show <thread-id> # thread-id → head 查询
uwf thread list [--all] # 列出活跃 threads(--all 含已归档)
uwf thread kill <thread-id> # 终结 thread,归档
# workflow 组
uwf workflow put <file.yaml> # 注册 workflow(YAML → CAS)
uwf workflow show <workflow-id> # 查看 workflow 定义
uwf workflow list # 列出已注册 workflows
```
两组对称,各 3-4 个子命令。CAS 操作交给 `ocas` CLI,不在 `uwf` 中重复。
### 1.2 `uwf thread start`
```bash
uwf thread start <workflow> -p "Fix the login bug described in issue #42"
```
- `<workflow>` — workflow 名或 CAS hash
- `-p` — 用户 prompt(必填)
**输出(JSON to stdout):**
```jsonc
{
"workflow": "4KNM2PXR3B1QW", // workflow CAS hash (XXH64, 13-char Crockford Base32)
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T" // ULID
}
```
**做的事:**
1. 解析 workflow(名字查 registry → CAS hash)
2. 生成 thread ULID
3. 写 StartNode 到 CAS
4. 在 threads.yaml 中记录链头 → StartNode hash
5. 输出 JSON
### 1.3 `uwf thread step`
```bash
uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T
uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T --agent "bunx uwf-cursor"
```
**输出(JSON to stdout):**
```jsonc
{
"workflow": "4KNM2PXR3B1QW",
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T",
"head": "8FWKR3TN5V1QA", // 新链头 StepNode 的 CAS hash
"done": false // true = moderator 返回 END,thread 已归档
}
```
`done: true` 时 head 仍然有值(最后一个 StepNode),但 thread 已从 threads.yaml 移除。
对已结束或不存在的 thread 调用 step 会报错(非 active thread)。
详细信息通过 `uwf thread show <thread-id>``json-cas get <head>` 查看。
**做的事:**
1. 读链头 → 当前 StepNode(或 StartNode)
2. 收集 thread 历史(遍历链)
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
7. 更新链头指针
8. 再次调 moderator(基于新 StepNode)判断 done
9. 输出 JSON
### 1.4 `uwf thread show`
```bash
uwf thread show 01J7K9M2XNPQR5VWBCDF8G3H4T
```
**输出(JSON to stdout):**
```jsonc
{
"workflow": "4KNM2PXR3B1QW",
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T",
"head": "8FWKR3TN5V1QA",
"done": false
}
```
纯 thread-id → head 查询。详细内容用 `json-cas get <head>``json-cas walk <head>` 查看。
### 1.5 Agent CLI 协议
每个 agent 是一个命令,接受 thread-id 和 role 两个参数:
```bash
uwf-hermes <thread-id> <role>
```
**约定:**
- `uwf step` 负责 moderator 决策,将 role 传给 agent CLI
- agent-kit 根据 thread + role 从 CAS 读 goal / capabilities / procedure / output / meta
- agent-kit 组装完整 prompt(role goal/capabilities/procedure/output + thread context + user prompt from StartNode)
- agent 执行实际逻辑,agent-kit 负责 extract
- agent 将 StepNode 写入 CAS(含 output、detail、agent、prev),但**不挪链头指针**
- stdout 输出新 StepNode 的 CAS hash(纯文本,一行)
- 所有配置从环境变量读(LLM model、API key、extractor config)
- exit 0 = 成功,非 0 = 失败
**stdout 输出:**
```
8FWKR3TN5V1QA
```
`uwf step` 拿到这个 hash 后更新链头指针、判断 done。
---
## 2. CAS 结构定义
### 2.1 类型层级
沿用 json-cas 的三层:bootstrap meta-schema → JSON Schema nodes → data nodes。
下面所有 CAS 节点都遵循 `{ type: ocas_ref, payload: T, timestamp: number }` 的标准格式。
`ocas_ref` 类型的字符串字段在 ocas 中已内置支持,不需要额外的 `$ref` 包装。
### 2.2 数据节点
#### `Workflow`
Roles 和 moderator 内联在 Workflow 中,只有 meta 独立为 CAS 节点(方便 ocas 校验)。
```yaml
type: <workflow-schema-hash>
payload:
name: "solve-issue"
description: "End-to-end issue resolution"
roles:
planner:
description: "Creates implementation plan"
goal: "You are a planning agent..."
capabilities: [planning, issue-analysis]
procedure: "Analyze the issue and create a plan."
output: "Output the plan summary."
meta: "5GWKR8TN1V3JA" # ocas_ref → JSON Schema 节点(ocas 内置)
developer:
description: "Implements code changes"
goal: "You are a developer agent..."
capabilities: [file-edit, shell]
procedure: "Implement the plan."
output: "List all files changed."
meta: "8CNWT4KR6D1HV" # ocas_ref → JSON Schema 节点
reviewer:
description: "Reviews code changes"
goal: "You are a code reviewer..."
capabilities: [code-review]
procedure: "Review the implementation."
output: "Approve or reject with comments."
meta: "1VPBG9SM5E7WK" # ocas_ref → JSON Schema 节点
conditions:
needsClarification:
description: "Planner requests clarification from user"
expression: "$exists(steps[-1].output.needsClarification)"
notApproved:
description: "Reviewer rejected the implementation"
expression: "steps[-1].output.approved = false"
graph:
$START:
- role: "planner"
condition: null # 无条件(fallback)
planner:
- role: "developer"
condition: "needsClarification"
- role: "$END"
condition: null
developer:
- role: "reviewer"
condition: null
reviewer:
- role: "developer"
condition: "notApproved"
- role: "$END"
condition: null
```
- `roles` — 内联定义,每个 role 的 `meta` 是独立的 ocas_ref(指向 ocas 内置 JSON Schema 节点)
- `graph``Record<Role | "$START", Record<Status, Target>>`,每个 Target = `{ role, prompt }`
- Status 来自上一个 role 输出的 `$status` 字段,`$START` 使用 `new`(首次启动)和 `resume`(恢复已完成的 thread)作为 status
- Prompt 模板使用 Mustache 渲染,变量来自 lastOutput
- 不含 agent binding — agent 配置在 `~/.uwf/config.yaml` 中管理
Moderator 的求值逻辑:
```typescript
evaluate(graph, lastRole, lastOutput) { role, prompt }
// 1. status = lastOutput.$status (e.g. "new" for $START first run, "resume" for completed thread resume)
// 2. target = graph[lastRole][status]
// 3. prompt = mustache.render(target.prompt, lastOutput)
```
注:routing 基于 `lastOutput.status` 字段的值,直接在 graph map 中查找对应的 Target。
#### `StartNode`(Thread 起点)
```yaml
type: <start-node-schema-hash>
payload:
workflow: "4KNM2PXR3B1QW" # ocas_ref → Workflow
prompt: "Fix the login bug..."
```
- 没有 thread-id — thread-id 是索引层面的事,不进 CAS 内容
- 没有 agent binding — 运行时从 config.yaml 解析
#### `StepNode`(Thread 每一步)
```yaml
type: <step-node-schema-hash>
payload:
start: "4TNVW8KR2B3MA" # ocas_ref → StartNode(每个 step 都引用)
prev: "2MXBG6PN4A8JR" # ocas_ref → 前一个 StepNode,第一步为 null
role: "developer"
output: "9KRVW3TN5F1QA" # ocas_ref → 结构化输出节点(符合 role 的 meta schema)
detail: "7BQST3VW9F2MA" # ocas_ref → 执行详情(content node / 子 workflow terminal StepNode / ...)
agent: "uwf-cursor" # 实际使用的 agent 命令(纯字符串)
```
- `start` — 每个 StepNode 都直接引用 StartNode,方便随机访问
- `prev` — 前一个 StepNode 的 ocas_ref,第一步为 `null`(不指向 StartNode)
- `output` — ocas_ref,指向符合 role meta schema 的 CAS 节点,可用 ocas 校验
- `detail` — ocas_ref,指向执行详情。可以是原始 agent 输出(content node),也可以是子 workflow thread 的 terminal StepNode(workflowAsAgent 场景)
- `agent` — 纯字符串,不是 CAS 节点
### 2.3 链式结构
```
threads.yaml: { "01J7K9M2XNPQR5VWBCDF8G3H4T": "8FWKR3TN5V1QA" }
StepNode (step 3)
├── start ──→ StartNode
│ ├── workflow → CAS(Workflow)
│ └── prompt: "Fix..."
├── prev ──→ StepNode (step 2)
│ ├── start ──→ (same StartNode)
│ ├── prev ──→ StepNode (step 1)
│ │ ├── start ──→ (same StartNode)
│ │ ├── prev: null
│ │ ├── role: "planner"
│ │ └── ...
│ ├── role: "developer"
│ └── ...
├── role: "reviewer"
├── output → CAS({ approved: true })
├── detail → CAS(raw output | sub-workflow terminal node)
└── agent: "uwf-hermes"
```
### 2.4 可变状态
系统两个顶层 YAML 文件和一个 env 文件:
```yaml
# ~/.uwf/config.yaml — 全局配置
providers:
openai:
baseUrl: "https://api.openai.com/v1"
apiKey: "sk-..."
anthropic:
baseUrl: "https://api.anthropic.com/v1"
apiKey: "sk-ant-..."
openrouter:
baseUrl: "https://openrouter.ai/api/v1"
apiKey: "sk-or-..."
models:
sonnet:
provider: "openrouter"
name: "anthropic/claude-sonnet-4"
gpt4o-mini:
provider: "openai"
name: "gpt-4o-mini"
agents:
hermes:
command: "uwf-hermes"
args: []
cursor:
command: "uwf-cursor"
args: []
defaultAgent: "hermes"
agentOverrides:
solve-issue:
developer: "cursor"
defaultModel: "sonnet"
modelOverrides:
extract: "gpt4o-mini"
```
```yaml
# ~/.uwf/threads.yaml — active thread 链头指针
01J7K9M2XNPQR5VWBCDF8G3H4T: "8FWKR3TN5V1QA"
01J8AB3QRMSTV6WKXZ2C4DF7GN: "3CNWT9KR6D2HV"
```
Thread 结束时从 threads.yaml 移除。可选:追加到 `history.jsonl` 做归档。
```bash
# ~/.uwf/.env — 敏感信息(API keys)
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
OPENROUTER_API_KEY=sk-or-...
```
- `config.yaml` — 非敏感配置(agent 命令、model 名、provider 名)
- `.env` — 敏感信息(API keys),agent-kit 启动时自动加载
- `threads.yaml` — 运行时状态
---
## 3. 包结构
全新包,不复用现有 packages,避免命名冲突。CAS 直接依赖 `@ocas/core`
```
packages/
├── cli/ # @united-workforce/cli — uwf CLI(thread/workflow 命令,含 src/moderator/)
├── util-agent/ # @united-workforce/util-agent — Agent CLI 框架(含 extractor)
├── agent-hermes/ # @united-workforce/agent-hermes — uwf-hermes CLI
├── workflow-agent-cursor/ # @united-workforce/agent-cursor — uwf-cursor CLI
└── protocol/ # @united-workforce/protocol — 共享类型定义
```
**外部依赖:**
- `@ocas/core` — CAS 存储、hash、schema 校验
- `@ocas/fs` — 文件系统 CAS 后端
**现有包全部保留不动**,新旧并存,逐步迁移。
---
## 4. 关键数据类型
Moderator 通过 status-based map lookup 进行路由。StepNode payload 和上下文中的 step 共享大量字段,提取为公共类型。
### 4.1 公共类型
```typescript
/** CAS hash — XXH64, 13-char Crockford Base32 */
type CasRef = string;
/** Thread ID — ULID, 26-char Crockford Base32 */
type ThreadId = string;
/** 一个 step 的核心数据,被 StepNode payload 和 moderator 上下文共享 */
type StepRecord = {
role: string;
output: CasRef; // ocas_ref → 结构化输出节点(符合 role meta schema)
detail: CasRef; // ocas_ref → 执行详情(content node / 子 workflow terminal StepNode)
agent: string; // 实际使用的 agent 命令(纯字符串)
};
```
### 4.2 Workflow 定义
```typescript
type RoleDefinition = {
description: string;
goal: string;
capabilities: string[];
procedure: string;
output: string;
meta: CasRef; // ocas_ref → ocas 内置 JSON Schema 节点
};
type Target = {
role: string; // 目标 role 名 或 "$END"
prompt: string; // Mustache 模板,渲染时注入 lastOutput
};
type WorkflowPayload = {
name: string;
description: string;
roles: Record<string, RoleDefinition>;
graph: Record<string, Record<string, Target>>; // Record<Role | "$START", Record<Status, Target>>
};
```
### 4.3 Thread 节点
```typescript
type StartNodePayload = {
workflow: CasRef; // ocas_ref → Workflow
prompt: string;
};
type StepNodePayload = StepRecord & {
start: CasRef; // ocas_ref → StartNode(每个 step 都引用)
prev: CasRef | null; // ocas_ref → 前一个 StepNode,第一步为 null
};
```
### 4.4 Moderator 求值
Moderator 使用 `evaluate(graph, lastRole, lastOutput)` 进行同步 status-based routing:
```typescript
// graph[lastRole][lastOutput.$status] → Target { role, prompt }
// $START 使用 "new"(首次启动)和 "resume"(恢复已完成 thread)作为 status
// prompt 通过 Mustache 模板渲染,变量来自 lastOutput
```
### 4.5 CLI 输出
```typescript
/** uwf thread start */
type StartOutput = {
workflow: CasRef;
thread: ThreadId;
};
/** uwf thread step / uwf thread show */
type StepOutput = {
workflow: CasRef;
thread: ThreadId;
head: CasRef;
done: boolean;
};
/** uwf thread list */
type ThreadListItem = {
thread: ThreadId;
workflow: CasRef;
head: CasRef;
};
```
### 4.6 配置
```typescript
/** Alias types for config references */
type AgentAlias = string;
type ModelAlias = string;
type ProviderAlias = string;
type WorkflowName = string;
type RoleName = string;
type Scenario = string; // e.g. "extract"
type ProviderConfig = {
baseUrl: string;
apiKey: string; // API key stored directly
};
type ModelConfig = {
provider: ProviderAlias;
name: string; // e.g. "anthropic/claude-sonnet-4", "gpt-4o-mini"
};
type AgentConfig = {
command: string;
args: string[];
};
/** ~/.uwf/config.yaml */
type WorkflowConfig = {
providers: Record<ProviderAlias, ProviderConfig>;
models: Record<ModelAlias, ModelConfig>;
agents: Record<AgentAlias, AgentConfig>;
defaultAgent: AgentAlias;
agentOverrides: Record<WorkflowName, Record<RoleName, AgentAlias>> | null;
defaultModel: ModelAlias;
modelOverrides: Record<Scenario, ModelAlias> | null;
};
/** ~/.uwf/threads.yaml */
type ThreadsIndex = Record<ThreadId, CasRef>;
// ^ thread-id ^ head StepNode/StartNode hash
```
### 4.7 类型关系图
```
WorkflowConfig (config.yaml)
ThreadsIndex (threads.yaml) ← 唯二可变状态
│ thread-id → head hash
StepNodePayload ──extends──→ StepRecord ←──maps to──→ StepContext
│ │ │
├── start → StartNodePayload│ │ (output 展开)
├── prev → StepNodePayload │ │
│ ├── role ├── role
│ ├── output (CasRef) ├── output (展开)
│ ├── detail (CasRef) ├── detail (CasRef)
│ └── agent (string) └── agent (string)
└── start.workflow → WorkflowPayload
├── roles: Record<name, RoleDefinition>
└── graph: Record<role, Record<status, Target>>
```
+1 -1
View File
@@ -23,7 +23,7 @@ roles:
type: object
properties:
$status:
enum: ["done"]
const: done
thesis:
type: string
keyPoints:
+124 -56
View File
@@ -1,63 +1,131 @@
name: "debate"
description: "Structured debate between two sides. Tests cross-process session resume."
name: debate
description: "Multi-role structured debate with critical thinking framework and host summary."
# Shared frontmatter schema for debater roles (YAML anchor)
x-debater-frontmatter: &debater-frontmatter
type: object
oneOf:
- properties:
$status: { const: speak }
argument: { type: string }
required: [$status, argument]
- properties:
$status: { const: conceded }
reason: { type: string }
required: [$status, reason]
- properties:
$status: { const: final }
closing: { type: string }
required: [$status, closing]
roles:
against:
description: "Argues against the proposition"
goal: |
You are a skilled debater arguing AGAINST the proposition.
Be logical, cite evidence, and directly address your opponent's points.
Keep each argument concise (under 200 words).
capabilities:
- argumentation
- critical-thinking
proponent:
description: "Argues FOR the proposition"
goal: "Build a compelling case for the proposition through logical reasoning and evidence"
capabilities: []
procedure: |
1. If this is the opening, present your strongest argument against the proposition.
2. If responding to the other side, directly counter their points with evidence and logic.
3. If you find yourself genuinely convinced by the other side, you may concede.
output: |
Provide your argument in the frontmatter.
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
Otherwise set status to "continue".
You are an experienced scholar arguing FOR the proposition.
## Critical Thinking Framework (execute before every speech)
### A. Pre-speech reflection (internal, do not output)
- Does every step in my argument chain hold? Any hidden assumptions or logical gaps?
- If I were my opponent, how would I attack this? Where am I weakest?
- Does my evidence actually support my claim, or could it backfire?
- Should I go on offense or defense this round?
### B. Evidence discipline
- Verify key numbers — watch for order-of-magnitude errors
- Assess data freshness — fast-moving fields have short half-lives
- Distinguish primary data from secondary citations, expert opinion, and common assumptions
### C. Anti-fragility
- Anticipate counterarguments; preemptively strengthen or strategically abandon weak points
- Catch logical gaps, data misuse, or outdated claims in your opponent's reasoning
## Rules
1. Check Thread Progress to see how many times you have spoken.
2. On your 3rd speech, you MUST output $status: final (closing statement).
3. If genuinely convinced by the opponent, output $status: conceded.
4. Otherwise output $status: speak and counter the opponent's points.
5. Be rigorous, cite evidence, stay concise.
output: "Debate argument"
frontmatter: *debater-frontmatter
opponent:
description: "Argues AGAINST the proposition"
goal: "Build a compelling case against the proposition through logical reasoning and evidence"
capabilities: []
procedure: |
You are an experienced scholar arguing AGAINST the proposition.
## Critical Thinking Framework (execute before every speech)
### A. Pre-speech reflection (internal, do not output)
- Does every step in my argument chain hold? Any hidden assumptions or logical gaps?
- If I were my opponent, how would I attack this? Where am I weakest?
- Does my evidence actually support my claim, or could it backfire?
- Should I go on offense or defense this round?
### B. Evidence discipline
- Verify key numbers — watch for order-of-magnitude errors
- Assess data freshness — fast-moving fields have short half-lives
- Distinguish primary data from secondary citations, expert opinion, and common assumptions
### C. Anti-fragility
- Anticipate counterarguments; preemptively strengthen or strategically abandon weak points
- Catch logical gaps, data misuse, or outdated claims in your opponent's reasoning
## Rules
1. Check Thread Progress to see how many times you have spoken.
2. On your 3rd speech, or when the proponent has issued a final statement, you MUST output $status: final.
3. If genuinely convinced by the proponent, output $status: conceded.
4. Otherwise output $status: speak and counter the proponent's points.
5. Be rigorous, cite evidence, stay concise.
output: "Debate argument"
frontmatter: *debater-frontmatter
host:
description: "Debate moderator — delivers impartial summary and verdict"
goal: "Objectively review the debate, analyze both sides, and deliver a verdict"
capabilities: []
procedure: |
You are an experienced academic debate moderator.
## Task
1. Outline each side's core arguments
2. Evaluate reasoning quality and evidence use
3. Highlight the most impactful exchanges
4. Analyze the deeper significance of the topic
5. Deliver an overall verdict
## Style
- Impartial but with independent judgment
- Substantive, not superficial
output: "Debate summary report"
frontmatter:
type: object
properties:
$status:
enum: ["continue", "conceded"]
argument:
type: string
required: [$status, argument]
for:
description: "Argues for the proposition"
goal: |
You are a skilled debater arguing FOR the proposition.
Be logical, cite evidence, and directly address your opponent's points.
Keep each argument concise (under 200 words).
capabilities:
- argumentation
- critical-thinking
procedure: |
1. Read the opposing side's latest argument carefully.
2. Counter their points with evidence and logic.
3. If you find yourself genuinely convinced by the other side, you may concede.
output: |
Provide your argument in the frontmatter.
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
required: [$status, argument]
$status: { const: done }
summary: { type: string }
highlights: { type: string }
verdict: { type: string }
required: [$status, summary, highlights, verdict]
graph:
$START:
new: { role: "against", prompt: "Present your opening argument against the proposition." }
resume: { role: "against", prompt: "Review the previous debate output and continue the argument against the proposition." }
against:
conceded: { role: "$END", prompt: "The against side conceded. Debate over." }
continue: { role: "for", prompt: "Counter the opposing argument: {{{argument}}}" }
for:
conceded: { role: "$END", prompt: "The for side conceded. Debate over." }
continue: { role: "against", prompt: "Counter the opposing argument: {{{argument}}}" }
new: { role: proponent, prompt: "The debate begins. You are arguing FOR the proposition. Present your opening argument." }
resume: { role: proponent, prompt: "The debate continues." }
proponent:
speak: { role: opponent, prompt: "Proponent argues:\n\n{{{argument}}}\n\nYou are the opponent. Counter this argument." }
conceded: { role: host, prompt: "The proponent conceded: {{{reason}}}\n\nPlease summarize the debate." }
final: { role: opponent, prompt: "Proponent's closing statement:\n\n{{{closing}}}\n\nYou are the opponent. Deliver your final response." }
opponent:
speak: { role: proponent, prompt: "Opponent argues:\n\n{{{argument}}}\n\nYou are the proponent. Counter this argument." }
conceded: { role: host, prompt: "The opponent conceded: {{{reason}}}\n\nPlease summarize the debate." }
final: { role: host, prompt: "Opponent's closing statement:\n\n{{{closing}}}\n\nThe debate is over. Please summarize." }
host:
done: { role: "$END", prompt: "Summary complete." }
@@ -1,5 +1,5 @@
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."
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. Uses pnpm."
roles:
bootstrap:
description: "Start Docker container with isolated storage, verify uwf is runnable"
@@ -27,34 +27,32 @@ roles:
On macOS Docker Desktop, host.docker.internal is already available;
--add-host ensures it also works on Linux Docker.
2. Inside the container, copy source to a writable location, install bun, install deps,
then `bun link` all packages so that `uwf`, `uwf-hermes`, `uwf-builtin` are on PATH:
2. Inside the container, copy source to a writable location, install pnpm, install deps,
then link all packages so that `uwf`, `uwf-hermes`, `uwf-builtin`, `uwf-claude-code` are on PATH:
```
docker exec uwf-e2e-$$ bash -c '
# Copy source to writable location (mount is read-only)
cp -r /workspace /root/workflow
# Install bun
curl -fsSL https://bun.sh/install | bash
export PATH="$HOME/.bun/bin:$PATH"
# Install pnpm
npm install -g pnpm
# Isolated storage
mkdir -p $UWF_HOME
# Install workspace deps
cd /root/workflow && bun install
# Install workspace deps (pnpm links workspace packages automatically)
cd /root/workflow && pnpm install
# bun link each package that has a bin entry
cd packages/cli && bun link && cd ../..
cd packages/agent-hermes && bun link && cd ../..
cd packages/agent-builtin && bun link && cd ../..
# Build all packages
pnpm run build
'
```
3. Verify all three commands are available inside the container:
3. Verify all four 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'
docker exec uwf-e2e-$$ bash -c 'uwf --version'
docker exec uwf-e2e-$$ bash -c 'uwf-hermes --help'
docker exec uwf-e2e-$$ bash -c 'uwf-builtin --help'
docker exec uwf-e2e-$$ bash -c 'uwf-claude-code --help'
```
4. Copy host uwf config into the container's isolated storage.
The host config contains provider credentials and model settings needed for LLM calls.
@@ -92,9 +90,8 @@ roles:
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).
All commands use `uwf` (installed via `pnpm install` inside the container).
Remember to set env vars in each exec:
export PATH="$HOME/.bun/bin:$PATH"
export UWF_HOME=/tmp/uwf-e2e-storage
Config tests:
@@ -133,7 +130,7 @@ roles:
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" UWF_HOME=/tmp/uwf-e2e-storage
Set env: UWF_HOME=/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
@@ -166,7 +163,7 @@ roles:
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" UWF_HOME=/tmp/uwf-e2e-storage
Set env: UWF_HOME=/tmp/uwf-e2e-storage
Step inspection:
1. `uwf step list <threadId>` — verify steps array has length > 1
@@ -208,9 +205,9 @@ roles:
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" UWF_HOME=/tmp/uwf-e2e-storage
Set env: UWF_HOME=/tmp/uwf-e2e-storage
Cancel:
Use containerName, threadId, lastStepHash, and workflowName from your prompt.
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 cancelled list: `uwf thread list --status cancelled`
+1 -2
View File
@@ -18,8 +18,7 @@ roles:
type: object
properties:
$status:
type: string
enum: [done]
const: done
summary:
type: string
required: [$status, summary]
+27 -7
View File
@@ -1,5 +1,5 @@
name: "solve-issue"
description: "TDD-driven issue resolution for small, focused changes. Loop protection relies on engine maxRounds."
description: "TDD-driven issue resolution for small, focused changes. Loop protection relies on engine maxRounds. Uses pnpm."
roles:
planner:
description: "Analyzes issue and outputs a TDD test spec"
@@ -80,7 +80,7 @@ roles:
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`
- `cd .worktrees/fix/<issue-number>-<short-slug> && pnpm install`
4. If continuing on existing branch (prompt says "Continue work on existing branch" or provides a worktree path):
- cd directly into the worktree path provided in the prompt
- `git fetch origin && git rebase origin/main`
@@ -95,8 +95,20 @@ roles:
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
10. Ensure `pnpm run build` passes with no errors
11. Run `pnpm test` to verify all tests pass
After implementation, before reporting done:
12. Add a changeset file (`.changeset/<short-slug>.md`) with correct bump type:
- `patch` for bug fixes, internal refactors, test-only changes
- `minor` for new features, new CLI commands, new API surfaces
- `major` for breaking changes
List every affected package in the changeset frontmatter.
13. Update documentation if the change affects user-facing behavior:
- `README.md` — usage examples, feature descriptions
- `.cards/` — architecture decision records (if applicable)
- CLI prompt subcommand output (if CLI help text changes)
- CLI `--help` text (if flags/commands are added or changed)
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.
@@ -127,8 +139,8 @@ roles:
Then perform code review:
Hard checks (must all pass):
3. `bun run build` — no build errors
4. `bunx biome check` — no lint violations
3. `pnpm run build` — no build errors
4. `pnpm run check` — no lint violations
5. TypeScript strict mode — no type errors
Soft checks (review against project conventions if CLAUDE.md / .cursor/rules exist):
@@ -136,6 +148,14 @@ roles:
- No `console.log` in production code
- No dynamic imports in production code
Documentation & changeset checks:
6. Changeset exists in `.changeset/` with correct bump type (`patch`/`minor`/`major`) and lists all affected packages
7. If the change is user-facing, documentation is updated:
- `README.md` reflects new/changed behavior
- `.cards/` architecture cards updated if design decisions changed
- CLI prompt subcommand output updated (if it generates skill/reference content)
- CLI `--help` text matches new flags/commands
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)."
@@ -159,7 +179,7 @@ roles:
procedure: |
The worktree path is provided in your task prompt. cd into it first.
1. Run `bun test` for automated test verification
1. Run `pnpm test` for automated test verification
2. Read the test spec from CAS: `ocas 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:
+1 -1
View File
@@ -21,7 +21,7 @@
"@agentclientprotocol/sdk": "^0.22.1",
"@biomejs/biome": "^2.4.14",
"@changesets/cli": "^2.31.0",
"@shazhou/proman": "^0.5.1",
"@shazhou/proman": "^0.6.3",
"@types/node": "^25.7.0",
"@types/xxhashjs": "^0.2.4",
"@united-workforce/agent-hermes": "workspace:*",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@united-workforce/agent-builtin",
"version": "0.1.1",
"version": "0.1.2",
"files": [
"src",
"dist",
@@ -21,7 +21,7 @@
"test:ci": "vitest run __tests__/"
},
"dependencies": {
"@ocas/core": "^0.3.0",
"@ocas/core": "^0.4.0",
"@united-workforce/util": "workspace:^",
"@united-workforce/util-agent": "workspace:^"
},
+2
View File
@@ -167,5 +167,7 @@ export function createBuiltinAgent(): () => Promise<void> {
name: "builtin",
run: runBuiltin,
continue: continueBuiltin,
fork: null,
cleanup: null,
});
}
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env node
#!/usr/bin/env -S node --disable-warning=ExperimentalWarning
// eslint-disable-next-line -- dynamic import for version
const pkg = await import("../package.json", { with: { type: "json" } });
+8
View File
@@ -0,0 +1,8 @@
# Changelog
## 0.1.4 — 2026-06-07
- fix: decouple session resume from isFirstVisit guard
When frontmatter validation fails, the step is never written to CAS, so isFirstVisit remains true on the next run. Both adapters now always check the session cache regardless of isFirstVisit. When resuming after a frontmatter-only failure (isFirstVisit + cache hit), a minimal correction prompt is sent via buildFrontmatterRetryPrompt() instead of re-sending the full initial prompt.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@united-workforce/agent-claude-code",
"version": "0.1.1",
"version": "0.1.4",
"files": [
"src",
"dist",
@@ -21,7 +21,7 @@
"test:ci": "vitest run __tests__/"
},
"dependencies": {
"@ocas/core": "^0.3.0",
"@ocas/core": "^0.4.0",
"@united-workforce/protocol": "workspace:^",
"@united-workforce/util": "workspace:^",
"@united-workforce/util-agent": "workspace:^"
+23 -4
View File
@@ -6,7 +6,9 @@ import {
type AgentContext,
type AgentRunResult,
buildContinuationPrompt,
buildFrontmatterRetryPrompt,
buildRolePrompt,
buildThreadProgress,
createAgent,
getCachedSessionId,
setCachedSessionId,
@@ -27,6 +29,10 @@ export function buildClaudeCodePrompt(ctx: AgentContext): string {
if (ctx.outputFormatInstruction !== undefined && ctx.outputFormatInstruction !== "") {
parts.push(ctx.outputFormatInstruction, "");
}
// Inject thread progress so the agent knows step count and role visit count
parts.push(buildThreadProgress(ctx.steps, ctx.role), "");
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
if (!ctx.isFirstVisit) {
@@ -171,8 +177,12 @@ async function runClaudeCode(ctx: AgentContext, model: string | null): Promise<A
log("K7R2M4N8", `prompt for role=${ctx.role} (length=${fullPrompt.length}):\n${fullPrompt}`);
// Try resuming a cached session for re-entry scenarios (e.g. reviewer reject → developer re-entry).
if (!ctx.isFirstVisit) {
// Try resuming a cached session. This covers both normal re-entry
// (e.g. reviewer reject → developer re-entry) AND the case where a
// previous run completed but frontmatter validation failed — the step
// was never written to CAS so isFirstVisit is still true, but the
// session cache holds a valid session we should resume.
{
const cachedSessionId = await getCachedSessionId(
"claude-code",
ctx.threadId,
@@ -180,13 +190,20 @@ async function runClaudeCode(ctx: AgentContext, model: string | null): Promise<A
ctx.storageRoot,
);
if (cachedSessionId !== null) {
// isFirstVisit + cache hit = previous run completed but frontmatter
// validation failed. The session already has full context — send a
// minimal correction prompt instead of the full initial prompt.
const resumePrompt = ctx.isFirstVisit
? buildFrontmatterRetryPrompt(ctx.outputFormatInstruction)
: fullPrompt;
try {
const { stdout, stderr, exitCode } = await spawnClaudeResume(
cachedSessionId,
fullPrompt,
resumePrompt,
model,
);
const result = await processClaudeOutput(stdout, stderr, exitCode, ctx.store, fullPrompt);
const result = await processClaudeOutput(stdout, stderr, exitCode, ctx.store, resumePrompt);
if (result.sessionId !== undefined && result.sessionId !== "") {
await setCachedSessionId(
"claude-code",
@@ -236,5 +253,7 @@ export function createClaudeCodeAgent(model: string | null): () => Promise<void>
name: "claude-code",
run: (ctx) => runClaudeCode(ctx, model),
continue: (sessionId, message, store) => continueClaudeCode(sessionId, message, store, model),
fork: null,
cleanup: null,
});
}
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env node
#!/usr/bin/env -S node --disable-warning=ExperimentalWarning
// eslint-disable-next-line -- dynamic import for version
const pkg = await import("../package.json", { with: { type: "json" } });
+6
View File
@@ -1,5 +1,11 @@
# @united-workforce/agent-hermes
## 0.1.5 — 2026-06-07
- fix: decouple session resume from isFirstVisit guard
When frontmatter validation fails, the step is never written to CAS, so isFirstVisit remains true on the next run. Both adapters now always check the session cache regardless of isFirstVisit. When resuming after a frontmatter-only failure (isFirstVisit + cache hit), a minimal correction prompt is sent via buildFrontmatterRetryPrompt() instead of re-sending the full initial prompt.
## 0.1.1
### Patch Changes
@@ -15,7 +15,8 @@ describe("Issue #551 — bin entry & engines", () => {
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf-8"));
const binPath = pkg.bin["uwf-hermes"];
const content = readFileSync(join(PKG_ROOT, binPath), "utf-8");
expect(content.startsWith("#!/usr/bin/env node")).toBe(true);
expect(content.startsWith("#!/usr/bin/env")).toBe(true);
expect(content).toContain("node");
});
test("README.md explains uwf-hermes is an adapter", () => {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@united-workforce/agent-hermes",
"version": "0.1.2",
"version": "0.1.5",
"files": [
"src",
"dist",
@@ -21,7 +21,7 @@
"test:ci": "vitest run __tests__/"
},
"dependencies": {
"@ocas/core": "^0.3.0",
"@ocas/core": "^0.4.0",
"@united-workforce/protocol": "workspace:^",
"@united-workforce/util": "workspace:^",
"@united-workforce/util-agent": "workspace:^"
+7 -2
View File
@@ -12,7 +12,11 @@ const OWN_VERSION = (
}
).version;
const HERMES_COMMAND = "hermes";
/** Resolve hermes binary: `UWF_HERMES_BIN` override → default `"hermes"` via PATH. */
function resolveHermesCommand(): string {
const override = process.env.UWF_HERMES_BIN;
return override !== undefined && override !== "" ? override : "hermes";
}
const PROTOCOL_VERSION = 1;
type JsonRpcResponse = {
@@ -271,7 +275,8 @@ export class HermesAcpClient {
return;
}
const child = spawn(HERMES_COMMAND, ["acp"], {
const hermesCommand = resolveHermesCommand();
const child = spawn(hermesCommand, ["acp"], {
env: process.env,
shell: false,
stdio: ["pipe", "pipe", "pipe"],
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env node
#!/usr/bin/env -S node --disable-warning=ExperimentalWarning
// eslint-disable-next-line -- dynamic import for version
const pkg = await import("../package.json", { with: { type: "json" } });
+29 -9
View File
@@ -5,7 +5,9 @@ import {
type AgentContext,
type AgentRunResult,
buildContinuationPrompt,
buildFrontmatterRetryPrompt,
buildRolePrompt,
buildThreadProgress,
createAgent,
} from "@united-workforce/util-agent";
import type { AcpUsage } from "./acp-client.js";
@@ -60,6 +62,9 @@ export function buildHermesPrompt(ctx: AgentContext): string {
parts.push(ctx.outputFormatInstruction, "");
}
// Inject thread progress so the agent knows step count and role visit count
parts.push(buildThreadProgress(ctx.steps, ctx.role), "");
if (!ctx.isFirstVisit) {
// Re-entry: show only steps since last visit, meta only
parts.push(buildContinuationPrompt(ctx.steps, ctx.role, ctx.edgePrompt));
@@ -98,6 +103,8 @@ async function storePromptResult(store: Store, sessionId: string): Promise<{ det
type PromptAttempt = {
useContinuation: boolean;
resumed: boolean;
/** True when resuming after a frontmatter-only failure (isFirstVisit + cache hit). */
frontmatterRetry: boolean;
};
async function prepareSession(
@@ -106,28 +113,36 @@ async function prepareSession(
cwd: string,
resumeDisabled: boolean,
): Promise<PromptAttempt> {
if (ctx.isFirstVisit || resumeDisabled) {
if (resumeDisabled) {
await client.connect(cwd);
return { useContinuation: false, resumed: false };
return { useContinuation: false, resumed: false, frontmatterRetry: false };
}
// Check session cache regardless of isFirstVisit. A previous run may
// have completed and cached its session but failed frontmatter
// validation — the step never got written to CAS so isFirstVisit is
// still true, yet we should resume the existing session.
const cachedSessionId = await getCachedSessionId(ctx.threadId, ctx.role, ctx.storageRoot);
if (cachedSessionId === null) {
log("6RWK3N8Q", `no cached session for ${ctx.threadId}:${ctx.role}, starting new session`);
await client.connect(cwd);
return { useContinuation: false, resumed: false };
return { useContinuation: false, resumed: false, frontmatterRetry: false };
}
try {
await client.resume(cachedSessionId, cwd);
log("9MHT4V2P", `resumed hermes session ${cachedSessionId} for ${ctx.threadId}:${ctx.role}`);
return { useContinuation: true, resumed: true };
return {
useContinuation: !ctx.isFirstVisit,
resumed: true,
frontmatterRetry: ctx.isFirstVisit,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log("3XPN7K4W", `session resume failed, falling back to new session: ${message}`);
await client.close();
await client.connect(cwd);
return { useContinuation: false, resumed: false };
return { useContinuation: false, resumed: false, frontmatterRetry: false };
}
}
@@ -150,9 +165,12 @@ export function createHermesAgent(resumeDisabled: boolean): () => Promise<void>
ctx: AgentContext,
useContinuation: boolean,
beforeTurns: TurnsSnapshot,
frontmatterRetry: boolean,
): Promise<AgentRunResult> {
const effectiveCtx = useContinuation ? ctx : { ...ctx, isFirstVisit: true };
const fullPrompt = buildHermesPrompt(effectiveCtx);
// Frontmatter retry: session has full context, just re-output the format.
const fullPrompt = frontmatterRetry
? buildFrontmatterRetryPrompt(ctx.outputFormatInstruction)
: buildHermesPrompt(useContinuation ? ctx : { ...ctx, isFirstVisit: true });
const startMs = Date.now();
const { text, sessionId, usage: acpUsage } = await client.prompt(fullPrompt);
const durationSec = (Date.now() - startMs) / 1000;
@@ -184,7 +202,7 @@ export function createHermesAgent(resumeDisabled: boolean): () => Promise<void>
const beforeTurns = snapshotTurns(beforeSession);
try {
return await runPrompt(ctx, attempt.useContinuation, beforeTurns);
return await runPrompt(ctx, attempt.useContinuation, beforeTurns, attempt.frontmatterRetry);
} catch (error) {
if (!attempt.resumed) {
throw error;
@@ -195,7 +213,7 @@ export function createHermesAgent(resumeDisabled: boolean): () => Promise<void>
await client.close();
await client.connect(cwd);
// Fresh session after retry — reset snapshot to zero
return runPrompt(ctx, false, ZERO_TURNS);
return runPrompt(ctx, false, ZERO_TURNS, false);
}
}
@@ -228,6 +246,8 @@ export function createHermesAgent(resumeDisabled: boolean): () => Promise<void>
name: "hermes",
run: runHermes,
continue: continueHermes,
fork: null,
cleanup: null,
});
// Wrap to ensure ACP client is closed after agent completes,
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@united-workforce/agent-mock",
"version": "0.1.1",
"version": "0.1.2",
"files": [
"src",
"dist",
@@ -21,7 +21,7 @@
"test:ci": "vitest run __tests__/"
},
"dependencies": {
"@ocas/core": "^0.3.0",
"@ocas/core": "^0.4.0",
"@united-workforce/protocol": "workspace:^",
"@united-workforce/util": "workspace:^",
"@united-workforce/util-agent": "workspace:^",
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env node
#!/usr/bin/env -S node --disable-warning=ExperimentalWarning
// eslint-disable-next-line -- dynamic import for version
const pkg = await import("../package.json", { with: { type: "json" } });
+2
View File
@@ -125,5 +125,7 @@ export function createMockAgent(mockDataPath: string): () => Promise<void> {
name: "mock",
run,
continue: continueRun,
fork: null,
cleanup: null,
});
}
+6 -1
View File
@@ -49,7 +49,7 @@ bun link packages/cli
| `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 list [--status <status>] [--all] [--after <date>] [--before <date>] [--skip <n>] [--take <n>]` | List threads (defaults to active: idle + running). Use `--all` to include completed/cancelled/suspended, or `--status` to filter explicitly (idle, running, suspended, completed, cancelled, active, or comma-separated). Supports time range and 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.
@@ -63,6 +63,8 @@ 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
uwf thread list --all
uwf thread list --status running
uwf thread list --status active
uwf thread list --status idle,completed
@@ -79,6 +81,7 @@ uwf thread stop 01ARZ3NDEKTSV4RRFFQ69G5FAV
| `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 |
| `uwf step ask <step-hash> -p <prompt> [--agent <cmd>] [--no-fork]` | Ask a follow-up question to a historical step's agent (read-only; no thread mutation) |
Examples:
@@ -87,6 +90,8 @@ uwf step list 01ARZ3NDEKTSV4RRFFQ69G5FAV
uwf step show 32GCDE899RRQ3
uwf step read 32GCDE899RRQ3 --quota 2000
uwf step fork 32GCDE899RRQ3
uwf step ask 32GCDE899RRQ3 -p "Why did you choose this approach?"
uwf step ask 32GCDE899RRQ3 -p "Summarise the key findings" --no-fork
```
### Workflow (Layer 1: Templates)
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@united-workforce/cli",
"version": "0.2.0",
"version": "0.3.0",
"files": [
"src",
"dist",
@@ -11,8 +11,8 @@
"uwf": "./dist/cli.js"
},
"dependencies": {
"@ocas/core": "^0.3.0",
"@ocas/fs": "^0.3.0",
"@ocas/core": "^0.4.0",
"@ocas/fs": "^0.4.0",
"@united-workforce/protocol": "workspace:^",
"@united-workforce/util": "workspace:^",
"@united-workforce/util-agent": "workspace:^",
+19 -11
View File
@@ -28,9 +28,13 @@ roles:
$status: "ready"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string, enum: ["ready", "not-ready"] }
oneOf:
- properties:
$status: { const: "ready" }
required: ["$status"]
- properties:
$status: { const: "not-ready" }
required: ["$status"]
roleB:
description: Second role
goal: Do B
@@ -42,7 +46,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { type: string, enum: ["done"] }
$status: { const: "done" }
graph:
$START:
new:
@@ -82,9 +86,13 @@ roles:
$status: "pass"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string, enum: ["pass", "fail"] }
oneOf:
- properties:
$status: { const: "pass" }
required: ["$status"]
- properties:
$status: { const: "fail" }
required: ["$status"]
roleB:
description: Pass role
goal: Do B
@@ -96,7 +104,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { type: string, enum: ["done"] }
$status: { const: "done" }
roleC:
description: Fail role
goal: Do C
@@ -108,7 +116,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { type: string, enum: ["done"] }
$status: { const: "done" }
graph:
$START:
new:
@@ -155,7 +163,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { type: string, enum: ["done"] }
$status: { const: "done" }
graph:
$START:
new:
@@ -376,7 +384,7 @@ describe("currentRole field", () => {
const _compHead = loadActiveThreads(uwfForIndex.varStore)[compId]!.head;
completeThread(uwfForIndex.varStore, compId, "completed");
const list = await cmdThreadList(storageRoot, null, null, null, 0, 100);
const list = await cmdThreadList(storageRoot, null, null, null, 0, 100, true);
const idleItem = list.find((i) => i.thread === idleId);
expect(idleItem).toBeDefined();
+40
View File
@@ -5,6 +5,7 @@ import { describe, expect, test } from "vitest";
const __dirname = dirname(fileURLToPath(import.meta.url));
import { generateCliReference } from "@united-workforce/util";
import {
cmdPromptAdapterDeveloping,
cmdPromptBootstrap,
@@ -42,6 +43,24 @@ describe("prompt commands", () => {
expect(result.length).toBeGreaterThan(500);
});
test("prompt usage describes .workflow/ auto-discovery", () => {
const result = cmdPromptUsage();
expect(result).toContain(".workflow/");
expect(result).toContain("uwf thread start solve-issue");
expect(result.toLowerCase()).toContain("auto-discover");
expect(result.toLowerCase()).toContain("recommended");
});
test("prompt cli-reference describes .workflow/ auto-discovery", () => {
const ref = generateCliReference();
expect(ref).toContain(".workflow/");
expect(ref.toLowerCase()).toContain("cwd upward");
expect(ref).toContain("workflow list");
expect(ref).toMatch(/CAS hash/i);
expect(ref).toMatch(/file path/i);
expect(ref).toMatch(/registry/i);
});
test("prompt workflow-authoring returns non-empty markdown string with frontmatter", () => {
const result = cmdPromptWorkflowAuthoring();
expect(typeof result).toBe("string");
@@ -56,6 +75,17 @@ describe("prompt commands", () => {
expect(result.length).toBeGreaterThan(500);
});
test("prompt workflow-authoring documents .workflow/ Placement section", () => {
const result = cmdPromptWorkflowAuthoring();
expect(result).toContain("## Placement");
expect(result).toContain(".workflow/");
expect(result).toContain("solve-issue.yaml");
expect(result.toLowerCase()).toContain("auto-discover");
expect(result.toLowerCase()).toContain("no workflow add");
// Placement must appear before Self-Testing
expect(result.indexOf("## Placement")).toBeLessThan(result.indexOf("## Self-Testing"));
});
test("prompt adapter-developing returns non-empty markdown string with frontmatter", () => {
const result = cmdPromptAdapterDeveloping();
expect(typeof result).toBe("string");
@@ -71,12 +101,22 @@ describe("prompt commands", () => {
test("prompt bootstrap returns framework-agnostic setup instructions", () => {
const result = cmdPromptBootstrap();
expect(typeof result).toBe("string");
// Skills installation
expect(result).toContain("uwf prompt usage");
expect(result).toContain("uwf prompt workflow-authoring");
expect(result).toContain("uwf prompt adapter-developing");
expect(result).toContain("uwf-usage");
expect(result).toContain("uwf-workflow-authoring");
expect(result).toContain("uwf-adapter-developing");
// Fresh install scenario
expect(result).toContain("Fresh Install");
expect(result).toContain("uwf setup");
expect(result).toContain("--provider");
expect(result).toContain("--api-key");
expect(result).toContain("agent adapter");
// Upgrade scenario
expect(result).toContain("Upgrade");
expect(result).toContain("Migrate");
// Should NOT contain Hermes-specific paths
expect(result).not.toContain("~/.hermes/skills/");
expect(result).not.toContain("> ~/.hermes/");
@@ -21,11 +21,11 @@ describe("solve-issue workflow: Gitea API PR creation", () => {
"..",
"..",
"..",
".workflows",
"examples",
"solve-issue.yaml",
);
test("committer procedure should use curl API instead of tea pr create", async () => {
test("committer procedure should create PR via tea pr create", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
const workflow = parse(yamlContent) as WorkflowPayload;
@@ -33,25 +33,22 @@ describe("solve-issue workflow: Gitea API PR creation", () => {
const committerProcedure = workflow.roles.committer?.procedure;
expect(committerProcedure).toBeDefined();
// Verify the procedure uses curl API, not tea pr create
expect(committerProcedure).toContain("curl");
expect(committerProcedure).toContain("api/v1/repos");
expect(committerProcedure).toContain("/pulls");
// Verify it explicitly warns against tea pr create
expect(committerProcedure).toMatch(/do NOT use.*tea pr create/i);
// Verify the procedure uses tea pr create for PR creation
expect(committerProcedure).toContain("tea pr create");
expect(committerProcedure).toContain("git push");
expect(committerProcedure).toContain("Fixes #N");
});
test("committer procedure should reference repoRemote from task prompt", async () => {
test("committer procedure should extract owner/repo from git remote", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
const workflow = parse(yamlContent) as WorkflowPayload;
const committerProcedure = workflow.roles.committer?.procedure;
expect(committerProcedure).toBeDefined();
// Verify the procedure mentions repoRemote is provided in task prompt
expect(committerProcedure).toMatch(/repo remote.*provided.*task prompt/i);
expect(committerProcedure).toMatch(/owner\/repo/i);
// Verify the procedure extracts owner/repo from remote
expect(committerProcedure).toContain("git remote get-url origin");
expect(committerProcedure).toContain("hook_failed");
});
test("committer procedure should include error handling for curl failures", async () => {
@@ -100,45 +97,42 @@ describe("solve-issue workflow: Gitea API PR creation", () => {
expect(committedVariant.required).toContain("$status");
});
test("developer procedure should include mandatory verification step", async () => {
test("developer procedure should include worktree setup", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
const workflow = parse(yamlContent) as WorkflowPayload;
const developerProcedure = workflow.roles.developer?.procedure;
expect(developerProcedure).toBeDefined();
// Verify the procedure includes mandatory verification step
expect(developerProcedure).toContain("MANDATORY VERIFICATION");
expect(developerProcedure).toContain("git branch --show-current");
expect(developerProcedure).toContain("git status");
expect(developerProcedure).toMatch(/ls -la|verify.*exist/i);
// Verify the procedure includes worktree setup
expect(developerProcedure).toContain("IMPORTANT");
expect(developerProcedure).toContain("git worktree add");
expect(developerProcedure).toContain("pnpm install");
});
test("reviewer procedure should enforce worktree path verification", async () => {
test("reviewer procedure should verify branch and run checks", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
const workflow = parse(yamlContent) as WorkflowPayload;
const reviewerProcedure = workflow.roles.reviewer?.procedure;
expect(reviewerProcedure).toBeDefined();
// Verify the procedure includes critical enforcement
expect(reviewerProcedure).toContain("CRITICAL");
expect(reviewerProcedure).toMatch(/cd.*pwd/);
expect(reviewerProcedure).toContain(
"Do NOT report results without running the actual commands",
);
// Verify the procedure includes branch verification and build checks
expect(reviewerProcedure).toContain("git branch --show-current");
expect(reviewerProcedure).toContain("pnpm run build");
expect(reviewerProcedure).toContain("pnpm run check");
});
test("developer procedure should include test debugging escalation", async () => {
test("developer procedure should include changeset and failure handling", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
const workflow = parse(yamlContent) as WorkflowPayload;
const developerProcedure = workflow.roles.developer?.procedure;
expect(developerProcedure).toBeDefined();
// Verify the procedure includes test failure guidance
expect(developerProcedure).toMatch(/tests fail.*first run/i);
expect(developerProcedure).toMatch(/3 test cycles|after 3 attempts/i);
// Verify the procedure includes changeset requirement and failure path
expect(developerProcedure).toContain(".changeset/");
expect(developerProcedure).toContain("$status=failed");
expect(developerProcedure).toContain("pnpm test");
});
});
+670
View File
@@ -0,0 +1,670 @@
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { bootstrap, putSchema } from "@ocas/core";
import { openStore } from "@ocas/fs";
import type { CasRef, ThreadId, ThreadIndexEntry } from "@united-workforce/protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { registerUwfSchemas } from "../schemas.js";
import { seedThreads } from "./thread-test-helpers.js";
const OUTPUT_SCHEMA = {
type: "object" as const,
properties: {
$status: { type: "string" as const },
note: { type: "string" as const },
},
required: ["$status"],
additionalProperties: false,
};
const DETAIL_SCHEMA = {
title: "ask-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: "ocas_ref" },
},
},
additionalProperties: false,
};
const THREAD_ID = "01ASKSTEPTEST000000000" as ThreadId;
const STEP_SESSION_ID = "ses-original-step-001";
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-step-ask-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
type SetupOpts = {
threadStatus: ThreadIndexEntry["status"];
withDetail: boolean;
// The agent name (path or alias) to record in the head StepNode.agent field.
// Defaults to mockAgentPath.
stepAgentNameOverride: string | null;
// Pre-cached fork session-id. When provided, the cache file is written
// before running so the test can verify reuse semantics.
preCachedForkSessionId: string | null;
};
type SetupResult = {
casDir: string;
stepHash: CasRef;
startHash: CasRef;
workflowHash: CasRef;
detailHash: CasRef | null;
mockAgentPath: string;
failingAgentPath: string;
promptCapturePath: string;
modeCapturePath: string;
forkSessionCapturePath: string;
askSessionCapturePath: string;
envCapturePath: string;
};
async function setupAskFixture(opts: Partial<SetupOpts> = {}): Promise<SetupResult> {
const cfg: SetupOpts = {
threadStatus: opts.threadStatus ?? "idle",
withDetail: opts.withDetail ?? true,
stepAgentNameOverride: opts.stepAgentNameOverride ?? null,
preCachedForkSessionId: opts.preCachedForkSessionId ?? null,
};
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = await openStore(casDir);
await bootstrap(store);
const schemas = await registerUwfSchemas(store);
const outputSchemaHash = await putSchema(store, OUTPUT_SCHEMA);
const detailSchemaHash = await putSchema(store, DETAIL_SCHEMA);
const workflowHash = await store.cas.put(schemas.workflow, {
name: "test-ask",
description: "ask command integration test",
roles: {
worker: {
description: "Worker",
goal: "Work",
capabilities: [],
procedure: "work",
output: "result",
frontmatter: outputSchemaHash,
},
},
graph: {
$START: {
new: { role: "worker", prompt: "Start work", location: null },
},
worker: { ok: { role: "$END", prompt: "done", location: null } },
},
});
const startHash = await store.cas.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test ask task",
cwd: tmpDir,
});
// Set OCAS_HOME so seedThreads + in-test createUwfStore calls resolve to this CAS dir.
process.env.OCAS_HOME = casDir;
// Capture file paths
const promptCapturePath = join(tmpDir, "captured-prompt.txt");
const modeCapturePath = join(tmpDir, "captured-mode.txt");
const forkSessionCapturePath = join(tmpDir, "captured-fork-session.txt");
const askSessionCapturePath = join(tmpDir, "captured-ask-session.txt");
const envCapturePath = join(tmpDir, "captured-env.txt");
const mockAgentPath = join(tmpDir, "mock-agent.sh");
const failingAgentPath = join(tmpDir, "failing-agent.sh");
// Build a detail node with sessionId so step ask can extract it
let detailHash: CasRef | null = null;
if (cfg.withDetail) {
const turnHash = await store.cas.put(detailSchemaHash, {
sessionId: STEP_SESSION_ID,
model: "test-model",
duration: 1000,
turnCount: 0,
turns: [],
});
detailHash = turnHash;
}
// Build the StepNode at thread head
const outputHash = await store.cas.put(outputSchemaHash, { $status: "ok" });
const stepHash = await store.cas.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: cfg.stepAgentNameOverride ?? mockAgentPath,
edgePrompt: "Start work",
startedAtMs: 1716600000000,
completedAtMs: 1716600001000,
cwd: tmpDir,
assembledPrompt: null,
usage: null,
});
// Seed thread index entry
await seedThreads(tmpDir, {
[THREAD_ID]: {
head: stepHash,
status: cfg.threadStatus,
suspendedRole: null,
suspendMessage: null,
completedAt: cfg.threadStatus === "completed" ? 1716600001000 : null,
},
});
// Pre-seed the ask session cache so reuse tests have something to find.
if (cfg.preCachedForkSessionId !== null) {
const cachePath = join(tmpDir, "cache", "mock-sessions.json");
await mkdir(dirname(cachePath), { recursive: true });
await writeFile(
cachePath,
`${JSON.stringify({ [`${stepHash}:ask`]: cfg.preCachedForkSessionId }, null, 2)}\n`,
"utf8",
);
}
// Mock agent: dispatches based on `--mode` (ask|fork|run) and captures inputs.
// - --mode ask --session <id> --prompt <text>: writes to ask capture; echoes a fixed answer to stdout
// - --mode fork --session <id>: writes to fork capture; prints "forked-from-<id>" sessionId on stdout
// - default (uwf-* style invocation): captures and echoes adapter JSON (not used in this suite)
await writeFile(
mockAgentPath,
`#!/bin/sh
mode=""
prompt=""
session=""
detail=""
while [ $# -gt 0 ]; do
case "$1" in
--mode) mode="$2"; shift 2 ;;
--prompt) prompt="$2"; shift 2 ;;
--session) session="$2"; shift 2 ;;
--detail) detail="$2"; shift 2 ;;
*) shift ;;
esac
done
printf '%s' "$mode" > '${modeCapturePath}'
printf '%s' "$prompt" > '${promptCapturePath}'
printf 'OCAS_HOME=%s\\n' "$OCAS_HOME" > '${envCapturePath}'
case "$mode" in
fork)
printf '%s' "$session" > '${forkSessionCapturePath}'
new_id="forked-from-$session"
printf '%s\\n' "$new_id"
;;
ask)
printf '%s' "$session" > '${askSessionCapturePath}'
# Print a deterministic answer that the cmdStepAsk path will hand back.
printf 'MOCK_ANSWER prompt=%s session=%s detail=%s\\n' "$prompt" "$session" "$detail"
;;
*)
echo "{\\"stepHash\\":\\"unused\\"}"
;;
esac
`,
{ mode: 0o755 },
);
await writeFile(
failingAgentPath,
`#!/bin/sh
echo "boom" >&2
exit 7
`,
{ mode: 0o755 },
);
// Minimal config so loadWorkflowConfig succeeds.
const configPath = join(tmpDir, "config.yaml");
await writeFile(
configPath,
`defaultAgent: uwf-hermes\ndefaultModel: test-model\nagentOverrides: null\nagents: {}\nproviders: {}\nmodels: {}\n`,
);
return {
casDir,
stepHash,
startHash,
workflowHash,
detailHash,
mockAgentPath,
failingAgentPath,
promptCapturePath,
modeCapturePath,
forkSessionCapturePath,
askSessionCapturePath,
envCapturePath,
};
}
function runUwf(
args: string[],
casDir: string,
): { stdout: string; stderr: string; status: number } {
const cliPath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "dist", "cli.js");
try {
const stdout = execFileSync(process.execPath, [cliPath, ...args], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
UWF_HOME: tmpDir,
OCAS_HOME: casDir,
},
cwd: tmpDir,
timeout: 30000,
});
return { stdout, stderr: "", status: 0 };
} catch (error) {
const err = error as NodeJS.ErrnoException & {
stdout?: string | Buffer;
stderr?: string | Buffer;
status?: number;
};
return {
stdout: typeof err.stdout === "string" ? err.stdout : (err.stdout?.toString("utf8") ?? ""),
stderr: typeof err.stderr === "string" ? err.stderr : (err.stderr?.toString("utf8") ?? ""),
status: err.status ?? 1,
};
}
}
// ── Group 1: CLI argument validation ───────────────────────────────────────
describe("uwf step ask - CLI argument validation", () => {
test("1.1 missing step-hash exits non-zero", async () => {
const { casDir } = await setupAskFixture();
const result = runUwf(["step", "ask"], casDir);
expect(result.status).not.toBe(0);
});
test("1.2 missing -p flag exits non-zero", async () => {
const { casDir, stepHash } = await setupAskFixture();
const result = runUwf(["step", "ask", stepHash], casDir);
expect(result.status).not.toBe(0);
expect(result.stderr.toLowerCase()).toMatch(/required|missing|prompt/);
});
test("1.3 step-hash and -p accepted as valid invocation", async () => {
const { casDir, stepHash, mockAgentPath } = await setupAskFixture();
const result = runUwf(
["step", "ask", stepHash, "-p", "why?", "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
});
});
// ── Group 2: CAS validation errors ────────────────────────────────────────
describe("uwf step ask - CAS validation errors", () => {
test("2.1 non-existent CAS hash exits non-zero with 'not found'", async () => {
const { casDir, mockAgentPath } = await setupAskFixture();
const result = runUwf(
["step", "ask", "0000000000000", "-p", "why?", "--agent", mockAgentPath],
casDir,
);
expect(result.status).not.toBe(0);
expect(result.stderr.toLowerCase()).toContain("not found");
});
test("2.2 hash that is not a StepNode exits non-zero", async () => {
const { casDir, startHash, mockAgentPath } = await setupAskFixture();
// Use the StartNode hash — it exists but is not a StepNode
const result = runUwf(
["step", "ask", startHash, "-p", "why?", "--agent", mockAgentPath],
casDir,
);
expect(result.status).not.toBe(0);
expect(result.stderr.toLowerCase()).toContain("not a stepnode");
});
test("2.3 step with no detail ref exits non-zero", async () => {
const { casDir, stepHash, mockAgentPath } = await setupAskFixture({ withDetail: false });
const result = runUwf(
["step", "ask", stepHash, "-p", "why?", "--agent", mockAgentPath],
casDir,
);
expect(result.status).not.toBe(0);
expect(result.stderr.toLowerCase()).toMatch(/no detail|detail.*missing|missing.*detail/);
});
});
// ── Group 3: Successful ask (core behavior) ───────────────────────────────
describe("uwf step ask - successful ask (core)", () => {
test("3.1 stdout contains agent's response text", async () => {
const { casDir, stepHash, mockAgentPath } = await setupAskFixture();
const result = runUwf(
["step", "ask", stepHash, "-p", "why tar not zip?", "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
expect(result.stdout).toContain("MOCK_ANSWER");
expect(result.stdout).toContain("why tar not zip?");
});
test("3.2 thread index entry (head, status) is identical before and after ask", async () => {
const { casDir, stepHash, mockAgentPath } = await setupAskFixture();
// Before ask: snapshot the thread state
const { createUwfStore, getThread } = await import("../store.js");
const before = await createUwfStore(tmpDir);
const beforeEntry = getThread(before.varStore, THREAD_ID);
expect(beforeEntry).not.toBeNull();
const result = runUwf(
["step", "ask", stepHash, "-p", "anything", "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
// After ask: thread state should be unchanged
const after = await createUwfStore(tmpDir);
const afterEntry = getThread(after.varStore, THREAD_ID);
expect(afterEntry).not.toBeNull();
expect(afterEntry?.head).toBe(beforeEntry?.head);
expect(afterEntry?.status).toBe(beforeEntry?.status);
});
test("3.3 no new StepNode is written to CAS (step count unchanged)", async () => {
const { casDir, stepHash, mockAgentPath } = await setupAskFixture();
// Count StepNodes before
const { createUwfStore } = await import("../store.js");
const before = await createUwfStore(tmpDir);
const stepSchemaHash = before.schemas.stepNode;
function countStepNodes(uwfStore: typeof before): number {
const candidates = [stepHash];
let count = 0;
for (const h of candidates) {
const node = uwfStore.store.cas.get(h);
if (node !== null && node.type === stepSchemaHash) count++;
}
return count;
}
const beforeCount = countStepNodes(before);
expect(beforeCount).toBe(1);
const result = runUwf(
["step", "ask", stepHash, "-p", "anything", "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
// After ask: still only the seeded StepNode exists at head; no new step appended.
const after = await createUwfStore(tmpDir);
const headNode = after.store.cas.get(stepHash);
expect(headNode).not.toBeNull();
expect(headNode?.type).toBe(after.schemas.stepNode);
// Confirm thread head still points to the original step hash
const { getThread } = await import("../store.js");
const entry = getThread(after.varStore, THREAD_ID);
expect(entry?.head).toBe(stepHash);
});
});
// ── Group 4: Fork cache semantics ─────────────────────────────────────────
describe("uwf step ask - fork cache", () => {
test("4.1 first ask creates a fork session and caches it", async () => {
const { casDir, stepHash, mockAgentPath, forkSessionCapturePath } = await setupAskFixture();
const result = runUwf(
["step", "ask", stepHash, "-p", "first ask", "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
// The mock agent in fork mode receives the source session id
const forkArg = await readFile(forkSessionCapturePath, "utf8");
expect(forkArg).toBe(STEP_SESSION_ID);
// Cache file should now contain the ask key
const cachePath = join(tmpDir, "cache", "mock-sessions.json");
const raw = await readFile(cachePath, "utf8");
const parsed = JSON.parse(raw) as Record<string, string>;
expect(parsed[`${stepHash}:ask`]).toBeDefined();
expect(parsed[`${stepHash}:ask`]).toBe(`forked-from-${STEP_SESSION_ID}`);
});
test("4.2 second ask on same step reuses the cached fork session", async () => {
const cachedFork = "ses-already-forked-once";
const { casDir, stepHash, mockAgentPath, modeCapturePath, askSessionCapturePath } =
await setupAskFixture({ preCachedForkSessionId: cachedFork });
const result = runUwf(
["step", "ask", stepHash, "-p", "second ask", "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
// The mock agent must have been invoked in `ask` mode (no fork performed).
const mode = await readFile(modeCapturePath, "utf8");
expect(mode).toBe("ask");
// The ask invocation should have received the cached fork session id.
const askArg = await readFile(askSessionCapturePath, "utf8");
expect(askArg).toBe(cachedFork);
});
test("4.3 different step hash creates an independent fork", async () => {
// Run a first ask on the base step → caches forkA
const { casDir, stepHash, mockAgentPath } = await setupAskFixture();
const r1 = runUwf(
["step", "ask", stepHash, "-p", "ask on step A", "--agent", mockAgentPath],
casDir,
);
expect(r1.status).toBe(0);
// Build a second StepNode (different hash) with a different sessionId so
// its detail-derived ask session is independent of the first.
const { createUwfStore } = await import("../store.js");
const uwf = await createUwfStore(tmpDir);
const detailSchemaHash = await putSchema(uwf.store, DETAIL_SCHEMA);
const outputSchemaHash = await putSchema(uwf.store, OUTPUT_SCHEMA);
const otherDetailHash = await uwf.store.cas.put(detailSchemaHash, {
sessionId: "ses-original-step-002",
model: "test-model",
duration: 1000,
turnCount: 0,
turns: [],
});
const otherOutputHash = await uwf.store.cas.put(outputSchemaHash, {
$status: "ok",
note: "alt",
});
// Reuse the same start ref the first step points to so the new step is a valid sibling.
const head = uwf.store.cas.get(stepHash);
const startRefFromHead = (head?.payload as { start: CasRef }).start;
const properOtherStep = await uwf.store.cas.put(uwf.schemas.stepNode, {
start: startRefFromHead,
prev: null,
role: "worker",
output: otherOutputHash,
detail: otherDetailHash,
agent: mockAgentPath,
edgePrompt: "Start work",
startedAtMs: 1716600002000,
completedAtMs: 1716600003000,
cwd: tmpDir,
assembledPrompt: null,
usage: null,
});
// sanity check we constructed a separate hash
expect(properOtherStep).not.toBe(stepHash);
const r2 = runUwf(
["step", "ask", properOtherStep, "-p", "ask on step B", "--agent", mockAgentPath],
casDir,
);
expect(r2.status).toBe(0);
const cachePath = join(tmpDir, "cache", "mock-sessions.json");
const raw = await readFile(cachePath, "utf8");
const parsed = JSON.parse(raw) as Record<string, string>;
expect(parsed[`${stepHash}:ask`]).toBeDefined();
expect(parsed[`${properOtherStep}:ask`]).toBeDefined();
expect(parsed[`${stepHash}:ask`]).not.toBe(parsed[`${properOtherStep}:ask`]);
});
});
// ── Group 5: Fallback (agent has no fork support) ─────────────────────────
describe("uwf step ask - fallback path", () => {
test("5.1 fallback agent (no fork support) still answers via stdout", async () => {
// Use a fallback agent that ONLY supports `ask` mode without ever being asked
// to fork. The CLI should detect missing fork support and inject context instead.
const { casDir, stepHash, mockAgentPath } = await setupAskFixture();
// Create a fallback agent script that fails with non-zero exit on "fork" mode.
// Fallback path must NOT call mode=fork; it should call mode=ask directly.
const fallbackPath = join(tmpDir, "fallback-agent.sh");
const promptCapture = join(tmpDir, "fallback-prompt.txt");
const sessionCapture = join(tmpDir, "fallback-session.txt");
const modeCapture = join(tmpDir, "fallback-mode.txt");
await writeFile(
fallbackPath,
`#!/bin/sh
mode=""
prompt=""
session=""
detail=""
while [ $# -gt 0 ]; do
case "$1" in
--mode) mode="$2"; shift 2 ;;
--prompt) prompt="$2"; shift 2 ;;
--session) session="$2"; shift 2 ;;
--detail) detail="$2"; shift 2 ;;
*) shift ;;
esac
done
printf '%s' "$mode" > '${modeCapture}'
printf '%s' "$prompt" > '${promptCapture}'
printf '%s' "$session" > '${sessionCapture}'
case "$mode" in
fork) echo "fork not supported" >&2; exit 99 ;;
ask) printf 'FALLBACK_ANSWER for: %s (detail=%s)\\n' "$prompt" "$detail" ;;
*) echo "unknown" >&2; exit 1 ;;
esac
`,
{ mode: 0o755 },
);
const result = runUwf(
["step", "ask", stepHash, "-p", "explain context", "--agent", fallbackPath, "--no-fork"],
casDir,
);
expect(result.status).toBe(0);
expect(result.stdout).toContain("FALLBACK_ANSWER");
expect(result.stdout).toContain("explain context");
// The fallback agent should be invoked in `ask` mode, with NO session id
// (since no fork happened). The detail ref must be passed for context injection.
const mode = await readFile(modeCapture, "utf8");
expect(mode).toBe("ask");
const session = await readFile(sessionCapture, "utf8");
expect(session).toBe("");
// Make sure mockAgentPath's mock never ran.
void mockAgentPath;
});
test("5.2 fallback ask still does NOT mutate thread state", async () => {
const { casDir, stepHash } = await setupAskFixture();
const fallbackPath = join(tmpDir, "fallback-agent.sh");
await writeFile(
fallbackPath,
`#!/bin/sh
mode=""
prompt=""
while [ $# -gt 0 ]; do
case "$1" in
--mode) mode="$2"; shift 2 ;;
--prompt) prompt="$2"; shift 2 ;;
*) shift ;;
esac
done
case "$mode" in
fork) echo "fork not supported" >&2; exit 99 ;;
ask) printf 'OK %s\\n' "$prompt" ;;
*) exit 1 ;;
esac
`,
{ mode: 0o755 },
);
const { createUwfStore, getThread } = await import("../store.js");
const before = await createUwfStore(tmpDir);
const beforeEntry = getThread(before.varStore, THREAD_ID);
const result = runUwf(
["step", "ask", stepHash, "-p", "any", "--agent", fallbackPath, "--no-fork"],
casDir,
);
expect(result.status).toBe(0);
const after = await createUwfStore(tmpDir);
const afterEntry = getThread(after.varStore, THREAD_ID);
expect(afterEntry?.head).toBe(beforeEntry?.head);
expect(afterEntry?.status).toBe(beforeEntry?.status);
});
});
// ── Group 6: Agent resolution ─────────────────────────────────────────────
describe("uwf step ask - agent resolution", () => {
test("6.1 without --agent flag, agent is resolved from step's agent field", async () => {
// Step's agent field points at mockAgentPath by default.
const { casDir, stepHash, modeCapturePath, promptCapturePath } = await setupAskFixture();
const result = runUwf(["step", "ask", stepHash, "-p", "explain"], casDir);
expect(result.status).toBe(0);
// The mockAgentPath must have been invoked in ask mode with the user prompt.
const mode = await readFile(modeCapturePath, "utf8");
expect(mode).toBe("ask");
const captured = await readFile(promptCapturePath, "utf8");
expect(captured).toBe("explain");
});
test("6.2 --agent override beats step's recorded agent", async () => {
// Record a non-existent agent in step.agent. Provide a working one via --agent.
const { casDir, stepHash, mockAgentPath } = await setupAskFixture({
stepAgentNameOverride: "uwf-does-not-exist",
});
const result = runUwf(
["step", "ask", stepHash, "-p", "explain", "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
expect(result.stdout).toContain("MOCK_ANSWER");
});
});
@@ -167,7 +167,7 @@ describe("cmdThreadList status filter", () => {
expect(result[0]?.status).toBe("completed");
});
test("should return all threads when no status filter provided", async () => {
test("should return only active threads when no filter and no --all", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
@@ -185,8 +185,290 @@ describe("cmdThreadList status filter", () => {
const result = await cmdThreadList(tmpDir, null, null, null, null, null);
// Default behavior (issue #147): only active threads (idle + running)
expect(result).toHaveLength(2);
expect(result.map((r) => r.thread).sort()).toEqual([thread1, thread2].sort());
// Clean up marker
await deleteMarker(tmpDir, thread2);
});
test("should return all threads when --all (showAll=true)", 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 uwfIdx = await createUwfStore(tmpDir);
const index = loadAllThreads(uwfIdx.varStore);
const thread3Head = index[thread3]!.head;
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, true);
expect(result).toHaveLength(3);
expect(result.map((r) => r.thread).sort()).toEqual([thread1, thread2, thread3].sort());
// Clean up marker
await deleteMarker(tmpDir, thread2);
});
});
// ── default behavior tests (issue #147) ───────────────────────────────────────
describe("cmdThreadList default behavior (issue #147)", () => {
test("default returns only idle + running threads", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const threadA = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 4000);
const threadB = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
const threadC = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
const threadD = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
await markThreadRunning(tmpDir, threadB, workflowHash);
const uwfIdx = await createUwfStore(tmpDir);
const index = loadAllThreads(uwfIdx.varStore);
const threadCHead = index[threadC]!.head;
if (threadCHead === undefined) throw new Error("threadC head not found");
await completeThread(tmpDir, threadC, workflowHash, threadCHead);
// Cancel threadD
const threadDHead = index[threadD]!.head;
if (threadDHead === undefined) throw new Error("threadD head not found");
const uwfCancel = await createUwfStore(tmpDir);
completeThreadInStore(uwfCancel.varStore, threadD, "cancelled");
const result = await cmdThreadList(tmpDir, null, null, null, null, null);
expect(result).toHaveLength(2);
expect(result.map((r) => r.thread).sort()).toEqual([threadA, threadB].sort());
await deleteMarker(tmpDir, threadB);
});
test("default excludes completed threads", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const idleThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 6000);
const completedThreads: ThreadId[] = [];
for (let i = 0; i < 5; i++) {
const t = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - (5 - i) * 1000);
completedThreads.push(t);
const uwfIdx = await createUwfStore(tmpDir);
const index = loadAllThreads(uwfIdx.varStore);
const head = index[t]!.head;
if (head === undefined) throw new Error("head not found");
await completeThread(tmpDir, t, workflowHash, head);
}
const result = await cmdThreadList(tmpDir, null, null, null, null, null);
expect(result).toHaveLength(1);
expect(result[0]?.thread).toBe(idleThread);
});
test("default excludes cancelled threads", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const runningThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 4000);
await markThreadRunning(tmpDir, runningThread, workflowHash);
const cancelled: ThreadId[] = [];
for (let i = 0; i < 3; i++) {
const t = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - (3 - i) * 1000);
cancelled.push(t);
const uwfIdx = await createUwfStore(tmpDir);
completeThreadInStore(uwfIdx.varStore, t, "cancelled");
}
const result = await cmdThreadList(tmpDir, null, null, null, null, null);
expect(result).toHaveLength(1);
expect(result[0]?.thread).toBe(runningThread);
await deleteMarker(tmpDir, runningThread);
});
test("--all (showAll=true) returns every status", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const idleThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 4000);
const runningThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
await markThreadRunning(tmpDir, runningThread, workflowHash);
const completedThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
const uwfIdx = await createUwfStore(tmpDir);
const idx = loadAllThreads(uwfIdx.varStore);
const ch = idx[completedThread]!.head;
if (ch === undefined) throw new Error("completedThread head not found");
await completeThread(tmpDir, completedThread, workflowHash, ch);
const cancelledThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
completeThreadInStore(uwfIdx.varStore, cancelledThread, "cancelled");
const result = await cmdThreadList(tmpDir, null, null, null, null, null, true);
expect(result).toHaveLength(4);
expect(result.map((r) => r.thread).sort()).toEqual(
[idleThread, runningThread, completedThread, cancelledThread].sort(),
);
await deleteMarker(tmpDir, runningThread);
});
test("explicit --status overrides default (still returns just the filtered statuses)", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const _idleThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
const runningThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
await markThreadRunning(tmpDir, runningThread, workflowHash);
const completedThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
const uwfIdx = await createUwfStore(tmpDir);
const idx = loadAllThreads(uwfIdx.varStore);
const ch = idx[completedThread]!.head;
if (ch === undefined) throw new Error("completedThread head not found");
await completeThread(tmpDir, completedThread, workflowHash, ch);
const result = await cmdThreadList(tmpDir, ["completed"], null, null, null, null);
expect(result).toHaveLength(1);
expect(result[0]?.thread).toBe(completedThread);
expect(result[0]?.status).toBe("completed");
await deleteMarker(tmpDir, runningThread);
});
test("--status active keeps working", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const idleThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
const runningThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
await markThreadRunning(tmpDir, runningThread, workflowHash);
const completedThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
const uwfIdx = await createUwfStore(tmpDir);
const idx = loadAllThreads(uwfIdx.varStore);
const ch = idx[completedThread]!.head;
if (ch === undefined) throw new Error("completedThread head not found");
await completeThread(tmpDir, completedThread, workflowHash, ch);
const result = await cmdThreadList(tmpDir, ["idle", "running"], null, null, null, null);
expect(result).toHaveLength(2);
expect(result.map((r) => r.thread).sort()).toEqual([idleThread, runningThread].sort());
await deleteMarker(tmpDir, runningThread);
});
test("--status + --all — explicit status wins", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const _idleThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
const runningThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
await markThreadRunning(tmpDir, runningThread, workflowHash);
const completedThread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
const uwfIdx = await createUwfStore(tmpDir);
const idx = loadAllThreads(uwfIdx.varStore);
const ch = idx[completedThread]!.head;
if (ch === undefined) throw new Error("completedThread head not found");
await completeThread(tmpDir, completedThread, workflowHash, ch);
const result = await cmdThreadList(tmpDir, ["completed"], null, null, null, null, true);
expect(result).toHaveLength(1);
expect(result[0]?.thread).toBe(completedThread);
await deleteMarker(tmpDir, runningThread);
});
test("default returns empty when no threads", async () => {
await makeUwfStore(tmpDir);
const result = await cmdThreadList(tmpDir, null, null, null, null, null);
expect(result).toHaveLength(0);
});
test("default + time range filter composes correctly", 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 ts5 = Date.UTC(2026, 4, 24, 0, 0, 0);
const _t1 = await createTestThread(uwf, tmpDir, workflowHash, ts1);
const t2 = await createTestThread(uwf, tmpDir, workflowHash, ts2);
const t3 = await createTestThread(uwf, tmpDir, workflowHash, ts3);
const t4 = await createTestThread(uwf, tmpDir, workflowHash, ts4);
const _t5 = await createTestThread(uwf, tmpDir, workflowHash, ts5);
// Mark t3 running
await markThreadRunning(tmpDir, t3, workflowHash);
// Complete t4 (should be excluded by default)
const uwfIdx = await createUwfStore(tmpDir);
const idx = loadAllThreads(uwfIdx.varStore);
const t4head = idx[t4]!.head;
if (t4head === undefined) throw new Error("t4 head not found");
await completeThread(tmpDir, t4, workflowHash, t4head);
// afterMs in middle of range to exclude _t1
const afterMs = Date.UTC(2026, 4, 20, 12, 0, 0);
const result = await cmdThreadList(tmpDir, null, afterMs, null, null, null);
// Expected: t2 (idle), t3 (running), _t5 (idle); excludes t4 (completed) and _t1 (filtered by time)
expect(result).toHaveLength(3);
const ids = result.map((r) => r.thread).sort();
expect(ids).toEqual([t2, t3, _t5].sort());
await deleteMarker(tmpDir, t3);
});
test("default + pagination composes correctly", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
// Create 10 idle threads + 5 completed threads
const idleThreads: ThreadId[] = [];
for (let i = 0; i < 10; i++) {
idleThreads.push(
await createTestThread(uwf, tmpDir, workflowHash, Date.now() - (15 - i) * 1000),
);
}
for (let i = 0; i < 5; i++) {
const t = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - (5 - i) * 1000);
const uwfIdx = await createUwfStore(tmpDir);
const idx = loadAllThreads(uwfIdx.varStore);
const head = idx[t]!.head;
if (head === undefined) throw new Error("head not found");
await completeThread(tmpDir, t, workflowHash, head);
}
const result = await cmdThreadList(tmpDir, null, null, null, 2, 3);
expect(result).toHaveLength(3);
// All results should be idle (default excludes completed)
for (const r of result) {
expect(r.status).toBe("idle");
}
});
});
@@ -54,7 +54,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { type: string, enum: ["ready"] }
$status: { const: "ready" }
graph:
$START:
new:
@@ -114,7 +114,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { type: string, enum: ["ready"] }
$status: { const: "ready" }
graph:
$START:
new:
@@ -161,7 +161,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { type: string, enum: ["ready"] }
$status: { const: "ready" }
graph:
$START:
new:
@@ -0,0 +1,549 @@
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { putSchema } from "@ocas/core";
import { openStore } from "@ocas/fs";
import type {
CasRef,
StepNodePayload,
ThreadId,
ThreadIndexEntry,
} from "@united-workforce/protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { registerUwfSchemas } from "../schemas.js";
import { seedThreads } from "./thread-test-helpers.js";
const OUTPUT_SCHEMA = {
type: "object" as const,
properties: {
$status: { type: "string" as const },
note: { type: "string" as const },
},
required: ["$status"],
additionalProperties: false,
};
const THREAD_ID = "01POKESTEPTEST00000000" as ThreadId;
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-poke-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
type SetupResult = {
casDir: string;
oldStepHash: CasRef;
oldStepPrev: CasRef | null;
oldStepCompletedAtMs: number;
startHash: CasRef;
workflowHash: CasRef;
mockAgentPath: string;
failingAgentPath: string;
promptCapturePath: string;
envCapturePath: string;
};
type SetupOpts = {
threadStatus: ThreadIndexEntry["status"];
multipleSteps: boolean;
newCompletedAtMs: number;
newStatus: string;
// The agent name to record in the head StepNode.agent field. Defaults to mockAgentPath.
stepAgentNameOverride: string | null;
// Whether to seed an actual head StepNode (false → only StartNode is the head).
withHeadStep: boolean;
};
async function setupThread(opts: Partial<SetupOpts> = {}): Promise<SetupResult> {
const cfg: SetupOpts = {
threadStatus: opts.threadStatus ?? "idle",
multipleSteps: opts.multipleSteps ?? false,
newCompletedAtMs: opts.newCompletedAtMs ?? 1716600005000,
newStatus: opts.newStatus ?? "ok",
stepAgentNameOverride: opts.stepAgentNameOverride ?? null,
withHeadStep: opts.withHeadStep ?? true,
};
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = await openStore(casDir);
const schemas = await registerUwfSchemas(store);
const outputSchemaHash = await putSchema(store, OUTPUT_SCHEMA);
const workflowHash = await store.cas.put(schemas.workflow, {
name: "test-poke",
description: "poke command integration test",
roles: {
worker: {
description: "Worker role",
goal: "Work",
capabilities: [],
procedure: "work",
output: "result",
frontmatter: outputSchemaHash,
},
reviewer: {
description: "Reviewer role",
goal: "Review",
capabilities: [],
procedure: "review",
output: "result",
frontmatter: outputSchemaHash,
},
},
graph: {
$START: {
new: { role: "worker", prompt: "Start work", location: null },
resume: { role: "worker", prompt: "Resume the work", location: null },
},
worker: {
ok: { role: "reviewer", prompt: "Review the work", location: null },
needs_input: {
role: "$SUSPEND",
prompt: "Please clarify",
location: null,
},
},
reviewer: { done: { role: "$END", prompt: "Done", location: null } },
},
});
const startHash = await store.cas.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test poke task",
cwd: tmpDir,
});
process.env.OCAS_HOME = casDir;
// Paths for mock agent and capture files (set early so we can use mockAgentPath as the recorded agent name)
const promptCapturePath = join(tmpDir, "captured-prompt.txt");
const envCapturePath = join(tmpDir, "captured-env.txt");
const mockAgentPath = join(tmpDir, "mock-agent.sh");
const failingAgentPath = join(tmpDir, "failing-agent.sh");
// Build head StepNode chain
let oldStepPrev: CasRef | null = null;
if (cfg.multipleSteps) {
// First step: prev=null
const firstOutputHash = await store.cas.put(outputSchemaHash, { $status: "ok" });
const firstDetailHash = await store.cas.put(schemas.text, "first detail");
const firstStepHash = await store.cas.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: firstOutputHash,
detail: firstDetailHash,
agent: cfg.stepAgentNameOverride ?? mockAgentPath,
edgePrompt: "Start work",
startedAtMs: 1716600000000,
completedAtMs: 1716600001000,
cwd: tmpDir,
assembledPrompt: null,
usage: null,
});
oldStepPrev = firstStepHash;
}
let oldStepHash: CasRef = startHash;
const oldStepCompletedAtMs = 1716600002000;
if (cfg.withHeadStep) {
const outputHash = await store.cas.put(outputSchemaHash, { $status: "ok" });
const detailHash = await store.cas.put(schemas.text, "head step detail");
oldStepHash = await store.cas.put(schemas.stepNode, {
start: startHash,
prev: oldStepPrev,
role: "worker",
output: outputHash,
detail: detailHash,
agent: cfg.stepAgentNameOverride ?? mockAgentPath,
edgePrompt: "Start work",
startedAtMs: 1716600001500,
completedAtMs: oldStepCompletedAtMs,
cwd: tmpDir,
assembledPrompt: null,
usage: null,
});
}
// Seed thread index entry. For "running" we let the test create the marker separately.
await seedThreads(tmpDir, {
[THREAD_ID]: {
head: oldStepHash,
status: cfg.threadStatus,
suspendedRole: cfg.threadStatus === "suspended" ? "worker" : null,
suspendMessage: cfg.threadStatus === "suspended" ? "Please clarify" : null,
completedAt:
cfg.threadStatus === "completed" || cfg.threadStatus === "cancelled"
? oldStepCompletedAtMs
: null,
},
});
// Mock agent always emits a stepNode keyed off the current thread head (which we
// observe through OCAS_HOME). The script writes prompt/env captures and then prints
// an adapter JSON that references a pre-built stepHash.
// We pre-build the agent's stepHash with prev=oldStepHash (normal append behaviour).
const newOutputHash = await store.cas.put(outputSchemaHash, {
$status: cfg.newStatus,
note: "poked output",
});
const newDetailHash = await store.cas.put(schemas.text, "poked detail");
const agentStepHash = await store.cas.put(schemas.stepNode, {
start: startHash,
prev: cfg.withHeadStep ? oldStepHash : null,
role: "worker",
output: newOutputHash,
detail: newDetailHash,
agent: "mock-agent-output",
edgePrompt: "poke prompt placeholder",
startedAtMs: cfg.newCompletedAtMs - 100,
completedAtMs: cfg.newCompletedAtMs,
cwd: tmpDir,
assembledPrompt: null,
usage: null,
});
const adapterJson = JSON.stringify({
stepHash: agentStepHash,
detailHash: newDetailHash,
role: "worker",
frontmatter: { $status: cfg.newStatus, note: "poked output" },
body: "",
startedAtMs: cfg.newCompletedAtMs - 100,
completedAtMs: cfg.newCompletedAtMs,
usage: null,
});
await writeFile(
mockAgentPath,
`#!/bin/sh
prompt=""
while [ $# -gt 0 ]; do
if [ "$1" = "--prompt" ]; then
prompt="$2"
shift 2
else
shift
fi
done
printf '%s' "$prompt" > '${promptCapturePath}'
printf 'OCAS_HOME=%s\\n' "$OCAS_HOME" > '${envCapturePath}'
echo '${adapterJson}'
`,
{ mode: 0o755 },
);
await writeFile(
failingAgentPath,
`#!/bin/sh
echo "boom" >&2
exit 7
`,
{ mode: 0o755 },
);
const configPath = join(tmpDir, "config.yaml");
await writeFile(
configPath,
`defaultAgent: uwf-hermes\ndefaultModel: test-model\nagentOverrides: null\nagents: {}\nproviders: {}\nmodels: {}\n`,
);
return {
casDir,
oldStepHash,
oldStepPrev,
oldStepCompletedAtMs,
startHash,
workflowHash,
mockAgentPath,
failingAgentPath,
promptCapturePath,
envCapturePath,
};
}
function runUwf(
args: string[],
casDir: string,
): { stdout: string; stderr: string; status: number } {
const cliPath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "dist", "cli.js");
try {
const stdout = execFileSync(process.execPath, [cliPath, ...args], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
UWF_HOME: tmpDir,
OCAS_HOME: casDir,
},
cwd: tmpDir,
timeout: 30000,
});
return { stdout, stderr: "", status: 0 };
} catch (error) {
const err = error as NodeJS.ErrnoException & {
stdout?: string | Buffer;
stderr?: string | Buffer;
status?: number;
};
return {
stdout: typeof err.stdout === "string" ? err.stdout : (err.stdout?.toString("utf8") ?? ""),
stderr: typeof err.stderr === "string" ? err.stderr : (err.stderr?.toString("utf8") ?? ""),
status: err.status ?? 1,
};
}
}
// ── Group 1: CLI argument validation ───────────────────────────────────────
describe("uwf thread poke - CLI argument validation", () => {
test("1.1 missing -p flag exits non-zero", async () => {
const { casDir } = await setupThread();
const result = runUwf(["thread", "poke", THREAD_ID], casDir);
expect(result.status).not.toBe(0);
expect(result.stderr.toLowerCase()).toMatch(/required|missing|prompt/);
});
test("1.2 -p without --agent succeeds", async () => {
const { casDir } = await setupThread();
const result = runUwf(["thread", "poke", THREAD_ID, "-p", "do it again"], casDir);
expect(result.status).toBe(0);
});
test("1.3 -p with --agent succeeds", async () => {
const { casDir, mockAgentPath } = await setupThread();
const result = runUwf(
["thread", "poke", THREAD_ID, "-p", "do it again", "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
});
});
// ── Group 2: Guard errors ──────────────────────────────────────────────────
describe("uwf thread poke - guard errors", () => {
test("2.1 thread not found", async () => {
const { casDir } = await setupThread();
const result = runUwf(["thread", "poke", "01NOSUCHTHREAD0000000A", "-p", "prompt"], casDir);
expect(result.status).not.toBe(0);
expect(result.stderr.toLowerCase()).toMatch(/not found|not active/);
});
test("2.2 thread running rejects poke", async () => {
const { casDir, workflowHash } = await setupThread();
// Create background marker to simulate running
const { createMarker } = await import("../background/index.js");
await createMarker(tmpDir, {
thread: THREAD_ID,
workflow: workflowHash,
pid: process.pid,
startedAt: Date.now(),
});
const result = runUwf(["thread", "poke", THREAD_ID, "-p", "prompt"], casDir);
expect(result.status).not.toBe(0);
expect(result.stderr.toLowerCase()).toContain("already executing");
});
test("2.3 completed thread rejects poke", async () => {
const { casDir } = await setupThread({ threadStatus: "completed" });
const result = runUwf(["thread", "poke", THREAD_ID, "-p", "prompt"], casDir);
expect(result.status).not.toBe(0);
expect(result.stderr.toLowerCase()).toMatch(/cannot be poked|completed/);
});
test("2.4 cancelled thread rejects poke", async () => {
const { casDir } = await setupThread({ threadStatus: "cancelled" });
const result = runUwf(["thread", "poke", THREAD_ID, "-p", "prompt"], casDir);
expect(result.status).not.toBe(0);
expect(result.stderr.toLowerCase()).toMatch(/cannot be poked|cancelled/);
});
test("2.5 thread head is StartNode (no StepNode) rejects poke", async () => {
const { casDir } = await setupThread({ withHeadStep: false });
const result = runUwf(["thread", "poke", THREAD_ID, "-p", "prompt"], casDir);
expect(result.status).not.toBe(0);
expect(result.stderr.toLowerCase()).toMatch(/no step|cannot be poked/);
});
});
// ── Group 3: Success happy path ────────────────────────────────────────────
describe("uwf thread poke - success", () => {
test("3.1, 3.4 idle thread → new head differs from old, thread index updated", async () => {
const { casDir, oldStepHash, mockAgentPath } = await setupThread();
const result = runUwf(
["thread", "poke", THREAD_ID, "-p", "redo", "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
const cliOutput = JSON.parse(result.stdout.trim());
expect(cliOutput.head).not.toBe(oldStepHash);
const { createUwfStore, getThread } = await import("../store.js");
const uwf = await createUwfStore(tmpDir);
const entry = getThread(uwf.varStore, THREAD_ID);
expect(entry?.head).toBe(cliOutput.head);
});
test("3.2 new step's prev equals old head's prev (replace, not append)", async () => {
const { casDir, oldStepPrev, mockAgentPath } = await setupThread({ multipleSteps: true });
const result = runUwf(
["thread", "poke", THREAD_ID, "-p", "redo", "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
const cliOutput = JSON.parse(result.stdout.trim());
const { createUwfStore } = await import("../store.js");
const uwf = await createUwfStore(tmpDir);
const node = uwf.store.cas.get(cliOutput.head as CasRef);
expect(node).not.toBeNull();
expect(node?.type).toBe(uwf.schemas.stepNode);
const payload = node?.payload as StepNodePayload;
expect(payload.prev).toBe(oldStepPrev);
});
test("3.2b new step's prev is null when old head was the first step", async () => {
// multipleSteps:false means oldHead.prev = null
const { casDir, mockAgentPath } = await setupThread({ multipleSteps: false });
const result = runUwf(
["thread", "poke", THREAD_ID, "-p", "redo", "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
const cliOutput = JSON.parse(result.stdout.trim());
const { createUwfStore } = await import("../store.js");
const uwf = await createUwfStore(tmpDir);
const node = uwf.store.cas.get(cliOutput.head as CasRef);
const payload = node?.payload as StepNodePayload;
expect(payload.prev).toBeNull();
});
test("3.3 new step's completedAtMs is later than old", async () => {
const { casDir, oldStepCompletedAtMs, mockAgentPath } = await setupThread();
const result = runUwf(
["thread", "poke", THREAD_ID, "-p", "redo", "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
const cliOutput = JSON.parse(result.stdout.trim());
const { createUwfStore } = await import("../store.js");
const uwf = await createUwfStore(tmpDir);
const node = uwf.store.cas.get(cliOutput.head as CasRef);
const payload = node?.payload as StepNodePayload;
expect(payload.completedAtMs).toBeGreaterThan(oldStepCompletedAtMs);
});
test("3.5 status remains idle after poke (no completion/suspend)", async () => {
const { casDir, mockAgentPath } = await setupThread();
const result = runUwf(
["thread", "poke", THREAD_ID, "-p", "redo", "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
const cliOutput = JSON.parse(result.stdout.trim());
expect(cliOutput.status).toBe("idle");
expect(cliOutput.done).toBe(false);
expect(cliOutput.suspendedRole).toBeNull();
expect(cliOutput.suspendMessage).toBeNull();
});
test("3.6 currentRole unchanged after poke (no moderator re-route)", async () => {
// Before poke: idle thread with worker step having $status=ok → moderator would route to reviewer.
// After poke (mock returns same $status=ok), moderator routing remains the same.
const { casDir, mockAgentPath } = await setupThread();
const result = runUwf(
["thread", "poke", THREAD_ID, "-p", "redo", "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
const cliOutput = JSON.parse(result.stdout.trim());
expect(cliOutput.currentRole).toBe("reviewer");
});
});
// ── Group 4: Agent resolution ──────────────────────────────────────────────
describe("uwf thread poke - agent resolution", () => {
test("4.1 without --agent, agent command read from head step's agent field", async () => {
// Head step's agent field points at mockAgentPath (default in setupThread)
const { casDir, promptCapturePath } = await setupThread();
const result = runUwf(["thread", "poke", THREAD_ID, "-p", "redo"], casDir);
expect(result.status).toBe(0);
const captured = await readFile(promptCapturePath, "utf8");
expect(captured).toBe("redo");
});
test("4.2 with --agent, explicit override is used", async () => {
// Head step records "uwf-mock" (which is not a real binary). Override with mockAgentPath.
const { casDir, mockAgentPath } = await setupThread({ stepAgentNameOverride: "uwf-mock" });
const result = runUwf(
["thread", "poke", THREAD_ID, "-p", "redo", "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
});
});
// ── Group 5: Prompt passthrough ────────────────────────────────────────────
describe("uwf thread poke - prompt passthrough", () => {
test("5.1 -p value is passed to agent as --prompt", async () => {
const { casDir, mockAgentPath, promptCapturePath } = await setupThread();
const supplement = "Use the REST API instead.";
const result = runUwf(
["thread", "poke", THREAD_ID, "-p", supplement, "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
const captured = await readFile(promptCapturePath, "utf8");
expect(captured).toBe(supplement);
});
});
// ── Group 6: Edge cases ────────────────────────────────────────────────────
describe("uwf thread poke - edge cases", () => {
test("6.1 poke succeeds on suspended thread", async () => {
const { casDir, oldStepHash, mockAgentPath } = await setupThread({
threadStatus: "suspended",
});
const result = runUwf(
["thread", "poke", THREAD_ID, "-p", "redo", "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
const cliOutput = JSON.parse(result.stdout.trim());
expect(cliOutput.head).not.toBe(oldStepHash);
expect(cliOutput.status).toBe("idle");
expect(cliOutput.suspendedRole).toBeNull();
expect(cliOutput.suspendMessage).toBeNull();
});
test("6.2 agent failure leaves thread head unchanged", async () => {
const { casDir, oldStepHash, failingAgentPath } = await setupThread();
const result = runUwf(
["thread", "poke", THREAD_ID, "-p", "redo", "--agent", failingAgentPath],
casDir,
);
expect(result.status).not.toBe(0);
const { createUwfStore, getThread } = await import("../store.js");
const uwf = await createUwfStore(tmpDir);
const entry = getThread(uwf.varStore, THREAD_ID);
expect(entry?.head).toBe(oldStepHash);
});
});
@@ -31,7 +31,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { type: string, enum: ["ready"] }
$status: { const: "ready" }
graph:
$START:
new:
@@ -54,7 +54,7 @@ roles:
type: object
required: ["$status"]
properties:
$status: { type: string, enum: ["ready"] }
$status: { const: "ready" }
graph:
$START:
new:
@@ -118,8 +118,8 @@ describe("suspended thread display", () => {
[idleThreadId]: idleEntry,
});
// Test thread list
const listResult = await cmdThreadList(tmpDir, null, null, null, null, null);
// Test thread list — pass showAll=true to include suspended threads
const listResult = await cmdThreadList(tmpDir, null, null, null, null, null, true);
// Find the suspended and idle threads in results
const suspendedItem = listResult.find((item) => item.thread === suspendedThreadId);
@@ -17,7 +17,7 @@ function makeWorkflow(overrides?: Partial<WorkflowPayload>): WorkflowPayload {
frontmatter: {
type: "object",
properties: {
$status: { enum: ["done"] },
$status: { const: "done" },
plan: { type: "string" },
},
required: ["$status", "plan"],
@@ -85,7 +85,7 @@ describe("Suite 1: Role Reference Integrity", () => {
output: "None",
frontmatter: {
type: "object",
properties: { $status: { enum: ["done"] } },
properties: { $status: { const: "done" } },
required: ["$status"],
} as unknown as string,
};
@@ -187,7 +187,7 @@ describe("Suite 2: Graph Structure", () => {
output: "Isolated",
frontmatter: {
type: "object",
properties: { $status: { enum: ["done"] } },
properties: { $status: { const: "done" } },
required: ["$status"],
} as unknown as string,
};
@@ -272,8 +272,8 @@ describe("Suite 3: Status-Edge Consistency", () => {
});
});
describe("Suite 3b: Enum-Based Multi-Exit", () => {
test("3b.1 enum multi-exit passes with matching graph keys", () => {
describe("Suite 3b: Enum-Based $status is Rejected", () => {
test("3b.1 enum multi-exit is rejected (must use oneOf + const)", () => {
const wf = makeWorkflow();
wf.roles.reviewer = {
...wf.roles.reviewer,
@@ -291,52 +291,10 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
rejected: { role: "writer", prompt: "Fix: {{{comments}}}", location: null },
};
const errors = validateWorkflow(wf);
expect(errors).toEqual([]);
expect(errors.some((e) => e.includes("must define") && e.includes("const"))).toBe(true);
});
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", location: null },
rejected: { role: "writer", prompt: "Fix", location: null },
timeout: { role: "$END", prompt: "Timed out", location: null },
};
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", location: null },
};
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("missing status keys: rejected"))).toBe(true);
});
test("3b.4 enum with single explicit value passes", () => {
test("3b.2 enum single-exit is rejected (must use const)", () => {
const wf = makeWorkflow();
wf.roles.writer = {
...wf.roles.writer,
@@ -351,28 +309,71 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
};
wf.graph.writer = { ready: { role: "reviewer", prompt: "Review: {{{plan}}}", location: null } };
const errors = validateWorkflow(wf);
expect(errors).toEqual([]);
expect(errors.some((e) => e.includes("must define") && e.includes("const"))).toBe(true);
});
});
test("3b.5 enum multi-exit mustache var not in frontmatter", () => {
describe("Suite 3c: Const-Based Flat Schema", () => {
test("3c.1 flat schema with const $status passes validation", () => {
const wf = makeWorkflow();
wf.roles.reviewer = {
...wf.roles.reviewer,
wf.roles.writer = {
...wf.roles.writer,
frontmatter: {
type: "object",
properties: {
$status: { enum: ["approved", "rejected"] },
comments: { type: "string" },
$status: { const: "done" },
plan: { type: "string" },
},
required: ["$status", "comments"],
required: ["$status", "plan"],
} as unknown as string,
};
wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done: {{{nonexistent}}}", location: null },
rejected: { role: "writer", prompt: "Fix: {{{comments}}}", location: null },
const errors = validateWorkflow(wf);
expect(errors).toEqual([]);
});
test("3c.2 flat schema with const $status detects extra graph key", () => {
const wf = makeWorkflow();
wf.roles.writer = {
...wf.roles.writer,
frontmatter: {
type: "object",
properties: {
$status: { const: "done" },
plan: { type: "string" },
},
required: ["$status", "plan"],
} as unknown as string,
};
wf.graph.writer = {
done: { role: "reviewer", prompt: "Review.", location: null },
extra: { role: "$END", prompt: "Nope.", location: null },
};
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("nonexistent") && e.includes("not found"))).toBe(true);
expect(errors.some((e) => e.includes("extra status keys") && e.includes("extra"))).toBe(true);
});
test("3c.3 flat schema with const $status validates mustache vars", () => {
const wf = makeWorkflow();
wf.roles.writer = {
...wf.roles.writer,
frontmatter: {
type: "object",
properties: {
$status: { const: "done" },
plan: { type: "string" },
},
required: ["$status", "plan"],
} as unknown as string,
};
wf.graph.writer = {
done: { role: "reviewer", prompt: "Review: {{{nonexistent}}}", location: null },
};
const errors = validateWorkflow(wf);
expect(
errors.some(
(e) => e.includes('prompt variable "nonexistent"') && e.includes('role "writer"'),
),
).toBe(true);
});
});
@@ -480,7 +481,7 @@ describe("Suite 6: Multiple Errors Collection", () => {
output: "None",
frontmatter: {
type: "object",
properties: { $status: { enum: ["done"] } },
properties: { $status: { const: "done" } },
required: ["$status"],
} as unknown as string,
};
@@ -0,0 +1,225 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasRef, WorkflowPayload } from "@united-workforce/protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { stringify } from "yaml";
import { cmdThreadStart } from "../commands/thread.js";
import { cmdWorkflowList } from "../commands/workflow.js";
import type { UwfStore } from "../store.js";
import { createUwfStore, discoverProjectWorkflows } from "../store.js";
// ── helpers ───────────────────────────────────────────────────────────────────
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = join(storageRoot, "cas");
await mkdir(casDir, { recursive: true });
process.env.OCAS_HOME = casDir;
return createUwfStore(storageRoot);
}
function makeMinimalPayload(name: string, description: string): WorkflowPayload {
return {
name,
description,
roles: {
worker: {
description: "worker role",
goal: "do work",
capabilities: [],
procedure: "",
output: "",
frontmatter: {
type: "object",
properties: {
$status: { const: "done" },
},
required: ["$status"],
} as unknown as CasRef,
},
},
graph: {
$START: {
new: { role: "worker", prompt: "start working", location: null },
resume: { role: "worker", prompt: "resume working", location: null },
},
worker: { done: { role: "$END", prompt: "done", location: null } },
},
};
}
async function createWorkflowYaml(name: string, version: string | null = null): Promise<string> {
const payload = makeMinimalPayload(
name,
version !== null ? `Test workflow (${version})` : "Test workflow",
);
return stringify(payload);
}
// ── fixture ───────────────────────────────────────────────────────────────────
let tmpDir: string;
let storageRoot: string;
let projectRoot: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "uwf-wf-list-recursive-"));
storageRoot = join(tmpDir, "storage");
projectRoot = join(tmpDir, "project");
await mkdir(storageRoot, { recursive: true });
await mkdir(projectRoot, { recursive: true });
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ── discoverProjectWorkflows — parent traversal ───────────────────────────────
describe("discoverProjectWorkflows — parent traversal", () => {
test("B1: finds workflows in cwd's .workflow/", async () => {
const wfDir = join(projectRoot, ".workflow");
await mkdir(wfDir, { recursive: true });
await writeFile(join(wfDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue"));
const entries = await discoverProjectWorkflows(projectRoot);
expect(entries.map((e) => e.name)).toContain("solve-issue");
});
test("B2: finds workflows in ancestor's .workflow/ when called from subdirectory", async () => {
const wfDir = join(projectRoot, ".workflow");
await mkdir(wfDir, { recursive: true });
await writeFile(join(wfDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue"));
const subdir = join(projectRoot, "packages", "cli", "src");
await mkdir(subdir, { recursive: true });
const entries = await discoverProjectWorkflows(subdir);
expect(entries.map((e) => e.name)).toContain("solve-issue");
});
test("B3: returns [] when no .workflow/ exists in any ancestor", async () => {
// Use a deep path under tmpDir that has no .workflow/ on the way up.
// (Traversal will stop at filesystem root and find nothing.)
const deepPath = join(tmpDir, "isolated", "no", "workflow", "here");
await mkdir(deepPath, { recursive: true });
const entries = await discoverProjectWorkflows(deepPath);
expect(entries).toEqual([]);
});
test("B4: .workflow/ entries win over .workflows/ within the same directory", async () => {
const wfDir = join(projectRoot, ".workflow");
const legacyDir = join(projectRoot, ".workflows");
await mkdir(wfDir, { recursive: true });
await mkdir(legacyDir, { recursive: true });
await writeFile(
join(wfDir, "solve-issue.yaml"),
await createWorkflowYaml("solve-issue", "new"),
);
await writeFile(
join(legacyDir, "solve-issue.yaml"),
await createWorkflowYaml("solve-issue", "legacy"),
);
const entries = await discoverProjectWorkflows(projectRoot);
const match = entries.find((e) => e.name === "solve-issue");
expect(match).toBeDefined();
expect(match?.filePath).toBe(join(wfDir, "solve-issue.yaml"));
});
test("B5: nearest .workflow/ wins over ancestor's .workflow/", async () => {
const ancestorWf = join(projectRoot, ".workflow");
await mkdir(ancestorWf, { recursive: true });
await writeFile(join(ancestorWf, "foo.yaml"), await createWorkflowYaml("foo", "ancestor"));
const nearDir = join(projectRoot, "pkg");
const nearWf = join(nearDir, ".workflow");
await mkdir(nearWf, { recursive: true });
await writeFile(join(nearWf, "foo.yaml"), await createWorkflowYaml("foo", "near"));
const entries = await discoverProjectWorkflows(nearDir);
const match = entries.find((e) => e.name === "foo");
expect(match).toBeDefined();
expect(match?.filePath).toBe(join(nearWf, "foo.yaml"));
// Should not include duplicates from ancestor
expect(entries.filter((e) => e.name === "foo")).toHaveLength(1);
});
test("B6: returns all entries from the nearest .workflow/ when called from a deep subdir", async () => {
const wfDir = join(projectRoot, ".workflow");
await mkdir(wfDir, { recursive: true });
await writeFile(join(wfDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue"));
await writeFile(join(wfDir, "review-code.yaml"), await createWorkflowYaml("review-code"));
const deep = join(projectRoot, "a", "b", "c", "d");
await mkdir(deep, { recursive: true });
const entries = await discoverProjectWorkflows(deep);
const names = entries.map((e) => e.name).sort();
expect(names).toEqual(["review-code", "solve-issue"]);
});
test("B7: discovers folder-based layout (name/index.yaml) via parent traversal", async () => {
const folderDir = join(projectRoot, ".workflow", "solve-issue");
await mkdir(folderDir, { recursive: true });
await writeFile(join(folderDir, "index.yaml"), await createWorkflowYaml("solve-issue"));
const subdir = join(projectRoot, "deep", "sub");
await mkdir(subdir, { recursive: true });
const entries = await discoverProjectWorkflows(subdir);
const match = entries.find((e) => e.name === "solve-issue");
expect(match).toBeDefined();
expect(match?.filePath).toBe(join(folderDir, "index.yaml"));
});
});
// ── cmdWorkflowList — parent traversal ───────────────────────────────────────
describe("cmdWorkflowList — parent traversal", () => {
test("B9: lists local workflows discovered from a subdirectory", async () => {
await makeUwfStore(storageRoot);
const wfDir = join(projectRoot, ".workflow");
await mkdir(wfDir, { recursive: true });
await writeFile(join(wfDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue"));
const subdir = join(projectRoot, "packages", "foo", "src");
await mkdir(subdir, { recursive: true });
const result = await cmdWorkflowList(storageRoot, subdir);
const match = result.find((e) => e.name === "solve-issue");
expect(match).toBeDefined();
expect(match?.hash).toBe("(local)");
expect(match?.origin).toBe("local");
});
test("aligns with cmdThreadStart discovery from same subdirectory", async () => {
await makeUwfStore(storageRoot);
const wfDir = join(projectRoot, ".workflow");
await mkdir(wfDir, { recursive: true });
await writeFile(join(wfDir, "foo.yaml"), await createWorkflowYaml("foo"));
const subdir = join(projectRoot, "packages", "foo", "src");
await mkdir(subdir, { recursive: true });
// cmdThreadStart already resolves foo successfully from subdir (existing behavior)
const startResult = await cmdThreadStart(storageRoot, "foo", "prompt", subdir);
expect(startResult.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
// cmdWorkflowList must ALSO include foo (newly aligned behavior)
const listResult = await cmdWorkflowList(storageRoot, subdir);
const match = listResult.find((e) => e.name === "foo");
expect(match).toBeDefined();
expect(match?.origin).toBe("local");
});
});
@@ -31,7 +31,7 @@ function makeMinimalPayload(name: string, description: string): WorkflowPayload
frontmatter: {
type: "object",
properties: {
$status: { type: "string", enum: ["done"] },
$status: { const: "done" },
},
required: ["$status"],
} as unknown as CasRef,
+63 -8
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env node
#!/usr/bin/env -S node --disable-warning=ExperimentalWarning
import type { CasRef, ThreadId, ThreadStatus } from "@united-workforce/protocol";
import { Command } from "commander";
@@ -11,12 +11,13 @@ import {
cmdPromptUsage,
cmdPromptWorkflowAuthoring,
} from "./commands/prompt.js";
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
import { cmdStepFork, cmdStepList, cmdStepRead, cmdStepShow } from "./commands/step.js";
import { cmdSetup, cmdSetupInteractive, resolvePresetBaseUrl } from "./commands/setup.js";
import { cmdStepAsk, cmdStepFork, cmdStepList, cmdStepRead, cmdStepShow } from "./commands/step.js";
import {
cmdThreadCancel,
cmdThreadExec,
cmdThreadList,
cmdThreadPoke,
cmdThreadRead,
cmdThreadResume,
cmdThreadShow,
@@ -232,11 +233,12 @@ function parsePaginationOptions(
thread
.command("list")
.description("List threads")
.description("List threads (defaults to active: idle + running)")
.option(
"--status <status>",
"Filter by status: idle, running, completed, cancelled, active (idle+running), or comma-separated values",
)
.option("--all", "Show all threads regardless of status (overrides default active-only filter)")
.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")
@@ -244,6 +246,7 @@ thread
.action(
(opts: {
status: string | undefined;
all: boolean | undefined;
after: string | undefined;
before: string | undefined;
skip: string | undefined;
@@ -255,6 +258,7 @@ thread
const nowMs = Date.now();
const { afterMs, beforeMs } = parseTimeFilters(opts.after, opts.before, nowMs);
const { skip, take } = parsePaginationOptions(opts.skip, opts.take);
const showAll = opts.all === true;
const result = await cmdThreadList(
storageRoot,
@@ -263,6 +267,7 @@ thread
beforeMs,
skip,
take,
showAll,
);
writeOutput(result);
});
@@ -290,6 +295,26 @@ thread
});
});
thread
.command("poke")
.description("Re-run the head step's agent with a supplementary prompt (replaces head step)")
.argument("<thread-id>", "Thread ULID")
.requiredOption("-p, --prompt <text>", "Supplementary prompt for the agent")
.option("--agent <cmd>", "Override agent command (defaults to head step's agent)")
.action((threadId: string, opts: { prompt: string; agent: string | undefined }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const agentOverride = opts.agent ?? null;
const result = await cmdThreadPoke(
storageRoot,
threadId as ThreadId,
opts.prompt,
agentOverride,
);
writeOutput(result);
});
});
thread
.command("stop")
.description("Stop background execution of a thread (keep thread active)")
@@ -369,6 +394,32 @@ step
});
});
step
.command("ask")
.description(
"Ask a follow-up question to a historical step's agent (read-only; no thread mutation)",
)
.argument("<step-hash>", "CAS hash of the StepNode to query")
.requiredOption("-p, --prompt <text>", "Question to ask the step's agent")
.option("--agent <cmd>", "Override agent command (defaults to the step's recorded agent)")
.option(
"--no-fork",
"Skip session-fork; spawn the agent in a fresh ask session and inject the step's detail ref for context",
)
.action(
(stepHash: string, opts: { prompt: string; agent: string | undefined; fork: boolean }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const stdout = await cmdStepAsk(storageRoot, stepHash as CasRef, {
prompt: opts.prompt,
agentOverride: opts.agent ?? null,
fork: opts.fork,
});
process.stdout.write(stdout.endsWith("\n") ? stdout : `${stdout}\n`);
});
},
);
step
.command("read")
.description("Read a step's turns as human-readable markdown")
@@ -542,7 +593,7 @@ prompt
program
.command("setup")
.description("Configure provider, model, and agent")
.description("Configure provider, model, and agent. Run without options for interactive wizard.")
.option("--provider <name>", "Provider name")
.option("--base-url <url>", "OpenAI-compatible API base URL")
.option("--api-key <key>", "API key")
@@ -558,10 +609,14 @@ program
}) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
if (opts.provider && opts.baseUrl && opts.apiKey && opts.model) {
// Resolve preset base-url when provider is known but --base-url is omitted
const resolvedBaseUrl =
opts.baseUrl ??
(opts.provider !== undefined ? resolvePresetBaseUrl(opts.provider) : null);
if (opts.provider && resolvedBaseUrl && opts.apiKey && opts.model) {
const result = await cmdSetup({
provider: opts.provider,
baseUrl: opts.baseUrl,
baseUrl: resolvedBaseUrl,
apiKey: opts.apiKey,
model: opts.model,
agent: opts.agent ?? undefined,
@@ -572,7 +627,7 @@ program
await cmdSetupInteractive(storageRoot);
} else {
throw new Error(
"Non-interactive setup requires all of: --provider, --base-url, --api-key, --model",
"Non-interactive setup requires: --provider, --api-key, --model (--base-url is optional for preset providers)",
);
}
});
+286 -18
View File
@@ -1,10 +1,35 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
generateAdapterDevelopingReference,
generateUsageReference,
generateWorkflowAuthoringReference,
VERSION,
} from "@united-workforce/util";
// CLI package version (for bootstrap prompt — uwf --version prints this)
// Walk up from __dirname to find the nearest package.json (works from both src/ and dist/)
function _findCliVersion(): string {
let dir = dirname(fileURLToPath(import.meta.url));
for (let i = 0; i < 5; i++) {
const candidate = join(dir, "package.json");
try {
const pkg = JSON.parse(readFileSync(candidate, "utf-8")) as {
name?: string;
version?: string;
};
if (pkg.name === "@united-workforce/cli") {
return pkg.version ?? "0.0.0";
}
} catch {
// not found, keep walking
}
dir = dirname(dir);
}
return "0.0.0";
}
const CLI_VERSION = _findCliVersion();
export {
generateAdapterDevelopingReference as cmdPromptAdapterDeveloping,
generateUsageReference as cmdPromptUsage,
@@ -22,33 +47,275 @@ export function cmdPromptList(): ReadonlyArray<string> {
}
export function cmdPromptBootstrap(): string {
return `# uwf Skill Bootstrap
return `# uwf Bootstrap
Install or update the uwf (United Workforce) skills for your agent.
Set up or upgrade uwf (United Workforce) from zero to running your first workflow.
## Steps
## Scenario A: Fresh Install
1. **Check if uwf CLI is installed:**
### Step 0 Environment pre-flight check
Run ALL checks below BEFORE installing anything. If any check fails, fix it first do NOT skip ahead.
\`\`\`bash
# 1. Node.js
node --version # need v20+
# FIX: install via nvm (https://github.com/nvm-sh/nvm) or your OS package manager
# 2. Package manager pnpm (preferred) or npm
pnpm --version || npm --version
# FIX for pnpm: corepack enable pnpm (Node 20+)
# or: npm install -g pnpm
# If pnpm is not an option, npm works too adjust install commands below
# 3. Global bin directory is in PATH
# For pnpm:
pnpm bin -g # prints the global bin directory
# For npm:
npm prefix -g # global prefix; bin is <prefix>/bin
# The directory printed above MUST be in your PATH.
# Test: echo \\$PATH | tr ':' '\\\\n'
# FIX: add the missing directory to your shell's startup file
# (e.g. ~/.bashrc, ~/.zshrc, ~/.profile, or fish config):
# export PATH="<global-bin-dir>:$PATH"
\`\`\`
**All checks must pass before continuing.** If you had to modify PATH, verify the change persists by opening a new shell or sourcing your shell config.
### Step 1 Discover agents and install adapter
**First, detect which supported agents are already installed on the user's machine:**
\`\`\`bash
# Check for Hermes Agent
which hermes 2>/dev/null && hermes --version
# Check for Claude Code
which claude 2>/dev/null && claude --version # should show "X.Y.Z (Claude Code)"
\`\`\`
**Based on the results:**
- **Only hermes found** install \`uwf-hermes\` adapter
- **Only claude found** install \`uwf-claude-code\` adapter
- **Both found** ask the user which agent they want uwf to use as default
- **Neither found** the user must install at least one agent first:
- Hermes Agent: https://hermes-agent.nousresearch.com/docs
- Claude Code: \`npm install -g @anthropic-ai/claude-code\`
**Install the uwf CLI and the chosen adapter** using pnpm or npm:
\`\`\`bash
# CLI (required)
pnpm add -g @united-workforce/cli # or: npm install -g @united-workforce/cli
# Adapter install the one matching the detected agent:
pnpm add -g @united-workforce/agent-hermes # or: npm i -g @united-workforce/agent-hermes
pnpm add -g @united-workforce/agent-claude-code # or: npm i -g @united-workforce/agent-claude-code
\`\`\`
** Adapter versions are independent from CLI versions.** Do NOT try to match adapter version to CLI version. Just install \`@latest\` (the default).
**After installing, verify that \`uwf\` and the adapter are available in PATH:**
\`\`\`bash
uwf --version # should print ${CLI_VERSION}
uwf-hermes --version # or: uwf-claude-code --version
\`\`\`
If either command is not found, the global bin directory is not in the current shell's PATH. **You must fix this before continuing:**
1. Find where the binary was installed:
\`\`\`bash
uwf --help
find ~/.local ~/.hermes /usr/local -name uwf -type f 2>/dev/null
npm prefix -g # global prefix bin is <prefix>/bin
\`\`\`
If not installed: \`npm install -g @united-workforce/cli\`
Current version: ${VERSION}
2. Add the directory to PATH permanently by appending to the user's shell config (e.g. \`~/.bashrc\`, \`~/.zshrc\`, \`~/.profile\`, or fish config):
\`\`\`bash
export PATH="<global-bin-dir>:$PATH"
\`\`\`
3. Source the updated config or open a new shell, then re-verify the commands work.
2. **Install the skills** by running each command below and creating a skill from its output:
- Run \`uwf prompt usage\` → create a skill named \`uwf-usage\`
- Run \`uwf prompt workflow-authoring\` → create a skill named \`uwf-workflow-authoring\`
- Run \`uwf prompt adapter-developing\` → create a skill named \`uwf-adapter-developing\`
**uwf-hermes** also requires the Hermes ACP plugin. Verify with \`hermes acp --help\`. If not available, install it:
\`\`\`bash
# Option A: install into hermes venv (recommended)
source ~/.hermes/hermes-agent/.venv/bin/activate && pip install hermes-agent[acp]
Each command outputs a complete SKILL.md with YAML frontmatter use your agent framework's skill creation API to save them.
# Option B: pipx
pipx install 'hermes-agent[acp]'
3. **Verify** the skills are loadable by your agent framework.
# Option C: if installed from source
pip install -e '.[acp]'
\`\`\`
## Updating
### Step 2 Configure provider and model
When \`uwf\` is upgraded, re-run \`uwf prompt bootstrap\` and follow the steps again.
The skill content is bundled with the CLI always use \`uwf prompt <name>\` to get
content matching your installed version.
uwf needs an LLM provider to run agents. **Ask the user** for their provider, API key, and model, then run:
\`\`\`bash
uwf setup --provider <name> --api-key <key> --model <model> --agent <adapter-command>
\`\`\`
**Note:** \`--agent\` takes the adapter **command name** (e.g. \`uwf-hermes\`), not the npm package name.
**Preset providers** when using a preset name, \`--base-url\` is auto-filled and can be omitted:
| Provider | Name | Default base URL |
|----------|------|-----------------|
| OpenAI | \`openai\` | https://api.openai.com/v1 |
| xAI | \`xai\` | https://api.x.ai/v1 |
| OpenRouter | \`openrouter\` | https://openrouter.ai/api/v1 |
| Venice | \`venice\` | https://api.venice.ai/api/v1 |
| Dashscope | \`dashscope\` | https://dashscope.aliyuncs.com/compatible-mode/v1 |
| DeepSeek | \`deepseek\` | https://api.deepseek.com/v1 |
| SiliconFlow | \`siliconflow\` | https://api.siliconflow.cn/v1 |
| VolcEngine | \`volcengine\` | https://ark.cn-beijing.volces.com/api/v3 |
| Kimi (Moonshot) | \`kimi\` | https://api.moonshot.cn/v1 |
| GLM (Zhipu AI) | \`glm\` | https://open.bigmodel.cn/api/paas/v4 |
| StepFun | \`stepfun\` | https://api.stepfun.com/v1 |
| MiniMax | \`minimax\` | https://api.minimax.io/v1 |
| Ollama (local) | \`ollama\` | http://localhost:11434/v1 |
For **non-preset providers**, you must specify \`--base-url\` manually.
Example:
\`\`\`bash
uwf setup --provider openrouter --api-key sk-or-... --model anthropic/claude-sonnet-4 --agent uwf-hermes
\`\`\`
If the user doesn't know what to choose, suggest \`openrouter\` with \`anthropic/claude-sonnet-4\` as a sensible default.
Config is saved to \`~/.uwf/config.yaml\`. Verify with \`cat ~/.uwf/config.yaml\`.
### Step 3 Install skills
Run each command and create a skill from its output:
\`\`\`bash
uwf prompt usage # save as skill "uwf-usage"
uwf prompt workflow-authoring # save as skill "uwf-workflow-authoring"
uwf prompt adapter-developing # save as skill "uwf-adapter-developing"
\`\`\`
Each command outputs a complete SKILL.md with YAML frontmatter. Use your agent framework's skill creation API to save them (e.g. \`skill_manage(action='create', name='uwf-usage', content=<output>)\`).
Verify skills are installed by listing them (e.g. \`skills_list()\`) and confirming all three appear.
** After saving all skills, start a new session** so the agent loads the updated skill content. Skills saved in the current session are not active until the next session.
### Step 4 Verify end-to-end
Create a minimal workflow file to test your setup:
\`\`\`bash
cat > /tmp/hello.yaml << 'YAML'
name: hello
description: Minimal smoke test
roles:
greeter:
description: "Greet the user"
goal: "Respond with a friendly greeting"
capabilities: []
procedure: "Write a short greeting based on the prompt."
output: "A greeting message."
frontmatter:
type: object
properties:
$status: { const: done }
message: { type: string }
required: [$status, message]
graph:
$START:
new: { role: greeter, prompt: "Say hello to the user." }
resume: { role: greeter, prompt: "Greet the user again." }
greeter:
done: { role: "$END", prompt: "Done." }
YAML
\`\`\`
Then run:
\`\`\`bash
uwf thread start /tmp/hello.yaml -p "Hello, world!"
uwf thread exec <thread-id>
uwf thread show <thread-id>
\`\`\`
If the thread reaches \`$END\` with status \`completed\`, the setup is working.
## Scenario B: Upgrade from Previous Version
### Step 1 Update packages
\`\`\`bash
# Using pnpm
pnpm add -g @united-workforce/cli@latest
# Using npm
npm install -g @united-workforce/cli@latest
\`\`\`
\`\`\`bash
uwf --version # should print ${CLI_VERSION}
\`\`\`
Also update your adapter(s):
\`\`\`bash
# pnpm
pnpm add -g @united-workforce/agent-hermes@latest
# npm
npm install -g @united-workforce/agent-hermes@latest
\`\`\`
### Step 2 Regenerate skills
Skill content is bundled with the CLI always regenerate after upgrading:
\`\`\`bash
uwf prompt usage # update skill "uwf-usage"
uwf prompt workflow-authoring # update skill "uwf-workflow-authoring"
uwf prompt adapter-developing # update skill "uwf-adapter-developing"
\`\`\`
** After updating skills, start a new session** to load the new skill content.
### Step 3 Migrate workflow YAML files (if needed)
Check the changelog for breaking changes. Known migrations:
- **v0.2.0**: \`$START._\`\`$START.new\` + \`$START.resume\`. All workflow YAML files must be updated:
\`\`\`yaml
# Before (v0.1.x)
$START:
_: { role: planner, prompt: "..." }
# After (v0.2.0+)
$START:
new: { role: planner, prompt: "..." }
resume: { role: planner, prompt: "Review previous run and continue." }
\`\`\`
Update all \`.workflow/\` and \`.workflows/\` YAML files in your projects. \`uwf workflow add\` will reject files with the old \`_\` syntax.
- **v0.2.1**: \`$status: { enum: [value] }\`\`$status: { const: "value" }\`. The validator no longer accepts \`enum\` for \`$status\`. Update all workflow YAML files:
\`\`\`yaml
# Before (v0.2.0)
$status: { enum: [done] }
$status: { type: string, enum: ["ready", "failed"] }
# After (v0.2.1+)
$status: { const: "done" }
# For multi-exit, use oneOf with const (unchanged)
\`\`\`
### Step 4 Verify
\`\`\`bash
uwf thread start <your-workflow> -p "upgrade test"
uwf thread exec <thread-id>
\`\`\`
## Available prompts
@@ -57,6 +324,7 @@ uwf prompt list # list available prompt names
uwf prompt usage # CLI usage guide
uwf prompt workflow-authoring # workflow YAML design guide
uwf prompt adapter-developing # building agent adapters
uwf prompt bootstrap # this guide
\`\`\`
`;
}
+49 -1
View File
@@ -1,3 +1,4 @@
import { execFileSync } from "node:child_process";
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";
@@ -72,6 +73,12 @@ const PRESET_PROVIDERS = [
{ name: "ollama", label: "Ollama (local)", baseUrl: "http://localhost:11434/v1" },
] as const;
/** Look up the base URL for a preset provider name. Returns null if not a preset. */
export function resolvePresetBaseUrl(providerName: string): string | null {
const preset = PRESET_PROVIDERS.find((p) => p.name === providerName);
return preset !== undefined ? preset.baseUrl : null;
}
type SetupArgs = {
provider: string;
baseUrl: string;
@@ -175,7 +182,6 @@ export async function _discoverAgents(): Promise<string[]> {
async function _tryWhichDiscovery(): Promise<string[] | null> {
try {
const { execFileSync } = await import("node:child_process");
const text = execFileSync("which", ["-a", "uwf-hermes", "uwf-claude-code", "uwf-cursor"], {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
@@ -391,6 +397,37 @@ function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record
};
}
/**
* Check if the configured adapter binary (and its dependencies) are in PATH.
* Returns warnings array empty means all good.
*/
export function _checkAdapterAvailability(agentName: string): string[] {
const warnings: string[] = [];
const binary = `uwf-${agentName}`;
try {
execFileSync("which", [binary], { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
} catch {
warnings.push(
`${binary} not found in PATH. Install it: pnpm add -g @united-workforce/agent-${agentName}`,
);
return warnings; // skip dependency check if adapter itself is missing
}
// uwf-hermes depends on hermes CLI
if (agentName === "hermes") {
try {
execFileSync("which", ["hermes"], { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
} catch {
warnings.push(
'hermes CLI not found in PATH (required by uwf-hermes). Fix: export PATH="$HOME/.hermes/hermes-agent/.venv/bin:$PATH"',
);
}
}
return warnings;
}
/**
* Non-interactive setup. All required args provided via CLI flags.
*/
@@ -405,15 +442,26 @@ export async function cmdSetup(args: SetupArgs): Promise<Record<string, unknown>
writeFileSync(configPath, stringify(merged, { indent: 2 }), "utf8");
// Print config path to stderr (stdout is reserved for JSON output)
console.error(`Config saved to ${configPath}`);
// Validate model connectivity
const validation = await validateModel(args.baseUrl, args.apiKey, args.model);
// Check adapter availability
const agentName = _agentNameFromBinary(args.agent ?? "hermes");
const adapterWarnings = _checkAdapterAvailability(agentName);
for (const w of adapterWarnings) {
console.error(`${w}`);
}
return {
configPath,
provider: args.provider,
model: args.model,
defaultAgent: merged.defaultAgent,
validation,
adapterWarnings,
};
}
+221 -1
View File
@@ -1,5 +1,8 @@
import { execFileSync } from "node:child_process";
import type { CasStore } from "@ocas/core";
import type {
AgentAlias,
AgentConfig,
CasRef,
StartEntry,
StepEntry,
@@ -7,9 +10,12 @@ import type {
ThreadForkOutput,
ThreadId,
ThreadStepsOutput,
WorkflowConfig,
WorkflowPayload,
} from "@united-workforce/protocol";
import { generateUlid } from "@united-workforce/util";
import { createUwfStore, setThread } from "../store.js";
import { getAskSessionId, loadWorkflowConfig, setAskSessionId } from "@united-workforce/util-agent";
import { createUwfStore, setThread, type UwfStore } from "../store.js";
import {
collectOrderedSteps,
expandDeep,
@@ -341,3 +347,217 @@ export async function cmdStepRead(
return formatStepMarkdown(stepHash, payload.role, payload.agent, turnData, selectedTurns);
}
// ── step ask ────────────────────────────────────────────────────────────────
function parseAgentOverride(override: string): AgentConfig {
const parts = override
.trim()
.split(/\s+/)
.filter((p) => p.length > 0);
const command = parts[0];
if (command === undefined) {
fail("agent override must not be empty");
}
return { command, args: parts.slice(1) };
}
function resolveAskAgentConfig(
config: WorkflowConfig,
workflow: WorkflowPayload | null,
role: string,
agentOverride: string | null,
recordedAgent: string,
): AgentConfig {
if (agentOverride !== null) {
const fromAlias = config.agents[agentOverride as AgentAlias];
if (fromAlias !== undefined) {
return fromAlias;
}
return parseAgentOverride(agentOverride);
}
// Try to resolve via the recorded agent name as a config alias.
const fromRecorded = config.agents[recordedAgent as AgentAlias];
if (fromRecorded !== undefined) {
return fromRecorded;
}
// Fall back to default agent for the workflow / role.
if (workflow !== null && config.agentOverrides !== null) {
const roleOverrides = config.agentOverrides[workflow.name];
if (roleOverrides !== undefined && roleOverrides[role] !== undefined) {
const alias = roleOverrides[role];
const agentConfig = config.agents[alias];
if (agentConfig !== undefined) {
return agentConfig;
}
}
}
// Treat the recorded value as a raw command path.
return parseAgentOverride(recordedAgent);
}
/**
* Derive the agent name used for cache file partitioning from an executable
* path or alias. Examples:
* uwf-hermes hermes
* uwf-claude-code claude-code
* /tmp/mock-agent.sh mock
* /usr/bin/agent agent
*/
function deriveAgentName(commandPath: string): string {
const basename = commandPath.split(/[/\\]/).pop() ?? commandPath;
// Strip a trailing extension (.sh, .js, .mjs, .cjs)
const noExt = basename.replace(/\.(sh|js|mjs|cjs|ts)$/i, "");
// Strip the `uwf-` prefix introduced by agentLabel().
const noPrefix = noExt.startsWith("uwf-") ? noExt.slice(4) : noExt;
// Strip the trailing `-agent` suffix used by tests / generic agent shells.
const noSuffix = noPrefix.endsWith("-agent") ? noPrefix.slice(0, -"-agent".length) : noPrefix;
return noSuffix === "" ? noExt : noSuffix;
}
function loadDetailNode(
store: CasStore,
detailRef: CasRef,
): { sessionId: string | null; payload: Record<string, unknown> } {
const detailNode = store.get(detailRef);
if (detailNode === null) {
fail(`detail node not found: ${detailRef}`);
}
const payload = detailNode.payload as Record<string, unknown>;
const sessionId = typeof payload.sessionId === "string" ? payload.sessionId : null;
return { sessionId, payload };
}
function spawnAskAgent(agent: AgentConfig, argv: string[], cwd: string): { stdout: string } {
try {
const stdout = execFileSync(agent.command, [...agent.args, ...argv], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 50 * 1024 * 1024,
cwd,
});
return { stdout };
} catch (e) {
const err = e as NodeJS.ErrnoException & { stderr: Buffer | string | null };
if (err.code === "ENOENT") {
fail(
`"${agent.command}" not found in PATH. Install it or check your PATH config. Run: which ${agent.command}`,
);
}
const stderr =
err.stderr == null
? ""
: typeof err.stderr === "string"
? err.stderr
: err.stderr.toString("utf8");
const detail = stderr.trim() !== "" ? `: ${stderr.trim()}` : "";
fail(`agent command failed (${agent.command})${detail}`);
}
}
function resolveAskWorkflow(uwf: UwfStore, payload: StepNodePayload): WorkflowPayload | null {
const startNode = uwf.store.cas.get(payload.start);
if (startNode === null) {
return null;
}
const start = startNode.payload as { workflow: CasRef };
const workflowNode = uwf.store.cas.get(start.workflow);
if (workflowNode === null) {
return null;
}
return workflowNode.payload as WorkflowPayload;
}
async function performFork(
agent: AgentConfig,
agentName: string,
stepHash: CasRef,
sourceSessionId: string,
storageRoot: string,
cwd: string,
): Promise<string> {
const cached = await getAskSessionId(agentName, stepHash, storageRoot);
if (cached !== null) {
return cached;
}
const { stdout } = spawnAskAgent(agent, ["--mode", "fork", "--session", sourceSessionId], cwd);
const newSessionId = stdout.trim().split("\n").pop()?.trim() ?? "";
if (newSessionId === "") {
fail(`agent fork did not return a session id (${agent.command})`);
}
await setAskSessionId(agentName, stepHash, newSessionId, storageRoot);
return newSessionId;
}
export type CmdStepAskOptions = {
prompt: string;
agentOverride: string | null;
/** When false, skip session forking and pass detail ref for context injection. */
fork: boolean;
};
/**
* Ask a follow-up question to a historical step's agent (read-only).
*
* Does NOT write a new StepNode and does NOT mutate thread state. The agent's
* raw stdout is returned so the CLI entry point can stream it directly.
*/
export async function cmdStepAsk(
storageRoot: string,
stepHash: CasRef,
options: CmdStepAskOptions,
): Promise<string> {
const uwf = await createUwfStore(storageRoot);
const node = uwf.store.cas.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) {
fail(`step ${stepHash} has no detail; cannot ask`);
}
const detailRef = payload.detail;
const { sessionId: sourceSessionId } = loadDetailNode(uwf.store.cas, detailRef);
const workflow = resolveAskWorkflow(uwf, payload);
const config = await loadWorkflowConfig(storageRoot);
const agent = resolveAskAgentConfig(
config,
workflow,
payload.role,
options.agentOverride,
payload.agent,
);
const agentName = deriveAgentName(agent.command);
const cwd = payload.cwd !== "" ? payload.cwd : process.cwd();
// Fork path: fork (or reuse cached fork) → ask with that session.
if (options.fork && sourceSessionId !== null) {
const askSessionId = await performFork(
agent,
agentName,
stepHash,
sourceSessionId,
storageRoot,
cwd,
);
const argv = ["--mode", "ask", "--session", askSessionId, "--prompt", options.prompt];
argv.push("--detail", detailRef);
const { stdout } = spawnAskAgent(agent, argv, cwd);
return stdout;
}
// Fallback path: ask without forking; inject detail ref for context.
const argv = ["--mode", "ask", "--prompt", options.prompt];
argv.push("--detail", detailRef);
const { stdout } = spawnAskAgent(agent, argv, cwd);
return stdout;
}
+160 -5
View File
@@ -199,6 +199,7 @@ const PL_THREAD_ARCHIVED = "F4D8Q2K5";
const PL_STEP_ERROR = "B8T5N1V6";
const PL_BACKGROUND_START = "X7Q4W9M2";
const PL_THREAD_RESUME = "K2R7M4N8";
const PL_THREAD_POKE = "P4Q9R3X7";
type ResumeStepConfig = {
role: string;
@@ -649,18 +650,25 @@ export async function cmdThreadList(
beforeMs: number | null,
skip: number | null,
take: number | null,
showAll: boolean = false,
): Promise<ThreadListItemWithStatus[]> {
const uwf = await createUwfStore(storageRoot);
const index = loadActiveThreads(uwf.varStore);
// Resolve the effective filter:
// - explicit --status wins (showAll has no effect)
// - otherwise: --all → no filter; default → ["idle", "running"]
const effectiveFilter: ThreadStatus[] | null =
statusFilter !== null ? statusFilter : showAll ? null : ["idle", "running"];
// Collect active threads
let items = await collectActiveThreads(storageRoot, uwf, index);
// Collect completed threads (if relevant for status filter)
const includeCompleted =
statusFilter === null ||
statusFilter.includes("completed") ||
statusFilter.includes("cancelled");
effectiveFilter === null ||
effectiveFilter.includes("completed") ||
effectiveFilter.includes("cancelled");
if (includeCompleted) {
const activeIds = new Set(items.map((i) => i.thread));
const completedItems = collectCompletedThreads(uwf, activeIds);
@@ -668,8 +676,8 @@ export async function cmdThreadList(
}
// Apply status filter
if (statusFilter !== null) {
items = items.filter((item) => statusFilter.includes(item.status));
if (effectiveFilter !== null) {
items = items.filter((item) => effectiveFilter.includes(item.status));
}
// Apply time range filters
@@ -1004,6 +1012,12 @@ function spawnAgent(
});
} catch (e) {
const err = e as NodeJS.ErrnoException & { stderr?: Buffer | string | null };
if (err.code === "ENOENT") {
failStep(
plog,
`"${agent.command}" not found in PATH. Install it or check your PATH config. Run: which ${agent.command}`,
);
}
const stderr =
err.stderr == null
? ""
@@ -1129,6 +1143,147 @@ export async function cmdThreadResume(
});
}
/**
* Validate that a thread can be poked. Returns the existing entry and the head StepNode payload.
* Fails (process exit) when the thread is missing, running, completed, cancelled, or has no
* StepNode at its head.
*/
async function validatePokePreconditions(
storageRoot: string,
uwf: UwfStore,
threadId: ThreadId,
): Promise<{ entry: ThreadIndexEntry; oldHead: CasRef; oldHeadPayload: StepNodePayload }> {
const runningMarker = await isThreadRunning(storageRoot, threadId);
if (runningMarker !== null) {
fail(`thread already executing in background (PID: ${runningMarker.pid})`);
}
const entry = getThread(uwf.varStore, threadId);
if (entry === null) {
fail(`thread not active: ${threadId}`);
}
if (entry.status === "completed" || entry.status === "cancelled") {
fail(`thread cannot be poked: ${threadId} (status: ${entry.status})`);
}
const oldHead = entry.head;
const oldHeadNode = uwf.store.cas.get(oldHead);
if (oldHeadNode === null) {
fail(`CAS node not found: ${oldHead}`);
}
if (oldHeadNode.type !== uwf.schemas.stepNode) {
fail("thread cannot be poked: no step to replace (head is StartNode)");
}
return { entry, oldHead, oldHeadPayload: oldHeadNode.payload as StepNodePayload };
}
/**
* Resolve the next role from the post-poke chain state, used for the StepOutput.currentRole field.
* Returns null when the next role is $END, evaluation fails, or the result is a suspend.
*/
function resolveCurrentRoleFromChain(
uwfAfter: UwfStore,
workflow: WorkflowPayload,
replacedHash: CasRef,
): string | null {
const chainAfter = walkChain(uwfAfter, replacedHash);
const { lastRole, lastOutput } = resolveEvaluateArgs(uwfAfter, chainAfter);
const afterResult = evaluate(workflow.graph, lastRole, lastOutput);
if (!afterResult.ok || isSuspendResult(afterResult.value)) {
return null;
}
if (afterResult.value.role === END_ROLE) {
return null;
}
return afterResult.value.role;
}
/**
* Poke a thread: re-run the agent on the head step with a supplementary prompt,
* replacing the head step's output. The new step's `prev` points to the OLD head's
* `prev` semantically replacing (not appending to) the head. The moderator is NOT
* re-evaluated for routing; the role of the head step is re-used.
*/
export async function cmdThreadPoke(
storageRoot: string,
threadId: ThreadId,
prompt: string,
agentOverride: string | null,
): Promise<StepOutput> {
const uwf = await createUwfStore(storageRoot);
const { entry, oldHeadPayload } = await validatePokePreconditions(storageRoot, uwf, threadId);
const chain = walkChain(uwf, entry.head);
const workflowHash = chain.start.workflow;
const threadCwd = chain.start.cwd;
const plog = createProcessLogger({
storageRoot,
context: { thread: threadId, workflow: workflowHash },
});
// Resolve the agent: --agent override wins; otherwise read from old head step's `agent` field.
const config = await loadWorkflowConfig(storageRoot);
const workflow = loadWorkflowPayload(uwf, workflowHash);
const role = oldHeadPayload.role;
const agent =
agentOverride !== null
? resolveAgentConfig(config, workflow, role, agentOverride)
: parseAgentOverride(oldHeadPayload.agent);
const effectiveCwd = oldHeadPayload.cwd !== "" ? oldHeadPayload.cwd : threadCwd;
plog.log(PL_THREAD_POKE, `poke role=${role} agent=${agent.command}`, null);
plog.log(PL_AGENT_SPAWN, `spawning agent command=${agent.command}`, {
args: [...agent.args, threadId, role].join(" "),
});
loadDotenv({ path: getEnvPath(storageRoot) });
// Spawn the agent. The agent will create a new StepNode with prev=oldHead (it reads
// the active thread head). After the agent returns, we rewrite that node's prev so
// that the new head replaces the old head instead of appending after it.
const agentResult = spawnAgent(plog, agent, threadId, role, prompt, effectiveCwd);
const agentStepHash = agentResult.stepHash as CasRef;
plog.log(PL_AGENT_DONE, `agent returned head=${agentStepHash}`, null);
const uwfAfter = await createUwfStore(storageRoot);
const agentNode = uwfAfter.store.cas.get(agentStepHash);
if (agentNode === null || agentNode.type !== uwfAfter.schemas.stepNode) {
failStep(plog, `agent returned hash that is not a StepNode: ${agentStepHash}`);
}
const agentPayload = agentNode.payload as StepNodePayload;
// Rewrite the new step so that its `prev` points to the OLD head's prev (replace semantics).
const replacedPayload: StepNodePayload = {
...agentPayload,
prev: oldHeadPayload.prev,
};
const replacedHash = await uwfAfter.store.cas.put(uwfAfter.schemas.stepNode, replacedPayload);
const replacedNode = uwfAfter.store.cas.get(replacedHash);
if (replacedNode === null || !validate(uwfAfter.store, replacedNode)) {
failStep(plog, "rewritten StepNode failed schema validation");
}
// Update thread head to the replaced step. Status becomes idle (no moderator re-route).
setThread(uwfAfter.varStore, threadId, updateThreadHead(entry, replacedHash));
return {
workflow: workflowHash,
thread: threadId,
head: replacedHash,
status: "idle",
currentRole: resolveCurrentRoleFromChain(uwfAfter, workflow, replacedHash),
suspendedRole: null,
suspendMessage: null,
done: false,
background: null,
};
}
export function validateCount(count: number): void {
if (count < 1 || !Number.isInteger(count)) {
throw new Error(`--count must be a positive integer, got: ${count}`);
+43 -15
View File
@@ -2,7 +2,7 @@ import type { Dirent } from "node:fs";
import { existsSync } from "node:fs";
import { access, mkdir, readdir, readFile, rename } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { dirname, join, resolve as resolvePath } from "node:path";
import { bootstrap, type Hash, type Store, type VarStore } from "@ocas/core";
import { createFsStore, createSqliteVarStore } from "@ocas/fs";
@@ -83,23 +83,51 @@ async function scanWorkflowDir(dir: string): Promise<ProjectWorkflowEntry[]> {
}
/**
* Scan `<projectRoot>/.workflow/` (preferred) and `.workflows/` (legacy) for workflow entries.
* .workflow/ takes priority: if a name is found in both, .workflow/ wins.
* Returns an empty array if neither directory exists.
* Discover project-local workflows by walking from `startDir` up through parent
* directories. The nearest directory that contains a `.workflow/` or `.workflows/`
* directory wins once a match is found, traversal stops (entries from more
* distant ancestors are NOT merged in).
*
* Within the winning directory:
* - `.workflow/` (preferred) takes priority over `.workflows/` (legacy).
* - If both exist in that directory, `.workflow/` entries win when names collide.
*
* This matches the resolution strategy of `findWorkflowInParents` used by
* `uwf thread start`, so `uwf workflow list` and `uwf thread start` agree on
* what's discoverable from any given subdirectory.
*
* Returns an empty array if no `.workflow/` or `.workflows/` directory exists
* anywhere from `startDir` up to the filesystem root.
*/
export async function discoverProjectWorkflows(
projectRoot: string,
): Promise<ProjectWorkflowEntry[]> {
const primary = await scanWorkflowDir(join(projectRoot, ".workflow"));
const legacy = await scanWorkflowDir(join(projectRoot, ".workflows"));
const seen = new Set(primary.map((e) => e.name));
const merged = [...primary];
for (const entry of legacy) {
if (!seen.has(entry.name)) {
merged.push(entry);
export async function discoverProjectWorkflows(startDir: string): Promise<ProjectWorkflowEntry[]> {
let currentDir = resolvePath(startDir);
const root = resolvePath("/");
while (true) {
const primary = await scanWorkflowDir(join(currentDir, ".workflow"));
const legacy = await scanWorkflowDir(join(currentDir, ".workflows"));
if (primary.length > 0 || legacy.length > 0) {
const seen = new Set(primary.map((e) => e.name));
const merged = [...primary];
for (const entry of legacy) {
if (!seen.has(entry.name)) {
merged.push(entry);
}
}
return merged;
}
// Stop at filesystem root
if (currentDir === root) {
return [];
}
const parentDir = dirname(currentDir);
if (parentDir === currentDir) {
return [];
}
currentDir = parentDir;
}
return merged;
}
/** Default filesystem root for uwf data (`~/.uwf`). */
+13 -13
View File
@@ -24,22 +24,22 @@ function isOneOfSchema(fm: unknown): fm is SchemaObj & { oneOf: SchemaObj[] } {
return Array.isArray(obj.oneOf);
}
/** Check if a frontmatter schema declares "$status" as an enum (the required form for user roles). */
function hasStatusEnum(fm: unknown): boolean {
/** Check if a frontmatter schema declares "$status" as const (flat schema form). */
function hasStatusConst(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;
return Array.isArray(props.$status.enum);
return typeof props.$status.const === "string";
}
/** Extract status values from an enum-based $status field. */
function getEnumStatuses(fm: SchemaObj): string[] {
/** Extract status values from a const-based $status field. */
function getConstStatuses(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[];
if (typeof statusDef.const === "string") return [statusDef.const];
return [];
}
/** Get property names from a schema object. */
@@ -248,21 +248,21 @@ function checkRoleConsistency(payload: WorkflowPayload, errors: string[]): void
checkOneOfDiscriminant(roleName, variants, statuses, errors);
checkStatusEdges(roleName, graphKeys, new Set(statuses), errors);
checkMultiExitMustache(roleName, graphEntry, variants, errors);
} else if (hasStatusEnum(fm)) {
const statuses = getEnumStatuses(fm as SchemaObj);
} else if (hasStatusConst(fm)) {
const statuses = getConstStatuses(fm as SchemaObj);
checkStatusEdges(roleName, graphKeys, new Set(statuses), errors);
// For enum-based schemas, mustache vars come from the flat properties
checkEnumMustache(roleName, graphEntry, fm as SchemaObj, errors);
// For const-based flat schemas, mustache vars come from the flat properties
checkFlatMustache(roleName, graphEntry, fm as SchemaObj, errors);
} else {
errors.push(
`role "${roleName}" must define "$status" as an enum (or oneOf const) in frontmatter`,
`role "${roleName}" must define "$status" as const (or oneOf with const) in frontmatter`,
);
}
}
}
/** Check mustache vars in all edge prompts against flat schema properties. */
function checkEnumMustache(
function checkFlatMustache(
roleName: string,
graphEntry: Record<string, { role: string; prompt: string }>,
fm: SchemaObj,

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