Compare commits

..

24 Commits

Author SHA1 Message Date
xiaoju 45dacf540b feat: thread step --count/-c <number> to run multiple steps
Add --count/-c flag to 'uwf thread step' for running N steps in one
invocation, stopping early if $END is reached.

- cmdThreadStep now loops up to count times, delegates to cmdThreadStepOnce
- CLI parses -c/--count, defaults to 1 (backward compatible single output)
- Validation rejects 0, negative, and non-integer counts
- 7 new tests covering CLI parsing and count validation

Fixes #373

Co-authored-by: uwf-hermes (solve-issue workflow)
2026-05-22 08:06:26 +00:00
xiaomo 2eb5ee0666 Merge pull request 'fix: accept omitted condition in fallback transitions' (#378) from fix/fallback-transition-validation into main 2026-05-22 07:56:18 +00:00
xiaoju e67932c83c fix: accept omitted condition in fallback transitions
Fallback transitions (last entry in graph node) omit the condition
field in YAML, resulting in undefined instead of null. The validator
and materializer now handle this:

- validate.ts: accept undefined as valid condition value
- workflow.ts: normalizeGraph() coerces undefined → null before CAS put

This was broken by the graph fallback pattern introduced in #370.
2026-05-22 07:38:24 +00:00
xiaomo 04a12231c3 Merge pull request 'feat: register $first/$last JSONata functions in moderator' (#377) from feat/376-first-last-jsonata into main 2026-05-22 07:32:17 +00:00
xiaoju e5ae9a134c feat: register $first/$last JSONata functions in moderator
Register custom $first(role) and $last(role) functions in the JSONata
evaluator. These search the steps array and return the matching role's
frontmatter (output) directly, replacing verbose steps[-1].output.x
expressions with semantic $last('role').field syntax.

- workflow-moderator: register functions via expr.registerFunction()
- Updated all condition expressions in .workflows/ and examples/
- Added tests for $last, $first, and unmatched role (undefined)

Fixes #376
2026-05-22 06:29:56 +00:00
xiaomo bdafaf3aa1 Merge pull request 'refactor!: rename RoleDefinition.meta → frontmatter' (#375) from refactor/374-meta-to-frontmatter into main 2026-05-22 06:06:06 +00:00
xiaoju 02f7f0b708 refactor!: rename RoleDefinition.meta → frontmatter
BREAKING CHANGE: All workflow YAML files must use 'frontmatter' instead of 'meta'.

- workflow-protocol: RoleDefinition.meta → frontmatter, schema updated
- cli-workflow: validate.ts, workflow.ts — resolveMetaRef → resolveFrontmatterRef
- workflow-agent-kit: run.ts — metaSchema → frontmatterSchema
- All YAML files updated (examples/, .workflows/)

Fixes #374
2026-05-22 06:05:07 +00:00
xiaoju 8ea554bb5e Merge pull request 'feat: create .workflows/solve-issue.yaml' (#372) from feat/370-solve-issue-workflow into main 2026-05-22 06:02:15 +00:00
xiaoju 8a425521da fix: output instructions now specify required frontmatter meta fields 2026-05-22 05:42:17 +00:00
xiaoju f174f2fd0a fix: remove redundant condition null from $START 2026-05-22 05:33:39 +00:00
xiaoju 355594d074 refactor: graph fallback pattern + positive condition names
- Last transition in each graph node is now the fallback (no condition)
- Remove redundant positive conditions (ready, devDone, approved, passed, pushSuccess)
- notApproved → rejected (positive naming)
2026-05-22 05:31:43 +00:00
xiaoju fd7609fe90 fix: address review feedback from xingyue
1. npm/npx → bun/bunx (project standard)
2. Fix tea CLI usage (tea comment + -r flag)
3. cursor-agent → coding (abstract capability)
4. Clarify committer inherits developer's worktree
5. Mark meta.plan required when status=ready
6. PR description must follow What/Why/Changes/Ref template
7. Note maxRounds loop protection in description
2026-05-22 05:27:21 +00:00
xiaoju dacecfbbb7 feat: create .workflows/solve-issue.yaml
TDD-driven issue resolution workflow with 5 roles:
- planner: analyzes issue, outputs TDD test spec (stored in CAS)
- developer: implements code following TDD
- reviewer: code standards compliance check (not functionality)
- tester: functional correctness verification
- committer: commits and creates PR

Graph handles bounce-backs: reviewer→developer, tester→developer,
tester→planner (fix_spec), committer→developer (hook_failed).

Refs #370
2026-05-22 05:21:19 +00:00
xiaomo 3238eaeddf Merge pull request 'feat: add uwf skill cli command and Prepare section' (#371) from feat/369-uwf-skill-cli into main 2026-05-22 04:50:12 +00:00
xiaoju 995f273fa5 address review: move CLI reference to workflow-util, inline in prompt
- Move generateCliReference() to @uncaged/workflow-util
- buildRolePrompt inlines CLI reference directly (no agent tool call)
- Fix Role terminology to use new field names
- Add maintenance comment in cli-reference.ts
- Fix test assertions
2026-05-22 03:29:01 +00:00
xiaoju 866154ad73 feat: add uwf skill cli command and Prepare section in role prompt
- Add 'uwf skill cli' command that prints markdown CLI reference
- buildRolePrompt now generates ## Prepare section:
  - Always prompts agent to run 'uwf skill cli' (explicit skill)
  - Renders capabilities as keyword hints for implicit skill loading

Fixes #369
2026-05-22 03:20:04 +00:00
xiaomo 8efc5050cb Merge pull request 'chore: exclude legacy code from biome check' (#368) from chore/ignore-legacy-biome into main 2026-05-22 02:10:20 +00:00
xiaoju 3fb60ee649 chore: exclude legacy-packages and scripts from biome check
- Add legacy-packages/ and scripts/ to biome ignore
- Allow noDefaultExport in vitest.config.* and .d.ts
- Allow console in cli.ts and setup.ts (CLI user output)
- Fix unused imports in cas.ts and setup.ts
2026-05-22 02:09:18 +00:00
xiaomo e181f67a2d Merge pull request 'feat: support project-local workflow discovery' (#367) from feat/365-project-local-workflows into main 2026-05-22 02:07:33 +00:00
xiaoju a3114bf840 chore: apply biome formatting across codebase 2026-05-22 02:06:05 +00:00
xiaoju e59ae9aca1 feat: support project-local workflow discovery
- Add .workflows/*.yaml scanning from project root (cwd)
- Resolution: project-local first, then global registry
- On-the-fly CAS materialization for local workflows
- Filename/name consistency check
- uwf workflow list shows origin (local/global)

Fixes #365
2026-05-22 01:01:45 +00:00
xiaomo c050a38f38 Merge pull request 'refactor: rename RoleDefinition fields for clarity' (#366) from refactor/364-rename-role-fields into main 2026-05-22 00:48:23 +00:00
xiaoju c60c310074 refactor: rename RoleDefinition fields for clarity
- identity → goal
- prepare → capabilities (string[])
- execute → procedure
- report → output
- outputSchema → meta

Fixes #364
2026-05-22 00:46:06 +00:00
xiaomo fe035c065d Merge pull request 'feat: Role 四段式描述 (identity/prepare/execute/report)' (#361) from feat/359-role-four-phase into main 2026-05-21 03:11:00 +00:00
46 changed files with 1099 additions and 288 deletions
+167
View File
@@ -0,0 +1,167 @@
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. Read CLAUDE.md (or equivalent project conventions file) to understand coding standards
3. Assess whether the issue has enough information to produce a test spec
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output status=insufficient_info and terminate
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
On subsequent runs (bounced back by tester with fix_spec):
1. Read the tester's output from the previous step to understand what's wrong with the spec
2. Revise the test spec accordingly
After producing the test spec:
1. Store it via `uwf cas put "<markdown content>"` and capture the returned hash
2. Put the hash in meta.plan (required when status=ready)
output: "Output a brief summary of the test spec. Frontmatter must include: status (ready or insufficient_info) and plan (CAS hash of the test spec, required when status=ready)."
frontmatter:
type: object
properties:
status:
type: string
enum: [ready, insufficient_info]
plan:
type: string
required: [status]
developer:
description: "TDD implementation per test spec"
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
capabilities:
- coding
procedure: |
1. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's meta.plan)
2. If bounced back from reviewer or tester: read the previous role's output to understand what needs fixing
3. Write tests first based on the spec
4. Implement the code to make tests pass
5. Ensure `bun run build` passes with no errors
6. Run `bun test` to verify all tests pass
output: "List all files changed and provide a summary. Frontmatter must include: status (done or failed)."
frontmatter:
type: object
properties:
status:
type: string
enum: [done, failed]
required: [status]
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: |
Hard checks (must all pass):
1. `bun run build` — no build errors
2. `bunx biome check` — no lint violations
3. TypeScript strict mode — no type errors
Soft checks (review against CLAUDE.md conventions):
- Functional-first: `function` + `type`, not `class` + `interface`
- No optional properties (`?:`) — use `T | null`
- Naming conventions (kebab-case files, PascalCase types, camelCase functions)
- Module boundary discipline (folder exports via index.ts)
- No `console.log` (use structured logger)
- No dynamic imports in production code
Only review standards compliance. Do NOT test functionality.
If rejecting, you MUST explain the specific reason in your output.
output: "Explain your decision with specific file/line references. Frontmatter must include: approved (true or false)."
frontmatter:
type: object
properties:
approved:
type: boolean
required: [approved]
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: |
1. Run `bun test` for automated test verification
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's meta.plan)
3. Verify each scenario in the spec is covered and passing
4. Determine outcome:
- passed: all scenarios verified, tests pass
- fix_code: tests fail or implementation doesn't match spec → send back to developer
- fix_spec: the spec itself is wrong or incomplete → send back to planner
output: "Report test results per scenario. Frontmatter must include: status (passed, fix_code, or fix_spec)."
frontmatter:
type: object
properties:
status:
type: string
enum: [passed, fix_code, fix_spec]
required: [status]
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: |
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
1. Stage all changes: `git add -A`
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
3. Push the branch: `git push -u origin <branch-name>`
- If push hook fails: capture the error log in your output, mark hook_failed
4. On push success: create a PR via `tea pr create --title "..." --description "..."`
- PR description must follow the project template: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
output: "Include PR URL on success or error log on failure. Frontmatter must include: success (true or false)."
frontmatter:
type: object
properties:
success:
type: boolean
required: [success]
conditions:
insufficientInfo:
description: "Planner determined there's not enough info to proceed"
expression: "$last('planner').status = 'insufficient_info'"
devFailed:
description: "Developer failed to implement"
expression: "$last('developer').status = 'failed'"
rejected:
description: "Reviewer rejected the implementation"
expression: "$last('reviewer').approved = false"
fixCode:
description: "Tester found code issues"
expression: "$last('tester').status = 'fix_code'"
fixSpec:
description: "Tester found spec issues"
expression: "$last('tester').status = 'fix_spec'"
hookFailed:
description: "Push hook failed"
expression: "$last('committer').success = false"
graph:
$START:
- role: "planner"
planner:
- role: "$END"
condition: "insufficientInfo"
- role: "developer"
developer:
- role: "$END"
condition: "devFailed"
- role: "reviewer"
reviewer:
- role: "developer"
condition: "rejected"
- role: "tester"
tester:
- role: "developer"
condition: "fixCode"
- role: "planner"
condition: "fixSpec"
- role: "committer"
committer:
- role: "developer"
condition: "hookFailed"
- role: "$END"
+13 -1
View File
@@ -5,6 +5,8 @@
"**",
"!**/dist",
"!**/node_modules",
"!**/legacy-packages",
"!scripts",
"!packages/workflow/workflow",
"!xiaoju/scripts/bundle.ts"
]
@@ -36,7 +38,7 @@
}
},
{
"includes": ["**/*.d.ts"],
"includes": ["**/*.d.ts", "**/vitest.config.*"],
"linter": {
"rules": {
"style": {
@@ -44,6 +46,16 @@
}
}
}
},
{
"includes": ["**/cli.ts", "**/setup.ts"],
"linter": {
"rules": {
"suspicious": {
"noConsole": "off"
}
}
}
}
],
"linter": {
+32 -15
View File
@@ -85,8 +85,13 @@ description: "End-to-end issue resolution"
roles:
planner:
description: "Creates implementation plan"
systemPrompt: "You are a planning agent. Analyze the issue and create a step-by-step plan."
outputSchema:
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 }
@@ -94,8 +99,13 @@ roles:
required: [plan, steps]
developer:
description: "Implements code changes"
systemPrompt: "You are a developer agent. Implement the plan."
outputSchema:
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 } }
@@ -103,8 +113,12 @@ roles:
required: [filesChanged, summary]
reviewer:
description: "Reviews code changes"
systemPrompt: "You are a code reviewer. Review the implementation."
outputSchema:
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 }
@@ -133,7 +147,7 @@ graph:
Key properties:
- **`roles`** — inline role definitions; each `outputSchema` is a JSON Schema (stored as its own CAS node on registration)
- **`roles`** — inline role definitions; each `meta` is a JSON Schema (stored as its own CAS node on registration)
- **`conditions`** — named JSONata expressions evaluated against the `ModeratorContext`
- **`graph`** — `Record<Role | "$START", Transition[]>` — first matching transition wins; `condition: null` = fallback
- **No agent binding** — agent selection is a deployment concern, configured in `config.yaml`
@@ -156,7 +170,7 @@ Each `uwf thread step` runs exactly one cycle: moderator → agent → extract.
│ Output: raw string (frontmatter markdown)
│ Phase 3: EXTRACT
│ Input: raw agent output + role's outputSchema
│ Input: raw agent output + role's meta schema
│ Engine: two-layer extract (frontmatter fast path → LLM fallback)
│ Output: CasRef to structured output node
@@ -213,7 +227,7 @@ Contract:
- Parses argv
- Loads `.env` from storage root
- Builds `AgentContext` by walking the CAS chain from `threads.yaml` head
- Resolves the role's `outputSchema` and builds `outputFormatInstruction`
- 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)
@@ -242,7 +256,7 @@ scope: role
Fixed the login redirect by updating the auth middleware...
```
The `outputFormatInstruction` (built by `buildOutputFormatInstruction` in `workflow-agent-kit`) is prepended to the role's system prompt, so the deliverable format is the first thing the agent sees. It lists the expected frontmatter fields derived from the role's JSON Schema.
The `outputFormatInstruction` (built by `buildOutputFormatInstruction` in `workflow-agent-kit`) is prepended to the role's system prompt, so the deliverable format is the first thing the agent sees. It lists the expected frontmatter fields derived from the role's `meta` JSON Schema.
## Two-layer extract
@@ -253,7 +267,7 @@ Structured output extraction uses a two-layer strategy (`workflow-agent-kit`):
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 `outputSchema`
4. `store.put()` the candidate against the role's `meta` schema
5. Validate with `json-cas` schema validation
6. If valid → return `outputHash` (zero LLM cost)
@@ -272,7 +286,7 @@ If the fast path returns `null` (no frontmatter, invalid, or doesn't satisfy sch
`workflow-agent-kit` prepends two pieces of context to the agent's system prompt:
1. **Deliverable format instruction** — generated from the role's `outputSchema`, tells the agent exactly what frontmatter fields to produce and the expected format
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.
@@ -289,8 +303,11 @@ payload:
roles:
planner:
description: "Creates implementation plan"
systemPrompt: "You are a planning agent..."
outputSchema: "5GWKR8TN1V3JA" # cas_ref → JSON Schema node
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" # cas_ref → JSON Schema node
conditions:
notApproved:
description: "Reviewer rejected"
@@ -318,7 +335,7 @@ payload:
start: "4TNVW8KR2B3MA" # cas_ref → StartNode
prev: "2MXBG6PN4A8JR" # cas_ref → previous StepNode (null for first step)
role: "developer"
output: "9KRVW3TN5F1QA" # cas_ref → structured output (validated against outputSchema)
output: "9KRVW3TN5F1QA" # cas_ref → structured output (validated against meta schema)
detail: "7BQST3VW9F2MA" # cas_ref → execution detail (raw turns, session data)
agent: "uwf-hermes" # agent command used (plain string)
```
+27 -15
View File
@@ -112,8 +112,8 @@ uwf-hermes <thread-id> <role>
**约定:**
- `uwf step` 负责 moderator 决策,将 role 传给 agent CLI
- agent-kit 根据 thread + role 从 CAS 读 systemPrompt / outputSchema
- agent-kit 组装完整 prompt(role systemPrompt + thread context + user prompt from StartNode)
- 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(纯文本,一行)
@@ -143,7 +143,7 @@ uwf-hermes <thread-id> <role>
#### `Workflow`
Roles 和 moderator 内联在 Workflow 中,只有 outputSchema 独立为 CAS 节点(方便 json-cas 校验)。
Roles 和 moderator 内联在 Workflow 中,只有 meta 独立为 CAS 节点(方便 json-cas 校验)。
```yaml
type: <workflow-schema-hash>
@@ -153,16 +153,25 @@ payload:
roles:
planner:
description: "Creates implementation plan"
systemPrompt: "You are a planning agent..."
outputSchema: "5GWKR8TN1V3JA" # cas_ref → JSON Schema 节点(json-cas 内置)
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" # cas_ref → JSON Schema 节点(json-cas 内置)
developer:
description: "Implements code changes"
systemPrompt: "You are a developer agent..."
outputSchema: "8CNWT4KR6D1HV" # cas_ref → JSON Schema 节点
goal: "You are a developer agent..."
capabilities: [file-edit, shell]
procedure: "Implement the plan."
output: "List all files changed."
meta: "8CNWT4KR6D1HV" # cas_ref → JSON Schema 节点
reviewer:
description: "Reviews code changes"
systemPrompt: "You are a code reviewer..."
outputSchema: "1VPBG9SM5E7WK" # cas_ref → JSON Schema 节点
goal: "You are a code reviewer..."
capabilities: [code-review]
procedure: "Review the implementation."
output: "Approve or reject with comments."
meta: "1VPBG9SM5E7WK" # cas_ref → JSON Schema 节点
conditions:
needsClarification:
description: "Planner requests clarification from user"
@@ -189,7 +198,7 @@ payload:
condition: null
```
- `roles` — 内联定义,每个 role 的 `outputSchema` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
- `roles` — 内联定义,每个 role 的 `meta` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
- `conditions``Record<Name, JSONata>`,命名条件,方便画图描述
- `graph``Record<Role | "$START", Transition[]>`,每个 Transition = `{ role, condition }`
- `condition` 引用 conditions 中的 key,`null` = fallback
@@ -234,14 +243,14 @@ payload:
start: "4TNVW8KR2B3MA" # cas_ref → StartNode(每个 step 都引用)
prev: "2MXBG6PN4A8JR" # cas_ref → 前一个 StepNode,第一步为 null
role: "developer"
output: "9KRVW3TN5F1QA" # cas_ref → 结构化输出节点(符合 role 的 outputSchema)
output: "9KRVW3TN5F1QA" # cas_ref → 结构化输出节点(符合 role 的 meta schema)
detail: "7BQST3VW9F2MA" # cas_ref → 执行详情(content node / 子 workflow terminal StepNode / ...)
agent: "uwf-cursor" # 实际使用的 agent 命令(纯字符串)
```
- `start` — 每个 StepNode 都直接引用 StartNode,方便随机访问
- `prev` — 前一个 StepNode 的 cas_ref,第一步为 `null`(不指向 StartNode)
- `output` — cas_ref,指向符合 role outputSchema 的 CAS 节点,可用 json-cas 校验
- `output` — cas_ref,指向符合 role meta schema 的 CAS 节点,可用 json-cas 校验
- `detail` — cas_ref,指向执行详情。可以是原始 agent 输出(content node),也可以是子 workflow thread 的 terminal StepNode(workflowAsAgent 场景)
- `agent` — 纯字符串,不是 CAS 节点
@@ -372,7 +381,7 @@ type ThreadId = string;
/** 一个 step 的核心数据,被 StepNode payload 和 JSONata 上下文共享 */
type StepRecord = {
role: string;
output: CasRef; // cas_ref → 结构化输出节点(符合 role outputSchema)
output: CasRef; // cas_ref → 结构化输出节点(符合 role meta schema)
detail: CasRef; // cas_ref → 执行详情(content node / 子 workflow terminal StepNode)
agent: string; // 实际使用的 agent 命令(纯字符串)
};
@@ -383,8 +392,11 @@ type StepRecord = {
```typescript
type RoleDefinition = {
description: string;
systemPrompt: string;
outputSchema: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
goal: string;
capabilities: string[];
procedure: string;
output: string;
meta: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
};
type Transition = {
+8 -7
View File
@@ -3,22 +3,23 @@ description: "Single-role topic analysis using four-phase role description"
roles:
analyst:
description: "Analyzes a given topic and produces a structured summary"
identity: |
goal: |
You are a research analyst with expertise in breaking down complex topics
into clear, structured summaries. You think critically and cite key points.
prepare: |
Review the topic carefully. Consider multiple perspectives and identify
the core question being asked.
execute: |
capabilities:
- research
- critical-thinking
- structured-writing
procedure: |
Analyze the topic by:
1. Identifying the main thesis or question
2. Listing 3-5 key points with brief explanations
3. Noting any counterarguments or caveats
Keep your analysis concise (under 500 words).
report: |
output: |
Provide your analysis as markdown under the frontmatter.
The frontmatter must include your structured findings.
outputSchema:
frontmatter:
type: object
properties:
thesis:
+23 -16
View File
@@ -3,11 +3,13 @@ description: "End-to-end issue resolution"
roles:
planner:
description: "Creates implementation plan"
identity: "You are a planning agent. You analyze issues and create step-by-step plans."
prepare: "Read the issue description and any linked context carefully."
execute: "Analyze the issue and create a detailed, actionable implementation plan."
report: "Output the plan summary and list of concrete steps."
outputSchema:
goal: "You are a planning agent. You analyze issues and create step-by-step plans."
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."
frontmatter:
type: object
properties:
plan:
@@ -19,11 +21,14 @@ roles:
required: [plan, steps]
developer:
description: "Implements code changes"
identity: "You are a developer agent. You implement code changes according to plans."
prepare: "Load coding tools and review the project structure and conventions."
execute: "Implement the plan. Write code, tests, and ensure existing tests pass."
report: "List all files changed and provide a summary of the implementation."
outputSchema:
goal: "You are a developer agent. You implement code changes according to plans."
capabilities:
- file-edit
- shell
- testing
procedure: "Implement the plan. Write code, tests, and ensure existing tests pass."
output: "List all files changed and provide a summary of the implementation."
frontmatter:
type: object
properties:
filesChanged:
@@ -35,11 +40,13 @@ roles:
required: [filesChanged, summary]
reviewer:
description: "Reviews code changes"
identity: "You are a code reviewer. You review implementations for correctness and quality."
prepare: "Review the project's coding standards and conventions."
execute: "Review the implementation against the plan. Check for bugs, edge cases, and style."
report: "Approve or reject with detailed comments explaining your decision."
outputSchema:
goal: "You are a code reviewer. You review implementations for correctness and quality."
capabilities:
- code-review
- static-analysis
procedure: "Review the implementation against the plan. Check for bugs, edge cases, and style."
output: "Approve or reject with detailed comments explaining your decision."
frontmatter:
type: object
properties:
approved:
@@ -50,7 +57,7 @@ roles:
conditions:
notApproved:
description: "Reviewer rejected the implementation"
expression: "steps[-1].output.approved = false"
expression: "$last('reviewer').approved = false"
graph:
$START:
- role: "planner"
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/package-descriptor.js";
import { createDocxDiffAgent } from "../src/agent.js";
import { packageDescriptor } from "../src/package-descriptor.js";
describe("createDocxDiffAgent", () => {
test("returns an AdapterFn (function)", () => {
@@ -1,8 +1,8 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, mock, test } from "bun:test";
import { ok, err } from "@uncaged/workflow-util";
import { mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { err, ok } from "@uncaged/workflow-util";
import type { SpawnCliConfig } from "@uncaged/workflow-util-agent";
import { runDocxDiff } from "../src/runner.js";
@@ -74,7 +74,12 @@ describe("runDocxDiff", () => {
test("exit 2: throws error", async () => {
const dir = tempDir();
const spawnFn = makeSpawn(
err({ kind: "non_zero_exit", exitCode: 2, stdout: "", stderr: "fatal error" }) as MockSpawnResult,
err({
kind: "non_zero_exit",
exitCode: 2,
stdout: "",
stderr: "fatal error",
}) as MockSpawnResult,
);
await expect(
@@ -1,7 +1,11 @@
{
"name": "@uncaged/workflow-agent-docx-diff",
"version": "0.1.0",
"files": ["src", "dist", "package.json"],
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"types": "src/index.ts",
"exports": {
@@ -1,7 +1,12 @@
import * as z from "zod/v4";
import { dirname, join } from "node:path";
import type { AdapterFn, RoleResult, ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
import type {
AdapterFn,
RoleResult,
ThreadContext,
WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import type { WriterMeta } from "@uncaged/workflow-template-document";
import type * as z from "zod/v4";
import { runDocxDiff } from "./runner.js";
import type { DocxDiffAgentConfig } from "./types.js";
@@ -12,16 +17,10 @@ export function createDocxDiffAgent(config: DocxDiffAgentConfig): AdapterFn {
if (writerStep === undefined) 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");
if (writerMeta.mode !== "edit") throw new Error("differ: writer did not run in edit mode");
const diffDocx = join(dirname(writerMeta.outputDocx), "diff.docx");
const raw = await runDocxDiff(
config,
writerMeta.sourceDocx,
writerMeta.outputDocx,
diffDocx,
);
const raw = await runDocxDiff(config, writerMeta.sourceDocx, writerMeta.outputDocx, diffDocx);
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
@@ -1,6 +1,6 @@
import { stat } from "node:fs/promises";
import { spawnCli } from "@uncaged/workflow-util-agent";
import type { SpawnCliError } from "@uncaged/workflow-util-agent";
import { spawnCli } from "@uncaged/workflow-util-agent";
import type { DocxDiffAgentConfig } from "./types.js";
type SpawnCliFn = typeof spawnCli;
@@ -8,8 +8,7 @@ type SpawnCliFn = typeof spawnCli;
function throwSpawnError(e: SpawnCliError): never {
if (e.kind === "non_zero_exit")
throw new Error(`docx-diff failed (exit ${e.exitCode}): ${e.stderr}`);
if (e.kind === "timeout")
throw new Error("docx-diff: timed out");
if (e.kind === "timeout") throw new Error("docx-diff: timed out");
throw new Error(`docx-diff: spawn failed: ${e.message}`);
}
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/package-descriptor.js";
import { createOfficeAgent } from "../src/agent.js";
import { packageDescriptor } from "../src/package-descriptor.js";
describe("createOfficeAgent", () => {
test("returns an AdapterFn (function)", () => {
@@ -1,8 +1,8 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, mock, test } from "bun:test";
import { ok, err } from "@uncaged/workflow-util";
import { mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { err, ok } from "@uncaged/workflow-util";
import type { SpawnCliConfig } from "@uncaged/workflow-util-agent";
import { editDocument, generateDocument } from "../src/runner.js";
@@ -123,7 +123,13 @@ describe("editDocument", () => {
);
await expect(
editDocument({ outputDir: base, command: null, timeout: null }, "te2", "edit", inputFile, spawnFn),
editDocument(
{ outputDir: base, command: null, timeout: null },
"te2",
"edit",
inputFile,
spawnFn,
),
).rejects.toThrow("spawn failed");
});
});
@@ -1,7 +1,11 @@
{
"name": "@uncaged/workflow-agent-office",
"version": "0.1.0",
"files": ["src", "dist", "package.json"],
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"types": "src/index.ts",
"exports": {
@@ -1,6 +1,11 @@
import * as z from "zod/v4";
import type { AdapterFn, RoleResult, ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
import type {
AdapterFn,
RoleResult,
ThreadContext,
WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import { createLogger } from "@uncaged/workflow-util";
import type * as z from "zod/v4";
import { editDocument, generateDocument } from "./runner.js";
import type { OfficeAgentConfig } from "./types.js";
@@ -27,7 +32,10 @@ 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);
log("8FQKP3NV", `office-agent: mode=${inputDocx === null ? "generate" : "edit"} thread=${ctx.threadId}`);
log(
"8FQKP3NV",
`office-agent: mode=${inputDocx === null ? "generate" : "edit"} thread=${ctx.threadId}`,
);
let raw: string;
if (inputDocx === null) {
@@ -35,7 +43,11 @@ export function createOfficeAgent(config: OfficeAgentConfig): AdapterFn {
raw = JSON.stringify({ mode: "generate", outputDocx: result.outputDocx, sourceDocx: null });
} else {
const result = await editDocument(config, ctx.threadId, prompt, inputDocx);
raw = JSON.stringify({ mode: "edit", outputDocx: result.outputDocx, sourceDocx: result.sourceDocx });
raw = JSON.stringify({
mode: "edit",
outputDocx: result.outputDocx,
sourceDocx: result.sourceDocx,
});
}
const meta = schema.parse(JSON.parse(raw)) as T;
@@ -1,7 +1,7 @@
import { copyFile, mkdir, stat } from "node:fs/promises";
import { join } from "node:path";
import { spawnCli } from "@uncaged/workflow-util-agent";
import type { SpawnCliError } from "@uncaged/workflow-util-agent";
import { spawnCli } from "@uncaged/workflow-util-agent";
import type { OfficeAgentConfig } from "./types.js";
type SpawnCliFn = typeof spawnCli;
@@ -9,8 +9,7 @@ type SpawnCliFn = typeof spawnCli;
function throwSpawnError(e: SpawnCliError): never {
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");
if (e.kind === "timeout") throw new Error("office-agent: timed out");
throw new Error(`office-agent: spawn failed: ${e.message}`);
}
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Workflow Dashboard</title>
<script>
(function () {
(() => {
var t = localStorage.getItem("theme");
if (t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches)) {
document.documentElement.classList.add("dark");
@@ -54,10 +54,14 @@ type CallExpression = {
arguments: Array<AstExpression>;
};
type AstExpression = Identifier | MemberExpression | CallExpression | {
type: string;
[key: string]: unknown;
};
type AstExpression =
| Identifier
| MemberExpression
| CallExpression
| {
type: string;
[key: string]: unknown;
};
type VariableDeclarator = {
id: Identifier | null;
@@ -258,15 +262,21 @@ function createLimitResolver(options: LimitLineOptions): (id: string) => Resolve
}
function shouldProcess(id: string, options: LimitLineOptions): boolean {
return options.include.test(id) && !id.includes("node_modules") && (options.exclude === null || !options.exclude.test(id));
return (
options.include.test(id) &&
!id.includes("node_modules") &&
(options.exclude === null || !options.exclude.test(id))
);
}
// --- Plugin ---
function viteLimitLinePlugin(
userOptions: Partial<LimitLineOptions> = {},
): Array<Plugin> {
const options: LimitLineOptions = { ...DEFAULT_OPTIONS, ...userOptions, overrides: userOptions.overrides ?? [] };
function viteLimitLinePlugin(userOptions: Partial<LimitLineOptions> = {}): Array<Plugin> {
const options: LimitLineOptions = {
...DEFAULT_OPTIONS,
...userOptions,
overrides: userOptions.overrides ?? [],
};
const resolve = createLimitResolver(options);
const rawCodeCache = new Map<string, string>();
@@ -358,5 +368,5 @@ function viteLimitLinePlugin(
];
}
export { viteLimitLinePlugin };
export type { LimitLineOptions, LimitLineOverride };
export { viteLimitLinePlugin };
@@ -55,10 +55,7 @@ export function ResizablePanel({
}, []);
return (
<div
className={cn("relative shrink-0", className)}
style={{ ...style, width }}
>
<div className={cn("relative shrink-0", className)} style={{ ...style, width }}>
{children}
<div
className="absolute top-0 -right-1 w-2 h-full cursor-col-resize z-10 group"
@@ -9,9 +9,7 @@ import type { DocumentMeta } from "../src/roles.js";
const documentModerator = tableToModerator(documentTable);
function makeCtx(
steps: ModeratorContext<DocumentMeta>["steps"],
): ModeratorContext<DocumentMeta> {
function makeCtx(steps: ModeratorContext<DocumentMeta>["steps"]): ModeratorContext<DocumentMeta> {
return {
threadId: "01TEST000000000000000000TR",
depth: 0,
@@ -25,7 +23,11 @@ function writerGenerateStep(): RoleStep<DocumentMeta> {
return {
role: "writer",
contentHash: "STUBHASHWRITER001",
meta: { mode: "generate", outputDocx: "/out/output.docx", sourceDocx: null } satisfies WriterMeta,
meta: {
mode: "generate",
outputDocx: "/out/output.docx",
sourceDocx: null,
} satisfies WriterMeta,
refs: [],
timestamp: 1,
};
@@ -35,7 +37,11 @@ function writerEditStep(): RoleStep<DocumentMeta> {
return {
role: "writer",
contentHash: "STUBHASHWRITER002",
meta: { mode: "edit", outputDocx: "/out/modified.docx", sourceDocx: "/out/original.docx" } satisfies WriterMeta,
meta: {
mode: "edit",
outputDocx: "/out/modified.docx",
sourceDocx: "/out/original.docx",
} satisfies WriterMeta,
refs: [],
timestamp: 1,
};
@@ -1,7 +1,11 @@
{
"name": "@uncaged/workflow-template-document",
"version": "0.1.0",
"files": ["src", "dist", "package.json"],
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"types": "src/index.ts",
"exports": {
@@ -0,0 +1,71 @@
import { execFileSync } from "node:child_process";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
const CLI_PATH = join(import.meta.dirname, "..", "cli.js");
function runCli(args: string[]): { stdout: string; stderr: string; exitCode: number } {
try {
const stdout = execFileSync("bun", ["run", CLI_PATH, ...args], {
encoding: "utf8",
env: { ...process.env, WORKFLOW_STORAGE_ROOT: "/tmp/uwf-test-nonexistent" },
stdio: ["ignore", "pipe", "pipe"],
});
return { stdout, stderr: "", exitCode: 0 };
} catch (e: unknown) {
const err = e as NodeJS.ErrnoException & { stdout?: string; stderr?: string; status?: number };
return {
stdout: err.stdout ?? "",
stderr: err.stderr ?? "",
exitCode: err.status ?? 1,
};
}
}
describe("thread step --count CLI parsing", () => {
test("--help shows -c/--count option", () => {
const result = runCli(["thread", "step", "--help"]);
expect(result.stdout).toContain("--count");
expect(result.stdout).toContain("-c");
});
test("description says 'one or more steps'", () => {
const result = runCli(["thread", "step", "--help"]);
expect(result.stdout).toContain("one or more steps");
});
});
describe("cmdThreadStep count logic", () => {
test("count=0 fails with validation error", () => {
const result = runCli(["thread", "step", "FAKE_THREAD_ID", "-c", "0"]);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("positive integer");
});
test("negative count fails with validation error", () => {
const result = runCli(["thread", "step", "FAKE_THREAD_ID", "-c", "-1"]);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("positive integer");
});
test("non-integer count fails with validation error", () => {
const result = runCli(["thread", "step", "FAKE_THREAD_ID", "-c", "1.5"]);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("positive integer");
});
test("count=1 is the default (no -c flag)", () => {
// Without -c, it should attempt to run 1 step (failing on missing thread, not on count validation)
const result = runCli(["thread", "step", "FAKE_THREAD_ID"]);
expect(result.exitCode).not.toBe(0);
// Should NOT contain "positive integer" error — should fail on thread lookup instead
expect(result.stderr).not.toContain("positive integer");
});
test("count=3 passes validation (fails on thread lookup)", () => {
const result = runCli(["thread", "step", "FAKE_THREAD_ID", "-c", "3"]);
expect(result.exitCode).not.toBe(0);
// Should NOT contain "positive integer" error — should fail on thread/storage lookup
expect(result.stderr).not.toContain("positive integer");
});
});
@@ -211,11 +211,11 @@ describe("cmdThreadRead ### Content section", () => {
roles: {
writer: {
description: "Write",
identity: "You are a writer.",
prepare: "",
execute: "Write content as requested.",
report: "Summarize what was written.",
outputSchema: "placeholder00" as CasRef,
goal: "You are a writer.",
capabilities: [],
procedure: "Write content as requested.",
output: "Summarize what was written.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
+26 -7
View File
@@ -14,6 +14,7 @@ import {
cmdCasWalk,
} from "./commands/cas.js";
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
import { cmdSkillCli } from "./commands/skill.js";
import {
cmdThreadFork,
cmdThreadKill,
@@ -47,7 +48,10 @@ const program = new Command();
// eslint-disable-next-line -- dynamic import for version
const pkg = await import("../package.json", { with: { type: "json" } });
program.name("uwf").description("Stateless workflow CLI").version(pkg.default.version, "-V, --version");
program
.name("uwf")
.description("Stateless workflow CLI")
.version(pkg.default.version, "-V, --version");
program.option("--format <fmt>", "Output format: json or yaml", "json");
const workflow = program.command("workflow").description("Workflow registry and CAS");
@@ -82,7 +86,7 @@ workflow
.action(() => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdWorkflowList(storageRoot);
const result = await cmdWorkflowList(storageRoot, process.cwd());
writeOutput(result);
});
});
@@ -97,22 +101,28 @@ thread
.action((workflow: string, opts: { prompt: string }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadStart(storageRoot, workflow, opts.prompt);
const result = await cmdThreadStart(storageRoot, workflow, opts.prompt, process.cwd());
writeOutput(result);
});
});
thread
.command("step")
.description("Execute one step")
.description("Execute one or more steps")
.argument("<thread-id>", "Thread ULID")
.option("--agent <cmd>", "Override agent command")
.action((threadId: string, opts: { agent: string | undefined }) => {
.option("-c, --count <number>", "Number of steps to run (default: 1)")
.action((threadId: string, opts: { agent: string | undefined; count: string | undefined }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const agentOverride = opts.agent ?? null;
const result = await cmdThreadStep(storageRoot, threadId, agentOverride);
writeOutput(result);
const count = opts.count !== undefined ? Number(opts.count) : 1;
const results = await cmdThreadStep(storageRoot, threadId, agentOverride, count);
if (results.length === 1) {
writeOutput(results[0]);
} else {
writeOutput(results);
}
});
});
@@ -217,6 +227,15 @@ thread
});
});
const skill = program.command("skill").description("Built-in skill references for agents");
skill
.command("cli")
.description("Print a markdown reference of all uwf commands")
.action(() => {
console.log(cmdSkillCli());
});
program
.command("setup")
.description("Configure provider, model, and agent")
+7 -23
View File
@@ -1,7 +1,7 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { Hash, JSONSchema, Store } from "@uncaged/json-cas";
import type { JSONSchema, Store } from "@uncaged/json-cas";
import { bootstrap, getSchema, refs, walk } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
@@ -53,18 +53,12 @@ export async function cmdCasPut(
return { hash };
}
export async function cmdCasHas(
storageRoot: string,
hash: string,
): Promise<{ exists: boolean }> {
export async function cmdCasHas(storageRoot: string, hash: string): Promise<{ exists: boolean }> {
const store = openStore(storageRoot);
return { exists: store.has(hash) };
}
export async function cmdCasRefs(
storageRoot: string,
hash: string,
): Promise<{ refs: string[] }> {
export async function cmdCasRefs(storageRoot: string, hash: string): Promise<{ refs: string[] }> {
const store = openStore(storageRoot);
const node = store.get(hash);
if (node === null) {
@@ -73,10 +67,7 @@ export async function cmdCasRefs(
return { refs: refs(store, node) };
}
export async function cmdCasWalk(
storageRoot: string,
hash: string,
): Promise<{ hashes: string[] }> {
export async function cmdCasWalk(storageRoot: string, hash: string): Promise<{ hashes: string[] }> {
const store = openStore(storageRoot);
const result: string[] = [];
walk(store, hash, (h) => {
@@ -90,9 +81,7 @@ export type SchemaListEntry = {
title: string;
};
export async function cmdCasSchemaList(
storageRoot: string,
): Promise<SchemaListEntry[]> {
export async function cmdCasSchemaList(storageRoot: string): Promise<SchemaListEntry[]> {
const store = openStore(storageRoot);
const metaHash = await bootstrap(store);
const entries: SchemaListEntry[] = [];
@@ -115,9 +104,7 @@ export async function cmdCasSchemaList(
return entries;
}
export async function cmdCasReindex(
storageRoot: string,
): Promise<{ status: string }> {
export async function cmdCasReindex(storageRoot: string): Promise<{ status: string }> {
const indexDir = join(storageRoot, "cas", "_index");
const { rmSync } = await import("node:fs");
rmSync(indexDir, { recursive: true, force: true });
@@ -126,10 +113,7 @@ export async function cmdCasReindex(
return { status: "reindexed" };
}
export async function cmdCasSchemaGet(
storageRoot: string,
hash: string,
): Promise<unknown> {
export async function cmdCasSchemaGet(storageRoot: string, hash: string): Promise<unknown> {
const store = openStore(storageRoot);
const schema = getSchema(store, hash);
if (schema === null) {
+35 -18
View File
@@ -1,10 +1,9 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { createInterface } from "node:readline/promises";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { stdin as input, stdout as output } from "node:process";
import { createInterface } from "node:readline/promises";
import { stringify, parse } from "yaml";
import { parse, stringify } from "yaml";
/**
* Preset provider list — embedded to avoid runtime YAML loading dependency.
@@ -17,10 +16,18 @@ const PRESET_PROVIDERS = [
{ name: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1" },
{ name: "venice", label: "Venice", baseUrl: "https://api.venice.ai/api/v1" },
// China
{ name: "dashscope", label: "DashScope (Alibaba)", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1" },
{
name: "dashscope",
label: "DashScope (Alibaba)",
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
},
{ name: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/v1" },
{ name: "siliconflow", label: "SiliconFlow", baseUrl: "https://api.siliconflow.cn/v1" },
{ name: "volcengine", label: "Volcengine (ByteDance)", baseUrl: "https://ark.cn-beijing.volces.com/api/v3" },
{
name: "volcengine",
label: "Volcengine (ByteDance)",
baseUrl: "https://ark.cn-beijing.volces.com/api/v3",
},
{ name: "kimi", label: "Kimi (Moonshot)", baseUrl: "https://api.moonshot.cn/v1" },
{ name: "glm", label: "GLM (Zhipu AI)", baseUrl: "https://open.bigmodel.cn/api/paas/v4" },
{ name: "stepfun", label: "StepFun", baseUrl: "https://api.stepfun.com/v1" },
@@ -98,21 +105,27 @@ function apiKeyEnvName(providerName: string): string {
* Merge setup args into config.yaml structure. Non-destructive — preserves existing entries.
*/
function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record<string, unknown> {
const providers = (typeof existing.providers === "object" && existing.providers !== null
? { ...(existing.providers as Record<string, unknown>) }
: {}) as Record<string, unknown>;
const providers = (
typeof existing.providers === "object" && existing.providers !== null
? { ...(existing.providers as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
const envName = apiKeyEnvName(args.provider);
providers[args.provider] = { baseUrl: args.baseUrl, apiKeyEnv: envName };
const models = (typeof existing.models === "object" && existing.models !== null
? { ...(existing.models as Record<string, unknown>) }
: {}) as Record<string, unknown>;
const models = (
typeof existing.models === "object" && existing.models !== null
? { ...(existing.models as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
models.default = { provider: args.provider, name: args.model };
const agents = (typeof existing.agents === "object" && existing.agents !== null
? { ...(existing.agents as Record<string, unknown>) }
: {}) as Record<string, unknown>;
const agents = (
typeof existing.agents === "object" && existing.agents !== null
? { ...(existing.agents as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
const agentName = args.agent ?? "hermes";
if (Object.keys(agents).length === 0) {
@@ -211,8 +224,12 @@ async function fetchModels(baseUrl: string, apiKey: string): Promise<string[]> {
if (!res.ok) return [];
const body = (await res.json()) as { data?: { id: string }[] };
if (!Array.isArray(body.data)) return [];
const NON_CHAT = /speech|embed|image|video|audio|ocr|rerank|tts|asr|paraformer|sambert|cosyvoice|wordart|wanx|wan2|flux|stable-diffusion|gui-/i;
return body.data.map((m) => m.id).filter((id) => !NON_CHAT.test(id)).sort();
const NON_CHAT =
/speech|embed|image|video|audio|ocr|rerank|tts|asr|paraformer|sambert|cosyvoice|wordart|wanx|wan2|flux|stable-diffusion|gui-/i;
return body.data
.map((m) => m.id)
.filter((id) => !NON_CHAT.test(id))
.sort();
} catch {
return [];
}
@@ -0,0 +1 @@
export { generateCliReference as cmdSkillCli } from "@uncaged/workflow-util";
+74 -4
View File
@@ -1,4 +1,5 @@
import { execFileSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import type { Store as CasStore, JSONSchema } from "@uncaged/json-cas";
import { getSchema, validate } from "@uncaged/json-cas";
import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-agent-kit";
@@ -24,21 +25,24 @@ import type {
} from "@uncaged/workflow-protocol";
import { generateUlid } from "@uncaged/workflow-util";
import { config as loadDotenv } from "dotenv";
import { stringify } from "yaml";
import { parse, stringify } from "yaml";
import {
appendThreadHistory,
createUwfStore,
discoverProjectWorkflows,
findThreadInHistory,
loadThreadHistory,
loadThreadsIndex,
loadWorkflowRegistry,
resolveProjectWorkflowFile,
resolveWorkflowHash,
saveThreadsIndex,
type ThreadHistoryLine,
type UwfStore,
} from "../store.js";
import { isCasRef } from "../validate.js";
import { checkWorkflowFilenameConsistency, isCasRef, parseWorkflowPayload } from "../validate.js";
import { materializeWorkflowPayload } from "./workflow.js";
const END_ROLE = "$END";
export const THREAD_READ_DEFAULT_QUOTA = 4000;
@@ -66,11 +70,55 @@ function fail(message: string): never {
process.exit(1);
}
async function materializeLocalWorkflow(uwf: UwfStore, filePath: string): Promise<CasRef> {
let text: string;
try {
text = await readFile(filePath, "utf8");
} catch {
fail(`project workflow file not found: ${filePath}`);
}
let raw: unknown;
try {
raw = parse(text) as unknown;
} catch (e) {
fail(`invalid YAML in ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
}
const payload = parseWorkflowPayload(raw);
if (payload === null) {
fail(`invalid workflow YAML in ${filePath}: expected WorkflowPayload shape`);
}
const filenameError = checkWorkflowFilenameConsistency(filePath, payload);
if (filenameError !== null) {
fail(filenameError);
}
const materialized = await materializeWorkflowPayload(uwf, payload);
const hash = await uwf.store.put(uwf.schemas.workflow, materialized);
const stored = uwf.store.get(hash);
if (stored === null || !validate(uwf.store, stored)) {
fail("stored local workflow failed schema validation");
}
return hash;
}
async function resolveWorkflowCasRef(
uwf: UwfStore,
storageRoot: string,
workflowId: string,
projectRoot: string,
): Promise<CasRef> {
// Project-local resolution: check .workflows/<workflowId>.yaml first
const localEntries = await discoverProjectWorkflows(projectRoot);
const localFile = resolveProjectWorkflowFile(localEntries, workflowId);
if (localFile !== null) {
return materializeLocalWorkflow(uwf, localFile);
}
// Global registry fallback
const registry = await loadWorkflowRegistry(storageRoot);
const hash = resolveWorkflowHash(registry, workflowId);
if (!isCasRef(hash)) {
@@ -114,9 +162,10 @@ export async function cmdThreadStart(
storageRoot: string,
workflowId: string,
prompt: string,
projectRoot: string,
): Promise<StartOutput> {
const uwf = await createUwfStore(storageRoot);
const workflowHash = await resolveWorkflowCasRef(uwf, storageRoot, workflowId);
const workflowHash = await resolveWorkflowCasRef(uwf, storageRoot, workflowId, projectRoot);
const threadId = generateUlid(Date.now()) as ThreadId;
const startPayload: StartNodePayload = {
@@ -500,7 +549,7 @@ function formatThreadReadMarkdown(options: {
];
const roleDef = workflow.roles[item.payload.role];
if (roleDef) {
const prompt = roleDef.identity;
const prompt = roleDef.goal;
stepLines.push("", "### Prompt", "", prompt);
}
if (item.payload.detail) {
@@ -624,6 +673,27 @@ export async function cmdThreadStep(
storageRoot: string,
threadId: ThreadId,
agentOverride: string | null,
count: number,
): Promise<StepOutput[]> {
if (count < 1 || !Number.isInteger(count)) {
fail(`--count must be a positive integer, got: ${count}`);
}
const results: StepOutput[] = [];
for (let i = 0; i < count; i++) {
const result = await cmdThreadStepOnce(storageRoot, threadId, agentOverride);
results.push(result);
if (result.done) {
break;
}
}
return results;
}
async function cmdThreadStepOnce(
storageRoot: string,
threadId: ThreadId,
agentOverride: string | null,
): Promise<StepOutput> {
const index = await loadThreadsIndex(storageRoot);
const headHash = index[threadId];
+64 -20
View File
@@ -2,22 +2,31 @@ import { readFile } from "node:fs/promises";
import type { JSONSchema } from "@uncaged/json-cas";
import { putSchema, validate } from "@uncaged/json-cas";
import type { CasRef, RoleDefinition, WorkflowPayload } from "@uncaged/workflow-protocol";
import type {
CasRef,
RoleDefinition,
Transition,
WorkflowPayload,
} from "@uncaged/workflow-protocol";
import { parse } from "yaml";
import {
createUwfStore,
discoverProjectWorkflows,
findRegistryName,
loadWorkflowRegistry,
resolveWorkflowHash,
saveWorkflowRegistry,
type UwfStore,
} from "../store.js";
import { parseWorkflowPayload } from "../validate.js";
import { checkWorkflowFilenameConsistency, parseWorkflowPayload } from "../validate.js";
export type WorkflowOrigin = "local" | "global";
export type WorkflowListEntry = {
name: string;
hash: CasRef;
origin: WorkflowOrigin;
};
export type WorkflowPutOutput = {
@@ -42,38 +51,49 @@ function isJsonSchema(value: unknown): value is JSONSchema {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function resolveOutputSchemaRef(
/** Normalize graph transitions: ensure condition is null (not undefined) for fallback entries. */
function normalizeGraph(graph: Record<string, Transition[]>): Record<string, Transition[]> {
const result: Record<string, Transition[]> = {};
for (const [node, transitions] of Object.entries(graph)) {
result[node] = transitions.map((t) => ({
role: t.role,
condition: t.condition ?? null,
}));
}
return result;
}
async function resolveFrontmatterRef(
uwf: UwfStore,
roleName: string,
outputSchema: unknown,
frontmatter: unknown,
): Promise<CasRef> {
if (!isJsonSchema(outputSchema)) {
fail(`role "${roleName}": outputSchema must be a JSON Schema object`);
if (!isJsonSchema(frontmatter)) {
fail(`role "${roleName}": frontmatter must be a JSON Schema object`);
}
const schema: JSONSchema = outputSchema.title === undefined
? { ...outputSchema, title: roleName }
: outputSchema;
const schema: JSONSchema =
frontmatter.title === undefined ? { ...frontmatter, title: roleName } : frontmatter;
return putSchema(uwf.store, schema);
}
async function materializeWorkflowPayload(
export async function materializeWorkflowPayload(
uwf: UwfStore,
raw: WorkflowPayload,
): Promise<WorkflowPayload> {
const roles: Record<string, RoleDefinition> = {};
for (const [roleName, role] of Object.entries(raw.roles)) {
const outputSchema = await resolveOutputSchemaRef(
const frontmatter = await resolveFrontmatterRef(
uwf,
`${raw.name}.${roleName}`,
role.outputSchema,
role.frontmatter,
);
roles[roleName] = {
description: role.description,
identity: role.identity,
prepare: role.prepare,
execute: role.execute,
report: role.report,
outputSchema,
goal: role.goal,
capabilities: role.capabilities,
procedure: role.procedure,
output: role.output,
frontmatter,
};
}
return {
@@ -81,7 +101,7 @@ async function materializeWorkflowPayload(
description: raw.description,
roles,
conditions: raw.conditions,
graph: raw.graph,
graph: normalizeGraph(raw.graph),
};
}
@@ -108,6 +128,11 @@ export async function cmdWorkflowPut(
fail("invalid workflow YAML: expected WorkflowPayload shape");
}
const filenameError = checkWorkflowFilenameConsistency(filePath, payload);
if (filenameError !== null) {
fail(filenameError);
}
const uwf = await createUwfStore(storageRoot);
const materialized = await materializeWorkflowPayload(uwf, payload);
@@ -150,7 +175,26 @@ export async function cmdWorkflowShow(
};
}
export async function cmdWorkflowList(storageRoot: string): Promise<WorkflowListEntry[]> {
export async function cmdWorkflowList(
storageRoot: string,
projectRoot: string,
): Promise<WorkflowListEntry[]> {
const localEntries = await discoverProjectWorkflows(projectRoot);
const registry = await loadWorkflowRegistry(storageRoot);
return Object.entries(registry).map(([name, hash]) => ({ name, hash }));
const result: WorkflowListEntry[] = [];
const localNames = new Set<string>();
for (const entry of localEntries) {
localNames.add(entry.name);
result.push({ name: entry.name, hash: "(local)", origin: "local" });
}
for (const [name, hash] of Object.entries(registry)) {
if (!localNames.has(name)) {
result.push({ name, hash, origin: "global" });
}
}
return result;
}
+1 -5
View File
@@ -1,10 +1,6 @@
import type { Hash, Store } from "@uncaged/json-cas";
import { putSchema } from "@uncaged/json-cas";
import {
START_NODE_SCHEMA,
STEP_NODE_SCHEMA,
WORKFLOW_SCHEMA,
} from "@uncaged/workflow-protocol";
import { START_NODE_SCHEMA, STEP_NODE_SCHEMA, WORKFLOW_SCHEMA } from "@uncaged/workflow-protocol";
export type UwfSchemaHashes = {
workflow: Hash;
+55 -1
View File
@@ -1,4 +1,4 @@
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { appendFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
@@ -11,6 +11,44 @@ import { registerUwfSchemas, type UwfSchemaHashes } from "./schemas.js";
export type WorkflowRegistry = Record<string, CasRef>;
/** A workflow entry discovered from the project-local .workflows/ directory. */
export type ProjectWorkflowEntry = {
/** Workflow name (from YAML `name` field, equals filename stem). */
name: string;
/** Absolute path to the YAML file. */
filePath: string;
};
/**
* Scan `<projectRoot>/.workflows/*.yaml` (non-recursive) and return discovered entries.
* Returns an empty array if the directory does not exist.
*/
export async function discoverProjectWorkflows(
projectRoot: string,
): Promise<ProjectWorkflowEntry[]> {
const dir = join(projectRoot, ".workflows");
let entries: string[];
try {
entries = await readdir(dir);
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
return [];
}
throw e;
}
const result: ProjectWorkflowEntry[] = [];
for (const entry of entries) {
if (!entry.endsWith(".yaml") && !entry.endsWith(".yml")) {
continue;
}
const stem = entry.endsWith(".yaml") ? entry.slice(0, -5) : entry.slice(0, -4);
result.push({ name: stem, filePath: join(dir, entry) });
}
return result;
}
/** Default filesystem root for uwf data (`~/.uncaged/workflow`). */
export function getDefaultStorageRoot(): string {
return join(homedir(), ".uncaged", "workflow");
@@ -104,6 +142,22 @@ export function resolveWorkflowHash(registry: WorkflowRegistry, id: string): Cas
return registry[id] !== undefined ? registry[id] : id;
}
/**
* Resolve a workflow name to a project-local YAML file path.
* Returns null if the name is not found in the local entries.
*/
export function resolveProjectWorkflowFile(
localEntries: ProjectWorkflowEntry[],
name: string,
): string | null {
for (const entry of localEntries) {
if (entry.name === name) {
return entry.filePath;
}
}
return null;
}
export function findRegistryName(registry: WorkflowRegistry, hash: Hash): string | null {
for (const [name, h] of Object.entries(registry)) {
if (h === hash) {
+42 -8
View File
@@ -1,3 +1,4 @@
import { basename } from "node:path";
import type { CasRef, WorkflowPayload } from "@uncaged/workflow-protocol";
const CAS_REF_PATTERN = /^[0-9A-HJKMNP-TV-Z]{13}$/;
@@ -14,15 +15,18 @@ function isRoleDefinition(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
const outputSchema = value.outputSchema;
const schemaOk = isRecord(outputSchema) && typeof outputSchema.type === "string";
const frontmatter = value.frontmatter;
const frontmatterOk = isRecord(frontmatter) && typeof frontmatter.type === "string";
const capabilities = value.capabilities;
const capabilitiesOk =
Array.isArray(capabilities) && capabilities.every((c) => typeof c === "string");
return (
typeof value.description === "string" &&
typeof value.identity === "string" &&
typeof value.prepare === "string" &&
typeof value.execute === "string" &&
typeof value.report === "string" &&
schemaOk
typeof value.goal === "string" &&
capabilitiesOk &&
typeof value.procedure === "string" &&
typeof value.output === "string" &&
frontmatterOk
);
}
@@ -38,7 +42,10 @@ function isTransition(value: unknown): boolean {
return false;
}
const condition = value.condition;
return typeof value.role === "string" && (condition === null || typeof condition === "string");
return (
typeof value.role === "string" &&
(condition === null || condition === undefined || typeof condition === "string")
);
}
function isStringRecord(value: unknown, itemCheck: (item: unknown) => boolean): boolean {
@@ -57,6 +64,33 @@ function isGraph(value: unknown): boolean {
);
}
/**
* Derive the expected workflow name from a file path (stem without extension).
* Returns the stem for `.yaml` / `.yml` files.
*/
export function workflowNameFromPath(filePath: string): string {
const base = basename(filePath);
if (base.endsWith(".yaml")) return base.slice(0, -5);
if (base.endsWith(".yml")) return base.slice(0, -4);
return base;
}
/**
* Check that the `name` field in a parsed payload matches the expected name
* derived from the file path. Returns an error message string on mismatch,
* or null when the names are consistent.
*/
export function checkWorkflowFilenameConsistency(
filePath: string,
payload: WorkflowPayload,
): string | null {
const expected = workflowNameFromPath(filePath);
if (payload.name !== expected) {
return `workflow name mismatch: file "${basename(filePath)}" implies name "${expected}" but YAML declares name "${payload.name}"`;
}
return null;
}
/** Validate YAML-parsed workflow document shape (outputSchema may be inline JSON Schema). */
export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null {
if (!isRecord(raw)) {
+6 -1
View File
@@ -1,6 +1,11 @@
import { spawn } from "node:child_process";
import { type AgentContext, type AgentRunResult, buildRolePrompt, createAgent } from "@uncaged/workflow-agent-kit";
import {
type AgentContext,
type AgentRunResult,
buildRolePrompt,
createAgent,
} from "@uncaged/workflow-agent-kit";
import {
loadHermesSession,
@@ -1,54 +1,81 @@
import { describe, expect, test } from "vitest";
import type { RoleDefinition } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { buildRolePrompt } from "../src/build-role-prompt.js";
describe("buildRolePrompt", () => {
test("all fields present", () => {
const role: RoleDefinition = {
description: "A coder",
identity: "You are a senior developer.",
prepare: "Load cursor-agent skill.",
execute: "Implement the feature.",
report: "Summarize changes.",
outputSchema: "placeholder00000" as string,
goal: "You are a senior developer.",
capabilities: ["cursor-agent", "file-edit"],
procedure: "Implement the feature.",
output: "Summarize changes.",
meta: "placeholder00000" as string,
};
const result = buildRolePrompt(role);
expect(result).toContain("## Identity");
expect(result).toContain("## Goal");
expect(result).toContain("You are a senior developer.");
expect(result).toContain("## Capabilities");
expect(result).toContain("- cursor-agent");
expect(result).toContain("- file-edit");
expect(result).toContain("## Prepare");
expect(result).toContain("Load cursor-agent skill.");
expect(result).toContain("## Execute");
expect(result).toContain("uwf CLI Reference");
expect(result).toContain("cursor-agent, file-edit");
expect(result).toContain("## Procedure");
expect(result).toContain("Implement the feature.");
expect(result).toContain("## Report");
expect(result).toContain("## Output");
expect(result).toContain("Summarize changes.");
});
test("empty fields are omitted", () => {
test("empty fields are omitted but Prepare is always present", () => {
const role: RoleDefinition = {
description: "A reviewer",
identity: "You are a code reviewer.",
prepare: "",
execute: "Review the PR diff carefully.",
report: "",
outputSchema: "placeholder00000" as string,
goal: "You are a code reviewer.",
capabilities: [],
procedure: "Review the PR diff carefully.",
output: "",
meta: "placeholder00000" as string,
};
const result = buildRolePrompt(role);
expect(result).toContain("## Identity");
expect(result).toContain("## Execute");
expect(result).not.toContain("## Prepare");
expect(result).not.toContain("## Report");
expect(result).toContain("## Goal");
expect(result).toContain("## Prepare");
expect(result).toContain("uwf CLI Reference");
expect(result).toContain("## Procedure");
expect(result).not.toContain("## Capabilities");
expect(result).not.toContain("## Output");
});
test("all empty returns empty string", () => {
test("all empty still includes Prepare section", () => {
const role: RoleDefinition = {
description: "Minimal",
identity: "",
prepare: "",
execute: "",
report: "",
outputSchema: "placeholder00000" as string,
goal: "",
capabilities: [],
procedure: "",
output: "",
meta: "placeholder00000" as string,
};
const result = buildRolePrompt(role);
expect(result).toBe("");
expect(result).toContain("## Prepare");
expect(result).toContain("uwf CLI Reference");
expect(result).not.toContain("## Goal");
expect(result).not.toContain("## Capabilities");
expect(result).not.toContain("## Procedure");
expect(result).not.toContain("## Output");
});
test("capabilities rendered as bullet list", () => {
const role: RoleDefinition = {
description: "Agent",
goal: "",
capabilities: ["search", "code", "browse"],
procedure: "",
output: "",
meta: "placeholder00000" as string,
};
const result = buildRolePrompt(role);
expect(result).toContain("## Capabilities");
expect(result).toContain("- search");
expect(result).toContain("- code");
expect(result).toContain("- browse");
});
});
@@ -1,6 +1,5 @@
import { describe, expect, test } from "vitest";
import { createMemoryStore, putSchema } from "@uncaged/json-cas";
import { describe, expect, test } from "vitest";
import { tryFrontmatterFastPath } from "../src/frontmatter.js";
@@ -68,7 +67,8 @@ describe("tryFrontmatterFastPath — happy path", () => {
test("stored CAS node payload matches frontmatter fields", async () => {
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA);
const raw = "---\nstatus: done\nnext: null\nconfidence: null\nartifacts: []\nscope: role\n---\n\nBody.";
const raw =
"---\nstatus: done\nnext: null\nconfidence: null\nartifacts: []\nscope: role\n---\n\nBody.";
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).not.toBeNull();
@@ -1,28 +1,43 @@
import type { RoleDefinition } from "@uncaged/workflow-protocol";
import { generateCliReference } from "@uncaged/workflow-util";
/**
* Build the role prompt from a RoleDefinition.
*
* Assembles structured sections: Identity, Prepare, Execute, Report.
* Empty strings are omitted from the output.
* Assembles structured sections: Goal, Capabilities, Prepare, Procedure, Output.
* Empty strings and empty arrays are omitted from the output.
*
* The Prepare section always inlines the uwf CLI reference so the agent has
* workflow knowledge without needing to run an external command. The capabilities
* array is rendered as keyword hints for implicit skill loading.
*/
export function buildRolePrompt(role: RoleDefinition): string {
const sections: string[] = [];
if (role.identity !== "") {
sections.push(`## Identity\n\n${role.identity}`);
if (role.goal !== "") {
sections.push(`## Goal\n\n${role.goal}`);
}
if (role.prepare !== "") {
sections.push(`## Prepare\n\n${role.prepare}`);
if (role.capabilities.length > 0) {
const list = role.capabilities.map((c) => `- ${c}`).join("\n");
sections.push(`## Capabilities\n\n${list}`);
}
if (role.execute !== "") {
sections.push(`## Execute\n\n${role.execute}`);
const prepareLines: string[] = [generateCliReference()];
if (role.capabilities.length > 0) {
const keywords = role.capabilities.join(", ");
prepareLines.push(
`You have the following capabilities: ${keywords}. Load relevant skills matching these keywords before starting work.`,
);
}
sections.push(`## Prepare\n\n${prepareLines.join("\n\n")}`);
if (role.procedure !== "") {
sections.push(`## Procedure\n\n${role.procedure}`);
}
if (role.report !== "") {
sections.push(`## Report\n\n${role.report}`);
if (role.output !== "") {
sections.push(`## Output\n\n${role.output}`);
}
return sections.join("\n\n");
+4 -15
View File
@@ -6,8 +6,8 @@ import type {
StepNodePayload,
ThreadId,
} from "@uncaged/workflow-protocol";
import { createAgentStore, loadThreadsIndex, resolveStorageRoot } from "./storage.js";
import type { AgentStore } from "./storage.js";
import { createAgentStore, loadThreadsIndex, resolveStorageRoot } from "./storage.js";
import type { AgentContext } from "./types.js";
type ChainState = {
@@ -21,11 +21,7 @@ function fail(message: string): never {
throw new Error(message);
}
function walkChain(
store: Store,
schemas: AgentStore["schemas"],
headHash: CasRef,
): ChainState {
function walkChain(store: Store, schemas: AgentStore["schemas"], headHash: CasRef): ChainState {
const headNode = store.get(headHash);
if (headNode === null) {
fail(`CAS node not found: ${headHash}`);
@@ -78,10 +74,7 @@ function walkChain(
};
}
function expandOutput(
store: Store,
outputRef: CasRef,
): unknown {
function expandOutput(store: Store, outputRef: CasRef): unknown {
const node = store.get(outputRef);
if (node === null) {
return {};
@@ -106,11 +99,7 @@ async function buildHistory(
return history;
}
async function loadWorkflow(
store: Store,
schemas: AgentStore["schemas"],
workflowRef: CasRef,
) {
async function loadWorkflow(store: Store, schemas: AgentStore["schemas"], workflowRef: CasRef) {
const node = store.get(workflowRef);
if (node === null) {
fail(`workflow CAS node not found: ${workflowRef}`);
@@ -1,5 +1,5 @@
import { validate } from "@uncaged/json-cas";
import type { Store } from "@uncaged/json-cas";
import { validate } from "@uncaged/json-cas";
import type { CasRef } from "@uncaged/workflow-protocol";
import { parseFrontmatterMarkdown, validateFrontmatter } from "@uncaged/workflow-util";
+2 -2
View File
@@ -1,3 +1,5 @@
export { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
export { buildRolePrompt } from "./build-role-prompt.js";
export type { BuildContextMeta } from "./context.js";
export { buildContext, buildContextWithMeta } from "./context.js";
export type { ExtractResult, ResolvedLlmProvider } from "./extract.js";
@@ -6,8 +8,6 @@ export {
resolveExtractModelAlias,
resolveModel,
} from "./extract.js";
export { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
export { buildRolePrompt } from "./build-role-prompt.js";
export type { FrontmatterFastPathResult } from "./frontmatter.js";
export { tryFrontmatterFastPath } from "./frontmatter.js";
export { createAgent } from "./run.js";
+10 -6
View File
@@ -1,9 +1,8 @@
import { getSchema, validate } from "@uncaged/json-cas";
import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/workflow-protocol";
import { config as loadDotenv } from "dotenv";
import { buildContextWithMeta } from "./context.js";
import { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
import { buildContextWithMeta } from "./context.js";
import { extract } from "./extract.js";
import { tryFrontmatterFastPath } from "./frontmatter.js";
import type { AgentStore } from "./storage.js";
@@ -131,13 +130,18 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
fail(`unknown role: ${role}`);
}
const outputSchema = getSchema(ctx.meta.store, roleDef.outputSchema);
if (outputSchema !== null) {
ctx.outputFormatInstruction = buildOutputFormatInstruction(outputSchema);
const frontmatterSchema = getSchema(ctx.meta.store, roleDef.frontmatter);
if (frontmatterSchema !== null) {
ctx.outputFormatInstruction = buildOutputFormatInstruction(frontmatterSchema);
}
const agentResult = await runAgent(options, ctx);
const outputHash = await extractOutput(agentResult.output, roleDef.outputSchema, storageRoot, ctx);
const outputHash = await extractOutput(
agentResult.output,
roleDef.frontmatter,
storageRoot,
ctx,
);
const stepHash = await persistStep({
ctx,
outputHash,
@@ -35,11 +35,11 @@ const solveIssueWorkflow: WorkflowPayload = {
conditions: {
needsClarification: {
description: "Planner requests clarification from user",
expression: "$exists(steps[-1].output.needsClarification)",
expression: "$exists($last('planner').needsClarification)",
},
notApproved: {
rejected: {
description: "Reviewer rejected the implementation",
expression: "steps[-1].output.approved = false",
expression: "$last('reviewer').approved = false",
},
},
graph: {
@@ -50,7 +50,7 @@ const solveIssueWorkflow: WorkflowPayload = {
],
developer: [{ role: "reviewer", condition: null }],
reviewer: [
{ role: "developer", condition: "notApproved" },
{ role: "developer", condition: "rejected" },
{ role: "$END", condition: null },
],
},
@@ -72,7 +72,7 @@ describe("evaluate", () => {
expect(result).toEqual({ ok: true, value: "planner" });
});
test("condition match (notApproved → developer)", async () => {
test("condition match (rejected → developer)", async () => {
const context = makeContext([
{
role: "reviewer",
@@ -126,4 +126,116 @@ describe("evaluate", () => {
const result = await evaluate(solveIssueWorkflow, context);
expect(result).toEqual({ ok: true, value: "developer" });
});
test("$last returns most recent matching role's frontmatter", async () => {
const workflow: WorkflowPayload = {
...solveIssueWorkflow,
conditions: {
devFailed: {
description: "Developer failed",
expression: "$last('developer').status = 'failed'",
},
},
graph: {
$START: [{ role: "developer", condition: null }],
developer: [
{ role: "$END", condition: "devFailed" },
{ role: "reviewer", condition: null },
],
},
};
const context = makeContext([
{
role: "developer",
output: { status: "done" },
detail: "1VPBG9SM5E7WK",
agent: "uwf-hermes",
},
{
role: "reviewer",
output: { approved: false },
detail: "2MXBG6PN4A8JR",
agent: "uwf-hermes",
},
{
role: "developer",
output: { status: "failed" },
detail: "3QNTH7WK8D2PA",
agent: "uwf-hermes",
},
]);
const result = await evaluate(workflow, context);
expect(result).toEqual({ ok: true, value: "$END" });
});
test("$first returns earliest matching role's frontmatter", async () => {
const workflow: WorkflowPayload = {
...solveIssueWorkflow,
conditions: {
firstPlanReady: {
description: "First planner run was ready",
expression: "$first('planner').status = 'ready'",
},
},
graph: {
$START: [{ role: "planner", condition: null }],
planner: [
{ role: "$END", condition: "firstPlanReady" },
{ role: "developer", condition: null },
],
},
};
const context = makeContext([
{
role: "planner",
output: { status: "ready", plan: "ABC123" },
detail: "7BQST3VW9F2MA",
agent: "uwf-hermes",
},
{
role: "developer",
output: { status: "done" },
detail: "1VPBG9SM5E7WK",
agent: "uwf-hermes",
},
{
role: "planner",
output: { status: "revised", plan: "DEF456" },
detail: "4RNMK6PX8B3WQ",
agent: "uwf-hermes",
},
]);
const result = await evaluate(workflow, context);
expect(result).toEqual({ ok: true, value: "$END" });
});
test("$last returns undefined for unmatched role", async () => {
const workflow: WorkflowPayload = {
...solveIssueWorkflow,
conditions: {
hasReviewer: {
description: "Reviewer has run",
expression: "$exists($last('reviewer'))",
},
},
graph: {
$START: [{ role: "planner", condition: null }],
planner: [
{ role: "$END", condition: "hasReviewer" },
{ role: "developer", condition: null },
],
},
};
const context = makeContext([
{
role: "planner",
output: { status: "ready" },
detail: "7BQST3VW9F2MA",
agent: "uwf-hermes",
},
]);
const result = await evaluate(workflow, context);
// no reviewer step → $exists returns false → fallback to developer
expect(result).toEqual({ ok: true, value: "developer" });
});
});
+37 -2
View File
@@ -21,9 +21,44 @@ function isTruthy(value: unknown): boolean {
return true;
}
async function evaluateJsonata(expression: string, context: ModeratorContext): Promise<Result<unknown, Error>> {
function findByRole(
steps: ModeratorContext["steps"],
role: string,
direction: "first" | "last",
): unknown {
if (direction === "last") {
for (let i = steps.length - 1; i >= 0; i--) {
if (steps[i].role === role) {
return steps[i].output;
}
}
} else {
for (const step of steps) {
if (step.role === role) {
return step.output;
}
}
}
return undefined;
}
async function evaluateJsonata(
expression: string,
context: ModeratorContext,
): Promise<Result<unknown, Error>> {
try {
const result = await jsonata(expression).evaluate(context);
const expr = jsonata(expression);
expr.registerFunction(
"first",
(role: string) => findByRole(context.steps, role, "first"),
"<s:x>",
);
expr.registerFunction(
"last",
(role: string) => findByRole(context.steps, role, "last"),
"<s:x>",
);
const result = await expr.evaluate(context);
return { ok: true, value: result };
} catch (error) {
return {
+6 -6
View File
@@ -2,14 +2,14 @@ import type { JSONSchema } from "@uncaged/json-cas";
const ROLE_DEFINITION: JSONSchema = {
type: "object",
required: ["description", "identity", "prepare", "execute", "report", "outputSchema"],
required: ["description", "goal", "capabilities", "procedure", "output", "frontmatter"],
properties: {
description: { type: "string" },
identity: { type: "string" },
prepare: { type: "string" },
execute: { type: "string" },
report: { type: "string" },
outputSchema: { type: "string", format: "cas_ref" },
goal: { type: "string" },
capabilities: { type: "array", items: { type: "string" } },
procedure: { type: "string" },
output: { type: "string" },
frontmatter: { type: "string", format: "cas_ref" },
},
additionalProperties: false,
};
+5 -5
View File
@@ -18,11 +18,11 @@ export type StepRecord = {
export type RoleDefinition = {
description: string;
identity: string;
prepare: string;
execute: string;
report: string;
outputSchema: CasRef;
goal: string;
capabilities: string[];
procedure: string;
output: string;
frontmatter: CasRef;
};
export type Transition = {
@@ -0,0 +1,72 @@
// MAINTENANCE: This string must be kept in sync with the actual uwf CLI commands.
// Update whenever commands are added, removed, or their signatures change.
export function generateCliReference(): string {
return `# uwf CLI Reference
## Setup
\`\`\`
uwf setup # interactive setup wizard
uwf setup --provider <name> --base-url <url> \\
--api-key <key> --model <name> # non-interactive setup
[--agent <name>] # optional: default agent alias
\`\`\`
## Workflow Commands
\`\`\`
uwf workflow put <file> # register a workflow from YAML file
uwf workflow show <id> # show workflow by name or CAS hash
uwf workflow list # list all registered workflows
\`\`\`
## Thread Commands
\`\`\`
uwf thread start <workflow> -p <prompt> # create a thread (no execution)
uwf thread step <thread-id> # execute one moderator→agent→extract cycle
[--agent <cmd>] # override agent command
uwf thread show <thread-id> # show thread head pointer
uwf thread list # list active threads
[--all] # include archived threads
uwf thread kill <thread-id> # terminate and archive a thread
uwf thread steps <thread-id> # list all steps in a thread
uwf thread read <thread-id> # render thread context as markdown
[--quota <chars>] # max output characters (default 32000)
[--before <step-hash>] # load steps before this hash (exclusive)
[--start] # include start step in output
uwf thread fork <step-hash> # fork a thread from a specific step
uwf thread step-details <step-hash> # dump full detail node of a step as YAML
\`\`\`
## CAS Commands
\`\`\`
uwf cas get <hash> # read a CAS node (type + payload)
[--timestamp] # include timestamp in output
uwf cas put <type-hash> <data> # store a node, print its hash
# <data>: JSON file path or inline JSON string
uwf cas has <hash> # check if a hash exists
uwf cas refs <hash> # list direct CAS references from a node
uwf cas walk <hash> # recursive traversal from a node
uwf cas reindex # rebuild type index from all CAS nodes
uwf cas schema list # list all registered schemas
uwf cas schema get <hash> # show a schema by its type hash
\`\`\`
## Global Options
\`\`\`
uwf --format <fmt> # output format: json (default) or yaml
uwf -V, --version # print version
\`\`\`
## Key Concepts
- **Workflow**: YAML definition with roles, conditions, and a routing graph; stored as a CAS node identified by its XXH64 hash.
- **Thread**: A single workflow execution (ULID). State is an immutable CAS chain; active threads are indexed in \`threads.yaml\`.
- **Step**: One moderator→agent→extract cycle. Run \`uwf thread step\` repeatedly until \`$END\`.
- **CAS**: Content-Addressed Storage — all nodes are immutable and identified by hash.
- **Role**: Named actor with goal, capabilities, procedure, output, and meta; the moderator routes between roles.
`;
}
+6 -5
View File
@@ -1,10 +1,6 @@
export { err, ok } from "./result.js";
export { encodeUint64AsCrockford } from "./base32.js";
export { generateCliReference } from "./cli-reference.js";
export { env } from "./env.js";
export {
parseFrontmatterMarkdown,
validateFrontmatter,
} from "./frontmatter-markdown/index.js";
export type {
AgentFrontmatter,
FrontmatterScope,
@@ -12,8 +8,13 @@ export type {
FrontmatterValidationError,
ParsedFrontmatterMarkdown,
} from "./frontmatter-markdown/index.js";
export {
parseFrontmatterMarkdown,
validateFrontmatter,
} from "./frontmatter-markdown/index.js";
export { createLogger } from "./logger.js";
export { normalizeRefsField } from "./refs-field.js";
export { err, ok } from "./result.js";
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
export type { LogFn, Result } from "./types.js";
export { generateUlid } from "./ulid.js";