Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 97637ad831 | |||
| 27d699fa73 | |||
| 80bbb8b5f9 | |||
| d310d43ab8 | |||
| 7612c97ae7 | |||
| 8ffea10db0 | |||
| abe516f739 | |||
| 647f40bdd5 | |||
| 2265b32933 | |||
| 3971c26dc1 | |||
| 27d6062992 | |||
| 625d975c3d | |||
| b4919aa921 | |||
| 3a927de63f | |||
| 512a3f8653 | |||
| 0b20e88317 | |||
| abc9dcfc5a | |||
| 080b37c2be | |||
| 7935b73374 | |||
| cfa890f83c | |||
| 81c08ac7e2 | |||
| fdcfcc7eba | |||
| 4972f99ca0 | |||
| 48bf701281 | |||
| d8cba5eea0 | |||
| d9f7648fdd | |||
| a2e9dd9785 | |||
| 3b498069b6 | |||
| 984d93a6f5 | |||
| 2274de29c3 | |||
| 911cbf2a8a | |||
| 09a5da2df2 | |||
| e4c228d36e | |||
| f8de0e913b | |||
| cb97507e9a | |||
| 4b442bb251 | |||
| ac53128ff7 | |||
| 607366c469 |
@@ -7,18 +7,19 @@ on:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
- run: bun install
|
||||
|
||||
- name: Check
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
- name: Lint
|
||||
run: bun run check
|
||||
|
||||
- name: Test
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
# 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.
|
||||
@@ -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/workflow/cas/ — Workflow stored as CAS node
|
||||
~/.uncaged/json-cas/ — Workflow stored as CAS node (unified CAS store)
|
||||
~/.uncaged/workflow/registry.yaml — name → hash mapping updated
|
||||
│ uwf thread start <name> -p "..."
|
||||
▼
|
||||
|
||||
+2
-1
@@ -39,7 +39,8 @@
|
||||
"linter": {
|
||||
"rules": {
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off"
|
||||
"noExplicitAny": "off",
|
||||
"noConsole": "off"
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
|
||||
+7
-1
@@ -1,11 +1,14 @@
|
||||
{
|
||||
"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",
|
||||
@@ -23,7 +26,10 @@
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/xxhashjs": "^0.2.4",
|
||||
"@uncaged/workflow-agent-hermes": "workspace:*",
|
||||
"bun-types": "^1.3.13"
|
||||
"bun-types": "^1.3.13",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^4.1.7",
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -209,4 +209,13 @@ 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/workflow/cas/` | Content-addressed node storage |
|
||||
| `~/.uncaged/json-cas/` | Content-addressed node storage (unified CAS store, shared with `json-cas` 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` |
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"scripts": {
|
||||
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
|
||||
"test": "vitest run",
|
||||
"test:ci": "vitest run"
|
||||
},
|
||||
@@ -34,12 +35,12 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
|
||||
"url": "https://git.shazhou.work/uncaged/workflow.git",
|
||||
"directory": "packages/cli-workflow"
|
||||
},
|
||||
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
|
||||
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
|
||||
"url": "https://git.shazhou.work/uncaged/workflow/issues"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
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 "@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";
|
||||
import { saveThreadsIndex } from "../store.js";
|
||||
|
||||
// ── schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const OUTPUT_SCHEMA = {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
$status: { type: "string" as const, enum: ["done", "failed"] },
|
||||
result: { type: "string" as const },
|
||||
},
|
||||
required: ["$status"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
// ── fixture ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-roundtrip-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("C1: adapter JSON round-trip integration", () => {
|
||||
test("mock agent outputs JSON, CLI parses it and updates thread head in CAS", async () => {
|
||||
// 1. Set up CAS store with workflow, start node, and output schema
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
|
||||
const outputSchemaHash = await putSchema(store, OUTPUT_SCHEMA);
|
||||
|
||||
const workflowHash = await store.put(schemas.workflow, {
|
||||
name: "test-roundtrip",
|
||||
description: "roundtrip integration test",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker role",
|
||||
goal: "Do work",
|
||||
capabilities: [],
|
||||
procedure: "work",
|
||||
output: "result",
|
||||
frontmatter: outputSchemaHash,
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: { _: { role: "worker", prompt: "Do the work", location: null } },
|
||||
worker: { done: { role: "$END", prompt: "completed", location: null } },
|
||||
},
|
||||
});
|
||||
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Test round-trip task",
|
||||
});
|
||||
|
||||
const threadId = "01ROUNDTRIPTEST0000000000" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: startHash });
|
||||
|
||||
// 2. Pre-create CAS nodes that the mock agent would produce
|
||||
const outputHash = await store.put(outputSchemaHash, {
|
||||
$status: "done",
|
||||
result: "test-ok",
|
||||
});
|
||||
|
||||
// Use text schema for detail (simple placeholder)
|
||||
const detailHash = await store.put(schemas.text, "mock detail");
|
||||
|
||||
const startedAtMs = 1716600000000;
|
||||
const completedAtMs = 1716600001500;
|
||||
|
||||
const stepHash = await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-mock",
|
||||
edgePrompt: "Do the work",
|
||||
startedAtMs,
|
||||
completedAtMs,
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
// 3. Create a minimal mock agent shell script that just outputs JSON
|
||||
// The step node is already in CAS — the agent just needs to print the JSON line
|
||||
const mockAgentPath = join(tmpDir, "mock-agent.sh");
|
||||
const adapterJson = JSON.stringify({
|
||||
stepHash,
|
||||
detailHash,
|
||||
role: "worker",
|
||||
frontmatter: { $status: "done", result: "test-ok" },
|
||||
body: "",
|
||||
startedAtMs,
|
||||
completedAtMs,
|
||||
});
|
||||
await writeFile(mockAgentPath, `#!/bin/sh\necho '${adapterJson}'\n`, { mode: 0o755 });
|
||||
|
||||
// 4. Write config.yaml
|
||||
const configPath = join(tmpDir, "config.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`defaultAgent: uwf-hermes\ndefaultModel: test-model\nagentOverrides: null\nagents: {}\nproviders: {}\nmodels: {}\n`,
|
||||
);
|
||||
|
||||
// 5. Run CLI with agent override pointing to our mock
|
||||
const cliPath = join(import.meta.dirname, "..", "cli.js");
|
||||
let stdout: string;
|
||||
let stderr: string;
|
||||
let exitCode: number;
|
||||
|
||||
try {
|
||||
stdout = execFileSync(
|
||||
"bun",
|
||||
["run", cliPath, "thread", "exec", threadId, "--agent", mockAgentPath],
|
||||
{
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
WORKFLOW_STORAGE_ROOT: tmpDir,
|
||||
UNCAGED_CAS_DIR: casDir,
|
||||
},
|
||||
cwd: tmpDir,
|
||||
timeout: 30000,
|
||||
},
|
||||
);
|
||||
stderr = "";
|
||||
exitCode = 0;
|
||||
} catch (e: unknown) {
|
||||
const err = e as NodeJS.ErrnoException & {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
status?: number;
|
||||
};
|
||||
stdout = err.stdout ?? "";
|
||||
stderr = err.stderr ?? "";
|
||||
exitCode = err.status ?? 1;
|
||||
}
|
||||
|
||||
// 6. Verify
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`CLI exited with code ${exitCode}\nstdout: ${stdout}\nstderr: ${stderr}`);
|
||||
}
|
||||
|
||||
// Parse CLI output
|
||||
const cliOutput = JSON.parse(stdout.trim());
|
||||
expect(cliOutput).toHaveProperty("thread", threadId);
|
||||
expect(cliOutput).toHaveProperty("head", stepHash);
|
||||
expect(cliOutput.head).toMatch(/^[0-9A-HJ-NP-TV-Z]{13}$/);
|
||||
|
||||
// Verify the CAS step node exists and has correct metadata
|
||||
const storeAfter = createFsStore(casDir);
|
||||
const stepNode = storeAfter.get(cliOutput.head as CasRef);
|
||||
expect(stepNode).not.toBeNull();
|
||||
|
||||
const payload = stepNode!.payload as StepNodePayload;
|
||||
expect(payload.role).toBe("worker");
|
||||
expect(payload.agent).toBe("uwf-mock");
|
||||
expect(payload.startedAtMs).toBe(1716600000000);
|
||||
expect(payload.completedAtMs).toBe(1716600001500);
|
||||
expect(payload.output).toBe(outputHash);
|
||||
expect(payload.detail).toBe(detailHash);
|
||||
});
|
||||
});
|
||||
@@ -6,14 +6,22 @@ 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");
|
||||
@@ -21,6 +29,13 @@ 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 = {
|
||||
@@ -32,7 +47,11 @@ type ExecResult = {
|
||||
function execUwf(args: string[]): ExecResult {
|
||||
try {
|
||||
const stdout = execSync(`bun ${uwfPath} ${args.join(" ")}`, {
|
||||
env: { ...process.env, WORKFLOW_STORAGE_ROOT: storageRoot },
|
||||
env: {
|
||||
...process.env,
|
||||
WORKFLOW_STORAGE_ROOT: storageRoot,
|
||||
UNCAGED_CAS_DIR: casDir,
|
||||
},
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
@@ -143,6 +143,44 @@ defaultModel: default
|
||||
const masked = maskApiKeys(config);
|
||||
expect(masked).toEqual(config);
|
||||
});
|
||||
|
||||
test("does not mask non-provider apiKey fields", () => {
|
||||
const config = {
|
||||
apiKey: "root-level-key",
|
||||
providers: {
|
||||
dashscope: { apiKey: "sk-secret" },
|
||||
},
|
||||
models: {
|
||||
default: { provider: "dashscope" },
|
||||
},
|
||||
};
|
||||
const masked = maskApiKeys(config);
|
||||
// Root-level apiKey should NOT be masked
|
||||
expect(masked.apiKey).toBe("root-level-key");
|
||||
// Provider apiKey SHOULD be masked
|
||||
const providers = masked.providers as Record<string, Record<string, unknown>>;
|
||||
expect(providers.dashscope.apiKey).toBe("***MASKED***");
|
||||
});
|
||||
|
||||
test("handles empty provider object", () => {
|
||||
const config = {
|
||||
providers: { dashscope: {} },
|
||||
};
|
||||
const masked = maskApiKeys(config);
|
||||
expect(masked).toEqual({ providers: { dashscope: {} } });
|
||||
});
|
||||
|
||||
test("handles provider with null apiKey", () => {
|
||||
const config = {
|
||||
providers: {
|
||||
dashscope: { apiKey: null, baseUrl: "https://example.com" },
|
||||
},
|
||||
};
|
||||
const masked = maskApiKeys(config);
|
||||
const providers = masked.providers as Record<string, Record<string, unknown>>;
|
||||
expect(providers.dashscope.apiKey).toBe("***MASKED***");
|
||||
expect(providers.dashscope.baseUrl).toBe("https://example.com");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -618,5 +656,82 @@ defaultModel: default
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("agentOverrides — accepts valid 3-segment path", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await cmdConfigSet(tempDir, "agentOverrides.solve-issue.planner", "claude-code");
|
||||
const value = await cmdConfigGet(tempDir, "agentOverrides.solve-issue.planner");
|
||||
expect(value).toBe("claude-code");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("agentOverrides — rejects incomplete path (2 segments)", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await expect(cmdConfigSet(tempDir, "agentOverrides.solve-issue", "hermes")).rejects.toThrow(
|
||||
/incomplete path|must specify a field/i,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("modelOverrides — accepts valid 2-segment path", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await cmdConfigSet(tempDir, "modelOverrides.extract", "gpt4");
|
||||
const value = await cmdConfigGet(tempDir, "modelOverrides.extract");
|
||||
expect(value).toBe("gpt4");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("modelOverrides — rejects incomplete path (1 segment only)", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await expect(cmdConfigSet(tempDir, "modelOverrides", "gpt4")).rejects.toThrow(
|
||||
/incomplete path|must specify a field/i,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects unknown top-level key (regression)", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
|
||||
try {
|
||||
createTestConfig(tempDir, sampleConfig);
|
||||
await expect(cmdConfigSet(tempDir, "randomKey", "value")).rejects.toThrow(
|
||||
/Unknown config key/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("no legacy apiKeyEnv references", () => {
|
||||
test("config.ts has no references to apiKeyEnv", () => {
|
||||
const configSource = readFileSync(join(__dirname, "..", "commands", "config.ts"), "utf8");
|
||||
expect(configSource).not.toContain("apiKeyEnv");
|
||||
});
|
||||
|
||||
test("config.test.ts has no references to apiKeyEnv (except this test)", () => {
|
||||
const testSource = readFileSync(__filename, "utf8");
|
||||
// Remove this test block's own mentions before checking
|
||||
const withoutThisTest = testSource.replace(
|
||||
/describe\("no legacy apiKeyEnv references"[\s\S]*$/,
|
||||
"",
|
||||
);
|
||||
expect(withoutThisTest).not.toContain("apiKeyEnv");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
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";
|
||||
import { cmdThreadList, cmdThreadShow, cmdThreadStart } from "../commands/thread.js";
|
||||
import {
|
||||
appendThreadHistory,
|
||||
createUwfStore,
|
||||
loadThreadsIndex,
|
||||
saveThreadsIndex,
|
||||
} from "../store.js";
|
||||
|
||||
const OUTPUT_SCHEMA = {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
$status: { type: "string" as const },
|
||||
},
|
||||
};
|
||||
|
||||
const SIMPLE_WORKFLOW_YAML = `
|
||||
name: test-current-role
|
||||
description: Test workflow for currentRole
|
||||
roles:
|
||||
roleA:
|
||||
description: First role
|
||||
goal: Do A
|
||||
capabilities: ["coding"]
|
||||
procedure: Do A
|
||||
output: |
|
||||
$status: "ready"
|
||||
frontmatter:
|
||||
type: object
|
||||
required: ["$status"]
|
||||
properties:
|
||||
$status: { type: string, enum: ["ready", "not-ready"] }
|
||||
roleB:
|
||||
description: Second role
|
||||
goal: Do B
|
||||
capabilities: ["coding"]
|
||||
procedure: Do B
|
||||
output: |
|
||||
$status: "done"
|
||||
frontmatter:
|
||||
type: object
|
||||
required: ["$status"]
|
||||
properties:
|
||||
$status: { type: string }
|
||||
graph:
|
||||
$START:
|
||||
_:
|
||||
role: roleA
|
||||
prompt: "Do A"
|
||||
location: null
|
||||
roleA:
|
||||
ready:
|
||||
role: roleB
|
||||
prompt: "Do B"
|
||||
location: null
|
||||
not-ready:
|
||||
role: roleA
|
||||
prompt: "Try again"
|
||||
location: null
|
||||
roleB:
|
||||
_:
|
||||
role: $END
|
||||
prompt: "Done"
|
||||
location: null
|
||||
`;
|
||||
|
||||
const CONDITIONAL_WORKFLOW_YAML = `
|
||||
name: test-conditional-role
|
||||
description: Conditional routing workflow
|
||||
roles:
|
||||
roleA:
|
||||
description: First role
|
||||
goal: Do A
|
||||
capabilities: ["coding"]
|
||||
procedure: Do A
|
||||
output: |
|
||||
$status: "pass"
|
||||
frontmatter:
|
||||
type: object
|
||||
required: ["$status"]
|
||||
properties:
|
||||
$status: { type: string, enum: ["pass", "fail"] }
|
||||
roleB:
|
||||
description: Pass role
|
||||
goal: Do B
|
||||
capabilities: ["coding"]
|
||||
procedure: Do B
|
||||
output: |
|
||||
$status: "done"
|
||||
frontmatter:
|
||||
type: object
|
||||
required: ["$status"]
|
||||
properties:
|
||||
$status: { type: string }
|
||||
roleC:
|
||||
description: Fail role
|
||||
goal: Do C
|
||||
capabilities: ["coding"]
|
||||
procedure: Do C
|
||||
output: |
|
||||
$status: "done"
|
||||
frontmatter:
|
||||
type: object
|
||||
required: ["$status"]
|
||||
properties:
|
||||
$status: { type: string }
|
||||
graph:
|
||||
$START:
|
||||
_:
|
||||
role: roleA
|
||||
prompt: "Do A"
|
||||
location: null
|
||||
roleA:
|
||||
pass:
|
||||
role: roleB
|
||||
prompt: "Do B (pass)"
|
||||
location: null
|
||||
fail:
|
||||
role: roleC
|
||||
prompt: "Do C (fail)"
|
||||
location: null
|
||||
roleB:
|
||||
_:
|
||||
role: $END
|
||||
prompt: "Done"
|
||||
location: null
|
||||
roleC:
|
||||
_:
|
||||
role: $END
|
||||
prompt: "Done"
|
||||
location: null
|
||||
`;
|
||||
|
||||
const SINGLE_ROLE_WORKFLOW_YAML = `
|
||||
name: test-single-role
|
||||
description: Single role that goes to END
|
||||
roles:
|
||||
worker:
|
||||
description: Worker
|
||||
goal: Work
|
||||
capabilities: ["coding"]
|
||||
procedure: Work
|
||||
output: |
|
||||
$status: "done"
|
||||
frontmatter:
|
||||
type: object
|
||||
required: ["$status"]
|
||||
properties:
|
||||
$status: { type: string }
|
||||
graph:
|
||||
$START:
|
||||
_:
|
||||
role: worker
|
||||
prompt: "Work"
|
||||
location: null
|
||||
worker:
|
||||
_:
|
||||
role: $END
|
||||
prompt: "Done"
|
||||
location: null
|
||||
`;
|
||||
|
||||
/** Helper: insert a completed step node after the current head. */
|
||||
async function insertStepNode(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
role: string,
|
||||
outputPayload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const head = index[threadId];
|
||||
if (head === undefined) throw new Error(`thread ${threadId} not in index`);
|
||||
|
||||
const outputSchemaHash = await putSchema(uwf.store, OUTPUT_SCHEMA);
|
||||
const outputHash = await uwf.store.put(outputSchemaHash, outputPayload);
|
||||
|
||||
// Use text schema for detail (simple placeholder)
|
||||
const detailHash = await uwf.store.put(uwf.schemas.text, "detail-placeholder");
|
||||
|
||||
// Resolve start hash from head
|
||||
const headNode = uwf.store.get(head);
|
||||
if (headNode === null) throw new Error(`head ${head} not found`);
|
||||
const isStart = headNode.type === uwf.schemas.startNode;
|
||||
const startHash = isStart ? head : (headNode.payload as { start: CasRef }).start;
|
||||
|
||||
const stepHash = (await uwf.store.put(uwf.schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: isStart ? null : head,
|
||||
role,
|
||||
prompt: `Do ${role}`,
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
})) as CasRef;
|
||||
|
||||
index[threadId] = stepHash;
|
||||
await saveThreadsIndex(storageRoot, index);
|
||||
}
|
||||
|
||||
describe("currentRole field", () => {
|
||||
let tmpDir: string;
|
||||
let storageRoot: string;
|
||||
let casDir: string;
|
||||
let originalEnv: string | undefined;
|
||||
|
||||
async function setup() {
|
||||
tmpDir = join(
|
||||
tmpdir(),
|
||||
`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
|
||||
test("thread show — idle at start returns first role as currentRole", async () => {
|
||||
await setup();
|
||||
try {
|
||||
const wf = join(tmpDir, "test-current-role.yaml");
|
||||
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
|
||||
const { thread } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
|
||||
|
||||
const result = await cmdThreadShow(storageRoot, thread as ThreadId);
|
||||
expect(result.status).toBe("idle");
|
||||
expect(result.currentRole).toBe("roleA");
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
// T2: idle after one step — currentRole = next role
|
||||
test("thread show — idle after step returns next role as currentRole", async () => {
|
||||
await setup();
|
||||
try {
|
||||
const wf = join(tmpDir, "test-current-role.yaml");
|
||||
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
|
||||
const { thread } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
|
||||
|
||||
await insertStepNode(storageRoot, thread as ThreadId, "roleA", { $status: "ready" });
|
||||
|
||||
const result = await cmdThreadShow(storageRoot, thread as ThreadId);
|
||||
expect(result.status).toBe("idle");
|
||||
expect(result.currentRole).toBe("roleB");
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
// T3: completed → currentRole = null
|
||||
test("thread show — completed thread returns null currentRole", async () => {
|
||||
await setup();
|
||||
try {
|
||||
const wf = join(tmpDir, "test-current-role.yaml");
|
||||
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
|
||||
const { thread, workflow } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
|
||||
const tid = thread as ThreadId;
|
||||
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const head = index[tid]!;
|
||||
delete index[tid];
|
||||
await saveThreadsIndex(storageRoot, index);
|
||||
await appendThreadHistory(storageRoot, {
|
||||
thread: tid,
|
||||
workflow,
|
||||
head,
|
||||
completedAt: Date.now(),
|
||||
reason: "completed",
|
||||
});
|
||||
|
||||
const result = await cmdThreadShow(storageRoot, tid);
|
||||
expect(result.status).toBe("completed");
|
||||
expect(result.currentRole).toBe(null);
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
// T4: cancelled → currentRole = null
|
||||
test("thread show — cancelled thread returns null currentRole", async () => {
|
||||
await setup();
|
||||
try {
|
||||
const wf = join(tmpDir, "test-current-role.yaml");
|
||||
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
|
||||
const { thread, workflow } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
|
||||
const tid = thread as ThreadId;
|
||||
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const head = index[tid]!;
|
||||
delete index[tid];
|
||||
await saveThreadsIndex(storageRoot, index);
|
||||
await appendThreadHistory(storageRoot, {
|
||||
thread: tid,
|
||||
workflow,
|
||||
head,
|
||||
completedAt: Date.now(),
|
||||
reason: "cancelled",
|
||||
});
|
||||
|
||||
const result = await cmdThreadShow(storageRoot, tid);
|
||||
expect(result.status).toBe("cancelled");
|
||||
expect(result.currentRole).toBe(null);
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
// T5: running → currentRole = role being executed
|
||||
test("thread show — running thread returns current role", async () => {
|
||||
await setup();
|
||||
try {
|
||||
const wf = join(tmpDir, "test-current-role.yaml");
|
||||
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
|
||||
const { thread, workflow } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
|
||||
const tid = thread as ThreadId;
|
||||
|
||||
await createMarker(storageRoot, {
|
||||
thread: tid,
|
||||
workflow,
|
||||
pid: process.pid,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await cmdThreadShow(storageRoot, tid);
|
||||
expect(result.status).toBe("running");
|
||||
expect(result.currentRole).toBe("roleA");
|
||||
} finally {
|
||||
await deleteMarker(storageRoot, tid);
|
||||
}
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
// T6: thread list — mixed statuses with correct currentRole
|
||||
test("thread list — returns correct currentRole for each status", async () => {
|
||||
await setup();
|
||||
try {
|
||||
const wf = join(tmpDir, "test-current-role.yaml");
|
||||
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
|
||||
|
||||
// idle thread
|
||||
const idle = await cmdThreadStart(storageRoot, wf, "idle", tmpDir);
|
||||
const idleId = idle.thread as ThreadId;
|
||||
|
||||
// completed thread
|
||||
const comp = await cmdThreadStart(storageRoot, wf, "completed", tmpDir);
|
||||
const compId = comp.thread as ThreadId;
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const compHead = index[compId]!;
|
||||
delete index[compId];
|
||||
await saveThreadsIndex(storageRoot, index);
|
||||
await appendThreadHistory(storageRoot, {
|
||||
thread: compId,
|
||||
workflow: comp.workflow,
|
||||
head: compHead,
|
||||
completedAt: Date.now(),
|
||||
reason: "completed",
|
||||
});
|
||||
|
||||
const list = await cmdThreadList(storageRoot, null, null, null, 0, 100);
|
||||
|
||||
const idleItem = list.find((i) => i.thread === idleId);
|
||||
expect(idleItem).toBeDefined();
|
||||
expect(idleItem!.currentRole).toBe("roleA");
|
||||
|
||||
const compItem = list.find((i) => i.thread === compId);
|
||||
expect(compItem).toBeDefined();
|
||||
expect(compItem!.currentRole).toBe(null);
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
// T7: thread list — idle at start has correct currentRole
|
||||
test("thread list — idle thread at start has correct currentRole", async () => {
|
||||
await setup();
|
||||
try {
|
||||
const wf = join(tmpDir, "test-current-role.yaml");
|
||||
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
|
||||
const { thread } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
|
||||
|
||||
const list = await cmdThreadList(storageRoot, null, null, null, 0, 100);
|
||||
const item = list.find((i) => i.thread === (thread as ThreadId));
|
||||
expect(item).toBeDefined();
|
||||
expect(item!.currentRole).toBe("roleA");
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
// T8: conditional routing — $status=pass vs fail
|
||||
test("thread show — conditional routing selects correct next role", async () => {
|
||||
await setup();
|
||||
try {
|
||||
const wf = join(tmpDir, "test-conditional-role.yaml");
|
||||
await writeFile(wf, CONDITIONAL_WORKFLOW_YAML, "utf8");
|
||||
|
||||
// pass path
|
||||
const t1 = await cmdThreadStart(storageRoot, wf, "pass test", tmpDir);
|
||||
await insertStepNode(storageRoot, t1.thread as ThreadId, "roleA", { $status: "pass" });
|
||||
const r1 = await cmdThreadShow(storageRoot, t1.thread as ThreadId);
|
||||
expect(r1.currentRole).toBe("roleB");
|
||||
|
||||
// fail path
|
||||
const t2 = await cmdThreadStart(storageRoot, wf, "fail test", tmpDir);
|
||||
await insertStepNode(storageRoot, t2.thread as ThreadId, "roleA", { $status: "fail" });
|
||||
const r2 = await cmdThreadShow(storageRoot, t2.thread as ThreadId);
|
||||
expect(r2.currentRole).toBe("roleC");
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
// T9: next role is $END → currentRole = null
|
||||
test("thread show — when next is $END, currentRole is null", async () => {
|
||||
await setup();
|
||||
try {
|
||||
const wf = join(tmpDir, "test-single-role.yaml");
|
||||
await writeFile(wf, SINGLE_ROLE_WORKFLOW_YAML, "utf8");
|
||||
|
||||
const { thread } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
|
||||
// worker → _ maps to $END
|
||||
await insertStepNode(storageRoot, thread as ThreadId, "worker", {});
|
||||
|
||||
const result = await cmdThreadShow(storageRoot, thread as ThreadId);
|
||||
expect(result.currentRole).toBe(null);
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,17 +5,17 @@ import { evaluate } from "../moderator/evaluate.js";
|
||||
|
||||
const solveIssueGraph: WorkflowPayload["graph"] = {
|
||||
$START: {
|
||||
_: { role: "planner", prompt: "Start planning from the issue in the task." },
|
||||
_: { role: "planner", prompt: "Start planning from the issue in the task.", location: null },
|
||||
},
|
||||
planner: {
|
||||
_: { role: "developer", prompt: "Implement the plan: {{plan}}" },
|
||||
_: { role: "developer", prompt: "Implement the plan: {{plan}}", location: null },
|
||||
},
|
||||
developer: {
|
||||
_: { role: "reviewer", prompt: "Review the changes: {{summary}}" },
|
||||
_: { role: "reviewer", prompt: "Review the changes: {{summary}}", location: null },
|
||||
},
|
||||
reviewer: {
|
||||
approved: { role: "$END", prompt: "Done." },
|
||||
rejected: { role: "developer", prompt: "Fix: {{comments}}" },
|
||||
approved: { role: "$END", prompt: "Done.", location: null },
|
||||
rejected: { role: "developer", prompt: "Fix: {{comments}}", location: null },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -24,7 +24,11 @@ describe("evaluate", () => {
|
||||
const result = evaluate(solveIssueGraph, "$START", { $status: "_" });
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "planner", prompt: "Start planning from the issue in the task." },
|
||||
value: {
|
||||
role: "planner",
|
||||
prompt: "Start planning from the issue in the task.",
|
||||
location: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +39,7 @@ describe("evaluate", () => {
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Fix: missing tests" },
|
||||
value: { role: "developer", prompt: "Fix: missing tests", location: null },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +47,7 @@ describe("evaluate", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", { $status: "approved" });
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "$END", prompt: "Done." },
|
||||
value: { role: "$END", prompt: "Done.", location: null },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,7 +74,11 @@ describe("evaluate", () => {
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Implement the plan: Add auth middleware" },
|
||||
value: {
|
||||
role: "developer",
|
||||
prompt: "Implement the plan: Add auth middleware",
|
||||
location: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,14 +89,14 @@ describe("evaluate", () => {
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: 'Fix: use <T> & "Result<T, E>" types' },
|
||||
value: { role: "developer", prompt: 'Fix: use <T> & "Result<T, E>" types', location: null },
|
||||
});
|
||||
});
|
||||
|
||||
test("triple mustache also works for unescaped output", () => {
|
||||
const graph: Record<string, Record<string, Target>> = {
|
||||
reviewer: {
|
||||
_: { role: "developer", prompt: "Fix: {{{comments}}}" },
|
||||
_: { role: "developer", prompt: "Fix: {{{comments}}}", location: null },
|
||||
},
|
||||
};
|
||||
const result = evaluate(graph, "reviewer", {
|
||||
@@ -97,7 +105,7 @@ describe("evaluate", () => {
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Fix: <script>alert(1)</script>" },
|
||||
value: { role: "developer", prompt: "Fix: <script>alert(1)</script>", location: null },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,7 +115,11 @@ describe("evaluate", () => {
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Implement the plan: Add auth middleware" },
|
||||
value: {
|
||||
role: "developer",
|
||||
prompt: "Implement the plan: Add auth middleware",
|
||||
location: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,6 +129,7 @@ describe("evaluate", () => {
|
||||
_: {
|
||||
role: "developer",
|
||||
prompt: "Address: {{review.comments}}",
|
||||
location: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -126,7 +139,7 @@ describe("evaluate", () => {
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Address: refactor the handler" },
|
||||
value: { role: "developer", prompt: "Address: refactor the handler", location: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,27 +6,17 @@ 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");
|
||||
@@ -36,50 +26,6 @@ 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");
|
||||
@@ -126,11 +72,6 @@ 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");
|
||||
|
||||
@@ -98,7 +98,7 @@ describe("solve-issue workflow: tea pr create worktree fix", () => {
|
||||
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");
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
/**
|
||||
* B-group tests: validate JSON parsing logic used by spawnAgent.
|
||||
*
|
||||
* We test the parsing logic inline since spawnAgent is a private function.
|
||||
* These tests verify the contract: last line of stdout must be valid JSON
|
||||
* with a valid stepHash CasRef.
|
||||
*/
|
||||
|
||||
const CASREF_PATTERN = /^[0-9A-HJ-NP-TV-Z]{13}$/;
|
||||
|
||||
function isCasRef(s: string): boolean {
|
||||
return CASREF_PATTERN.test(s);
|
||||
}
|
||||
|
||||
type AdapterOutput = {
|
||||
stepHash: string;
|
||||
detailHash: string;
|
||||
role: string;
|
||||
frontmatter: Record<string, unknown>;
|
||||
body: string;
|
||||
startedAtMs: number;
|
||||
completedAtMs: number;
|
||||
};
|
||||
|
||||
function parseAgentStdout(stdout: string): AdapterOutput {
|
||||
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
throw new Error(`agent stdout last line is not valid JSON: ${line || "(empty)"}`);
|
||||
}
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (
|
||||
typeof obj !== "object" ||
|
||||
obj === null ||
|
||||
typeof obj.stepHash !== "string" ||
|
||||
!isCasRef(obj.stepHash as string)
|
||||
) {
|
||||
throw new Error(`agent stdout JSON missing valid stepHash: ${line}`);
|
||||
}
|
||||
return obj as unknown as AdapterOutput;
|
||||
}
|
||||
|
||||
const VALID_OUTPUT: AdapterOutput = {
|
||||
stepHash: "0123456789ABC",
|
||||
detailHash: "DEFGH12345678",
|
||||
role: "planner",
|
||||
frontmatter: { $status: "ready", plan: "somehash" },
|
||||
body: "Plan body",
|
||||
startedAtMs: 1000,
|
||||
completedAtMs: 2000,
|
||||
};
|
||||
|
||||
describe("spawnAgent JSON parsing", () => {
|
||||
test("B1. parses valid JSON from agent stdout", () => {
|
||||
const stdout = `${JSON.stringify(VALID_OUTPUT)}\n`;
|
||||
const result = parseAgentStdout(stdout);
|
||||
expect(result.stepHash).toBe("0123456789ABC");
|
||||
expect(result.detailHash).toBe("DEFGH12345678");
|
||||
expect(result.role).toBe("planner");
|
||||
expect(result.frontmatter).toEqual({ $status: "ready", plan: "somehash" });
|
||||
expect(result.body).toBe("Plan body");
|
||||
expect(result.startedAtMs).toBe(1000);
|
||||
expect(result.completedAtMs).toBe(2000);
|
||||
});
|
||||
|
||||
test("B2. extracts stepHash for head pointer", () => {
|
||||
const stdout = `${JSON.stringify(VALID_OUTPUT)}\n`;
|
||||
const result = parseAgentStdout(stdout);
|
||||
expect(result.stepHash).toBe("0123456789ABC");
|
||||
expect(isCasRef(result.stepHash)).toBe(true);
|
||||
});
|
||||
|
||||
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 result = parseAgentStdout(stdout);
|
||||
expect(result.stepHash).toBe("0123456789ABC");
|
||||
});
|
||||
|
||||
test("B4. rejects non-JSON last line", () => {
|
||||
const stdout = "not-json-at-all\n";
|
||||
expect(() => parseAgentStdout(stdout)).toThrow("not valid JSON");
|
||||
});
|
||||
|
||||
test("B5. rejects JSON missing stepHash", () => {
|
||||
const incomplete = { detailHash: "DEFGH12345678", role: "planner" };
|
||||
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`;
|
||||
expect(() => parseAgentStdout(stdout)).toThrow("missing valid stepHash");
|
||||
});
|
||||
});
|
||||
@@ -66,13 +66,21 @@ 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 ───────────────────────────────────────────────────────────
|
||||
@@ -80,7 +88,10 @@ 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);
|
||||
@@ -146,10 +157,11 @@ describe("step read", () => {
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
// Read step with large quota
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 10000);
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 10000, false);
|
||||
|
||||
// Assert structure
|
||||
expect(markdown).toContain(`# Step ${stepHash}`);
|
||||
@@ -165,7 +177,9 @@ 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);
|
||||
@@ -231,10 +245,11 @@ 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);
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 700, false);
|
||||
|
||||
// Assert only most recent turns fit
|
||||
expect(markdown).toContain(`# Step ${stepHash}`);
|
||||
@@ -248,7 +263,9 @@ 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);
|
||||
@@ -310,10 +327,11 @@ 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);
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 1, false);
|
||||
|
||||
// Assert at least one turn is always shown
|
||||
expect(markdown).toContain("LongTurn");
|
||||
@@ -322,7 +340,9 @@ 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);
|
||||
|
||||
@@ -365,10 +385,11 @@ 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);
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 4000, false);
|
||||
|
||||
// Assert metadata is present
|
||||
expect(markdown).toContain(`# Step ${stepHash}`);
|
||||
@@ -380,7 +401,9 @@ 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);
|
||||
@@ -441,10 +464,11 @@ 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);
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 4000, false);
|
||||
|
||||
// Assert metadata is present
|
||||
expect(markdown).toContain(`# Step ${stepHash}`);
|
||||
@@ -455,7 +479,9 @@ 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);
|
||||
@@ -515,9 +541,10 @@ describe("step read", () => {
|
||||
agent: "uwf-hermes",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 4000, false);
|
||||
|
||||
expect(markdown).toContain("**Turn role:** assistant");
|
||||
expect(markdown).toContain("**terminal**");
|
||||
@@ -526,7 +553,9 @@ 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);
|
||||
@@ -588,10 +617,11 @@ describe("step read", () => {
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
// Read step
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 4000, false);
|
||||
|
||||
// Assert content is rendered correctly without corruption
|
||||
expect(markdown).toContain("`backticks`");
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
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 "@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";
|
||||
import { formatOutput } from "../format.js";
|
||||
import { registerUwfSchemas } from "../schemas.js";
|
||||
|
||||
const TURN_SCHEMA: JSONSchema = {
|
||||
title: "test-turn",
|
||||
type: "object",
|
||||
required: ["index", "role", "content"],
|
||||
properties: {
|
||||
index: { type: "integer" },
|
||||
role: { type: "string", enum: ["assistant", "tool"] },
|
||||
content: { type: "string" },
|
||||
toolCalls: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
required: ["name", "args"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
args: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{ type: "null" },
|
||||
],
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const DETAIL_SCHEMA: JSONSchema = {
|
||||
title: "test-detail",
|
||||
type: "object",
|
||||
required: ["turns"],
|
||||
properties: {
|
||||
turns: {
|
||||
type: "array",
|
||||
items: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
type TestSetup = {
|
||||
store: ReturnType<typeof createFsStore>;
|
||||
schemas: {
|
||||
workflow: Hash;
|
||||
startNode: Hash;
|
||||
stepNode: Hash;
|
||||
text: Hash;
|
||||
};
|
||||
turnType: Hash;
|
||||
detailType: Hash;
|
||||
};
|
||||
|
||||
async function setupTest(casDir: string): Promise<TestSetup> {
|
||||
const store = createFsStore(casDir);
|
||||
await bootstrap(store);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const [turnType, detailType] = await Promise.all([
|
||||
putSchema(store, TURN_SCHEMA),
|
||||
putSchema(store, DETAIL_SCHEMA),
|
||||
]);
|
||||
return { store, schemas, turnType, detailType };
|
||||
}
|
||||
|
||||
async function createTestStep(
|
||||
setup: TestSetup,
|
||||
turnPayloads: Array<{
|
||||
index: number;
|
||||
role: string;
|
||||
content: string;
|
||||
toolCalls: Array<{ name: string; args: string }> | null;
|
||||
}>,
|
||||
): Promise<CasRef> {
|
||||
const { store, schemas, turnType, detailType } = setup;
|
||||
|
||||
// Create turn nodes
|
||||
const turnHashes: CasRef[] = [];
|
||||
for (const payload of turnPayloads) {
|
||||
const turnHash = await store.put(turnType, payload);
|
||||
turnHashes.push(turnHash);
|
||||
}
|
||||
|
||||
// Create detail node
|
||||
const detailHash = await store.put(detailType, { turns: turnHashes });
|
||||
|
||||
// Create dummy start node
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: "0000000000000" as CasRef,
|
||||
prompt: "test prompt",
|
||||
cwd: "/tmp",
|
||||
});
|
||||
|
||||
// Create dummy output node
|
||||
const outputHash = await store.put(schemas.text, { $status: "done" });
|
||||
|
||||
// Create step node
|
||||
const stepPayload: StepNodePayload = {
|
||||
prev: null,
|
||||
start: startHash,
|
||||
role: "test-role",
|
||||
agent: "test-agent",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
edgePrompt: "",
|
||||
startedAtMs: Date.now(),
|
||||
completedAtMs: Date.now() + 1000,
|
||||
assembledPrompt: null,
|
||||
cwd: "/tmp",
|
||||
};
|
||||
return store.put(schemas.stepNode, stepPayload);
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const setup = await setupTest(casDir);
|
||||
const stepHash = await createTestStep(setup, [
|
||||
{
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: "Running command",
|
||||
toolCalls: [
|
||||
{
|
||||
name: "Bash",
|
||||
args: "echo 'line1'\necho 'line2'",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await cmdStepShow(testDir, stepHash);
|
||||
const jsonOutput = formatOutput(result, "json");
|
||||
|
||||
expect(() => JSON.parse(jsonOutput)).not.toThrow();
|
||||
expect(jsonOutput).toContain("\\n");
|
||||
|
||||
const parsed = JSON.parse(jsonOutput);
|
||||
expect(parsed.turns[0].toolCalls[0].args).toContain("\n");
|
||||
});
|
||||
|
||||
test("escapes tabs in tool call args", async () => {
|
||||
const setup = await setupTest(casDir);
|
||||
const stepHash = await createTestStep(setup, [
|
||||
{
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
toolCalls: [
|
||||
{
|
||||
name: "Bash",
|
||||
args: "cat <<EOF\nfield1\tfield2\tfield3\nEOF",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await cmdStepShow(testDir, stepHash);
|
||||
const jsonOutput = formatOutput(result, "json");
|
||||
|
||||
expect(() => JSON.parse(jsonOutput)).not.toThrow();
|
||||
expect(jsonOutput).toContain("\\t");
|
||||
});
|
||||
|
||||
test("escapes carriage returns", async () => {
|
||||
const setup = await setupTest(casDir);
|
||||
const stepHash = await createTestStep(setup, [
|
||||
{
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: "Committing changes",
|
||||
toolCalls: [
|
||||
{
|
||||
name: "Bash",
|
||||
args: 'git commit -m "First line\r\nSecond line"',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await cmdStepShow(testDir, stepHash);
|
||||
const jsonOutput = formatOutput(result, "json");
|
||||
|
||||
expect(() => JSON.parse(jsonOutput)).not.toThrow();
|
||||
expect(jsonOutput).toContain("\\r\\n");
|
||||
});
|
||||
|
||||
test("escapes backslashes and quotes", async () => {
|
||||
const setup = await setupTest(casDir);
|
||||
const stepHash = await createTestStep(setup, [
|
||||
{
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
toolCalls: [
|
||||
{
|
||||
name: "Bash",
|
||||
args: 'echo "He said \\"hello\\""',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await cmdStepShow(testDir, stepHash);
|
||||
const jsonOutput = formatOutput(result, "json");
|
||||
|
||||
expect(() => JSON.parse(jsonOutput)).not.toThrow();
|
||||
const parsed = JSON.parse(jsonOutput);
|
||||
expect(parsed.turns).toBeDefined();
|
||||
});
|
||||
|
||||
test("handles Unicode control characters", async () => {
|
||||
const setup = await setupTest(casDir);
|
||||
const stepHash = await createTestStep(setup, [
|
||||
{
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
toolCalls: [
|
||||
{
|
||||
name: "Bash",
|
||||
args: "echo '\u0001\u001F'",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await cmdStepShow(testDir, stepHash);
|
||||
const jsonOutput = formatOutput(result, "json");
|
||||
|
||||
expect(() => JSON.parse(jsonOutput)).not.toThrow();
|
||||
});
|
||||
|
||||
test("handles nested CAS refs with control characters", async () => {
|
||||
const setup = await setupTest(casDir);
|
||||
const stepHash = await createTestStep(setup, [
|
||||
{
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: "First turn\nwith newline",
|
||||
toolCalls: [
|
||||
{
|
||||
name: "Bash",
|
||||
args: "cmd1\nline2",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
role: "assistant",
|
||||
content: "Second turn\twith tab",
|
||||
toolCalls: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await cmdStepShow(testDir, stepHash);
|
||||
const jsonOutput = formatOutput(result, "json");
|
||||
|
||||
expect(() => JSON.parse(jsonOutput)).not.toThrow();
|
||||
const parsed = JSON.parse(jsonOutput);
|
||||
expect(parsed.turns).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("YAML output format is unaffected", async () => {
|
||||
const setup = await setupTest(casDir);
|
||||
const stepHash = await createTestStep(setup, [
|
||||
{
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: "Running command",
|
||||
toolCalls: [
|
||||
{
|
||||
name: "Bash",
|
||||
args: "echo 'line1'\necho 'line2'",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await cmdStepShow(testDir, stepHash);
|
||||
const yamlOutput = formatOutput(result, "yaml");
|
||||
|
||||
expect(yamlOutput).toContain("turns:");
|
||||
expect(yamlOutput.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("handles empty and null values", async () => {
|
||||
const setup = await setupTest(casDir);
|
||||
const stepHash = await createTestStep(setup, [
|
||||
{
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
toolCalls: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await cmdStepShow(testDir, stepHash);
|
||||
const jsonOutput = formatOutput(result, "json");
|
||||
|
||||
expect(() => JSON.parse(jsonOutput)).not.toThrow();
|
||||
const parsed = JSON.parse(jsonOutput);
|
||||
expect(parsed.turns).toBeDefined();
|
||||
});
|
||||
|
||||
test("handles large step with multiple tool calls", async () => {
|
||||
const setup = await setupTest(casDir);
|
||||
|
||||
const turns = [];
|
||||
for (let i = 0; i < 25; i++) {
|
||||
turns.push({
|
||||
index: i,
|
||||
role: "assistant" as const,
|
||||
content: `Turn ${i}\nwith newline`,
|
||||
toolCalls: [
|
||||
{
|
||||
name: "Bash",
|
||||
args: `command${i}\nline2\tfield${i}`,
|
||||
},
|
||||
{
|
||||
name: "Read",
|
||||
args: `/path/to/file${i}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const stepHash = await createTestStep(setup, turns);
|
||||
|
||||
const startTime = Date.now();
|
||||
const result = await cmdStepShow(testDir, stepHash);
|
||||
const jsonOutput = formatOutput(result, "json");
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(duration).toBeLessThan(2000);
|
||||
expect(() => JSON.parse(jsonOutput)).not.toThrow();
|
||||
|
||||
const parsed = JSON.parse(jsonOutput);
|
||||
expect(parsed.turns).toHaveLength(25);
|
||||
});
|
||||
});
|
||||
@@ -63,13 +63,22 @@ 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) ─────────────────────────────────────────
|
||||
@@ -85,6 +94,8 @@ describe("protocol types", () => {
|
||||
edgePrompt: "",
|
||||
startedAtMs: 1000,
|
||||
completedAtMs: 2000,
|
||||
assembledPrompt: null,
|
||||
cwd: "/test/path",
|
||||
};
|
||||
expect(record.startedAtMs).toBe(1000);
|
||||
expect(record.completedAtMs).toBe(2000);
|
||||
@@ -152,6 +163,7 @@ describe("StepNode JSON schema", () => {
|
||||
edgePrompt: "",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
expect(hash).toBeTruthy();
|
||||
});
|
||||
@@ -239,8 +251,8 @@ describe("thread read timing", () => {
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: { _: { role: "worker", prompt: "go" } },
|
||||
worker: { _: { role: "$END", prompt: "" } },
|
||||
$START: { _: { role: "worker", prompt: "go", location: null } },
|
||||
worker: { _: { role: "$END", prompt: "", location: null } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -305,8 +317,8 @@ describe("thread read timing", () => {
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: { _: { role: "worker", prompt: "go" } },
|
||||
worker: { _: { role: "$END", prompt: "" } },
|
||||
$START: { _: { role: "worker", prompt: "go", location: null } },
|
||||
worker: { _: { role: "$END", prompt: "", location: null } },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
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,6 +15,8 @@ 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { CasRef, StartNodePayload, ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { cmdThreadStart } from "../commands/thread.js";
|
||||
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 () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowYaml = `
|
||||
name: test-location
|
||||
description: Test workflow for location feature
|
||||
roles:
|
||||
planner:
|
||||
description: Plans the work
|
||||
goal: Plan implementation
|
||||
capabilities: ["planning"]
|
||||
procedure: Plan
|
||||
output: |
|
||||
$status: "ready"
|
||||
frontmatter:
|
||||
type: object
|
||||
required: ["$status"]
|
||||
properties:
|
||||
$status: { type: string }
|
||||
graph:
|
||||
$START:
|
||||
_:
|
||||
role: planner
|
||||
prompt: "Plan the work"
|
||||
location: null
|
||||
planner:
|
||||
_:
|
||||
role: $END
|
||||
prompt: "Done"
|
||||
location: null
|
||||
`;
|
||||
|
||||
const workflowPath = join(tmpDir, "test-location.yaml");
|
||||
await writeFile(workflowPath, workflowYaml, "utf8");
|
||||
|
||||
const testCwd = "/test/project/path";
|
||||
const result = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir, testCwd);
|
||||
|
||||
expect(result.thread).toBeDefined();
|
||||
expect(result.workflow).toBeDefined();
|
||||
|
||||
// Verify StartNode has the cwd field
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
|
||||
const headHash = index[result.thread as ThreadId];
|
||||
expect(headHash).toBeDefined();
|
||||
|
||||
const startNode = uwf.store.get(headHash as CasRef);
|
||||
expect(startNode).not.toBe(null);
|
||||
expect(startNode?.type).toBe(uwf.schemas.startNode);
|
||||
|
||||
const startPayload = startNode?.payload as StartNodePayload;
|
||||
expect(startPayload.cwd).toBe(testCwd);
|
||||
|
||||
await teardown();
|
||||
});
|
||||
|
||||
test("thread start validates cwd is absolute path", async () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowYaml = `
|
||||
name: test-location
|
||||
description: Test workflow
|
||||
roles:
|
||||
planner:
|
||||
description: Plans
|
||||
goal: Plan
|
||||
capabilities: ["planning"]
|
||||
procedure: Plan
|
||||
output: |
|
||||
$status: "ready"
|
||||
frontmatter:
|
||||
type: object
|
||||
required: ["$status"]
|
||||
properties:
|
||||
$status: { type: string }
|
||||
graph:
|
||||
$START:
|
||||
_:
|
||||
role: planner
|
||||
prompt: "Plan"
|
||||
location: null
|
||||
planner:
|
||||
_:
|
||||
role: $END
|
||||
prompt: "Done"
|
||||
location: null
|
||||
`;
|
||||
|
||||
const workflowPath = join(tmpDir, "test-location.yaml");
|
||||
await writeFile(workflowPath, workflowYaml, "utf8");
|
||||
|
||||
// Relative path should fail (process.exit is wrapped by vitest)
|
||||
await expect(
|
||||
cmdThreadStart(storageRoot, workflowPath, "test", tmpDir, "relative/path"),
|
||||
).rejects.toThrow();
|
||||
|
||||
await teardown();
|
||||
});
|
||||
|
||||
test("thread start uses process.cwd() as default", async () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowYaml = `
|
||||
name: test-default-cwd
|
||||
description: Test default cwd
|
||||
roles:
|
||||
planner:
|
||||
description: Plans
|
||||
goal: Plan
|
||||
capabilities: ["planning"]
|
||||
procedure: Plan
|
||||
output: |
|
||||
$status: "ready"
|
||||
frontmatter:
|
||||
type: object
|
||||
required: ["$status"]
|
||||
properties:
|
||||
$status: { type: string }
|
||||
graph:
|
||||
$START:
|
||||
_:
|
||||
role: planner
|
||||
prompt: "Plan"
|
||||
location: null
|
||||
planner:
|
||||
_:
|
||||
role: $END
|
||||
prompt: "Done"
|
||||
location: null
|
||||
`;
|
||||
|
||||
const workflowPath = join(tmpDir, "test-default-cwd.yaml");
|
||||
await writeFile(workflowPath, workflowYaml, "utf8");
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, workflowPath, "test", tmpDir);
|
||||
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
|
||||
const headHash = index[result.thread as ThreadId];
|
||||
|
||||
const startNode = uwf.store.get(headHash as CasRef);
|
||||
const startPayload = startNode?.payload as StartNodePayload;
|
||||
|
||||
// Should default to process.cwd()
|
||||
expect(startPayload.cwd).toBe(process.cwd());
|
||||
|
||||
await teardown();
|
||||
});
|
||||
});
|
||||
@@ -67,13 +67,22 @@ 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 ─────────────────────────────────────────────
|
||||
@@ -143,6 +152,7 @@ describe("thread read --quota flag", () => {
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
steps.push(stepHash);
|
||||
}
|
||||
@@ -225,6 +235,7 @@ describe("thread read --quota flag", () => {
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const step2Content = generateContent(600, "Second");
|
||||
@@ -251,6 +262,7 @@ describe("thread read --quota flag", () => {
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const threadId = "01HX2Q3R4S5T6V7W8X9YZ1" as ThreadId;
|
||||
@@ -336,6 +348,7 @@ describe("thread read --quota flag", () => {
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
steps.push(stepHash);
|
||||
}
|
||||
@@ -415,6 +428,7 @@ describe("thread read --quota flag", () => {
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const threadId = "01HX2Q3R4S5T6V7W8X9YZ4" as ThreadId;
|
||||
@@ -492,6 +506,7 @@ describe("thread read --quota flag", () => {
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
steps.push(stepHash);
|
||||
}
|
||||
@@ -573,6 +588,7 @@ describe("thread read --quota flag", () => {
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
steps.push(stepHash);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ 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 };
|
||||
@@ -141,6 +143,7 @@ describe("thread read XML tag isolation", () => {
|
||||
agent: "uwf-claude-code",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000001" as ThreadId;
|
||||
@@ -218,6 +221,7 @@ describe("thread read XML tag isolation", () => {
|
||||
agent: "uwf-claude-code",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000002" as ThreadId;
|
||||
@@ -280,6 +284,7 @@ describe("thread read XML tag isolation", () => {
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
|
||||
@@ -291,6 +296,7 @@ describe("thread read XML tag isolation", () => {
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000003" as ThreadId;
|
||||
@@ -345,6 +351,7 @@ describe("thread read XML tag isolation", () => {
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000004" as ThreadId;
|
||||
@@ -399,6 +406,7 @@ describe("thread read XML tag isolation", () => {
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000005" as ThreadId;
|
||||
@@ -453,6 +461,7 @@ describe("thread read XML tag isolation", () => {
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000006" as ThreadId;
|
||||
@@ -527,6 +536,7 @@ describe("thread read XML tag isolation", () => {
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
|
||||
@@ -538,6 +548,7 @@ describe("thread read XML tag isolation", () => {
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const step3 = await uwf.store.put(uwf.schemas.stepNode, {
|
||||
@@ -549,6 +560,7 @@ describe("thread read XML tag isolation", () => {
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000007" as ThreadId;
|
||||
@@ -629,6 +641,7 @@ describe("thread read XML tag isolation", () => {
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
});
|
||||
|
||||
const threadId = "01JTEST0000000000000008" as ThreadId;
|
||||
@@ -685,6 +698,7 @@ describe("thread read XML tag isolation", () => {
|
||||
agent: "uwf-test",
|
||||
startedAtMs: 1000000000000,
|
||||
completedAtMs: 1000000005000,
|
||||
assembledPrompt: null,
|
||||
})) as CasRef;
|
||||
steps.push(step);
|
||||
prev = step;
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { createMarker, deleteMarker } from "../background/index.js";
|
||||
import { cmdThreadShow, cmdThreadStart } from "../commands/thread.js";
|
||||
import { appendThreadHistory, loadThreadsIndex } from "../store.js";
|
||||
|
||||
const TEST_WORKFLOW_YAML = `
|
||||
name: test-status
|
||||
description: Test workflow for status field
|
||||
roles:
|
||||
planner:
|
||||
description: Plans the work
|
||||
goal: Plan implementation
|
||||
capabilities: ["planning"]
|
||||
procedure: Plan
|
||||
output: |
|
||||
$status: "ready"
|
||||
frontmatter:
|
||||
type: object
|
||||
required: ["$status"]
|
||||
properties:
|
||||
$status: { type: string }
|
||||
graph:
|
||||
$START:
|
||||
_:
|
||||
role: planner
|
||||
prompt: "Plan the work"
|
||||
location: null
|
||||
planner:
|
||||
_:
|
||||
role: $END
|
||||
prompt: "Done"
|
||||
location: null
|
||||
`;
|
||||
|
||||
describe("thread show status field", () => {
|
||||
let tmpDir: string;
|
||||
let storageRoot: string;
|
||||
|
||||
async function setupTestEnv() {
|
||||
tmpDir = join(tmpdir(), `uwf-test-status-${Date.now()}`);
|
||||
storageRoot = join(tmpDir, "storage");
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
}
|
||||
|
||||
async function teardown() {
|
||||
if (tmpDir) {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("active idle thread shows status 'idle'", async () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowPath = join(tmpDir, "test-status.yaml");
|
||||
await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8");
|
||||
|
||||
// Create a thread
|
||||
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
|
||||
const threadId = startResult.thread as ThreadId;
|
||||
|
||||
// Show the thread (should be idle)
|
||||
const result = await cmdThreadShow(storageRoot, threadId);
|
||||
|
||||
expect(result.status).toBe("idle");
|
||||
expect(result.done).toBe(false);
|
||||
expect(result.background).toBe(null);
|
||||
expect(result.thread).toBe(threadId);
|
||||
|
||||
await teardown();
|
||||
});
|
||||
|
||||
test("active running thread shows status 'running'", async () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowPath = join(tmpDir, "test-status.yaml");
|
||||
await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8");
|
||||
|
||||
// Create a thread
|
||||
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
|
||||
const threadId = startResult.thread as ThreadId;
|
||||
const workflow = startResult.workflow;
|
||||
|
||||
// Create a running marker
|
||||
await createMarker(storageRoot, {
|
||||
thread: threadId,
|
||||
workflow,
|
||||
pid: process.pid,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await cmdThreadShow(storageRoot, threadId);
|
||||
|
||||
expect(result.status).toBe("running");
|
||||
expect(result.done).toBe(false);
|
||||
expect(result.background).toBe(null);
|
||||
expect(result.thread).toBe(threadId);
|
||||
} finally {
|
||||
// Cleanup: delete marker
|
||||
await deleteMarker(storageRoot, threadId);
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
test("completed thread shows status 'completed'", async () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowPath = join(tmpDir, "test-status.yaml");
|
||||
await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8");
|
||||
|
||||
// Create a thread
|
||||
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
|
||||
const threadId = startResult.thread as ThreadId;
|
||||
const workflow = startResult.workflow;
|
||||
|
||||
// Get the head hash before moving to history
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const head = index[threadId];
|
||||
if (!head) throw new Error("Thread not found in index");
|
||||
|
||||
// Move thread to history with reason 'completed'
|
||||
const { saveThreadsIndex } = await import("../store.js");
|
||||
const newIndex = { ...index };
|
||||
delete newIndex[threadId];
|
||||
await saveThreadsIndex(storageRoot, newIndex);
|
||||
|
||||
await appendThreadHistory(storageRoot, {
|
||||
thread: threadId,
|
||||
workflow,
|
||||
head,
|
||||
completedAt: Date.now(),
|
||||
reason: "completed",
|
||||
});
|
||||
|
||||
const result = await cmdThreadShow(storageRoot, threadId);
|
||||
|
||||
expect(result.status).toBe("completed");
|
||||
expect(result.done).toBe(true);
|
||||
expect(result.background).toBe(null);
|
||||
expect(result.thread).toBe(threadId);
|
||||
|
||||
await teardown();
|
||||
});
|
||||
|
||||
test("cancelled thread shows status 'cancelled'", async () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowPath = join(tmpDir, "test-status.yaml");
|
||||
await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8");
|
||||
|
||||
// Create a thread
|
||||
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
|
||||
const threadId = startResult.thread as ThreadId;
|
||||
const workflow = startResult.workflow;
|
||||
|
||||
// Get the head hash before moving to history
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const head = index[threadId];
|
||||
if (!head) throw new Error("Thread not found in index");
|
||||
|
||||
// Move thread to history with reason 'cancelled'
|
||||
const { saveThreadsIndex } = await import("../store.js");
|
||||
const newIndex = { ...index };
|
||||
delete newIndex[threadId];
|
||||
await saveThreadsIndex(storageRoot, newIndex);
|
||||
|
||||
await appendThreadHistory(storageRoot, {
|
||||
thread: threadId,
|
||||
workflow,
|
||||
head,
|
||||
completedAt: Date.now(),
|
||||
reason: "cancelled",
|
||||
});
|
||||
|
||||
const result = await cmdThreadShow(storageRoot, threadId);
|
||||
|
||||
expect(result.status).toBe("cancelled");
|
||||
expect(result.done).toBe(true);
|
||||
expect(result.background).toBe(null);
|
||||
expect(result.thread).toBe(threadId);
|
||||
|
||||
await teardown();
|
||||
});
|
||||
|
||||
test("legacy completed thread without reason shows status 'completed'", async () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowPath = join(tmpDir, "test-status.yaml");
|
||||
await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8");
|
||||
|
||||
// Create a thread
|
||||
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
|
||||
const threadId = startResult.thread as ThreadId;
|
||||
const workflow = startResult.workflow;
|
||||
|
||||
// Get the head hash before moving to history
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const head = index[threadId];
|
||||
if (!head) throw new Error("Thread not found in index");
|
||||
|
||||
// Move thread to history with reason null (legacy format)
|
||||
const { saveThreadsIndex } = await import("../store.js");
|
||||
const newIndex = { ...index };
|
||||
delete newIndex[threadId];
|
||||
await saveThreadsIndex(storageRoot, newIndex);
|
||||
|
||||
await appendThreadHistory(storageRoot, {
|
||||
thread: threadId,
|
||||
workflow,
|
||||
head,
|
||||
completedAt: Date.now(),
|
||||
reason: null,
|
||||
});
|
||||
|
||||
const result = await cmdThreadShow(storageRoot, threadId);
|
||||
|
||||
expect(result.status).toBe("completed");
|
||||
expect(result.done).toBe(true);
|
||||
expect(result.background).toBe(null);
|
||||
|
||||
await teardown();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { CasRef, StartNodePayload, ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { cmdThreadStart } from "../commands/thread.js";
|
||||
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> {
|
||||
const workflowYaml = `
|
||||
name: test-cwd-cli
|
||||
description: Test workflow for CLI cwd option
|
||||
roles:
|
||||
planner:
|
||||
description: Plans the work
|
||||
goal: Plan implementation
|
||||
capabilities: ["planning"]
|
||||
procedure: Plan
|
||||
output: |
|
||||
$status: "ready"
|
||||
frontmatter:
|
||||
type: object
|
||||
required: ["$status"]
|
||||
properties:
|
||||
$status: { type: string }
|
||||
graph:
|
||||
$START:
|
||||
_:
|
||||
role: planner
|
||||
prompt: "Plan the work"
|
||||
location: null
|
||||
planner:
|
||||
_:
|
||||
role: $END
|
||||
prompt: "Done"
|
||||
location: null
|
||||
`;
|
||||
|
||||
const workflowPath = join(tmpDir, "test-cwd-cli.yaml");
|
||||
await writeFile(workflowPath, workflowYaml, "utf8");
|
||||
return workflowPath;
|
||||
}
|
||||
|
||||
async function getStartNodeCwd(threadId: string): Promise<string> {
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const headHash = index[threadId as ThreadId];
|
||||
expect(headHash).toBeDefined();
|
||||
|
||||
const startNode = uwf.store.get(headHash as CasRef);
|
||||
expect(startNode).not.toBe(null);
|
||||
expect(startNode?.type).toBe(uwf.schemas.startNode);
|
||||
|
||||
const startPayload = startNode?.payload as StartNodePayload;
|
||||
return startPayload.cwd;
|
||||
}
|
||||
|
||||
test("thread start with custom cwd via cmdThreadStart", async () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowPath = await createTestWorkflow();
|
||||
const testCwd = "/test/custom/path";
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir, testCwd);
|
||||
|
||||
expect(result.thread).toBeDefined();
|
||||
const actualCwd = await getStartNodeCwd(result.thread);
|
||||
expect(actualCwd).toBe(testCwd);
|
||||
|
||||
await teardown();
|
||||
});
|
||||
|
||||
test("thread start without cwd defaults to process.cwd()", async () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowPath = await createTestWorkflow();
|
||||
|
||||
// Call without cwd parameter (it defaults to process.cwd())
|
||||
const result = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
|
||||
|
||||
expect(result.thread).toBeDefined();
|
||||
const actualCwd = await getStartNodeCwd(result.thread);
|
||||
expect(actualCwd).toBe(process.cwd());
|
||||
|
||||
await teardown();
|
||||
});
|
||||
|
||||
test("thread start with relative path fails", async () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowPath = await createTestWorkflow();
|
||||
|
||||
await expect(
|
||||
cmdThreadStart(storageRoot, workflowPath, "test", tmpDir, "relative/path"),
|
||||
).rejects.toThrow();
|
||||
|
||||
await teardown();
|
||||
});
|
||||
|
||||
test("CLI accepts --cwd option without error", async () => {
|
||||
await setupTestEnv();
|
||||
|
||||
const workflowPath = await createTestWorkflow();
|
||||
const testCwd = "/test/cli/path";
|
||||
const uwfBin = join(process.cwd(), "dist", "cli.js");
|
||||
|
||||
// Register the workflow
|
||||
execFileSync("node", [uwfBin, "workflow", "add", workflowPath], {
|
||||
env: { ...process.env, UWF_STORAGE_ROOT: storageRoot, UNCAGED_CAS_DIR: casDir },
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
// Verify CLI accepts --cwd option (no error thrown)
|
||||
const output = execFileSync(
|
||||
"node",
|
||||
[uwfBin, "thread", "start", "test-cwd-cli", "-p", "test prompt", "--cwd", testCwd],
|
||||
{
|
||||
env: { ...process.env, UWF_STORAGE_ROOT: storageRoot, UNCAGED_CAS_DIR: casDir },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
|
||||
const result = JSON.parse(output);
|
||||
expect(result.thread).toBeDefined();
|
||||
expect(result.workflow).toBeDefined();
|
||||
|
||||
// The fact that we got here without throwing means CLI accepted the --cwd option
|
||||
// The actual cwd functionality is tested by the other tests using cmdThreadStart directly
|
||||
await teardown();
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,8 @@ 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 };
|
||||
|
||||
@@ -51,11 +51,11 @@ function makeWorkflow(overrides?: Partial<WorkflowPayload>): WorkflowPayload {
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: { _: { role: "writer", prompt: "Begin writing" } },
|
||||
writer: { _: { role: "reviewer", prompt: "Review this: {{{plan}}}" } },
|
||||
$START: { _: { role: "writer", prompt: "Begin writing", location: null } },
|
||||
writer: { _: { role: "reviewer", prompt: "Review this: {{{plan}}}", location: null } },
|
||||
reviewer: {
|
||||
approved: { role: "$END", prompt: "Done: {{{summary}}}" },
|
||||
rejected: { role: "writer", prompt: "Fix: {{{reason}}}" },
|
||||
approved: { role: "$END", prompt: "Done: {{{summary}}}", location: null },
|
||||
rejected: { role: "writer", prompt: "Fix: {{{reason}}}", location: null },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -67,7 +67,7 @@ function makeWorkflow(overrides?: Partial<WorkflowPayload>): WorkflowPayload {
|
||||
describe("Suite 1: Role Reference Integrity", () => {
|
||||
test("1.1 graph references unknown role", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.nonexistent = { _: { role: "$END", prompt: "done" } };
|
||||
wf.graph.nonexistent = { _: { role: "$END", prompt: "done", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes('unknown role "nonexistent"'))).toBe(true);
|
||||
});
|
||||
@@ -138,8 +138,8 @@ describe("Suite 2: Graph Structure", () => {
|
||||
test("2.2 $START has multiple status keys", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.$START = {
|
||||
_: { role: "writer", prompt: "Begin" },
|
||||
other: { role: "reviewer", prompt: "Also" },
|
||||
_: { role: "writer", prompt: "Begin", location: null },
|
||||
other: { role: "reviewer", prompt: "Also", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(
|
||||
@@ -149,7 +149,7 @@ describe("Suite 2: Graph Structure", () => {
|
||||
|
||||
test("2.3 $START edge uses non-_ status", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.$START = { ready: { role: "writer", prompt: "Begin" } };
|
||||
wf.graph.$START = { ready: { role: "writer", prompt: "Begin", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(
|
||||
errors.some((e) => e.includes('$START must have exactly one edge with status "_"')),
|
||||
@@ -158,7 +158,7 @@ describe("Suite 2: Graph Structure", () => {
|
||||
|
||||
test("2.4 $END has outgoing edges", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.$END = { _: { role: "writer", prompt: "Loop" } };
|
||||
wf.graph.$END = { _: { role: "writer", prompt: "Loop", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes("$END must not have outgoing edges"))).toBe(true);
|
||||
});
|
||||
@@ -177,7 +177,7 @@ describe("Suite 2: Graph Structure", () => {
|
||||
required: ["$status"],
|
||||
} as unknown as string,
|
||||
};
|
||||
wf.graph.isolated = { _: { role: "$END", prompt: "done" } };
|
||||
wf.graph.isolated = { _: { role: "$END", prompt: "done", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes('role "isolated" is not reachable from $START'))).toBe(
|
||||
true,
|
||||
@@ -186,7 +186,7 @@ describe("Suite 2: Graph Structure", () => {
|
||||
|
||||
test("2.6 edge target references invalid role", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.writer = { _: { role: "ghost", prompt: "Go to ghost" } };
|
||||
wf.graph.writer = { _: { role: "ghost", prompt: "Go to ghost", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes('unknown target role "ghost"'))).toBe(true);
|
||||
});
|
||||
@@ -196,8 +196,8 @@ describe("Suite 3: Status-Edge Consistency", () => {
|
||||
test("3.1 single-exit role with multiple graph keys", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.writer = {
|
||||
_: { role: "reviewer", prompt: "Review" },
|
||||
extra: { role: "$END", prompt: "Done" },
|
||||
_: { role: "reviewer", prompt: "Review", location: null },
|
||||
extra: { role: "$END", prompt: "Done", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(
|
||||
@@ -209,7 +209,7 @@ describe("Suite 3: Status-Edge Consistency", () => {
|
||||
|
||||
test("3.2 single-exit role missing _ key", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.writer = { done: { role: "reviewer", prompt: "Review" } };
|
||||
wf.graph.writer = { done: { role: "reviewer", prompt: "Review", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(
|
||||
errors.some((e) => e.includes('role "writer" is single-exit but graph has no "_" key')),
|
||||
@@ -219,9 +219,9 @@ describe("Suite 3: Status-Edge Consistency", () => {
|
||||
test("3.3 multi-exit role with extra statuses", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.reviewer = {
|
||||
approved: { role: "$END", prompt: "Done" },
|
||||
rejected: { role: "writer", prompt: "Fix" },
|
||||
timeout: { role: "$END", prompt: "Timed out" },
|
||||
approved: { role: "$END", prompt: "Done", location: null },
|
||||
rejected: { role: "writer", prompt: "Fix", location: null },
|
||||
timeout: { role: "$END", prompt: "Timed out", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(
|
||||
@@ -232,7 +232,7 @@ describe("Suite 3: Status-Edge Consistency", () => {
|
||||
test("3.4 multi-exit role missing a status", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.reviewer = {
|
||||
approved: { role: "$END", prompt: "Done" },
|
||||
approved: { role: "$END", prompt: "Done", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(
|
||||
@@ -242,7 +242,7 @@ describe("Suite 3: Status-Edge Consistency", () => {
|
||||
|
||||
test("3.5 multi-exit role with _ key", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.reviewer = { _: { role: "$END", prompt: "Done" } };
|
||||
wf.graph.reviewer = { _: { role: "$END", prompt: "Done", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes('role "reviewer" is multi-exit but graph uses "_"'))).toBe(
|
||||
true,
|
||||
@@ -265,8 +265,8 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
|
||||
} as unknown as string,
|
||||
};
|
||||
wf.graph.reviewer = {
|
||||
approved: { role: "$END", prompt: "Done" },
|
||||
rejected: { role: "writer", prompt: "Fix: {{{comments}}}" },
|
||||
approved: { role: "$END", prompt: "Done", location: null },
|
||||
rejected: { role: "writer", prompt: "Fix: {{{comments}}}", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors).toEqual([]);
|
||||
@@ -286,9 +286,9 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
|
||||
} as unknown as string,
|
||||
};
|
||||
wf.graph.reviewer = {
|
||||
approved: { role: "$END", prompt: "Done" },
|
||||
rejected: { role: "writer", prompt: "Fix" },
|
||||
timeout: { role: "$END", prompt: "Timed out" },
|
||||
approved: { role: "$END", prompt: "Done", location: null },
|
||||
rejected: { role: "writer", prompt: "Fix", location: null },
|
||||
timeout: { role: "$END", prompt: "Timed out", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes("extra status keys: timeout"))).toBe(true);
|
||||
@@ -308,7 +308,7 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
|
||||
} as unknown as string,
|
||||
};
|
||||
wf.graph.reviewer = {
|
||||
approved: { role: "$END", prompt: "Done" },
|
||||
approved: { role: "$END", prompt: "Done", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes("missing status keys: rejected"))).toBe(true);
|
||||
@@ -327,7 +327,7 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
|
||||
required: ["$status", "plan"],
|
||||
} as unknown as string,
|
||||
};
|
||||
wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{plan}}}" } };
|
||||
wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{plan}}}", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -346,8 +346,8 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
|
||||
} as unknown as string,
|
||||
};
|
||||
wf.graph.reviewer = {
|
||||
approved: { role: "$END", prompt: "Done: {{{nonexistent}}}" },
|
||||
rejected: { role: "writer", prompt: "Fix: {{{comments}}}" },
|
||||
approved: { role: "$END", prompt: "Done: {{{nonexistent}}}", location: null },
|
||||
rejected: { role: "writer", prompt: "Fix: {{{comments}}}", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.some((e) => e.includes("nonexistent") && e.includes("not found"))).toBe(true);
|
||||
@@ -357,7 +357,7 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
|
||||
describe("Suite 4: Mustache Template Variable Existence", () => {
|
||||
test("4.1 prompt references nonexistent variable (single-exit)", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{branch}}}" } };
|
||||
wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{branch}}}", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(
|
||||
errors.some((e) =>
|
||||
@@ -369,8 +369,8 @@ describe("Suite 4: Mustache Template Variable Existence", () => {
|
||||
test("4.2 prompt references nonexistent variable (multi-exit)", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.reviewer = {
|
||||
approved: { role: "$END", prompt: "Done: {{{branch}}}" },
|
||||
rejected: { role: "writer", prompt: "Fix: {{{reason}}}" },
|
||||
approved: { role: "$END", prompt: "Done: {{{branch}}}", location: null },
|
||||
rejected: { role: "writer", prompt: "Fix: {{{reason}}}", location: null },
|
||||
};
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(
|
||||
@@ -388,7 +388,7 @@ describe("Suite 4: Mustache Template Variable Existence", () => {
|
||||
|
||||
test("4.4 $status variable is always valid", () => {
|
||||
const wf = makeWorkflow();
|
||||
wf.graph.writer = { _: { role: "reviewer", prompt: "Status: {{$status}}" } };
|
||||
wf.graph.writer = { _: { role: "reviewer", prompt: "Status: {{$status}}", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -461,9 +461,9 @@ describe("Suite 6: Multiple Errors Collection", () => {
|
||||
} as unknown as string,
|
||||
};
|
||||
// unknown graph reference
|
||||
wf.graph.nonexistent = { _: { role: "$END", prompt: "done" } };
|
||||
wf.graph.nonexistent = { _: { role: "$END", prompt: "done", location: null } };
|
||||
// bad mustache var
|
||||
wf.graph.writer = { _: { role: "reviewer", prompt: "{{{badvar}}}" } };
|
||||
wf.graph.writer = { _: { role: "reviewer", prompt: "{{{badvar}}}", location: null } };
|
||||
const errors = validateWorkflow(wf);
|
||||
expect(errors.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ 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 };
|
||||
@@ -41,8 +43,8 @@ function makeMinimalPayload(name: string, description: string): WorkflowPayload
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: { _: { role: "worker", prompt: "start working" } },
|
||||
worker: { _: { role: "$END", prompt: "done" } },
|
||||
$START: { _: { role: "worker", prompt: "start working", location: null } },
|
||||
worker: { _: { role: "$END", prompt: "done", location: null } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||
import type { CasRef, ThreadId, ThreadStatus } from "@uncaged/workflow-protocol";
|
||||
import { Command } from "commander";
|
||||
import {
|
||||
cmdCasGet,
|
||||
@@ -17,16 +17,12 @@ 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,
|
||||
cmdSkillCli,
|
||||
cmdSkillBootstrap,
|
||||
cmdSkillDeveloper,
|
||||
cmdSkillList,
|
||||
cmdSkillModerator,
|
||||
cmdSkillUser,
|
||||
cmdSkillYaml,
|
||||
} from "./commands/skill.js";
|
||||
import { cmdStepFork, cmdStepList, cmdStepRead, cmdStepShow } from "./commands/step.js";
|
||||
import {
|
||||
@@ -38,7 +34,6 @@ import {
|
||||
cmdThreadStart,
|
||||
cmdThreadStop,
|
||||
THREAD_READ_DEFAULT_QUOTA,
|
||||
type ThreadStatus,
|
||||
} from "./commands/thread.js";
|
||||
import { parseTimeInput } from "./commands/thread-time-parser.js";
|
||||
import { cmdWorkflowAdd, cmdWorkflowList, cmdWorkflowShow } from "./commands/workflow.js";
|
||||
@@ -118,10 +113,17 @@ thread
|
||||
.description("Create a thread without executing")
|
||||
.argument("<workflow>", "Workflow name or hash")
|
||||
.requiredOption("-p, --prompt <text>", "User prompt")
|
||||
.action((workflow: string, opts: { prompt: string }) => {
|
||||
.option("--cwd <path>", "Working directory for thread execution (default: process.cwd())")
|
||||
.action((workflow: string, opts: { prompt: string; cwd: string | undefined }) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdThreadStart(storageRoot, workflow, opts.prompt, process.cwd());
|
||||
const result = await cmdThreadStart(
|
||||
storageRoot,
|
||||
workflow,
|
||||
opts.prompt,
|
||||
process.cwd(),
|
||||
opts.cwd ?? process.cwd(),
|
||||
);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
@@ -362,7 +364,8 @@ 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")
|
||||
.action((stepHash: string, opts: { quota: string }) => {
|
||||
.option("--prompt", "Show the assembled prompt sent to the agent instead of turns")
|
||||
.action((stepHash: string, opts: { quota: string; prompt: boolean }) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const quota = Number.parseInt(opts.quota, 10);
|
||||
@@ -370,7 +373,12 @@ step
|
||||
process.stderr.write("invalid --quota: must be a positive integer\n");
|
||||
process.exit(1);
|
||||
}
|
||||
const markdown = await cmdStepRead(storageRoot, stepHash as CasRef, quota);
|
||||
const markdown = await cmdStepRead(
|
||||
storageRoot,
|
||||
stepHash as CasRef,
|
||||
quota,
|
||||
opts.prompt === true,
|
||||
);
|
||||
process.stdout.write(markdown.endsWith("\n") ? markdown : `${markdown}\n`);
|
||||
});
|
||||
});
|
||||
@@ -487,34 +495,6 @@ 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)")
|
||||
@@ -536,13 +516,6 @@ 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)")
|
||||
@@ -550,6 +523,13 @@ 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")
|
||||
@@ -564,7 +544,7 @@ program
|
||||
.option("--base-url <url>", "OpenAI-compatible API base URL")
|
||||
.option("--api-key <key>", "API key")
|
||||
.option("--model <name>", "Default model name")
|
||||
.option("--agent <name>", "Default agent alias")
|
||||
.option("--agent <name>", "Default agent adapter (e.g. hermes → uwf-hermes)")
|
||||
.action(
|
||||
(opts: {
|
||||
provider?: string;
|
||||
|
||||
@@ -5,7 +5,10 @@ import { parse, stringify } from "yaml";
|
||||
/**
|
||||
* Valid configuration key schema
|
||||
*/
|
||||
const VALID_CONFIG_KEYS: Record<string, { nested: boolean; knownFields?: string[] }> = {
|
||||
const VALID_CONFIG_KEYS: Record<
|
||||
string,
|
||||
{ nested: boolean; knownFields?: string[]; minDepth?: number }
|
||||
> = {
|
||||
providers: {
|
||||
nested: true,
|
||||
knownFields: ["baseUrl", "apiKey"],
|
||||
@@ -18,6 +21,17 @@ const VALID_CONFIG_KEYS: Record<string, { nested: boolean; knownFields?: string[
|
||||
nested: true,
|
||||
knownFields: ["command", "args"],
|
||||
},
|
||||
agentOverrides: {
|
||||
nested: true,
|
||||
// agentOverrides.<workflowName>.<roleName> = agentAlias (string value)
|
||||
// No knownFields — workflow/role names are user-defined
|
||||
},
|
||||
modelOverrides: {
|
||||
nested: true,
|
||||
minDepth: 2,
|
||||
// modelOverrides.<scenario> = modelAlias (string value)
|
||||
// No knownFields — scenarios are user-defined
|
||||
},
|
||||
defaultAgent: { nested: false },
|
||||
defaultModel: { nested: false },
|
||||
};
|
||||
@@ -43,8 +57,9 @@ function validateConfigKey(path: string[]): void {
|
||||
throw new Error(`${topLevel} is a scalar key and cannot have nested properties`);
|
||||
}
|
||||
|
||||
// Nested keys must have at least 3 segments (e.g., providers.myProvider.baseUrl)
|
||||
if (schema.nested && path.length < 3) {
|
||||
// Nested keys must have at least minDepth segments (default 3)
|
||||
const minDepth = schema.minDepth ?? 3;
|
||||
if (schema.nested && path.length < minDepth) {
|
||||
const fields = schema.knownFields?.join(", ") ?? "";
|
||||
throw new Error(
|
||||
`Incomplete path for ${topLevel}. Must specify a field (e.g., ${topLevel}.<name>.<field>). Valid fields: ${fields}`,
|
||||
|
||||
@@ -1,26 +1,12 @@
|
||||
export {
|
||||
generateActorReference as cmdSkillActor,
|
||||
generateAdapterReference as cmdSkillAdapter,
|
||||
generateArchitectureReference as cmdSkillArchitecture,
|
||||
generateAuthorReference as cmdSkillAuthor,
|
||||
generateCliReference as cmdSkillCli,
|
||||
generateBootstrapReference as cmdSkillBootstrap,
|
||||
generateDeveloperReference as cmdSkillDeveloper,
|
||||
generateModeratorReference as cmdSkillModerator,
|
||||
generateUserReference as cmdSkillUser,
|
||||
generateYamlReference as cmdSkillYaml,
|
||||
} from "@uncaged/workflow-util";
|
||||
|
||||
const SKILL_NAMES = [
|
||||
"cli",
|
||||
"architecture",
|
||||
"yaml",
|
||||
"moderator",
|
||||
"actor",
|
||||
"user",
|
||||
"author",
|
||||
"developer",
|
||||
"adapter",
|
||||
] as const;
|
||||
const SKILL_NAMES = ["user", "author", "developer", "adapter", "bootstrap"] as const;
|
||||
|
||||
export function cmdSkillList(): ReadonlyArray<string> {
|
||||
return [...SKILL_NAMES];
|
||||
|
||||
@@ -289,6 +289,7 @@ 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);
|
||||
@@ -300,6 +301,23 @@ 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, [], []);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
StepOutput,
|
||||
ThreadId,
|
||||
ThreadListItem,
|
||||
ThreadStatus,
|
||||
ThreadsIndex,
|
||||
WorkflowConfig,
|
||||
WorkflowPayload,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
generateUlid,
|
||||
type ProcessLogger,
|
||||
} from "@uncaged/workflow-util";
|
||||
import type { AdapterOutput } from "@uncaged/workflow-util-agent";
|
||||
import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-util-agent";
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
import { parse } from "yaml";
|
||||
@@ -55,6 +57,21 @@ const END_ROLE = "$END";
|
||||
const START_ROLE = "$START";
|
||||
export const THREAD_READ_DEFAULT_QUOTA = 4000;
|
||||
|
||||
/**
|
||||
* Derive the current/next role from the workflow graph and chain state.
|
||||
* Returns null when the next role is $END or evaluation fails.
|
||||
*/
|
||||
function resolveCurrentRole(uwf: UwfStore, head: CasRef, workflowRef: CasRef): string | null {
|
||||
const chain = walkChain(uwf, head);
|
||||
const { lastRole, lastOutput } = resolveEvaluateArgs(uwf, chain);
|
||||
const workflow = loadWorkflowPayload(uwf, workflowRef);
|
||||
const result = evaluate(workflow.graph, lastRole, lastOutput);
|
||||
if (!result.ok) {
|
||||
return null;
|
||||
}
|
||||
return result.value.role === END_ROLE ? null : result.value.role;
|
||||
}
|
||||
|
||||
const PL_THREAD_START = "7HNQ4B2X";
|
||||
const PL_MODERATOR = "M3K8V9T1";
|
||||
const PL_AGENT_SPAWN = "R5J2W8N4";
|
||||
@@ -266,7 +283,13 @@ export async function cmdThreadStart(
|
||||
workflowId: string,
|
||||
prompt: string,
|
||||
projectRoot: string,
|
||||
cwd: string = process.cwd(),
|
||||
): Promise<StartOutput> {
|
||||
// Validate cwd is an absolute path
|
||||
if (!isAbsolute(cwd)) {
|
||||
fail("cwd must be an absolute path");
|
||||
}
|
||||
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const workflowHash = await resolveWorkflowCasRef(uwf, storageRoot, workflowId, projectRoot);
|
||||
|
||||
@@ -278,6 +301,7 @@ export async function cmdThreadStart(
|
||||
const startPayload: StartNodePayload = {
|
||||
workflow: workflowHash,
|
||||
prompt,
|
||||
cwd,
|
||||
};
|
||||
|
||||
const headHash = await uwf.store.put(uwf.schemas.startNode, startPayload);
|
||||
@@ -308,10 +332,18 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
|
||||
if (workflow === null) {
|
||||
fail(`failed to resolve workflow from head: ${activeHead}`);
|
||||
}
|
||||
|
||||
// Check if thread is running
|
||||
const runningMarker = await isThreadRunning(storageRoot, threadId);
|
||||
const status: ThreadStatus = runningMarker !== null ? "running" : "idle";
|
||||
const currentRole = resolveCurrentRole(uwf, activeHead, workflow);
|
||||
|
||||
return {
|
||||
workflow,
|
||||
thread: threadId,
|
||||
head: activeHead,
|
||||
status,
|
||||
currentRole,
|
||||
done: false,
|
||||
background: null,
|
||||
};
|
||||
@@ -319,10 +351,14 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
|
||||
|
||||
const hist = await findThreadInHistory(storageRoot, threadId);
|
||||
if (hist !== null) {
|
||||
const status: ThreadStatus = hist.reason === "cancelled" ? "cancelled" : "completed";
|
||||
|
||||
return {
|
||||
workflow: hist.workflow,
|
||||
thread: threadId,
|
||||
head: hist.head,
|
||||
status,
|
||||
currentRole: null,
|
||||
done: true,
|
||||
background: null,
|
||||
};
|
||||
@@ -331,10 +367,9 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
|
||||
fail(`thread not found: ${threadId}`);
|
||||
}
|
||||
|
||||
export type ThreadStatus = "idle" | "running" | "completed" | "cancelled";
|
||||
|
||||
export type ThreadListItemWithStatus = ThreadListItem & {
|
||||
status: ThreadStatus;
|
||||
currentRole: string | null;
|
||||
};
|
||||
|
||||
async function threadListItemFromActive(
|
||||
@@ -352,7 +387,13 @@ async function threadListItemFromActive(
|
||||
const runningMarker = await isThreadRunning(storageRoot, threadId);
|
||||
const status: ThreadStatus = runningMarker !== null ? "running" : "idle";
|
||||
|
||||
return { thread: threadId, workflow, head, status };
|
||||
return {
|
||||
thread: threadId,
|
||||
workflow,
|
||||
head,
|
||||
status,
|
||||
currentRole: resolveCurrentRole(uwf, head, workflow),
|
||||
};
|
||||
}
|
||||
|
||||
async function collectActiveThreads(
|
||||
@@ -390,6 +431,7 @@ async function collectCompletedThreads(
|
||||
workflow: entry.workflow,
|
||||
head: entry.head,
|
||||
status: entry.reason === "cancelled" ? "cancelled" : "completed",
|
||||
currentRole: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -772,7 +814,8 @@ function spawnAgent(
|
||||
threadId: ThreadId,
|
||||
role: string,
|
||||
edgePrompt: string,
|
||||
): CasRef {
|
||||
cwd: string,
|
||||
): AdapterOutput {
|
||||
const argv = [...agent.args, "--thread", threadId, "--role", role, "--prompt", edgePrompt];
|
||||
let stdout: string;
|
||||
try {
|
||||
@@ -780,6 +823,7 @@ function spawnAgent(
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
maxBuffer: 50 * 1024 * 1024, // 50 MB — stream-json output can be large
|
||||
cwd,
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as NodeJS.ErrnoException & { stderr?: Buffer | string | null };
|
||||
@@ -794,10 +838,22 @@ function spawnAgent(
|
||||
}
|
||||
|
||||
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
|
||||
if (!isCasRef(line)) {
|
||||
failStep(plog, `agent stdout is not a valid CAS hash: ${line || "(empty)"}`);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
failStep(plog, `agent stdout last line is not valid JSON: ${line || "(empty)"}`);
|
||||
}
|
||||
return line;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (
|
||||
typeof obj !== "object" ||
|
||||
obj === null ||
|
||||
typeof obj.stepHash !== "string" ||
|
||||
!isCasRef(obj.stepHash as string)
|
||||
) {
|
||||
failStep(plog, `agent stdout JSON missing valid stepHash: ${line}`);
|
||||
}
|
||||
return obj as unknown as AdapterOutput;
|
||||
}
|
||||
|
||||
async function archiveThread(
|
||||
@@ -908,6 +964,8 @@ async function cmdThreadStepBackground(
|
||||
failStep(plog, `thread not active: ${threadId}`);
|
||||
}
|
||||
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
|
||||
// Spawn detached background process
|
||||
const scriptPath = process.argv[1];
|
||||
if (scriptPath === undefined) {
|
||||
@@ -938,6 +996,8 @@ async function cmdThreadStepBackground(
|
||||
workflow: workflowHash,
|
||||
thread: threadId,
|
||||
head: headHash,
|
||||
status: "running",
|
||||
currentRole: resolveCurrentRole(uwf, headHash, workflowHash),
|
||||
done: false,
|
||||
background: true,
|
||||
},
|
||||
@@ -980,6 +1040,8 @@ async function cmdThreadStepOnce(
|
||||
workflow: workflowHash,
|
||||
thread: threadId,
|
||||
head: headHash,
|
||||
status: "completed",
|
||||
currentRole: null,
|
||||
done: true,
|
||||
background: null,
|
||||
};
|
||||
@@ -987,6 +1049,11 @@ async function cmdThreadStepOnce(
|
||||
|
||||
const role = nextResult.value.role;
|
||||
const edgePrompt = nextResult.value.prompt;
|
||||
|
||||
// Resolve cwd: use edge location if provided, otherwise inherit thread.cwd
|
||||
const threadCwd = chain.start.cwd;
|
||||
const effectiveCwd = nextResult.value.location !== null ? nextResult.value.location : threadCwd;
|
||||
|
||||
const config = await loadWorkflowConfig(storageRoot);
|
||||
const agent = resolveAgentConfig(config, workflow, role, agentOverride);
|
||||
|
||||
@@ -995,7 +1062,8 @@ async function cmdThreadStepOnce(
|
||||
});
|
||||
|
||||
loadDotenv({ path: getEnvPath(storageRoot) });
|
||||
const newHead = spawnAgent(plog, agent, threadId, role, edgePrompt);
|
||||
const agentResult = spawnAgent(plog, agent, threadId, role, edgePrompt, effectiveCwd);
|
||||
const newHead = agentResult.stepHash as CasRef;
|
||||
|
||||
plog.log(PL_AGENT_DONE, `agent returned head=${newHead}`, null);
|
||||
|
||||
@@ -1027,10 +1095,16 @@ async function cmdThreadStepOnce(
|
||||
await archiveThread(storageRoot, threadId, workflowHash, newHead);
|
||||
}
|
||||
|
||||
// Determine status based on whether thread is done and running state
|
||||
const status: ThreadStatus = done ? "completed" : "idle";
|
||||
const currentRole = done ? null : afterResult.value.role;
|
||||
|
||||
return {
|
||||
workflow: workflowHash,
|
||||
thread: threadId,
|
||||
head: newHead,
|
||||
status,
|
||||
currentRole,
|
||||
done,
|
||||
background: null,
|
||||
};
|
||||
|
||||
@@ -61,6 +61,7 @@ function normalizeGraph(
|
||||
normalized[status] = {
|
||||
role: target.role,
|
||||
prompt: target.prompt,
|
||||
location: target.location ?? null,
|
||||
};
|
||||
}
|
||||
result[node] = normalized;
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { evaluate } from "../evaluate.js";
|
||||
|
||||
describe("Edge prompt template variable resolution", () => {
|
||||
test("returns error when rendered prompt is empty string", () => {
|
||||
const graph = {
|
||||
$START: {
|
||||
_: { role: "classifier", prompt: "{{{userPrompt}}}", location: null },
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "$START", {});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain("prompt");
|
||||
expect(result.error.message).toContain("empty");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns error when rendered prompt is whitespace-only", () => {
|
||||
const graph = {
|
||||
$START: {
|
||||
_: { role: "classifier", prompt: " {{{userPrompt}}} ", location: null },
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "$START", {});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain("prompt");
|
||||
expect(result.error.message).toContain("empty");
|
||||
}
|
||||
});
|
||||
|
||||
test("succeeds when all template variables resolve to non-empty values", () => {
|
||||
const graph = {
|
||||
$START: {
|
||||
_: { role: "classifier", prompt: "{{{userPrompt}}}", location: null },
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "$START", { userPrompt: "Fix the bug" });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.prompt).toBe("Fix the bug");
|
||||
}
|
||||
});
|
||||
|
||||
test("succeeds with static (no-variable) prompt", () => {
|
||||
const graph = {
|
||||
$START: {
|
||||
_: { role: "classifier", prompt: "Classify this input", location: null },
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "$START", {});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.prompt).toBe("Classify this input");
|
||||
}
|
||||
});
|
||||
|
||||
test("succeeds when prompt has mix of static text and unresolved variables", () => {
|
||||
const graph = {
|
||||
$START: {
|
||||
_: { role: "classifier", prompt: "Please handle: {{{userPrompt}}}", location: null },
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "$START", {});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.prompt).toBe("Please handle: ");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns error when ALL variables missing and no static text remains", () => {
|
||||
const graph = {
|
||||
$START: {
|
||||
_: { role: "classifier", prompt: "{{{a}}}{{{b}}}", location: null },
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "$START", {});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Moderator location resolution", () => {
|
||||
test("returns null location when edge has no location field", () => {
|
||||
const graph = {
|
||||
planner: {
|
||||
ready: {
|
||||
role: "coder",
|
||||
prompt: "Implement the code",
|
||||
location: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "planner", { $status: "ready" });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.location).toBe(null);
|
||||
}
|
||||
});
|
||||
|
||||
test("resolves static location string", () => {
|
||||
const graph = {
|
||||
planner: {
|
||||
ready: {
|
||||
role: "coder",
|
||||
prompt: "Implement the code",
|
||||
location: "/static/path",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "planner", { $status: "ready" });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.location).toBe("/static/path");
|
||||
}
|
||||
});
|
||||
|
||||
test("resolves mustache template location", () => {
|
||||
const graph = {
|
||||
planner: {
|
||||
ready: {
|
||||
role: "coder",
|
||||
prompt: "Implement the code",
|
||||
location: "{{{repoPath}}}",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "planner", {
|
||||
$status: "ready",
|
||||
repoPath: "/home/user/repo",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.location).toBe("/home/user/repo");
|
||||
}
|
||||
});
|
||||
|
||||
test("resolves mustache template with multiple variables", () => {
|
||||
const graph = {
|
||||
planner: {
|
||||
ready: {
|
||||
role: "coder",
|
||||
prompt: "Implement the code",
|
||||
location: "{{{basePath}}}/{{{projectName}}}",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "planner", {
|
||||
$status: "ready",
|
||||
basePath: "/home/user",
|
||||
projectName: "myproject",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.location).toBe("/home/user/myproject");
|
||||
}
|
||||
});
|
||||
|
||||
test("handles missing template variable gracefully", () => {
|
||||
const graph = {
|
||||
planner: {
|
||||
ready: {
|
||||
role: "coder",
|
||||
prompt: "Implement the code",
|
||||
location: "{{{repoPath}}}",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluate(graph, "planner", { $status: "ready" });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
// Mustache renders missing variables as empty string
|
||||
expect(result.value.location).toBe("");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,16 @@ export function evaluate(
|
||||
|
||||
try {
|
||||
const prompt = mustache.render(target.prompt, lastOutput);
|
||||
return { ok: true, value: { role: target.role, prompt } };
|
||||
if (prompt.trim() === "") {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(
|
||||
`edge prompt resolved to empty string for role "${target.role}" (template: "${target.prompt}"). Check that upstream output includes required variables.`,
|
||||
),
|
||||
};
|
||||
}
|
||||
const location = target.location !== null ? mustache.render(target.location, lastOutput) : null;
|
||||
return { ok: true, value: { role: target.role, prompt, location } };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -4,4 +4,6 @@ export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
|
||||
export type EvaluateResult = {
|
||||
role: string;
|
||||
prompt: string;
|
||||
/** Resolved working directory from edge location field (null = inherit thread cwd). */
|
||||
location: string | null;
|
||||
};
|
||||
|
||||
@@ -70,10 +70,26 @@ 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");
|
||||
}
|
||||
@@ -98,7 +114,7 @@ export type UwfStore = {
|
||||
};
|
||||
|
||||
export async function createUwfStore(storageRoot: string): Promise<UwfStore> {
|
||||
const casDir = getCasDir(storageRoot);
|
||||
const casDir = getGlobalCasDir();
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
|
||||
@@ -36,8 +36,13 @@ function isTarget(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const hasValidLocation =
|
||||
value.location === undefined || value.location === null || typeof value.location === "string";
|
||||
return (
|
||||
typeof value.role === "string" && typeof value.prompt === "string" && value.prompt.trim() !== ""
|
||||
typeof value.role === "string" &&
|
||||
typeof value.prompt === "string" &&
|
||||
value.prompt.trim() !== "" &&
|
||||
hasValidLocation
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,5 +100,22 @@ export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null {
|
||||
if (!isStringRecord(raw.roles, isRoleDefinition) || !isGraph(raw.graph)) {
|
||||
return null;
|
||||
}
|
||||
return raw as WorkflowPayload;
|
||||
|
||||
// Normalize location field: undefined → null
|
||||
const normalized = { ...raw } as WorkflowPayload;
|
||||
for (const roleName of Object.keys(normalized.graph)) {
|
||||
const statusMap = normalized.graph[roleName];
|
||||
if (statusMap !== undefined) {
|
||||
for (const status of Object.keys(statusMap)) {
|
||||
const target = statusMap[status];
|
||||
if (target !== undefined) {
|
||||
if (target.location === undefined) {
|
||||
target.location = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -3,5 +3,6 @@ import { defineConfig } from "vitest/config";
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/__tests__/**/*.test.ts"],
|
||||
passWithNoTests: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -18,8 +18,9 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"test:ci": "bun test"
|
||||
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
|
||||
"test": "vitest run",
|
||||
"test:ci": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "^0.5.3",
|
||||
@@ -34,12 +35,12 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
|
||||
"url": "https://git.shazhou.work/uncaged/workflow.git",
|
||||
"directory": "packages/workflow-agent-builtin"
|
||||
},
|
||||
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
|
||||
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
|
||||
"url": "https://git.shazhou.work/uncaged/workflow/issues"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
return { output: "", detailHash: "", sessionId: session.sessionId, assembledPrompt: "" };
|
||||
}
|
||||
|
||||
// Read jsonl → persist turns to CAS → store detail
|
||||
@@ -94,7 +94,12 @@ async function runBuiltinWithMessages(
|
||||
session.startedAtMs,
|
||||
);
|
||||
|
||||
return { output: stripPreamble(loopResult.finalText), detailHash, sessionId: session.sessionId };
|
||||
return {
|
||||
output: stripPreamble(loopResult.finalText),
|
||||
detailHash,
|
||||
sessionId: session.sessionId,
|
||||
assembledPrompt: "",
|
||||
};
|
||||
}
|
||||
|
||||
async function runBuiltin(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/__tests__/**/*.test.ts"],
|
||||
passWithNoTests: true,
|
||||
},
|
||||
});
|
||||
@@ -18,8 +18,9 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"test:ci": "bun test"
|
||||
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
|
||||
"test": "vitest run",
|
||||
"test:ci": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "^0.5.3",
|
||||
@@ -34,12 +35,12 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
|
||||
"url": "https://git.shazhou.work/uncaged/workflow.git",
|
||||
"directory": "packages/workflow-agent-claude-code"
|
||||
},
|
||||
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
|
||||
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
|
||||
"url": "https://git.shazhou.work/uncaged/workflow/issues"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@@ -120,12 +120,16 @@ function spawnClaudeResume(
|
||||
return spawnClaude(args);
|
||||
}
|
||||
|
||||
async function processClaudeOutput(stdout: string, store: Store): Promise<AgentRunResult> {
|
||||
async function processClaudeOutput(
|
||||
stdout: string,
|
||||
store: Store,
|
||||
assembledPrompt: string,
|
||||
): Promise<AgentRunResult> {
|
||||
const parsed = parseClaudeCodeStreamOutput(stdout);
|
||||
|
||||
if (parsed !== null) {
|
||||
const { detailHash, output, sessionId } = await storeClaudeCodeDetail(store, parsed);
|
||||
return { output, detailHash, sessionId };
|
||||
return { output, detailHash, sessionId, assembledPrompt };
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
@@ -144,7 +148,7 @@ async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
if (cachedSessionId !== null) {
|
||||
try {
|
||||
const { stdout } = await spawnClaudeResume(cachedSessionId, fullPrompt);
|
||||
const result = await processClaudeOutput(stdout, ctx.store);
|
||||
const result = await processClaudeOutput(stdout, ctx.store, fullPrompt);
|
||||
if (result.sessionId !== undefined && result.sessionId !== "") {
|
||||
await setCachedSessionId("claude-code", ctx.threadId, ctx.role, result.sessionId);
|
||||
}
|
||||
@@ -152,16 +156,14 @@ async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
} catch (err) {
|
||||
log(
|
||||
"5VKR8N3Q",
|
||||
"resume failed for session %s, falling back to fresh run: %s",
|
||||
cachedSessionId,
|
||||
err,
|
||||
`resume failed for session ${cachedSessionId}, falling back to fresh run: ${err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { stdout } = await spawnClaudeRun(fullPrompt);
|
||||
const result = await processClaudeOutput(stdout, ctx.store);
|
||||
const result = await processClaudeOutput(stdout, ctx.store, fullPrompt);
|
||||
if (result.sessionId !== undefined && result.sessionId !== "") {
|
||||
await setCachedSessionId("claude-code", ctx.threadId, ctx.role, result.sessionId);
|
||||
}
|
||||
@@ -174,7 +176,7 @@ async function continueClaudeCode(
|
||||
store: Store,
|
||||
): Promise<AgentRunResult> {
|
||||
const { stdout } = await spawnClaudeResume(sessionId, message);
|
||||
return processClaudeOutput(stdout, store);
|
||||
return processClaudeOutput(stdout, store, "");
|
||||
}
|
||||
|
||||
/** Agent CLI factory: parses argv, runs Claude Code, extracts output, writes StepNode. */
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/__tests__/**/*.test.ts"],
|
||||
passWithNoTests: true,
|
||||
},
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
# @uncaged/workflow-agent-hermes
|
||||
|
||||
`uwf-hermes` agent — spawns Hermes chat via ACP and captures session detail.
|
||||
`uwf-hermes` — an **agent adapter** that bridges the `uwf` workflow engine and the Hermes CLI.
|
||||
|
||||
## Overview
|
||||
|
||||
Layer 3 agent implementation. Wraps the Hermes CLI using the Agent Client Protocol (ACP). 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.
|
||||
`uwf-hermes` is an adapter (not the Hermes CLI itself). The `uwf` engine speaks a generic agent protocol (stdin/stdout frontmatter contract); `uwf-hermes` translates that protocol into Hermes ACP (Agent Client Protocol) calls. Other adapters (e.g. `uwf-claude-code`, `uwf-cursor`) do the same for their respective CLIs.
|
||||
|
||||
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:** `@uncaged/json-cas`, `@uncaged/workflow-util-agent`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const PKG_ROOT = join(import.meta.dir, "..");
|
||||
|
||||
describe("Issue #551 — bin entry & engines", () => {
|
||||
test("package.json declares bun in engines", () => {
|
||||
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf-8"));
|
||||
expect(pkg.engines).toBeDefined();
|
||||
expect(pkg.engines.bun).toBeDefined();
|
||||
expect(pkg.engines.bun).toMatch(/^>=?\s*[\d.]+/);
|
||||
});
|
||||
|
||||
test("bin entry file has bun shebang", () => {
|
||||
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf-8"));
|
||||
const binPath = pkg.bin["uwf-hermes"];
|
||||
const content = readFileSync(join(PKG_ROOT, binPath), "utf-8");
|
||||
expect(content.startsWith("#!/usr/bin/env bun")).toBe(true);
|
||||
});
|
||||
|
||||
test("README.md explains uwf-hermes is an adapter", () => {
|
||||
const readme = readFileSync(join(PKG_ROOT, "README.md"), "utf-8");
|
||||
expect(readme.toLowerCase()).toContain("adapter");
|
||||
expect(readme).toMatch(/uwf-hermes/);
|
||||
expect(readme).toMatch(/hermes/);
|
||||
});
|
||||
});
|
||||
@@ -18,8 +18,9 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"test:ci": "bun test __tests__/*.test.ts"
|
||||
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
|
||||
"test": "vitest run",
|
||||
"test:ci": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "^0.5.3",
|
||||
@@ -35,12 +36,15 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
|
||||
"url": "https://git.shazhou.work/uncaged/workflow.git",
|
||||
"directory": "packages/workflow-agent-hermes"
|
||||
},
|
||||
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
|
||||
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
|
||||
"url": "https://git.shazhou.work/uncaged/workflow/issues"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">= 1.0.0"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ export function createHermesAgent(): () => Promise<void> {
|
||||
await setCachedSessionId(ctx.threadId, ctx.role, sessionId);
|
||||
}
|
||||
|
||||
return { output: text, detailHash, sessionId };
|
||||
return { output: text, detailHash, sessionId, assembledPrompt: fullPrompt };
|
||||
}
|
||||
|
||||
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 };
|
||||
return { output: text, detailHash, sessionId, assembledPrompt: "" };
|
||||
}
|
||||
|
||||
const agentMain = createAgent({
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/__tests__/**/*.test.ts"],
|
||||
passWithNoTests: true,
|
||||
},
|
||||
});
|
||||
@@ -5,7 +5,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun server.ts",
|
||||
"build": "vite build"
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"test:ci": "vitest run"
|
||||
},
|
||||
"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 || node.type !== "role") return;
|
||||
if (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 || node.type !== "role") return;
|
||||
if (node?.type !== "role") return;
|
||||
|
||||
const roleNode = node as WorkNode<"role">;
|
||||
const outEdges = outgoingEdges.get(nodeId) ?? [];
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
|
||||
"test": "vitest run",
|
||||
"test:ci": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "^0.5.3",
|
||||
"@uncaged/json-cas-fs": "^0.5.3"
|
||||
@@ -26,12 +31,12 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
|
||||
"url": "https://git.shazhou.work/uncaged/workflow.git",
|
||||
"directory": "packages/workflow-protocol"
|
||||
},
|
||||
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
|
||||
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
|
||||
"url": "https://git.shazhou.work/uncaged/workflow/issues"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { StartNodePayload, StepRecord, Target } from "../types.js";
|
||||
|
||||
describe("Protocol types for thread/edge location", () => {
|
||||
describe("StartNodePayload", () => {
|
||||
test("has required cwd field", () => {
|
||||
const payload: StartNodePayload = {
|
||||
workflow: "0123456789ABC",
|
||||
prompt: "Test prompt",
|
||||
cwd: "/home/user/project",
|
||||
};
|
||||
|
||||
expect(payload.cwd).toBe("/home/user/project");
|
||||
expect(typeof payload.cwd).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("StepRecord", () => {
|
||||
test("has required cwd field", () => {
|
||||
const record: StepRecord = {
|
||||
role: "planner",
|
||||
output: "0123456789ABC",
|
||||
detail: "DEF0123456789",
|
||||
agent: "uwf-hermes",
|
||||
edgePrompt: "Plan the implementation",
|
||||
startedAtMs: Date.now(),
|
||||
completedAtMs: Date.now() + 1000,
|
||||
assembledPrompt: null,
|
||||
cwd: "/home/user/project",
|
||||
};
|
||||
|
||||
expect(record.cwd).toBe("/home/user/project");
|
||||
expect(typeof record.cwd).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Target", () => {
|
||||
test("has location field that accepts string", () => {
|
||||
const target: Target = {
|
||||
role: "coder",
|
||||
prompt: "Implement the code",
|
||||
location: "/custom/path",
|
||||
};
|
||||
|
||||
expect(target.location).toBe("/custom/path");
|
||||
expect(typeof target.location).toBe("string");
|
||||
});
|
||||
|
||||
test("has location field that accepts null", () => {
|
||||
const target: Target = {
|
||||
role: "coder",
|
||||
prompt: "Implement the code",
|
||||
location: null,
|
||||
};
|
||||
|
||||
expect(target.location).toBe(null);
|
||||
});
|
||||
|
||||
test("location supports mustache template syntax", () => {
|
||||
const target: Target = {
|
||||
role: "coder",
|
||||
prompt: "Implement the code",
|
||||
location: "{{{repoPath}}}",
|
||||
};
|
||||
|
||||
expect(target.location).toBe("{{{repoPath}}}");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ export type {
|
||||
ThreadForkOutput,
|
||||
ThreadId,
|
||||
ThreadListItem,
|
||||
ThreadStatus,
|
||||
ThreadStepsOutput,
|
||||
ThreadsIndex,
|
||||
WorkflowConfig,
|
||||
|
||||
@@ -20,6 +20,9 @@ const TARGET: JSONSchema = {
|
||||
properties: {
|
||||
role: { type: "string" },
|
||||
prompt: { type: "string" },
|
||||
location: {
|
||||
anyOf: [{ type: "string" }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
@@ -49,10 +52,11 @@ export const WORKFLOW_SCHEMA: JSONSchema = {
|
||||
export const START_NODE_SCHEMA: JSONSchema = {
|
||||
title: "StartNode",
|
||||
type: "object",
|
||||
required: ["workflow", "prompt"],
|
||||
required: ["workflow", "prompt", "cwd"],
|
||||
properties: {
|
||||
workflow: { type: "string", format: "cas_ref" },
|
||||
prompt: { type: "string" },
|
||||
cwd: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
@@ -60,7 +64,17 @@ export const START_NODE_SCHEMA: JSONSchema = {
|
||||
export const STEP_NODE_SCHEMA: JSONSchema = {
|
||||
title: "StepNode",
|
||||
type: "object",
|
||||
required: ["start", "prev", "role", "output", "detail", "agent", "startedAtMs", "completedAtMs"],
|
||||
required: [
|
||||
"start",
|
||||
"prev",
|
||||
"role",
|
||||
"output",
|
||||
"detail",
|
||||
"agent",
|
||||
"startedAtMs",
|
||||
"completedAtMs",
|
||||
"cwd",
|
||||
],
|
||||
properties: {
|
||||
start: { type: "string", format: "cas_ref" },
|
||||
prev: {
|
||||
@@ -73,6 +87,10 @@ export const STEP_NODE_SCHEMA: JSONSchema = {
|
||||
edgePrompt: { type: "string" },
|
||||
startedAtMs: { type: "integer" },
|
||||
completedAtMs: { type: "integer" },
|
||||
cwd: { type: "string" },
|
||||
assembledPrompt: {
|
||||
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
@@ -18,6 +18,10 @@ export type StepRecord = {
|
||||
startedAtMs: number;
|
||||
/** Date.now() after agent returns */
|
||||
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 定义 ───────────────────────────────────────────────
|
||||
@@ -34,6 +38,8 @@ export type RoleDefinition = {
|
||||
export type Target = {
|
||||
role: string;
|
||||
prompt: string;
|
||||
/** Optional working directory override via mustache template. */
|
||||
location: string | null;
|
||||
};
|
||||
|
||||
export type WorkflowPayload = {
|
||||
@@ -48,6 +54,8 @@ export type WorkflowPayload = {
|
||||
export type StartNodePayload = {
|
||||
workflow: CasRef;
|
||||
prompt: string;
|
||||
/** Working directory where the thread was created. */
|
||||
cwd: string;
|
||||
};
|
||||
|
||||
export type StepNodePayload = StepRecord & {
|
||||
@@ -70,17 +78,29 @@ export type ModeratorContext = {
|
||||
|
||||
// ── 4.5 CLI 输出 ────────────────────────────────────────────────────
|
||||
|
||||
/** Thread status — unified status representation */
|
||||
export type ThreadStatus = "idle" | "running" | "completed" | "cancelled";
|
||||
|
||||
/** uwf thread start */
|
||||
export type StartOutput = {
|
||||
workflow: CasRef;
|
||||
thread: ThreadId;
|
||||
};
|
||||
|
||||
/** uwf thread step / uwf thread show */
|
||||
/**
|
||||
* Output from thread show and thread exec commands.
|
||||
*
|
||||
* @property status - Current thread status (idle/running/completed/cancelled)
|
||||
* @property done - @deprecated Use status field instead. True if thread is completed or cancelled.
|
||||
* @property background - @deprecated Use status field instead. Always null in current implementation.
|
||||
*/
|
||||
export type StepOutput = {
|
||||
workflow: CasRef;
|
||||
thread: ThreadId;
|
||||
head: CasRef;
|
||||
status: ThreadStatus;
|
||||
/** The current or next role. Null when completed, cancelled, or next is $END. */
|
||||
currentRole: string | null;
|
||||
done: boolean;
|
||||
background: boolean | null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/__tests__/**/*.test.ts"],
|
||||
passWithNoTests: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createMemoryStore, putSchema } from "@uncaged/json-cas";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { tryFrontmatterFastPath } from "../src/frontmatter.js";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const PLANNER_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
$status: { type: "string", enum: ["ready", "failed"] },
|
||||
plan: { type: "string" },
|
||||
},
|
||||
required: ["$status"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
describe("adapter-stdout: A4 retry loop survives JSON output", () => {
|
||||
test("A4. first extraction fails, second succeeds — final result has correct data", async () => {
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, PLANNER_SCHEMA);
|
||||
|
||||
// Simulate the retry loop from createAgent (run.ts lines 163-173):
|
||||
// First attempt: agent outputs garbage (no frontmatter)
|
||||
const badOutput = "Here is my response without frontmatter.\nJust plain text.";
|
||||
const firstAttempt = await tryFrontmatterFastPath(badOutput, schemaHash, store);
|
||||
expect(firstAttempt).toBeNull();
|
||||
|
||||
// Second attempt (after correction message): agent outputs valid frontmatter
|
||||
const goodOutput = `---\n$status: ready\nplan: corrected-hash\n---\nCorrected body with valid frontmatter.`;
|
||||
const secondAttempt = await tryFrontmatterFastPath(goodOutput, schemaHash, store);
|
||||
|
||||
expect(secondAttempt).not.toBeNull();
|
||||
expect(secondAttempt!.outputHash).toMatch(/^[0-9A-Z]{13}$/);
|
||||
expect(secondAttempt!.frontmatter).toEqual({ $status: "ready", plan: "corrected-hash" });
|
||||
expect(secondAttempt!.body).toBe("Corrected body with valid frontmatter.");
|
||||
|
||||
// Verify the final AdapterOutput shape would be correct
|
||||
const adapterOutput = {
|
||||
stepHash: "MOCK_STEP_HASH",
|
||||
detailHash: "MOCK_DETAIL_HA",
|
||||
role: "planner",
|
||||
frontmatter: secondAttempt!.frontmatter,
|
||||
body: secondAttempt!.body,
|
||||
startedAtMs: 1000,
|
||||
completedAtMs: 2000,
|
||||
assembledPrompt: null,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(adapterOutput);
|
||||
const parsed = JSON.parse(json);
|
||||
expect(parsed.frontmatter).toEqual({ $status: "ready", plan: "corrected-hash" });
|
||||
expect(parsed.body).toBe("Corrected body with valid frontmatter.");
|
||||
expect(parsed.completedAtMs).toBeGreaterThanOrEqual(parsed.startedAtMs);
|
||||
});
|
||||
|
||||
test("A4. all retries fail — extraction returns null on every attempt", async () => {
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, PLANNER_SCHEMA);
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
const badOutput = "No frontmatter here";
|
||||
|
||||
// Simulate MAX_FRONTMATTER_RETRIES iterations all failing
|
||||
let extracted = await tryFrontmatterFastPath(badOutput, schemaHash, store);
|
||||
for (let retry = 0; retry < MAX_RETRIES && extracted === null; retry++) {
|
||||
// Each retry also gets bad output
|
||||
extracted = await tryFrontmatterFastPath(badOutput, schemaHash, store);
|
||||
}
|
||||
|
||||
expect(extracted).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { createMemoryStore, putSchema } from "@uncaged/json-cas";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { tryFrontmatterFastPath } from "../src/frontmatter.js";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const PLANNER_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
$status: { type: "string", enum: ["ready", "failed"] },
|
||||
plan: { type: "string" },
|
||||
},
|
||||
required: ["$status"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const FRONTMATTER_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
next: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
confidence: { anyOf: [{ type: "number" }, { type: "null" }] },
|
||||
artifacts: { type: "array", items: { type: "string" } },
|
||||
scope: { type: "string" },
|
||||
},
|
||||
required: ["status", "next", "confidence", "artifacts", "scope"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
describe("adapter-stdout: FrontmatterFastPathResult includes frontmatter", () => {
|
||||
test("A2. frontmatter field contains the parsed YAML frontmatter object", async () => {
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, PLANNER_SCHEMA);
|
||||
|
||||
const raw = `---\n$status: ready\nplan: abc123\n---\nSome body text`;
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.frontmatter).toEqual({ $status: "ready", plan: "abc123" });
|
||||
});
|
||||
|
||||
test("A3. body field contains the markdown body after frontmatter", async () => {
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, PLANNER_SCHEMA);
|
||||
|
||||
const raw = `---\n$status: ready\nplan: hash123\n---\nHere is the body.\n\nWith multiple paragraphs.`;
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.body).toBe("Here is the body.\n\nWith multiple paragraphs.");
|
||||
});
|
||||
|
||||
test("A1. result contains outputHash as valid CasRef", async () => {
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, FRONTMATTER_SCHEMA);
|
||||
|
||||
const raw = `---\nstatus: done\nnext: null\nconfidence: 0.9\nartifacts: []\nscope: test\n---\nBody`;
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.outputHash).toMatch(/^[0-9A-Z]{13}$/);
|
||||
expect(result!.frontmatter).toBeDefined();
|
||||
expect(result!.body).toBe("Body");
|
||||
});
|
||||
});
|
||||
|
||||
describe("adapter-stdout: AdapterOutput JSON shape", () => {
|
||||
test("A5. JSON.stringify produces valid parseable JSON with all fields", () => {
|
||||
const output = {
|
||||
stepHash: "0123456789ABC",
|
||||
detailHash: "DEFGH12345678",
|
||||
role: "planner",
|
||||
frontmatter: { $status: "ready", plan: "somehash" },
|
||||
body: "Plan body text",
|
||||
startedAtMs: 1000,
|
||||
completedAtMs: 2000,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(output);
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
expect(parsed.stepHash).toBe("0123456789ABC");
|
||||
expect(parsed.detailHash).toBe("DEFGH12345678");
|
||||
expect(parsed.role).toBe("planner");
|
||||
expect(parsed.frontmatter).toEqual({ $status: "ready", plan: "somehash" });
|
||||
expect(parsed.body).toBe("Plan body text");
|
||||
expect(parsed.startedAtMs).toBe(1000);
|
||||
expect(parsed.completedAtMs).toBe(2000);
|
||||
});
|
||||
|
||||
test("completedAtMs >= startedAtMs", () => {
|
||||
const output = {
|
||||
stepHash: "0123456789ABC",
|
||||
detailHash: "DEFGH12345678",
|
||||
role: "planner",
|
||||
frontmatter: {},
|
||||
body: "",
|
||||
startedAtMs: 1000,
|
||||
completedAtMs: 2000,
|
||||
};
|
||||
|
||||
expect(output.completedAtMs).toBeGreaterThanOrEqual(output.startedAtMs);
|
||||
});
|
||||
});
|
||||
@@ -5,17 +5,13 @@ import { tryFrontmatterFastPath } from "../src/frontmatter.js";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** JSON Schema that exactly matches the AgentFrontmatter fields. */
|
||||
const FRONTMATTER_SCHEMA = {
|
||||
/** JSON Schema that matches the new status-only AgentFrontmatter. */
|
||||
const STATUS_ONLY_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
next: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
confidence: { anyOf: [{ type: "number" }, { type: "null" }] },
|
||||
artifacts: { type: "array", items: { type: "string" } },
|
||||
scope: { type: "string" },
|
||||
},
|
||||
required: ["status", "next", "confidence", "artifacts", "scope"],
|
||||
required: ["status"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
@@ -56,24 +52,41 @@ async function makeStoreWithSchema(schema: Record<string, unknown>) {
|
||||
return { store, schemaHash };
|
||||
}
|
||||
|
||||
// ── STANDARD_KEYS ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("STANDARD_KEYS contains only status", () => {
|
||||
test("STANDARD_KEYS is ['status']", async () => {
|
||||
// We verify indirectly: defaultCandidate (no schema fields) returns only { status }
|
||||
const { store, schemaHash } = await makeStoreWithSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
},
|
||||
});
|
||||
|
||||
const raw = "---\nstatus: done\n---\n\nBody.";
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
const node = store.get(result!.outputHash);
|
||||
expect(node).not.toBeNull();
|
||||
const payload = node!.payload as Record<string, unknown>;
|
||||
expect(payload.status).toBe("done");
|
||||
// Legacy fields must NOT be present
|
||||
expect(payload.next).toBeUndefined();
|
||||
expect(payload.confidence).toBeUndefined();
|
||||
expect(payload.artifacts).toBeUndefined();
|
||||
expect(payload.scope).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Happy path ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("tryFrontmatterFastPath — happy path", () => {
|
||||
test("parses valid frontmatter and returns outputHash + stripped body", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA);
|
||||
const { store, schemaHash } = await makeStoreWithSchema(STATUS_ONLY_SCHEMA);
|
||||
|
||||
const raw = [
|
||||
"---",
|
||||
"status: done",
|
||||
"next: reviewer",
|
||||
"confidence: 0.9",
|
||||
"artifacts: [src/foo.ts]",
|
||||
"scope: role",
|
||||
"---",
|
||||
"",
|
||||
"## Summary",
|
||||
"Work is complete.",
|
||||
].join("\n");
|
||||
const raw = ["---", "status: done", "---", "", "## Summary", "Work is complete."].join("\n");
|
||||
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
|
||||
@@ -85,11 +98,10 @@ describe("tryFrontmatterFastPath — happy path", () => {
|
||||
expect((result?.outputHash ?? "").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("stored CAS node payload matches frontmatter fields", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA);
|
||||
test("stored CAS node payload has only status", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(STATUS_ONLY_SCHEMA);
|
||||
|
||||
const raw =
|
||||
"---\nstatus: done\nnext: null\nconfidence: null\nartifacts: []\nscope: role\n---\n\nBody.";
|
||||
const raw = "---\nstatus: done\n---\n\nBody.";
|
||||
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
expect(result).not.toBeNull();
|
||||
@@ -98,10 +110,29 @@ describe("tryFrontmatterFastPath — happy path", () => {
|
||||
expect(node).not.toBeNull();
|
||||
const payload = node!.payload as Record<string, unknown>;
|
||||
expect(payload.status).toBe("done");
|
||||
expect(payload.next).toBeNull();
|
||||
expect(payload.confidence).toBeNull();
|
||||
expect(payload.artifacts).toEqual([]);
|
||||
expect(payload.scope).toBe("role");
|
||||
expect(Object.keys(payload)).toEqual(["status"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Legacy fields in input are ignored ──────────────────────────────────────
|
||||
|
||||
describe("tryFrontmatterFastPath — legacy fields ignored", () => {
|
||||
test("legacy fields in input do not appear in CAS output", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(STATUS_ONLY_SCHEMA);
|
||||
|
||||
const raw =
|
||||
"---\nstatus: done\nnext: reviewer\nconfidence: 0.9\nartifacts: [a.ts]\nscope: thread\n---\n\nBody.";
|
||||
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
const node = store.get(result!.outputHash);
|
||||
const payload = node!.payload as Record<string, unknown>;
|
||||
expect(payload.status).toBe("done");
|
||||
expect(payload.next).toBeUndefined();
|
||||
expect(payload.confidence).toBeUndefined();
|
||||
expect(payload.artifacts).toBeUndefined();
|
||||
expect(payload.scope).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,7 +140,7 @@ describe("tryFrontmatterFastPath — happy path", () => {
|
||||
|
||||
describe("tryFrontmatterFastPath — fallback: no frontmatter", () => {
|
||||
test("returns null for plain markdown without frontmatter block", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA);
|
||||
const { store, schemaHash } = await makeStoreWithSchema(STATUS_ONLY_SCHEMA);
|
||||
|
||||
const result = await tryFrontmatterFastPath(
|
||||
"This is plain markdown without any frontmatter.",
|
||||
@@ -121,35 +152,13 @@ describe("tryFrontmatterFastPath — fallback: no frontmatter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Fallback: invalid frontmatter ─────────────────────────────────────────────
|
||||
|
||||
describe("tryFrontmatterFastPath — fallback: invalid frontmatter", () => {
|
||||
test("returns null when confidence is out of range [0, 1]", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA);
|
||||
|
||||
const raw = "---\nstatus: done\nconfidence: 1.5\nscope: role\n---\n\nBody.";
|
||||
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when next contains whitespace", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA);
|
||||
|
||||
const raw = "---\nstatus: done\nnext: some role\nscope: role\n---\n\nBody.";
|
||||
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Fallback: schema mismatch ─────────────────────────────────────────────────
|
||||
|
||||
describe("tryFrontmatterFastPath — fallback: schema mismatch", () => {
|
||||
test("returns null when outputSchema requires fields not in frontmatter", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(STRICT_SCHEMA);
|
||||
|
||||
const raw = "---\nstatus: done\nscope: role\n---\n\nBody.";
|
||||
const raw = "---\nstatus: done\n---\n\nBody.";
|
||||
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
expect(result).toBeNull();
|
||||
@@ -194,7 +203,7 @@ describe("tryFrontmatterFastPath — role-specific fields", () => {
|
||||
test("returns null when required role-specific field is missing", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(REVIEWER_SCHEMA);
|
||||
|
||||
const raw = "---\nstatus: done\nscope: role\n---\n\nBody.";
|
||||
const raw = "---\nstatus: done\n---\n\nBody.";
|
||||
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
expect(result).toBeNull();
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"test:ci": "bun test"
|
||||
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
|
||||
"test": "vitest run",
|
||||
"test:ci": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "^0.5.3",
|
||||
@@ -34,12 +35,12 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
|
||||
"url": "https://git.shazhou.work/uncaged/workflow.git",
|
||||
"directory": "packages/workflow-util-agent"
|
||||
},
|
||||
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
|
||||
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
|
||||
"url": "https://git.shazhou.work/uncaged/workflow/issues"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
describe("parseArgv empty prompt error message", () => {
|
||||
let stderrOutput: string;
|
||||
let _exitCode: number | null;
|
||||
const originalExit = process.exit;
|
||||
const originalStderrWrite = process.stderr.write;
|
||||
|
||||
beforeEach(() => {
|
||||
stderrOutput = "";
|
||||
_exitCode = null;
|
||||
process.exit = ((code?: number) => {
|
||||
_exitCode = code ?? 1;
|
||||
throw new Error("process.exit called");
|
||||
}) as any;
|
||||
process.stderr.write = ((chunk: string) => {
|
||||
stderrOutput += chunk;
|
||||
return true;
|
||||
}) as any;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.exit = originalExit;
|
||||
process.stderr.write = originalStderrWrite;
|
||||
});
|
||||
|
||||
test("empty prompt produces error message mentioning template variables", async () => {
|
||||
const { parseArgv } = await import("../run.js");
|
||||
const argv = [
|
||||
"node",
|
||||
"uwf-hermes",
|
||||
"--thread",
|
||||
"01ABCDEFGHIJKLMNOPQRSTUVWX",
|
||||
"--role",
|
||||
"classifier",
|
||||
"--prompt",
|
||||
"",
|
||||
];
|
||||
|
||||
expect(() => parseArgv(argv)).toThrow("process.exit called");
|
||||
expect(stderrOutput).toContain("prompt");
|
||||
expect(stderrOutput).toContain("empty");
|
||||
expect(stderrOutput).toContain("template");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -130,6 +130,8 @@ async function buildHistory(
|
||||
edgePrompt: step.edgePrompt ?? "",
|
||||
startedAtMs: step.startedAtMs,
|
||||
completedAtMs: step.completedAtMs,
|
||||
cwd: step.cwd ?? "",
|
||||
assembledPrompt: step.assembledPrompt ?? null,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,13 +13,14 @@ import { extractSchemaFields } from "./build-output-format-instruction.js";
|
||||
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
const STANDARD_KEYS = ["status", "next", "confidence", "artifacts", "scope"] as const;
|
||||
const STANDARD_KEYS = ["status"] as const;
|
||||
|
||||
type StandardKey = (typeof STANDARD_KEYS)[number];
|
||||
|
||||
export type FrontmatterFastPathResult = {
|
||||
body: string;
|
||||
outputHash: CasRef;
|
||||
frontmatter: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function extractYamlBlock(raw: string): string | null {
|
||||
@@ -62,10 +63,6 @@ function parseRawFrontmatterFields(raw: string): Record<string, unknown> {
|
||||
function defaultCandidate(frontmatter: AgentFrontmatter): Record<string, unknown> {
|
||||
return {
|
||||
status: frontmatter.status,
|
||||
next: frontmatter.next,
|
||||
confidence: frontmatter.confidence,
|
||||
artifacts: [...frontmatter.artifacts],
|
||||
scope: frontmatter.scope,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,14 +70,6 @@ function pickStandardField(frontmatter: AgentFrontmatter, key: StandardKey): unk
|
||||
switch (key) {
|
||||
case "status":
|
||||
return frontmatter.status;
|
||||
case "next":
|
||||
return frontmatter.next;
|
||||
case "confidence":
|
||||
return frontmatter.confidence;
|
||||
case "artifacts":
|
||||
return [...frontmatter.artifacts];
|
||||
case "scope":
|
||||
return frontmatter.scope;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,9 +87,6 @@ function pickFieldValue(
|
||||
}
|
||||
|
||||
const coerced = pickStandardField(frontmatter, field);
|
||||
if (field === "artifacts" || field === "scope") {
|
||||
return coerced;
|
||||
}
|
||||
if (coerced !== null) {
|
||||
return coerced;
|
||||
}
|
||||
@@ -110,8 +96,8 @@ function pickFieldValue(
|
||||
/**
|
||||
* Build a CAS candidate object from schema property keys and parsed frontmatter.
|
||||
*
|
||||
* When the schema has no inspectable properties, falls back to the five standard
|
||||
* agent frontmatter fields for backward compatibility.
|
||||
* When the schema has no inspectable properties, falls back to the standard
|
||||
* agent frontmatter field (status only).
|
||||
*/
|
||||
function buildCandidate(
|
||||
frontmatter: AgentFrontmatter,
|
||||
@@ -191,5 +177,5 @@ export async function tryFrontmatterFastPath(
|
||||
return null;
|
||||
}
|
||||
|
||||
return { body, outputHash };
|
||||
return { body, outputHash, frontmatter: candidate };
|
||||
}
|
||||
|
||||
@@ -11,10 +11,11 @@ export {
|
||||
} from "./extract.js";
|
||||
export type { FrontmatterFastPathResult } from "./frontmatter.js";
|
||||
export { tryFrontmatterFastPath } from "./frontmatter.js";
|
||||
export { createAgent } from "./run.js";
|
||||
export { createAgent, parseArgv } from "./run.js";
|
||||
export { getCachedSessionId, getCachePath, setCachedSessionId } from "./session-cache.js";
|
||||
export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
|
||||
export type {
|
||||
AdapterOutput,
|
||||
AgentContext,
|
||||
AgentContinueFn,
|
||||
AgentOptions,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { buildContextWithMeta } from "./context.js";
|
||||
import { tryFrontmatterFastPath } from "./frontmatter.js";
|
||||
import type { AgentStore } from "./storage.js";
|
||||
import { getEnvPath, resolveStorageRoot } from "./storage.js";
|
||||
import type { AgentOptions } from "./types.js";
|
||||
import type { AdapterOutput, AgentOptions } from "./types.js";
|
||||
|
||||
const MAX_FRONTMATTER_RETRIES = 2;
|
||||
|
||||
@@ -32,13 +32,16 @@ function getNamedArg(argv: string[], name: string): string {
|
||||
return argv[idx + 1];
|
||||
}
|
||||
|
||||
function parseArgv(argv: string[]): { threadId: ThreadId; role: string; prompt: string } {
|
||||
export function parseArgv(argv: string[]): { threadId: ThreadId; role: string; prompt: string } {
|
||||
const threadId = getNamedArg(argv, "--thread");
|
||||
const role = getNamedArg(argv, "--role");
|
||||
const prompt = getNamedArg(argv, "--prompt");
|
||||
if (threadId === "") fail(USAGE);
|
||||
if (role === "") fail(USAGE);
|
||||
if (prompt === "") fail(USAGE);
|
||||
if (prompt === "")
|
||||
fail(
|
||||
`--prompt is empty. If this agent was spawned by uwf, the edge prompt template may have unresolved variables. ${USAGE}`,
|
||||
);
|
||||
return { threadId: threadId as ThreadId, role, prompt };
|
||||
}
|
||||
|
||||
@@ -61,6 +64,7 @@ async function writeStepNode(options: {
|
||||
edgePrompt: string;
|
||||
startedAtMs: number;
|
||||
completedAtMs: number;
|
||||
assembledPromptHash: CasRef | null;
|
||||
}): Promise<CasRef> {
|
||||
const payload: StepNodePayload = {
|
||||
start: options.startHash,
|
||||
@@ -72,6 +76,8 @@ async function writeStepNode(options: {
|
||||
edgePrompt: options.edgePrompt,
|
||||
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);
|
||||
@@ -81,14 +87,24 @@ async function writeStepNode(options: {
|
||||
return hash;
|
||||
}
|
||||
|
||||
type ExtractedOutput = {
|
||||
outputHash: CasRef;
|
||||
frontmatter: Record<string, unknown>;
|
||||
body: string;
|
||||
};
|
||||
|
||||
async function tryExtractOutput(
|
||||
rawOutput: string,
|
||||
outputSchema: CasRef,
|
||||
ctx: Awaited<ReturnType<typeof buildContextWithMeta>>,
|
||||
): Promise<CasRef | null> {
|
||||
): Promise<ExtractedOutput | null> {
|
||||
const fastPath = await tryFrontmatterFastPath(rawOutput, outputSchema, ctx.meta.store);
|
||||
if (fastPath !== null) {
|
||||
return fastPath.outputHash;
|
||||
return {
|
||||
outputHash: fastPath.outputHash,
|
||||
frontmatter: fastPath.frontmatter,
|
||||
body: fastPath.body,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -100,6 +116,7 @@ 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({
|
||||
@@ -114,6 +131,7 @@ async function persistStep(options: {
|
||||
edgePrompt: options.ctx.edgePrompt,
|
||||
startedAtMs: options.startedAtMs,
|
||||
completedAtMs: options.completedAtMs,
|
||||
assembledPromptHash: options.assembledPromptHash,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -137,6 +155,7 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
|
||||
const startedAtMs = Date.now();
|
||||
let agentResult = await runWithMessage("agent run failed", () => options.run(ctx));
|
||||
agentResult.output = agentResult.output.trimStart();
|
||||
|
||||
// Preserve the primary detail from the first run — it contains the full
|
||||
// tool-call turn history. Continuation retries only fix frontmatter
|
||||
@@ -144,9 +163,9 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
const primaryDetailHash = agentResult.detailHash;
|
||||
|
||||
// Try to extract frontmatter; retry via continue if it fails
|
||||
let outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
|
||||
let extracted = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
|
||||
|
||||
for (let retry = 0; retry < MAX_FRONTMATTER_RETRIES && outputHash === null; retry++) {
|
||||
for (let retry = 0; retry < MAX_FRONTMATTER_RETRIES && extracted === null; retry++) {
|
||||
const correctionMessage =
|
||||
"Your previous response did not contain valid YAML frontmatter matching the role schema.\n" +
|
||||
"You MUST begin your response with a YAML frontmatter block (--- delimited).\n" +
|
||||
@@ -155,10 +174,11 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
agentResult = await runWithMessage("agent continue failed", () =>
|
||||
options.continue(agentResult.sessionId, correctionMessage, ctx.meta.store),
|
||||
);
|
||||
outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
|
||||
agentResult.output = agentResult.output.trimStart();
|
||||
extracted = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
|
||||
}
|
||||
|
||||
if (outputHash === null) {
|
||||
if (extracted === null) {
|
||||
fail(
|
||||
"Agent output does not contain valid YAML frontmatter matching the role schema " +
|
||||
`after ${MAX_FRONTMATTER_RETRIES} retries.\n` +
|
||||
@@ -166,15 +186,33 @@ 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,
|
||||
outputHash: extracted.outputHash,
|
||||
detailHash: primaryDetailHash,
|
||||
agentName: agentLabel(options.name),
|
||||
startedAtMs,
|
||||
completedAtMs,
|
||||
assembledPromptHash,
|
||||
});
|
||||
|
||||
process.stdout.write(`${stepHash}\n`);
|
||||
const adapterOutput: AdapterOutput = {
|
||||
stepHash,
|
||||
detailHash: primaryDetailHash,
|
||||
role,
|
||||
frontmatter: extracted.frontmatter,
|
||||
body: extracted.body,
|
||||
startedAtMs,
|
||||
completedAtMs,
|
||||
};
|
||||
process.stdout.write(`${JSON.stringify(adapterOutput)}\n`);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,17 +6,21 @@ 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] = await Promise.all([
|
||||
const [workflow, startNode, stepNode, text] = 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 };
|
||||
return { workflow, startNode, stepNode, text };
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ export type AgentRunResult = {
|
||||
output: string;
|
||||
detailHash: string;
|
||||
sessionId: string;
|
||||
/** The fully assembled prompt that was sent to the agent. */
|
||||
assembledPrompt: string;
|
||||
};
|
||||
|
||||
export type AgentContinueFn = (
|
||||
@@ -37,6 +39,16 @@ export type AgentContinueFn = (
|
||||
|
||||
export type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
|
||||
|
||||
export type AdapterOutput = {
|
||||
stepHash: string;
|
||||
detailHash: string;
|
||||
role: string;
|
||||
frontmatter: Record<string, unknown>;
|
||||
body: string;
|
||||
startedAtMs: number;
|
||||
completedAtMs: number;
|
||||
};
|
||||
|
||||
export type AgentOptions = {
|
||||
name: string;
|
||||
run: AgentRunFn;
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "../workflow-protocol" }]
|
||||
"references": [{ "path": "../workflow-protocol" }, { "path": "../workflow-util" }]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/__tests__/**/*.test.ts"],
|
||||
passWithNoTests: true,
|
||||
},
|
||||
});
|
||||
@@ -41,31 +41,13 @@ describe("parseFrontmatterMarkdown", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("full frontmatter document", () => {
|
||||
it("parses all fields from a well-formed document", () => {
|
||||
const raw = `---
|
||||
status: done
|
||||
next: reviewer
|
||||
confidence: 0.9
|
||||
artifacts:
|
||||
- src/foo.ts
|
||||
- src/bar.ts
|
||||
scope: thread
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Everything looks good.`;
|
||||
|
||||
describe("status-only frontmatter", () => {
|
||||
it("parses status-only frontmatter", () => {
|
||||
const raw = "---\nstatus: done\n---\nbody";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter).not.toBeNull();
|
||||
const fm = result.frontmatter!;
|
||||
expect(fm.status).toBe("done");
|
||||
expect(fm.next).toBe("reviewer");
|
||||
expect(fm.confidence).toBe(0.9);
|
||||
expect(fm.artifacts).toEqual(["src/foo.ts", "src/bar.ts"]);
|
||||
expect(fm.scope).toBe("thread");
|
||||
expect(result.body).toBe("## Summary\n\nEverything looks good.");
|
||||
expect(result.frontmatter).toEqual({ status: "done" });
|
||||
expect(result.body).toBe("body");
|
||||
});
|
||||
|
||||
it("strips leading newline from body", () => {
|
||||
@@ -87,6 +69,22 @@ Everything looks good.`;
|
||||
});
|
||||
});
|
||||
|
||||
describe("ignores legacy fields", () => {
|
||||
it("legacy fields next/confidence/artifacts/scope are NOT present on result", () => {
|
||||
const raw =
|
||||
"---\nstatus: done\nnext: reviewer\nconfidence: 0.9\nartifacts:\n - src/foo.ts\nscope: thread\n---\n\nBody.";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter).not.toBeNull();
|
||||
const fm = result.frontmatter!;
|
||||
expect(fm.status).toBe("done");
|
||||
// Legacy fields must not exist on the object at all
|
||||
expect("next" in fm).toBe(false);
|
||||
expect("confidence" in fm).toBe(false);
|
||||
expect("artifacts" in fm).toBe(false);
|
||||
expect("scope" in fm).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("status field", () => {
|
||||
it.each([
|
||||
"done",
|
||||
@@ -106,109 +104,18 @@ Everything looks good.`;
|
||||
});
|
||||
|
||||
it("returns null status when omitted", () => {
|
||||
const raw = "---\nconfidence: 0.5\n---\nbody";
|
||||
const raw = "---\nfoo: bar\n---\nbody";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter?.status).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("confidence field", () => {
|
||||
it("parses integer as number", () => {
|
||||
const raw = "---\nconfidence: 1\n---\nbody";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter?.confidence).toBe(1);
|
||||
});
|
||||
|
||||
it("parses decimal", () => {
|
||||
const raw = "---\nconfidence: 0.75\n---\nbody";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter?.confidence).toBe(0.75);
|
||||
});
|
||||
|
||||
it("returns null when omitted", () => {
|
||||
const raw = "---\nstatus: done\n---\nbody";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter?.confidence).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-numeric value", () => {
|
||||
const raw = "---\nconfidence: high\n---\nbody";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter?.confidence).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("artifacts field", () => {
|
||||
it("parses block sequence", () => {
|
||||
const raw = "---\nartifacts:\n - a.ts\n - b.ts\n---\nbody";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter?.artifacts).toEqual(["a.ts", "b.ts"]);
|
||||
});
|
||||
|
||||
it("parses inline sequence", () => {
|
||||
const raw = "---\nartifacts: [a.ts, b.ts]\n---\nbody";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter?.artifacts).toEqual(["a.ts", "b.ts"]);
|
||||
});
|
||||
|
||||
it("returns empty array when omitted", () => {
|
||||
const raw = "---\nstatus: done\n---\nbody";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter?.artifacts).toEqual([]);
|
||||
});
|
||||
|
||||
it("wraps single scalar in array", () => {
|
||||
const raw = "---\nartifacts: only-one.ts\n---\nbody";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter?.artifacts).toEqual(["only-one.ts"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scope field", () => {
|
||||
it('parses scope "role"', () => {
|
||||
const raw = "---\nscope: role\n---\nbody";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter?.scope).toBe("role");
|
||||
});
|
||||
|
||||
it('parses scope "thread"', () => {
|
||||
const raw = "---\nscope: thread\n---\nbody";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter?.scope).toBe("thread");
|
||||
});
|
||||
|
||||
it('defaults to "role" when omitted', () => {
|
||||
const raw = "---\nstatus: done\n---\nbody";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter?.scope).toBe("role");
|
||||
});
|
||||
|
||||
it('defaults to "role" for unknown scope value', () => {
|
||||
const raw = "---\nscope: global\n---\nbody";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter?.scope).toBe("role");
|
||||
});
|
||||
});
|
||||
|
||||
describe("next field", () => {
|
||||
it("parses a role name", () => {
|
||||
const raw = "---\nnext: planner\n---\nbody";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter?.next).toBe("planner");
|
||||
});
|
||||
|
||||
it("returns null when omitted", () => {
|
||||
const raw = "---\nstatus: done\n---\nbody";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter?.next).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unknown fields", () => {
|
||||
it("ignores unknown keys silently", () => {
|
||||
const raw = "---\nunknown_field: some_value\nstatus: done\n---\nbody";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter?.status).toBe("done");
|
||||
expect(Object.keys(result.frontmatter!)).toEqual(["status"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -221,123 +128,58 @@ Everything looks good.`;
|
||||
});
|
||||
|
||||
describe("empty frontmatter block", () => {
|
||||
it("parses empty frontmatter and uses all defaults", () => {
|
||||
it("parses empty frontmatter with status null", () => {
|
||||
const raw = "---\n---\nbody";
|
||||
const result = parseFrontmatterMarkdown(raw);
|
||||
expect(result.frontmatter).not.toBeNull();
|
||||
const fm = result.frontmatter!;
|
||||
expect(fm.status).toBeNull();
|
||||
expect(fm.next).toBeNull();
|
||||
expect(fm.confidence).toBeNull();
|
||||
expect(fm.artifacts).toEqual([]);
|
||||
expect(fm.scope).toBe("role");
|
||||
expect(Object.keys(fm)).toEqual(["status"]);
|
||||
expect(result.body).toBe("body");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentFrontmatter has exactly one field", () => {
|
||||
it("has only status key", () => {
|
||||
const fm: AgentFrontmatter = { status: null };
|
||||
expect(Object.keys(fm)).toEqual(["status"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FrontmatterValidationError only has status variant", () => {
|
||||
it("status variant is valid", () => {
|
||||
const err: import("../src/index.js").FrontmatterValidationError = {
|
||||
field: "status",
|
||||
message: "test",
|
||||
};
|
||||
expect(err.field).toBe("status");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── validateFrontmatter ──────────────────────────────────────────────────────
|
||||
|
||||
function validFm(overrides: Partial<AgentFrontmatter> = {}): AgentFrontmatter {
|
||||
return {
|
||||
status: "done",
|
||||
next: null,
|
||||
confidence: null,
|
||||
artifacts: [],
|
||||
scope: "role",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("validateFrontmatter", () => {
|
||||
it("returns no errors for a fully valid frontmatter", () => {
|
||||
const errors = validateFrontmatter(validFm());
|
||||
it("returns no errors for a valid status", () => {
|
||||
const errors = validateFrontmatter({ status: "done" });
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns no errors when all nullable fields are null", () => {
|
||||
const fm: AgentFrontmatter = {
|
||||
status: null,
|
||||
next: null,
|
||||
confidence: null,
|
||||
artifacts: [],
|
||||
scope: "role",
|
||||
};
|
||||
it("returns no errors when status is null", () => {
|
||||
const errors = validateFrontmatter({ status: null });
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns error for invalid status", () => {
|
||||
const errors = validateFrontmatter({ status: "bogus" as never });
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]?.field).toBe("status");
|
||||
});
|
||||
|
||||
it("no validation for next/confidence/artifacts/scope — fields do not exist", () => {
|
||||
// AgentFrontmatter only has status — verify at runtime
|
||||
const fm: AgentFrontmatter = { status: "done" };
|
||||
expect(Object.keys(fm)).toEqual(["status"]);
|
||||
expect(validateFrontmatter(fm)).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe("confidence validation", () => {
|
||||
it("accepts 0.0", () => {
|
||||
expect(validateFrontmatter(validFm({ confidence: 0 }))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("accepts 1.0", () => {
|
||||
expect(validateFrontmatter(validFm({ confidence: 1 }))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects value below 0", () => {
|
||||
const errors = validateFrontmatter(validFm({ confidence: -0.1 }));
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]?.field).toBe("confidence");
|
||||
});
|
||||
|
||||
it("rejects value above 1", () => {
|
||||
const errors = validateFrontmatter(validFm({ confidence: 1.01 }));
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]?.field).toBe("confidence");
|
||||
});
|
||||
});
|
||||
|
||||
describe("next validation", () => {
|
||||
it("accepts a simple role name", () => {
|
||||
expect(validateFrontmatter(validFm({ next: "reviewer" }))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("accepts kebab-case role name", () => {
|
||||
expect(validateFrontmatter(validFm({ next: "code-reviewer" }))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects role name with whitespace", () => {
|
||||
const errors = validateFrontmatter(validFm({ next: "role name" }));
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]?.field).toBe("next");
|
||||
});
|
||||
});
|
||||
|
||||
describe("artifacts validation", () => {
|
||||
it("accepts non-empty path strings", () => {
|
||||
expect(
|
||||
validateFrontmatter(validFm({ artifacts: ["src/foo.ts", "src/bar.ts"] })),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects empty string artifact entries", () => {
|
||||
const errors = validateFrontmatter(validFm({ artifacts: [""] }));
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]?.field).toBe("artifacts");
|
||||
});
|
||||
|
||||
it("rejects whitespace-only artifact entries", () => {
|
||||
const errors = validateFrontmatter(validFm({ artifacts: [" "] }));
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]?.field).toBe("artifacts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple errors", () => {
|
||||
it("reports multiple violations at once", () => {
|
||||
const fm: AgentFrontmatter = {
|
||||
status: "done",
|
||||
next: "bad role",
|
||||
confidence: 2,
|
||||
artifacts: [""],
|
||||
scope: "role",
|
||||
};
|
||||
const errors = validateFrontmatter(fm);
|
||||
const fields = errors.map((e) => e.field);
|
||||
expect(fields).toContain("next");
|
||||
expect(fields).toContain("confidence");
|
||||
expect(fields).toContain("artifacts");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
"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"
|
||||
@@ -23,12 +28,12 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
|
||||
"url": "https://git.shazhou.work/uncaged/workflow.git",
|
||||
"directory": "packages/workflow-util"
|
||||
},
|
||||
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
|
||||
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
|
||||
"url": "https://git.shazhou.work/uncaged/workflow/issues"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractUlidTimestamp, generateUlid } from "../ulid.js";
|
||||
|
||||
describe("extractUlidTimestamp", () => {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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)。
|
||||
`;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import type {
|
||||
AgentFrontmatter,
|
||||
FrontmatterScope,
|
||||
FrontmatterStatus,
|
||||
FrontmatterValidationError,
|
||||
ParsedFrontmatterMarkdown,
|
||||
@@ -159,40 +158,12 @@ function parseMinimalYaml(yaml: string): Record<string, YamlValue> {
|
||||
|
||||
const VALID_STATUS: readonly FrontmatterStatus[] = ["done", "needs_input", "in_progress", "failed"];
|
||||
|
||||
const VALID_SCOPE: readonly FrontmatterScope[] = ["role", "thread"];
|
||||
|
||||
function coerceStatus(raw: YamlValue): FrontmatterStatus | null {
|
||||
if (raw === null || raw === undefined) return null;
|
||||
const s = String(raw).trim().toLowerCase();
|
||||
return VALID_STATUS.includes(s as FrontmatterStatus) ? (s as FrontmatterStatus) : null;
|
||||
}
|
||||
|
||||
function coerceNext(raw: YamlValue): string | null {
|
||||
if (raw === null || raw === undefined) return null;
|
||||
const s = String(raw).trim();
|
||||
return s === "" ? null : s;
|
||||
}
|
||||
|
||||
function coerceConfidence(raw: YamlValue): number | null {
|
||||
if (raw === null || raw === undefined) return null;
|
||||
const n = typeof raw === "number" ? raw : Number(String(raw).trim());
|
||||
if (Number.isNaN(n)) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
function coerceArtifacts(raw: YamlValue): readonly string[] {
|
||||
if (raw === null || raw === undefined) return [];
|
||||
if (Array.isArray(raw)) return raw.map(String).filter((s) => s !== "");
|
||||
const s = String(raw).trim();
|
||||
return s === "" ? [] : [s];
|
||||
}
|
||||
|
||||
function coerceScope(raw: YamlValue): FrontmatterScope {
|
||||
if (raw === null || raw === undefined) return "role";
|
||||
const s = String(raw).trim().toLowerCase();
|
||||
return VALID_SCOPE.includes(s as FrontmatterScope) ? (s as FrontmatterScope) : "role";
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -220,10 +191,6 @@ export function parseFrontmatterMarkdown(raw: string): ParsedFrontmatterMarkdown
|
||||
|
||||
const frontmatter: AgentFrontmatter = {
|
||||
status: coerceStatus(fields.status ?? null),
|
||||
next: coerceNext(fields.next ?? null),
|
||||
confidence: coerceConfidence(fields.confidence ?? null),
|
||||
artifacts: coerceArtifacts(fields.artifacts ?? null),
|
||||
scope: coerceScope(fields.scope ?? null),
|
||||
};
|
||||
|
||||
return { frontmatter, body };
|
||||
@@ -235,11 +202,7 @@ export function parseFrontmatterMarkdown(raw: string): ParsedFrontmatterMarkdown
|
||||
* An empty array means the frontmatter is valid.
|
||||
*
|
||||
* Validated constraints:
|
||||
* - `status` — must be one of the FrontmatterStatus literals (if non-null)
|
||||
* - `confidence` — must be in [0.0, 1.0] (if non-null)
|
||||
* - `next` — must be a non-empty string with no whitespace (if non-null)
|
||||
* - `artifacts` — each entry must be a non-empty string
|
||||
* - `scope` — must be one of the FrontmatterScope literals
|
||||
* - `status` — must be one of the FrontmatterStatus literals (if non-null)
|
||||
*/
|
||||
export function validateFrontmatter(
|
||||
frontmatter: AgentFrontmatter,
|
||||
@@ -253,39 +216,5 @@ export function validateFrontmatter(
|
||||
});
|
||||
}
|
||||
|
||||
if (frontmatter.confidence !== null) {
|
||||
if (frontmatter.confidence < 0 || frontmatter.confidence > 1) {
|
||||
errors.push({
|
||||
field: "confidence",
|
||||
message: `confidence ${frontmatter.confidence} is out of range; must be between 0.0 and 1.0 inclusive`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (frontmatter.next !== null) {
|
||||
if (frontmatter.next.trim() === "") {
|
||||
errors.push({ field: "next", message: "next must be a non-empty string when present" });
|
||||
} else if (/\s/.test(frontmatter.next)) {
|
||||
errors.push({
|
||||
field: "next",
|
||||
message: `next "${frontmatter.next}" must not contain whitespace`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const artifact of frontmatter.artifacts) {
|
||||
if (artifact.trim() === "") {
|
||||
errors.push({ field: "artifacts", message: "artifact entries must be non-empty strings" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!VALID_SCOPE.includes(frontmatter.scope)) {
|
||||
errors.push({
|
||||
field: "scope",
|
||||
message: `invalid scope "${frontmatter.scope}"; must be one of: ${VALID_SCOPE.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export { parseFrontmatterMarkdown, validateFrontmatter } from "./frontmatter-markdown.js";
|
||||
export type {
|
||||
AgentFrontmatter,
|
||||
FrontmatterScope,
|
||||
FrontmatterStatus,
|
||||
FrontmatterValidationError,
|
||||
ParsedFrontmatterMarkdown,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Frontmatter Markdown — agent output format (RFC #351 Phase 1).
|
||||
* Frontmatter Markdown — agent output format.
|
||||
*
|
||||
* An agent response is a Markdown document with an optional YAML frontmatter
|
||||
* block at the top. The frontmatter carries structured signals that the
|
||||
@@ -9,17 +9,12 @@
|
||||
*
|
||||
* ---
|
||||
* status: done
|
||||
* next: reviewer
|
||||
* confidence: 0.9
|
||||
* artifacts:
|
||||
* - src/foo.ts
|
||||
* scope: role
|
||||
* ---
|
||||
*
|
||||
* ... free-form markdown body ...
|
||||
*
|
||||
* All frontmatter fields are optional at the parse level. `validateFrontmatter`
|
||||
* enforces the constraints documented on each field below.
|
||||
* Only `status` is a standard frontmatter field. All other fields are
|
||||
* role-specific and defined by the output schema.
|
||||
*/
|
||||
|
||||
// ── Vocabulary types ─────────────────────────────────────────────────────────
|
||||
@@ -34,20 +29,12 @@
|
||||
*/
|
||||
export type FrontmatterStatus = "done" | "needs_input" | "in_progress" | "failed";
|
||||
|
||||
/**
|
||||
* Scope of frontmatter signals.
|
||||
*
|
||||
* - `role` — signals apply to the current role execution only (default)
|
||||
* - `thread` — signals are suggestions for the entire thread moderator
|
||||
*/
|
||||
export type FrontmatterScope = "role" | "thread";
|
||||
|
||||
// ── Core frontmatter schema ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parsed and validated frontmatter from an agent response.
|
||||
*
|
||||
* All fields use explicit `T | null` (no optional `?:` per convention).
|
||||
* Only `status` is a standard field. All other fields are role-specific.
|
||||
*/
|
||||
export type AgentFrontmatter = {
|
||||
/**
|
||||
@@ -55,32 +42,6 @@ export type AgentFrontmatter = {
|
||||
* Null when omitted — engine treats it as "done" for backward compatibility.
|
||||
*/
|
||||
status: FrontmatterStatus | null;
|
||||
|
||||
/**
|
||||
* Suggested next role name for the moderator.
|
||||
* The moderator is NOT obligated to follow this — it is advisory only.
|
||||
* Null when the agent has no preference.
|
||||
*/
|
||||
next: string | null;
|
||||
|
||||
/**
|
||||
* Agent's self-assessed confidence in its output (0.0 – 1.0 inclusive).
|
||||
* Null when omitted.
|
||||
*/
|
||||
confidence: number | null;
|
||||
|
||||
/**
|
||||
* Relative file paths or CAS hashes the agent considers its primary outputs.
|
||||
* Used for GC ref-tracing and human-readable summaries.
|
||||
* Empty array when omitted (never null — an absent list is an empty list).
|
||||
*/
|
||||
artifacts: readonly string[];
|
||||
|
||||
/**
|
||||
* Scope of the frontmatter signals.
|
||||
* Defaults to "role" when omitted.
|
||||
*/
|
||||
scope: FrontmatterScope;
|
||||
};
|
||||
|
||||
// ── Parse output ─────────────────────────────────────────────────────────────
|
||||
@@ -103,9 +64,4 @@ export type ParsedFrontmatterMarkdown = {
|
||||
|
||||
// ── Validation error ─────────────────────────────────────────────────────────
|
||||
|
||||
export type FrontmatterValidationError =
|
||||
| { field: "status"; message: string }
|
||||
| { field: "next"; message: string }
|
||||
| { field: "confidence"; message: string }
|
||||
| { field: "artifacts"; message: string }
|
||||
| { field: "scope"; message: string };
|
||||
export type FrontmatterValidationError = { field: "status"; message: string };
|
||||
|
||||
@@ -3,12 +3,12 @@ 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";
|
||||
export type {
|
||||
AgentFrontmatter,
|
||||
FrontmatterScope,
|
||||
FrontmatterStatus,
|
||||
FrontmatterValidationError,
|
||||
ParsedFrontmatterMarkdown,
|
||||
|
||||
@@ -121,5 +121,15 @@ 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 |
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/__tests__/**/*.test.ts"],
|
||||
passWithNoTests: true,
|
||||
},
|
||||
});
|
||||
@@ -21,6 +21,7 @@ const publishOrder = [
|
||||
"workflow-util-agent",
|
||||
"workflow-agent-hermes",
|
||||
"workflow-agent-builtin",
|
||||
"workflow-agent-claude-code",
|
||||
"cli-workflow",
|
||||
];
|
||||
|
||||
@@ -59,7 +60,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 ${tagFlag}`;
|
||||
const cmd = `npm publish --access public --ignore-scripts ${tagFlag}`;
|
||||
|
||||
console.log(`📦 ${name}...`);
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
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)。
|
||||
@@ -23,6 +23,7 @@
|
||||
{ "path": "packages/workflow-util-agent" },
|
||||
{ "path": "packages/workflow-agent-hermes" },
|
||||
{ "path": "packages/workflow-agent-builtin" },
|
||||
{ "path": "packages/workflow-agent-claude-code" },
|
||||
{ "path": "packages/cli-workflow" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,852 @@
|
||||
name: normalize-bun-monorepo
|
||||
graph:
|
||||
ci:
|
||||
done:
|
||||
role: solve-issue-workflow
|
||||
prompt: CI configured. Register solve-issue workflow for repo at {{{repoPath}}}.
|
||||
skipped:
|
||||
role: solve-issue-workflow
|
||||
prompt: "ci already configured, skipped."
|
||||
failed:
|
||||
role: solve-issue-workflow
|
||||
prompt: CI setup failed ({{{reason}}}), but continue. Register solve-issue workflow for repo at {{{repoPath}}}.
|
||||
biome:
|
||||
done:
|
||||
role: package-metadata
|
||||
prompt: Biome configured. Standardize package metadata for repo at {{{repoPath}}}.
|
||||
skipped:
|
||||
role: package-metadata
|
||||
prompt: "biome already configured, skipped."
|
||||
failed:
|
||||
role: package-metadata
|
||||
prompt: Biome setup failed ({{{reason}}}), but continue. Standardize package metadata for repo at {{{repoPath}}}.
|
||||
$START:
|
||||
_:
|
||||
role: workspace
|
||||
prompt: Set up bun workspace structure for repo at {{{repoPath}}}.
|
||||
release:
|
||||
done:
|
||||
role: testing
|
||||
prompt: Release pipeline configured. Set up vitest for repo at {{{repoPath}}}.
|
||||
skipped:
|
||||
role: testing
|
||||
prompt: "release already configured, skipped."
|
||||
failed:
|
||||
role: testing
|
||||
prompt: Release pipeline failed ({{{reason}}}), but continue. Set up vitest for repo at {{{repoPath}}}.
|
||||
testing:
|
||||
done:
|
||||
role: ci
|
||||
prompt: Testing configured. Set up Gitea CI for repo at {{{repoPath}}}.
|
||||
skipped:
|
||||
role: ci
|
||||
prompt: "testing already configured, skipped."
|
||||
failed:
|
||||
role: ci
|
||||
prompt: Testing setup failed ({{{reason}}}), but continue. Set up Gitea CI for repo at {{{repoPath}}}.
|
||||
committer:
|
||||
failed:
|
||||
role: $END
|
||||
prompt: "Commit failed: {{{reason}}}."
|
||||
committed:
|
||||
role: $END
|
||||
prompt: "Normalization committed: {{{commitHash}}}."
|
||||
no_changes:
|
||||
role: $END
|
||||
prompt: Repo already normalized, no changes needed.
|
||||
workspace:
|
||||
done:
|
||||
role: typescript
|
||||
prompt: Workspace ready. Configure TypeScript for repo at {{{repoPath}}}.
|
||||
skipped:
|
||||
role: typescript
|
||||
prompt: "workspace already configured, skipped."
|
||||
failed:
|
||||
role: typescript
|
||||
prompt: Workspace setup failed ({{{reason}}}), but continue. Configure TypeScript for repo at {{{repoPath}}}.
|
||||
guardrails:
|
||||
done:
|
||||
role: committer
|
||||
prompt: All normalization complete. Commit changes in repo at {{{repoPath}}}.
|
||||
skipped:
|
||||
role: committer
|
||||
prompt: "guardrails already configured, skipped."
|
||||
failed:
|
||||
role: committer
|
||||
prompt: Guardrails failed ({{{reason}}}), but commit whatever was done in repo at {{{repoPath}}}.
|
||||
typescript:
|
||||
done:
|
||||
role: biome
|
||||
prompt: TypeScript configured. Set up Biome for repo at {{{repoPath}}}.
|
||||
skipped:
|
||||
role: biome
|
||||
prompt: "typescript already configured, skipped."
|
||||
failed:
|
||||
role: biome
|
||||
prompt: TypeScript setup failed ({{{reason}}}), but continue. Set up Biome for repo at {{{repoPath}}}.
|
||||
package-metadata:
|
||||
done:
|
||||
role: release
|
||||
prompt: Package metadata standardized. Configure release pipeline for repo at {{{repoPath}}}.
|
||||
skipped:
|
||||
role: release
|
||||
prompt: "package-metadata already configured, skipped."
|
||||
failed:
|
||||
role: release
|
||||
prompt: Package metadata failed ({{{reason}}}), but continue. Configure release pipeline for repo at {{{repoPath}}}.
|
||||
solve-issue-workflow:
|
||||
done:
|
||||
role: guardrails
|
||||
prompt: Solve-issue workflow placed in .workflows/. Install guardrails for repo at {{{repoPath}}}.
|
||||
skipped:
|
||||
role: guardrails
|
||||
prompt: "solve-issue-workflow already configured, skipped."
|
||||
failed:
|
||||
role: guardrails
|
||||
prompt: Solve-issue workflow failed ({{{reason}}}), but continue. Install guardrails for repo at {{{repoPath}}}.
|
||||
roles:
|
||||
ci:
|
||||
goal: You configure Gitea Actions CI for build, lint, and test on push/PR.
|
||||
output: Describe the CI pipeline configured. Set $status to done or failed.
|
||||
procedure: |
|
||||
## GROUND RULES (read before doing anything)
|
||||
- Only report actions you ACTUALLY performed and files you ACTUALLY created or modified.
|
||||
- If everything is already correctly configured, set $status=skipped.
|
||||
- NEVER fabricate command output. Run every verification command for real and paste the actual stdout/stderr.
|
||||
- After writing a file, run `test -f <path> && echo EXISTS || echo MISSING` to confirm it was actually written.
|
||||
|
||||
cd into the repo path from your task prompt.
|
||||
|
||||
If `.gitea/workflows/ci.yml` already exists, review it for completeness but don't overwrite unless it's missing key steps.
|
||||
If `.github/workflows/` exists (GitHub Actions), keep it — add `.gitea/workflows/` alongside it.
|
||||
|
||||
Create `.gitea/workflows/ci.yml` (if not present):
|
||||
```yaml
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['*']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- run: bun install
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
- name: Lint
|
||||
run: bun run check
|
||||
|
||||
- name: Test
|
||||
run: bun run test:ci
|
||||
```
|
||||
|
||||
## Verification
|
||||
You MUST actually run each command below and include real output. Do NOT guess or fabricate results.
|
||||
```bash
|
||||
# 1. CI file exists
|
||||
test -f .gitea/workflows/ci.yml
|
||||
# 2. YAML is valid
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('.gitea/workflows/ci.yml', 'utf8');
|
||||
if (!content.includes('bun')) { console.error('Missing bun setup'); process.exit(1); }
|
||||
if (!content.includes('test')) { console.error('Missing test step'); process.exit(1); }
|
||||
console.log('CI YAML looks valid');
|
||||
"
|
||||
# 3. Required root scripts exist for CI to work
|
||||
node -e "
|
||||
const pkg = require('./package.json');
|
||||
const required = ['build', 'check', 'test:ci'];
|
||||
const missing = required.filter(s => !pkg.scripts?.[s]);
|
||||
if (missing.length) { console.error('Missing scripts for CI:', missing.join(', ')); process.exit(1); }
|
||||
console.log('All CI-required scripts present');
|
||||
"
|
||||
```
|
||||
|
||||
Post-condition: CI file exists, YAML references bun and test, all required scripts exist in package.json.
|
||||
description: Set up Gitea CI workflow
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status:
|
||||
const: done
|
||||
repoPath:
|
||||
type: string
|
||||
- properties:
|
||||
$status:
|
||||
const: skipped
|
||||
repoPath:
|
||||
type: string
|
||||
- properties:
|
||||
$status:
|
||||
const: failed
|
||||
reason:
|
||||
type: string
|
||||
repoPath:
|
||||
type: string
|
||||
capabilities:
|
||||
- ci-config
|
||||
biome:
|
||||
goal: You configure Biome for consistent code quality across the monorepo.
|
||||
output: List what was configured and any remaining lint issues. Set $status to done or failed.
|
||||
procedure: |
|
||||
## GROUND RULES (read before doing anything)
|
||||
- Only report actions you ACTUALLY performed and files you ACTUALLY created or modified.
|
||||
- If everything is already correctly configured, set $status=skipped.
|
||||
- NEVER fabricate command output. Run every verification command for real and paste the actual stdout/stderr.
|
||||
- After writing a file, run `test -f <path> && echo EXISTS || echo MISSING` to confirm it was actually written.
|
||||
|
||||
cd into the repo path from your task prompt.
|
||||
|
||||
Be idempotent — if biome.json already exists, merge missing settings rather than overwriting.
|
||||
|
||||
Check and fix:
|
||||
1. Install biome: add `@biomejs/biome` to root devDependencies (skip if already present)
|
||||
2. Root `biome.json` must exist with at minimum:
|
||||
- `files.includes`: `["**", "!**/dist", "!**/node_modules"]`
|
||||
- `formatter`: indentStyle space, indentWidth 2, lineWidth 100
|
||||
- `javascript.formatter`: quoteStyle double, semicolons always
|
||||
- `linter.rules.nursery.noConsole: "error"` (production code)
|
||||
- Override for test files (`**/__tests__/**`): `noConsole: "off"`, `noExplicitAny: "off"`
|
||||
- `assist.actions.source.organizeImports: "on"`
|
||||
If biome.json already exists, only add missing fields — preserve existing customizations.
|
||||
3. Root scripts must include: `"check"` (should include `biome check .`), `"format": "biome format --write ."`
|
||||
4. Run `bunx biome check .` — fix auto-fixable issues with `bunx biome check . --fix`
|
||||
5. Remaining unfixable issues: list them but don't block
|
||||
|
||||
## Verification
|
||||
You MUST actually run each command below and include real output. Do NOT guess or fabricate results.
|
||||
```bash
|
||||
# 1. biome.json exists
|
||||
test -f biome.json
|
||||
# 2. biome is installed
|
||||
bunx biome --version
|
||||
# 3. check runs (exit 0 or list remaining issues)
|
||||
bunx biome check . 2>&1 || true
|
||||
```
|
||||
|
||||
Post-condition: `biome.json` exists, `bunx biome check .` runs (may have warnings but no infrastructure errors).
|
||||
description: Configure Biome linter and formatter
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status:
|
||||
const: done
|
||||
repoPath:
|
||||
type: string
|
||||
- properties:
|
||||
$status:
|
||||
const: skipped
|
||||
repoPath:
|
||||
type: string
|
||||
- properties:
|
||||
$status:
|
||||
const: failed
|
||||
reason:
|
||||
type: string
|
||||
repoPath:
|
||||
type: string
|
||||
capabilities:
|
||||
- linter-config
|
||||
release:
|
||||
goal: "You set up the complete release pipeline: changesets for version management, publish script for npm release."
|
||||
output: Describe what was configured. Set $status to done or failed.
|
||||
procedure: |
|
||||
## GROUND RULES (read before doing anything)
|
||||
- Only report actions you ACTUALLY performed and files you ACTUALLY created or modified.
|
||||
- If everything is already correctly configured, set $status=skipped.
|
||||
- NEVER fabricate command output. Run every verification command for real and paste the actual stdout/stderr.
|
||||
- After writing a file, run `test -f <path> && echo EXISTS || echo MISSING` to confirm it was actually written.
|
||||
|
||||
cd into the repo path from your task prompt.
|
||||
|
||||
Be idempotent — skip steps that are already done.
|
||||
|
||||
## Part 1: Changesets
|
||||
1. Install: add `@changesets/cli` to root devDependencies (skip if present), run `bun install`
|
||||
2. If `.changeset/config.json` does not exist, run `bunx changeset init`
|
||||
3. Verify `.changeset/config.json` has:
|
||||
- `"access": "public"` (for @scoped packages)
|
||||
- `"baseBranch": "main"`
|
||||
4. Add `.changeset/README.md` if missing
|
||||
|
||||
## Part 2: Publish Script
|
||||
5. Create `scripts/publish-all.mjs` (skip if already exists and looks correct):
|
||||
- Scan `packages/` for non-private packages (skip `"private": true`)
|
||||
- Determine publish order by resolving workspace dependency graph (dependees before dependents)
|
||||
- For each package in order:
|
||||
a. Replace `"workspace:^"` deps with actual versions from workspace
|
||||
b. Run `npm publish --access public --ignore-scripts` (MUST use --ignore-scripts to bypass prepublishOnly guardrail)
|
||||
c. Restore original package.json
|
||||
- Support flags: `--dry-run`, `--tag <name>`
|
||||
- Handle errors: if one package fails, restore and continue
|
||||
CRITICAL: The publish command MUST include `--ignore-scripts` because the guardrails role adds a `prepublishOnly` script that blocks direct publishing.
|
||||
|
||||
## Part 3: Root Scripts
|
||||
6. Root scripts must include:
|
||||
- `"changeset": "bunx changeset"`
|
||||
- `"version": "bunx changeset version"`
|
||||
- `"release": "bun run build && bun run test && node scripts/publish-all.mjs"`
|
||||
|
||||
## Verification
|
||||
You MUST actually run each command below and include real output. Do NOT guess or fabricate results.
|
||||
```bash
|
||||
# 1. changeset config exists and is valid
|
||||
test -f .changeset/config.json
|
||||
node -e "const c = require('./.changeset/config.json'); console.log('access:', c.access, 'baseBranch:', c.baseBranch)"
|
||||
# 2. changeset status works
|
||||
bunx changeset status 2>&1 || true
|
||||
# 3. publish script exists and uses --ignore-scripts
|
||||
test -f scripts/publish-all.mjs
|
||||
grep -q 'ignore-scripts' scripts/publish-all.mjs
|
||||
# 4. dry run works
|
||||
node scripts/publish-all.mjs --dry-run
|
||||
```
|
||||
|
||||
Post-condition: All verification commands pass.
|
||||
description: Configure changesets and publish pipeline
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status:
|
||||
const: done
|
||||
repoPath:
|
||||
type: string
|
||||
- properties:
|
||||
$status:
|
||||
const: skipped
|
||||
repoPath:
|
||||
type: string
|
||||
- properties:
|
||||
$status:
|
||||
const: failed
|
||||
reason:
|
||||
type: string
|
||||
repoPath:
|
||||
type: string
|
||||
capabilities:
|
||||
- changeset-config
|
||||
- release-config
|
||||
testing:
|
||||
goal: You set up vitest test infrastructure across the monorepo.
|
||||
output: List what was configured per package. Set $status to done or failed.
|
||||
procedure: |
|
||||
## GROUND RULES (read before doing anything)
|
||||
- Only report actions you ACTUALLY performed and files you ACTUALLY created or modified.
|
||||
- If everything is already correctly configured, set $status=skipped.
|
||||
- NEVER fabricate command output. Run every verification command for real and paste the actual stdout/stderr.
|
||||
- After writing a file, run `test -f <path> && echo EXISTS || echo MISSING` to confirm it was actually written.
|
||||
|
||||
cd into the repo path from your task prompt.
|
||||
|
||||
Be idempotent — do NOT overwrite existing vitest.config.ts or test files.
|
||||
|
||||
Check and fix:
|
||||
1. Add `vitest` to root devDependencies (skip if present), run `bun install`
|
||||
2. For each package under `packages/`:
|
||||
- If `vitest.config.ts` does NOT already exist, create it:
|
||||
```ts
|
||||
import { defineConfig } from "vitest/config";
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/__tests__/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
```
|
||||
- If package.json has no `"test"` script, add: `"test": "vitest run --passWithNoTests"`, `"test:ci": "vitest run --passWithNoTests"`
|
||||
- Create `src/__tests__/` directory if it doesn't exist
|
||||
3. Root package.json scripts must include:
|
||||
- `"test": "bun run --filter './packages/*' test"`
|
||||
- `"test:ci": "bun run --filter './packages/*' test:ci"`
|
||||
|
||||
## Verification
|
||||
You MUST actually run each command below and include real output. Do NOT guess or fabricate results.
|
||||
```bash
|
||||
# 1. vitest is installed
|
||||
bunx vitest --version
|
||||
# 2. each package has vitest config
|
||||
for d in packages/*/; do
|
||||
if [ -f "$d/vitest.config.ts" ]; then echo "$d: ✅"; else echo "$d: ❌ missing vitest.config.ts"; fi
|
||||
done
|
||||
# 3. root test script works
|
||||
bun run test 2>&1 || true
|
||||
```
|
||||
|
||||
Post-condition: `bun run test` runs without infrastructure errors (no tests is OK, test failures are OK).
|
||||
description: Configure vitest for all packages
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status:
|
||||
const: done
|
||||
repoPath:
|
||||
type: string
|
||||
- properties:
|
||||
$status:
|
||||
const: skipped
|
||||
repoPath:
|
||||
type: string
|
||||
- properties:
|
||||
$status:
|
||||
const: failed
|
||||
reason:
|
||||
type: string
|
||||
repoPath:
|
||||
type: string
|
||||
capabilities:
|
||||
- test-config
|
||||
committer:
|
||||
goal: You commit all the changes made by previous roles in a single clean commit.
|
||||
output: List files changed and commit hash. Set $status to committed or no_changes.
|
||||
procedure: |
|
||||
## GROUND RULES (read before doing anything)
|
||||
- Only report actions you ACTUALLY performed and files you ACTUALLY created or modified.
|
||||
- If everything is already correctly configured, set $status=skipped.
|
||||
- NEVER fabricate command output. Run every verification command for real and paste the actual stdout/stderr.
|
||||
- After writing a file, run `test -f <path> && echo EXISTS || echo MISSING` to confirm it was actually written.
|
||||
|
||||
cd into the repo path from your task prompt.
|
||||
|
||||
1. Review all changes: `git diff --stat` and `git status`
|
||||
2. Verify key files exist from previous roles (spot-check):
|
||||
```bash
|
||||
echo "=== Spot-check ==="
|
||||
if [ -f tsconfig.json ]; then echo "✅ tsconfig.json"; else echo "ℹ️ tsconfig.json (skipped for JS/MJS)"; fi
|
||||
for f in .gitignore biome.json .changeset/config.json .gitea/workflows/ci.yml .githooks/pre-push; do
|
||||
test -f "$f" && echo "✅ $f" || echo "⚠️ MISSING: $f"
|
||||
done
|
||||
node -e "
|
||||
const p = require('./package.json');
|
||||
const required = ['build', 'check', 'test', 'test:ci', 'format', 'preinstall', 'prepublishOnly'];
|
||||
const missing = required.filter(s => !p.scripts?.[s]);
|
||||
if (missing.length) console.log('⚠️ Missing scripts:', missing.join(', '));
|
||||
else console.log('✅ All scripts present');
|
||||
if (!p.packageManager) console.log('⚠️ Missing packageManager');
|
||||
else console.log('✅ packageManager:', p.packageManager);
|
||||
"
|
||||
```
|
||||
List any missing items as warnings but still commit what exists.
|
||||
3. If no changes: set $status=no_changes
|
||||
4. Stage all: `git add -A`
|
||||
5. **Before committing, check for build artifacts that should NOT be committed:**
|
||||
```bash
|
||||
# Detect compiled output accidentally staged
|
||||
git diff --cached --name-only | grep -E '\.(d\.ts|\.js\.map)$' | grep -v node_modules | head -20
|
||||
# Also check for .js files next to .ts sources (build output in src/)
|
||||
for f in $(git diff --cached --name-only | grep -E '\.js$' | grep -v node_modules | grep -v scripts/); do
|
||||
ts_file="${f%.js}.ts"
|
||||
if [ -f "$ts_file" ]; then echo "BUILD ARTIFACT: $f (has matching $ts_file)"; fi
|
||||
done
|
||||
```
|
||||
If build artifacts are found:
|
||||
- Unstage them: `git reset HEAD <files>`
|
||||
- Add patterns to `.gitignore` if missing (e.g. `*.d.ts`, `*.js.map`, or specific output dirs)
|
||||
- Re-run `git add -A` after updating `.gitignore`
|
||||
6. Commit: `git commit -m "chore: normalize to bun monorepo conventions"`
|
||||
7. Push: `git push`
|
||||
|
||||
Post-condition: Clean commit pushed, `git status` shows clean working tree. No build artifacts in the commit.
|
||||
description: Commits all normalization changes
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status:
|
||||
const: committed
|
||||
commitHash:
|
||||
type: string
|
||||
repoPath:
|
||||
type: string
|
||||
- properties:
|
||||
$status:
|
||||
const: no_changes
|
||||
repoPath:
|
||||
type: string
|
||||
- properties:
|
||||
$status:
|
||||
const: failed
|
||||
reason:
|
||||
type: string
|
||||
repoPath:
|
||||
type: string
|
||||
capabilities: []
|
||||
workspace:
|
||||
goal: You set up the foundational bun workspace configuration for a monorepo.
|
||||
output: List what was changed. Set $status to done (workspace working) or failed (with reason).
|
||||
procedure: |
|
||||
## GROUND RULES (read before doing anything)
|
||||
- Only report actions you ACTUALLY performed and files you ACTUALLY created or modified.
|
||||
- If everything is already correctly configured, set $status=skipped.
|
||||
- NEVER fabricate command output. Run every verification command for real and paste the actual stdout/stderr.
|
||||
- After writing a file, run `test -f <path> && echo EXISTS || echo MISSING` to confirm it was actually written.
|
||||
|
||||
cd into the repo path provided in your task prompt.
|
||||
|
||||
Be idempotent — check before modifying. If something is already correct, skip it.
|
||||
|
||||
Check and fix:
|
||||
1. Root `package.json` must have `"workspaces": ["packages/*"]`
|
||||
2. Root `package.json` must have `"private": true`
|
||||
3. If packages exist in other locations (e.g. root src/, top-level dirs), migrate them under `packages/`
|
||||
4. Each package under `packages/` must have its own `package.json` with `"name"` and `"type": "module"`
|
||||
5. `.gitignore` must exist and include at minimum:
|
||||
```
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
```
|
||||
If `.gitignore` is missing or doesn't cover these, append the missing entries (don't overwrite existing content).
|
||||
6. If node_modules/ is already tracked in git, remove it: `git rm -r --cached node_modules/ */node_modules/ 2>/dev/null`
|
||||
7. Run `bun install` to verify workspace resolution works
|
||||
|
||||
## Verification (must all pass)
|
||||
You MUST actually run each command below and include real output. Do NOT guess or fabricate results.
|
||||
```bash
|
||||
# 1. bun install works
|
||||
bun install
|
||||
# 2. gitignore covers essentials
|
||||
grep -q 'node_modules' .gitignore
|
||||
grep -q 'dist' .gitignore
|
||||
# 3. no node_modules tracked
|
||||
test -z "$(git ls-files | grep node_modules)"
|
||||
# 4. all packages have package.json
|
||||
for d in packages/*/; do test -f "$d/package.json" || echo "MISSING: $d/package.json"; done
|
||||
```
|
||||
|
||||
Post-condition: All verification commands pass.
|
||||
description: Ensure bun workspace structure
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status:
|
||||
const: done
|
||||
repoPath:
|
||||
type: string
|
||||
- properties:
|
||||
$status:
|
||||
const: skipped
|
||||
repoPath:
|
||||
type: string
|
||||
- properties:
|
||||
$status:
|
||||
const: failed
|
||||
reason:
|
||||
type: string
|
||||
repoPath:
|
||||
type: string
|
||||
capabilities:
|
||||
- workspace-setup
|
||||
guardrails:
|
||||
goal: You configure enforcement mechanisms that block npm/pnpm/yarn usage and direct npm publish.
|
||||
output: List what guardrails were installed. Set $status to done or failed.
|
||||
procedure: |
|
||||
## GROUND RULES (read before doing anything)
|
||||
- Only report actions you ACTUALLY performed and files you ACTUALLY created or modified.
|
||||
- If everything is already correctly configured, set $status=skipped.
|
||||
- NEVER fabricate command output. Run every verification command for real and paste the actual stdout/stderr.
|
||||
- After writing a file, run `test -f <path> && echo EXISTS || echo MISSING` to confirm it was actually written.
|
||||
|
||||
cd into the repo path from your task prompt.
|
||||
|
||||
Be idempotent — check before adding.
|
||||
|
||||
## 1. Block wrong package manager
|
||||
Add to root `package.json` (if not already present):
|
||||
- `"packageManager": "bun@<version>"` — use the version from `bun --version`
|
||||
- `"scripts.preinstall": "npx only-allow bun"` — blocks npm/pnpm/yarn install
|
||||
|
||||
## 2. Block direct npm publish
|
||||
Add to root `package.json` (if not already present):
|
||||
- `"scripts.prepublishOnly": "echo 'Use bun run release instead' && exit 1"`
|
||||
|
||||
For each non-private package under `packages/`:
|
||||
- Add `"scripts.prepublishOnly": "echo 'Use bun run release from repo root' && exit 1"` to their package.json (if not present)
|
||||
|
||||
If `scripts/publish-all.mjs` exists, verify it uses `--ignore-scripts` in the npm publish command.
|
||||
If it doesn't, add `--ignore-scripts` to the publish command.
|
||||
|
||||
## 3. Git hooks
|
||||
Create `.githooks/pre-push` (if not already present):
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo "🔍 Running checks..."
|
||||
bun run check
|
||||
echo "🧪 Running tests..."
|
||||
bun run test
|
||||
echo "✅ All checks passed!"
|
||||
```
|
||||
Make it executable: `chmod +x .githooks/pre-push`
|
||||
Configure git to use hooks dir: `git config core.hooksPath .githooks`
|
||||
|
||||
## Verification
|
||||
You MUST actually run each command below and include real output. Do NOT guess or fabricate results.
|
||||
```bash
|
||||
# 1. packageManager field exists
|
||||
node -e "const p = require('./package.json'); if (!p.packageManager) { console.error('❌ missing packageManager'); process.exit(1); } console.log('✅ packageManager:', p.packageManager)"
|
||||
# 2. preinstall guard exists
|
||||
node -e "const p = require('./package.json'); if (!p.scripts?.preinstall?.includes('only-allow')) { console.error('❌ missing preinstall guard'); process.exit(1); } console.log('✅ preinstall guard')"
|
||||
# 3. npm install is blocked (with timeout to prevent hang)
|
||||
timeout 10 npm install 2>&1 | head -5 || true
|
||||
# 4. prepublishOnly exists
|
||||
node -e "const p = require('./package.json'); if (!p.scripts?.prepublishOnly) { console.error('❌ missing prepublishOnly'); process.exit(1); } console.log('✅ prepublishOnly guard')"
|
||||
# 5. publish script uses --ignore-scripts
|
||||
if [ -f scripts/publish-all.mjs ]; then grep -q 'ignore-scripts' scripts/publish-all.mjs && echo '✅ publish uses --ignore-scripts' || echo '❌ publish missing --ignore-scripts'; fi
|
||||
# 6. git hooks configured
|
||||
test -f .githooks/pre-push && echo '✅ pre-push hook' || echo '❌ missing pre-push hook'
|
||||
```
|
||||
|
||||
Post-condition: All verification checks pass.
|
||||
description: Install project guardrails to prevent wrong package manager and publish workflow
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status:
|
||||
const: done
|
||||
repoPath:
|
||||
type: string
|
||||
- properties:
|
||||
$status:
|
||||
const: skipped
|
||||
repoPath:
|
||||
type: string
|
||||
- properties:
|
||||
$status:
|
||||
const: failed
|
||||
reason:
|
||||
type: string
|
||||
repoPath:
|
||||
type: string
|
||||
capabilities:
|
||||
- guardrails
|
||||
typescript:
|
||||
goal: You configure TypeScript for a bun monorepo with composite project references.
|
||||
output: List what was configured. Set $status to done or failed.
|
||||
procedure: |-
|
||||
## GROUND RULES (read before doing anything)
|
||||
- Only report actions you ACTUALLY performed and files you ACTUALLY created or modified.
|
||||
- If everything is already correctly configured, set $status=skipped.
|
||||
- NEVER fabricate command output. Run every verification command for real and paste the actual stdout/stderr.
|
||||
- After writing a file, run `test -f <path> && echo EXISTS || echo MISSING` to confirm it was actually written.
|
||||
|
||||
cd into the repo path from your task prompt.
|
||||
|
||||
Be idempotent — if tsconfig.json already exists with correct settings, don't overwrite.
|
||||
|
||||
## Step 0: Detect if project needs TypeScript compilation
|
||||
|
||||
```bash
|
||||
TS_FILES=$(find packages/ -name '*.ts' -o -name '*.tsx' | grep -v node_modules | grep -v dist | grep -v '.d.ts' | head -5)
|
||||
```
|
||||
|
||||
**If there are NO .ts/.tsx source files** (pure JS/MJS project):
|
||||
- Do NOT create root tsconfig.json
|
||||
- Do NOT add `bunx tsc --build` as the build script
|
||||
- Do NOT add typescript/bun-types to devDependencies unless already present
|
||||
- Preserve the existing `build` script (e.g. vite build, esbuild, etc.)
|
||||
- If no build script exists, add one based on the project's bundler (check for vite.config, esbuild, etc.)
|
||||
- Set $status=done with note "JS/MJS project — skipped TypeScript setup"
|
||||
- STOP HERE.
|
||||
|
||||
**If .ts/.tsx files exist**, continue with full TypeScript setup:
|
||||
|
||||
Check and fix:
|
||||
1. Root `tsconfig.json` must exist with:
|
||||
- `"compilerOptions"`: target ES2022, module NodeNext, moduleResolution NodeNext, strict true, composite true, declaration true, declarationMap true, sourceMap true
|
||||
- `"references"`: array with `{ "path": "packages/<name>" }` for each package
|
||||
- `"files": []` (root does not compile files directly)
|
||||
2. Each package must have `tsconfig.json` with:
|
||||
- `"extends": "../../tsconfig.json"` (inherit root config)
|
||||
- `"compilerOptions": { "rootDir": "src", "outDir": "dist" }`
|
||||
- `"include": ["src"]`
|
||||
- `"references"` to sibling packages it depends on
|
||||
3. Root scripts must include: `"build": "bunx tsc --build"`, `"typecheck": "bunx tsc --build"`
|
||||
4. `devDependencies` at root: `typescript`, `bun-types`, `@types/node`
|
||||
|
||||
## Verification
|
||||
You MUST actually run each command below and include real output. Do NOT guess or fabricate results.
|
||||
```bash
|
||||
if [ ! -f tsconfig.json ]; then
|
||||
echo "JS/MJS project — no tsconfig needed"
|
||||
node -e "const p = require('./package.json'); if (!p.scripts?.build) { console.error('Missing build script'); process.exit(1); } console.log('build:', p.scripts.build)"
|
||||
bun run build
|
||||
exit 0
|
||||
fi
|
||||
test -f tsconfig.json
|
||||
for d in packages/*/; do test -f "$d/tsconfig.json" || echo "MISSING: $d/tsconfig.json"; done
|
||||
bunx tsc --build
|
||||
```
|
||||
|
||||
Post-condition: For TS projects — `bunx tsc --build` succeeds. For JS/MJS projects — `bun run build` succeeds.
|
||||
description: Configure TypeScript with project references
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status:
|
||||
const: done
|
||||
repoPath:
|
||||
type: string
|
||||
- properties:
|
||||
$status:
|
||||
const: skipped
|
||||
repoPath:
|
||||
type: string
|
||||
- properties:
|
||||
$status:
|
||||
const: failed
|
||||
reason:
|
||||
type: string
|
||||
repoPath:
|
||||
type: string
|
||||
capabilities:
|
||||
- typescript-config
|
||||
package-metadata:
|
||||
goal: You ensure every package has consistent metadata for publishing and discoverability.
|
||||
output: List what was standardized per package. Set $status to done or failed.
|
||||
procedure: |
|
||||
## GROUND RULES (read before doing anything)
|
||||
- Only report actions you ACTUALLY performed and files you ACTUALLY created or modified.
|
||||
- If everything is already correctly configured, set $status=skipped.
|
||||
- NEVER fabricate command output. Run every verification command for real and paste the actual stdout/stderr.
|
||||
- After writing a file, run `test -f <path> && echo EXISTS || echo MISSING` to confirm it was actually written.
|
||||
|
||||
cd into the repo path from your task prompt.
|
||||
|
||||
Be idempotent — skip fields that are already correctly set.
|
||||
|
||||
For each package under `packages/`:
|
||||
1. `"type": "module"` — must be set
|
||||
2. `"files": ["src", "dist", "package.json"]` — for published packages (non-private)
|
||||
3. `"publishConfig": { "access": "public" }` — for @scoped public packages (non-private)
|
||||
4. `"repository"` — must point to the correct git remote and directory
|
||||
- Read remote URL: `git remote get-url origin`
|
||||
- Set `"directory": "packages/<name>"`
|
||||
5. `"exports"` — conditional exports for TypeScript packages:
|
||||
```json
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
```
|
||||
Skip if package has no `src/index.ts`.
|
||||
6. Private packages (server, frontend, tools) must have `"private": true` and can skip publishConfig/files
|
||||
7. Each package should have a `"scripts"` section with at least `"test"` if tests exist
|
||||
8. Workspace dependencies should use `"workspace:^"` protocol, not version numbers
|
||||
- Check: `grep -r '"@uncaged/' packages/*/package.json | grep -v 'workspace:'`
|
||||
|
||||
## Verification
|
||||
You MUST actually run each command below and include real output. Do NOT guess or fabricate results.
|
||||
```bash
|
||||
# Check every non-private package has required fields
|
||||
for d in packages/*/; do
|
||||
pkg="$d/package.json"
|
||||
test -f "$pkg" || continue
|
||||
node -e "
|
||||
const p = require('./$pkg');
|
||||
if (p.private) { console.log('$d: private, skip'); process.exit(0); }
|
||||
const issues = [];
|
||||
if (p.type !== 'module') issues.push('missing type:module');
|
||||
if (!p.exports) issues.push('missing exports');
|
||||
if (!p.publishConfig) issues.push('missing publishConfig');
|
||||
if (!p.files) issues.push('missing files');
|
||||
if (issues.length) console.log('$d:', issues.join(', '));
|
||||
else console.log('$d: OK');
|
||||
"
|
||||
done
|
||||
# No hardcoded workspace deps
|
||||
! grep -r '"@uncaged/' packages/*/package.json | grep -v 'workspace:' | grep -v node_modules
|
||||
```
|
||||
|
||||
Post-condition: Verification script shows OK for all non-private packages, no hardcoded workspace versions.
|
||||
description: Standardize package.json metadata across all packages
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status:
|
||||
const: done
|
||||
repoPath:
|
||||
type: string
|
||||
- properties:
|
||||
$status:
|
||||
const: skipped
|
||||
repoPath:
|
||||
type: string
|
||||
- properties:
|
||||
$status:
|
||||
const: failed
|
||||
reason:
|
||||
type: string
|
||||
repoPath:
|
||||
type: string
|
||||
capabilities:
|
||||
- package-config
|
||||
solve-issue-workflow:
|
||||
goal: You place a solve-issue workflow YAML in .workflows/ so the project can use uwf thread start .workflows/solve-issue.yaml for issue resolution.
|
||||
output: Describe the workflow registered. Set $status to done or failed.
|
||||
procedure: |-
|
||||
## GROUND RULES (read before doing anything)
|
||||
- Only report actions you ACTUALLY performed and files you ACTUALLY created or modified.
|
||||
- If everything is already correctly configured, set $status=skipped.
|
||||
- NEVER fabricate command output. Run every verification command for real and paste the actual stdout/stderr.
|
||||
- After writing a file, run `test -f <path> && echo EXISTS || echo MISSING` to confirm it was actually written.
|
||||
|
||||
cd into the repo path from your task prompt.
|
||||
|
||||
1. Check if `uwf` CLI is available: `which uwf`
|
||||
- If not available: set $status=failed, reason="uwf CLI not installed"
|
||||
2. Check if `.workflows/solve-issue.yaml` already exists:
|
||||
- If it exists and looks correct (has planner/developer/reviewer/tester/committer roles), skip. Set $status=done.
|
||||
3. Create `.workflows/solve-issue.yaml` adapted for this project:
|
||||
- Copy the standard solve-issue workflow structure (planner -> developer -> reviewer -> tester -> committer)
|
||||
- Adjust the developer role procedure to use this project's test runner and build commands
|
||||
- The workflow should reference the correct repo path and build toolchain (bun)
|
||||
NOTE: Place the file in `.workflows/` (dot-prefix), NOT `workflows/`. Do NOT run `uwf workflow add`. The file is used directly via `uwf thread start .workflows/solve-issue.yaml`.
|
||||
|
||||
## Verification
|
||||
You MUST actually run each command below and include real output. Do NOT guess or fabricate results.
|
||||
```bash
|
||||
test -f .workflows/solve-issue.yaml
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('.workflows/solve-issue.yaml', 'utf8');
|
||||
const required = ['planner', 'developer', 'reviewer', 'tester', 'committer'];
|
||||
const missing = required.filter(r => !content.includes(r + ':'));
|
||||
if (missing.length) { console.error('Missing roles:', missing.join(', ')); process.exit(1); }
|
||||
console.log('All roles present');
|
||||
"
|
||||
```
|
||||
|
||||
Post-condition: `.workflows/solve-issue.yaml` exists with all 5 roles.
|
||||
description: Register solve-issue workflow for the project
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status:
|
||||
const: done
|
||||
repoPath:
|
||||
type: string
|
||||
- properties:
|
||||
$status:
|
||||
const: skipped
|
||||
repoPath:
|
||||
type: string
|
||||
- properties:
|
||||
$status:
|
||||
const: failed
|
||||
reason:
|
||||
type: string
|
||||
repoPath:
|
||||
type: string
|
||||
capabilities:
|
||||
- workflow-config
|
||||
description: Normalize an existing project to @uncaged bun monorepo conventions. Supports both TypeScript and JS/MJS projects. Each role handles one configuration layer. All roles allow fail.
|
||||
@@ -0,0 +1,323 @@
|
||||
name: solve-issue
|
||||
description: TDD-driven issue resolution adapted for the workflow monorepo with bun + vitest
|
||||
roles:
|
||||
planner:
|
||||
description: Analyzes issue and outputs a TDD test spec
|
||||
goal: You are a planning agent. You analyze Gitea issues and produce a TDD test specification that downstream roles will implement and verify.
|
||||
capabilities:
|
||||
- issue-analysis
|
||||
- planning
|
||||
procedure: 'On first run (no previous steps):
|
||||
|
||||
1. Read the issue and all comments from Gitea using `tea issues <number> -r <owner/repo>`
|
||||
|
||||
2. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md) in the repo
|
||||
|
||||
3. Assess whether the issue has enough information to produce a test spec
|
||||
|
||||
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output $status=insufficient_info
|
||||
|
||||
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
|
||||
|
||||
|
||||
On subsequent runs (bounced back by tester with fix_spec):
|
||||
|
||||
1. Read the tester''s output from the previous step to understand what''s wrong with the spec
|
||||
|
||||
2. Revise the test spec accordingly
|
||||
|
||||
|
||||
After producing the test spec:
|
||||
|
||||
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
|
||||
|
||||
2. Put the hash in frontmatter.plan (required when $status=ready)
|
||||
|
||||
3. Set repoPath to the absolute path of the repository root
|
||||
|
||||
|
||||
|
||||
IMPORTANT: Extract the repo remote (owner/repo) from git:
|
||||
|
||||
```bash
|
||||
|
||||
git remote get-url origin | sed ''s|.*[:/]\([^/]*/[^.]*\).*|\1|''
|
||||
|
||||
```
|
||||
|
||||
Store the result as repoRemote in your frontmatter output so downstream roles can use it for tea/API calls.'
|
||||
output: Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info.
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status:
|
||||
const: ready
|
||||
plan:
|
||||
type: string
|
||||
repoPath:
|
||||
type: string
|
||||
repoRemote:
|
||||
type: string
|
||||
required:
|
||||
- $status
|
||||
- plan
|
||||
- repoPath
|
||||
- properties:
|
||||
$status:
|
||||
const: insufficient_info
|
||||
required:
|
||||
- $status
|
||||
developer:
|
||||
description: TDD implementation per test spec
|
||||
goal: You are a developer agent. You implement code changes following TDD — write tests first, then implementation.
|
||||
capabilities:
|
||||
- coding
|
||||
procedure: "IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.\nThe repo path and other details are provided in your task prompt.\n\nBefore starting any work,\
|
||||
\ set up an isolated worktree:\n1. cd into the repo path provided in your task prompt\n2. `git fetch origin` to get latest refs\n3. First time (no existing branch):\n - `git worktree add .worktrees/fix/<issue-number>-<short-slug>\
|
||||
\ -b fix/<issue-number>-<short-slug> origin/main`\n - `cd .worktrees/fix/<issue-number>-<short-slug> && bun install`\n4. If bounced back from reviewer or tester (branch already exists):\n - cd\
|
||||
\ into the existing worktree under `.worktrees/fix/<issue-number>-<short-slug>`\n - `git fetch origin && git rebase origin/main`\n5. ALL subsequent work must happen inside the worktree directory.\n\
|
||||
\nThen implement TDD:\n6. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner's output in your task prompt)\n7. If bounced back from reviewer or tester: read the\
|
||||
\ previous role's feedback in your task prompt\n8. Write tests first based on the spec (use vitest)\n9. Implement the code to make tests pass\n10. Ensure `bun run build` passes with no errors\n11.\
|
||||
\ Run `bun test` to verify all tests pass\n\nIf you cannot complete the implementation (e.g. the issue is too complex, blocked by external factors,\nor repeated attempts fail), set $status=failed\
|
||||
\ with a reason.\n"
|
||||
output: List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason).
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status:
|
||||
const: done
|
||||
branch:
|
||||
type: string
|
||||
worktree:
|
||||
type: string
|
||||
repoRemote:
|
||||
type: string
|
||||
required:
|
||||
- $status
|
||||
- branch
|
||||
- worktree
|
||||
- properties:
|
||||
$status:
|
||||
const: failed
|
||||
reason:
|
||||
type: string
|
||||
repoRemote:
|
||||
type: string
|
||||
required:
|
||||
- $status
|
||||
- reason
|
||||
reviewer:
|
||||
description: Code standards compliance check
|
||||
goal: You are a code reviewer. You verify code standards compliance — NOT functionality (that's the tester's job).
|
||||
capabilities:
|
||||
- code-review
|
||||
- static-analysis
|
||||
procedure: 'The worktree path is provided in your task prompt. cd into it first.
|
||||
|
||||
|
||||
Before reviewing, verify the git branch:
|
||||
|
||||
1. Run `git branch --show-current` — confirm the branch name references the issue number being worked on
|
||||
|
||||
2. If the branch doesn''t correspond to the issue, flag it in your output and reject
|
||||
|
||||
|
||||
Then perform code review:
|
||||
|
||||
Hard checks (must all pass):
|
||||
|
||||
3. `bun run build` — no build errors
|
||||
|
||||
4. `bunx biome check` — no lint violations
|
||||
|
||||
5. TypeScript strict mode — no type errors
|
||||
|
||||
|
||||
Soft checks (review against project conventions from CLAUDE.md):
|
||||
|
||||
- Functional-first: functions + types, no classes (except for errors or third-party requirements)
|
||||
|
||||
- Named exports only, no default exports
|
||||
|
||||
- No optional properties (use `T | null` instead of `?:`)
|
||||
|
||||
- Folder module discipline: index.ts only re-exports, types in types.ts
|
||||
|
||||
- Crockford Base32 log tags (8-char, unique per call site)
|
||||
|
||||
- No `console.log` in production code (use createLogger from @uncaged/workflow-util)
|
||||
|
||||
- No dynamic imports in production code
|
||||
|
||||
|
||||
Only review standards compliance. Do NOT test functionality.
|
||||
|
||||
If rejecting, you MUST explain the specific reason in your output.
|
||||
|
||||
'
|
||||
output: Explain your decision with specific file/line references. Set $status to approved (with branch/worktree) or rejected (with comments).
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status:
|
||||
const: approved
|
||||
branch:
|
||||
type: string
|
||||
worktree:
|
||||
type: string
|
||||
repoRemote:
|
||||
type: string
|
||||
required:
|
||||
- $status
|
||||
- branch
|
||||
- worktree
|
||||
- properties:
|
||||
$status:
|
||||
const: rejected
|
||||
comments:
|
||||
type: string
|
||||
worktree:
|
||||
type: string
|
||||
repoRemote:
|
||||
type: string
|
||||
required:
|
||||
- $status
|
||||
- comments
|
||||
- worktree
|
||||
tester:
|
||||
description: Functional correctness verification
|
||||
goal: You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec.
|
||||
capabilities:
|
||||
- testing
|
||||
procedure: "The worktree path is provided in your task prompt. cd into it first.\n\n1. Run `bun test` for automated test verification\n2. Read the test spec from CAS: `uwf cas get <plan hash>` (find\
|
||||
\ the hash from the planner step in the thread history)\n3. Verify each scenario in the spec is covered and passing\n4. Determine outcome:\n - passed: all scenarios verified, tests pass\n - fix_code:\
|
||||
\ tests fail or implementation doesn't match spec → send back to developer\n - fix_spec: the spec itself is wrong or incomplete → send back to planner\n"
|
||||
output: Report test results per scenario. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report).
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status:
|
||||
const: passed
|
||||
branch:
|
||||
type: string
|
||||
worktree:
|
||||
type: string
|
||||
repoRemote:
|
||||
type: string
|
||||
required:
|
||||
- $status
|
||||
- branch
|
||||
- worktree
|
||||
- properties:
|
||||
$status:
|
||||
const: fix_code
|
||||
report:
|
||||
type: string
|
||||
repoRemote:
|
||||
type: string
|
||||
worktree:
|
||||
type: string
|
||||
branch:
|
||||
type: string
|
||||
required:
|
||||
- $status
|
||||
- report
|
||||
- properties:
|
||||
$status:
|
||||
const: fix_spec
|
||||
report:
|
||||
type: string
|
||||
repoRemote:
|
||||
type: string
|
||||
worktree:
|
||||
type: string
|
||||
branch:
|
||||
type: string
|
||||
required:
|
||||
- $status
|
||||
- report
|
||||
committer:
|
||||
description: Commits and creates PR
|
||||
goal: You are a committer agent. You create a clean commit and push a PR linking the original issue.
|
||||
capabilities: []
|
||||
procedure: "The worktree path, branch name, and repo remote (owner/repo) are provided in your task prompt.\ncd into the worktree first.\n\nNote: You inherit the developer's worktree and branch. Do NOT\
|
||||
\ create a new branch.\n1. Stage all changes: `git add -A`\n2. Commit with a descriptive message referencing the issue: `git commit -m \"type: description\\n\\nFixes #N\"`\n3. Push the branch: `git\
|
||||
\ push -u origin <branch-name>`\n4. **Verify push succeeded** — run `git ls-remote origin <branch-name>` and confirm it prints a commit hash.\n - If no output or push failed: capture the error, mark hook_failed\n\
|
||||
5. Create a PR using the Gitea API (do NOT use `tea pr create` — it fails in worktrees):\n ```bash\n GITEA_TOKEN=$(cfg get GITEA_TOKEN)\n curl -s -X POST -H \"Authorization: token $GITEA_TOKEN\" -H \"Content-Type: application/json\" \\\n\
|
||||
\ \"https://git.shazhou.work/api/v1/repos/<owner>/<repo>/pulls\" \\\n -d '{\"title\":\"...\",\"body\":\"...\",\"head\":\"<branch>\",\"base\":\"main\"}'\n ```\n - The repo remote (owner/repo format, e.g. \"uncaged/workflow\") is given in your task prompt — use it directly.\n\
|
||||
\ - PR body must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref\n6. **Verify PR was created** — parse the curl response JSON: it must contain a `\"number\"` field. Print the PR URL.\n\
|
||||
\ - If curl returns an error or no number field: capture the response, mark hook_failed\n7. After PR creation, clean up the worktree:\n - cd to the repo root (parent of .worktrees)\n - `git worktree remove <worktree-path>`"
|
||||
output: Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error).
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status:
|
||||
const: committed
|
||||
prUrl:
|
||||
type: string
|
||||
repoRemote:
|
||||
type: string
|
||||
worktree:
|
||||
type: string
|
||||
branch:
|
||||
type: string
|
||||
required:
|
||||
- $status
|
||||
- prUrl
|
||||
- properties:
|
||||
$status:
|
||||
const: hook_failed
|
||||
error:
|
||||
type: string
|
||||
repoRemote:
|
||||
type: string
|
||||
worktree:
|
||||
type: string
|
||||
branch:
|
||||
type: string
|
||||
required:
|
||||
- $status
|
||||
- error
|
||||
graph:
|
||||
$START:
|
||||
_:
|
||||
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}}}.'
|
||||
developer:
|
||||
done:
|
||||
role: reviewer
|
||||
prompt: 'Review branch {{{branch}}} at {{{worktree}}} for code standards compliance. Repo remote: {{{repoRemote}}}.'
|
||||
failed:
|
||||
role: $END
|
||||
prompt: 'Developer failed: {{{reason}}}. Ending workflow.'
|
||||
reviewer:
|
||||
rejected:
|
||||
role: developer
|
||||
prompt: 'Reviewer rejected: {{{comments}}}. Fix the issues in repo {{{worktree}}}. Repo remote: {{{repoRemote}}}.'
|
||||
approved:
|
||||
role: tester
|
||||
prompt: 'Review passed. Run tests on branch {{{branch}}} at {{{worktree}}}. Repo remote: {{{repoRemote}}}.'
|
||||
tester:
|
||||
fix_code:
|
||||
role: developer
|
||||
prompt: 'Tests found code issues: {{{report}}}. Fix and re-submit. Worktree: {{{worktree}}}. Repo remote: {{{repoRemote}}}.'
|
||||
fix_spec:
|
||||
role: planner
|
||||
prompt: 'Tests found spec issues: {{{report}}}. Revise the test spec. Repo remote: {{{repoRemote}}}.'
|
||||
passed:
|
||||
role: committer
|
||||
prompt: 'All tests passed. Commit and push branch {{{branch}}} from {{{worktree}}}. Repo remote (owner/repo): {{{repoRemote}}}.'
|
||||
committer:
|
||||
hook_failed:
|
||||
role: developer
|
||||
prompt: 'Push hook failed: {{{error}}}. Fix and re-submit. Worktree: {{{worktree}}}. Repo remote: {{{repoRemote}}}.'
|
||||
committed:
|
||||
role: $END
|
||||
prompt: 'PR created: {{{prUrl}}}. Workflow complete.'
|
||||
Reference in New Issue
Block a user