diff --git a/.changeset/start-new-resume.md b/.changeset/start-new-resume.md new file mode 100644 index 0000000..6d8b12d --- /dev/null +++ b/.changeset/start-new-resume.md @@ -0,0 +1,9 @@ +--- +"@united-workforce/cli": minor +"@united-workforce/util": patch +--- + +feat: replace $START `_` status with `new`/`resume` semantics + +BREAKING: All workflow YAML files must update `$START._` to `$START.new` + `$START.resume`. +The `resume` edge prompt replaces the previously hardcoded resume message in the CLI. diff --git a/.workflows/e2e-walkthrough.yaml b/.workflows/e2e-walkthrough.yaml index b5b9310..bb3f2a1 100644 --- a/.workflows/e2e-walkthrough.yaml +++ b/.workflows/e2e-walkthrough.yaml @@ -264,7 +264,8 @@ roles: graph: $START: - _: { role: "bootstrap", prompt: "Set up the Docker container and verify uwf is runnable." } + new: { role: "bootstrap", prompt: "Set up the Docker container and verify uwf is runnable." } + resume: { role: "bootstrap", prompt: "Review the previous run output and continue the walkthrough." } bootstrap: pass: { role: "config-and-registry", prompt: "Container {{{containerName}}} is ready. Validate config and workflow registration." } fail: { role: "$END", prompt: "Bootstrap failed: {{{error}}}. No container was created." } diff --git a/.workflows/solve-issue.yaml b/.workflows/solve-issue.yaml index f975afa..9433f91 100644 --- a/.workflows/solve-issue.yaml +++ b/.workflows/solve-issue.yaml @@ -227,7 +227,8 @@ roles: required: [$status, error] graph: $START: - _: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." } + new: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." } + resume: { role: "planner", prompt: "Review the previous run output and continue the work." } planner: insufficient_info: { role: "$SUSPEND", prompt: "信息不足,需要补充:{{{reason}}}" } ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}. Repo remote: {{{repoRemote}}}." } diff --git a/examples/analyze-topic.yaml b/examples/analyze-topic.yaml index 6725630..661c747 100644 --- a/examples/analyze-topic.yaml +++ b/examples/analyze-topic.yaml @@ -35,6 +35,7 @@ roles: required: [$status, thesis, keyPoints] graph: $START: - _: { role: "analyst", prompt: "Analyze the topic in the task and produce a structured summary with key points." } + new: { role: "analyst", prompt: "Analyze the topic in the task and produce a structured summary with key points." } + resume: { role: "analyst", prompt: "Review the previous analysis output and continue with additional context." } analyst: done: { role: "$END", prompt: "Analysis complete. Finish the workflow." } diff --git a/examples/debate.yaml b/examples/debate.yaml index b28749f..d3fbaec 100644 --- a/examples/debate.yaml +++ b/examples/debate.yaml @@ -53,7 +53,8 @@ roles: required: [$status, argument] graph: $START: - _: { role: "against", prompt: "Present your opening argument against the proposition." } + new: { role: "against", prompt: "Present your opening argument against the proposition." } + resume: { role: "against", prompt: "Review the previous debate output and continue the argument against the proposition." } against: conceded: { role: "$END", prompt: "The against side conceded. Debate over." } continue: { role: "for", prompt: "Counter the opposing argument: {{{argument}}}" } diff --git a/examples/eval-simple.yaml b/examples/eval-simple.yaml index 800c63d..60fcc3a 100644 --- a/examples/eval-simple.yaml +++ b/examples/eval-simple.yaml @@ -25,6 +25,7 @@ roles: required: [$status, summary] graph: $START: - _: { role: "fixer", prompt: "Fix the code issue described in the task prompt." } + new: { role: "fixer", prompt: "Fix the code issue described in the task prompt." } + resume: { role: "fixer", prompt: "Review the previous run output and continue fixing the code issue." } fixer: done: { role: "$END", prompt: "Fix complete." } diff --git a/examples/solve-issue.yaml b/examples/solve-issue.yaml index a6af54b..9ca6c06 100644 --- a/examples/solve-issue.yaml +++ b/examples/solve-issue.yaml @@ -215,7 +215,8 @@ roles: required: [$status, error] graph: $START: - _: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." } + new: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." } + resume: { role: "planner", prompt: "Review the previous run output and continue the work." } planner: insufficient_info: { role: "$SUSPEND", prompt: "信息不足,需要补充:{{{reason}}}" } ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}." } diff --git a/packages/cli/src/__tests__/adapter-json-roundtrip.test.ts b/packages/cli/src/__tests__/adapter-json-roundtrip.test.ts index 6ac7b9d..c1181a9 100644 --- a/packages/cli/src/__tests__/adapter-json-roundtrip.test.ts +++ b/packages/cli/src/__tests__/adapter-json-roundtrip.test.ts @@ -58,7 +58,10 @@ describe("C1: adapter JSON round-trip integration", () => { }, }, graph: { - $START: { _: { role: "worker", prompt: "Do the work", location: null } }, + $START: { + new: { role: "worker", prompt: "Do the work", location: null }, + resume: { role: "worker", prompt: "Resume the work", location: null }, + }, worker: { done: { role: "$END", prompt: "completed", location: null } }, }, }); diff --git a/packages/cli/src/__tests__/current-role.test.ts b/packages/cli/src/__tests__/current-role.test.ts index ef1b70f..7eb8ce7 100644 --- a/packages/cli/src/__tests__/current-role.test.ts +++ b/packages/cli/src/__tests__/current-role.test.ts @@ -45,10 +45,14 @@ roles: $status: { type: string, enum: ["done"] } graph: $START: - _: + new: role: roleA prompt: "Do A" location: null + resume: + role: roleA + prompt: "Resume A" + location: null roleA: ready: role: roleB @@ -107,10 +111,14 @@ roles: $status: { type: string, enum: ["done"] } graph: $START: - _: + new: role: roleA prompt: "Do A" location: null + resume: + role: roleA + prompt: "Resume A" + location: null roleA: pass: role: roleB @@ -150,10 +158,14 @@ roles: $status: { type: string, enum: ["done"] } graph: $START: - _: + new: role: worker prompt: "Work" location: null + resume: + role: worker + prompt: "Resume work" + location: null worker: done: role: $END diff --git a/packages/cli/src/__tests__/fixtures/e2e-count.workflow.yaml b/packages/cli/src/__tests__/fixtures/e2e-count.workflow.yaml index 41848e1..3f5c1a8 100644 --- a/packages/cli/src/__tests__/fixtures/e2e-count.workflow.yaml +++ b/packages/cli/src/__tests__/fixtures/e2e-count.workflow.yaml @@ -36,7 +36,8 @@ roles: required: [$status] graph: $START: - _: { role: analyst, prompt: 'Analyze the task' } + new: { role: analyst, prompt: 'Analyze the task' } + resume: { role: analyst, prompt: 'Review the previous run output and continue the work.' } analyst: analyzed: { role: developer, prompt: 'Implement the change' } developer: diff --git a/packages/cli/src/__tests__/fixtures/e2e-linear.workflow.yaml b/packages/cli/src/__tests__/fixtures/e2e-linear.workflow.yaml index 9a4a638..04ea4b4 100644 --- a/packages/cli/src/__tests__/fixtures/e2e-linear.workflow.yaml +++ b/packages/cli/src/__tests__/fixtures/e2e-linear.workflow.yaml @@ -25,7 +25,8 @@ roles: required: [$status] graph: $START: - _: { role: planner, prompt: 'Plan the task' } + new: { role: planner, prompt: 'Plan the task' } + resume: { role: planner, prompt: 'Review the previous run output and continue the work.' } planner: ready: { role: worker, prompt: 'Do the work' } worker: diff --git a/packages/cli/src/__tests__/fixtures/e2e-loop.workflow.yaml b/packages/cli/src/__tests__/fixtures/e2e-loop.workflow.yaml index 604452a..a3f0763 100644 --- a/packages/cli/src/__tests__/fixtures/e2e-loop.workflow.yaml +++ b/packages/cli/src/__tests__/fixtures/e2e-loop.workflow.yaml @@ -28,7 +28,8 @@ roles: required: [$status] graph: $START: - _: { role: developer, prompt: 'Implement the change' } + new: { role: developer, prompt: 'Implement the change' } + resume: { role: developer, prompt: 'Review the previous run output and continue the work.' } developer: review_needed: { role: reviewer, prompt: 'Review the change' } reviewer: diff --git a/packages/cli/src/__tests__/fixtures/e2e-mustache.workflow.yaml b/packages/cli/src/__tests__/fixtures/e2e-mustache.workflow.yaml index 0d64e9b..aa2e483 100644 --- a/packages/cli/src/__tests__/fixtures/e2e-mustache.workflow.yaml +++ b/packages/cli/src/__tests__/fixtures/e2e-mustache.workflow.yaml @@ -27,7 +27,8 @@ roles: required: [$status] graph: $START: - _: { role: planner, prompt: 'Plan the task' } + new: { role: planner, prompt: 'Plan the task' } + resume: { role: planner, prompt: 'Review the previous run output and continue the work.' } planner: ready: { role: worker, prompt: 'Work on branch {{{branch}}} in {{{repoPath}}}' } worker: diff --git a/packages/cli/src/__tests__/fixtures/e2e-suspend.workflow.yaml b/packages/cli/src/__tests__/fixtures/e2e-suspend.workflow.yaml index 42d59b6..4ba1c60 100644 --- a/packages/cli/src/__tests__/fixtures/e2e-suspend.workflow.yaml +++ b/packages/cli/src/__tests__/fixtures/e2e-suspend.workflow.yaml @@ -18,7 +18,8 @@ roles: required: [$status] graph: $START: - _: { role: planner, prompt: 'Analyze the task' } + new: { role: planner, prompt: 'Analyze the task' } + resume: { role: planner, prompt: 'Review the previous run output and continue the work.' } planner: insufficient_info: { role: '$SUSPEND', prompt: 'Need more info: {{{reason}}}' } ready: { role: '$END', prompt: 'Done' } diff --git a/packages/cli/src/__tests__/moderator-evaluate.test.ts b/packages/cli/src/__tests__/moderator-evaluate.test.ts index 621fd8b..b89123a 100644 --- a/packages/cli/src/__tests__/moderator-evaluate.test.ts +++ b/packages/cli/src/__tests__/moderator-evaluate.test.ts @@ -5,7 +5,12 @@ import { evaluate } from "../moderator/evaluate.js"; const solveIssueGraph: WorkflowPayload["graph"] = { $START: { - _: { role: "planner", prompt: "Start planning from the issue in the task.", location: null }, + new: { role: "planner", prompt: "Start planning from the issue in the task.", location: null }, + resume: { + role: "planner", + prompt: "Review the previous run output and continue the work.", + location: null, + }, }, planner: { planned: { role: "developer", prompt: "Implement the plan: {{plan}}", location: null }, @@ -20,8 +25,8 @@ const solveIssueGraph: WorkflowPayload["graph"] = { }; describe("evaluate", () => { - test("$START → first role (unit status _)", () => { - const result = evaluate(solveIssueGraph, "$START", { $status: "_" }); + test("$START → first role (status new)", () => { + const result = evaluate(solveIssueGraph, "$START", { $status: "new" }); expect(result).toEqual({ ok: true, value: { @@ -32,6 +37,18 @@ describe("evaluate", () => { }); }); + test("$START → first role (status resume)", () => { + const result = evaluate(solveIssueGraph, "$START", { $status: "resume" }); + expect(result).toEqual({ + ok: true, + value: { + role: "planner", + prompt: "Review the previous run output and continue the work.", + location: null, + }, + }); + }); + test("status-based routing (reviewer rejected → developer)", () => { const result = evaluate(solveIssueGraph, "reviewer", { $status: "rejected", @@ -95,7 +112,7 @@ describe("evaluate", () => { }); test("missing role in graph → error", () => { - const result = evaluate(solveIssueGraph, "unknown-role", { $status: "_" }); + const result = evaluate(solveIssueGraph, "unknown-role", { $status: "new" }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.message).toBe('no transitions defined for role "unknown-role"'); diff --git a/packages/cli/src/__tests__/step-timing.test.ts b/packages/cli/src/__tests__/step-timing.test.ts index b8ccbca..007b8cd 100644 --- a/packages/cli/src/__tests__/step-timing.test.ts +++ b/packages/cli/src/__tests__/step-timing.test.ts @@ -253,7 +253,10 @@ describe("thread read timing", () => { }, }, graph: { - $START: { _: { role: "worker", prompt: "go", location: null } }, + $START: { + new: { role: "worker", prompt: "go", location: null }, + resume: { role: "worker", prompt: "resume", location: null }, + }, worker: { done: { role: "$END", prompt: "", location: null } }, }, }); @@ -319,7 +322,10 @@ describe("thread read timing", () => { }, }, graph: { - $START: { _: { role: "worker", prompt: "go", location: null } }, + $START: { + new: { role: "worker", prompt: "go", location: null }, + resume: { role: "worker", prompt: "resume", location: null }, + }, worker: { done: { role: "$END", prompt: "", location: null } }, }, }); diff --git a/packages/cli/src/__tests__/thread-location.test.ts b/packages/cli/src/__tests__/thread-location.test.ts index 6044e63..97f8712 100644 --- a/packages/cli/src/__tests__/thread-location.test.ts +++ b/packages/cli/src/__tests__/thread-location.test.ts @@ -57,10 +57,14 @@ roles: $status: { type: string, enum: ["ready"] } graph: $START: - _: + new: role: planner prompt: "Plan the work" location: null + resume: + role: planner + prompt: "Resume the work" + location: null planner: ready: role: $END @@ -113,10 +117,14 @@ roles: $status: { type: string, enum: ["ready"] } graph: $START: - _: + new: role: planner prompt: "Plan" location: null + resume: + role: planner + prompt: "Resume" + location: null planner: ready: role: $END @@ -156,10 +164,14 @@ roles: $status: { type: string, enum: ["ready"] } graph: $START: - _: + new: role: planner prompt: "Plan" location: null + resume: + role: planner + prompt: "Resume" + location: null planner: ready: role: $END diff --git a/packages/cli/src/__tests__/thread-resume.test.ts b/packages/cli/src/__tests__/thread-resume.test.ts index 2353d3a..bff54cc 100644 --- a/packages/cli/src/__tests__/thread-resume.test.ts +++ b/packages/cli/src/__tests__/thread-resume.test.ts @@ -70,7 +70,10 @@ async function setupSuspendedThread(mode: MockAgentMode): Promise<{ }, }, graph: { - $START: { _: { role: "worker", prompt: "Start work", location: null } }, + $START: { + new: { role: "worker", prompt: "Start work", location: null }, + resume: { role: "worker", prompt: "Resume the work", location: null }, + }, worker: { needs_input: { role: "$SUSPEND", @@ -233,7 +236,10 @@ describe("uwf thread resume", () => { }, }, graph: { - $START: { _: { role: "worker", prompt: "Start", location: null } }, + $START: { + new: { role: "worker", prompt: "Start", location: null }, + resume: { role: "worker", prompt: "Resume", location: null }, + }, worker: { done: { role: "$END", prompt: "Done", location: null } }, }, }); @@ -479,7 +485,10 @@ describe("uwf thread resume - completed threads", () => { }, }, graph: { - $START: { _: { role: "worker", prompt: "Start work", location: null } }, + $START: { + new: { role: "worker", prompt: "Start work", location: null }, + resume: { role: "worker", prompt: "Resume the work", location: null }, + }, worker: { done: { role: "reviewer", prompt: "Review the work", location: null } }, reviewer: { done: { role: "$END", prompt: "Done", location: null } }, }, @@ -610,7 +619,7 @@ echo '${adapterJson}' expect(cliOutput.done).toBe(false); const capturedPrompt = await readFile(promptCapturePath, "utf8"); - expect(capturedPrompt).toContain("Previous run completed"); + expect(capturedPrompt).toContain("Resume the work"); expect(capturedPrompt).toContain("Additional context"); const storeModule = await import("../store.js"); @@ -640,7 +649,10 @@ echo '${adapterJson}' }, }, graph: { - $START: { _: { role: "worker", prompt: "Start", location: null } }, + $START: { + new: { role: "worker", prompt: "Start", location: null }, + resume: { role: "worker", prompt: "Resume", location: null }, + }, worker: { done: { role: "$END", prompt: "Done", location: null } }, }, }); @@ -688,7 +700,10 @@ echo '${adapterJson}' }, }, graph: { - $START: { _: { role: "worker", prompt: "Start", location: null } }, + $START: { + new: { role: "worker", prompt: "Start", location: null }, + resume: { role: "worker", prompt: "Resume", location: null }, + }, worker: { done: { role: "$END", prompt: "Done", location: null } }, }, }); diff --git a/packages/cli/src/__tests__/thread-show-status.test.ts b/packages/cli/src/__tests__/thread-show-status.test.ts index b4d979c..367a64f 100644 --- a/packages/cli/src/__tests__/thread-show-status.test.ts +++ b/packages/cli/src/__tests__/thread-show-status.test.ts @@ -34,10 +34,14 @@ roles: $status: { type: string, enum: ["ready"] } graph: $START: - _: + new: role: planner prompt: "Plan the work" location: null + resume: + role: planner + prompt: "Resume the work" + location: null planner: ready: role: $END @@ -66,10 +70,14 @@ roles: question: { type: string } graph: $START: - _: + new: role: worker prompt: "Start work" location: null + resume: + role: worker + prompt: "Resume work" + location: null worker: needs_input: role: $SUSPEND diff --git a/packages/cli/src/__tests__/thread-start-cwd-cli.test.ts b/packages/cli/src/__tests__/thread-start-cwd-cli.test.ts index 2649a93..4831d1d 100644 --- a/packages/cli/src/__tests__/thread-start-cwd-cli.test.ts +++ b/packages/cli/src/__tests__/thread-start-cwd-cli.test.ts @@ -57,10 +57,14 @@ roles: $status: { type: string, enum: ["ready"] } graph: $START: - _: + new: role: planner prompt: "Plan the work" location: null + resume: + role: planner + prompt: "Resume the work" + location: null planner: ready: role: $END diff --git a/packages/cli/src/__tests__/thread-suspend-step.test.ts b/packages/cli/src/__tests__/thread-suspend-step.test.ts index 9796837..300123e 100644 --- a/packages/cli/src/__tests__/thread-suspend-step.test.ts +++ b/packages/cli/src/__tests__/thread-suspend-step.test.ts @@ -58,7 +58,10 @@ describe("suspend step CAS chain and threads.yaml metadata", () => { }, }, graph: { - $START: { _: { role: "worker", prompt: "Start work", location: null } }, + $START: { + new: { role: "worker", prompt: "Start work", location: null }, + resume: { role: "worker", prompt: "Resume work", location: null }, + }, worker: { needs_input: { role: "$SUSPEND", diff --git a/packages/cli/src/__tests__/thread-suspended-display.test.ts b/packages/cli/src/__tests__/thread-suspended-display.test.ts index c50d869..1e18cdf 100644 --- a/packages/cli/src/__tests__/thread-suspended-display.test.ts +++ b/packages/cli/src/__tests__/thread-suspended-display.test.ts @@ -55,7 +55,10 @@ describe("suspended thread display", () => { }, }, graph: { - $START: { _: { role: "worker", prompt: "Start work", location: null } }, + $START: { + new: { role: "worker", prompt: "Start work", location: null }, + resume: { role: "worker", prompt: "Resume work", location: null }, + }, worker: { needs_input: { role: "$SUSPEND", @@ -162,7 +165,10 @@ describe("suspended thread display", () => { }, }, graph: { - $START: { _: { role: "worker", prompt: "Start work", location: null } }, + $START: { + new: { role: "worker", prompt: "Start work", location: null }, + resume: { role: "worker", prompt: "Resume work", location: null }, + }, worker: { needs_input: { role: "$SUSPEND", @@ -248,7 +254,10 @@ describe("suspended thread display", () => { }, }, graph: { - $START: { _: { role: "worker", prompt: "Start work", location: null } }, + $START: { + new: { role: "worker", prompt: "Start work", location: null }, + resume: { role: "worker", prompt: "Resume work", location: null }, + }, }, }); diff --git a/packages/cli/src/__tests__/validate-semantic.test.ts b/packages/cli/src/__tests__/validate-semantic.test.ts index 5e8ade2..ec715a9 100644 --- a/packages/cli/src/__tests__/validate-semantic.test.ts +++ b/packages/cli/src/__tests__/validate-semantic.test.ts @@ -51,7 +51,10 @@ function makeWorkflow(overrides?: Partial): WorkflowPayload { }, }, graph: { - $START: { _: { role: "writer", prompt: "Begin writing", location: null } }, + $START: { + new: { role: "writer", prompt: "Begin writing", location: null }, + resume: { role: "writer", prompt: "Review previous output and continue", location: null }, + }, writer: { done: { role: "reviewer", prompt: "Review this: {{{plan}}}", location: null } }, reviewer: { approved: { role: "$END", prompt: "Done: {{{summary}}}", location: null }, @@ -135,27 +138,38 @@ describe("Suite 2: Graph Structure", () => { expect(errors.some((e) => e.includes("$START must be defined in graph"))).toBe(true); }); - test("2.2 $START has multiple status keys", () => { + test("2.2 $START missing resume edge", () => { const wf = makeWorkflow(); wf.graph.$START = { - _: { role: "writer", prompt: "Begin", location: null }, - other: { role: "reviewer", prompt: "Also", location: null }, + new: { role: "writer", prompt: "Begin", location: null }, }; const errors = validateWorkflow(wf); expect( - errors.some((e) => e.includes('$START must have exactly one edge with status "_"')), + errors.some((e) => e.includes('$START must have edges with statuses "new" and "resume"')), ).toBe(true); }); - test("2.3 $START edge uses non-_ status", () => { + test("2.3 $START missing new edge", () => { const wf = makeWorkflow(); - wf.graph.$START = { ready: { role: "writer", prompt: "Begin", location: null } }; + wf.graph.$START = { + resume: { role: "writer", prompt: "Resume", location: null }, + }; const errors = validateWorkflow(wf); expect( - errors.some((e) => e.includes('$START must have exactly one edge with status "_"')), + errors.some((e) => e.includes('$START must have edges with statuses "new" and "resume"')), ).toBe(true); }); + test("2.3b $START with new and resume passes", () => { + const wf = makeWorkflow(); + wf.graph.$START = { + new: { role: "writer", prompt: "Begin", location: null }, + resume: { role: "writer", prompt: "Resume", location: null }, + }; + const errors = validateWorkflow(wf); + expect(errors.some((e) => e.includes("$START must have edges"))).toBe(false); + }); + test("2.4 $END has outgoing edges", () => { const wf = makeWorkflow(); wf.graph.$END = { _: { role: "writer", prompt: "Loop", location: null } }; @@ -193,15 +207,18 @@ describe("Suite 2: Graph Structure", () => { }); describe("Suite 3: Status-Edge Consistency", () => { - test("3.1 user role using _ graph key is rejected", () => { + test("3.1 user role using _ graph key is treated as an unknown status", () => { + // "_" is no longer special-cased — it's just a status key that does not + // match the role's $status enum, so it surfaces as extra/missing keys. const wf = makeWorkflow(); wf.graph.writer = { _: { role: "reviewer", prompt: "Review", location: null } }; const errors = validateWorkflow(wf); - expect( - errors.some((e) => - e.includes('role "writer" must use explicit $status keys in graph, not "_"'), - ), - ).toBe(true); + expect(errors.some((e) => e.includes('role "writer" graph has extra status keys: _'))).toBe( + true, + ); + expect(errors.some((e) => e.includes('role "writer" graph is missing status keys: done'))).toBe( + true, + ); }); test("3.2 user role graph key not matching $status enum", () => { @@ -240,13 +257,16 @@ describe("Suite 3: Status-Edge Consistency", () => { ).toBe(true); }); - test("3.5 multi-exit role with _ key", () => { + test("3.5 multi-exit role with _ key is treated as an unknown status", () => { const wf = makeWorkflow(); wf.graph.reviewer = { _: { role: "$END", prompt: "Done", location: null } }; const errors = validateWorkflow(wf); + expect(errors.some((e) => e.includes('role "reviewer" graph has extra status keys: _'))).toBe( + true, + ); expect( errors.some((e) => - e.includes('role "reviewer" must use explicit $status keys in graph, not "_"'), + e.includes('role "reviewer" graph is missing status keys: approved, rejected'), ), ).toBe(true); }); diff --git a/packages/cli/src/__tests__/workflow-resolution.test.ts b/packages/cli/src/__tests__/workflow-resolution.test.ts index 029032a..6f85efa 100644 --- a/packages/cli/src/__tests__/workflow-resolution.test.ts +++ b/packages/cli/src/__tests__/workflow-resolution.test.ts @@ -38,7 +38,10 @@ function makeMinimalPayload(name: string, description: string): WorkflowPayload }, }, graph: { - $START: { _: { role: "worker", prompt: "start working", location: null } }, + $START: { + new: { role: "worker", prompt: "start working", location: null }, + resume: { role: "worker", prompt: "resume working", location: null }, + }, worker: { done: { role: "$END", prompt: "done", location: null } }, }, }; diff --git a/packages/cli/src/commands/thread.ts b/packages/cli/src/commands/thread.ts index cd88ece..c6cbcf4 100644 --- a/packages/cli/src/commands/thread.ts +++ b/packages/cli/src/commands/thread.ts @@ -911,7 +911,7 @@ function resolveEvaluateArgs( chain: ChainState, ): { lastRole: string; lastOutput: EvaluateLastOutput } { if (chain.headIsStart) { - return { lastRole: START_ROLE, lastOutput: { [STATUS_KEY]: "_" } }; + return { lastRole: START_ROLE, lastOutput: { [STATUS_KEY]: "new" } }; } const lastStep = chain.stepsNewestFirst[0]; @@ -1037,7 +1037,6 @@ function archiveThread(uwf: UwfStore, threadId: ThreadId, _workflow: CasRef, _he completeThread(uwf.varStore, threadId, "completed"); } -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: orchestration function with inherent branching export async function cmdThreadResume( storageRoot: string, threadId: ThreadId, @@ -1101,7 +1100,7 @@ export async function cmdThreadResume( // status === "completed" const workflow = loadWorkflowPayload(uwf, workflowHash); - const startResult = evaluate(workflow.graph, START_ROLE, {}); + const startResult = evaluate(workflow.graph, START_ROLE, { [STATUS_KEY]: "resume" }); if (!startResult.ok) { fail(`failed to evaluate $START: ${startResult.error.message}`); } @@ -1113,11 +1112,7 @@ export async function cmdThreadResume( } const startRole = startResult.value.role; - const completedPromptPrefix = "Previous run completed. Resuming with additional context."; - const completedResumePrompt = - supplement !== null && supplement !== "" - ? `${completedPromptPrefix}\n\n${supplement}` - : completedPromptPrefix; + const completedResumePrompt = buildResumePrompt(startResult.value.prompt, supplement); const updatedEntry = { ...entry, status: "idle" as const, completedAt: null }; setThread(uwf.varStore, threadId, updatedEntry); diff --git a/packages/cli/src/moderator/__tests__/evaluate.test.ts b/packages/cli/src/moderator/__tests__/evaluate.test.ts index 8290bbb..a3e8194 100644 --- a/packages/cli/src/moderator/__tests__/evaluate.test.ts +++ b/packages/cli/src/moderator/__tests__/evaluate.test.ts @@ -6,11 +6,11 @@ describe("Edge prompt template variable resolution", () => { test("returns error when rendered prompt is empty string", () => { const graph = { $START: { - _: { role: "classifier", prompt: "{{{userPrompt}}}", location: null }, + new: { role: "classifier", prompt: "{{{userPrompt}}}", location: null }, }, }; - const result = evaluate(graph, "$START", {}); + const result = evaluate(graph, "$START", { $status: "new" }); expect(result.ok).toBe(false); if (!result.ok) { @@ -22,11 +22,11 @@ describe("Edge prompt template variable resolution", () => { test("returns error when rendered prompt is whitespace-only", () => { const graph = { $START: { - _: { role: "classifier", prompt: " {{{userPrompt}}} ", location: null }, + new: { role: "classifier", prompt: " {{{userPrompt}}} ", location: null }, }, }; - const result = evaluate(graph, "$START", {}); + const result = evaluate(graph, "$START", { $status: "new" }); expect(result.ok).toBe(false); if (!result.ok) { @@ -38,11 +38,11 @@ describe("Edge prompt template variable resolution", () => { test("succeeds when all template variables resolve to non-empty values", () => { const graph = { $START: { - _: { role: "classifier", prompt: "{{{userPrompt}}}", location: null }, + new: { role: "classifier", prompt: "{{{userPrompt}}}", location: null }, }, }; - const result = evaluate(graph, "$START", { userPrompt: "Fix the bug" }); + const result = evaluate(graph, "$START", { $status: "new", userPrompt: "Fix the bug" }); expect(result.ok).toBe(true); if (result.ok) { @@ -53,11 +53,11 @@ describe("Edge prompt template variable resolution", () => { test("succeeds with static (no-variable) prompt", () => { const graph = { $START: { - _: { role: "classifier", prompt: "Classify this input", location: null }, + new: { role: "classifier", prompt: "Classify this input", location: null }, }, }; - const result = evaluate(graph, "$START", {}); + const result = evaluate(graph, "$START", { $status: "new" }); expect(result.ok).toBe(true); if (result.ok) { @@ -68,11 +68,11 @@ describe("Edge prompt template variable resolution", () => { test("succeeds when prompt has mix of static text and unresolved variables", () => { const graph = { $START: { - _: { role: "classifier", prompt: "Please handle: {{{userPrompt}}}", location: null }, + new: { role: "classifier", prompt: "Please handle: {{{userPrompt}}}", location: null }, }, }; - const result = evaluate(graph, "$START", {}); + const result = evaluate(graph, "$START", { $status: "new" }); expect(result.ok).toBe(true); if (result.ok) { @@ -83,11 +83,11 @@ describe("Edge prompt template variable resolution", () => { test("returns error when ALL variables missing and no static text remains", () => { const graph = { $START: { - _: { role: "classifier", prompt: "{{{a}}}{{{b}}}", location: null }, + new: { role: "classifier", prompt: "{{{a}}}{{{b}}}", location: null }, }, }; - const result = evaluate(graph, "$START", {}); + const result = evaluate(graph, "$START", { $status: "new" }); expect(result.ok).toBe(false); }); diff --git a/packages/cli/src/moderator/evaluate.ts b/packages/cli/src/moderator/evaluate.ts index b5372d2..279b1d4 100644 --- a/packages/cli/src/moderator/evaluate.ts +++ b/packages/cli/src/moderator/evaluate.ts @@ -6,10 +6,7 @@ import type { EvaluateResult, Result } from "./types.js"; // Disable HTML escaping — prompts are plain text, not HTML. mustache.escape = (text: string) => text; -const START_ROLE = "$START"; const SUSPEND_ROLE = "$SUSPEND"; -// $START is a special entry node with no agent output — it always uses this key. -const START_STATUS = "_"; type LastOutput = Record; @@ -21,9 +18,7 @@ export function evaluate( lastOutput: LastOutput, ): Result { let status: string; - if (lastRole === START_ROLE) { - status = START_STATUS; - } else if (typeof lastOutput[STATUS_KEY] === "string") { + if (typeof lastOutput[STATUS_KEY] === "string") { status = lastOutput[STATUS_KEY] as string; } else { return { diff --git a/packages/cli/src/validate-semantic.ts b/packages/cli/src/validate-semantic.ts index c543b88..35b8cac 100644 --- a/packages/cli/src/validate-semantic.ts +++ b/packages/cli/src/validate-semantic.ts @@ -97,9 +97,9 @@ function checkGraphStructure(payload: WorkflowPayload, errors: string[]): void { if (!graphNodes.has("$START")) { errors.push("$START must be defined in graph"); } else { - const startKeys = Object.keys(payload.graph.$START); - if (startKeys.length !== 1 || startKeys[0] !== "_") { - errors.push('$START must have exactly one edge with status "_"'); + const startKeys = new Set(Object.keys(payload.graph.$START)); + if (!startKeys.has("new") || !startKeys.has("resume")) { + errors.push('$START must have edges with statuses "new" and "resume"'); } } @@ -190,22 +190,13 @@ function checkOneOfDiscriminant( } } -/** Check status-edge consistency for a user role. "_" is reserved for $START and rejected here. */ +/** Check status-edge consistency for a user role. */ function checkStatusEdges( roleName: string, graphKeys: Set, statusSet: Set, errors: string[], ): void { - if (graphKeys.has("_")) { - errors.push(`role "${roleName}" must use explicit $status keys in graph, not "_"`); - return; - } - if (statusSet.has("_")) { - errors.push(`role "${roleName}" $status enum must use explicit values, not "_"`); - return; - } - const extraKeys = [...graphKeys].filter((k) => !statusSet.has(k)); const missingKeys = [...statusSet].filter((k) => !graphKeys.has(k)); if (extraKeys.length > 0) { diff --git a/packages/cli/src/validate.ts b/packages/cli/src/validate.ts index ff05145..5824454 100644 --- a/packages/cli/src/validate.ts +++ b/packages/cli/src/validate.ts @@ -57,13 +57,13 @@ function isGraph(value: unknown): boolean { if (!isRecord(value)) { return false; } - return Object.entries(value).every(([node, statusMap]) => { + return Object.values(value).every((statusMap) => { if (!isRecord(statusMap)) { return false; } return Object.entries(statusMap).every(([status, target]) => { - // "_" is only valid as a status key for the $START entry node. - if (status === "_" && node !== "$START") { + // "_" is no longer a valid status key anywhere — $START uses "new"/"resume". + if (status === "_") { return false; } return isTarget(target); diff --git a/packages/util/src/moderator-reference.ts b/packages/util/src/moderator-reference.ts index c4303eb..32a4fc5 100644 --- a/packages/util/src/moderator-reference.ts +++ b/packages/util/src/moderator-reference.ts @@ -16,7 +16,8 @@ The graph is a nested map: \`Record>\`. \`\`\`yaml graph: $START: - _: { role: planner, prompt: "Analyze the issue." } + new: { role: planner, prompt: "Analyze the issue." } + resume: { role: planner, prompt: "Review the previous run output and continue." } planner: ready: { role: developer, prompt: "Implement the plan (CAS hash: {{{plan}}})." } insufficient_info: { role: $END, prompt: "Not enough info." } @@ -41,7 +42,7 @@ Edge prompts use triple-brace Mustache syntax (\`{{{field}}}\`) to interpolate v ## Special Nodes -- \`$START\` — entry point; uses status key \`_\` (unconditional) since there is no previous output +- \`$START\` — entry point; uses status keys \`new\` (first start) and \`resume\` (resuming a completed thread) - \`$END\` — terminal node; thread completes when reached and is moved to history ## Integration with Steps diff --git a/packages/util/src/workflow-authoring-reference.ts b/packages/util/src/workflow-authoring-reference.ts index 3da3ffe..0220bc4 100644 --- a/packages/util/src/workflow-authoring-reference.ts +++ b/packages/util/src/workflow-authoring-reference.ts @@ -40,7 +40,8 @@ roles: # named actors graph: # status-based routing $START: - _: { role: planner, prompt: "Analyze the issue." } + new: { role: planner, prompt: "Analyze the issue." } + resume: { role: planner, prompt: "Review the previous run output and continue." } planner: ready: { role: developer, prompt: "Implement {{{plan}}}." } failed: { role: $END, prompt: "Failed: {{{error}}}" } @@ -113,7 +114,7 @@ graph[role][$status] → { role: nextRole, prompt: edgePrompt } | Node | Purpose | |------|---------| -| \`$START\` | Entry point — status key is always \`_\` (unconditional) | +| \`$START\` | Entry point — status keys \`new\` (first start) and \`resume\` (resuming a completed thread) | | \`$END\` | Terminal — thread completes and is archived | ### Edge Prompts @@ -178,7 +179,7 @@ ocas get 1. Every \`$status\` value in a role's frontmatter has a matching edge in the graph 2. Every field referenced in edge prompts (\`{{{field}}}\`) exists in the source role's schema 3. Every role referenced in the graph exists in \`roles\` -4. \`$START\` has exactly one edge with key \`_\` +4. \`$START\` has edges with keys \`new\` and \`resume\` 5. At least one path leads to \`$END\` 6. No orphan roles (defined but never routed to) diff --git a/packages/util/src/yaml-reference.ts b/packages/util/src/yaml-reference.ts index fb47621..53d5e1d 100644 --- a/packages/util/src/yaml-reference.ts +++ b/packages/util/src/yaml-reference.ts @@ -32,7 +32,8 @@ roles: # named actors in the workflow graph: # status-based routing (nested map) $START: - _: { role: planner, prompt: "Analyze the issue." } + new: { role: planner, prompt: "Analyze the issue." } + resume: { role: planner, prompt: "Review the previous run output and continue." } planner: ready: { role: developer, prompt: "Implement plan {{{plan}}}." } insufficient_info: { role: $END, prompt: "Not enough info." } @@ -70,10 +71,10 @@ Record> | Level | Key | Value | |-------|-----|-------| | Outer | Role name or \`$START\` | Status map for that role | -| Inner | \`$status\` value (or \`_\` for unconditional) | Target: \`{ role, prompt }\` | +| Inner | \`$status\` value | Target: \`{ role, prompt }\` | ### Special Nodes -- \`$START\` — entry point; uses status key \`_\` (unconditional, no previous output) +- \`$START\` — entry point; uses status keys \`new\` (first start) and \`resume\` (resuming a completed thread) - \`$END\` — terminal node; thread completes when reached ### Edge Prompts diff --git a/workflows/normalize-bun-monorepo.yaml b/workflows/normalize-bun-monorepo.yaml index e426c49..6d4f0d7 100644 --- a/workflows/normalize-bun-monorepo.yaml +++ b/workflows/normalize-bun-monorepo.yaml @@ -21,9 +21,12 @@ graph: role: package-metadata prompt: Biome setup failed ({{{reason}}}), but continue. Standardize package metadata for repo at {{{repoPath}}}. $START: - _: + new: role: workspace prompt: Set up bun workspace structure for repo at {{{repoPath}}}. + resume: + role: workspace + prompt: Review the previous run output and continue setting up the bun workspace structure for repo at {{{repoPath}}}. release: done: role: testing diff --git a/workflows/solve-issue.yaml b/workflows/solve-issue.yaml index f75759f..3276510 100644 --- a/workflows/solve-issue.yaml +++ b/workflows/solve-issue.yaml @@ -283,9 +283,12 @@ roles: - error graph: $START: - _: + new: role: planner prompt: Analyze the issue and produce an implementation plan. + resume: + role: planner + prompt: Review the previous run output and continue the work. planner: insufficient_info: role: $SUSPEND