@deksden-com/dd-flow-cli 0.9.0-beta.83 → 0.9.0-beta.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.87
4
+
5
+ ### Patch Changes
6
+
7
+ - a49655a: Use separate files for model-authored lifecycle payloads and preserve native
8
+ shell-composition diagnostics instead of reporting a missing hook receipt.
9
+
10
+ ## 0.9.0-beta.86
11
+
12
+ ### Patch Changes
13
+
14
+ - 8abe471: Settle invalid managed lifecycle arguments atomically before dispatch and return one safe corrected command.
15
+
16
+ ## 0.9.0-beta.85
17
+
18
+ ### Patch Changes
19
+
20
+ - fe8f493: Restore runtime-owned response publication options before dispatching a managed lifecycle command, and update the controller fixture to validate the public command contract.
21
+
22
+ ## 0.9.0-beta.84
23
+
24
+ ### Patch Changes
25
+
26
+ - 644d181: Keep RUN identity, project paths, context files, response destinations and
27
+ integrity hashes inside retained lifecycle authority instead of asking agents
28
+ to copy them. Restore those inputs deterministically when the observed command
29
+ executes, including MERGE apply and repair commands.
30
+
3
31
  ## 0.9.0-beta.83
4
32
 
5
33
  ### Patch Changes
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.83",
4
- "cli_commit": "7ede03ea5da48b77ab59b46bed8028b2a885da61",
5
- "built_at": "2026-09-20T14:09:35.175Z",
3
+ "cli_version": "0.9.0-beta.87",
4
+ "cli_commit": "68245785d197ab491e80db40faf2e57a18a6a055",
5
+ "built_at": "2026-09-20T22:46:39.592Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.1.1",
8
- "commit": "97f811d33c212ae3497020178b1ed825c7c3ebac",
8
+ "commit": "acaae844a871ddfbc49fd66355373f166dc3e683",
9
9
  "flow_contract": "dd-flow-canonical-2026-08",
10
10
  "repo_root": "/home/runner/work/dd-flow-cli/dd-flow-cli/dd-memorybank",
11
11
  "memorybank_root": "/home/runner/work/dd-flow-cli/dd-flow-cli/dd-memorybank/.memory-bank",
@@ -43,9 +43,9 @@ export const commandInputs = {
43
43
  "prompt render": route(0, `${project} run stage profile`, "workspace-root task-file plan-item"),
44
44
  "stage start": { ...route([0, 1], `${project} stage`, `${lifecycle} dir subject intake-file context-file context-sha256`, "bootstrap intake-stdin require-session-binding"), contextualPaths: words("project-root intake-file context-file") },
45
45
  "stage finish": { ...route(1, `${project} stage`, `${lifecycle} dir semantic-file data result-file outcome decision-file verification-file retry-check reason request work`, "result-stdin"), contextualPaths: words("project-root semantic-file result-file decision-file verification-file") },
46
- "stage pause": { ...route(1, `${project} stage work`, lifecycle, "question-stdin"), contextualPaths: ["project-root"] },
47
- "stage resume": { ...route(1, `${project} stage work`, lifecycle, "answer-stdin"), contextualPaths: ["project-root"] },
48
- "stage block": route(1, `${project} stage work kind code`, "", "summary-stdin retryable", "", { kind: ["engine", "harness", "environment"] }),
46
+ "stage pause": { ...route(1, `${project} stage work`, `${lifecycle} question-file`, "question-stdin"), contextualPaths: words("project-root question-file") },
47
+ "stage resume": { ...route(1, `${project} stage work`, `${lifecycle} answer-file`, "answer-stdin"), contextualPaths: words("project-root answer-file") },
48
+ "stage block": { ...route(1, `${project} stage work kind code`, "summary-file", "summary-stdin retryable", "", { kind: ["engine", "harness", "environment"] }), contextualPaths: words("project-root summary-file") },
49
49
  "stage unblock": route(1, `${project} stage work`),
50
50
  "stage fanout status": route(1, "stage", scoped),
51
51
  "stage fanout dispatch": route(1, "stage", scoped),
@@ -55,7 +55,7 @@ export const commandInputs = {
55
55
  "work add-batch": route(0, "parent file", scoped),
56
56
  "work ls": route(0, "", `${scoped} run parent status limit`, "ready include-results", "", { status: ["created", "running", "paused", "completed", "failed", "cancelled"] }),
57
57
  "work show": route(1, "", scoped),
58
- "work repair add": route(0, "run", `${scoped} origin-work from-check from-finding from-unresolved check-ref verification-file`, "task-stdin", "origin-work from-finding from-unresolved check-ref"),
58
+ "work repair add": { ...route(0, "run", `${scoped} origin-work from-check from-finding from-unresolved check-ref verification-file task-file`, "task-stdin", "origin-work from-finding from-unresolved check-ref"), contextualPaths: words("project-root verification-file task-file") },
59
59
  "work deps add": route(1, "on", scoped, "", "on"),
60
60
  "work deps remove": route(1, "on", scoped, "", "on"),
61
61
  "work deps list": route(1, "", scoped),
@@ -285,7 +285,8 @@ export function prepareCommandRoute(args) {
285
285
  ["root", "project-root"], ["payload-file", "json-file"],
286
286
  ["payload-file", "payload-json", "payload-base64"], ["semantic-file", "data", "result-file", "result-stdin"],
287
287
  ["stage-report", "dashboard"], ["task-file", "plan-item"], ["project", "project-root"],
288
- ["intake-file", "intake-stdin", "context-file"]
288
+ ["intake-file", "intake-stdin", "context-file"], ["question-file", "question-stdin"], ["answer-file", "answer-stdin"],
289
+ ["summary-file", "summary-stdin"], ["task-file", "task-stdin"]
289
290
  ]) {
290
291
  const present = aliases.filter(name => parsed.options.has(name));
291
292
  if (present.length > 1)
package/dist/cli/help.js CHANGED
@@ -231,9 +231,9 @@ Examples:
231
231
  Usage:
232
232
  dd-flow stage start <RUN-ID|RUN-short-id> --project-root <root> --stage <name> [--dir <NN-stage-slug>] [--require-session-binding] --json
233
233
  dd-flow stage start --bootstrap --project-root <root> --stage specify --subject <safe-subject> (--intake-file <path>|--intake-stdin) [--require-session-binding] --json
234
- dd-flow stage pause <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --work <WORK-ID> --question-stdin --json
235
- dd-flow stage resume <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --work <WORK-ID> --answer-stdin --json
236
- dd-flow stage block <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --work <WORK-ID> --kind engine|harness|environment --code <code> --summary-stdin [--retryable] --json
234
+ dd-flow stage pause <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --work <WORK-ID> (--question-file <path>|--question-stdin) --json
235
+ dd-flow stage resume <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --work <WORK-ID> (--answer-file <path>|--answer-stdin) --json
236
+ dd-flow stage block <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --work <WORK-ID> --kind engine|harness|environment --code <code> (--summary-file <path>|--summary-stdin) [--retryable] --json
237
237
  dd-flow stage unblock <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --work <WORK-ID> --json
238
238
  dd-flow stage fanout status <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --json
239
239
  dd-flow stage fanout dispatch <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --json
@@ -254,7 +254,7 @@ Usage:
254
254
  dd-flow work start <WORK-ID> --project-root <root> --json
255
255
  dd-flow work launch <WORK-ID> [--stage <name>] --json
256
256
  dd-flow work finish <WORK-ID> (--result-file <path>|--result-stdin) --project-root <root> --json
257
- dd-flow work repair add --run <RUN-ID> (--from-check <CHECK-ID>|--from-unresolved <gap> --verification-file <file>) --origin-work <WORK-ID>... --task-stdin --project-root <root> --json
257
+ dd-flow work repair add --run <RUN-ID> (--from-check <CHECK-ID>|--from-unresolved <gap> --verification-file <file>) --origin-work <WORK-ID>... (--task-file <path>|--task-stdin) --project-root <root> --json
258
258
 
259
259
  CODE workers receive their full accepted packet from work start. work finish validates the result and executes the packet's focused checks before completion. A failed aggregate CODE gate or unresolved semantic verification creates a repair Work from the selected completed origin context and retained evidence. Its planned write areas are coordination hints only; the repair may change any necessary project file inside its RUN workspace.`
260
260
  ],
@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
4
4
  import { quote } from "shell-quote";
5
5
  import { observeLifecycleCommand } from "../services/lifecycle-invocations.js";
6
6
  import { verifyCodexHookDelivery } from "../services/codex-hook-delivery.js";
7
- import { assertLifecycleInvocationCurrent, awaitLifecycleInvocation, expandLifecycleInvocationArgs, issueLifecycleInvocation, prepareLifecycleIssuance, lifecycleInvocationScope, observedLifecycleInvocation, settleLifecycleInvocation, settleLifecycleRejection, validateLifecyclePathAliases } from "../services/lifecycle-invocations.js";
7
+ import { assertLifecycleInvocationCurrent, awaitLifecycleInvocation, expandLifecycleInvocationArgs, issueLifecycleInvocation, prepareLifecycleIssuance, lifecycleInvocationScope, observedLifecycleDiagnostic, observedLifecycleInvocation, resolveObservedLifecycleArgs, settleLifecycleInvocation, settleLifecyclePreparationRejection, settleLifecycleRejection, validateLifecyclePathAliases } from "../services/lifecycle-invocations.js";
8
8
  import { createContext, createRouterContext } from "../runtime/context.js";
9
9
  import { migrateStoreWriter } from "../storage/writer-migration.js";
10
10
  import { helpForArgs } from "./help.js";
@@ -134,19 +134,43 @@ export async function runCli(args, io = defaultIo, env = process.env) {
134
134
  validateLifecyclePathAliases(output.args, lifecycleCommand.invocation.operation);
135
135
  const observedInvocation = observedLifecycleInvocation(routerContext, publicCommand);
136
136
  if (!observedInvocation && !suppliedInvocationId && env.DD_FLOW_DAEMON_ID && lifecycleCommand.kind === "standalone") {
137
+ const diagnostic = observedLifecycleDiagnostic(routerContext, publicCommand, env.DD_FLOW_DAEMON_ID);
138
+ if (diagnostic)
139
+ throw new AppError("lifecycle_shell_syntax_invalid", `Native observer rejected the shell form: ${String(diagnostic.reason ?? "unsupported shell composition")}. Write structured input to a file and run the returned dd-flow command separately.`, 2, { ...diagnostic, phase: "prepare", effect: "no_effect", recoverable: true });
137
140
  throw new AppError("invocation_receipt_missing", "Managed lifecycle command has no committed native receipt", 1, { effect: "no_effect", recoverable: false });
138
141
  }
139
142
  const invocationId = observedInvocation?.id ?? suppliedInvocationId;
140
- const boundInvocationArgs = invocationId && invocationId !== suppliedInvocationId
141
- ? [...output.args.filter((_, index, values) => values[index - 1] !== "--invocation-id" && values[index] !== "--invocation-id"), "--invocation-id", invocationId]
142
- : output.args;
143
+ let boundInvocationArgs;
144
+ try {
145
+ boundInvocationArgs = observedInvocation
146
+ ? resolveObservedLifecycleArgs(routerContext, observedInvocation.id, output.args)
147
+ : invocationId && invocationId !== suppliedInvocationId
148
+ ? [...output.args.filter((_, index, values) => values[index - 1] !== "--invocation-id" && values[index] !== "--invocation-id"), "--invocation-id", invocationId]
149
+ : output.args;
150
+ }
151
+ catch (error) {
152
+ if (!(observedInvocation && error instanceof AppError))
153
+ throw error;
154
+ const rejectionContext = createContext({ ...env }, "hook");
155
+ try {
156
+ Object.assign(error.details, settleLifecyclePreparationRejection(rejectionContext, observedInvocation.id, error));
157
+ }
158
+ finally {
159
+ rejectionContext.db.close?.();
160
+ }
161
+ throw error;
162
+ }
143
163
  const observedOperation = lifecycleCommand.kind === "standalone" ? lifecycleCommand.invocation.operation : null;
144
164
  if (observedInvocation && !observedOperation)
145
165
  throw new AppError("invocation_command_invalid", "Observed lifecycle command could not be parsed", 1);
146
166
  const invocationScope = invocationId ? lifecycleInvocationScope({ ...routerContext, env }, invocationId) : undefined;
147
- const invocationArgs = invocationScope && observedOperation
167
+ const expandedInvocationArgs = invocationScope && observedOperation
148
168
  ? expandLifecycleInvocationArgs(routerContext, boundInvocationArgs, invocationScope, observedOperation)
149
169
  : boundInvocationArgs;
170
+ const retainedOutput = observedInvocation ? parseOutputOptions(expandedInvocationArgs) : null;
171
+ if (retainedOutput?.responseFile)
172
+ output.responseFile = retainedOutput.responseFile;
173
+ const invocationArgs = retainedOutput?.args ?? expandedInvocationArgs;
150
174
  emitHumanProgress(io, output, progress, "start");
151
175
  if (output.progressJsonl) {
152
176
  writeProgressJsonl(io, "start", `Starting ${output.args.slice(0, 3).join(" ")}`);
@@ -957,9 +981,10 @@ async function prepareCliInputValue(args, context, io, scopeProjectRoot, respons
957
981
  const input = { projectRoot: requiredOption(parsed, "project-root"), runId: requiredPosition(parsed, 0, "run-id"), stage: requiredOption(parsed, "stage"), workId: requiredOption(parsed, "work") };
958
982
  try {
959
983
  if (route.key === "stage block") {
960
- if (!hasOption(parsed, "summary-stdin"))
961
- throw new AppError("usage", "stage block requires --summary-stdin", 2);
962
- prepared.interactionText = await readStdin(io.stdin);
984
+ const summaryFile = optionalOption(parsed, "summary-file"), summaryStdin = hasOption(parsed, "summary-stdin");
985
+ if (Boolean(summaryFile) === summaryStdin)
986
+ throw new AppError("usage", "stage block requires exactly one of --summary-file or --summary-stdin", 2);
987
+ prepared.interactionText = summaryFile ? prepareTextFile(summaryFile, "Runtime block summary").text : await readStdin(io.stdin);
963
988
  prepareStageRuntimeBlock(context, { ...input, kind: requiredOption(parsed, "kind"), code: requiredOption(parsed, "code"), summary: prepared.interactionText, retryable: hasOption(parsed, "retryable") });
964
989
  }
965
990
  else
@@ -992,11 +1017,13 @@ async function prepareCliInputValue(args, context, io, scopeProjectRoot, respons
992
1017
  }
993
1018
  }
994
1019
  if (route.key === "work repair add") {
995
- if (!hasOption(parsed, "task-stdin"))
996
- throw new AppError("usage", "work repair add requires --task-stdin", 2);
997
- if (io.stdin?.isTTY)
1020
+ const taskFile = optionalOption(parsed, "task-file"), taskStdin = hasOption(parsed, "task-stdin");
1021
+ if (Boolean(taskFile) === taskStdin)
1022
+ throw new AppError("usage", "work repair add requires exactly one of --task-file or --task-stdin", 2);
1023
+ if (taskStdin && io.stdin?.isTTY)
998
1024
  throw new AppError("usage", "--task-stdin requires piped or redirected input", 2);
999
- prepared.workRepair = prepareCliWorkRepair(context, parsed, await readStdin(io.stdin), scopeProjectRoot);
1025
+ const task = taskFile ? prepareTextFile(taskFile, "Repair task").text : await readStdin(io.stdin);
1026
+ prepared.workRepair = prepareCliWorkRepair(context, parsed, task, scopeProjectRoot);
1000
1027
  }
1001
1028
  if (route.key === "run config set" || route.key === "run vars set") {
1002
1029
  const input = { projectRoot: requiredOption(parsed, "project-root"), runId: requiredPosition(parsed, 1, "run-id"), key: requiredOption(parsed, "key"), value: requiredOption(parsed, "value") };
@@ -1132,14 +1159,17 @@ async function prepareCliInputValue(args, context, io, scopeProjectRoot, respons
1132
1159
  requiredOption(parsed, "project-root");
1133
1160
  requiredOption(parsed, "stage");
1134
1161
  requiredOption(parsed, "work");
1135
- const parameter = command === "pause" ? "question-stdin" : "answer-stdin";
1136
- if (!hasOption(parsed, parameter))
1137
- throw new AppError("usage", `stage ${command} requires --${parameter}`, 2);
1138
- if (io.stdin?.isTTY)
1139
- throw new AppError("usage", `--${parameter} requires piped or redirected input`, 2);
1140
- prepared.interactionText = await readStdin(io.stdin);
1162
+ const fileParameter = command === "pause" ? "question-file" : "answer-file";
1163
+ const stdinParameter = command === "pause" ? "question-stdin" : "answer-stdin";
1164
+ const inputFile = optionalOption(parsed, fileParameter);
1165
+ const fromStdin = hasOption(parsed, stdinParameter);
1166
+ if (Boolean(inputFile) === fromStdin)
1167
+ throw new AppError("usage", `stage ${command} requires exactly one of --${fileParameter} or --${stdinParameter}`, 2);
1168
+ if (fromStdin && io.stdin?.isTTY)
1169
+ throw new AppError("usage", `--${stdinParameter} requires piped or redirected input`, 2);
1170
+ prepared.interactionText = inputFile ? prepareTextFile(inputFile, command === "pause" ? "User question" : "User answer").text : await readStdin(io.stdin);
1141
1171
  if (!prepared.interactionText.trim())
1142
- return prepareError(new AppError("validation", `stage ${command} requires non-empty --${parameter}`, 2), { parameter });
1172
+ return prepareError(new AppError("validation", `stage ${command} requires non-empty input`, 2), { parameter: inputFile ? fileParameter : stdinParameter });
1143
1173
  const input = { projectRoot: requiredOption(parsed, "project-root"), runId: requiredPosition(parsed, 0, "run-id"), stage: requiredOption(parsed, "stage"), workId: requiredOption(parsed, "work") };
1144
1174
  try {
1145
1175
  if (command === "pause")
@@ -1726,8 +1756,6 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
1726
1756
  }, prepared.legacyStageFinish));
1727
1757
  }
1728
1758
  if (family === "stage" && command === "pause") {
1729
- if (!hasOption(parsed, "question-stdin"))
1730
- throw new AppError("usage", "stage pause requires --question-stdin", 2);
1731
1759
  const projectRoot = requiredOption(parsed, "project-root");
1732
1760
  const runId = requiredPosition(parsed, 0, "run-id");
1733
1761
  const stage = requiredOption(parsed, "stage");
@@ -1749,8 +1777,6 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
1749
1777
  });
1750
1778
  }
1751
1779
  if (family === "stage" && command === "resume") {
1752
- if (!hasOption(parsed, "answer-stdin"))
1753
- throw new AppError("usage", "stage resume requires --answer-stdin", 2);
1754
1780
  const hookEventId = optionalOption(parsed, "hook-event-id");
1755
1781
  return resumeStageAfterUser(context, {
1756
1782
  projectRoot: requiredOption(parsed, "project-root"),
@@ -1763,8 +1789,6 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
1763
1789
  });
1764
1790
  }
1765
1791
  if (family === "stage" && command === "block") {
1766
- if (!hasOption(parsed, "summary-stdin"))
1767
- throw new AppError("usage", "stage block requires --summary-stdin", 2);
1768
1792
  const kind = requiredOption(parsed, "kind");
1769
1793
  if (kind !== "engine" && kind !== "harness" && kind !== "environment")
1770
1794
  throw new AppError("usage", "--kind must be engine, harness or environment", 2);
@@ -5,11 +5,11 @@ import { dispatchVnextPlanReview } from "./vnext-plan-review.js";
5
5
  import { resolveExecutionPolicy } from "./execution-policy.js";
6
6
  import { renderDelegationInstructions } from "../harness-runtime/lib/delegation-instructions.mjs";
7
7
  /** Stage entry acknowledges the packet before the controller issues child commands. */
8
- export function controllerStageEntryPrompt(stage, command, responseFile) {
8
+ export function controllerStageEntryPrompt(stage, command) {
9
9
  return [
10
10
  `Start the assigned ${stage} Stage now. Your first tool call must be this exact standalone command:`,
11
11
  command,
12
- `Read the complete authoritative packet saved to ${JSON.stringify(responseFile)}, including worker_prompt_markdown. If a read is truncated, continue until the entire packet has been read.`,
12
+ "Read the complete authoritative packet from the command's response_file result, including worker_prompt_markdown. If a read is truncated, continue until the entire packet has been read.",
13
13
  "If the returned packet has orchestration.kind = work_fanout, this Turn is only Stage entry: acknowledge the packet and end your Turn immediately. The packet describes the whole Stage, but its graph/dispatch/finish instructions belong to later controller continuations. Do not list, start, launch or finish Work, create children, change files, run checks, or finish the Stage in this entry Turn. The controller will inspect the graph and send the next assignment with exact issued commands (or dispatch external workers itself).",
14
14
  "Otherwise perform this Stage's semantic work and stop at its boundary or a declared HITL boundary. Do not start a successor Stage.",
15
15
  "Execute each runtime-issued lifecycle operation and target as one standalone call. Internal invocation IDs are intentionally absent; never add, copy or invent one. For a no-effect input rejection, correct the input and use its returned retry_command. If a required command is missing or no safe retry is returned, report the exact error and end the Turn so the controller can handle it."
@@ -352,7 +352,7 @@ export function handleCodexHook(context, input) {
352
352
  ?? (bindingOwnerId ? sessionBinding(context, project.id, bindingOwnerId)?.transcript_path ?? locateCodexTranscript(bindingOwnerId, context.env.CODEX_HOME) : null);
353
353
  const persist = (scope) => {
354
354
  if (scope)
355
- matchKey = lifecycleMatchKey(lifecycle, project.root, scope);
355
+ matchKey = lifecycleMatchKey(lifecycleFacts(scope.command), project.root, scope);
356
356
  const binding = bindingOwnerId ? upsertSessionBindingFromPayload(context, project, bindingOwnerId, payload, transcriptPath) : undefined;
357
357
  const effectiveSessionId = storageId;
358
358
  const protocolId = binding?.protocol_id ?? null;
@@ -549,7 +549,7 @@ function recordZcodeLifecycle(context, input, invocationId) {
549
549
  const eventNameForReceipt = invocationId ? "ToolCallObserved" : "PreToolUse";
550
550
  const persist = (scope) => {
551
551
  if (scope)
552
- matchKey = lifecycleMatchKey(lifecycle, expectedRoot, scope);
552
+ matchKey = lifecycleMatchKey(lifecycleFacts(scope.command), expectedRoot, scope);
553
553
  assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "zcode-acp", providerSessionId, parentSessionId, daemonId, sessionId, turnId: null, eventName: eventNameForReceipt, toolName: toolName ?? "Bash", matchKey, cwd: expectedRoot });
554
554
  const payload = { ...hook, session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
555
555
  command, provider: stringValue(observedProfile.provider), model: stringValue(observedProfile.model), reasoning: stringValue(observedProfile.reasoning), mode: stringValue(observedProfile.mode) };
@@ -198,6 +198,10 @@ function lifecycleOperation(argv) {
198
198
  return "work_finish";
199
199
  if (key === "work fail")
200
200
  return "work_fail";
201
+ if (key === "merge apply")
202
+ return "merge_apply";
203
+ if (key === "merge repair")
204
+ return "merge_repair";
201
205
  return null;
202
206
  }
203
207
  export function parseCommandArgs(argv) {
@@ -58,10 +58,50 @@ function invocation(command) {
58
58
  // Input data may be supplied later (stdin/file contents); authority is bound to
59
59
  // the exact operation and literal argv. Existing lifecycle validation owns data.
60
60
  function fingerprint(parsed) {
61
- const options = [...parsed.args.options].filter(([key]) => !["invocation-id", "hook-event-id", "json", "progress-jsonl", "response-file"].includes(key))
61
+ const projected = publicInvocationArgs(parsed);
62
+ const publicParsed = invocation(`${quote([parsed.executable])} ${quoteModelFacingArgs(projected)}`);
63
+ const options = [...publicParsed.args.options].filter(([key]) => !["invocation-id", "hook-event-id", "json", "progress-jsonl"].includes(key))
62
64
  .map(([key, values]) => [key, key === "reason" ? ["<semantic-reason>"] : values.map(value => normalizedInvocationValue(key, value))])
63
65
  .sort(([a], [b]) => a.localeCompare(b));
64
- return crypto.createHash("sha256").update(JSON.stringify([parsed.operation, parsed.args.positional.map(value => normalizedInvocationValue("positional", value)), options])).digest("hex");
66
+ return crypto.createHash("sha256").update(JSON.stringify([parsed.operation, publicParsed.args.positional.map(value => normalizedInvocationValue("positional", value)), options])).digest("hex");
67
+ }
68
+ const runtimeOwnedOptions = {
69
+ stage_start: new Set(["project-root", "context-file", "context-sha256", "response-file", "require-session-binding"]),
70
+ stage_finish: new Set(["project-root", "result-file", "decision-file", "verification-file"]),
71
+ stage_pause: new Set(["project-root"]), stage_resume: new Set(["project-root"]),
72
+ work_start: new Set(["project-root", "recovery-id"]),
73
+ work_finish: new Set(["project-root"]), work_fail: new Set(["project-root"]),
74
+ merge_apply: new Set(["project-root"]), merge_repair: new Set(["project-root"]),
75
+ recovery_accept: new Set(["project-root"])
76
+ };
77
+ function publicInvocationArgs(parsed) {
78
+ const hidden = runtimeOwnedOptions[parsed.operation] ?? new Set();
79
+ const args = [];
80
+ for (let index = 0; index < parsed.argv.length; index += 1) {
81
+ const value = parsed.argv[index];
82
+ if (!value.startsWith("--")) {
83
+ args.push(value);
84
+ continue;
85
+ }
86
+ const key = value.slice(2).split("=", 1)[0];
87
+ const inline = value.includes("=");
88
+ const next = parsed.argv[index + 1];
89
+ if (hidden.has(key) || key === "invocation-id" || key === "hook-event-id") {
90
+ if (!inline && next && !next.startsWith("--"))
91
+ index += 1;
92
+ continue;
93
+ }
94
+ args.push(value);
95
+ if (!inline && next && !next.startsWith("--"))
96
+ args.push(parsed.argv[++index]);
97
+ }
98
+ // A managed coordinator Session is already bound to its RUN. Work IDs stay
99
+ // public because sibling Work assignments can coexist under one native root.
100
+ const operationOffset = parsed.operation === "recovery_accept" ? 3 : 2;
101
+ if (["stage_start", "stage_finish", "recovery_accept"].includes(parsed.operation)
102
+ && !parsed.args.options.has("bootstrap") && args[operationOffset] && !args[operationOffset].startsWith("--"))
103
+ args.splice(operationOffset, 1);
104
+ return args;
65
105
  }
66
106
  function normalizedInvocationValue(key, value) {
67
107
  value = normalizedPathAlias(value);
@@ -92,10 +132,7 @@ function quoteModelFacingArgs(argv) {
92
132
  }
93
133
  function withoutInvocationId(command, aliases) {
94
134
  const parsed = invocation(command);
95
- const argv = [...parsed.argv];
96
- const at = argv.indexOf("--invocation-id");
97
- if (at >= 0)
98
- argv.splice(at, 2);
135
+ const argv = publicInvocationArgs(parsed);
99
136
  for (let index = 0; index < argv.length; index += 1) {
100
137
  const option = argv[index - 1]?.replace(/^--/, "");
101
138
  if (option === "project-root")
@@ -123,11 +160,20 @@ function withoutInvocationId(command, aliases) {
123
160
  return `${prefix ? `${prefix} ` : ""}${executable}${argv.length ? ` ${quoteModelFacingArgs(argv)}` : ""}${suffix ? ` ${suffix}` : ""}`.trim();
124
161
  }
125
162
  function publicInvocationCommand(context, row, command) {
163
+ if (!commandOption(invocation(command), "project-root"))
164
+ command = retainedCommandWithoutInvocationId(row);
126
165
  const rendered = renderInvocationCommand(row, command);
127
166
  const scope = JSON.parse(row.scope_json);
128
167
  const run = scope.runId ? context.db.get("SELECT workspace_root, run_root FROM runs WHERE id = ?", [scope.runId]) : undefined;
129
168
  return withoutInvocationId(rendered, { project: scope.projectRoot, ...(run?.workspace_root ? { workspace: run.workspace_root } : {}), ...(run?.run_root ? { run: run.run_root } : {}) });
130
169
  }
170
+ function retainedCommandWithoutInvocationId(row) {
171
+ const marker = ` --invocation-id ${row.id}`;
172
+ const at = row.command.indexOf(marker);
173
+ if (at < 0 || row.command.indexOf(marker, at + marker.length) >= 0)
174
+ throw new AppError("invocation_command_invalid", "Retained issued command has an ambiguous invocation marker", 1);
175
+ return `${row.command.slice(0, at)}${row.command.slice(at + marker.length)}`;
176
+ }
131
177
  /** Resolve the small, declared alias vocabulary used by model-facing lifecycle commands.
132
178
  * Both native-hook admission and CLI execution call this function so they cannot
133
179
  * disagree about the command that an alias denotes. */
@@ -454,11 +500,7 @@ function successorLifecycleInvocationCommand(context, prior) {
454
500
  // The parser proves the marker belongs to argv. Remove that one rendered
455
501
  // token from the retained text so response-file/stdin/heredoc presentation
456
502
  // remains byte-for-byte stable across a retry.
457
- const marker = ` --invocation-id ${prior.id}`;
458
- const markerAt = prior.command.indexOf(marker);
459
- if (markerAt < 0 || prior.command.indexOf(marker, markerAt + marker.length) >= 0)
460
- throw new AppError("invocation_command_invalid", "Retained issued command has an ambiguous invocation marker", 1);
461
- const command = `${prior.command.slice(0, markerAt)}${prior.command.slice(markerAt + marker.length)}`;
503
+ const command = retainedCommandWithoutInvocationId(prior);
462
504
  return managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: prior.scope_json, DD_FLOW_CURRENT_INVOCATION: prior.id } }, command);
463
505
  }
464
506
  // Caller holds the write transaction and has checked authority. Issued (or
@@ -568,6 +610,11 @@ export function assertLifecycleInvocationCurrent(context, scope, command) {
568
610
  throw new AppError("invocation_scope_mismatch", "Work does not belong unambiguously to the issued RUN scope", 1);
569
611
  }
570
612
  }
613
+ else if (parsed.operation.startsWith("merge_")) {
614
+ const request = context.db.get("SELECT project_id, run_id FROM merge_requests WHERE merge_request_id = ?", [parsed.args.positional[0] ?? ""]);
615
+ if (!request || request.project_id !== project.id || request.run_id !== scope.runId)
616
+ throw new AppError("invocation_scope_mismatch", "MERGE request does not belong to the issued RUN scope", 1);
617
+ }
571
618
  else if (parsed.operation !== "session_register" && !parsed.args.options.has("bootstrap")) {
572
619
  const target = parsed.args.positional[0];
573
620
  const run = context.db.get("SELECT id FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [project.id, target ?? null, target ?? null]);
@@ -661,8 +708,30 @@ export function observeLifecycleInvocation(context, input) {
661
708
  storage(context);
662
709
  const supplied = input.id ? context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [input.id]) : undefined;
663
710
  const analysis = parseLifecycleCommand(input.command);
664
- if (analysis.kind !== "standalone" || analysis.invocation.wrapped)
665
- return { eventKey: input.recordReceipt(), duplicate: false, invocationId: null };
711
+ if (analysis.kind !== "standalone" || analysis.invocation.wrapped) {
712
+ const eventKey = input.recordReceipt();
713
+ if (analysis.kind !== "none") {
714
+ const retained = context.db.get("SELECT outcome_json FROM hook_events WHERE event_key = ?", [eventKey]);
715
+ let outcome = {};
716
+ try {
717
+ outcome = retained?.outcome_json ? JSON.parse(retained.outcome_json) : {};
718
+ }
719
+ catch {
720
+ outcome = {};
721
+ }
722
+ context.db.run("UPDATE hook_events SET outcome_json = ? WHERE event_key = ?", [JSON.stringify({
723
+ ...outcome,
724
+ lifecycle_observation: {
725
+ code: "lifecycle_shell_syntax_invalid",
726
+ reason: analysis.kind === "compound" ? analysis.reason : "wrapped_lifecycle_command",
727
+ command_fingerprint: fingerprint(analysis.invocation),
728
+ effect: "no_effect",
729
+ recoverable: true
730
+ }
731
+ }), eventKey]);
732
+ }
733
+ return { eventKey, duplicate: false, invocationId: null };
734
+ }
666
735
  const candidates = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('issued','observed') ORDER BY rowid DESC")
667
736
  .filter(row => {
668
737
  const scope = JSON.parse(row.scope_json);
@@ -713,7 +782,7 @@ export function observeLifecycleInvocation(context, input) {
713
782
  context.db.exec("COMMIT");
714
783
  return { eventKey: row.event_key, duplicate: true, ...resolved };
715
784
  }
716
- const eventKey = input.recordReceipt(scope);
785
+ const eventKey = input.recordReceipt({ projectRoot: scope.projectRoot, runId: scope.runId, command: initial.command });
717
786
  // A native hook may execute from a provider worktree while the issued
718
787
  // lifecycle scope is anchored to the stable project root. Project
719
788
  // identity is the canonical boundary; comparing raw cwd would reject a
@@ -738,6 +807,29 @@ export function observeLifecycleInvocation(context, input) {
738
807
  throw error;
739
808
  }
740
809
  }
810
+ /** Return a precise native-observer rejection for the same argv. This keeps
811
+ * shell diagnostics distinct from a genuinely absent hook receipt. */
812
+ export function observedLifecycleDiagnostic(context, command, daemonId) {
813
+ const analysis = parseLifecycleCommand(command);
814
+ if (analysis.kind !== "standalone")
815
+ return null;
816
+ const commandFingerprint = fingerprint(analysis.invocation);
817
+ const rows = context.db.all("SELECT event_key, outcome_json FROM hook_events WHERE daemon_id = ? AND status = 'observed' AND outcome_json IS NOT NULL ORDER BY id DESC LIMIT 20", [daemonId]).flatMap(row => {
818
+ try {
819
+ const outcome = JSON.parse(row.outcome_json);
820
+ return outcome.lifecycle_observation?.command_fingerprint === commandFingerprint ? [{ ...outcome.lifecycle_observation, event_key: row.event_key }] : [];
821
+ }
822
+ catch {
823
+ return [];
824
+ }
825
+ });
826
+ if (!rows.length)
827
+ return null;
828
+ const reasons = new Set(rows.map(row => `${String(row.code)}\0${String(row.reason)}`));
829
+ if (reasons.size !== 1)
830
+ return null;
831
+ return { ...rows[0], matching_events: rows.map(row => row.event_key) };
832
+ }
741
833
  /** Resolve only a command that a native hook has already bound to an issued
742
834
  * attempt. This is the CLI-side half of admission for model-facing commands
743
835
  * that intentionally omit the internal UUID. */
@@ -763,6 +855,43 @@ export function observedLifecycleInvocation(context, command) {
763
855
  throw new AppError("invocation_ambiguous", "More than one observed lifecycle attempt matches this command", 1, { effect: "no_effect", matches: matches.map(row => row.id) });
764
856
  return { id: matches[0].id, scope: JSON.parse(matches[0].scope_json) };
765
857
  }
858
+ /** Replace presentation argv with the exact retained machine assignment.
859
+ * Public semantic arguments are already covered by the projected fingerprint;
860
+ * any explicitly repeated private value must agree instead of being ignored. */
861
+ export function resolveObservedLifecycleArgs(context, id, actualArgs) {
862
+ const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [id]);
863
+ if (!row)
864
+ throw new AppError("invocation_unknown", "Lifecycle invocation is not registered", 1, { invocation_id: id });
865
+ const retained = invocation(row.command), actual = invocation(`${quote([retained.executable])} ${quoteModelFacingArgs(actualArgs)}`);
866
+ if (fingerprint(actual) !== row.fingerprint)
867
+ throw new AppError("invocation_command_mismatch", "Lifecycle command does not match its issued assignment", 2, { phase: "prepare", effect: "no_effect", recoverable: true });
868
+ for (const key of runtimeOwnedOptions[retained.operation] ?? []) {
869
+ const supplied = commandOption(actual, key), expected = commandOption(retained, key);
870
+ if (supplied !== undefined && normalizedInvocationValue(key, supplied) !== normalizedInvocationValue(key, expected ?? "")) {
871
+ throw new AppError("invocation_argument_mismatch", `--${key} is owned by the managed assignment and does not match it`, 2, { phase: "prepare", effect: "no_effect", recoverable: true, parameter: key });
872
+ }
873
+ }
874
+ return [...retained.argv];
875
+ }
876
+ export function settleLifecyclePreparationRejection(context, id, error) {
877
+ return context.db.writeTransaction(() => {
878
+ const row = load(context, id);
879
+ if (row.status === "settled") {
880
+ const details = row.outcome_json ? JSON.parse(row.outcome_json)?.error?.details : undefined;
881
+ return details?.effect === "no_effect" && details.retry_command ? details : { effect: "unknown", recoverable: false };
882
+ }
883
+ if (!["issued", "observed"].includes(row.status))
884
+ return { effect: "unknown", recoverable: false };
885
+ const scope = JSON.parse(row.scope_json);
886
+ if (scope.runId)
887
+ assertLifecycleInvocationCurrent(context, scope, row.command);
888
+ const details = { ...error.details, effect: "no_effect", recoverable: true,
889
+ retry_command: successorLifecycleInvocationCommand(context, row),
890
+ retry_instruction: "Execute retry_command verbatim in this same Session. The rejected call changed no lifecycle state." };
891
+ context.db.run("UPDATE lifecycle_invocations SET status = 'settled', outcome_json = ?, updated_at = ? WHERE id = ?", [JSON.stringify({ error: { ...errorRecord(error), details } }), context.now(), row.id]);
892
+ return details;
893
+ });
894
+ }
766
895
  /** One caller owns execution; a lost process leaves an explicit unknown
767
896
  * outcome, never an automatic replay. Existing recovery decides the next step. */
768
897
  export async function awaitLifecycleInvocation(context, input) {
@@ -782,27 +911,7 @@ export async function awaitLifecycleInvocation(context, input) {
782
911
  catch (error) {
783
912
  if (!(error instanceof AppError) || !(["usage", "invocation_command_mismatch", "invocation_command_invalid"].includes(error.code) || error.details.phase === "prepare"))
784
913
  throw error;
785
- const rejectionDetails = context.db.writeTransaction(() => {
786
- const row = load(context, input.id);
787
- if (row.status === "settled") {
788
- const details = row.outcome_json ? JSON.parse(row.outcome_json)?.error?.details : undefined;
789
- return details?.effect === "no_effect" && details.retry_command ? details : { effect: "unknown", recoverable: false };
790
- }
791
- if (!["issued", "observed"].includes(row.status)) {
792
- return { effect: "unknown", recoverable: false };
793
- }
794
- const scope = JSON.parse(row.scope_json);
795
- if (scope.runId)
796
- assertLifecycleInvocationCurrent(context, scope, row.command);
797
- // No executor has claimed this attempt. Atomically retain the rejected
798
- // call and issue one corrected command; old IDs only replay the outcome.
799
- const details = { ...error.details, effect: "no_effect", recoverable: true,
800
- retry_command: successorLifecycleInvocationCommand(context, row),
801
- retry_instruction: "Execute retry_command verbatim in this same Session. The rejected call changed no lifecycle state." };
802
- context.db.run("UPDATE lifecycle_invocations SET status = 'settled', outcome_json = ?, updated_at = ? WHERE id = ?", [JSON.stringify({ error: { ...errorRecord(error), details } }), context.now(), row.id]);
803
- return details;
804
- });
805
- Object.assign(error.details, rejectionDetails);
914
+ Object.assign(error.details, settleLifecyclePreparationRejection(context, input.id, error));
806
915
  throw error;
807
916
  }
808
917
  const timeoutMs = input.timeoutMs ?? 30000;
@@ -4,6 +4,7 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { setTimeout as delay } from "node:timers/promises";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { quote } from "shell-quote";
7
8
  import { ensureRunControllerStorage, getDatabase } from "../storage/database.js";
8
9
  import { canonicalPath, resolveProjectRoot } from "../storage/paths.js";
9
10
  import { AppError } from "../shared/errors.js";
@@ -608,7 +609,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
608
609
  assertOwner();
609
610
  // The accepted answer is logical intent; only its generation-bound
610
611
  // adapter operation represents a physical dispatch that stop must drain.
611
- const command = managedLifecycleCommand(executionContext, `${flowCommand(executionContext)} stage resume ${run.id} --stage ${next.stage} --work ${input.work_id} --project-root ${JSON.stringify(run.project_root)} --answer-stdin --json < ${JSON.stringify(input.file)}`);
612
+ const command = managedLifecycleCommand(executionContext, `${flowCommand(executionContext)} stage resume ${run.id} --stage ${next.stage} --work ${input.work_id} --project-root ${quote([run.project_root])} --answer-file ${quote([input.file])} --json`);
612
613
  const receipt = await prompt(state.sessions[state.current_session], `The user supplied the raw answer for pause ${pause.id}. Your first tool call must be this exact standalone command:\n\n${command}\n\nDo not alter the answer. Follow the returned packet, then stop at this Stage boundary.`, `${answer.operation_id}:prompt:${row.generation}`);
613
614
  context.db.run("UPDATE run_controller_operations SET status = 'completed', receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify({ delivered: true, session_id: adapterSessionId(receipt) }), context.now(), answer.operation_id]);
614
615
  state.last_continuation = null;
@@ -693,7 +694,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
693
694
  const responseFile = path.join(row.state_dir, `${next.stage}-${next.attempt ?? 1}.stage-start-response.json`);
694
695
  const command = managedLifecycleCommand(executionContext, `${flowCommand(executionContext)} stage start ${run.id} --stage ${next.stage} --project-root ${JSON.stringify(run.project_root)} --context-file ${JSON.stringify(stageContext.file)} --context-sha256 ${stageContext.sha256} --require-session-binding --response-file ${JSON.stringify(responseFile)} --json --progress-jsonl`);
695
696
  appendEvent(context, row.controller_id, "stage_entered", { stage: next.stage, session_id: session.id, context_sha256: stageContext.sha256 });
696
- await prompt(session, controllerStageEntryPrompt(next.stage, command, responseFile));
697
+ await prompt(session, controllerStageEntryPrompt(next.stage, command));
697
698
  continue;
698
699
  }
699
700
  if (state.current_session === null)
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { quote } from "shell-quote";
5
6
  import { lifecycleRetryCommands, managedLifecycleCommand } from "./lifecycle-invocations.js";
6
7
  import { AppError } from "../shared/errors.js";
7
8
  import { entityReferenceVariants, shortEntityReference } from "../shared/entity-references.js";
@@ -49,8 +50,9 @@ export function pauseStageForUser(context, input) {
49
50
  throw error;
50
51
  }
51
52
  refreshRunWorkProjection(context, project.id, run.id);
52
- const resume = stageResumeCommand(context, { runId: run.id, stage: stage.stage, workId: work.work_id, projectRoot });
53
- const resumeTemplate = `${resume} <<'USER_ANSWER'\n<paste the complete user answer exactly>\nUSER_ANSWER`;
53
+ const answerInputPath = path.join(pauseRoot, "answer-input.md");
54
+ const resume = stageResumeCommand(context, { runId: run.id, stage: stage.stage, workId: work.work_id, projectRoot, answerInputPath });
55
+ const resumeTemplate = `Write the complete user answer exactly to ${quote([answerInputPath])}, then run:\n${resume}`;
54
56
  return {
55
57
  ok: true,
56
58
  outcome: "paused",
@@ -59,7 +61,7 @@ export function pauseStageForUser(context, input) {
59
61
  stage: stage.stage,
60
62
  pause: { id: pauseId, reason: "waiting_for_user", question_path: questionPath, user_message: question },
61
63
  next_action: "ask_user_then_resume_same_stage",
62
- agent_instruction: "Send user_message to the user and stop this Turn. When the user answers, do not interpret or edit the answer first: make resume_command the first flow command, pass the complete raw answer on stdin, then follow the returned continuation prompt. Do not create a RUN, Work, attempt, or stage start.",
64
+ agent_instruction: `Send user_message to the user and stop this Turn. When the user answers, write the complete raw answer without interpretation to ${answerInputPath}, then make resume_command the first flow command and follow the returned continuation prompt. Do not create a RUN, Work, attempt, or stage start.`,
63
65
  resume_command: resume,
64
66
  resume_command_template: resumeTemplate
65
67
  };
@@ -157,15 +159,13 @@ export function resumeStageAfterUser(context, input) {
157
159
  return { ok: true, outcome: "resumed", run_id: run.id, work_id: work.work_id, stage: stage.stage, pause_id: pause.id, question_path: pause.question_path, answer_path: answerPath, prompt_path: workSession.prompt_path, worker_prompt_markdown: continuation, next_action: `continue_${stage.stage}` };
158
160
  }
159
161
  export function stagePauseCommand(context, input) {
160
- return managedLifecycleCommand(context, `${flowCommand(context)} stage pause ${shortEntityReference(input.runId)} --stage ${input.stage} --work ${shortEntityReference(input.workId)} --question-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`);
162
+ const questionInputPath = `@run/works/${input.workId}/question-input.md`;
163
+ return managedLifecycleCommand(context, `${flowCommand(context)} stage pause ${shortEntityReference(input.runId)} --stage ${input.stage} --work ${shortEntityReference(input.workId)} --question-file '${questionInputPath}' --project-root ${quote([input.projectRoot])} --json`);
161
164
  }
162
- /**
163
- * The only shell form allowed for an agent-owned HITL pause. Supplying a
164
- * complete heredoc makes stdin explicit without asking an agent to invent a
165
- * pipe, a temporary file or a second command.
166
- */
165
+ /** Render separate file-write and lifecycle-call instructions for HITL. */
167
166
  export function stagePauseCommandTemplate(command) {
168
- return `${command} <<'USER_QUESTION'
167
+ const questionFile = command.match(/--question-file\s+(?:'([^']*)'|"([^"]*)"|(\S+))/)?.slice(1).find(Boolean) ?? "<question-file-from-command>";
168
+ return `Write this text to ${questionFile}:
169
169
  ## Q-001 — <short decision title>
170
170
 
171
171
  Why this decision is required:
@@ -180,10 +180,12 @@ Recommendation:
180
180
 
181
181
  Effect on scope or acceptance:
182
182
  - <one sentence>
183
- USER_QUESTION`;
183
+
184
+ Then run this standalone command:
185
+ ${command}`;
184
186
  }
185
187
  function stageResumeCommand(context, input) {
186
- return managedLifecycleCommand(context, `${flowCommand(context)} stage resume ${shortEntityReference(input.runId)} --stage ${input.stage} --work ${shortEntityReference(input.workId)} --answer-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`);
188
+ return managedLifecycleCommand(context, `${flowCommand(context)} stage resume ${shortEntityReference(input.runId)} --stage ${input.stage} --work ${shortEntityReference(input.workId)} --answer-file ${quote([input.answerInputPath])} --project-root ${quote([input.projectRoot])} --json`);
187
189
  }
188
190
  export function flowCommand(context) {
189
191
  const defaultHome = path.resolve(path.join(os.homedir(), ".dd-flow"));
@@ -225,7 +225,8 @@ export async function finishVnextCodeReview(context, input) {
225
225
  outcome: "repair_required",
226
226
  retry_after_workspace_change: true,
227
227
  workspace_fingerprint: failed[0].workspace_fingerprint,
228
- repair_command: `${flowCommand(context)} work repair add --run ${run.id} --from-check ${failed[0].id} --origin-work <WORK-ID> --task-stdin --project-root ${JSON.stringify(projectRoot)} --json`,
228
+ repair_task_file: `@run/intake/code-review-repair-${failed[0].id}.md`,
229
+ repair_command: `${flowCommand(context)} work repair add --run ${run.id} --from-check ${failed[0].id} --origin-work <WORK-ID> --task-file @run/intake/code-review-repair-${failed[0].id}.md --project-root ${JSON.stringify(projectRoot)} --json`,
229
230
  retry_command: finishCommand(context, run.id, projectRoot, decisionFile, failed[0].id)
230
231
  });
231
232
  const stopTarget = executionStopTarget(run);
@@ -211,7 +211,8 @@ export async function finishVnextCode(context, input) {
211
211
  outcome: "repair_required",
212
212
  retry_after_workspace_change: true,
213
213
  workspace_fingerprint: failed[0].workspace_fingerprint,
214
- repair_command: `${flowCommand(context)} work repair add --run ${run.id} --from-check ${failed[0].id} --origin-work <WORK-ID> --task-stdin --project-root ${JSON.stringify(projectRoot)} --json`,
214
+ repair_task_file: `@run/intake/code-repair-${failed[0].id}.md`,
215
+ repair_command: `${flowCommand(context)} work repair add --run ${run.id} --from-check ${failed[0].id} --origin-work <WORK-ID> --task-file @run/intake/code-repair-${failed[0].id}.md --project-root ${JSON.stringify(projectRoot)} --json`,
215
216
  retry_command: finishCommand(context, run.id, projectRoot, input.verificationFile, failed[0].id)
216
217
  });
217
218
  }
@@ -500,9 +501,9 @@ function coordinatorPrompt(context, input) {
500
501
  "<execution_commands>",
501
502
  "Do not choose a provider delegation tool from this stage prompt. At each work_fanout boundary, stop at the Work-graph boundary; the shared controller supplies the selected adapter's native tool, exact parameters, child task, and wait contract. It launches only entries listed in graph.ready and uses at most the qualified capacity. Every registered CODE Work runs in a fresh child Session, including a serial dependency chain. The coordinator launches those children only through the controller-supplied native delegation instruction; it must never invoke a Work start_command itself. Each child receives its complete packet from dd-flow and runs its own exact start_command. After a Work finishes, the controller uses the graph returned by work finish to launch newly ready Work. To refresh the parent graph yourself use the exact command: " + `${flowCommand(context)} work ls --run ${input.run.id} --ready --project-root ${JSON.stringify(input.projectRoot)} --json`,
502
503
  "A quiet child is still running until the harness reports its turn completed, failed, cancelled or explicitly needs attention. An elapsed nominal wait, silence, or no new artifact is not an unresponsive-worker failure. Never interrupt, replace, relaunch, or stage-block a still-running child for that reason, even if an external controller asks. Long work finish and stage finish commands emit check progress on stderr. After you issue the exact CODE stage finish command, wait for that same command to return once: a completed command with a non-zero exit and structured `code_gate_failed` output is its terminal result, not a reason to keep waiting. Its repair_command only creates the repair Work; use it with the required origin Work IDs, then end the Turn. Do not invoke that new Work's start_command from the coordinator Session, inspect its PID, start a second finish command, or infer failure from quiet output. Close a disposable child only after its Work is accepted or explicitly failed/cancelled and the harness reports the turn settled.",
503
- `A repairable engine, harness, or environment failure is not a user question. Record it without finishing CODE: ${flowCommand(context)} stage block ${input.run.id} --stage code --work ${input.rootWork.work_id} --kind <engine|harness|environment> --code <stable-code> --summary-stdin --retryable --project-root ${JSON.stringify(input.projectRoot)} --json. Repair it externally, then run the exact unblock_command returned by dd-flow and continue this same stage.`,
504
+ `A repairable engine, harness, or environment failure is not a user question. Write the evidence summary to @run/works/${input.rootWork.work_id}/block-summary.md, then record it with a separate command: ${flowCommand(context)} stage block ${input.run.id} --stage code --work ${input.rootWork.work_id} --kind <engine|harness|environment> --code <stable-code> --summary-file @run/works/${input.rootWork.work_id}/block-summary.md --retryable --project-root ${JSON.stringify(input.projectRoot)} --json. Repair it externally, then run the exact unblock_command returned by dd-flow and continue this same stage.`,
504
505
  `When every CODE and repair Work is completed, write ${path.join(input.root, "code-verification.json")} using the exact contract below. Mark passed only when all accepted requirements and current-gate acceptance criteria are implemented or explicitly evidenced; list every remaining issue in unresolved. Every evidence_refs item must already exist as a relative workspace path or run://${input.run.id}/ path. Do not claim a browser or other check receipt that was not retained. Then finish: ${finishCommand(context, input.run.id, input.projectRoot, path.join(input.root, "code-verification.json"))}`,
505
- "If the aggregate gate fails, do not stop after `code_gate_failed`: that rejected finish does not create a repair Work. In the same coordinator Turn, use its returned repair command with the relevant completed origin Work IDs and a concise repair objective, then stop so the runner can dispatch the newly declared repair. Do not edit invisibly in the root orchestrator.",
506
+ "If the aggregate gate fails, do not stop after `code_gate_failed`: that rejected finish does not create a repair Work. In the same coordinator Turn, write the concise repair objective to repair_task_file, then use the returned standalone repair_command with the relevant completed origin Work IDs and stop so the runner can dispatch the newly declared repair. Do not edit invisibly in the root orchestrator.",
506
507
  "</execution_commands>",
507
508
  "",
508
509
  "<verification_contract>",
@@ -11,7 +11,7 @@ import { requireProjectByRoot } from "./projects.js";
11
11
  import { nextMergeRequestId } from "./ids.js";
12
12
  import { readProjectConfig } from "./config.js";
13
13
  import { appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRun, completeFlowRunStage, gitFacts, prepareFlowRunStageAttachment, prepareVnextMergeSourceRepairAttempt } from "./runs.js";
14
- import { flowCommand, stagePauseCommandTemplate } from "./stage-pause.js";
14
+ import { flowCommand, stagePauseCommand, stagePauseCommandTemplate } from "./stage-pause.js";
15
15
  import { writeStageReport } from "./stage-report-renderer.js";
16
16
  import { bindStageCoordinatorWork, createChildWork, failWork, finishFanInWork, finishWork, refreshRunWorkProjection, startStageCoordinatorWork } from "./work-registry.js";
17
17
  import { addVnextCodeRepair } from "./vnext-code.js";
@@ -290,7 +290,7 @@ export async function finishVnextMerge(context, input, prepared = prepareVnextMe
290
290
  const failed = receipts.filter((receipt) => receipt.status !== "passed");
291
291
  if (failed.length) {
292
292
  context.db.run("UPDATE merge_requests SET status = 'action_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "merge_gate_failed", failures: failed }), context.now(), request.merge_request_id]);
293
- throw new AppError("merge_gate_failed", "Integrated target checks failed. Classify retained evidence: source defects use source repair; restored environment uses retry.", 2, { failures: failed, repair_command: `${flowCommand(context)} merge repair ${request.merge_request_id} --project-root ${JSON.stringify(projectRoot)} --json`, retry_command: finishCommand(context, run.id, request, projectRoot, failed[0].id) });
293
+ throw new AppError("merge_gate_failed", "Integrated target checks failed. Classify retained evidence: source defects use source repair; restored environment uses retry.", 2, { failures: failed, repair_command: repairCommand(context, request, projectRoot), retry_command: finishCommand(context, run.id, request, projectRoot, failed[0].id) });
294
294
  }
295
295
  const passedRefs = new Set(receipts.flatMap((receipt) => receipt.check_refs));
296
296
  const missingRefs = frozenGateRefs(gate).filter((ref) => !passedRefs.has(ref));
@@ -429,7 +429,7 @@ export async function repairVnextMerge(context, input) {
429
429
  appendFlowRunTimelineEvent(context, project.id, request.run_id, { type: "merge_source_repair_created", merge_request_id: request.merge_request_id, repair_work_id: repair.repair_work_id, cycle: prepared.cycle, failed_receipt_ids: failed });
430
430
  return { ok: true, run_id: request.run_id, merge_request_id: request.merge_request_id, status: "superseded", source_repair: repair, next: { kind: "start_stage", stage: "code", command: managedLifecycleCommand(context, `${flowCommand(context)} stage start ${request.run_id} --stage code --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`) } };
431
431
  }
432
- function mergePrompt(context, input, checks, settings) { const pause = managedLifecycleCommand(context, `${flowCommand(context)} stage pause ${input.run.id} --stage merge --work ${input.request.executor_work_id} --project-root ${JSON.stringify(input.projectRoot)} --question-stdin --json`); const repair = `${flowCommand(context)} merge repair ${input.request.merge_request_id} --project-root ${JSON.stringify(input.projectRoot)} --json`; return ["<stage_identity>", `- RUN: ${input.run.id}`, `- MERGE request: ${input.request.merge_request_id}`, `- Work: ${input.request.executor_work_id}`, "- stage: merge", "</stage_identity>", "", "<trusted_runtime_context>", `- integration workspace: ${input.request.target_workspace}`, `- source workspace: ${input.request.source_workspace}`, `- frozen source commit: ${input.request.source_commit}`, `- target branch: ${input.request.target_branch}`, `- execution target baseline: ${input.request.execution_target_head}`, `- queue route: ${input.request.execution_route}`, `- delivery: ${JSON.stringify(settings.merge_delivery)}`, `- cleanup: ${JSON.stringify(settings.merge_cleanup)}`, "These facts and the acquired project integration lane were established by dd-flow. Do not repeat discovery and do not run git merge/rebase/squash yourself.", "</trusted_runtime_context>", "", "<effective_merge_gate>", ...checks.map((check) => `- ${check.canonical_ref ?? check.id}: ${check.command} — ${check.purpose}`), "</effective_merge_gate>", "", "<execution_contract>", `1. Run this exact standalone command first: ${applyCommand(context, input.request, input.projectRoot)}`, "2. If it reports conflicts, resolve only the actual unmerged paths in the integration workspace. Do not repeat merge apply and do not edit product code, tests, documentation or configuration merely to make a gate pass.", `3. Write the compact semantic result to ${input.resultPath}:`, "```json", JSON.stringify({ schema_id: "dd-flow/merge-result@1", outcome: "completed", summary: "What was integrated.", conflict_resolution: "How material conflicts were resolved, or empty when none.", verification_summary: "Why the integrated result is ready for deterministic checks.", residual_risks: [] }, null, 2), "```", `4. Finish with this exact standalone command and wait for all progress: ${finishCommand(context, input.run.id, input.request, input.projectRoot)}`, `If a gate fails, read its receipt and logs and determine the cause. For a product defect use ${repair}; it restores the target baseline and opens CODE → independent CODE-REVIEW → replacement MRG. For an environment failure restore the declared environment in this MERGE and use the returned finish command with --retry-check <receipt-id> --reason "<what was restored>". The CLI reruns the real gate and retains the old receipt. Never edit product code in the integration target to make a gate pass.`, "If a material conflict has no reasonable answer in accepted evidence, pause this same Work with the exact heredoc below, ask the returned user_message, then use the exact resume command returned by CLI:", "```sh", stagePauseCommandTemplate(pause), "```", "</execution_contract>", ""].join("\n"); }
432
+ function mergePrompt(context, input, checks, settings) { const pause = stagePauseCommand(context, { runId: input.run.id, stage: "merge", workId: input.request.executor_work_id, projectRoot: input.projectRoot }); const repair = repairCommand(context, input.request, input.projectRoot); return ["<stage_identity>", `- RUN: ${input.run.id}`, `- MERGE request: ${input.request.merge_request_id}`, `- Work: ${input.request.executor_work_id}`, "- stage: merge", "</stage_identity>", "", "<trusted_runtime_context>", `- integration workspace: ${input.request.target_workspace}`, `- source workspace: ${input.request.source_workspace}`, `- frozen source commit: ${input.request.source_commit}`, `- target branch: ${input.request.target_branch}`, `- execution target baseline: ${input.request.execution_target_head}`, `- queue route: ${input.request.execution_route}`, `- delivery: ${JSON.stringify(settings.merge_delivery)}`, `- cleanup: ${JSON.stringify(settings.merge_cleanup)}`, "These facts and the acquired project integration lane were established by dd-flow. Do not repeat discovery and do not run git merge/rebase/squash yourself.", "</trusted_runtime_context>", "", "<effective_merge_gate>", ...checks.map((check) => `- ${check.canonical_ref ?? check.id}: ${check.command} — ${check.purpose}`), "</effective_merge_gate>", "", "<execution_contract>", `1. Run this exact standalone command first: ${applyCommand(context, input.request, input.projectRoot)}`, "2. If it reports conflicts, resolve only the actual unmerged paths in the integration workspace. Do not repeat merge apply and do not edit product code, tests, documentation or configuration merely to make a gate pass.", `3. Write the compact semantic result to ${input.resultPath}:`, "```json", JSON.stringify({ schema_id: "dd-flow/merge-result@1", outcome: "completed", summary: "What was integrated.", conflict_resolution: "How material conflicts were resolved, or empty when none.", verification_summary: "Why the integrated result is ready for deterministic checks.", residual_risks: [] }, null, 2), "```", `4. Finish with this exact standalone command and wait for all progress: ${finishCommand(context, input.run.id, input.request, input.projectRoot)}`, `If a gate fails, read its receipt and logs and determine the cause. For a product defect use ${repair}; it restores the target baseline and opens CODE → independent CODE-REVIEW → replacement MRG. For an environment failure restore the declared environment in this MERGE and use the returned finish command with --retry-check <receipt-id> --reason "<what was restored>". The CLI reruns the real gate and retains the old receipt. Never edit product code in the integration target to make a gate pass.`, "If a material conflict has no reasonable answer in accepted evidence, pause this same Work by writing the question file and then running the separate standalone command below, ask the returned user_message, then use the exact resume command returned by CLI:", "```sh", stagePauseCommandTemplate(pause), "```", "</execution_contract>", ""].join("\n"); }
433
433
  function mergeReport(context, run, request, semantic, receipts) { const now = context.now(); const cleanup = cleanupReceiptPath(run); return { schema_id: "dd-flow/stage-report@2", run_id: run.id, stage, generated_at: now, verdict: "done", summary: semantic.summary, semantic: { result: semantic.summary, acceptance: ["source_commit_frozen", "integration_commit_created", "merge_gate_passed", "delivery_confirmed"], changed_files: [], checks: receipts.map((item) => item.command), evidence: [applyReceiptPath(context, request), ...receipts.map((item) => item.receipt_path), ...(fs.existsSync(cleanup) ? [cleanup] : [])], next_action: "merge_completed", merge: { merge_request_id: request.merge_request_id, work_id: request.executor_work_id, protocols: JSON.parse(request.protocol_ids_json), source_commit: request.source_commit, execution_target_head: request.execution_target_head, accepted_tree: request.accepted_tree, integration_commit: request.integration_commit, route: request.execution_route, delivery: executionSettings(run).merge_delivery, cleanup: executionSettings(run).merge_cleanup, verification_summary: semantic.verification_summary, residual_risks: semantic.residual_risks } }, mechanical: { started_at: request.lock_acquired_at, finished_at: now, git: gitFacts(request.target_workspace), queue: queueStatus(context, request) }, artifacts: { json: "stage-report.json", markdown: "stage-report.md", html: "stage-report.html" }, validation: { status: "passed" } }; }
434
434
  function effectiveMergeChecks(run, request) { return readFrozenMergeGate(path.join(requireHome(run), stageDir, "merge-gate.json"), request.merge_request_id).checks; }
435
435
  function planChecks(workspace, protocols) { return protocols.flatMap((protocol) => { const file = path.join(workspace, ".memory-bank", "protocol", protocol, "plan.json"); if (!fs.existsSync(file))
@@ -588,5 +588,6 @@ function findRootWork(context, projectId, runId) { const work = context.db.get("
588
588
  throw new AppError("runtime_missing", "vNext RUN has no root Work", 1); return work; }
589
589
  function requireRootWork(context, projectId, runId) { const work = findRootWork(context, projectId, runId); if (work.status !== "running")
590
590
  throw new AppError("runtime_missing", "vNext RUN has no running root Work", 1); return work; }
591
- function applyCommand(context, request, projectRoot) { return `${flowCommand(context)} merge apply ${request.merge_request_id} --work ${request.executor_work_id} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`; }
591
+ function applyCommand(context, request, projectRoot) { return managedLifecycleCommand(context, `${flowCommand(context)} merge apply ${request.merge_request_id} --work ${request.executor_work_id} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`); }
592
+ function repairCommand(context, request, projectRoot) { return managedLifecycleCommand(context, `${flowCommand(context)} merge repair ${request.merge_request_id} --project-root ${JSON.stringify(projectRoot)} --json`); }
592
593
  export function finishCommand(context, runId, request, projectRoot, retryCheckId) { const retry = retryCheckId ? ` --retry-check ${retryCheckId} --reason "<environment recovery evidence>"` : ""; return managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${runId} --stage merge --request ${request.merge_request_id} --work ${request.executor_work_id} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl${retry}`); }
@@ -316,7 +316,7 @@ function orchestratorPrompt(context, input) {
316
316
  const reviewerLaunch = capacity.source === "external_policy"
317
317
  ? `After dispatch, return at the Work-graph boundary. The shared runtime launches the queued reviewer Works as separate external Sessions, at most ${capacity.available_slots} in parallel under the RUN's frozen profiles. Do not launch native children, create provider roots, run a capacity probe, or record this external limit as native capacity. Reviewers are read-only leaf workers. Continue the semantic decision only after their Work receipts settle; do not substitute a missing result or relaunch a settled reviewer.`
318
318
  : "After dispatch, stop at the Work-graph boundary. The shared controller supplies the selected adapter's native tool, exact parameters, child task, and wait contract; do not choose a provider tool from this stage prompt or substitute a shell command. It launches at most the qualified capacity at once and starts unchanged queued Works only after the current wave settles. A launch rejected before it starts is not review evidence: do not create a replacement. Each reviewer must be a genuinely fresh harness child Session; the lifecycle adapter binds that observed Session, so do not bind or supply a Session ID manually. Reviewers are read-only and must not create children. As soon as a reviewer result is accepted, release that reviewer Session when the harness permits.";
319
- return ["<stage_identity>", `- RUN: ${input.run.id}`, `- Work: ${input.workId}`, "- Stage: plan-review", `- Mode: ${input.effective}`, "</stage_identity>", "", "<trusted_runtime_context>", "These facts were collected by dd-flow. Trust them; do not repeat CLI, Git, compatibility, permission or schema discovery.", `- Project root: ${input.projectRoot}`, `- Stage workspace: ${input.root}`, `- PLAN revision: ${revision}`, `- PLAN report checksum: ${input.planChecksum}`, `- Generated CODE batch checksum: ${input.batchChecksum}`, "</trusted_runtime_context>", "", workspaceContract, "", "<review_groups>", ...input.groups.map((group) => `- ${group.key}: ${group.aspect_ids.join(", ")}`), "</review_groups>", "", "<execution_commands>", `Dispatch fresh reviewers: ${dispatchCommand(context, input.run.id, input.projectRoot)}`, `If dispatch reports qualified_capacity_required, stop. The external harness controller qualifies the selected profile outside this RUN and records the resulting integer with ${capacityRecordCommand(context, input.run.id, input.projectRoot, "<qualified-native-child-count>")}. PLAN-REVIEW never launches a capacity probe.`, reviewerLaunch, "Review the execution environment of every selected check as part of its proof: a reset/fixture process, service process and client process must share the intended data and configuration world. A runtime entrypoint that can break that invariant must be explicit in one Work's task and verification and ordered before its consumer. planned_write_areas may advertise likely overlap, but do not treat them as ownership; required_read alone is not a delivery plan.", "If the final decision needs user input with no reasonable default, run this exact one-command heredoc, replacing only its placeholder body. The heredoc is the permitted stdin form; do not use cat, a pipe, a temporary file or a second shell command:", "```sh", input.pauseCommandTemplate, "```", "Ask the returned user_message, stop, then resume this same PLAN-REVIEW Work. Do not write decision.json or finish first.", `When all reviewer results are complete and every user question is resolved, classify every material finding, fix accepted findings in this same PLAN-REVIEW Work, then write ${decision} and finish: ${finishCommand(context, input.run.id, input.projectRoot, decision)}`, "Reviewer findings use local FIND-NNN ids. dd-flow exposes each finding to this coordinator as WRK-.../FIND-NNN; use that canonical finding_ref in the decision.", "A completed reviewer result with needs_changes or blocked is evidence, not the stage outcome. Classify its material findings and apply accepted fixes in this one review pass; do not start a second review automatically. Only a missing, malformed or unfinished reviewer result blocks the stage. For an accepted correction, increment PLAN revision and update only plan.json and the relevant aspect map. Do not edit or list code-work-batch.json: the CLI validates final PLAN and regenerates it. If no material correction is needed, set correction.status=not_required. The CLI checks mechanical handoff coherence; it does not prove semantic correctness.", "```json", JSON.stringify({ schema_id: "dd-flow/plan-review-decision@3", outcome: "accepted | failed | cancelled", summary: "Concise evidence-backed final decision.", finding_decisions: [{ finding_ref: "WRK-001-review/FIND-001", decision: "accepted_fix | rejected | deferred_as_DEF | requires_user | duplicate", reason: "Why." }], correction: { status: "not_required | applied", previous_plan_revision: revision, changed_paths: [], summary: "No material correction was needed, or summarize the applied correction." } }, null, 2), "```", "</execution_commands>", "", "<stage_instructions>", template, "</stage_instructions>", ""].join("\n");
319
+ return ["<stage_identity>", `- RUN: ${input.run.id}`, `- Work: ${input.workId}`, "- Stage: plan-review", `- Mode: ${input.effective}`, "</stage_identity>", "", "<trusted_runtime_context>", "These facts were collected by dd-flow. Trust them; do not repeat CLI, Git, compatibility, permission or schema discovery.", `- Project root: ${input.projectRoot}`, `- Stage workspace: ${input.root}`, `- PLAN revision: ${revision}`, `- PLAN report checksum: ${input.planChecksum}`, `- Generated CODE batch checksum: ${input.batchChecksum}`, "</trusted_runtime_context>", "", workspaceContract, "", "<review_groups>", ...input.groups.map((group) => `- ${group.key}: ${group.aspect_ids.join(", ")}`), "</review_groups>", "", "<execution_commands>", `Dispatch fresh reviewers: ${dispatchCommand(context, input.run.id, input.projectRoot)}`, `If dispatch reports qualified_capacity_required, stop. The external harness controller qualifies the selected profile outside this RUN and records the resulting integer with ${capacityRecordCommand(context, input.run.id, input.projectRoot, "<qualified-native-child-count>")}. PLAN-REVIEW never launches a capacity probe.`, reviewerLaunch, "Review the execution environment of every selected check as part of its proof: a reset/fixture process, service process and client process must share the intended data and configuration world. A runtime entrypoint that can break that invariant must be explicit in one Work's task and verification and ordered before its consumer. planned_write_areas may advertise likely overlap, but do not treat them as ownership; required_read alone is not a delivery plan.", "If the final decision needs user input with no reasonable default, write the question packet to the named text file, then run the separate standalone lifecycle command below. Do not combine file creation and dd-flow in one shell command:", "```sh", input.pauseCommandTemplate, "```", "Ask the returned user_message, stop, then resume this same PLAN-REVIEW Work. Do not write decision.json or finish first.", `When all reviewer results are complete and every user question is resolved, classify every material finding, fix accepted findings in this same PLAN-REVIEW Work, then write ${decision} and finish: ${finishCommand(context, input.run.id, input.projectRoot, decision)}`, "Reviewer findings use local FIND-NNN ids. dd-flow exposes each finding to this coordinator as WRK-.../FIND-NNN; use that canonical finding_ref in the decision.", "A completed reviewer result with needs_changes or blocked is evidence, not the stage outcome. Classify its material findings and apply accepted fixes in this one review pass; do not start a second review automatically. Only a missing, malformed or unfinished reviewer result blocks the stage. For an accepted correction, increment PLAN revision and update only plan.json and the relevant aspect map. Do not edit or list code-work-batch.json: the CLI validates final PLAN and regenerates it. If no material correction is needed, set correction.status=not_required. The CLI checks mechanical handoff coherence; it does not prove semantic correctness.", "```json", JSON.stringify({ schema_id: "dd-flow/plan-review-decision@3", outcome: "accepted | failed | cancelled", summary: "Concise evidence-backed final decision.", finding_decisions: [{ finding_ref: "WRK-001-review/FIND-001", decision: "accepted_fix | rejected | deferred_as_DEF | requires_user | duplicate", reason: "Why." }], correction: { status: "not_required | applied", previous_plan_revision: revision, changed_paths: [], summary: "No material correction was needed, or summarize the applied correction." } }, null, 2), "```", "</execution_commands>", "", "<stage_instructions>", template, "</stage_instructions>", ""].join("\n");
320
320
  }
321
321
  function reviewGroups(home, workspaceRoot) {
322
322
  const root = path.join(home, "03-plan");
@@ -106,7 +106,7 @@ export function startVnextPlan(context, input, prepared = prepareVnextPlanStart(
106
106
  ? [`This RUN must reach MERGE. Project policy already supplies the mandatory merge gate${policyMergeAliases.length === 1 ? "" : "s"}: ${policyMergeAliases.join(", ")}. Do not duplicate them in semantic checks[]. Add another merge check only when the task genuinely needs additional evidence.`]
107
107
  : ["This RUN must reach MERGE and project policy supplies no merge gate. Select at least one real top-level checks[] entry with run_at: merge. It may use an existing project alias or a planned alias materialised by a named P* provider Work. This is a planning obligation: do not defer it to CODE-REVIEW or MERGE."]), "The CLI validates the effective merge gate but never invents one or migrates an incompatible project policy.", "</merge_gate_contract>", ""]
108
108
  : [];
109
- const prompt = ["<stage_identity>", `- RUN: ${run.id}`, `- Work: ${planWorkId}`, "- stage: plan", "</stage_identity>", "", "<trusted_runtime_context>", "These facts were collected by dd-flow. Trust them; do not repeat CLI, Git, compatibility or permission discovery.", `- Project root: ${projectRoot}`, `- Workspace: ${run.workspace_root}`, `- Stage workspace: ${root}`, `- Git: ${JSON.stringify(git)}`, capacityContext, "</trusted_runtime_context>", "", "<workspace_contract>", `- route: ${workspaceRoute.route}`, `- feature branch: ${workspaceRoute.feature_branch ?? "not applicable"}`, `- base commit: ${workspaceRoute.base_ref ?? "not applicable"}`, `- write workspace: ${run.workspace_root}`, "The CLI has verified this frozen route. All project reads and writes for PLAN and later CODE happen in the write workspace; project root is only the stable runtime identity for lifecycle commands. Do not create, switch, merge or delete branches/worktrees.", "Keep the task runner's current cwd. Use the absolute paths in this packet instead of trying to set the provisioned workspace as a tool workdir.", "</workspace_contract>", "", "<accepted_inputs>", `- ${path.join(home, "01-specify", "specify.json")}`, `- ${path.join(home, "02-protocolize", "protocolize-result.json")}`, ...protocols.map((id) => `- ${path.join(run.workspace_root, ".memory-bank", "protocol", id, "summary.md")}`), "</accepted_inputs>", "", ...(codeCheckProfile ? ["<code_check_policy>", "You, not the CLI, select evidence for every accepted requirement and acceptance criterion. The profile only lists reusable aliases, mandatory project policy gates and guarded raw command prefixes. Inspect relevant package/test manifests before choosing a check. Do not classify checks by weight and do not omit a needed check because it looks expensive.", JSON.stringify(codeCheckProfile, null, 2), "</code_check_policy>", ""] : []), ...mergeContract, "<artifacts>", "The CLI has already materialized every artifact below as a partially filled draft. Edit these files in place; do not create replacements elsewhere.", "Prefilled and CLI-owned plan fields: schema_id, plan_id, protocol_id, initial revision and source_refs.", "Prefilled and CLI-owned aspect-map fields: schema_id, protocol_id, plan_id, plan revision, catalog_ref and every catalog aspect_id.", "You own the remaining semantic fields. Empty or missing semantic values are intentional draft markers and must be completed before validation.", ...planPaths.map((value) => `- partially filled plan: ${value}`), ...mapPaths.map((value) => `- partially filled aspect map: ${value}`), "</artifacts>", "", "<output_contract>", "Complete every named plan and aspect map in place. Do not create or edit code-work-batch.json: dd-flow derives it after validation.", "The CLI owns schema_id, plan_id, protocol_id, revision and source_refs. Preserve them exactly.", "Use protocol-plan@6. Its top-level checks[] is the single check catalog. Every check has id, command, purpose, run_at and availability. available means executable now. planned means one named P* Work first creates a NEW @check/... alias: planned therefore always needs provided_by and the exact alias definition. Every semantic @check alias, including an existing one, repeats its exact accepted profile command in definition so later stages can detect drift. Items and acceptance entries use check_refs only; never duplicate command declarations.", "For each R-* and AC-*, choose an actually relevant proof: an existing focused test, a new planned alias plus its provider Work, a project policy gate, or an autonomous executable check. Every plan item needs at least one check_ref. The CLI validates ids, provider ordering, materialization and guarded command policy; it never chooses a check for you. A provider Work may verify itself with the alias it has just created. A consumer must depend on that provider.", "Each plan item must name concrete existing source/test paths in required_read. planned_write_areas is optional: use stable component directories or files only when they help coordinate parallel Work; it is never a write allowlist. Reference every owned R-* and AC-* in one or more items; every AC-* needs an observable acceptance proof.", "For every selected check, inspect its command's launch path and the runtime entrypoints it starts. The fixture/reset process, service process and client process must observe one intended environment and data world. If a required runtime entrypoint needs a code change, make that change explicit in the Work task and its verification. Use planned_write_areas only to advertise likely concurrent overlap; do not treat it as ownership or assume another Work will repair an omitted change. If an independent infrastructure Work is clearer, plan that Work explicitly and order consumers after it.", reviewGroupingRule, "Complete compact contract and schema paths:", `- protocol plan schema: ${path.join(run.workspace_root, ".memory-bank", "dd-flow", "schemas", "vnext-protocol-plan.schema.json")}`, `- aspect map schema: ${path.join(run.workspace_root, ".memory-bank", "dd-flow", "schemas", "plan-aspect-map.schema.json")}`, "Minimal valid protocol-plan shape:", "```json", JSON.stringify(planExample(protocols[0]), null, 2), "```", "Minimal valid aspect-map shape:", "```json", JSON.stringify(aspectMapExample(protocols[0]), null, 2), "```", "</output_contract>", "", "<execution_commands>", "PLAN never launches independent reviewers or registers CODE Work.", "If PLAN needs a material user decision with no reasonable default, run this exact one-command heredoc, replacing only its placeholder body. The heredoc is the permitted stdin form; do not use cat, a pipe, a temporary file or a second shell command:", "```sh", pauseCommandTemplate, "```", "Ask the returned user_message, stop, and resume this same PLAN Work with the exact returned command.", "Validate both partially filled drafts after completing their semantic fields:", ...validationCommands.map((command) => `- ${command}`), "Finish PLAN only after all questions are resolved and both validation commands pass:", finishCommand, "The response returns the only PLAN-REVIEW start command. Follow it; do not start CODE directly.", "</execution_commands>", "", "<stage_instructions>", template, "</stage_instructions>", ""].join("\n");
109
+ const prompt = ["<stage_identity>", `- RUN: ${run.id}`, `- Work: ${planWorkId}`, "- stage: plan", "</stage_identity>", "", "<trusted_runtime_context>", "These facts were collected by dd-flow. Trust them; do not repeat CLI, Git, compatibility or permission discovery.", `- Project root: ${projectRoot}`, `- Workspace: ${run.workspace_root}`, `- Stage workspace: ${root}`, `- Git: ${JSON.stringify(git)}`, capacityContext, "</trusted_runtime_context>", "", "<workspace_contract>", `- route: ${workspaceRoute.route}`, `- feature branch: ${workspaceRoute.feature_branch ?? "not applicable"}`, `- base commit: ${workspaceRoute.base_ref ?? "not applicable"}`, `- write workspace: ${run.workspace_root}`, "The CLI has verified this frozen route. All project reads and writes for PLAN and later CODE happen in the write workspace; project root is only the stable runtime identity for lifecycle commands. Do not create, switch, merge or delete branches/worktrees.", "Keep the task runner's current cwd. Use the absolute paths in this packet instead of trying to set the provisioned workspace as a tool workdir.", "</workspace_contract>", "", "<accepted_inputs>", `- ${path.join(home, "01-specify", "specify.json")}`, `- ${path.join(home, "02-protocolize", "protocolize-result.json")}`, ...protocols.map((id) => `- ${path.join(run.workspace_root, ".memory-bank", "protocol", id, "summary.md")}`), "</accepted_inputs>", "", ...(codeCheckProfile ? ["<code_check_policy>", "You, not the CLI, select evidence for every accepted requirement and acceptance criterion. The profile only lists reusable aliases, mandatory project policy gates and guarded raw command prefixes. Inspect relevant package/test manifests before choosing a check. Do not classify checks by weight and do not omit a needed check because it looks expensive.", JSON.stringify(codeCheckProfile, null, 2), "</code_check_policy>", ""] : []), ...mergeContract, "<artifacts>", "The CLI has already materialized every artifact below as a partially filled draft. Edit these files in place; do not create replacements elsewhere.", "Prefilled and CLI-owned plan fields: schema_id, plan_id, protocol_id, initial revision and source_refs.", "Prefilled and CLI-owned aspect-map fields: schema_id, protocol_id, plan_id, plan revision, catalog_ref and every catalog aspect_id.", "You own the remaining semantic fields. Empty or missing semantic values are intentional draft markers and must be completed before validation.", ...planPaths.map((value) => `- partially filled plan: ${value}`), ...mapPaths.map((value) => `- partially filled aspect map: ${value}`), "</artifacts>", "", "<output_contract>", "Complete every named plan and aspect map in place. Do not create or edit code-work-batch.json: dd-flow derives it after validation.", "The CLI owns schema_id, plan_id, protocol_id, revision and source_refs. Preserve them exactly.", "Use protocol-plan@6. Its top-level checks[] is the single check catalog. Every check has id, command, purpose, run_at and availability. available means executable now. planned means one named P* Work first creates a NEW @check/... alias: planned therefore always needs provided_by and the exact alias definition. Every semantic @check alias, including an existing one, repeats its exact accepted profile command in definition so later stages can detect drift. Items and acceptance entries use check_refs only; never duplicate command declarations.", "For each R-* and AC-*, choose an actually relevant proof: an existing focused test, a new planned alias plus its provider Work, a project policy gate, or an autonomous executable check. Every plan item needs at least one check_ref. The CLI validates ids, provider ordering, materialization and guarded command policy; it never chooses a check for you. A provider Work may verify itself with the alias it has just created. A consumer must depend on that provider.", "Each plan item must name concrete existing source/test paths in required_read. planned_write_areas is optional: use stable component directories or files only when they help coordinate parallel Work; it is never a write allowlist. Reference every owned R-* and AC-* in one or more items; every AC-* needs an observable acceptance proof.", "For every selected check, inspect its command's launch path and the runtime entrypoints it starts. The fixture/reset process, service process and client process must observe one intended environment and data world. If a required runtime entrypoint needs a code change, make that change explicit in the Work task and its verification. Use planned_write_areas only to advertise likely concurrent overlap; do not treat it as ownership or assume another Work will repair an omitted change. If an independent infrastructure Work is clearer, plan that Work explicitly and order consumers after it.", reviewGroupingRule, "Complete compact contract and schema paths:", `- protocol plan schema: ${path.join(run.workspace_root, ".memory-bank", "dd-flow", "schemas", "vnext-protocol-plan.schema.json")}`, `- aspect map schema: ${path.join(run.workspace_root, ".memory-bank", "dd-flow", "schemas", "plan-aspect-map.schema.json")}`, "Minimal valid protocol-plan shape:", "```json", JSON.stringify(planExample(protocols[0]), null, 2), "```", "Minimal valid aspect-map shape:", "```json", JSON.stringify(aspectMapExample(protocols[0]), null, 2), "```", "</output_contract>", "", "<execution_commands>", "PLAN never launches independent reviewers or registers CODE Work.", "If PLAN needs a material user decision with no reasonable default, write the question packet to the named text file, then run the separate standalone lifecycle command below. Do not combine file creation and dd-flow in one shell command:", "```sh", pauseCommandTemplate, "```", "Ask the returned user_message, stop, and resume this same PLAN Work with the exact returned command.", "Validate both partially filled drafts after completing their semantic fields:", ...validationCommands.map((command) => `- ${command}`), "Finish PLAN only after all questions are resolved and both validation commands pass:", finishCommand, "The response returns the only PLAN-REVIEW start command. Follow it; do not start CODE directly.", "</execution_commands>", "", "<stage_instructions>", template, "</stage_instructions>", ""].join("\n");
110
110
  const artifactMaterialization = { status: "materialized", completeness: "partially_filled", plan_paths: planPaths, aspect_map_paths: mapPaths, cli_owned_plan_fields: ["schema_id", "plan_id", "protocol_id", "revision", "source_refs"], cli_owned_aspect_map_fields: ["schema_id", "protocol_id", "plan_id", "plan_revision", "catalog_ref", "aspects[].aspect_id"], validation_commands: validationCommands };
111
111
  const promptPath = path.join(root, "stage-prompt.md");
112
112
  // This explicit final rule supersedes historical pack wording: a flow gate
@@ -481,7 +481,7 @@ function resultTemplate(obligations = []) {
481
481
  }
482
482
  function renderPrompt(input) {
483
483
  const obligationList = input.obligations.map((obligation) => `- ${obligation.id}: ${obligation.statement}`).join("\n");
484
- return ["<work_context>", `- RUN: ${input.runId}`, `- Work: ${input.workId}`, `- Accepted SPECIFY JSON: ${input.specifyPath}`, `- Handoff: ${input.handoff.stage_handoff.effective}`, "</work_context>", "", "<accepted_obligations>", obligationList, "</accepted_obligations>", "", "<frozen_git_policy>", `- route: ${input.policy.route}`, `- integration branch: ${input.policy.integration_branch ?? "not applicable"}`, `- base ref: ${input.policy.base_ref ?? "not applicable"}`, `- feature branch: ${input.policy.feature_branch ?? "not applicable"}`, `- service worktree: ${input.policy.worktree_path ?? "not applicable"}`, `- bootstrap: ${input.policy.bootstrap_status}`, `- decision: ${input.policy.reason}`, "The CLI already created and bootstrapped this workspace at PROTOCOLIZE start. Do not create branches, worktrees, protocol files or Git commits yourself. This transition Work may run from the stable session; edit only the supplied RUN result file.", "</frozen_git_policy>", "", "<catalog>", input.catalog.length ? input.catalog.map((x) => `- ${x}`).join("\n") : "- inactive", "</catalog>", "", "<stage_instructions>", input.template.trim(), "</stage_instructions>", "", "<output_contract>", `Edit only ${input.resultPath}. Keep this exact JSON shape; CLI allocates ids and writes PRT/PSET/feature documents in the already provisioned workspace:\n\n\`\`\`json\n${JSON.stringify(resultTemplate(), null, 2)}\n\`\`\``, "Allocate every supplied R-* and AC-* exactly once in obligation_ownership. The CLI rejects unknown, duplicate or missing obligations, and every member must own at least one AC-*.", "If PROTOCOLIZE itself exposes a material user decision with no reasonable default, do not finish and do not return to SPECIFY. Run this exact one-command heredoc, replacing only its placeholder body. The heredoc is the permitted stdin form; do not use cat, a pipe, a temporary file or a second shell command:", "```sh", input.pauseCommandTemplate, "```", "Ask the returned user_message and stop. Resume this same PROTOCOLIZE Work using the exact command returned by pause.", `When the answer is incorporated and the result is protocolized, finish:\n\n${input.finishCommand}`, "</output_contract>", ""].join("\n");
484
+ return ["<work_context>", `- RUN: ${input.runId}`, `- Work: ${input.workId}`, `- Accepted SPECIFY JSON: ${input.specifyPath}`, `- Handoff: ${input.handoff.stage_handoff.effective}`, "</work_context>", "", "<accepted_obligations>", obligationList, "</accepted_obligations>", "", "<frozen_git_policy>", `- route: ${input.policy.route}`, `- integration branch: ${input.policy.integration_branch ?? "not applicable"}`, `- base ref: ${input.policy.base_ref ?? "not applicable"}`, `- feature branch: ${input.policy.feature_branch ?? "not applicable"}`, `- service worktree: ${input.policy.worktree_path ?? "not applicable"}`, `- bootstrap: ${input.policy.bootstrap_status}`, `- decision: ${input.policy.reason}`, "The CLI already created and bootstrapped this workspace at PROTOCOLIZE start. Do not create branches, worktrees, protocol files or Git commits yourself. This transition Work may run from the stable session; edit only the supplied RUN result file.", "</frozen_git_policy>", "", "<catalog>", input.catalog.length ? input.catalog.map((x) => `- ${x}`).join("\n") : "- inactive", "</catalog>", "", "<stage_instructions>", input.template.trim(), "</stage_instructions>", "", "<output_contract>", `Edit only ${input.resultPath}. Keep this exact JSON shape; CLI allocates ids and writes PRT/PSET/feature documents in the already provisioned workspace:\n\n\`\`\`json\n${JSON.stringify(resultTemplate(), null, 2)}\n\`\`\``, "Allocate every supplied R-* and AC-* exactly once in obligation_ownership. The CLI rejects unknown, duplicate or missing obligations, and every member must own at least one AC-*.", "If PROTOCOLIZE itself exposes a material user decision with no reasonable default, do not finish and do not return to SPECIFY. Write the question packet to the named text file, then run the separate standalone lifecycle command below. Do not combine file creation and dd-flow in one shell command:", "```sh", input.pauseCommandTemplate, "```", "Ask the returned user_message and stop. Resume this same PROTOCOLIZE Work using the exact command returned by pause.", `When the answer is incorporated and the result is protocolized, finish:\n\n${input.finishCommand}`, "</output_contract>", ""].join("\n");
485
485
  }
486
486
  function prepareWorkspaceForProtocolize(context, projectRoot, projectId, run, stageRoot) {
487
487
  const receiptPath = path.join(stageRoot, "workspace-route.json");
@@ -407,7 +407,7 @@ function renderPrompt(input) {
407
407
  "```",
408
408
  "The JSON must preserve the problem-space contract for a fresh PROTOCOLIZE worker: user intent, scope, acceptance and verification, settled defaults, relevant project facts, gap-method outcomes, task assessment, delivery shape and handoff. Use stable R-001... identifiers in requirements and AC-001... identifiers in acceptance_criteria. Do not describe implementation design.",
409
409
  "Do not finish while a material user question remains. Use stage pause instead of adding a question to specify.json.",
410
- "If a user answer is required, make this the lifecycle command instead of finish. Run this exact one-command heredoc, replacing only the placeholder body with the concise user-facing question packet. The heredoc is the permitted stdin form; do not use cat, a pipe, a temporary file or a second shell command:",
410
+ "If a user answer is required, use the file and standalone lifecycle command below instead of finish. Write only the concise user-facing question packet to the named text file; do not combine file creation and dd-flow in one shell command:",
411
411
  "```sh",
412
412
  input.pauseCommandTemplate,
413
413
  "```",
@@ -2,6 +2,7 @@ import { managedLifecycleCommand } from "./lifecycle-invocations.js";
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
+ import { quote } from "shell-quote";
5
6
  import { AppError } from "../shared/errors.js";
6
7
  import { entityReferenceVariants, shortEntityReference } from "../shared/entity-references.js";
7
8
  import { resolveWorkReference } from "../storage/work-references.js";
@@ -974,7 +975,8 @@ export function validateReviewResolutions(workId, assigned, resolutions) {
974
975
  function renderWorkerPrompt(context, work, run, dependencies) {
975
976
  const command = flowCommand(context);
976
977
  const packet = codePacket(work);
977
- const finishWorkCommand = managedLifecycleCommand(context, `${command} work finish ${work.work_id} --result-stdin --project-root ${JSON.stringify(run.project_root)} --json --progress-jsonl`);
978
+ const resultInputPath = path.join(requireRunHome(run), "works", work.work_id, "result-input.json");
979
+ const finishWorkCommand = managedLifecycleCommand(context, `${command} work finish ${work.work_id} --result-file ${quote([resultInputPath])} --project-root ${quote([run.project_root])} --json --progress-jsonl`);
978
980
  const failWorkCommand = managedLifecycleCommand(context, `${command} work fail ${shortWorkId(work.work_id)} --reason "receipt path + exact external or semantic blocker" --project-root ${JSON.stringify(run.project_root)} --json`);
979
981
  const mergeWork = parsePayload(work)?.kind === "merge";
980
982
  const writeBoundary = mergeWork
@@ -988,7 +990,7 @@ function renderWorkerPrompt(context, work, run, dependencies) {
988
990
  codeContext.push("<document_updates>", JSON.stringify(packet.document_updates, null, 2), "Materialize every listed update. dd-flow verifies the resulting file against its PLAN-time baseline.", "</document_updates>", "", "<completion_contract>", "Successful completion requires empty deviations and blockers and every assigned document update in changed_paths. A necessary path outside planned_write_areas is normal coordination drift, not a blocker; include it in changed_paths and continue.", "</completion_contract>", "");
989
991
  if (packet)
990
992
  codeContext.push("<temporary_services>", "Prefer the declared check launcher: it already owns check resources. If the planned scenario genuinely requires an interactive HTTP service, use the managed supervisor below. This is a template: replace the project service command, port names and readiness path from the plan/project instructions; do not invent a fixed port.", `${command} runtime process start --run ${run.id} --project-root ${JSON.stringify(run.project_root)} --command '<project-service-command>' --ports api --ready-port api --ready-path /health --json --progress-jsonl`, "The service receives DD_FLOW_PORT_API (and equivalent variables for all declared names). The command stays running as its supervisor. Retain its tool handle; wait for the service ready event and read its service.json receipt. Pass those exact ports and the same project environment to reset/seed, API and browser operations.", "A ready receipt proves service readiness only. Record the scenario outcome and real evidence separately. After the scenario, execute the exact stop_command from that receipt, then wait for the supervisor to exit. Never use pkill/killall or stop a sibling's process. If cleanup fails, retain the process id and report the failure; do not claim the resource is free.", "</temporary_services>", "");
991
- return ["<work>", `- work_id: ${work.work_id}`, `- run_id: ${work.run_id}`, `- project_root: ${run.project_root}`, `- workspace_root: ${run.workspace_root}`, `- run_home: ${requireRunHome(run)}`, "</work>", "", "<cli_context>", `- Work commands use the short ID ${shortWorkId(work.work_id)}; dd-flow binds it to this RUN.`, `- @project = ${run.project_root}`, `- @workspace = ${run.workspace_root}`, `- @run = ${requireRunHome(run)}`, "- @ aliases are accepted only by declared dd-flow path parameters. Copy the quoted alias literally; do not add backslashes. Shell tools such as cat and rg require ordinary paths relative to cwd or absolute paths.", "- Lifecycle invocation IDs are internal runtime authority and are intentionally omitted from commands.", "</cli_context>", "", "<hard_write_boundary>", ...writeBoundary, "</hard_write_boundary>", "", ...codeContext, "<dependency_results>", JSON.stringify(dependencies.filter(Boolean), null, 2), "</dependency_results>", "", "<task>", resolveRunReferences(work.task, work.run_id, requireRunHome(run)), "</task>", "", ...(work.result_schema ? ["<result_contract>", `Return JSON matching \`${work.result_schema}\`.`, ...resultSchemaGuidance(work, run.id), "Do not create result.json yourself. Send the JSON to dd-flow on stdin; it atomically validates and stores the canonical receipt.", "</result_contract>", ""] : []), "<completion>", packet?.repair?.verification_check_refs?.length ? "Work finish runs its normal work-scoped checks plus the listed causal repair checks. Their original run_at remains an aggregate obligation; this is the additional proof required before accepting this repair." : "Work finish runs only declared run_at=work checks. Stage finish owns readiness/code/merge gates; successful Work completion does not mean those gates have passed.", "A failed receipt means only that the check failed; it is not proof of an engine, harness, dependency, or environment blocker.", ...completionRepair, "Use Fail only for a concrete external blocker after deterministic bootstrap or a contradiction with an accepted requirement/non-goal. Never fail merely because a necessary project path was absent from planned_write_areas.", "Finish may run for several minutes. Preserve the shell tool's process/session handle and poll that same invocation until it exits; progress arrives as JSONL on stderr. Never reissue Finish merely because final stdout has not arrived.", `Finish as one standalone command with a quoted heredoc (replace the example JSON with your result):\n${finishWorkCommand} <<'DD_FLOW_RESULT'\n{}\nDD_FLOW_RESULT`, `Fail only for an evidenced external or semantic-contract blocker: ${failWorkCommand}`, "</completion>", ""].join("\n");
993
+ return ["<work>", `- work_id: ${work.work_id}`, `- run_id: ${work.run_id}`, `- project_root: ${run.project_root}`, `- workspace_root: ${run.workspace_root}`, `- run_home: ${requireRunHome(run)}`, "</work>", "", "<cli_context>", `- Work commands use the short ID ${shortWorkId(work.work_id)}; dd-flow binds it to this RUN.`, `- @project = ${run.project_root}`, `- @workspace = ${run.workspace_root}`, `- @run = ${requireRunHome(run)}`, "- @ aliases are accepted only by declared dd-flow path parameters. Copy the quoted alias literally; do not add backslashes. Shell tools such as cat and rg require ordinary paths relative to cwd or absolute paths.", "- Lifecycle invocation IDs are internal runtime authority and are intentionally omitted from commands.", "</cli_context>", "", "<hard_write_boundary>", ...writeBoundary, "</hard_write_boundary>", "", ...codeContext, "<dependency_results>", JSON.stringify(dependencies.filter(Boolean), null, 2), "</dependency_results>", "", "<task>", resolveRunReferences(work.task, work.run_id, requireRunHome(run)), "</task>", "", ...(work.result_schema ? ["<result_contract>", `Return JSON matching \`${work.result_schema}\`.`, ...resultSchemaGuidance(work, run.id), `Write the completed JSON to ${resultInputPath}. This is an attempt input, not the canonical result.json receipt. dd-flow validates the file and atomically stores the canonical receipt.`, "</result_contract>", ""] : []), "<completion>", packet?.repair?.verification_check_refs?.length ? "Work finish runs its normal work-scoped checks plus the listed causal repair checks. Their original run_at remains an aggregate obligation; this is the additional proof required before accepting this repair." : "Work finish runs only declared run_at=work checks. Stage finish owns readiness/code/merge gates; successful Work completion does not mean those gates have passed.", "A failed receipt means only that the check failed; it is not proof of an engine, harness, dependency, or environment blocker.", ...completionRepair, "Use Fail only for a concrete external blocker after deterministic bootstrap or a contradiction with an accepted requirement/non-goal. Never fail merely because a necessary project path was absent from planned_write_areas.", "Finish may run for several minutes. Preserve the shell tool's process/session handle and poll that same invocation until it exits; progress arrives as JSONL on stderr. Never reissue Finish merely because final stdout has not arrived.", `First write only the result JSON to ${resultInputPath}. Then run this standalone command:\n${finishWorkCommand}`, `Fail only for an evidenced external or semantic-contract blocker: ${failWorkCommand}`, "</completion>", ""].join("\n");
992
994
  }
993
995
  export function resultSchemaGuidance(work, runId) {
994
996
  const schema = work.result_schema;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.83",
3
+ "version": "0.9.0-beta.87",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {