diff --git a/packages/cli-workflow/__tests__/help.test.ts b/packages/cli-workflow/__tests__/help.test.ts index f8841aa..487e7eb 100644 --- a/packages/cli-workflow/__tests__/help.test.ts +++ b/packages/cli-workflow/__tests__/help.test.ts @@ -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 { + formatSkillDoc, + formatSkillIndex, + formatSkillTopic, + getSkillTopics, +} from "../src/cmd-help.js"; const STORAGE_ROOT = "/tmp/help-test-storage"; @@ -10,13 +15,53 @@ describe("help command", () => { expect(code).toBe(0); }); - test("help --skill returns 0", async () => { + test("help --skill (no topic) returns 0 and lists topics", async () => { const code = await runCli(STORAGE_ROOT, ["help", "--skill"]); expect(code).toBe(0); }); + + test("help --skill cli returns 0", async () => { + const code = await runCli(STORAGE_ROOT, ["help", "--skill", "cli"]); + expect(code).toBe(0); + }); + + test("help --skill develop returns 0", async () => { + const code = await runCli(STORAGE_ROOT, ["help", "--skill", "develop"]); + expect(code).toBe(0); + }); + + test("help --skill author returns 0", async () => { + const code = await runCli(STORAGE_ROOT, ["help", "--skill", "author"]); + expect(code).toBe(0); + }); + + test("help --skill unknown returns 1", async () => { + const code = await runCli(STORAGE_ROOT, ["help", "--skill", "unknown"]); + expect(code).toBe(1); + }); }); -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("cli"); + expect(idx).toContain("develop"); + expect(idx).toContain("author"); + expect(idx).toContain("help --skill "); + }); +}); + +describe("formatSkillTopic('cli') — legacy formatSkillDoc", () => { const doc = formatSkillDoc(); test("contains title", () => { @@ -82,3 +127,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(); + }); +}); diff --git a/packages/cli-workflow/src/cli-dispatch.ts b/packages/cli-workflow/src/cli-dispatch.ts index c95f61f..89b8740 100644 --- a/packages/cli-workflow/src/cli-dispatch.ts +++ b/packages/cli-workflow/src/cli-dispatch.ts @@ -3,7 +3,7 @@ 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 } from "./cmd-help.js"; import { cmdHistory } from "./cmd-history.js"; import { cmdInitTemplate, cmdInitWorkspace } from "./cmd-init.js"; import { cmdKill } from "./cmd-kill.js"; @@ -619,11 +619,22 @@ async function dispatchCas(storageRoot: string, argv: string[]): Promise // ── Help ──────────────────────────────────────────────────────────────── async function dispatchHelp(_storageRoot: string, argv: string[]): Promise { - if (argv.includes("--skill")) { - printCliLine(formatSkillDoc()); - } else { + const skillIdx = argv.indexOf("--skill"); + if (skillIdx === -1) { printCliLine(formatCliUsage()); + return 0; } + 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; } diff --git a/packages/cli-workflow/src/cmd-help.ts b/packages/cli-workflow/src/cmd-help.ts index 00a0806..a6c7db4 100644 --- a/packages/cli-workflow/src/cmd-help.ts +++ b/packages/cli-workflow/src/cmd-help.ts @@ -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 = [ + { 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 help --skill + +Available topics: + +| Topic | Description | +|-------|-------------| +${rows.join("\n")} + +Usage: \`uncaged-workflow help --skill \` +`; +} + +// ── 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 ''\` → prints hash | +| **Read** | \`uncaged-workflow cas get \` → prints content | +| **List** | \`uncaged-workflow cas list \` | + +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(); +} diff --git a/packages/workflow-template-develop/src/roles/coder.ts b/packages/workflow-template-develop/src/roles/coder.ts index 2dd3010..0368d9c 100644 --- a/packages/workflow-template-develop/src/roles/coder.ts +++ b/packages/workflow-template-develop/src/roles/coder.ts @@ -11,7 +11,7 @@ export type CoderMeta = z.infer; 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 help --skill develop\` for thread ID lookup, CAS commands, and meta output guide. ## Reading phase details diff --git a/packages/workflow-template-develop/src/roles/planner.ts b/packages/workflow-template-develop/src/roles/planner.ts index ce22434..cf2994f 100644 --- a/packages/workflow-template-develop/src/roles/planner.ts +++ b/packages/workflow-template-develop/src/roles/planner.ts @@ -14,7 +14,7 @@ export type PlannerMeta = z.infer; 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 help --skill develop\` for thread ID lookup, CAS commands, and meta output guide. ## Storing phase details — MANDATORY