Compare commits

..

8 Commits

Author SHA1 Message Date
xiaoju 23b2c3b47d feat(reviewer): enrich prompt with conventions awareness + strict verdicts
- Read preparer's conventions from thread context
- Review checklist: correctness, conventions, consistency, edge cases
- No nits: every issue is blocking, approve only at zero issues
- Generic prompt, no workflow-specific concepts

Closes #91

小橘 🍊
2026-05-07 16:25:31 +00:00
xingyue bfea771a52 Merge pull request 'fix(cli): improve usage format + fix skill index title' (#86) from fix/85-usage-format into main 2026-05-07 15:33:27 +00:00
xiaoju 5e411a1f19 fix(cli): improve usage output format + fix skill index title
- Usage: grouped sections with titles, aligned descriptions
- Header: 'uncaged-workflow — workflow engine CLI'
- Footer: 'Use <command> --help for subcommand details.'
- Fix skill index title: 'uncaged-workflow skill' (was: help --skill)
- 242 tests pass

Closes #85

小橘 🍊
2026-05-07 15:31:48 +00:00
xingyue 21238f7825 Merge pull request 'fix(cli): usage not red + skill subcommand + --help flag' (#84) from fix/83-cli-ux into main 2026-05-07 15:19:08 +00:00
xiaoju 6b3aa4ce35 fix(cli): usage not red + skill subcommand + --help flag on groups
1. No-args usage uses printCliLine (not printCliError), exit 1
2. 'skill [topic]' as first-class command (help --skill kept as compat)
3. 'workflow --help', 'thread --help' etc. show group subcommands
4. Role prompts updated: 'uncaged-workflow skill develop'

240 tests (6 new), build clean.

Closes #83

小橘 🍊
2026-05-07 15:17:20 +00:00
xingyue f042c9d640 Merge pull request 'feat(cli): help --skill <topic> for context-specific agent docs' (#82) from feat/81-skill-topics into main 2026-05-07 15:08:41 +00:00
xiaoju 66bca9ef03 feat(cli): help --skill <topic> — context-specific docs for agents
- help --skill (no args) → lists available topics
- help --skill cli → full CLI reference (was: help --skill)
- help --skill develop → thread ID, CAS, meta output guide for roles
- help --skill author → bundle structure, descriptor, role definition
- Role prompts updated: planner/coder reference 'help --skill develop'
- Legacy formatSkillDoc() preserved for compat
- 234 tests (15 new), build clean

Closes #81

小橘 🍊
2026-05-07 15:03:08 +00:00
xiaoju 309af39447 Merge pull request 'fix(cli): review nits — live --latest args + dispatchInit consistency' (#79) from fix/75-nits into main 2026-05-07 14:54:28 +00:00
7 changed files with 477 additions and 28 deletions
+165 -4
View File
@@ -1,6 +1,11 @@
import { describe, expect, test } from "bun:test";
import { runCli } from "../src/cli-dispatch.js";
import { formatSkillDoc } from "../src/cmd-help.js";
import { formatCliUsage, runCli } from "../src/cli-dispatch.js";
import {
formatSkillDoc,
formatSkillIndex,
formatSkillTopic,
getSkillTopics,
} from "../src/cmd-help.js";
const STORAGE_ROOT = "/tmp/help-test-storage";
@@ -10,13 +15,120 @@ describe("help command", () => {
expect(code).toBe(0);
});
test("help --skill returns 0", async () => {
test("no args prints usage (not red) and returns 1", async () => {
const code = await runCli(STORAGE_ROOT, []);
expect(code).toBe(1);
});
});
describe("skill command", () => {
test("skill (no topic) lists topics and returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["skill"]);
expect(code).toBe(0);
});
test("skill cli returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["skill", "cli"]);
expect(code).toBe(0);
});
test("skill develop returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["skill", "develop"]);
expect(code).toBe(0);
});
test("skill author returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["skill", "author"]);
expect(code).toBe(0);
});
test("skill unknown returns 1", async () => {
const code = await runCli(STORAGE_ROOT, ["skill", "unknown"]);
expect(code).toBe(1);
});
});
describe("--help flag on groups", () => {
test("workflow --help returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["workflow", "--help"]);
expect(code).toBe(0);
});
test("thread --help returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["thread", "--help"]);
expect(code).toBe(0);
});
test("cas --help returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["cas", "--help"]);
expect(code).toBe(0);
});
test("init --help returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["init", "--help"]);
expect(code).toBe(0);
});
});
describe("legacy help --skill compat", () => {
test("help --skill still works (lists topics)", async () => {
const code = await runCli(STORAGE_ROOT, ["help", "--skill"]);
expect(code).toBe(0);
});
});
describe("formatSkillDoc", () => {
describe("getSkillTopics", () => {
test("returns all topics", () => {
const topics = getSkillTopics();
const names = topics.map((t) => t.name);
expect(names).toContain("cli");
expect(names).toContain("develop");
expect(names).toContain("author");
});
});
describe("formatSkillIndex", () => {
test("lists all topics", () => {
const idx = formatSkillIndex();
expect(idx).toContain("# uncaged-workflow skill");
expect(idx).not.toContain("# uncaged-workflow help --skill");
expect(idx).toContain("cli");
expect(idx).toContain("develop");
expect(idx).toContain("author");
expect(idx).toContain("skill <topic>");
});
});
describe("formatCliUsage", () => {
test("has tagline, grouped sections, help hint, and env vars", () => {
const u = formatCliUsage();
expect(u.startsWith("uncaged-workflow — workflow engine CLI")).toBe(true);
expect(u).toContain("Workflow registry:");
expect(u).toContain("Thread execution:");
expect(u).toContain("Content-addressable storage:");
expect(u).toContain("Development:");
expect(u).toContain("Shortcuts:");
expect(u).toContain("Reference:");
expect(u).toContain("skill [topic]");
expect(u).toContain("Agent-consumable docs");
expect(u).toContain("Use <command> --help for subcommand details.");
expect(u).toContain("Environment variables:");
expect(u).toContain("WORKFLOW_STORAGE_ROOT");
expect(u).toContain("UNCAGED_WORKFLOW_STORAGE_ROOT");
});
test("lists commands from registry with descriptions", () => {
const u = formatCliUsage();
expect(u).toContain("workflow add");
expect(u).toContain("Register a workflow bundle in the registry");
expect(u).toContain("thread run");
expect(u).toContain("Start a new thread executing a workflow");
expect(u).toContain("cas gc");
expect(u).toContain("Garbage-collect unreferenced CAS entries");
});
});
describe("formatSkillTopic('cli') — legacy formatSkillDoc", () => {
const doc = formatSkillDoc();
test("contains title", () => {
@@ -82,3 +194,52 @@ describe("formatSkillDoc", () => {
expect(doc).toContain("## Typical Workflow");
});
});
describe("formatSkillTopic('develop')", () => {
const doc = formatSkillTopic("develop");
test("returns non-null", () => {
expect(doc).not.toBeNull();
});
test("contains thread ID info", () => {
expect(doc).toContain("Thread ID");
expect(doc).toContain("Crockford Base32");
});
test("contains CAS commands", () => {
expect(doc).toContain("cas put");
expect(doc).toContain("cas get");
});
test("contains meta output section", () => {
expect(doc).toContain("Meta Output");
});
});
describe("formatSkillTopic('author')", () => {
const doc = formatSkillTopic("author");
test("returns non-null", () => {
expect(doc).not.toBeNull();
});
test("contains bundle structure", () => {
expect(doc).toContain("Bundle Structure");
expect(doc).toContain(".esm.js");
});
test("contains descriptor info", () => {
expect(doc).toContain("WorkflowDescriptor");
});
test("contains role definition", () => {
expect(doc).toContain("Role Definition");
});
});
describe("formatSkillTopic unknown", () => {
test("returns null for unknown topic", () => {
expect(formatSkillTopic("nonexistent")).toBeNull();
});
});
@@ -129,8 +129,9 @@ describe("init workspace", () => {
test("usage lists init subcommands", () => {
const u = formatCliUsage();
expect(u).toContain("uncaged-workflow init workspace <name>");
expect(u).toContain("uncaged-workflow init template <name>");
expect(u).toContain("init workspace <name>");
expect(u).toContain("init template <name>");
expect(u).toContain("Development:");
});
test("runCli rejects unknown init subcommand", async () => {
+107 -17
View File
@@ -3,7 +3,12 @@ import { cmdAdd, formatAddSuccess, parseAddArgv } from "./cmd-add.js";
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "./cmd-cas.js";
import { cmdFork, parseForkArgv } from "./cmd-fork.js";
import { cmdGc } from "./cmd-gc.js";
import { formatSkillDoc } from "./cmd-help.js";
import {
formatSkillDoc,
formatSkillIndex,
formatSkillTopic,
getSkillTopics,
} from "./cmd-help.js";
import { cmdHistory } from "./cmd-history.js";
import { cmdInitTemplate, cmdInitWorkspace } from "./cmd-init.js";
import { cmdKill } from "./cmd-kill.js";
@@ -525,18 +530,68 @@ export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
// ── Auto-generated CLI usage ───────────────────────────────────────────
const USAGE_SECTION_BY_GROUP: Record<string, string> = {
workflow: "Workflow registry:",
thread: "Thread execution:",
cas: "Content-addressable storage:",
init: "Development:",
};
function formatUsageCommandLines(
rows: ReadonlyArray<{ prefix: string; description: string }>,
): string[] {
const maxPrefix = rows.reduce((max, row) => Math.max(max, row.prefix.length), 0);
const gap = 2;
return rows.map((row) => {
const pad = " ".repeat(maxPrefix - row.prefix.length + gap);
return ` ${row.prefix}${pad}${row.description}`;
});
}
export function formatCliUsage(): string {
const groups = getCommandRegistry();
const lines: string[] = ["Usage:"];
const lines: string[] = ["uncaged-workflow — workflow engine CLI", ""];
for (const group of groups) {
for (const cmd of group.commands) {
const args = cmd.args ? ` ${cmd.args}` : "";
lines.push(` uncaged-workflow ${group.name} ${cmd.name}${args}`);
const sectionTitle = USAGE_SECTION_BY_GROUP[group.name];
if (sectionTitle === undefined) {
throw new Error(`BUG: missing usage section title for group "${group.name}"`);
}
lines.push(sectionTitle);
const rows = group.commands.map((cmd) => {
const args = cmd.args ? ` ${cmd.args}` : "";
return {
prefix: `${group.name} ${cmd.name}${args}`,
description: cmd.description,
};
});
lines.push(...formatUsageCommandLines(rows));
lines.push("");
}
lines.push(" uncaged-workflow run <name> [...] (shortcut for thread run)");
lines.push(" uncaged-workflow live <thread-id> [...] (shortcut for thread live)");
lines.push("Shortcuts:");
lines.push(
...formatUsageCommandLines([
{ prefix: "run <name> [...]", description: "→ thread run" },
{ prefix: "live <id> [...]", description: "→ thread live" },
]),
);
lines.push("");
lines.push("Reference:");
const skillTopicNames = getSkillTopics()
.map((t) => t.name)
.join(", ");
lines.push(
...formatUsageCommandLines([
{
prefix: "skill [topic]",
description: `Agent-consumable docs (${skillTopicNames})`,
},
]),
);
lines.push("");
lines.push("Use <command> --help for subcommand details.");
lines.push("");
lines.push("Environment variables:");
lines.push(
@@ -561,9 +616,16 @@ function dispatchGroup(
argv: string[],
): Promise<number> | null {
const sub = argv[0];
if (sub === undefined) {
printCliError(`${formatCliUsage()}\n\nerror: unknown ${tableName} subcommand: (none)`);
return Promise.resolve(1);
if (sub === undefined || sub === "--help" || sub === "-h") {
const entries = Object.entries(table);
const lines = [`${tableName} subcommands:\n`];
for (const [name, e] of entries) {
const args = e.args ? ` ${e.args}` : "";
lines.push(` uncaged-workflow ${tableName} ${name}${args}`);
lines.push(` ${e.description}\n`);
}
printCliLine(lines.join("\n"));
return Promise.resolve(sub === undefined ? 1 : 0);
}
const entry = table[sub];
if (entry === undefined) {
@@ -618,12 +680,39 @@ async function dispatchCas(storageRoot: string, argv: string[]): Promise<number>
// ── Help ────────────────────────────────────────────────────────────────
async function dispatchHelp(_storageRoot: string, argv: string[]): Promise<number> {
if (argv.includes("--skill")) {
printCliLine(formatSkillDoc());
} else {
printCliLine(formatCliUsage());
async function dispatchSkill(_storageRoot: string, argv: string[]): Promise<number> {
const topic = argv[0];
if (topic === undefined) {
printCliLine(formatSkillIndex());
return 0;
}
const doc = formatSkillTopic(topic);
if (doc === null) {
printCliError(`unknown skill topic: ${topic}\n\n${formatSkillIndex()}`);
return 1;
}
printCliLine(doc);
return 0;
}
async function dispatchHelp(_storageRoot: string, argv: string[]): Promise<number> {
// Legacy compat: help --skill [topic] → skill [topic]
const skillIdx = argv.indexOf("--skill");
if (skillIdx !== -1) {
const topic = argv[skillIdx + 1];
if (topic === undefined) {
printCliLine(formatSkillIndex());
return 0;
}
const doc = formatSkillTopic(topic);
if (doc === null) {
printCliError(`unknown skill topic: ${topic}\n\n${formatSkillIndex()}`);
return 1;
}
printCliLine(doc);
return 0;
}
printCliLine(formatCliUsage());
return 0;
}
@@ -636,6 +725,7 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
cas: dispatchCas,
init: dispatchInit,
help: dispatchHelp,
skill: dispatchSkill,
// Top-level shortcuts (no deprecation)
run: dispatchRun,
@@ -661,12 +751,12 @@ const DEPRECATED_ALIASES: Record<string, { newCmd: string; handler: DispatchFn }
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
if (argv.length === 0) {
printCliError(formatCliUsage());
printCliLine(formatCliUsage());
return 1;
}
const command = argv[0];
if (command === undefined) {
printCliError(formatCliUsage());
printCliLine(formatCliUsage());
return 1;
}
const rest = argv.slice(1);
+179 -1
View File
@@ -1,6 +1,54 @@
import { getCommandRegistry } from "./cli-dispatch.js";
export function formatSkillDoc(): string {
type SkillTopic = {
name: string;
description: string;
format: () => string;
};
const SKILL_TOPICS: ReadonlyArray<SkillTopic> = [
{ name: "cli", description: "Full CLI command reference", format: formatSkillCli },
{
name: "develop",
description: "Guide for agents executing roles inside a workflow",
format: formatSkillDevelop,
},
{
name: "author",
description: "Guide for building and publishing workflow bundles",
format: formatSkillAuthor,
},
];
export function getSkillTopics(): ReadonlyArray<{ name: string; description: string }> {
return SKILL_TOPICS.map((t) => ({ name: t.name, description: t.description }));
}
export function formatSkillTopic(topic: string): string | null {
const entry = SKILL_TOPICS.find((t) => t.name === topic);
if (entry === undefined) {
return null;
}
return entry.format();
}
export function formatSkillIndex(): string {
const rows = SKILL_TOPICS.map((t) => `| \`${t.name}\` | ${t.description} |`);
return `# uncaged-workflow skill
Available topics:
| Topic | Description |
|-------|-------------|
${rows.join("\n")}
Usage: \`uncaged-workflow skill <topic>\`
`;
}
// ── cli topic (existing full reference) ────────────────────────────────
function formatSkillCli(): string {
const groups = getCommandRegistry();
const commandSections: string[] = [];
@@ -58,3 +106,133 @@ ${commandSections.join("\n\n")}
| \`UNCAGED_WORKFLOW_STORAGE_ROOT\` | Override the default storage directory for all workflow data |
`;
}
// ── develop topic (for agents inside a workflow) ───────────────────────
function formatSkillDevelop(): string {
return `# Workflow Role Guide
Reference for agents executing roles (planner, coder, reviewer, etc.) inside a running workflow thread.
## Thread ID
Every thread has a 26-character Crockford Base32 ULID (e.g. \`06F03H5V6JTMDST6P3TVH42RWM\`).
It appears in the **first message** of the conversation. If unsure:
\`\`\`
uncaged-workflow thread list
\`\`\`
## CAS (Content-Addressable Storage)
Store and retrieve content by hash, scoped to the current thread.
| Operation | Command |
|-----------|---------|
| **Store** | \`uncaged-workflow cas put <THREAD_ID> '<content>'\` → prints hash |
| **Read** | \`uncaged-workflow cas get <THREAD_ID> <HASH>\` → prints content |
| **List** | \`uncaged-workflow cas list <THREAD_ID>\` |
CAS is the **only** supported way to persist structured data (phase plans, review notes, etc.) within a thread. Do not use temp files.
## Meta Output
Each role must produce structured output that the moderator extracts. The exact schema depends on the role, but the pattern is:
1. Do your work (write code, run tests, etc.)
2. Output a compact JSON object matching the role's schema
3. The moderator extracts and validates it automatically
## Thread Context
The conversation history contains outputs from previous roles. Read it to understand:
- What task was requested (from the initial prompt)
- What previous roles produced (plans, code changes, review results)
- What the moderator decided (which phase to work on, whether to retry)
`;
}
// ── author topic (for workflow developers) ─────────────────────────────
function formatSkillAuthor(): string {
return `# Workflow Authoring Guide
How to build, test, and publish workflow bundles for uncaged-workflow.
## Bundle Structure
A workflow bundle is a single ESM file (\`.esm.js\`) that exports:
\`\`\`typescript
// Required exports
export const descriptor: WorkflowDescriptor;
export const run: WorkflowRun;
\`\`\`
## WorkflowDescriptor
Defines the workflow's metadata and role sequence:
\`\`\`typescript
type WorkflowDescriptor = {
name: string; // verb-first kebab-case, e.g. "solve-issue"
description: string; // one-line summary
roles: string[]; // ordered role names, e.g. ["planner", "coder", "reviewer"]
};
\`\`\`
## WorkflowRun
The main function that creates and returns a moderator:
\`\`\`typescript
type WorkflowRun = (ctx: WorkflowContext) => Moderator;
\`\`\`
The **Moderator** controls the flow — it decides which role runs next, handles retries, and determines when the workflow is complete.
## Role Definition
Each role has:
| Field | Type | Purpose |
|-------|------|---------|
| \`description\` | string | What the role does |
| \`systemPrompt\` | string | System prompt for the agent |
| \`extractPrompt\` | string | Instruction for extracting structured meta |
| \`schema\` | ZodSchema | Validates the extracted meta |
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
| \`extractMode\` | "single" | Extraction mode |
## Development Workflow
\`\`\`bash
# 1. Initialize a workspace
uncaged-workflow init workspace my-workflow
# 2. Write your template (roles + moderator + descriptor)
# 3. Build the ESM bundle
bun run build
# 4. Register locally
uncaged-workflow workflow add my-workflow ./dist/my-workflow.esm.js
# 5. Test
uncaged-workflow run my-workflow --prompt "test task"
uncaged-workflow live --latest
\`\`\`
## Versioning
Bundles are immutable and identified by XXH64 hash. Re-registering a workflow with a new bundle creates a new version. Use \`workflow history\` and \`workflow rollback\` to manage versions.
`;
}
// ── Legacy compat ──────────────────────────────────────────────────────
/** @deprecated Use formatSkillTopic("cli") instead */
export function formatSkillDoc(): string {
return formatSkillCli();
}
@@ -11,7 +11,7 @@ export type CoderMeta = z.infer<typeof coderMetaSchema>;
const CODER_SYSTEM = `You are a **coder**. Read the thread for the plan and work on the NEXT incomplete phase only.
Run \`uncaged-workflow help --skill\` for full CLI reference (thread ID lookup, CAS commands, etc.).
Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and meta output guide.
## Reading phase details
@@ -14,7 +14,7 @@ export type PlannerMeta = z.infer<typeof plannerMetaSchema>;
const PLANNER_SYSTEM = `You are a **planner** for a software task. Break the work into **sequential phases** the coder will execute one at a time.
Run \`uncaged-workflow help --skill\` for full CLI reference (thread ID lookup, CAS commands, etc.).
Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and meta output guide.
## Storing phase details — MANDATORY
@@ -12,8 +12,27 @@ export const reviewerMetaSchema = z.discriminatedUnion("status", [
]);
export type ReviewerMeta = z.infer<typeof reviewerMetaSchema>;
const REVIEWER_SYSTEM = `You are a code reviewer. Review the current git diff. Give a clear approve or reject verdict.
Only reject for blocking issues. End with your verdict.`;
const REVIEWER_SYSTEM = `You are a code reviewer. Review the git diff for correctness, consistency, and adherence to project conventions.
## Review process
1. Read the **preparer**'s output in the thread for project conventions (coding style, naming, commit format, etc.).
2. Review the diff against these conventions.
3. For documentation changes, verify that names, paths, and references match the actual codebase.
## Review checklist
- **Correctness** — does the code do what it claims? Logic bugs, off-by-one, missing returns?
- **Conventions** — naming, imports, code style per project rules?
- **Consistency** — do docs/comments match actual code? Are references current and accurate?
- **Edge cases** — missing error handling, null checks, boundary conditions?
## Verdict
- **Approve** only if there are zero issues
- **Reject** with specific issues that must be fixed — every issue you find is blocking
Be thorough. A false approve costs more than a false reject.`;
export const reviewerRole: RoleDefinition<ReviewerMeta> = {
description: "Runs git diff checks and sets approved when the change is ready.",