Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoju f61b395727 feat(cli): add currentRole field to thread show and thread list output (#571)
CI / test (pull_request) Successful in 1m9s
Add currentRole: string | null to StepOutput and ThreadListItemWithStatus.
- idle/running: derives next role via evaluate() on workflow graph
- completed/cancelled: null
-  as next role: null

Includes 9 test cases covering all status combinations and conditional routing.
2026-05-28 01:52:09 +00:00
104 changed files with 420 additions and 2902 deletions
+6 -7
View File
@@ -7,19 +7,18 @@ on:
branches: [main]
jobs:
check:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- run: bun install
- name: Install dependencies
run: bun install
- name: Build
run: bun run build
- name: Lint
- name: Check
run: bun run check
- name: Test
+19 -66
View File
@@ -23,12 +23,6 @@ roles:
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
2. Put the hash in frontmatter.plan (required when $status=ready)
3. Set repoPath to the absolute path of the repository root
IMPORTANT: Extract the repo remote (owner/repo) from git:
```bash
git remote get-url origin | sed 's|.*[:/]\([^/]*/[^.]*\).*|\1|'
```
Store the result as repoRemote in your frontmatter output so downstream roles can use it for tea/API calls.
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
frontmatter:
oneOf:
@@ -36,8 +30,7 @@ roles:
$status: { const: "ready" }
plan: { type: string }
repoPath: { type: string }
repoRemote: { type: string }
required: [$status, plan, repoPath, repoRemote]
required: [$status, plan, repoPath]
- properties:
$status: { const: "insufficient_info" }
required: [$status]
@@ -68,17 +61,6 @@ roles:
9. Implement the code to make tests pass
10. Ensure `bun run build` passes with no errors
11. Run `bun test` to verify all tests pass
- If tests fail on first run:
* Read the test output carefully for missing imports or setup issues
* Check if you're running tests from the correct working directory (package root vs workspace root)
* Fix the immediate issue and rerun ONCE
* If tests still fail after 2 attempts: check the test spec for ambiguities
* If stuck after 3 test cycles: set $status=failed with detailed error report rather than continuing blind retries
12. MANDATORY VERIFICATION before reporting done:
- Run `git branch --show-current` and confirm branch name matches expected
- Run `git status` and verify changed files exist
- Run `ls -la <key-implementation-files>` to verify they exist on disk
- If ANY verification fails: retry the implementation, do NOT report done
If you cannot complete the implementation (e.g. the issue is too complex, blocked by external factors,
or repeated attempts fail), set $status=failed with a reason.
@@ -89,7 +71,6 @@ roles:
$status: { const: "done" }
branch: { type: string }
worktree: { type: string }
repoRemote: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "failed" }
@@ -104,12 +85,7 @@ roles:
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
Before reviewing, verify the git branch:
1. Run `git branch --show-current` — confirm the branch name references the issue number being worked on
2. If the branch doesn't correspond to the issue, flag it in your output and reject
@@ -133,13 +109,11 @@ roles:
$status: { const: "approved" }
branch: { type: string }
worktree: { type: string }
repoRemote: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "rejected" }
comments: { type: string }
worktree: { type: string }
repoRemote: { type: string }
required: [$status, comments, worktree]
tester:
description: "Functional correctness verification"
@@ -163,48 +137,33 @@ roles:
$status: { const: "passed" }
branch: { type: string }
worktree: { type: string }
repoRemote: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "fix_code" }
report: { type: string }
repoRemote: { type: string }
worktree: { type: string }
branch: { type: string }
required: [$status, report]
- properties:
$status: { const: "fix_spec" }
report: { type: string }
repoRemote: { type: string }
worktree: { type: string }
branch: { type: string }
required: [$status, report]
committer:
description: "Commits and creates PR"
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
capabilities: []
procedure: |
The worktree path, branch name, and repo remote (owner/repo) are provided in your task prompt.
The worktree path, branch name, and repo info are provided in your task prompt.
cd into the worktree first.
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
1. 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"`
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>`
4. **Verify push succeeded** — run `git ls-remote origin <branch-name>` and confirm it prints a commit hash.
- If no output or push failed: capture the error, mark hook_failed
5. Create a PR using the Gitea API (do NOT use `tea pr create` — it fails in worktrees):
```bash
GITEA_TOKEN=$(cfg get GITEA_TOKEN)
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \
"https://git.shazhou.work/api/v1/repos/<owner>/<repo>/pulls" \
-d '{"title":"...","body":"...","head":"<branch>","base":"main"}'
```
- The repo remote (owner/repo format, e.g. "uncaged/workflow") is given in your task prompt — use it directly.
- PR body must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
6. **Verify PR was created** — parse the curl response JSON: it must contain a `"number"` field. Print the PR URL.
- If curl returns an error or no number field: capture the response, mark hook_failed
7. After PR creation, clean up the worktree:
- If push hook fails: capture the error log in your output, mark hook_failed
4. On push success: create a PR via `tea pr create --repo <owner/repo> --title "..." --description "..."`
- Extract owner/repo from: `git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/'`
- PR description must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
- On tea failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed
5. After PR creation, clean up the worktree:
- cd to the repo root (parent of .worktrees)
- `git worktree remove <worktree-path>`
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
@@ -213,33 +172,27 @@ roles:
- properties:
$status: { const: "committed" }
prUrl: { type: string }
repoRemote: { type: string }
worktree: { type: string }
branch: { type: string }
required: [$status, prUrl]
- properties:
$status: { const: "hook_failed" }
error: { type: string }
repoRemote: { type: string }
worktree: { type: string }
branch: { type: string }
required: [$status, error]
graph:
$START:
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
planner:
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}. Repo remote: {{{repoRemote}}}." }
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}." }
developer:
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance. Repo remote: {{{repoRemote}}}." }
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
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}}}." }
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in repo {{{worktree}}}." }
approved: { role: "tester", prompt: "Review passed. Run tests on branch {{{branch}}} at {{{worktree}}}." }
tester:
fix_code: { role: "developer", prompt: "Tests found code issues: {{{report}}}. Fix and re-submit. 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}}}." }
fix_code: { role: "developer", prompt: "Tests found code issues: {{{report}}}. Fix and re-submit." }
fix_spec: { role: "planner", prompt: "Tests found spec issues: {{{report}}}. Revise the test spec." }
passed: { role: "committer", prompt: "All tests passed. Commit and push branch {{{branch}}} from {{{worktree}}}." }
committer:
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit. Worktree: {{{worktree}}}. Repo remote: {{{repoRemote}}}." }
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow complete." }
-183
View File
@@ -1,183 +0,0 @@
# UWF Bootstrap Guide
This guide helps any AI agent set up `uwf` (Uncaged Workflow) from scratch — or self-check and upgrade an existing installation.
## Prerequisites
- **bun** — `uwf` is built with bun. Install: `curl -fsSL https://bun.sh/install | bash`
- **Network access** — to install npm packages
> **Already have uwf?** Jump to [Self-Check & Upgrade](#self-check--upgrade).
---
## Fresh Install
### 1. Install uwf CLI
```bash
bun install -g @uncaged/cli-workflow
```
**Check:** `uwf --version` prints a version number (e.g. `0.5.1`).
### 2. Install Agent Adapter
Install the adapter that matches your agent runtime. Pick **one**:
| Agent | Package | Binary |
|-------|---------|--------|
| Hermes | `@uncaged/workflow-agent-hermes` | `uwf-hermes` |
```bash
# Example: Hermes agent
bun install -g @uncaged/workflow-agent-hermes
```
**Check:** `uwf-hermes --version` prints a version number.
### 3. Setup
Run the interactive wizard:
```bash
uwf setup
```
Or configure non-interactively:
```bash
uwf setup \
--provider <name> \
--base-url <url> \
--api-key <key> \
--model <model-name> \
--agent hermes
```
This creates `~/.uncaged/workflow/config.yaml` with your provider, model, and default agent.
#### Config Structure
```yaml
providers:
my-provider:
baseUrl: https://api.example.com/v1
apiKey: sk-xxx
models:
default:
provider: my-provider
name: my-model
agents:
hermes:
command: uwf-hermes
args: []
defaultAgent: hermes
defaultModel: default
```
**Check:** `cat ~/.uncaged/workflow/config.yaml` shows valid provider, model, and agent config.
### 4. Verify Installation
```bash
uwf workflow list # should return empty array or existing workflows
uwf skill user # prints usage guide
uwf skill author # prints workflow authoring guide
```
**Check:** All three commands run without errors.
### 5. Add the uwf Skill
```bash
mkdir -p ~/.hermes/skills/devops/uwf
uwf skill bootstrap > ~/.hermes/skills/devops/uwf/SKILL.md
```
**Check:** `cat ~/.hermes/skills/devops/uwf/SKILL.md` shows the skill content with triggers `uwf`, `workflow`, `工作流`.
### 6. Smoke Test
```bash
# Register an example workflow
uwf workflow add examples/analyze-topic.yaml
# Start a thread
uwf thread start analyze-topic -p "Analyze the concept of technical debt"
# Execute it (one moderator → agent → extract cycle)
uwf thread exec <thread-id>
```
**Check:** Thread reaches `completed` status. Verify with `uwf thread list`.
---
## Self-Check & Upgrade
Already have uwf installed? Run through this checklist to verify and upgrade.
### Version Check
```bash
uwf --version
uwf-hermes --version # or your agent adapter
```
Compare with latest published versions:
```bash
bun pm ls -g | grep -E "cli-workflow|workflow-agent"
npm info @uncaged/cli-workflow version
npm info @uncaged/workflow-agent-hermes version
```
If local version < published version, upgrade:
```bash
bun install -g @uncaged/cli-workflow@latest
bun install -g @uncaged/workflow-agent-hermes@latest
```
**Check:** `uwf --version` matches `npm info @uncaged/cli-workflow version`.
### Config Check
```bash
cat ~/.uncaged/workflow/config.yaml
```
Verify:
- [ ] `providers` has at least one entry with valid `baseUrl` and `apiKey`
- [ ] `models.default` references an existing provider
- [ ] `agents` has your adapter configured
- [ ] `defaultAgent` and `defaultModel` are set
### Skill Check
```bash
cat ~/.hermes/skills/devops/uwf/SKILL.md
```
Verify the skill is up to date:
```bash
uwf skill bootstrap | diff - ~/.hermes/skills/devops/uwf/SKILL.md
```
If `diff` produces any output, the local skill is outdated. Update:
```bash
uwf skill bootstrap > ~/.hermes/skills/devops/uwf/SKILL.md
```
### Functional Check
```bash
uwf workflow list # should not error
uwf skill user # should print usage guide
uwf skill author # should print authoring guide
```
✅ All green? You're good to go.
+3 -3
View File
@@ -13,7 +13,7 @@ This monorepo implements a stateless workflow engine driven by a single-step CLI
| **Role** | A named actor within a workflow. Each role has a system prompt and a JSON Schema `outputSchema`. |
| **Moderator** | Status-based graph evaluator — determines the next role (or `$END`) with zero LLM cost. |
| **Agent** | An external CLI command (`uwf-hermes`, etc.) spawned by `uwf thread step`. Produces frontmatter markdown output. |
| **CAS** | Content-Addressed Storage via `@ocas/core` — all workflow definitions, thread nodes, and outputs are immutable CAS nodes. |
| **CAS** | Content-Addressed Storage via `@uncaged/json-cas` — all workflow definitions, thread nodes, and outputs are immutable CAS nodes. |
| **Registry** | `~/.uncaged/workflow/registry.yaml` — maps workflow names to current CAS hashes. |
### Monorepo Structure
@@ -35,7 +35,7 @@ workflow/
- Dependency layers: `workflow-protocol``workflow-util``workflow-util-agent``workflow-agent-hermes` / `cli-workflow`
- Packages use `workspace:^` protocol (resolves to `^x.y.z` on publish)
- External CAS: `@ocas/core` (store API, hashing, schema validation) + `@ocas/fs` (filesystem backend)
- External CAS: `@uncaged/json-cas` (store API, hashing, schema validation) + `@uncaged/json-cas-fs` (filesystem backend)
## Language & Paradigm
@@ -270,7 +270,7 @@ node scripts/publish-all.mjs --dry-run # preview without publishing
examples/solve-issue.yaml — write a workflow YAML definition
│ uwf workflow put
~/.uncaged/json-cas/ — Workflow stored as CAS node (unified CAS store)
~/.uncaged/workflow/cas/ — Workflow stored as CAS node
~/.uncaged/workflow/registry.yaml — name → hash mapping updated
│ uwf thread start <name> -p "..."
+1 -1
View File
@@ -67,7 +67,7 @@ App (uses protocol; not in the runtime engine stack)
workflow-dashboard Web UI for visual workflow editing
```
External CAS: [`@ocas/core`](https://www.npmjs.com/package/@ocas/core) (store API, hashing, schema validation) + `@ocas/fs` (filesystem backend).
External CAS: [`@uncaged/json-cas`](https://www.npmjs.com/package/@uncaged/json-cas) (store API, hashing, schema validation) + `@uncaged/json-cas-fs` (filesystem backend).
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, CAS node types, storage layout, agent CLI protocol, and design decisions.
+2 -3
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"files": {
"includes": [
"**",
@@ -39,8 +39,7 @@
"linter": {
"rules": {
"suspicious": {
"noExplicitAny": "off",
"noConsole": "off"
"noExplicitAny": "off"
},
"style": {
"noNonNullAssertion": "off"
+15 -15
View File
@@ -8,13 +8,13 @@
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions stored as CAS nodes; threads are immutable chains of CAS-linked step nodes. No daemon — each `uwf thread step` invocation runs one moderator→agent→extract cycle and exits.
The implementation lives in **5** active packages under `packages/`, plus two external CAS packages (`@ocas/core`, `@ocas/fs`). Legacy packages reside in `legacy-packages/` and are not part of the active stack.
The implementation lives in **5** active packages under `packages/`, plus two external CAS packages (`@uncaged/json-cas`, `@uncaged/json-cas-fs`). Legacy packages reside in `legacy-packages/` and are not part of the active stack.
## Package map
| Layer | Package | One-line role |
|-------|---------|---------------|
| Contract | `@uncaged/workflow-protocol``workflow-protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `ModeratorContext`, `WorkflowConfig`, etc.). No runtime deps beyond `@ocas/fs`. |
| Contract | `@uncaged/workflow-protocol``workflow-protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `ModeratorContext`, `WorkflowConfig`, etc.). No runtime deps beyond `@uncaged/json-cas-fs`. |
| Shared infra | `@uncaged/workflow-util``workflow-util` | Crockford Base32, ULID generation, `createLogger`, frontmatter parsing/validation. |
| Agent framework | `@uncaged/workflow-util-agent``workflow-util-agent` | `createAgent` entrypoint factory, context builder, frontmatter fast-path extractor, LLM extract fallback, output format instruction builder. |
| Agent: Hermes | `@uncaged/workflow-agent-hermes``workflow-agent-hermes` | `uwf-hermes` CLI binary — spawns `hermes chat`, pipes prompt, captures session detail. |
@@ -24,8 +24,8 @@ The implementation lives in **5** active packages under `packages/`, plus two ex
| Package | Role |
|---------|------|
| `@ocas/core` | Content-addressed store API, XXH64 hashing, JSON Schema registration and validation. |
| `@ocas/fs` | Filesystem backend for `ocas`. |
| `@uncaged/json-cas` | Content-addressed store API, XXH64 hashing, JSON Schema registration and validation. |
| `@uncaged/json-cas-fs` | Filesystem backend for `json-cas`. |
| `mustache` | Template renderer for edge prompts (used by `cli-workflow` moderator). |
| `commander` | CLI argument parsing (used by `cli-workflow`). |
| `dotenv` | Loads `.env` files for API keys. |
@@ -36,8 +36,8 @@ The implementation lives in **5** active packages under `packages/`, plus two ex
```mermaid
flowchart BT
subgraph External
jcas["@ocas/core"]
jcasfs["@ocas/fs"]
jcas["@uncaged/json-cas"]
jcasfs["@uncaged/json-cas-fs"]
end
subgraph L0["Layer 0 — contract"]
protocol["@uncaged/workflow-protocol"]
@@ -146,7 +146,7 @@ Key properties:
- **`roles`** — inline role definitions; each `meta` is a JSON Schema (stored as its own CAS node on registration)
- **`graph`** — `Record<Role | "$START", Record<Status, Target>>` — status-based routing; each role maps statuses to targets
- **No agent binding** — agent selection is a deployment concern, configured in `config.yaml`
- **No Zod** — all schemas are JSON Schema, validated through `@ocas/core`
- **No Zod** — all schemas are JSON Schema, validated through `@uncaged/json-cas`
## Three-phase engine loop
@@ -263,7 +263,7 @@ Structured output extraction uses a two-layer strategy (`workflow-util-agent`):
2. Validate required fields (`validateFrontmatter`)
3. Build a candidate object from frontmatter fields (`status`, `next`, `confidence`, `artifacts`, `scope`)
4. `store.put()` the candidate against the role's `meta` schema
5. Validate with `ocas` schema validation
5. Validate with `json-cas` schema validation
6. If valid → return `outputHash` (zero LLM cost)
### Layer 2: LLM extract fallback (`extract.ts`)
@@ -302,7 +302,7 @@ payload:
capabilities: [planning, issue-analysis]
procedure: "Analyze the issue and create a plan."
output: "Output the plan summary."
meta: "5GWKR8TN1V3JA" # ocas_ref → JSON Schema node
meta: "5GWKR8TN1V3JA" # cas_ref → JSON Schema node
conditions:
notApproved:
description: "Reviewer rejected"
@@ -318,7 +318,7 @@ payload:
```yaml
type: <start-node-schema-hash>
payload:
workflow: "4KNM2PXR3B1QW" # ocas_ref → Workflow
workflow: "4KNM2PXR3B1QW" # cas_ref → Workflow
prompt: "Fix the login bug..."
```
@@ -327,11 +327,11 @@ payload:
```yaml
type: <step-node-schema-hash>
payload:
start: "4TNVW8KR2B3MA" # ocas_ref → StartNode
prev: "2MXBG6PN4A8JR" # ocas_ref → previous StepNode (null for first step)
start: "4TNVW8KR2B3MA" # cas_ref → StartNode
prev: "2MXBG6PN4A8JR" # cas_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)
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)
```
@@ -484,7 +484,7 @@ Binary: `uwf`
| **Frontmatter markdown output** | Agents produce structured meta (YAML frontmatter) alongside free-form content (markdown body). Enables zero-cost extraction when frontmatter is well-formed. |
| **Two-layer extract** | Fast path avoids LLM calls when agents follow the format; LLM fallback handles messy output gracefully. |
| **Prompt injection for format** | Output format instruction prepended to system prompt ensures agents produce parseable output without per-agent configuration. |
| **JSON Schema (not Zod)** | Schemas are CAS-native data — storable, hashable, validatable through `ocas`. No code generation, no runtime library dependency. |
| **JSON Schema (not Zod)** | Schemas are CAS-native data — storable, hashable, validatable through `json-cas`. No code generation, no runtime library dependency. |
| **Agent as external command** | Agents are independent CLI binaries (`uwf-hermes`, `uwf-cursor`). Swappable per workflow/role via config. No tight coupling to the engine. |
| **No daemon** | Process starts, does one step, exits. Simpler failure model, no connection management. |
| **Crockford Base32** | Filesystem-safe, case-insensitive, readable, compact. |
+1 -1
View File
@@ -630,7 +630,7 @@ flowchart TB
Spawn -->|"stdout: step hash"| Step
```
**新包**:`packages/workflow-agent-builtin`,bin `uwf-builtin`,仅依赖 `workflow-util-agent`、`workflow-protocol`、`workflow-util`(可选 `@ocas/core` 写 detail schema)。
**新包**:`packages/workflow-agent-builtin`,bin `uwf-builtin`,仅依赖 `workflow-util-agent`、`workflow-protocol`、`workflow-util`(可选 `@uncaged/json-cas` 写 detail schema)。
**分层**:
+25 -25
View File
@@ -22,7 +22,7 @@ uwf workflow show <workflow-id> # 查看 workflow 定义
uwf workflow list # 列出已注册 workflows
```
两组对称,各 3-4 个子命令。CAS 操作交给 `ocas` CLI,不在 `uwf` 中重复。
两组对称,各 3-4 个子命令。CAS 操作交给 `json-cas` CLI,不在 `uwf` 中重复。
### 1.2 `uwf thread start`
@@ -136,14 +136,14 @@ uwf-hermes <thread-id> <role>
沿用 json-cas 的三层:bootstrap meta-schema → JSON Schema nodes → data nodes。
下面所有 CAS 节点都遵循 `{ type: ocas_ref, payload: T, timestamp: number }` 的标准格式。
`ocas_ref` 类型的字符串字段在 ocas 中已内置支持,不需要额外的 `$ref` 包装。
下面所有 CAS 节点都遵循 `{ type: cas_ref, payload: T, timestamp: number }` 的标准格式。
`cas_ref` 类型的字符串字段在 json-cas 中已内置支持,不需要额外的 `$ref` 包装。
### 2.2 数据节点
#### `Workflow`
Roles 和 moderator 内联在 Workflow 中,只有 meta 独立为 CAS 节点(方便 ocas 校验)。
Roles 和 moderator 内联在 Workflow 中,只有 meta 独立为 CAS 节点(方便 json-cas 校验)。
```yaml
type: <workflow-schema-hash>
@@ -157,21 +157,21 @@ payload:
capabilities: [planning, issue-analysis]
procedure: "Analyze the issue and create a plan."
output: "Output the plan summary."
meta: "5GWKR8TN1V3JA" # ocas_ref → JSON Schema 节点(ocas 内置)
meta: "5GWKR8TN1V3JA" # cas_ref → JSON Schema 节点(json-cas 内置)
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 节点
meta: "8CNWT4KR6D1HV" # cas_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 节点
meta: "1VPBG9SM5E7WK" # cas_ref → JSON Schema 节点
conditions:
needsClarification:
description: "Planner requests clarification from user"
@@ -198,7 +198,7 @@ payload:
condition: null
```
- `roles` — 内联定义,每个 role 的 `meta` 是独立的 ocas_ref(指向 ocas 内置 JSON Schema 节点)
- `roles` — 内联定义,每个 role 的 `meta` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
- `graph``Record<Role | "$START", Record<Status, Target>>`,每个 Target = `{ role, prompt }`
- Status 来自上一个 role 输出的 `status` 字段,`$START``_` 作为初始 status
- Prompt 模板使用 Mustache 渲染,变量来自 lastOutput
@@ -220,7 +220,7 @@ evaluate(graph, lastRole, lastOutput) → { role, prompt }
```yaml
type: <start-node-schema-hash>
payload:
workflow: "4KNM2PXR3B1QW" # ocas_ref → Workflow
workflow: "4KNM2PXR3B1QW" # cas_ref → Workflow
prompt: "Fix the login bug..."
```
@@ -232,18 +232,18 @@ payload:
```yaml
type: <step-node-schema-hash>
payload:
start: "4TNVW8KR2B3MA" # ocas_ref → StartNode(每个 step 都引用)
prev: "2MXBG6PN4A8JR" # ocas_ref → 前一个 StepNode,第一步为 null
start: "4TNVW8KR2B3MA" # cas_ref → StartNode(每个 step 都引用)
prev: "2MXBG6PN4A8JR" # cas_ref → 前一个 StepNode,第一步为 null
role: "developer"
output: "9KRVW3TN5F1QA" # ocas_ref → 结构化输出节点(符合 role 的 meta schema)
detail: "7BQST3VW9F2MA" # ocas_ref → 执行详情(content node / 子 workflow terminal StepNode / ...)
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 的 ocas_ref,第一步为 `null`(不指向 StartNode)
- `output`ocas_ref,指向符合 role meta schema 的 CAS 节点,可用 ocas 校验
- `detail`ocas_ref,指向执行详情。可以是原始 agent 输出(content node),也可以是子 workflow thread 的 terminal StepNode(workflowAsAgent 场景)
- `prev` — 前一个 StepNode 的 cas_ref,第一步为 `null`(不指向 StartNode)
- `output` — cas_ref,指向符合 role meta schema 的 CAS 节点,可用 json-cas 校验
- `detail` — cas_ref,指向执行详情。可以是原始 agent 输出(content node),也可以是子 workflow thread 的 terminal StepNode(workflowAsAgent 场景)
- `agent` — 纯字符串,不是 CAS 节点
### 2.3 链式结构
@@ -337,7 +337,7 @@ OPENROUTER_API_KEY=sk-or-...
## 3. 包结构
全新包,不复用现有 packages,避免命名冲突。CAS 直接依赖 `@ocas/core`
全新包,不复用现有 packages,避免命名冲突。CAS 直接依赖 `@uncaged/json-cas`
```
packages/
@@ -349,8 +349,8 @@ packages/
```
**外部依赖:**
- `@ocas/core` — CAS 存储、hash、schema 校验
- `@ocas/fs` — 文件系统 CAS 后端
- `@uncaged/json-cas` — CAS 存储、hash、schema 校验
- `@uncaged/json-cas-fs` — 文件系统 CAS 后端
**现有包全部保留不动**,新旧并存,逐步迁移。
@@ -372,8 +372,8 @@ 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)
output: CasRef; // cas_ref → 结构化输出节点(符合 role meta schema)
detail: CasRef; // cas_ref → 执行详情(content node / 子 workflow terminal StepNode)
agent: string; // 实际使用的 agent 命令(纯字符串)
};
```
@@ -387,7 +387,7 @@ type RoleDefinition = {
capabilities: string[];
procedure: string;
output: string;
meta: CasRef; // ocas_ref → ocas 内置 JSON Schema 节点
meta: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
};
type Target = {
@@ -407,13 +407,13 @@ type WorkflowPayload = {
```typescript
type StartNodePayload = {
workflow: CasRef; // ocas_ref → Workflow
workflow: CasRef; // cas_ref → Workflow
prompt: string;
};
type StepNodePayload = StepRecord & {
start: CasRef; // ocas_ref → StartNode(每个 step 都引用)
prev: CasRef | null; // ocas_ref → 前一个 StepNode,第一步为 null
start: CasRef; // cas_ref → StartNode(每个 step 都引用)
prev: CasRef | null; // cas_ref → 前一个 StepNode,第一步为 null
};
```
+9 -45
View File
@@ -8,46 +8,22 @@ roles:
- issue-analysis
- planning
procedure: |
CRITICAL: First, determine which mode you are in by scanning the task prompt.
Choose EXACTLY ONE mode — do NOT default to Mode A if Mode B applies.
**How to choose:**
- If the prompt contains ANY of these keywords: "PR #", "PR#", "pulls/", "继续修复", "continue", "review feedback", "existing branch", "fix/", or mentions a branch name → **Mode B**
- If the prompt was forwarded from tester with fix_spec → **Mode C**
- Otherwise → **Mode A**
**Mode A — Fresh issue (first time, no existing PR):**
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
6. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
7. Output **$status=ready** with plan hash and repoPath
**Mode B — Continue on existing PR (prompt mentions PR, branch, or review feedback):**
YOU MUST output $status=continue (NOT ready) when in this mode.
1. Extract the PR number and branch name from the prompt
2. Read the PR and its review comments from Gitea: `tea pr <number> --comments -r <owner/repo>`
3. Read the existing issue for full context: `tea issues <number> -r <owner/repo>`
4. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
5. Produce a TDD test spec that ONLY covers the changes requested in the review — do NOT re-spec already-implemented features
6. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
7. Find the existing worktree: `git worktree list` and locate the branch
8. Output **$status=continue** with plan hash, repoPath, branch name, and worktree path
**Mode C — Bounced back by tester (fix_spec):**
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
3. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
4. Output **$status=ready** with plan hash and repoPath
IMPORTANT: Extract the repo remote (owner/repo) from git:
```bash
git remote get-url origin | sed 's|.*[:/]\([^/]*/[^.]*\).*|\1|'
```
Store the result as repoRemote in your frontmatter output so downstream roles can use it.
output: "Output a brief summary of the test spec. Set $status to ready (fresh), continue (existing PR), or insufficient_info."
After producing the test spec:
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
2. Put the hash in frontmatter.plan (required when $status=ready)
3. Set repoPath to the absolute path of the repository root
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
frontmatter:
oneOf:
- properties:
@@ -55,13 +31,6 @@ roles:
plan: { type: string }
repoPath: { type: string }
required: [$status, plan, repoPath]
- properties:
$status: { const: "continue" }
plan: { type: string }
repoPath: { type: string }
branch: { type: string }
worktree: { type: string }
required: [$status, plan, repoPath, branch, worktree]
- properties:
$status: { const: "insufficient_info" }
required: [$status]
@@ -80,14 +49,10 @@ roles:
3. First time (no existing branch):
- `git worktree add .worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
- `cd .worktrees/fix/<issue-number>-<short-slug> && bun install`
4. If continuing on existing branch (prompt says "Continue work on existing branch" or provides a worktree path):
- cd directly into the worktree path provided in the prompt
- `git fetch origin && git rebase origin/main`
- Do NOT create a new branch or worktree
5. If bounced back from reviewer or tester (branch already exists but no explicit worktree path):
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`
6. ALL subsequent work must happen inside the worktree directory.
5. ALL subsequent work must happen inside the worktree directory.
Then implement TDD:
6. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner's output in your task prompt)
@@ -218,7 +183,6 @@ graph:
planner:
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}." }
continue: { role: "developer", prompt: "Continue work on existing branch {{{branch}}} at worktree {{{worktree}}}. Implement the revised TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}. Do NOT create a new branch or worktree — cd into the existing worktree and work there." }
developer:
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
+1 -7
View File
@@ -1,14 +1,11 @@
{
"name": "@uncaged/workflow-monorepo",
"private": true,
"packageManager": "bun@1.3.14",
"workspaces": [
"packages/*"
],
"scripts": {
"uwf": "bun packages/cli-workflow/src/cli.ts",
"preinstall": "npx only-allow bun",
"prepublishOnly": "echo 'Use bun run release instead' && exit 1",
"build": "bunx tsc --build",
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
"typecheck": "bunx tsc --build",
@@ -26,10 +23,7 @@
"@types/node": "^25.7.0",
"@types/xxhashjs": "^0.2.4",
"@uncaged/workflow-agent-hermes": "workspace:*",
"bun-types": "^1.3.13",
"typescript": "^5.8.3",
"vitest": "^4.1.7",
"yaml": "^2.9.0"
"bun-types": "^1.3.13"
},
"repository": {
"type": "git",
+2 -11
View File
@@ -20,7 +20,7 @@ workflow → thread → step → turn
This package has no library `src/index.ts` — it is consumed as a CLI binary only.
**Dependencies:** `@ocas/core`, `@ocas/fs`, `@uncaged/workflow-util-agent`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`, `commander`, `dotenv`, `mustache`, `yaml`
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`, `@uncaged/workflow-util-agent`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`, `commander`, `dotenv`, `mustache`, `yaml`
## Installation
@@ -209,13 +209,4 @@ src/
| `~/.uncaged/workflow/.env` | API keys (referenced by `apiKeyEnv` in config) |
| `~/.uncaged/workflow/registry.yaml` | Workflow name → CAS hash |
| `~/.uncaged/workflow/threads.yaml` | Active thread head pointers |
| `~/.uncaged/json-cas/` | Content-addressed node storage (unified CAS store, shared with `ocas` CLI) |
### Environment Variables
| Variable | Purpose | Default |
|----------|---------|---------|
| `UNCAGED_CAS_DIR` | Override the global CAS directory location | `~/.uncaged/json-cas` |
| `UNCAGED_WORKFLOW_STORAGE_ROOT` | Internal override for workflow metadata storage | `~/.uncaged/workflow` |
| `WORKFLOW_STORAGE_ROOT` | User override for workflow metadata storage | `~/.uncaged/workflow` |
| `~/.uncaged/workflow/cas/` | Content-addressed node storage |
+5 -6
View File
@@ -11,8 +11,8 @@
"uwf": "./dist/cli.js"
},
"dependencies": {
"@ocas/core": "^0.1.1",
"@ocas/fs": "^0.1.1",
"@uncaged/json-cas": "^0.5.3",
"@uncaged/json-cas-fs": "^0.5.3",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^",
@@ -22,7 +22,6 @@
"yaml": "^2.8.4"
},
"scripts": {
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
"test": "vitest run",
"test:ci": "vitest run"
},
@@ -35,12 +34,12 @@
},
"repository": {
"type": "git",
"url": "https://git.shazhou.work/uncaged/workflow.git",
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
"directory": "packages/cli-workflow"
},
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
"bugs": {
"url": "https://git.shazhou.work/uncaged/workflow/issues"
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
},
"license": "MIT"
}
@@ -2,8 +2,8 @@ import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { putSchema } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import { putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { registerUwfSchemas } from "../schemas.js";
@@ -129,11 +129,7 @@ describe("C1: adapter JSON round-trip integration", () => {
{
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
WORKFLOW_STORAGE_ROOT: tmpDir,
UNCAGED_CAS_DIR: casDir,
},
env: { ...process.env, WORKFLOW_STORAGE_ROOT: tmpDir },
cwd: tmpDir,
timeout: 30000,
},
@@ -6,22 +6,14 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdCasPutText } from "../commands/cas.js";
let storageRoot: string;
let casDir: string;
let uwfPath: string;
let originalEnv: string | undefined;
beforeEach(async () => {
storageRoot = join(
tmpdir(),
`uwf-cas-exit-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
casDir = join(storageRoot, "cas");
await mkdir(storageRoot, { recursive: true });
await mkdir(casDir, { recursive: true });
// Set UNCAGED_CAS_DIR for this test
originalEnv = process.env.UNCAGED_CAS_DIR;
process.env.UNCAGED_CAS_DIR = casDir;
// Find the uwf CLI path
uwfPath = join(__dirname, "../../src/cli.ts");
@@ -29,13 +21,6 @@ beforeEach(async () => {
afterEach(async () => {
await rm(storageRoot, { recursive: true, force: true });
// Restore original environment
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
});
type ExecResult = {
@@ -47,11 +32,7 @@ type ExecResult = {
function execUwf(args: string[]): ExecResult {
try {
const stdout = execSync(`bun ${uwfPath} ${args.join(" ")}`, {
env: {
...process.env,
WORKFLOW_STORAGE_ROOT: storageRoot,
UNCAGED_CAS_DIR: casDir,
},
env: { ...process.env, WORKFLOW_STORAGE_ROOT: storageRoot },
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});
@@ -720,7 +720,7 @@ defaultModel: default
describe("no legacy apiKeyEnv references", () => {
test("config.ts has no references to apiKeyEnv", () => {
const configSource = readFileSync(join(__dirname, "..", "..", "src", "commands", "config.ts"), "utf8");
const configSource = readFileSync(join(__dirname, "..", "commands", "config.ts"), "utf8");
expect(configSource).not.toContain("apiKeyEnv");
});
@@ -1,7 +1,7 @@
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { putSchema } from "@ocas/core";
import { putSchema } from "@uncaged/json-cas";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { createMarker, deleteMarker } from "../background/index.js";
@@ -206,8 +206,6 @@ async function insertStepNode(
describe("currentRole field", () => {
let tmpDir: string;
let storageRoot: string;
let casDir: string;
let originalEnv: string | undefined;
async function setup() {
tmpDir = join(
@@ -215,25 +213,13 @@ describe("currentRole field", () => {
`uwf-test-current-role-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
storageRoot = join(tmpDir, "storage");
casDir = join(tmpDir, "cas");
await mkdir(storageRoot, { recursive: true });
await mkdir(casDir, { recursive: true });
// Set UNCAGED_CAS_DIR for this test
originalEnv = process.env.UNCAGED_CAS_DIR;
process.env.UNCAGED_CAS_DIR = casDir;
}
async function teardown() {
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true });
}
// Restore original environment
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
}
// T1: idle at start — currentRole = first role from graph
@@ -1,84 +0,0 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { parse } from "yaml";
import { createIncludeTag } from "../include.js";
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "include-tag-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
describe("!include tag", () => {
test("includes .md file as string", async () => {
await writeFile(join(tmpDir, "prompt.md"), "You are an analyst.");
const yaml = "system: !include prompt.md";
const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] });
expect(result.system).toBe("You are an analyst.");
});
test("includes .json file as parsed object", async () => {
await writeFile(join(tmpDir, "schema.json"), '{"type":"object","properties":{}}');
const yaml = "outputSchema: !include schema.json";
const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] });
expect(result.outputSchema).toEqual({ type: "object", properties: {} });
});
test("includes .yaml file as parsed object", async () => {
await writeFile(join(tmpDir, "config.yaml"), "key: value\nlist:\n - a\n - b");
const yaml = "config: !include config.yaml";
const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] });
expect(result.config).toEqual({ key: "value", list: ["a", "b"] });
});
test("resolves relative subdirectory paths", async () => {
const subdir = join(tmpDir, "roles");
await mkdir(subdir, { recursive: true });
await writeFile(join(subdir, "analyst.md"), "Analyze data.");
const yaml = "system: !include roles/analyst.md";
const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] });
expect(result.system).toBe("Analyze data.");
});
test("throws on missing file", () => {
const yaml = "system: !include nonexistent.md";
expect(() => parse(yaml, { customTags: [createIncludeTag(tmpDir)] })).toThrow();
});
test("includes .txt file as string", async () => {
await writeFile(join(tmpDir, "note.txt"), "Hello world");
const yaml = "note: !include note.txt";
const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] });
expect(result.note).toBe("Hello world");
});
test("blocks path traversal with ../", async () => {
const yaml = "secret: !include ../../etc/passwd";
expect(() => parse(yaml, { customTags: [createIncludeTag(tmpDir)] })).toThrow(
/path traversal blocked/,
);
});
test("blocks absolute path traversal", async () => {
const yaml = "secret: !include /etc/passwd";
expect(() => parse(yaml, { customTags: [createIncludeTag(tmpDir)] })).toThrow(
/path traversal blocked/,
);
});
test("supports nested !include in yaml files", async () => {
const subdir = join(tmpDir, "parts");
await mkdir(subdir, { recursive: true });
await writeFile(join(subdir, "inner.md"), "nested content");
await writeFile(join(tmpDir, "outer.yaml"), "value: !include parts/inner.md");
const yaml = "config: !include outer.yaml";
const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] });
expect(result.config).toEqual({ value: "nested content" });
});
});
@@ -6,17 +6,27 @@ import { describe, expect, test } from "vitest";
const __dirname = dirname(fileURLToPath(import.meta.url));
import {
cmdSkillActor,
cmdSkillAdapter,
cmdSkillArchitecture,
cmdSkillAuthor,
cmdSkillCli,
cmdSkillDeveloper,
cmdSkillList,
cmdSkillModerator,
cmdSkillUser,
cmdSkillYaml,
} from "../commands/skill.js";
describe("skill commands", () => {
test("skill list returns all skill names", () => {
const result = cmdSkillList();
expect(result).toBeInstanceOf(Array);
expect(result).toContain("cli");
expect(result).toContain("architecture");
expect(result).toContain("yaml");
expect(result).toContain("moderator");
expect(result).toContain("actor");
expect(result).toContain("user");
expect(result).toContain("author");
expect(result).toContain("developer");
@@ -26,6 +36,50 @@ describe("skill commands", () => {
}
});
test("skill architecture returns non-empty markdown string", () => {
const result = cmdSkillArchitecture();
expect(typeof result).toBe("string");
expect(result).toContain("CAS");
expect(result).toContain("Thread");
expect(result).toContain("Workflow");
expect(result).toContain("Step");
expect(result.length).toBeGreaterThan(200);
});
test("skill yaml returns non-empty markdown string", () => {
const result = cmdSkillYaml();
expect(typeof result).toBe("string");
expect(result).toContain("roles");
expect(result).toContain("graph");
expect(result).toContain("frontmatter");
expect(result.length).toBeGreaterThan(200);
});
test("skill moderator returns non-empty markdown string", () => {
const result = cmdSkillModerator();
expect(typeof result).toBe("string");
expect(result).toContain("routing");
expect(result).toContain("status");
expect(result.length).toBeGreaterThan(200);
// Check for edge or graph
expect(result).toMatch(/edge|graph/i);
});
test("skill cli returns CLI reference markdown", () => {
const result = cmdSkillCli();
expect(typeof result).toBe("string");
expect(result).toContain("uwf");
});
test("skill actor returns non-empty markdown string", () => {
const result = cmdSkillActor();
expect(typeof result).toBe("string");
expect(result).toContain("frontmatter");
expect(result).toContain("CAS");
expect(result).toContain("status");
expect(result.length).toBeGreaterThan(200);
});
test("skill user returns non-empty markdown string", () => {
const result = cmdSkillUser();
expect(typeof result).toBe("string");
@@ -72,6 +126,11 @@ describe("skill commands", () => {
env: { ...process.env, PATH: `/opt/homebrew/bin:${process.env.PATH}` },
});
expect(output).not.toMatch(/help\s+\[command\]/i);
expect(output).toContain("cli");
expect(output).toContain("architecture");
expect(output).toContain("yaml");
expect(output).toContain("moderator");
expect(output).toContain("actor");
expect(output).toContain("user");
expect(output).toContain("author");
expect(output).toContain("developer");
@@ -8,11 +8,11 @@ import { parse } from "yaml";
* Test: Issue #474 - tea pr create fails in git worktree directories
*
* This test verifies that the solve-issue workflow's committer role
* uses direct Gitea API calls via curl instead of tea pr create,
* which fixes the "path segment [0] is empty" error in worktree directories.
* includes the --repo flag when running tea pr create, which fixes
* the "path segment [0] is empty" error in worktree directories.
*/
describe("solve-issue workflow: Gitea API PR creation", () => {
describe("solve-issue workflow: tea pr create worktree fix", () => {
// Navigate up from packages/cli-workflow/src/__tests__ to repo root
const workflowPath = join(
import.meta.dirname,
@@ -24,7 +24,7 @@ describe("solve-issue workflow: Gitea API PR creation", () => {
"solve-issue.yaml",
);
test("committer procedure should use curl API instead of tea pr create", async () => {
test("committer procedure should include --repo flag in tea pr create command", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
const workflow = parse(yamlContent) as WorkflowPayload;
@@ -32,38 +32,43 @@ 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 the procedure includes tea pr create with --repo flag
expect(committerProcedure).toContain("tea pr create");
expect(committerProcedure).toContain("--repo");
// Verify it explicitly warns against tea pr create
expect(committerProcedure).toMatch(/do NOT use.*tea pr create/i);
// Verify the --repo flag appears before or together with tea pr create
// This ensures the command is: tea pr create --repo <owner/repo> ...
const teaPrCreateMatch = committerProcedure?.match(/tea pr create[^\n]*/);
expect(teaPrCreateMatch).not.toBeNull();
if (teaPrCreateMatch) {
const teaCommandLine = teaPrCreateMatch[0];
expect(teaCommandLine).toContain("--repo");
}
});
test("committer procedure should reference repoRemote from task prompt", async () => {
test("committer procedure should mention repo extraction from git remote", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
const workflow = parse(yamlContent) as WorkflowPayload;
const committerProcedure = workflow.roles.committer?.procedure;
expect(committerProcedure).toBeDefined();
// Verify the procedure mentions repoRemote is provided in task prompt
expect(committerProcedure).toMatch(/repo remote.*provided.*task prompt/i);
expect(committerProcedure).toMatch(/owner\/repo/i);
// Verify the procedure mentions extracting repo info from git remote
// This ensures fallback logic is documented
expect(committerProcedure).toMatch(/git remote/i);
});
test("committer procedure should include error handling for curl failures", async () => {
test("committer procedure should include error handling for tea failures", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
const workflow = parse(yamlContent) as WorkflowPayload;
const committerProcedure = workflow.roles.committer?.procedure;
expect(committerProcedure).toBeDefined();
// Verify the procedure includes error handling guidance for curl
// Verify the procedure includes error handling guidance
// This ensures we capture failures and provide actionable output
expect(committerProcedure).toMatch(/error|fail/i);
expect(committerProcedure).toContain("hook_failed");
});
test("workflow should be parseable as valid WorkflowPayload", async () => {
@@ -93,51 +98,9 @@ describe("solve-issue workflow: Gitea API PR creation", () => {
expect(frontmatter).toBeDefined();
expect(frontmatter?.oneOf).toBeDefined();
const committedVariant = frontmatter.oneOf.find(
(v: any) => v.properties?.$status?.const === "committed",
(v: any) => v.properties?.["$status"]?.const === "committed",
);
expect(committedVariant).toBeDefined();
expect(committedVariant.required).toContain("$status");
});
test("developer procedure should include mandatory verification step", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
const workflow = parse(yamlContent) as WorkflowPayload;
const developerProcedure = workflow.roles.developer?.procedure;
expect(developerProcedure).toBeDefined();
// Verify the procedure includes mandatory verification step
expect(developerProcedure).toContain("MANDATORY VERIFICATION");
expect(developerProcedure).toContain("git branch --show-current");
expect(developerProcedure).toContain("git status");
expect(developerProcedure).toMatch(/ls -la|verify.*exist/i);
});
test("reviewer procedure should enforce worktree path verification", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
const workflow = parse(yamlContent) as WorkflowPayload;
const reviewerProcedure = workflow.roles.reviewer?.procedure;
expect(reviewerProcedure).toBeDefined();
// Verify the procedure includes critical enforcement
expect(reviewerProcedure).toContain("CRITICAL");
expect(reviewerProcedure).toMatch(/cd.*pwd/);
expect(reviewerProcedure).toContain(
"Do NOT report results without running the actual commands",
);
});
test("developer procedure should include test debugging escalation", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
const workflow = parse(yamlContent) as WorkflowPayload;
const developerProcedure = workflow.roles.developer?.procedure;
expect(developerProcedure).toBeDefined();
// Verify the procedure includes test failure guidance
expect(developerProcedure).toMatch(/tests fail.*first run/i);
expect(developerProcedure).toMatch(/3 test cycles|after 3 attempts/i);
expect(developerProcedure).toContain("$status=failed");
});
});
@@ -56,7 +56,7 @@ const VALID_OUTPUT: AdapterOutput = {
describe("spawnAgent JSON parsing", () => {
test("B1. parses valid JSON from agent stdout", () => {
const stdout = `${JSON.stringify(VALID_OUTPUT)}\n`;
const stdout = JSON.stringify(VALID_OUTPUT) + "\n";
const result = parseAgentStdout(stdout);
expect(result.stepHash).toBe("0123456789ABC");
expect(result.detailHash).toBe("DEFGH12345678");
@@ -68,7 +68,7 @@ describe("spawnAgent JSON parsing", () => {
});
test("B2. extracts stepHash for head pointer", () => {
const stdout = `${JSON.stringify(VALID_OUTPUT)}\n`;
const stdout = JSON.stringify(VALID_OUTPUT) + "\n";
const result = parseAgentStdout(stdout);
expect(result.stepHash).toBe("0123456789ABC");
expect(isCasRef(result.stepHash)).toBe(true);
@@ -76,7 +76,7 @@ describe("spawnAgent JSON parsing", () => {
test("B3. handles debug lines before JSON", () => {
const debugLines = "[debug] loading context...\n[debug] running agent...\n";
const stdout = `${debugLines + JSON.stringify(VALID_OUTPUT)}\n`;
const stdout = debugLines + JSON.stringify(VALID_OUTPUT) + "\n";
const result = parseAgentStdout(stdout);
expect(result.stepHash).toBe("0123456789ABC");
});
@@ -88,13 +88,13 @@ describe("spawnAgent JSON parsing", () => {
test("B5. rejects JSON missing stepHash", () => {
const incomplete = { detailHash: "DEFGH12345678", role: "planner" };
const stdout = `${JSON.stringify(incomplete)}\n`;
const stdout = JSON.stringify(incomplete) + "\n";
expect(() => parseAgentStdout(stdout)).toThrow("missing valid stepHash");
});
test("B6. rejects JSON with invalid stepHash", () => {
const bad = { ...VALID_OUTPUT, stepHash: "not-a-hash" };
const stdout = `${JSON.stringify(bad)}\n`;
const stdout = JSON.stringify(bad) + "\n";
expect(() => parseAgentStdout(stdout)).toThrow("missing valid stepHash");
});
});
@@ -1,8 +1,8 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import { bootstrap, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdStepRead } from "../commands/step.js";
@@ -40,7 +40,7 @@ const DETAIL_SCHEMA = {
turnCount: { type: "integer" as const },
turns: {
type: "array" as const,
items: { type: "string" as const, format: "ocas_ref" },
items: { type: "string" as const, format: "cas_ref" },
},
},
additionalProperties: false,
@@ -66,21 +66,13 @@ function generateContent(size: number, prefix = "Content"): string {
// ── fixture ───────────────────────────────────────────────────────────────────
let tmpDir: string;
let originalEnv: string | undefined;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-step-read-test-"));
originalEnv = process.env.UNCAGED_CAS_DIR;
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
// Restore original environment
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
});
// ── step read tests ───────────────────────────────────────────────────────────
@@ -88,10 +80,7 @@ afterEach(async () => {
describe("step read", () => {
test("test 1: basic single-step read with 3 turns", async () => {
const casDir = join(tmpDir, "cas");
process.env.UNCAGED_CAS_DIR = casDir;
await mkdir(casDir, { recursive: true });
process.env.UNCAGED_CAS_DIR = casDir;
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
@@ -157,11 +146,10 @@ describe("step read", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
// Read step with large quota
const markdown = await cmdStepRead(tmpDir, stepHash, 10000, false);
const markdown = await cmdStepRead(tmpDir, stepHash, 10000);
// Assert structure
expect(markdown).toContain(`# Step ${stepHash}`);
@@ -177,9 +165,7 @@ describe("step read", () => {
test("test 2: quota enforcement - multiple turns", async () => {
const casDir = join(tmpDir, "cas");
process.env.UNCAGED_CAS_DIR = casDir;
await mkdir(casDir, { recursive: true });
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
@@ -245,11 +231,10 @@ describe("step read", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
// Read step with limited quota (700 chars)
const markdown = await cmdStepRead(tmpDir, stepHash, 700, false);
const markdown = await cmdStepRead(tmpDir, stepHash, 700);
// Assert only most recent turns fit
expect(markdown).toContain(`# Step ${stepHash}`);
@@ -263,9 +248,7 @@ describe("step read", () => {
test("test 3: minimal quota edge case - always show at least one turn", async () => {
const casDir = join(tmpDir, "cas");
process.env.UNCAGED_CAS_DIR = casDir;
await mkdir(casDir, { recursive: true });
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
@@ -327,11 +310,10 @@ describe("step read", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
// Read step with minimal quota (1 char)
const markdown = await cmdStepRead(tmpDir, stepHash, 1, false);
const markdown = await cmdStepRead(tmpDir, stepHash, 1);
// Assert at least one turn is always shown
expect(markdown).toContain("LongTurn");
@@ -340,9 +322,7 @@ describe("step read", () => {
test("test 4: step with no detail field", async () => {
const casDir = join(tmpDir, "cas");
process.env.UNCAGED_CAS_DIR = casDir;
await mkdir(casDir, { recursive: true });
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
@@ -385,11 +365,10 @@ describe("step read", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
// Read step - should return metadata only (no error)
const markdown = await cmdStepRead(tmpDir, stepHash, 4000, false);
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
// Assert metadata is present
expect(markdown).toContain(`# Step ${stepHash}`);
@@ -401,9 +380,7 @@ describe("step read", () => {
test("test 5: step with detail but no turns array", async () => {
const casDir = join(tmpDir, "cas");
process.env.UNCAGED_CAS_DIR = casDir;
await mkdir(casDir, { recursive: true });
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
await registerDetailSchemas(store);
@@ -464,11 +441,10 @@ describe("step read", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
// Read step - should return metadata only (no error)
const markdown = await cmdStepRead(tmpDir, stepHash, 4000, false);
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
// Assert metadata is present
expect(markdown).toContain(`# Step ${stepHash}`);
@@ -479,9 +455,7 @@ describe("step read", () => {
test("test 6: displays role and tool calls in turn body", async () => {
const casDir = join(tmpDir, "cas");
process.env.UNCAGED_CAS_DIR = casDir;
await mkdir(casDir, { recursive: true });
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
@@ -541,10 +515,9 @@ describe("step read", () => {
agent: "uwf-hermes",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const markdown = await cmdStepRead(tmpDir, stepHash, 4000, false);
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
expect(markdown).toContain("**Turn role:** assistant");
expect(markdown).toContain("**terminal**");
@@ -553,9 +526,7 @@ describe("step read", () => {
test("test 7: turn content with special characters", async () => {
const casDir = join(tmpDir, "cas");
process.env.UNCAGED_CAS_DIR = casDir;
await mkdir(casDir, { recursive: true });
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
@@ -617,11 +588,10 @@ describe("step read", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
// Read step
const markdown = await cmdStepRead(tmpDir, stepHash, 4000, false);
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
// Assert content is rendered correctly without corruption
expect(markdown).toContain("`backticks`");
@@ -1,8 +1,8 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, type Hash, type JSONSchema, putSchema } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import { bootstrap, type Hash, type JSONSchema, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, StepNodePayload } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdStepShow } from "../commands/step.js";
@@ -45,7 +45,7 @@ const DETAIL_SCHEMA: JSONSchema = {
properties: {
turns: {
type: "array",
items: { type: "string", format: "ocas_ref" },
items: { type: "string", format: "cas_ref" },
},
},
additionalProperties: false,
@@ -116,7 +116,6 @@ async function createTestStep(
edgePrompt: "",
startedAtMs: Date.now(),
completedAtMs: Date.now() + 1000,
assembledPrompt: null,
cwd: "/tmp",
};
return store.put(schemas.stepNode, stepPayload);
@@ -125,23 +124,15 @@ async function createTestStep(
describe("cmdStepShow JSON serialization", () => {
let testDir: string;
let casDir: string;
let originalEnv: string | undefined;
beforeEach(async () => {
testDir = await mkdtemp(join(tmpdir(), "uwf-test-"));
casDir = join(testDir, "cas");
await mkdir(casDir, { recursive: true });
originalEnv = process.env.UNCAGED_CAS_DIR;
process.env.UNCAGED_CAS_DIR = casDir;
});
afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
});
test("escapes newlines in tool call args", async () => {
@@ -1,8 +1,8 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import { bootstrap, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { STEP_NODE_SCHEMA } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
@@ -43,7 +43,7 @@ const DETAIL_SCHEMA = {
turnCount: { type: "integer" as const },
turns: {
type: "array" as const,
items: { type: "string" as const, format: "ocas_ref" },
items: { type: "string" as const, format: "cas_ref" },
},
},
additionalProperties: false,
@@ -63,22 +63,13 @@ async function registerDetailSchemas(store: ReturnType<typeof createFsStore>) {
// ── fixture ──────────────────────────────────────────────────────────────────
let tmpDir: string;
let originalEnv: string | undefined;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-step-timing-test-"));
originalEnv = process.env.UNCAGED_CAS_DIR;
process.env.UNCAGED_CAS_DIR = join(tmpDir, "cas");
await mkdir(process.env.UNCAGED_CAS_DIR, { recursive: true });
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
});
// ── 1. Protocol types (compile-time) ─────────────────────────────────────────
@@ -94,7 +85,6 @@ describe("protocol types", () => {
edgePrompt: "",
startedAtMs: 1000,
completedAtMs: 2000,
assembledPrompt: null,
cwd: "/test/path",
};
expect(record.startedAtMs).toBe(1000);
@@ -163,7 +153,6 @@ describe("StepNode JSON schema", () => {
edgePrompt: "",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
expect(hash).toBeTruthy();
});
@@ -1,224 +0,0 @@
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createUwfStore, getCasDir, getGlobalCasDir } from "../store.js";
describe("Global CAS directory", () => {
let tmpDir: string;
let originalEnv: string | undefined;
beforeEach(async () => {
tmpDir = join(tmpdir(), `uwf-test-global-cas-${Date.now()}`);
await mkdir(tmpDir, { recursive: true });
originalEnv = process.env.UNCAGED_CAS_DIR;
});
afterEach(async () => {
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true });
}
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
});
test("getGlobalCasDir returns default path when no env var set", () => {
delete process.env.UNCAGED_CAS_DIR;
const casDir = getGlobalCasDir();
// Should return ~/.uncaged/json-cas
expect(casDir).toContain(".uncaged");
expect(casDir).toContain("json-cas");
});
test("getGlobalCasDir respects UNCAGED_CAS_DIR environment variable", () => {
const customPath = join(tmpDir, "custom-cas");
process.env.UNCAGED_CAS_DIR = customPath;
const casDir = getGlobalCasDir();
expect(casDir).toBe(customPath);
});
test("getGlobalCasDir ignores empty UNCAGED_CAS_DIR", () => {
process.env.UNCAGED_CAS_DIR = "";
const casDir = getGlobalCasDir();
expect(casDir).toContain(".uncaged");
expect(casDir).toContain("json-cas");
});
test("getCasDir is deprecated but still works for backward compatibility", () => {
const storageRoot = join(tmpDir, "storage");
const casDir = getCasDir(storageRoot);
expect(casDir).toBe(join(storageRoot, "cas"));
});
test("createUwfStore uses global CAS directory", async () => {
const globalCasDir = join(tmpDir, "global-cas");
process.env.UNCAGED_CAS_DIR = globalCasDir;
const storageRoot = join(tmpDir, "storage");
await mkdir(storageRoot, { recursive: true });
const uwf = await createUwfStore(storageRoot);
// Verify the store was created in the global CAS directory
expect(uwf.storageRoot).toBe(storageRoot);
expect(uwf.store).toBeDefined();
expect(uwf.schemas).toBeDefined();
// The global CAS directory should be created
const { stat } = await import("node:fs/promises");
const stats = await stat(globalCasDir);
expect(stats.isDirectory()).toBe(true);
});
test("createUwfStore creates global CAS directory if it does not exist", async () => {
const globalCasDir = join(tmpDir, "new-global-cas");
process.env.UNCAGED_CAS_DIR = globalCasDir;
const storageRoot = join(tmpDir, "storage");
await mkdir(storageRoot, { recursive: true });
await createUwfStore(storageRoot);
// Verify the directory was created
const { stat } = await import("node:fs/promises");
const stats = await stat(globalCasDir);
expect(stats.isDirectory()).toBe(true);
});
test("multiple uwfStore instances share the same global CAS filesystem", async () => {
const globalCasDir = join(tmpDir, "shared-cas");
process.env.UNCAGED_CAS_DIR = globalCasDir;
const storageRoot1 = join(tmpDir, "storage1");
const storageRoot2 = join(tmpDir, "storage2");
await mkdir(storageRoot1, { recursive: true });
await mkdir(storageRoot2, { recursive: true });
const uwf1 = await createUwfStore(storageRoot1);
const uwf2 = await createUwfStore(storageRoot2);
// Both should use the same global CAS directory
expect(uwf1.store).toBeDefined();
expect(uwf2.store).toBeDefined();
// Store a node in the first store
const testData = { test: "data" };
const _hash = uwf1.store.put(uwf1.schemas.text, JSON.stringify(testData));
// Both stores share the same CAS filesystem directory
// Since schemas are registered idempotently, they should have the same hash
expect(uwf2.schemas.text).toBe(uwf1.schemas.text);
// Verify the CAS files are written to the shared directory
const { readdir } = await import("node:fs/promises");
const files = await readdir(globalCasDir);
expect(files.length).toBeGreaterThan(0);
});
test("workflow metadata remains in storageRoot, not global CAS", async () => {
const globalCasDir = join(tmpDir, "global-cas");
process.env.UNCAGED_CAS_DIR = globalCasDir;
const storageRoot = join(tmpDir, "storage");
await mkdir(storageRoot, { recursive: true });
const _uwf = await createUwfStore(storageRoot);
// Write workflow registry file
const { saveWorkflowRegistry } = await import("../store.js");
await saveWorkflowRegistry(storageRoot, { "test-workflow": "ABC123" });
// Verify registry is in storageRoot, not global CAS
const { readFile } = await import("node:fs/promises");
const registryPath = join(storageRoot, "workflows.yaml");
const content = await readFile(registryPath, "utf8");
expect(content).toContain("test-workflow");
expect(content).toContain("ABC123");
// Verify registry is NOT in global CAS directory
const globalRegistryPath = join(globalCasDir, "workflows.yaml");
await expect(readFile(globalRegistryPath, "utf8")).rejects.toThrow();
});
test("thread metadata remains in storageRoot", async () => {
const globalCasDir = join(tmpDir, "global-cas");
process.env.UNCAGED_CAS_DIR = globalCasDir;
const storageRoot = join(tmpDir, "storage");
await mkdir(storageRoot, { recursive: true });
await createUwfStore(storageRoot);
// Write threads index
const { saveThreadsIndex } = await import("../store.js");
await saveThreadsIndex(storageRoot, { "thread-123": "hash-456" });
// Verify threads.yaml is in storageRoot, not global CAS
const { readFile } = await import("node:fs/promises");
const threadsPath = join(storageRoot, "threads.yaml");
const content = await readFile(threadsPath, "utf8");
expect(content).toContain("thread-123");
expect(content).toContain("hash-456");
// Verify threads.yaml is NOT in global CAS directory
const globalThreadsPath = join(globalCasDir, "threads.yaml");
await expect(readFile(globalThreadsPath, "utf8")).rejects.toThrow();
});
test("history remains in storageRoot", async () => {
const globalCasDir = join(tmpDir, "global-cas");
process.env.UNCAGED_CAS_DIR = globalCasDir;
const storageRoot = join(tmpDir, "storage");
await mkdir(storageRoot, { recursive: true });
await createUwfStore(storageRoot);
// Write history
const { appendThreadHistory } = await import("../store.js");
await appendThreadHistory(storageRoot, {
thread: "thread-123" as any,
workflow: "workflow-456",
head: "hash-789",
completedAt: Date.now(),
reason: "completed",
});
// Verify history.jsonl is in storageRoot, not global CAS
const { readFile } = await import("node:fs/promises");
const historyPath = join(storageRoot, "history.jsonl");
const content = await readFile(historyPath, "utf8");
expect(content).toContain("thread-123");
expect(content).toContain("workflow-456");
// Verify history.jsonl is NOT in global CAS directory
const globalHistoryPath = join(globalCasDir, "history.jsonl");
await expect(readFile(globalHistoryPath, "utf8")).rejects.toThrow();
});
test("CAS nodes are stored in global directory", async () => {
const globalCasDir = join(tmpDir, "global-cas");
process.env.UNCAGED_CAS_DIR = globalCasDir;
const storageRoot = join(tmpDir, "storage");
await mkdir(storageRoot, { recursive: true });
const uwf = await createUwfStore(storageRoot);
// Store a CAS node
const testPayload = JSON.stringify({ test: "node" });
const _hash = uwf.store.put(uwf.schemas.text, testPayload);
// Verify the node is in global CAS directory
const { readdir } = await import("node:fs/promises");
const files = await readdir(globalCasDir);
expect(files.length).toBeGreaterThan(0);
// Verify the node is NOT in the old storageRoot/cas location
const oldCasDir = join(storageRoot, "cas");
await expect(readdir(oldCasDir)).rejects.toThrow();
});
});
@@ -15,8 +15,6 @@ import { appendThreadHistory, createUwfStore, saveThreadsIndex } from "../store.
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = join(storageRoot, "cas");
await mkdir(casDir, { recursive: true });
// Set UNCAGED_CAS_DIR to use the test's CAS directory
process.env.UNCAGED_CAS_DIR = casDir;
return createUwfStore(storageRoot);
}
@@ -9,31 +9,17 @@ import { createUwfStore } from "../store.js";
describe("Thread and edge location integration", () => {
let tmpDir: string;
let storageRoot: string;
let casDir: string;
let originalEnv: string | undefined;
async function setupTestEnv() {
tmpDir = join(tmpdir(), `uwf-test-location-${Date.now()}`);
storageRoot = join(tmpDir, "storage");
casDir = join(tmpDir, "cas");
await mkdir(storageRoot, { recursive: true });
await mkdir(casDir, { recursive: true });
// Set UNCAGED_CAS_DIR for this test
originalEnv = process.env.UNCAGED_CAS_DIR;
process.env.UNCAGED_CAS_DIR = casDir;
}
async function teardown() {
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true });
}
// Restore original environment
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
}
test("thread start captures cwd in StartNode", async () => {
@@ -1,8 +1,8 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import { bootstrap, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdThreadRead } from "../commands/thread.js";
@@ -41,7 +41,7 @@ const DETAIL_SCHEMA = {
turnCount: { type: "integer" as const },
turns: {
type: "array" as const,
items: { type: "string" as const, format: "ocas_ref" },
items: { type: "string" as const, format: "cas_ref" },
},
},
additionalProperties: false,
@@ -67,22 +67,13 @@ function generateContent(size: number, prefix = "Content"): string {
// ── fixture ───────────────────────────────────────────────────────────────────
let tmpDir: string;
let originalEnv: string | undefined;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-quota-test-"));
originalEnv = process.env.UNCAGED_CAS_DIR;
process.env.UNCAGED_CAS_DIR = join(tmpDir, "cas");
await mkdir(process.env.UNCAGED_CAS_DIR, { recursive: true });
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
});
// ── thread read quota enforcement ─────────────────────────────────────────────
@@ -152,7 +143,6 @@ describe("thread read --quota flag", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
steps.push(stepHash);
}
@@ -235,7 +225,6 @@ describe("thread read --quota flag", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const step2Content = generateContent(600, "Second");
@@ -262,7 +251,6 @@ describe("thread read --quota flag", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01HX2Q3R4S5T6V7W8X9YZ1" as ThreadId;
@@ -348,7 +336,6 @@ describe("thread read --quota flag", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
steps.push(stepHash);
}
@@ -428,7 +415,6 @@ describe("thread read --quota flag", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01HX2Q3R4S5T6V7W8X9YZ4" as ThreadId;
@@ -506,7 +492,6 @@ describe("thread read --quota flag", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
steps.push(stepHash);
}
@@ -588,7 +573,6 @@ describe("thread read --quota flag", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
steps.push(stepHash);
}
@@ -1,8 +1,8 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import { bootstrap, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdThreadRead, THREAD_READ_DEFAULT_QUOTA } from "../commands/thread.js";
@@ -42,7 +42,7 @@ const DETAIL_SCHEMA = {
turnCount: { type: "integer" as const },
turns: {
type: "array" as const,
items: { type: "string" as const, format: "ocas_ref" },
items: { type: "string" as const, format: "cas_ref" },
},
},
additionalProperties: false,
@@ -53,8 +53,6 @@ const DETAIL_SCHEMA = {
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = join(storageRoot, "cas");
await mkdir(casDir, { recursive: true });
// Set UNCAGED_CAS_DIR to use the test's CAS directory
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
return { storageRoot, store, schemas };
@@ -143,7 +141,6 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-claude-code",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01JTEST0000000000000001" as ThreadId;
@@ -221,7 +218,6 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-claude-code",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01JTEST0000000000000002" as ThreadId;
@@ -284,7 +280,6 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
@@ -296,7 +291,6 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01JTEST0000000000000003" as ThreadId;
@@ -351,7 +345,6 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01JTEST0000000000000004" as ThreadId;
@@ -406,7 +399,6 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01JTEST0000000000000005" as ThreadId;
@@ -461,7 +453,6 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01JTEST0000000000000006" as ThreadId;
@@ -536,7 +527,6 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
@@ -548,7 +538,6 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const step3 = await uwf.store.put(uwf.schemas.stepNode, {
@@ -560,7 +549,6 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01JTEST0000000000000007" as ThreadId;
@@ -641,7 +629,6 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
});
const threadId = "01JTEST0000000000000008" as ThreadId;
@@ -698,7 +685,6 @@ describe("thread read XML tag isolation", () => {
agent: "uwf-test",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
assembledPrompt: null,
})) as CasRef;
steps.push(step);
prev = step;
@@ -10,31 +10,17 @@ import { createUwfStore, loadThreadsIndex } from "../store.js";
describe("thread start --cwd CLI option", () => {
let tmpDir: string;
let storageRoot: string;
let casDir: string;
let originalEnv: string | undefined;
async function setupTestEnv() {
tmpDir = join(tmpdir(), `uwf-test-cwd-cli-${Date.now()}`);
storageRoot = join(tmpDir, "storage");
casDir = join(tmpDir, "cas");
await mkdir(storageRoot, { recursive: true });
await mkdir(casDir, { recursive: true });
// Set UNCAGED_CAS_DIR for this test
originalEnv = process.env.UNCAGED_CAS_DIR;
process.env.UNCAGED_CAS_DIR = casDir;
}
async function teardown() {
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true });
}
// Restore original environment
if (originalEnv === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalEnv;
}
}
async function createTestWorkflow(): Promise<string> {
@@ -137,7 +123,7 @@ graph:
// Register the workflow
execFileSync("node", [uwfBin, "workflow", "add", workflowPath], {
env: { ...process.env, UWF_STORAGE_ROOT: storageRoot, UNCAGED_CAS_DIR: casDir },
env: { ...process.env, UWF_STORAGE_ROOT: storageRoot },
encoding: "utf8",
});
@@ -146,7 +132,7 @@ graph:
"node",
[uwfBin, "thread", "start", "test-cwd-cli", "-p", "test prompt", "--cwd", testCwd],
{
env: { ...process.env, UWF_STORAGE_ROOT: storageRoot, UNCAGED_CAS_DIR: casDir },
env: { ...process.env, UWF_STORAGE_ROOT: storageRoot },
encoding: "utf8",
},
);
@@ -1,8 +1,8 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import { bootstrap, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdStepList, cmdStepShow } from "../commands/step.js";
@@ -47,7 +47,7 @@ const DETAIL_SCHEMA = {
turnCount: { type: "integer" as const },
turns: {
type: "array" as const,
items: { type: "string" as const, format: "ocas_ref" },
items: { type: "string" as const, format: "cas_ref" },
},
},
additionalProperties: false,
@@ -58,8 +58,6 @@ const DETAIL_SCHEMA = {
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = join(storageRoot, "cas");
await mkdir(casDir, { recursive: true });
// Set UNCAGED_CAS_DIR to use the test's CAS directory
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
return { storageRoot, store, schemas };
@@ -1,7 +1,7 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createFsStore } from "@ocas/fs";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, WorkflowPayload } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { stringify } from "yaml";
@@ -15,8 +15,6 @@ import { loadWorkflowRegistry, saveWorkflowRegistry } from "../store.js";
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = join(storageRoot, "cas");
await mkdir(casDir, { recursive: true });
// Set UNCAGED_CAS_DIR to use the test's CAS directory
process.env.UNCAGED_CAS_DIR = casDir;
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
return { storageRoot, store, schemas };
@@ -257,49 +255,6 @@ describe("Strategy 3: Local Discovery", () => {
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("should find workflow in folder-based layout (name/index.yaml)", async () => {
await makeUwfStore(storageRoot);
const workflowDir = join(projectRoot, ".workflow", "solve-issue");
await mkdir(workflowDir, { recursive: true });
await writeFile(join(workflowDir, "index.yaml"), await createWorkflowYaml("solve-issue"));
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot);
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
const uwf = await makeUwfStore(storageRoot);
const node = uwf.store.get(result.workflow);
expect(node).not.toBeNull();
if (node !== null) {
expect((node.payload as WorkflowPayload).name).toBe("solve-issue");
}
});
test("should prefer flat file over folder-based layout", async () => {
await makeUwfStore(storageRoot);
const workflowDir = join(projectRoot, ".workflow");
await mkdir(workflowDir, { recursive: true });
await writeFile(
join(workflowDir, "solve-issue.yaml"),
await createWorkflowYaml("solve-issue", "flat"),
);
const folderDir = join(workflowDir, "solve-issue");
await mkdir(folderDir, { recursive: true });
await writeFile(
join(folderDir, "index.yaml"),
await createWorkflowYaml("solve-issue", "folder"),
);
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot);
const uwf = await makeUwfStore(storageRoot);
const node = uwf.store.get(result.workflow);
expect(node).not.toBeNull();
if (node !== null) {
expect((node.payload as WorkflowPayload).description).toBe("Test workflow (flat)");
}
});
});
// ── Strategy 4: Global Registry Fallback ──────────────────────────────────────
+42 -16
View File
@@ -17,12 +17,16 @@ import { cmdConfigGet, cmdConfigList, cmdConfigSet } from "./commands/config.js"
import { cmdLogClean, cmdLogList, cmdLogShow } from "./commands/log.js";
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
import {
cmdSkillActor,
cmdSkillAdapter,
cmdSkillArchitecture,
cmdSkillAuthor,
cmdSkillBootstrap,
cmdSkillCli,
cmdSkillDeveloper,
cmdSkillList,
cmdSkillModerator,
cmdSkillUser,
cmdSkillYaml,
} from "./commands/skill.js";
import { cmdStepFork, cmdStepList, cmdStepRead, cmdStepShow } from "./commands/step.js";
import {
@@ -364,8 +368,7 @@ step
.description("Read a step's turns as human-readable markdown")
.argument("<step-hash>", "CAS hash of the StepNode")
.option("--quota <chars>", "Max output characters", "4000")
.option("--prompt", "Show the assembled prompt sent to the agent instead of turns")
.action((stepHash: string, opts: { quota: string; prompt: boolean }) => {
.action((stepHash: string, opts: { quota: string }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const quota = Number.parseInt(opts.quota, 10);
@@ -373,12 +376,7 @@ step
process.stderr.write("invalid --quota: must be a positive integer\n");
process.exit(1);
}
const markdown = await cmdStepRead(
storageRoot,
stepHash as CasRef,
quota,
opts.prompt === true,
);
const markdown = await cmdStepRead(storageRoot, stepHash as CasRef, quota);
process.stdout.write(markdown.endsWith("\n") ? markdown : `${markdown}\n`);
});
});
@@ -495,6 +493,34 @@ For more information, see: uwf help thread list
const skill = program.command("skill").description("Built-in skill references for agents");
skill.addHelpCommand(false);
skill
.command("cli")
.description("Print a markdown reference of all uwf commands")
.action(() => {
console.log(cmdSkillCli());
});
skill
.command("architecture")
.description("Print the architecture reference")
.action(() => {
console.log(cmdSkillArchitecture());
});
skill
.command("yaml")
.description("Print the workflow YAML schema reference")
.action(() => {
console.log(cmdSkillYaml());
});
skill
.command("actor")
.description("Print the actor reference (frontmatter protocol + CAS)")
.action(() => {
console.log(cmdSkillActor());
});
skill
.command("adapter")
.description("Print the adapter reference (building agent adapters)")
@@ -516,6 +542,13 @@ skill
console.log(cmdSkillDeveloper());
});
skill
.command("moderator")
.description("Print the moderator reference")
.action(() => {
console.log(cmdSkillModerator());
});
skill
.command("user")
.description("Print the user reference (CLI guide + typical workflows)")
@@ -523,13 +556,6 @@ skill
console.log(cmdSkillUser());
});
skill
.command("bootstrap")
.description("Print the bootstrap skill YAML for Hermes agents")
.action(() => {
console.log(cmdSkillBootstrap());
});
skill
.command("list")
.description("List all available skill names")
+5 -9
View File
@@ -1,9 +1,9 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { JSONSchema, Store } from "@ocas/core";
import { bootstrap, getSchema, putSchema, refs, walk } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import type { JSONSchema, Store } from "@uncaged/json-cas";
import { bootstrap, getSchema, putSchema, refs, walk } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import { TEXT_SCHEMA } from "../schemas.js";
@@ -85,17 +85,13 @@ export type SchemaListEntry = {
export async function cmdCasSchemaList(storageRoot: string): Promise<SchemaListEntry[]> {
const store = openStore(storageRoot);
const aliases = await bootstrap(store);
const metaHash = aliases["@ocas/schema"];
if (metaHash === undefined) {
throw new Error("Meta-schema not found in bootstrap result");
}
const metaHash = await bootstrap(store);
const entries: SchemaListEntry[] = [];
// Include meta-schema itself
entries.push({ hash: metaHash, title: "(meta-schema)" });
for (const { hash } of store.listByType(metaHash)) {
for (const hash of store.listByType(metaHash)) {
if (hash === metaHash) continue;
const node = store.get(hash);
if (node !== null) {
+5 -5
View File
@@ -1,5 +1,5 @@
import type { Store as CasStore, JSONSchema } from "@ocas/core";
import { getSchema } from "@ocas/core";
import type { Store as CasStore, JSONSchema } from "@uncaged/json-cas";
import { getSchema } from "@uncaged/json-cas";
import type {
CasRef,
StartNodePayload,
@@ -88,7 +88,7 @@ function expandOutput(uwf: UwfStore, outputRef: CasRef): unknown {
}
/**
* Recursively expand all ocas_ref fields in a CAS node's payload,
* Recursively expand all cas_ref fields in a CAS node's payload,
* replacing hash strings with the referenced node's expanded payload.
*/
function expandDeep(store: CasStore, hash: CasRef, visited?: Set<string>): unknown {
@@ -120,7 +120,7 @@ function expandAnyOfField(
): unknown {
if (!Array.isArray(schema.anyOf)) return value;
for (const sub of schema.anyOf as JSONSchema[]) {
if (sub.format === "ocas_ref" && typeof value === "string") {
if (sub.format === "cas_ref" && typeof value === "string") {
return expandDeep(store, value as CasRef, visited);
}
}
@@ -163,7 +163,7 @@ function expandValue(
value: unknown,
visited: Set<string>,
): unknown {
if (schema.format === "ocas_ref") return expandCasRefField(store, value, visited);
if (schema.format === "cas_ref") return expandCasRefField(store, value, visited);
if (Array.isArray(schema.anyOf)) return expandAnyOfField(store, schema, value, visited);
if (schema.type === "array") return expandArrayField(store, schema, value, visited);
return expandObjectField(store, schema, value, visited);
+16 -2
View File
@@ -1,12 +1,26 @@
export {
generateActorReference as cmdSkillActor,
generateAdapterReference as cmdSkillAdapter,
generateArchitectureReference as cmdSkillArchitecture,
generateAuthorReference as cmdSkillAuthor,
generateBootstrapReference as cmdSkillBootstrap,
generateCliReference as cmdSkillCli,
generateDeveloperReference as cmdSkillDeveloper,
generateModeratorReference as cmdSkillModerator,
generateUserReference as cmdSkillUser,
generateYamlReference as cmdSkillYaml,
} from "@uncaged/workflow-util";
const SKILL_NAMES = ["user", "author", "developer", "adapter", "bootstrap"] as const;
const SKILL_NAMES = [
"cli",
"architecture",
"yaml",
"moderator",
"actor",
"user",
"author",
"developer",
"adapter",
] as const;
export function cmdSkillList(): ReadonlyArray<string> {
return [...SKILL_NAMES];
+1 -19
View File
@@ -1,4 +1,4 @@
import type { BootstrapCapableStore } from "@ocas/core";
import type { BootstrapCapableStore } from "@uncaged/json-cas";
import type {
CasRef,
StartEntry,
@@ -289,7 +289,6 @@ export async function cmdStepRead(
storageRoot: string,
stepHash: CasRef,
quota: number,
showPrompt: boolean,
): Promise<string> {
const uwf = await createUwfStore(storageRoot);
const node = uwf.store.get(stepHash);
@@ -301,23 +300,6 @@ export async function cmdStepRead(
}
const payload = node.payload as StepNodePayload;
// --prompt mode: show the assembled prompt that was sent to the agent
if (showPrompt) {
const promptRef = (payload as Record<string, unknown>).assembledPrompt;
if (typeof promptRef !== "string") {
return `# Step ${stepHash}\n\n_Prompt not recorded (legacy step)._`;
}
const promptNode = uwf.store.get(promptRef as CasRef);
if (promptNode === null) {
return `# Step ${stepHash}\n\n_Prompt CAS node not found: ${promptRef}_`;
}
const promptText =
typeof promptNode.payload === "string"
? promptNode.payload
: JSON.stringify(promptNode.payload);
return `# Step ${stepHash}\n\n**Role:** ${payload.role}\n**Agent:** ${payload.agent}\n\n## Prompt\n\n${promptText}`;
}
if (payload.detail === null) {
return formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
}
+2 -21
View File
@@ -1,7 +1,7 @@
import { execFileSync, spawn } from "node:child_process";
import { access, readFile } from "node:fs/promises";
import { dirname, isAbsolute, resolve as resolvePath } from "node:path";
import { validate } from "@ocas/core";
import { validate } from "@uncaged/json-cas";
import type {
AgentAlias,
AgentConfig,
@@ -28,7 +28,6 @@ import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-util-agent";
import { config as loadDotenv } from "dotenv";
import { parse } from "yaml";
import { createMarker, deleteMarker, isThreadRunning } from "../background/index.js";
import { createIncludeTag } from "../include.js";
import { evaluate } from "../moderator/index.js";
import {
appendThreadHistory,
@@ -119,15 +118,6 @@ async function findWorkflowInDir(dir: string, name: string): Promise<string | nu
return result;
}
}
for (const indexName of ["index.yaml", "index.yml"]) {
const candidate = resolvePath(dir, ".workflow", name, indexName);
try {
await access(candidate);
return candidate;
} catch {
/* not found */
}
}
// Check .workflows/ directory as fallback (legacy)
for (const ext of [".yaml", ".yml"]) {
@@ -136,15 +126,6 @@ async function findWorkflowInDir(dir: string, name: string): Promise<string | nu
return result;
}
}
for (const indexName of ["index.yaml", "index.yml"]) {
const candidate = resolvePath(dir, ".workflows", name, indexName);
try {
await access(candidate);
return candidate;
} catch {
/* not found */
}
}
return null;
}
@@ -191,7 +172,7 @@ async function materializeLocalWorkflow(uwf: UwfStore, filePath: string): Promis
let raw: unknown;
try {
raw = parse(text, { customTags: [createIncludeTag(dirname(filePath))] }) as unknown;
raw = parse(text) as unknown;
} catch (e) {
fail(`invalid YAML in ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
}
@@ -1,11 +1,9 @@
import { readFile } from "node:fs/promises";
import { dirname, resolve as resolvePath } from "node:path";
import type { JSONSchema } from "@ocas/core";
import { putSchema, validate } from "@ocas/core";
import type { JSONSchema } from "@uncaged/json-cas";
import { putSchema, validate } from "@uncaged/json-cas";
import type { CasRef, RoleDefinition, Target, WorkflowPayload } from "@uncaged/workflow-protocol";
import { parse } from "yaml";
import { createIncludeTag } from "../include.js";
import {
createUwfStore,
@@ -125,9 +123,7 @@ export async function cmdWorkflowAdd(
let raw: unknown;
try {
raw = parse(text, {
customTags: [createIncludeTag(dirname(resolvePath(filePath)))],
}) as unknown;
raw = parse(text) as unknown;
} catch (e) {
fail(`invalid YAML: ${e instanceof Error ? e.message : String(e)}`);
}
-37
View File
@@ -1,37 +0,0 @@
import { readFileSync } from "node:fs";
import { dirname, extname, resolve } from "node:path";
import { parse as parseYaml } from "yaml";
/**
* Create a YAML customTags entry for !include that resolves file paths
* relative to the given base directory.
*
* Security: resolved paths must stay within baseDir (path traversal prevention).
* Nested !include in .yaml/.yml files is supported (customTags passed recursively).
*/
export function createIncludeTag(baseDir: string) {
const resolvedBase = resolve(baseDir);
return {
tag: "!include",
resolve(str: string) {
const filePath = resolve(resolvedBase, str);
// Path traversal guard: resolved path must be inside baseDir
if (!filePath.startsWith(`${resolvedBase}/`) && filePath !== resolvedBase) {
throw new Error(
`!include path traversal blocked: "${str}" resolves outside base directory`,
);
}
const content = readFileSync(filePath, "utf8");
const ext = extname(filePath).toLowerCase();
if (ext === ".json") {
return JSON.parse(content);
}
if (ext === ".yaml" || ext === ".yml") {
// Pass customTags recursively so nested !include works,
// scoped to the included file's directory
return parseYaml(content, { customTags: [createIncludeTag(dirname(filePath))] });
}
return content;
},
};
}
+2 -2
View File
@@ -1,5 +1,5 @@
import type { Hash, Store } from "@ocas/core";
import { putSchema } from "@ocas/core";
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";
export const TEXT_SCHEMA = { type: "string" as const };
+17 -78
View File
@@ -1,10 +1,9 @@
import type { Dirent } from "node:fs";
import { access, appendFile, mkdir, readdir, 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";
import type { BootstrapCapableStore, Hash } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import type { BootstrapCapableStore, Hash } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, ThreadId, ThreadListItem, ThreadsIndex } from "@uncaged/workflow-protocol";
import { parse, stringify } from "yaml";
@@ -20,38 +19,17 @@ export type ProjectWorkflowEntry = {
filePath: string;
};
/** Extract workflow name from a YAML filename (strip .yaml/.yml extension). */
function stemFromYaml(name: string): string {
if (name.endsWith(".yaml")) return name.slice(0, -5);
if (name.endsWith(".yml")) return name.slice(0, -4);
return name;
}
/** Check if a directory contains an index.yaml or index.yml workflow file. */
async function findIndexWorkflow(
dir: string,
dirName: string,
): Promise<ProjectWorkflowEntry | null> {
for (const indexName of ["index.yaml", "index.yml"]) {
const indexPath = join(dir, dirName, indexName);
try {
await access(indexPath);
return { name: dirName, filePath: indexPath };
} catch {
// not found, try next
}
}
return null;
}
/**
* Scan a single directory for workflow entries (flat YAML files + folder/index.yaml).
* Returns discovered entries. Returns empty array if directory does not exist.
* Scan `<projectRoot>/.workflows/*.yaml` (non-recursive) and return discovered entries.
* Returns an empty array if the directory does not exist.
*/
async function scanWorkflowDir(dir: string): Promise<ProjectWorkflowEntry[]> {
let dirents: Dirent[];
export async function discoverProjectWorkflows(
projectRoot: string,
): Promise<ProjectWorkflowEntry[]> {
const dir = join(projectRoot, ".workflows");
let entries: string[];
try {
dirents = await readdir(dir, { withFileTypes: true });
entries = await readdir(dir);
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
@@ -61,39 +39,16 @@ async function scanWorkflowDir(dir: string): Promise<ProjectWorkflowEntry[]> {
}
const result: ProjectWorkflowEntry[] = [];
for (const entry of dirents) {
if (entry.isFile() && (entry.name.endsWith(".yaml") || entry.name.endsWith(".yml"))) {
result.push({ name: stemFromYaml(entry.name), filePath: join(dir, entry.name) });
} else if (entry.isDirectory()) {
const found = await findIndexWorkflow(dir, entry.name);
if (found !== null) {
result.push(found);
}
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;
}
/**
* Scan `<projectRoot>/.workflow/` (preferred) and `.workflows/` (legacy) for workflow entries.
* .workflow/ takes priority: if a name is found in both, .workflow/ wins.
* Returns an empty array if neither directory exists.
*/
export async function discoverProjectWorkflows(
projectRoot: string,
): Promise<ProjectWorkflowEntry[]> {
const primary = await scanWorkflowDir(join(projectRoot, ".workflow"));
const legacy = await scanWorkflowDir(join(projectRoot, ".workflows"));
const seen = new Set(primary.map((e) => e.name));
const merged = [...primary];
for (const entry of legacy) {
if (!seen.has(entry.name)) {
merged.push(entry);
}
}
return merged;
}
/** Default filesystem root for uwf data (`~/.uncaged/workflow`). */
export function getDefaultStorageRoot(): string {
return join(homedir(), ".uncaged", "workflow");
@@ -115,26 +70,10 @@ export function resolveStorageRoot(): string {
return getDefaultStorageRoot();
}
/**
* Deprecated: Use `getGlobalCasDir()` instead.
* Returns the old CAS directory for backward compatibility.
*/
export function getCasDir(storageRoot: string): string {
return join(storageRoot, "cas");
}
/**
* Returns the global CAS directory shared by all uwf and json-cas tools.
* Priority: UNCAGED_CAS_DIR environment variable default ~/.uncaged/json-cas
*/
export function getGlobalCasDir(): string {
const envPath = process.env.UNCAGED_CAS_DIR;
if (envPath !== undefined && envPath !== "") {
return envPath;
}
return join(homedir(), ".uncaged", "json-cas");
}
export function getRegistryPath(storageRoot: string): string {
return join(storageRoot, "workflows.yaml");
}
@@ -159,7 +98,7 @@ export type UwfStore = {
};
export async function createUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = getGlobalCasDir();
const casDir = getCasDir(storageRoot);
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
+4 -10
View File
@@ -1,4 +1,4 @@
import { basename, dirname } from "node:path";
import { basename } from "node:path";
import type { CasRef, WorkflowPayload } from "@uncaged/workflow-protocol";
const CAS_REF_PATTERN = /^[0-9A-HJKMNP-TV-Z]{13}$/;
@@ -68,15 +68,9 @@ function isGraph(value: unknown): boolean {
*/
export function workflowNameFromPath(filePath: string): string {
const base = basename(filePath);
const stem = base.endsWith(".yaml")
? base.slice(0, -5)
: base.endsWith(".yml")
? base.slice(0, -4)
: base;
if (stem === "index") {
return basename(dirname(filePath));
}
return stem;
if (base.endsWith(".yaml")) return base.slice(0, -5);
if (base.endsWith(".yml")) return base.slice(0, -4);
return base;
}
/**
-1
View File
@@ -3,6 +3,5 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
+1 -1
View File
@@ -8,7 +8,7 @@ Layer 3 agent implementation. Runs an OpenAI-compatible chat completion loop wit
Useful when you want a self-contained agent without an external CLI like Hermes or Claude Code.
**Dependencies:** `@ocas/core`, `@uncaged/workflow-util-agent`, `@uncaged/workflow-util`
**Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-util-agent`, `@uncaged/workflow-util`
## Installation
+6 -7
View File
@@ -18,12 +18,11 @@
}
},
"scripts": {
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
"test": "vitest run",
"test:ci": "vitest run"
"test": "bun test",
"test:ci": "bun test"
},
"dependencies": {
"@ocas/core": "^0.1.1",
"@uncaged/json-cas": "^0.5.3",
"@uncaged/workflow-util-agent": "workspace:^",
"@uncaged/workflow-util": "workspace:^"
},
@@ -35,12 +34,12 @@
},
"repository": {
"type": "git",
"url": "https://git.shazhou.work/uncaged/workflow.git",
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
"directory": "packages/workflow-agent-builtin"
},
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
"bugs": {
"url": "https://git.shazhou.work/uncaged/workflow/issues"
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
},
"license": "MIT"
}
+3 -8
View File
@@ -1,4 +1,4 @@
import type { Store } from "@ocas/core";
import type { Store } from "@uncaged/json-cas";
import { createLogger, generateUlid } from "@uncaged/workflow-util";
import {
type AgentContext,
@@ -82,7 +82,7 @@ async function runBuiltinWithMessages(
if (loopResult.turnCount === 0) {
log("5RWTK9NB", "no turns produced, returning empty output");
return { output: "", detailHash: "", sessionId: session.sessionId, assembledPrompt: "" };
return { output: "", detailHash: "", sessionId: session.sessionId };
}
// Read jsonl → persist turns to CAS → store detail
@@ -94,12 +94,7 @@ async function runBuiltinWithMessages(
session.startedAtMs,
);
return {
output: stripPreamble(loopResult.finalText),
detailHash,
sessionId: session.sessionId,
assembledPrompt: "",
};
return { output: stripPreamble(loopResult.finalText), detailHash, sessionId: session.sessionId };
}
async function runBuiltin(ctx: AgentContext): Promise<AgentRunResult> {
@@ -1,4 +1,4 @@
import { bootstrap, putSchema, type Store } from "@ocas/core";
import { bootstrap, putSchema, type Store } from "@uncaged/json-cas";
import { BUILTIN_DETAIL_SCHEMA, BUILTIN_TURN_SCHEMA } from "./schemas.js";
import { readSessionTurns } from "./session.js";
@@ -1,4 +1,4 @@
import type { JSONSchema } from "@ocas/core";
import type { JSONSchema } from "@uncaged/json-cas";
const BUILTIN_TOOL_CALL_SCHEMA: JSONSchema = {
type: "object",
@@ -38,7 +38,7 @@ export const BUILTIN_DETAIL_SCHEMA: JSONSchema = {
turnCount: { type: "integer" },
turns: {
type: "array",
items: { type: "string", format: "ocas_ref" },
items: { type: "string", format: "cas_ref" },
},
},
additionalProperties: false,
@@ -1,4 +1,4 @@
import type { JSONSchema } from "@ocas/core";
import type { JSONSchema } from "@uncaged/json-cas";
export type ToolContext = {
cwd: string;
@@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
@@ -6,7 +6,7 @@
Layer 3 agent implementation. Spawns the `claude` CLI with a composed system prompt (role definition, task, prior steps, edge prompt). Parses stream or JSON stdout, caches session IDs for multi-turn continuation, and stores raw output plus structured detail in CAS.
**Dependencies:** `@ocas/core`, `@uncaged/workflow-util-agent`
**Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-util-agent`
## Installation
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { createMemoryStore, walk } from "@ocas/core";
import { createMemoryStore, walk } from "@uncaged/json-cas";
import {
parseClaudeCodeJsonOutput,
parseClaudeCodeStreamOutput,
@@ -301,179 +301,6 @@ describe("storeClaudeCodeDetail", () => {
});
});
describe("parseClaudeCodeStreamOutput — incomplete output (no result line)", () => {
test("Test 1.1: parses stream with turns but no result line", () => {
const lines = [
JSON.stringify({
type: "system",
subtype: "init",
session_id: "sess-incomplete-1",
model: "claude-sonnet-4.5",
}),
JSON.stringify({
type: "assistant",
message: {
role: "assistant",
content: [{ type: "text", text: "Starting work..." }],
},
}),
JSON.stringify({
type: "assistant",
message: {
role: "assistant",
content: [{ type: "text", text: "This is the last assistant message." }],
},
}),
];
const stdout = lines.join("\n");
const parsed = parseClaudeCodeStreamOutput(stdout);
expect(parsed).not.toBeNull();
expect(parsed!.subtype).toBe("incomplete");
expect(parsed!.result).toBe("This is the last assistant message.");
expect(parsed!.sessionId).toBe("sess-incomplete-1");
expect(parsed!.model).toBe("claude-sonnet-4.5");
expect(parsed!.turns).toHaveLength(2);
expect(parsed!.stopReason).toBe("incomplete_no_result_line");
expect(parsed!.numTurns).toBe(2);
expect(parsed!.durationMs).toBe(0);
expect(parsed!.totalCostUsd).toBe(0);
});
test("Test 1.2: parses stream with no turns and no result line", () => {
const lines = [
JSON.stringify({
type: "system",
session_id: "sess-no-turns",
model: "claude-opus-4",
}),
];
const stdout = lines.join("\n");
const parsed = parseClaudeCodeStreamOutput(stdout);
expect(parsed).not.toBeNull();
expect(parsed!.subtype).toBe("incomplete");
expect(parsed!.result).toBe("");
expect(parsed!.sessionId).toBe("sess-no-turns");
expect(parsed!.model).toBe("claude-opus-4");
expect(parsed!.turns).toHaveLength(0);
expect(parsed!.stopReason).toBe("incomplete_no_result_line");
});
test("Test 1.3: returns null for completely empty output", () => {
const parsed1 = parseClaudeCodeStreamOutput("");
expect(parsed1).toBeNull();
const parsed2 = parseClaudeCodeStreamOutput(" \n \n ");
expect(parsed2).toBeNull();
});
test("Test 1.4: returns null for malformed JSON lines only", () => {
const stdout = "not json\n{broken json\n[invalid";
const parsed = parseClaudeCodeStreamOutput(stdout);
expect(parsed).toBeNull();
});
test("Test 6.1: extracts from last assistant text-only turn", () => {
const lines = [
JSON.stringify({ type: "system", session_id: "s1", model: "test" }),
JSON.stringify({
type: "assistant",
message: { role: "assistant", content: [{ type: "text", text: "First message" }] },
}),
JSON.stringify({
type: "assistant",
message: { role: "assistant", content: [{ type: "text", text: "Last message" }] },
}),
];
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
expect(parsed).not.toBeNull();
expect(parsed!.result).toBe("Last message");
});
test("Test 6.2: extracts from last assistant turn with tool calls", () => {
const lines = [
JSON.stringify({ type: "system", session_id: "s1", model: "test" }),
JSON.stringify({
type: "assistant",
message: {
role: "assistant",
content: [
{ type: "text", text: "Text with tools" },
{ type: "tool_use", name: "Bash", input: { command: "ls" } },
],
},
}),
];
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
expect(parsed).not.toBeNull();
expect(parsed!.result).toBe("Text with tools");
});
test("Test 6.3: returns empty string when no assistant turns", () => {
const lines = [JSON.stringify({ type: "system", session_id: "s1", model: "test" })];
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
expect(parsed).not.toBeNull();
expect(parsed!.result).toBe("");
});
test("Test 6.4: extracts from most recent assistant turn before tool_result", () => {
const lines = [
JSON.stringify({ type: "system", session_id: "s1", model: "test" }),
JSON.stringify({
type: "assistant",
message: { role: "assistant", content: [{ type: "text", text: "Before tool call" }] },
}),
JSON.stringify({
type: "user",
message: { role: "user", content: [{ type: "tool_result", content: "tool output" }] },
}),
];
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
expect(parsed).not.toBeNull();
expect(parsed!.result).toBe("Before tool call");
});
});
describe("storeClaudeCodeDetail — incomplete results", () => {
test("Test 4.1: stores incomplete result as detail", async () => {
const store = createMemoryStore();
const incompleteParsed: ClaudeCodeParsedResult = {
type: "result",
subtype: "incomplete",
result: "Partial output",
sessionId: "sess-incomplete",
numTurns: 2,
totalCostUsd: 0,
durationMs: 0,
model: "claude-sonnet-4.5",
stopReason: "incomplete_no_result_line",
usage: {
inputTokens: 0,
outputTokens: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
turns: [
{ index: 0, role: "assistant", content: "Turn 1", toolCalls: null },
{ index: 1, role: "assistant", content: "Partial output", toolCalls: null },
],
};
const { detailHash, output, sessionId } = await storeClaudeCodeDetail(store, incompleteParsed);
expect(detailHash).toHaveLength(13);
expect(output).toBe("Partial output");
expect(sessionId).toBe("sess-incomplete");
const node = await store.get(detailHash);
expect(node).not.toBeNull();
expect(node!.payload.subtype).toBe("incomplete");
expect(node!.payload.stopReason).toBe("incomplete_no_result_line");
expect(node!.payload.turns).toHaveLength(2);
});
});
describe("storeClaudeCodeRawOutput", () => {
test("stores raw text when JSON parsing fails", async () => {
const store = createMemoryStore();
@@ -18,12 +18,11 @@
}
},
"scripts": {
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
"test": "vitest run",
"test:ci": "vitest run"
"test": "bun test",
"test:ci": "bun test"
},
"dependencies": {
"@ocas/core": "^0.1.1",
"@uncaged/json-cas": "^0.5.3",
"@uncaged/workflow-util-agent": "workspace:^",
"@uncaged/workflow-util": "workspace:^"
},
@@ -35,12 +34,12 @@
},
"repository": {
"type": "git",
"url": "https://git.shazhou.work/uncaged/workflow.git",
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
"directory": "packages/workflow-agent-claude-code"
},
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
"bugs": {
"url": "https://git.shazhou.work/uncaged/workflow/issues"
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
},
"license": "MIT"
}
@@ -1,5 +1,5 @@
import { spawn } from "node:child_process";
import type { Store } from "@ocas/core";
import type { Store } from "@uncaged/json-cas";
import { createLogger } from "@uncaged/workflow-util";
import {
type AgentContext,
@@ -48,9 +48,7 @@ export function buildClaudeCodePrompt(ctx: AgentContext): string {
return parts.join("\n");
}
function spawnClaude(
args: string[],
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
function spawnClaude(args: string[]): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawn(CLAUDE_COMMAND, args, {
env: process.env,
@@ -74,7 +72,7 @@ function spawnClaude(
child.on("close", (code) => {
if (code === 0) {
resolve({ stdout, stderr, exitCode: code });
resolve({ stdout, stderr });
return;
}
const detail = stderr.trim() !== "" ? ` stderr=${stderr.trim()}` : "";
@@ -83,9 +81,7 @@ function spawnClaude(
});
}
function spawnClaudeRun(
prompt: string,
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
function spawnClaudeRun(prompt: string): Promise<{ stdout: string; stderr: string }> {
const args = [
"-p",
prompt,
@@ -105,7 +101,7 @@ function spawnClaudeRun(
function spawnClaudeResume(
sessionId: string,
message: string,
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
): Promise<{ stdout: string; stderr: string }> {
const args = [
"-p",
message,
@@ -124,36 +120,16 @@ function spawnClaudeResume(
return spawnClaude(args);
}
async function processClaudeOutput(
stdout: string,
stderr: string,
exitCode: number | null,
store: Store,
assembledPrompt: string,
): Promise<AgentRunResult> {
async function processClaudeOutput(stdout: string, store: Store): Promise<AgentRunResult> {
const parsed = parseClaudeCodeStreamOutput(stdout);
if (parsed !== null) {
const { detailHash, output, sessionId } = await storeClaudeCodeDetail(store, parsed);
// Log incomplete results for visibility
if (parsed.subtype === "incomplete") {
log(
"7NQW8R4P",
`Claude Code exited with incomplete output (no result line). Exit code: ${exitCode ?? "null"}, stderr: ${stderr.slice(0, 200)}`,
);
}
return { output, detailHash, sessionId, assembledPrompt };
return { output, detailHash, sessionId };
}
// Truly unparseable output - provide enhanced error message
const exitInfo = exitCode !== null && exitCode !== 0 ? `Exit code: ${exitCode}\n` : "";
const stderrInfo = stderr.trim() !== "" ? `Stderr: ${stderr.slice(0, 200)}\n` : "";
const stdoutSnippet = stdout.slice(0, 200);
throw new Error(
`Claude Code exited without producing parseable output.\n${exitInfo}${stderrInfo}Stdout (first 200 chars): ${stdoutSnippet}`,
`Claude Code returned unparseable output (first 200 chars): ${stdout.slice(0, 200)}`,
);
}
@@ -167,8 +143,8 @@ async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
const cachedSessionId = await getCachedSessionId("claude-code", ctx.threadId, ctx.role);
if (cachedSessionId !== null) {
try {
const { stdout, stderr, exitCode } = await spawnClaudeResume(cachedSessionId, fullPrompt);
const result = await processClaudeOutput(stdout, stderr, exitCode, ctx.store, fullPrompt);
const { stdout } = await spawnClaudeResume(cachedSessionId, fullPrompt);
const result = await processClaudeOutput(stdout, ctx.store);
if (result.sessionId !== undefined && result.sessionId !== "") {
await setCachedSessionId("claude-code", ctx.threadId, ctx.role, result.sessionId);
}
@@ -176,14 +152,16 @@ async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
} catch (err) {
log(
"5VKR8N3Q",
`resume failed for session ${cachedSessionId}, falling back to fresh run: ${err}`,
"resume failed for session %s, falling back to fresh run: %s",
cachedSessionId,
err,
);
}
}
}
const { stdout, stderr, exitCode } = await spawnClaudeRun(fullPrompt);
const result = await processClaudeOutput(stdout, stderr, exitCode, ctx.store, fullPrompt);
const { stdout } = await spawnClaudeRun(fullPrompt);
const result = await processClaudeOutput(stdout, ctx.store);
if (result.sessionId !== undefined && result.sessionId !== "") {
await setCachedSessionId("claude-code", ctx.threadId, ctx.role, result.sessionId);
}
@@ -195,8 +173,8 @@ async function continueClaudeCode(
message: string,
store: Store,
): Promise<AgentRunResult> {
const { stdout, stderr, exitCode } = await spawnClaudeResume(sessionId, message);
return processClaudeOutput(stdout, stderr, exitCode, store, "");
const { stdout } = await spawnClaudeResume(sessionId, message);
return processClaudeOutput(stdout, store);
}
/** Agent CLI factory: parses argv, runs Claude Code, extracts output, writes StepNode. */
@@ -1,4 +1,4 @@
import type { JSONSchema } from "@ocas/core";
import type { JSONSchema } from "@uncaged/json-cas";
export const CLAUDE_CODE_DETAIL_SCHEMA: JSONSchema = {
title: "claude-code-detail",
@@ -34,7 +34,7 @@ export const CLAUDE_CODE_DETAIL_SCHEMA: JSONSchema = {
},
turns: {
type: "array",
items: { type: "string", format: "ocas_ref" },
items: { type: "string", format: "cas_ref" },
},
},
additionalProperties: false,
@@ -1,4 +1,4 @@
import { bootstrap, putSchema, type Store } from "@ocas/core";
import { bootstrap, putSchema, type Store } from "@uncaged/json-cas";
import {
CLAUDE_CODE_DETAIL_SCHEMA,
@@ -71,7 +71,6 @@ type ParseState = {
turns: ClaudeCodeTurnPayload[];
resultLine: Record<string, unknown> | null;
model: string;
sessionId: string;
turnIndex: number;
};
@@ -79,9 +78,6 @@ function processSystemLine(parsed: Record<string, unknown>, state: ParseState):
if (typeof parsed.model === "string") {
state.model = parsed.model;
}
if (typeof parsed.session_id === "string") {
state.sessionId = parsed.session_id;
}
}
function processAssistantLine(parsed: Record<string, unknown>, state: ParseState): void {
@@ -128,52 +124,8 @@ function processLine(line: string, state: ParseState): void {
else if (type === "result") state.resultLine = parsed;
}
/**
* Extract output text from the last assistant turn.
* Used for best-effort extraction when no result line is present.
*/
function extractLastAssistantContent(turns: ClaudeCodeTurnPayload[]): string {
for (let i = turns.length - 1; i >= 0; i--) {
const turn = turns[i];
if (turn !== undefined && turn.role === "assistant" && turn.content !== "") {
return turn.content;
}
}
return "";
}
function assembleResult(state: ParseState): ClaudeCodeParsedResult | null {
// Handle incomplete result (no result line)
if (state.resultLine === null) {
// Need at least a session_id from system line to be parseable
if (state.sessionId === "") {
return null;
}
// Best-effort extraction: get output from last assistant turn
const result = extractLastAssistantContent(state.turns);
return {
type: "result",
subtype: "incomplete",
result,
sessionId: state.sessionId,
numTurns: state.turns.length,
totalCostUsd: 0,
durationMs: 0,
model: state.model,
stopReason: "incomplete_no_result_line",
usage: {
inputTokens: 0,
outputTokens: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
turns: state.turns,
};
}
// Handle complete result (has result line)
if (state.resultLine === null) return null;
const sessionId = state.resultLine.session_id;
const result = state.resultLine.result;
const subtype = state.resultLine.subtype;
@@ -207,13 +159,7 @@ function assembleResult(state: ParseState): ClaudeCodeParsedResult | null {
*/
export function parseClaudeCodeStreamOutput(stdout: string): ClaudeCodeParsedResult | null {
const lines = stdout.trim().split("\n");
const state: ParseState = {
turns: [],
resultLine: null,
model: "",
sessionId: "",
turnIndex: 0,
};
const state: ParseState = { turns: [], resultLine: null, model: "", turnIndex: 0 };
for (const line of lines) {
processLine(line, state);
}
@@ -1,4 +1,4 @@
export type ClaudeCodeResultSubtype = "success" | "error_max_turns" | "error_budget" | "incomplete";
export type ClaudeCodeResultSubtype = "success" | "error_max_turns" | "error_budget";
/** A single tool call within an assistant turn. */
export type ClaudeCodeToolCall = {
@@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
+1 -1
View File
@@ -8,7 +8,7 @@
On first visit to a role it sends a composed prompt (role definition, task, history, edge prompt); on continuation it resumes the cached session. Session transcripts and raw output are stored as CAS detail nodes.
**Dependencies:** `@ocas/core`, `@uncaged/workflow-util-agent`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`
**Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-util-agent`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`
## Installation
@@ -3,7 +3,7 @@ import { describe, expect, test } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createMemoryStore, refs, validate, walk } from "@ocas/core";
import { createMemoryStore, refs, validate, walk } from "@uncaged/json-cas";
import {
computeDurationMs,
@@ -82,7 +82,7 @@ describe("computeDurationMs", () => {
});
describe("storeHermesSessionDetail", () => {
test("stores hermes-detail root with ocas_ref turns walkable", async () => {
test("stores hermes-detail root with cas_ref turns walkable", async () => {
const session: HermesSessionJson = {
session_id: "20260518_133159_6a84e8",
model: "claude-opus-4.6",
+6 -7
View File
@@ -18,12 +18,11 @@
}
},
"scripts": {
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
"test": "vitest run",
"test:ci": "vitest run"
"test": "bun test",
"test:ci": "bun test __tests__/*.test.ts"
},
"dependencies": {
"@ocas/core": "^0.1.1",
"@uncaged/json-cas": "^0.5.3",
"@uncaged/workflow-util-agent": "workspace:^",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^"
@@ -36,12 +35,12 @@
},
"repository": {
"type": "git",
"url": "https://git.shazhou.work/uncaged/workflow.git",
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
"directory": "packages/workflow-agent-hermes"
},
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
"bugs": {
"url": "https://git.shazhou.work/uncaged/workflow/issues"
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
},
"engines": {
"bun": ">= 1.0.0"
+3 -3
View File
@@ -1,4 +1,4 @@
import type { Store } from "@ocas/core";
import type { Store } from "@uncaged/json-cas";
import { createLogger } from "@uncaged/workflow-util";
import {
type AgentContext,
@@ -117,7 +117,7 @@ export function createHermesAgent(): () => Promise<void> {
await setCachedSessionId(ctx.threadId, ctx.role, sessionId);
}
return { output: text, detailHash, sessionId, assembledPrompt: fullPrompt };
return { output: text, detailHash, sessionId };
}
async function runHermes(ctx: AgentContext): Promise<AgentRunResult> {
@@ -148,7 +148,7 @@ export function createHermesAgent(): () => Promise<void> {
// so the agent sees the full conversation history (crucial for retries).
const { text, sessionId } = await client.prompt(message);
const { detailHash } = await storePromptResult(store, sessionId);
return { output: text, detailHash, sessionId, assembledPrompt: "" };
return { output: text, detailHash, sessionId };
}
const agentMain = createAgent({
@@ -1,4 +1,4 @@
import type { JSONSchema } from "@ocas/core";
import type { JSONSchema } from "@uncaged/json-cas";
const HERMES_TOOL_CALL_SCHEMA: JSONSchema = {
type: "object",
@@ -39,7 +39,7 @@ export const HERMES_DETAIL_SCHEMA: JSONSchema = {
turnCount: { type: "integer" },
turns: {
type: "array",
items: { type: "string", format: "ocas_ref" },
items: { type: "string", format: "cas_ref" },
},
},
additionalProperties: false,
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema, type Store } from "@ocas/core";
import { bootstrap, putSchema, type Store } from "@uncaged/json-cas";
import { HERMES_DETAIL_SCHEMA, HERMES_RAW_OUTPUT_SCHEMA, HERMES_TURN_SCHEMA } from "./schemas.js";
import type {
@@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
+1 -3
View File
@@ -5,9 +5,7 @@
"type": "module",
"scripts": {
"dev": "bun server.ts",
"build": "vite build",
"test": "vitest run",
"test:ci": "vitest run"
"build": "vite build"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
@@ -14,7 +14,7 @@ export const editNodeViewModel = define.view("editNodeView", editNodeView, (set,
function start(nodeId: string) {
const [nodes] = model.use(nodesModel);
const node = nodes.find((n) => n.id === nodeId);
if (node?.type !== "role") return;
if (!node || node.type !== "role") return;
set({ node: node as WorkNode<"role"> });
}
@@ -40,7 +40,7 @@ function traverse(
visited.add(nodeId);
const node = nodeMap.get(nodeId);
if (node?.type !== "role") return;
if (!node || node.type !== "role") return;
const roleNode = node as WorkNode<"role">;
const outEdges = outgoingEdges.get(nodeId) ?? [];
+2 -2
View File
@@ -4,9 +4,9 @@ Shared TypeScript types and JSON Schema constants for the workflow engine.
## Overview
This is the contract layer (Layer 0). It defines `WorkflowPayload`, thread node payloads, moderator context, CLI output shapes, and configuration types used across every other package. It has no runtime logic beyond exporting schema objects from `@ocas/core`.
This is the contract layer (Layer 0). It defines `WorkflowPayload`, thread node payloads, moderator context, CLI output shapes, and configuration types used across every other package. It has no runtime logic beyond exporting schema objects from `@uncaged/json-cas`.
**Dependencies:** `@ocas/core`, `@ocas/fs`
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`
## Installation
+5 -10
View File
@@ -14,14 +14,9 @@
"import": "./dist/index.js"
}
},
"scripts": {
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
"test": "vitest run",
"test:ci": "vitest run"
},
"dependencies": {
"@ocas/core": "^0.1.1",
"@ocas/fs": "^0.1.1"
"@uncaged/json-cas": "^0.5.3",
"@uncaged/json-cas-fs": "^0.5.3"
},
"devDependencies": {
"typescript": "^5.8.3"
@@ -31,12 +26,12 @@
},
"repository": {
"type": "git",
"url": "https://git.shazhou.work/uncaged/workflow.git",
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
"directory": "packages/workflow-protocol"
},
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
"bugs": {
"url": "https://git.shazhou.work/uncaged/workflow/issues"
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
},
"license": "MIT"
}
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test } from "bun:test";
import type { StartNodePayload, StepRecord, Target } from "../types.js";
describe("Protocol types for thread/edge location", () => {
@@ -25,7 +25,6 @@ describe("Protocol types for thread/edge location", () => {
edgePrompt: "Plan the implementation",
startedAtMs: Date.now(),
completedAtMs: Date.now() + 1000,
assembledPrompt: null,
cwd: "/home/user/project",
};
+7 -10
View File
@@ -1,4 +1,4 @@
import type { JSONSchema } from "@ocas/core";
import type { JSONSchema } from "@uncaged/json-cas";
const ROLE_DEFINITION: JSONSchema = {
type: "object",
@@ -9,7 +9,7 @@ const ROLE_DEFINITION: JSONSchema = {
capabilities: { type: "array", items: { type: "string" } },
procedure: { type: "string" },
output: { type: "string" },
frontmatter: { type: "string", format: "ocas_ref" },
frontmatter: { type: "string", format: "cas_ref" },
},
additionalProperties: false,
};
@@ -54,7 +54,7 @@ export const START_NODE_SCHEMA: JSONSchema = {
type: "object",
required: ["workflow", "prompt", "cwd"],
properties: {
workflow: { type: "string", format: "ocas_ref" },
workflow: { type: "string", format: "cas_ref" },
prompt: { type: "string" },
cwd: { type: "string" },
},
@@ -76,21 +76,18 @@ export const STEP_NODE_SCHEMA: JSONSchema = {
"cwd",
],
properties: {
start: { type: "string", format: "ocas_ref" },
start: { type: "string", format: "cas_ref" },
prev: {
anyOf: [{ type: "string", format: "ocas_ref" }, { type: "null" }],
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
role: { type: "string" },
output: { type: "string", format: "ocas_ref" },
detail: { type: "string", format: "ocas_ref" },
output: { type: "string", format: "cas_ref" },
detail: { type: "string", format: "cas_ref" },
agent: { type: "string" },
edgePrompt: { type: "string" },
startedAtMs: { type: "integer" },
completedAtMs: { type: "integer" },
cwd: { type: "string" },
assembledPrompt: {
anyOf: [{ type: "string", format: "ocas_ref" }, { type: "null" }],
},
},
additionalProperties: false,
};
-2
View File
@@ -20,8 +20,6 @@ export type StepRecord = {
completedAtMs: number;
/** Working directory where the agent executed. Missing in legacy nodes → "". */
cwd: string;
/** CAS ref to the fully assembled prompt sent to the agent. null for legacy steps. */
assembledPrompt: CasRef | null;
};
// ── 4.2 Workflow 定义 ───────────────────────────────────────────────
@@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
+1 -1
View File
@@ -8,7 +8,7 @@ Layer 2 agent framework. Provides the standard entrypoint for all agent CLIs: pa
Also exports prompt builders, config/storage helpers, and session ID caching for multi-turn agents.
**Dependencies:** `@ocas/core`, `@ocas/fs`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`, `dotenv`, `yaml`
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`, `dotenv`, `yaml`
## Installation
@@ -1,4 +1,4 @@
import { createMemoryStore, putSchema } from "@ocas/core";
import { createMemoryStore, putSchema } from "@uncaged/json-cas";
import { describe, expect, test } from "vitest";
import { tryFrontmatterFastPath } from "../src/frontmatter.js";
@@ -44,7 +44,6 @@ describe("adapter-stdout: A4 retry loop survives JSON output", () => {
body: secondAttempt!.body,
startedAtMs: 1000,
completedAtMs: 2000,
assembledPrompt: null,
};
const json = JSON.stringify(adapterOutput);
@@ -1,4 +1,4 @@
import { createMemoryStore, putSchema } from "@ocas/core";
import { createMemoryStore, putSchema } from "@uncaged/json-cas";
import { describe, expect, test } from "vitest";
import { tryFrontmatterFastPath } from "../src/frontmatter.js";
@@ -1,4 +1,4 @@
import { createMemoryStore, putSchema } from "@ocas/core";
import { createMemoryStore, putSchema } from "@uncaged/json-cas";
import { describe, expect, test } from "vitest";
import { tryFrontmatterFastPath } from "../src/frontmatter.js";
+7 -8
View File
@@ -15,13 +15,12 @@
}
},
"scripts": {
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
"test": "vitest run",
"test:ci": "vitest run"
"test": "bun test",
"test:ci": "bun test"
},
"dependencies": {
"@ocas/core": "^0.1.1",
"@ocas/fs": "^0.1.1",
"@uncaged/json-cas": "^0.5.3",
"@uncaged/json-cas-fs": "^0.5.3",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"dotenv": "^16.6.1",
@@ -35,12 +34,12 @@
},
"repository": {
"type": "git",
"url": "https://git.shazhou.work/uncaged/workflow.git",
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
"directory": "packages/workflow-util-agent"
},
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
"bugs": {
"url": "https://git.shazhou.work/uncaged/workflow/issues"
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
},
"license": "MIT"
}
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
describe("parseArgv empty prompt error message", () => {
let stderrOutput: string;
@@ -1,4 +1,4 @@
import type { JSONSchema } from "@ocas/core";
import type { JSONSchema } from "@uncaged/json-cas";
type SchemaProperty = {
name: string;
@@ -214,7 +214,7 @@ function getConstValue(propSchema: JSONSchema): string {
function buildVariantBlock(variant: JSONSchema, discriminant: string): string {
const props = extractSchemaProperties(variant);
const value = getConstValue(
(variant.properties as Record<string, JSONSchema>)?.[discriminant] ?? {},
((variant.properties as Record<string, JSONSchema>) ?? {})[discriminant] ?? {},
);
const yamlExample = buildYamlExampleBlock(props);
const fieldList = buildFieldList(props);
+1 -2
View File
@@ -1,4 +1,4 @@
import type { Store } from "@ocas/core";
import type { Store } from "@uncaged/json-cas";
import type {
CasRef,
StartNodePayload,
@@ -131,7 +131,6 @@ async function buildHistory(
startedAtMs: step.startedAtMs,
completedAtMs: step.completedAtMs,
cwd: step.cwd ?? "",
assembledPrompt: step.assembledPrompt ?? null,
content,
});
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { getSchema, validate } from "@ocas/core";
import { getSchema, validate } from "@uncaged/json-cas";
import type { CasRef, ModelAlias, WorkflowConfig } from "@uncaged/workflow-protocol";
import { createAgentStore, resolveStorageRoot } from "./storage.js";
@@ -1,5 +1,5 @@
import type { Store } from "@ocas/core";
import { getSchema, validate } from "@ocas/core";
import type { Store } from "@uncaged/json-cas";
import { getSchema, validate } from "@uncaged/json-cas";
import type { CasRef } from "@uncaged/workflow-protocol";
import {
type AgentFrontmatter,
+1 -14
View File
@@ -1,4 +1,4 @@
import { getSchema, validate } from "@ocas/core";
import { getSchema, validate } from "@uncaged/json-cas";
import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/workflow-protocol";
import { config as loadDotenv } from "dotenv";
import { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
@@ -64,7 +64,6 @@ async function writeStepNode(options: {
edgePrompt: string;
startedAtMs: number;
completedAtMs: number;
assembledPromptHash: CasRef | null;
}): Promise<CasRef> {
const payload: StepNodePayload = {
start: options.startHash,
@@ -77,7 +76,6 @@ async function writeStepNode(options: {
startedAtMs: options.startedAtMs,
completedAtMs: options.completedAtMs,
cwd: process.cwd(),
assembledPrompt: options.assembledPromptHash,
};
const hash = await options.store.put(options.schemas.stepNode, payload);
const node = options.store.get(hash);
@@ -116,7 +114,6 @@ async function persistStep(options: {
agentName: string;
startedAtMs: number;
completedAtMs: number;
assembledPromptHash: CasRef | null;
}): Promise<CasRef> {
const { store, schemas, chain, headHash } = options.ctx.meta;
return writeStepNode({
@@ -131,7 +128,6 @@ async function persistStep(options: {
edgePrompt: options.ctx.edgePrompt,
startedAtMs: options.startedAtMs,
completedAtMs: options.completedAtMs,
assembledPromptHash: options.assembledPromptHash,
});
}
@@ -186,14 +182,6 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
);
}
const completedAtMs = Date.now();
// Store the assembled prompt in CAS for later inspection via `step read --prompt`
const promptText = agentResult.assembledPrompt;
const assembledPromptHash =
promptText !== ""
? await ctx.meta.store.put(ctx.meta.schemas.text, promptText).catch(() => null)
: null;
const stepHash = await persistStep({
ctx,
outputHash: extracted.outputHash,
@@ -201,7 +189,6 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
agentName: agentLabel(options.name),
startedAtMs,
completedAtMs,
assembledPromptHash,
});
const adapterOutput: AdapterOutput = {
+4 -8
View File
@@ -1,26 +1,22 @@
import type { Hash, Store } from "@ocas/core";
import { putSchema } from "@ocas/core";
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";
export type UwfAgentSchemaHashes = {
workflow: Hash;
startNode: Hash;
stepNode: Hash;
text: Hash;
};
const TEXT_SCHEMA = { type: "string" as const };
/**
* Register Workflow, StartNode, and StepNode JSON Schemas in the CAS store.
* Idempotent: safe to call on every agent invocation.
*/
export async function registerAgentSchemas(store: Store): Promise<UwfAgentSchemaHashes> {
const [workflow, startNode, stepNode, text] = await Promise.all([
const [workflow, startNode, stepNode] = await Promise.all([
putSchema(store, WORKFLOW_SCHEMA),
putSchema(store, START_NODE_SCHEMA),
putSchema(store, STEP_NODE_SCHEMA),
putSchema(store, TEXT_SCHEMA),
]);
return { workflow, startNode, stepNode, text };
return { workflow, startNode, stepNode };
}
+2 -2
View File
@@ -2,8 +2,8 @@ import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import type { Store } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import type { Store } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type {
AgentAlias,
AgentConfig,
+1 -3
View File
@@ -1,4 +1,4 @@
import type { Store } from "@ocas/core";
import type { Store } from "@uncaged/json-cas";
import type { ModeratorContext, ThreadId, WorkflowPayload } from "@uncaged/workflow-protocol";
export type AgentContext = ModeratorContext & {
@@ -27,8 +27,6 @@ export type AgentRunResult = {
output: string;
detailHash: string;
sessionId: string;
/** The fully assembled prompt that was sent to the agent. */
assembledPrompt: string;
};
export type AgentContinueFn = (
+1 -1
View File
@@ -5,5 +5,5 @@
"outDir": "dist"
},
"include": ["src"],
"references": [{ "path": "../workflow-protocol" }, { "path": "../workflow-util" }]
"references": [{ "path": "../workflow-protocol" }]
}
@@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
+3 -8
View File
@@ -14,11 +14,6 @@
"import": "./dist/index.js"
}
},
"scripts": {
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
"test": "vitest run",
"test:ci": "vitest run"
},
"dependencies": {},
"devDependencies": {
"typescript": "^5.8.3"
@@ -28,12 +23,12 @@
},
"repository": {
"type": "git",
"url": "https://git.shazhou.work/uncaged/workflow.git",
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
"directory": "packages/workflow-util"
},
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
"bugs": {
"url": "https://git.shazhou.work/uncaged/workflow/issues"
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
},
"license": "MIT"
}
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it } from "bun:test";
import { extractUlidTimestamp, generateUlid } from "../ulid.js";
describe("extractUlidTimestamp", () => {
@@ -1,39 +0,0 @@
export function generateBootstrapReference(): string {
return `---
name: uwf
description: "Uncaged Workflow (uwf) — YAML 状态机工作流引擎。任务涉及 workflow 时加载此 skill。"
tags: [workflow, uwf, uncaged]
triggers:
- uwf
- workflow
-
---
# uwf (Uncaged Workflow)
YAML workflow **uwf workflow**YAML Hermes skill \`uwf\` CLI 操作,不要混淆。
## 使
\`\`\`bash
uwf skill user # 使CLI thread
uwf skill author # workflow role graph schema
\`\`\`
##
\`\`\`bash
uwf workflow list # workflow
uwf workflow add <file.yaml> # workflow
uwf thread start <workflow> -p "prompt" # thread
uwf thread exec <thread-id> -c 10 # 10
uwf thread list # thread
\`\`\`
## workflow
\`examples/\` 目录下的 YAML 文件(analyze-topic、debate、solve-issue)。
`;
}
@@ -20,7 +20,7 @@ Dependency layers (each only imports from packages above it):
protocol util util-agent agent-hermes / agent-builtin / cli-workflow
\`\`\`
External CAS: \`@ocas/core\` (store API, hashing, schema validation) + \`@ocas/fs\` (filesystem backend).
External CAS: \`@uncaged/json-cas\` (store API, hashing, schema validation) + \`@uncaged/json-cas-fs\` (filesystem backend).
## Coding Conventions
@@ -122,7 +122,7 @@ Shared entry point for all agent CLIs. Handles:
### CAS Integration
All data is CAS-addressed via \`@ocas/core\`:
All data is CAS-addressed via \`@uncaged/json-cas\`:
- \`store.put(schemaHash, data)\` → content hash
- \`store.get(hash)\` → node
- \`validate(store, node)\` → schema check
-1
View File
@@ -3,7 +3,6 @@ export { generateAdapterReference } from "./adapter-reference.js";
export { generateArchitectureReference } from "./architecture-reference.js";
export { generateAuthorReference } from "./author-reference.js";
export { encodeUint64AsCrockford } from "./base32.js";
export { generateBootstrapReference } from "./bootstrap-reference.js";
export { generateCliReference } from "./cli-reference.js";
export { generateDeveloperReference } from "./developer-reference.js";
export { env } from "./env.js";
@@ -121,15 +121,5 @@ uwf log clean --before <date> # delete old logs
uwf --format <json|yaml> # output format (default: json)
uwf -V, --version # print version
\`\`\`
## Other Skill References
For specific scenarios, run the corresponding \`uwf skill\` command:
| Scenario | Command | When to use |
|----------|---------|-------------|
| Writing workflow YAML | \`uwf skill author\` | Designing roles, conditions, graphs, and edge prompts |
| Contributing to the engine | \`uwf skill developer\` | Modifying the workflow engine codebase itself |
| Building a new agent adapter | \`uwf skill adapter\` | Creating a new \`uwf-<name>\` CLI adapter |
`;
}
-8
View File
@@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bun
// Mock agent for smoke testing
import { bootstrap, type JSONSchema, putSchema } from "@ocas/core";
import { bootstrap, type JSONSchema, putSchema } from "@uncaged/json-cas";
import { createAgent } from "../packages/uwf-agent-kit/src/index.js";
const MOCK_RAW_OUTPUT_SCHEMA: JSONSchema = {
+1 -2
View File
@@ -21,7 +21,6 @@ const publishOrder = [
"workflow-util-agent",
"workflow-agent-hermes",
"workflow-agent-builtin",
"workflow-agent-claude-code",
"cli-workflow",
];
@@ -60,7 +59,7 @@ let failed = false;
for (const name of publishOrder) {
const pkgDir = join(root, "packages", name);
const tagFlag = tag ? `--tag ${tag}` : "";
const cmd = `npm publish --access public --ignore-scripts ${tagFlag}`;
const cmd = `npm publish --access public ${tagFlag}`;
console.log(`📦 ${name}...`);

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