@deksden-com/dd-flow-cli 0.9.0-beta.86 → 0.9.0-beta.88

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,20 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.88
4
+
5
+ ### Patch Changes
6
+
7
+ - 5f3806c: Keep managed HITL answer paths inside retained runtime authority, preserve early
8
+ native CLI failures, and fail controllers conclusively when an answer turn does
9
+ not resume its assigned pause.
10
+
11
+ ## 0.9.0-beta.87
12
+
13
+ ### Patch Changes
14
+
15
+ - a49655a: Use separate files for model-authored lifecycle payloads and preserve native
16
+ shell-composition diagnostics instead of reporting a missing hook receipt.
17
+
3
18
  ## 0.9.0-beta.86
4
19
 
5
20
  ### Patch Changes
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.86",
4
- "cli_commit": "979a6151c910228ed3da8daa015bcf89098b04f1",
5
- "built_at": "2026-09-20T19:12:05.808Z",
3
+ "cli_version": "0.9.0-beta.88",
4
+ "cli_commit": "6cd7da3104a366d4b2866fa31ef8635949e79143",
5
+ "built_at": "2026-09-21T00:22:16.324Z",
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,11 @@ 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
+
237
+ Managed resume commands restore the accepted answer file from runtime state. Execute the returned command without adding an answer path or recreating the answer. The explicit answer flags above are for unmanaged calls.
238
+ 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
239
  dd-flow stage unblock <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --work <WORK-ID> --json
238
240
  dd-flow stage fanout status <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --json
239
241
  dd-flow stage fanout dispatch <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --json
@@ -254,7 +256,7 @@ Usage:
254
256
  dd-flow work start <WORK-ID> --project-root <root> --json
255
257
  dd-flow work launch <WORK-ID> [--stage <name>] --json
256
258
  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
259
+ 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
260
 
259
261
  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
262
  ],
@@ -2,9 +2,9 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { quote } from "shell-quote";
5
- import { observeLifecycleCommand } from "../services/lifecycle-invocations.js";
5
+ import { observeLifecycleCommand, observedLifecycleNativeEvent } from "../services/lifecycle-invocations.js";
6
6
  import { verifyCodexHookDelivery } from "../services/codex-hook-delivery.js";
7
- import { assertLifecycleInvocationCurrent, awaitLifecycleInvocation, expandLifecycleInvocationArgs, issueLifecycleInvocation, prepareLifecycleIssuance, lifecycleInvocationScope, observedLifecycleInvocation, resolveObservedLifecycleArgs, settleLifecycleInvocation, settleLifecyclePreparationRejection, 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";
@@ -132,9 +132,13 @@ export async function runCli(args, io = defaultIo, env = process.env) {
132
132
  const lifecycleCommand = parseLifecycleCommand(publicCommand);
133
133
  if (lifecycleCommand.kind === "standalone")
134
134
  validateLifecyclePathAliases(output.args, lifecycleCommand.invocation.operation);
135
- const observedInvocation = observedLifecycleInvocation(routerContext, publicCommand);
135
+ const observedInvocation = observedLifecycleInvocation(routerContext, publicCommand, env.DD_FLOW_DAEMON_ID);
136
136
  if (!observedInvocation && !suppliedInvocationId && env.DD_FLOW_DAEMON_ID && lifecycleCommand.kind === "standalone") {
137
- throw new AppError("invocation_receipt_missing", "Managed lifecycle command has no committed native receipt", 1, { effect: "no_effect", recoverable: false });
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 });
140
+ const nativeEvent = observedLifecycleNativeEvent(routerContext, publicCommand, env.DD_FLOW_DAEMON_ID);
141
+ throw new AppError(nativeEvent ? "invocation_assignment_missing" : "invocation_receipt_missing", nativeEvent ? "Native call was observed, but its arguments do not match an issued lifecycle assignment" : "Managed lifecycle command has no committed native receipt", 1, { effect: "no_effect", recoverable: false });
138
142
  }
139
143
  const invocationId = observedInvocation?.id ?? suppliedInvocationId;
140
144
  let boundInvocationArgs;
@@ -263,6 +267,23 @@ export async function runCli(args, io = defaultIo, env = process.env) {
263
267
  }
264
268
  catch (caught) {
265
269
  const error = isAppError(caught) ? caught : new AppError("unexpected", caught instanceof Error ? caught.message : String(caught), 1, { effect: "unknown", cause: { code: caught?.code ?? null, sqlite_extended_code: caught?.errcode ?? null } });
270
+ if (!settleHook && !invocationSettlement && env.DD_FLOW_DAEMON_ID) {
271
+ let earlyContext;
272
+ try {
273
+ const command = quote(["dd-flow", ...output.args]);
274
+ const event = observedLifecycleNativeEvent(createRouterContext(env), command, env.DD_FLOW_DAEMON_ID);
275
+ if (event) {
276
+ earlyContext = createContext({ ...env }, "hook");
277
+ observeLifecycleCommand(earlyContext, command, event)({ error });
278
+ }
279
+ }
280
+ catch (persistence) {
281
+ error.details.lifecycle_diagnostic_error = String(persistence);
282
+ }
283
+ finally {
284
+ earlyContext?.db.close?.();
285
+ }
286
+ }
266
287
  if (diagnosticHome && (!isAppError(caught) || error.code === "storage_write_failed")) {
267
288
  try {
268
289
  const directory = path.join(diagnosticHome, "diagnostics", "cli");
@@ -978,9 +999,10 @@ async function prepareCliInputValue(args, context, io, scopeProjectRoot, respons
978
999
  const input = { projectRoot: requiredOption(parsed, "project-root"), runId: requiredPosition(parsed, 0, "run-id"), stage: requiredOption(parsed, "stage"), workId: requiredOption(parsed, "work") };
979
1000
  try {
980
1001
  if (route.key === "stage block") {
981
- if (!hasOption(parsed, "summary-stdin"))
982
- throw new AppError("usage", "stage block requires --summary-stdin", 2);
983
- prepared.interactionText = await readStdin(io.stdin);
1002
+ const summaryFile = optionalOption(parsed, "summary-file"), summaryStdin = hasOption(parsed, "summary-stdin");
1003
+ if (Boolean(summaryFile) === summaryStdin)
1004
+ throw new AppError("usage", "stage block requires exactly one of --summary-file or --summary-stdin", 2);
1005
+ prepared.interactionText = summaryFile ? prepareTextFile(summaryFile, "Runtime block summary").text : await readStdin(io.stdin);
984
1006
  prepareStageRuntimeBlock(context, { ...input, kind: requiredOption(parsed, "kind"), code: requiredOption(parsed, "code"), summary: prepared.interactionText, retryable: hasOption(parsed, "retryable") });
985
1007
  }
986
1008
  else
@@ -1013,11 +1035,13 @@ async function prepareCliInputValue(args, context, io, scopeProjectRoot, respons
1013
1035
  }
1014
1036
  }
1015
1037
  if (route.key === "work repair add") {
1016
- if (!hasOption(parsed, "task-stdin"))
1017
- throw new AppError("usage", "work repair add requires --task-stdin", 2);
1018
- if (io.stdin?.isTTY)
1038
+ const taskFile = optionalOption(parsed, "task-file"), taskStdin = hasOption(parsed, "task-stdin");
1039
+ if (Boolean(taskFile) === taskStdin)
1040
+ throw new AppError("usage", "work repair add requires exactly one of --task-file or --task-stdin", 2);
1041
+ if (taskStdin && io.stdin?.isTTY)
1019
1042
  throw new AppError("usage", "--task-stdin requires piped or redirected input", 2);
1020
- prepared.workRepair = prepareCliWorkRepair(context, parsed, await readStdin(io.stdin), scopeProjectRoot);
1043
+ const task = taskFile ? prepareTextFile(taskFile, "Repair task").text : await readStdin(io.stdin);
1044
+ prepared.workRepair = prepareCliWorkRepair(context, parsed, task, scopeProjectRoot);
1021
1045
  }
1022
1046
  if (route.key === "run config set" || route.key === "run vars set") {
1023
1047
  const input = { projectRoot: requiredOption(parsed, "project-root"), runId: requiredPosition(parsed, 1, "run-id"), key: requiredOption(parsed, "key"), value: requiredOption(parsed, "value") };
@@ -1153,14 +1177,17 @@ async function prepareCliInputValue(args, context, io, scopeProjectRoot, respons
1153
1177
  requiredOption(parsed, "project-root");
1154
1178
  requiredOption(parsed, "stage");
1155
1179
  requiredOption(parsed, "work");
1156
- const parameter = command === "pause" ? "question-stdin" : "answer-stdin";
1157
- if (!hasOption(parsed, parameter))
1158
- throw new AppError("usage", `stage ${command} requires --${parameter}`, 2);
1159
- if (io.stdin?.isTTY)
1160
- throw new AppError("usage", `--${parameter} requires piped or redirected input`, 2);
1161
- prepared.interactionText = await readStdin(io.stdin);
1180
+ const fileParameter = command === "pause" ? "question-file" : "answer-file";
1181
+ const stdinParameter = command === "pause" ? "question-stdin" : "answer-stdin";
1182
+ const inputFile = optionalOption(parsed, fileParameter);
1183
+ const fromStdin = hasOption(parsed, stdinParameter);
1184
+ if (Boolean(inputFile) === fromStdin)
1185
+ throw new AppError("usage", `stage ${command} requires exactly one of --${fileParameter} or --${stdinParameter}`, 2);
1186
+ if (fromStdin && io.stdin?.isTTY)
1187
+ throw new AppError("usage", `--${stdinParameter} requires piped or redirected input`, 2);
1188
+ prepared.interactionText = inputFile ? prepareTextFile(inputFile, command === "pause" ? "User question" : "User answer").text : await readStdin(io.stdin);
1162
1189
  if (!prepared.interactionText.trim())
1163
- return prepareError(new AppError("validation", `stage ${command} requires non-empty --${parameter}`, 2), { parameter });
1190
+ return prepareError(new AppError("validation", `stage ${command} requires non-empty input`, 2), { parameter: inputFile ? fileParameter : stdinParameter });
1164
1191
  const input = { projectRoot: requiredOption(parsed, "project-root"), runId: requiredPosition(parsed, 0, "run-id"), stage: requiredOption(parsed, "stage"), workId: requiredOption(parsed, "work") };
1165
1192
  try {
1166
1193
  if (command === "pause")
@@ -1747,8 +1774,6 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
1747
1774
  }, prepared.legacyStageFinish));
1748
1775
  }
1749
1776
  if (family === "stage" && command === "pause") {
1750
- if (!hasOption(parsed, "question-stdin"))
1751
- throw new AppError("usage", "stage pause requires --question-stdin", 2);
1752
1777
  const projectRoot = requiredOption(parsed, "project-root");
1753
1778
  const runId = requiredPosition(parsed, 0, "run-id");
1754
1779
  const stage = requiredOption(parsed, "stage");
@@ -1770,8 +1795,6 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
1770
1795
  });
1771
1796
  }
1772
1797
  if (family === "stage" && command === "resume") {
1773
- if (!hasOption(parsed, "answer-stdin"))
1774
- throw new AppError("usage", "stage resume requires --answer-stdin", 2);
1775
1798
  const hookEventId = optionalOption(parsed, "hook-event-id");
1776
1799
  return resumeStageAfterUser(context, {
1777
1800
  projectRoot: requiredOption(parsed, "project-root"),
@@ -1784,8 +1807,6 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
1784
1807
  });
1785
1808
  }
1786
1809
  if (family === "stage" && command === "block") {
1787
- if (!hasOption(parsed, "summary-stdin"))
1788
- throw new AppError("usage", "stage block requires --summary-stdin", 2);
1789
1810
  const kind = requiredOption(parsed, "kind");
1790
1811
  if (kind !== "engine" && kind !== "harness" && kind !== "environment")
1791
1812
  throw new AppError("usage", "--kind must be engine, harness or environment", 2);
@@ -6,7 +6,7 @@ import { setTimeout as delay } from "node:timers/promises";
6
6
  import { AppError, errorRecord } from "../shared/errors.js";
7
7
  import { shortEntityReference } from "../shared/entity-references.js";
8
8
  import { resolveWorkReference } from "../storage/work-references.js";
9
- import { commandOption, parseLifecycleCommand, shellSuffixOffset } from "./lifecycle-command.js";
9
+ import { commandOption, lifecycleCommandFromPayload, parseLifecycleCommand, shellSuffixOffset } from "./lifecycle-command.js";
10
10
  import { requireProjectByRoot } from "./projects.js";
11
11
  import { assertRunMutationAllowed, assertStageSettlementAllowed, assertWorkSettlementAllowed, recoveryGuard } from "./run-recovery.js";
12
12
  import { readNativeDaemonState, requireManagedDaemonBinding } from "./managed-daemon-binding.js";
@@ -68,7 +68,7 @@ function fingerprint(parsed) {
68
68
  const runtimeOwnedOptions = {
69
69
  stage_start: new Set(["project-root", "context-file", "context-sha256", "response-file", "require-session-binding"]),
70
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"]),
71
+ stage_pause: new Set(["project-root"]), stage_resume: new Set(["project-root", "answer-file"]),
72
72
  work_start: new Set(["project-root", "recovery-id"]),
73
73
  work_finish: new Set(["project-root"]), work_fail: new Set(["project-root"]),
74
74
  merge_apply: new Set(["project-root"]), merge_repair: new Set(["project-root"]),
@@ -278,7 +278,7 @@ export function assertLifecycleOutcomes(context, scope) {
278
278
  }
279
279
  /** Hooks already own native identity. Retain command outcomes there, with an
280
280
  * atomic controller notification; missing notifications reconcile from the row. */
281
- export function observeLifecycleCommand(context, command) {
281
+ export function observeLifecycleCommand(context, command, nativeEvent) {
282
282
  const parsed = parseLifecycleCommand(command);
283
283
  if (parsed.kind !== "standalone")
284
284
  return () => { };
@@ -335,9 +335,12 @@ export function observeLifecycleCommand(context, command) {
335
335
  return;
336
336
  anchor = { project_id: projectId, run_id: runId, generation: generation ?? recoveryGuard(context, projectId, runId)?.generation ?? 0, hook_event_id: eventId, session_id: event.session_id, harness: event.harness, operation: parsed.invocation.operation };
337
337
  };
338
- observeCommandHook(context, command);
338
+ if (nativeEvent)
339
+ context.observeLifecycleHook(nativeEvent.project_id, nativeEvent.id);
340
+ else
341
+ observeCommandHook(context, command);
339
342
  const invocationId = commandOption(parsed.invocation, "invocation-id");
340
- if (!anchor && invocationId) {
343
+ if (!anchor && invocationId && !nativeEvent) {
341
344
  const attempt = load(context, invocationId);
342
345
  checkCommand(attempt, command);
343
346
  if (attempt.event_key && ["observed", "executing"].includes(attempt.status)) {
@@ -441,9 +444,10 @@ export function managedInvocationContext(context, input) {
441
444
  env.DD_FLOW_INVOCATION_SCOPE = JSON.stringify(scope);
442
445
  return { ...context, env };
443
446
  }
444
- /** Generated commands retain their attempt across repeated rendering. Only a
445
- * currently executing command can emit its own successor with the same argv. */
446
- export function managedLifecycleCommand(context, command) {
447
+ /** Generated commands retain their attempt across repeated rendering. Execution
448
+ * can issue a retry; accepted HITL intent can replace an unobserved placeholder
449
+ * or issue the next pause's assignment after successful settlement. */
450
+ export function managedLifecycleCommand(context, command, options = {}) {
447
451
  const configured = context.env.DD_FLOW_INVOCATION_SCOPE;
448
452
  if (!configured)
449
453
  return command;
@@ -474,7 +478,18 @@ export function managedLifecycleCommand(context, command) {
474
478
  return context.db.writeTransaction(() => {
475
479
  const prior = context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [JSON.stringify(scope), fingerprint(parsed)]);
476
480
  if (prior && prior.id !== context.env.DD_FLOW_CURRENT_INVOCATION) {
477
- return publicInvocationCommand(context, prior, command);
481
+ const replacesAnswer = options.replaceUnobservedAnswer && parsed.operation === "stage_resume"
482
+ && (prior.status === "settled" || commandOption(invocation(prior.command), "answer-file") !== commandOption(parsed, "answer-file"));
483
+ if (!replacesAnswer)
484
+ return publicInvocationCommand(context, prior, command);
485
+ // The pause packet can prepare a placeholder before the controller has
486
+ // accepted an answer. Replace only an unobserved assignment; never change
487
+ // the authority of a native call already in flight.
488
+ const completed = prior.status === "settled" && prior.outcome_json && !JSON.parse(prior.outcome_json).error;
489
+ if (prior.status !== "issued" && !completed)
490
+ throw new AppError("invocation_answer_conflict", "Cannot replace an answer while its lifecycle assignment is in flight or failed", 1, { invocation_id: prior.id });
491
+ if (prior.status === "issued")
492
+ context.db.run("UPDATE lifecycle_invocations SET status = 'expired' WHERE id = ?", [prior.id]);
478
493
  }
479
494
  if (context.env.DD_FLOW_INVOCATION_READONLY === "1")
480
495
  throw new AppError("invocation_command_unprepared", "No prepared lifecycle command is available; read-only inspection cannot issue one", 1);
@@ -708,8 +723,30 @@ export function observeLifecycleInvocation(context, input) {
708
723
  storage(context);
709
724
  const supplied = input.id ? context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [input.id]) : undefined;
710
725
  const analysis = parseLifecycleCommand(input.command);
711
- if (analysis.kind !== "standalone" || analysis.invocation.wrapped)
712
- return { eventKey: input.recordReceipt(), duplicate: false, invocationId: null };
726
+ if (analysis.kind !== "standalone" || analysis.invocation.wrapped) {
727
+ const eventKey = input.recordReceipt();
728
+ if (analysis.kind !== "none") {
729
+ const retained = context.db.get("SELECT outcome_json FROM hook_events WHERE event_key = ?", [eventKey]);
730
+ let outcome = {};
731
+ try {
732
+ outcome = retained?.outcome_json ? JSON.parse(retained.outcome_json) : {};
733
+ }
734
+ catch {
735
+ outcome = {};
736
+ }
737
+ context.db.run("UPDATE hook_events SET outcome_json = ? WHERE event_key = ?", [JSON.stringify({
738
+ ...outcome,
739
+ lifecycle_observation: {
740
+ code: "lifecycle_shell_syntax_invalid",
741
+ reason: analysis.kind === "compound" ? analysis.reason : "wrapped_lifecycle_command",
742
+ command_fingerprint: fingerprint(analysis.invocation),
743
+ effect: "no_effect",
744
+ recoverable: true
745
+ }
746
+ }), eventKey]);
747
+ }
748
+ return { eventKey, duplicate: false, invocationId: null };
749
+ }
713
750
  const candidates = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('issued','observed') ORDER BY rowid DESC")
714
751
  .filter(row => {
715
752
  const scope = JSON.parse(row.scope_json);
@@ -785,10 +822,63 @@ export function observeLifecycleInvocation(context, input) {
785
822
  throw error;
786
823
  }
787
824
  }
825
+ /** Correlate exact native argv for diagnostics, independently of assignment
826
+ * matching. A hook-added event key must name the same persisted native call. */
827
+ export function observedLifecycleNativeEvent(context, command, daemonId) {
828
+ if (!context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'hook_events'"))
829
+ return null;
830
+ const signature = (value) => {
831
+ const parsed = parseLifecycleCommand(value);
832
+ if (parsed.kind !== "standalone" || parsed.invocation.wrapped)
833
+ return null;
834
+ return JSON.stringify([parsed.invocation.operation, parsed.invocation.args.positional,
835
+ [...parsed.invocation.args.options].filter(([key]) => !["json", "progress-jsonl", "hook-event-id"].includes(key)).sort(([a], [b]) => a.localeCompare(b))]);
836
+ };
837
+ const expected = signature(command);
838
+ if (!expected)
839
+ return null;
840
+ const eventKey = commandOption(invocation(command), "hook-event-id");
841
+ const matches = context.db.all("SELECT project_id, id, event_key, sanitized_summary FROM hook_events WHERE daemon_id = ? AND status = 'observed' AND outcome_json IS NULL", [daemonId]).filter(row => {
842
+ if (eventKey && row.event_key !== eventKey)
843
+ return false;
844
+ try {
845
+ const observed = lifecycleCommandFromPayload(JSON.parse(row.sanitized_summary));
846
+ return typeof observed === "string" && signature(observed) === expected;
847
+ }
848
+ catch {
849
+ return false;
850
+ }
851
+ });
852
+ // Diagnostic correlation only: never use this as execution authority, and
853
+ // never choose the latest of several identical native calls.
854
+ return matches.length === 1 ? { project_id: matches[0].project_id, id: matches[0].id } : null;
855
+ }
856
+ /** Keep shell diagnostics distinct from genuinely absent native delivery. */
857
+ export function observedLifecycleDiagnostic(context, command, daemonId) {
858
+ const analysis = parseLifecycleCommand(command);
859
+ if (analysis.kind !== "standalone")
860
+ return null;
861
+ const commandFingerprint = fingerprint(analysis.invocation);
862
+ 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 => {
863
+ try {
864
+ const outcome = JSON.parse(row.outcome_json);
865
+ return outcome.lifecycle_observation?.command_fingerprint === commandFingerprint ? [{ ...outcome.lifecycle_observation, event_key: row.event_key }] : [];
866
+ }
867
+ catch {
868
+ return [];
869
+ }
870
+ });
871
+ if (!rows.length)
872
+ return null;
873
+ const reasons = new Set(rows.map(row => `${String(row.code)}\0${String(row.reason)}`));
874
+ if (reasons.size !== 1)
875
+ return null;
876
+ return { ...rows[0], matching_events: rows.map(row => row.event_key) };
877
+ }
788
878
  /** Resolve only a command that a native hook has already bound to an issued
789
879
  * attempt. This is the CLI-side half of admission for model-facing commands
790
880
  * that intentionally omit the internal UUID. */
791
- export function observedLifecycleInvocation(context, command) {
881
+ export function observedLifecycleInvocation(context, command, daemonId) {
792
882
  if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
793
883
  return null;
794
884
  const analysis = parseLifecycleCommand(command);
@@ -798,6 +888,8 @@ export function observedLifecycleInvocation(context, command) {
798
888
  .filter(row => {
799
889
  try {
800
890
  const scope = JSON.parse(row.scope_json);
891
+ if (daemonId && scope.daemonId !== daemonId)
892
+ return false;
801
893
  return fingerprint(invocation(canonicalManagedCommand(context, command, scope))) === row.fingerprint;
802
894
  }
803
895
  catch {
@@ -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";
@@ -597,19 +598,25 @@ async function executeController(context, row, manifest, state, assertOwnership,
597
598
  const pause = view.index.stage_runs.find(stage => stage.stage === next.stage)?.pause;
598
599
  if (!pause || state.current_session === null)
599
600
  throw new AppError("controller_hitl_missing", "Paused RUN has no managed Session or HITL receipt", 1);
600
- const answer = context.db.get("SELECT operation_id, input_json FROM run_controller_operations WHERE controller_id = ? AND status = 'accepted' AND json_extract(input_json, '$.action') = 'user_answer' AND json_extract(input_json, '$.pause_id') = ? ORDER BY created_at LIMIT 1", [row.controller_id, pause.id]);
601
+ const answer = context.db.get("SELECT operation_id, input_json, status FROM run_controller_operations WHERE controller_id = ? AND status IN ('accepted','completed') AND json_extract(input_json, '$.action') = 'user_answer' AND json_extract(input_json, '$.pause_id') = ? ORDER BY created_at LIMIT 1", [row.controller_id, pause.id]);
601
602
  if (!answer) {
602
603
  await delay(1000);
603
604
  continue;
604
605
  }
606
+ if (answer.status === "completed")
607
+ throw new AppError("controller_answer_not_applied", "Completed answer operation still has its original HITL pause", 1, { pause_id: pause.id, operation_id: answer.operation_id, stage: next.stage });
605
608
  const input = JSON.parse(answer.input_json);
606
609
  if (digestBytes(fs.readFileSync(input.file)) !== input.sha256)
607
610
  throw new AppError("context_checksum_mismatch", "Accepted HITL answer bytes changed", 1);
608
611
  assertOwner();
609
612
  // The accepted answer is logical intent; only its generation-bound
610
613
  // 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)}`);
614
+ 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`, { replaceUnobservedAnswer: true });
612
615
  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}`);
616
+ const afterAnswer = getFlowRunStatus(executionContext, { projectRoot: run.project_root, runId: run.id });
617
+ if (afterAnswer.index.stage_runs.some(stage => stage.status === "paused" && stage.pause?.id === pause.id)) {
618
+ throw new AppError("controller_answer_not_applied", "The answer Turn completed without resuming the assigned HITL pause", 1, { pause_id: pause.id, operation_id: answer.operation_id, stage: next.stage, effect: "no_progress" });
619
+ }
613
620
  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
621
  state.last_continuation = null;
615
622
  saveState(context, row, state);
@@ -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";
@@ -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 = 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 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))
@@ -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.86",
3
+ "version": "0.9.0-beta.88",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {