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

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,19 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.89
4
+
5
+ ### Minor Changes
6
+
7
+ - Register aggregate CODE and CODE-REVIEW repairs atomically from all failed receipts, with stable replay identity and stage/attempt/cycle membership. Resolve declared contextual file paths independently of lifecycle authority, retain exact native command rejection causes, and emit shell-safe commands with explicit JSON recovery inputs. Keep historical Work out of current dispatch and block ambiguous legacy fanout execution.
8
+
9
+ ## 0.9.0-beta.88
10
+
11
+ ### Patch Changes
12
+
13
+ - 5f3806c: Keep managed HITL answer paths inside retained runtime authority, preserve early
14
+ native CLI failures, and fail controllers conclusively when an answer turn does
15
+ not resume its assigned pause.
16
+
3
17
  ## 0.9.0-beta.87
4
18
 
5
19
  ### Patch Changes
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.87",
4
- "cli_commit": "68245785d197ab491e80db40faf2e57a18a6a055",
5
- "built_at": "2026-09-20T22:46:39.592Z",
3
+ "cli_version": "0.9.0-beta.89",
4
+ "cli_commit": "e36aabd9160805831809ca27575600fb6db47511",
5
+ "built_at": "2026-09-21T08:53:05.446Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.1.1",
8
- "commit": "acaae844a871ddfbc49fd66355373f166dc3e683",
8
+ "commit": "d1a6081ab15ab92ac917ff5d037121a40c709db1",
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",
@@ -11,6 +11,9 @@ const scoped = "project-root"; // Optional when the router resolves a unique ret
11
11
  const lifecycle = "invocation-id hook-event-id";
12
12
  const worker = "worker-id";
13
13
  const control = "project-root run";
14
+ /** Path-shaped arguments default to physical paths unless the route explicitly
15
+ * opts into contextualPaths. This inventory does not expand arbitrary strings. */
16
+ export const physicalPathOptions = new Set(words("root project-root workspace-root workspace-path path dir file schema-dir output result-file semantic-file data decision-file verification-file reason-file task-file intake-file context-file observations-file observation-file summary-file question-file answer-file payload-file json-file task-profile-file execution-routing-file protocol-override-file manifest-file routing-file config-file input-file report-file snapshot-file output-dir state-dir capture-root capture-path destination source archive snapshot plan-file backup-path database backup source-home target-home stdout stderr adapter-executable memory-bank flow stage-report dashboard report"));
14
17
  export const commandInputs = {
15
18
  "version": route(0),
16
19
  "status": route(0, "", "root project-root", "check-registry"),
@@ -40,19 +43,19 @@ export const commandInputs = {
40
43
  "plan item block": route(2, "reason user-required", scoped),
41
44
  "plan item skip": route(2, "reason", scoped),
42
45
  "plan-review dispatch": route(1, "", scoped),
43
- "prompt render": route(0, `${project} run stage profile`, "workspace-root task-file plan-item"),
46
+ "prompt render": { ...route(0, `${project} run stage profile`, "workspace-root task-file plan-item"), contextualPaths: words("project-root workspace-root task-file") },
44
47
  "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
- "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") },
48
+ "stage finish": { ...route(1, `${project} stage`, `${lifecycle} dir semantic-file data result-file outcome decision-file verification-file retry-check reason reason-file request work`, "result-stdin"), contextualPaths: words("project-root semantic-file result-file decision-file verification-file reason-file") },
46
49
  "stage pause": { ...route(1, `${project} stage work`, `${lifecycle} question-file`, "question-stdin"), contextualPaths: words("project-root question-file") },
47
50
  "stage resume": { ...route(1, `${project} stage work`, `${lifecycle} answer-file`, "answer-stdin"), contextualPaths: words("project-root answer-file") },
48
51
  "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
52
  "stage unblock": route(1, `${project} stage work`),
50
53
  "stage fanout status": route(1, "stage", scoped),
51
54
  "stage fanout dispatch": route(1, "stage", scoped),
52
- "stage fanout reconcile": route(1, "stage observations-file", scoped),
53
- "stage native observe": route(1, "stage observations-file", scoped),
55
+ "stage fanout reconcile": { ...route(1, "stage observations-file", scoped), contextualPaths: words("project-root observations-file") },
56
+ "stage native observe": { ...route(1, "stage observations-file", scoped), contextualPaths: words("project-root observations-file") },
54
57
  "work status": route(1, project),
55
- "work add-batch": route(0, "parent file", scoped),
58
+ "work add-batch": { ...route(0, "parent file", scoped), contextualPaths: words("project-root file") },
56
59
  "work ls": route(0, "", `${scoped} run parent status limit`, "ready include-results", "", { status: ["created", "running", "paused", "completed", "failed", "cancelled"] }),
57
60
  "work show": route(1, "", scoped),
58
61
  "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") },
@@ -63,7 +66,7 @@ export const commandInputs = {
63
66
  "work delete": route(1, "", scoped),
64
67
  "work launch": route(1, "", `${scoped} stage`),
65
68
  "work start": { ...route(1, "", `${scoped} ${lifecycle} recovery-id`), contextualPaths: ["project-root"] },
66
- "work finish": { ...route(1, "", `${scoped} ${lifecycle} result-file retry-check reason`, "result-stdin"), contextualPaths: words("project-root result-file") },
69
+ "work finish": { ...route(1, "", `${scoped} ${lifecycle} result-file retry-check reason reason-file`, "result-stdin"), contextualPaths: words("project-root result-file reason-file") },
67
70
  "work fail": { ...route(1, "reason", `${scoped} ${lifecycle}`), contextualPaths: ["project-root"] },
68
71
  "work cancel": route(1, "reason", scoped),
69
72
  "work retry": route(1, "reason", scoped),
@@ -218,7 +221,7 @@ export const commandInputs = {
218
221
  "dashboard open": route(0, "", "project project-root protocol viewer format"),
219
222
  "dashboard refresh": route(0, "", "project project-root protocol open format output", "all"),
220
223
  "dashboard refresh-global": route(0, "", "output format"),
221
- "schema validate": route(0, "schema file", "schema-dir project-root run"),
224
+ "schema validate": { ...route(0, "schema file", "schema-dir project-root run"), contextualPaths: words("project-root file") },
222
225
  "memory permissions preflight": route(0, "root memory-bank flow mode", "tasks")
223
226
  };
224
227
  export function contextualPathOptionsForLifecycle(operation) {
@@ -286,14 +289,14 @@ export function prepareCommandRoute(args) {
286
289
  ["payload-file", "payload-json", "payload-base64"], ["semantic-file", "data", "result-file", "result-stdin"],
287
290
  ["stage-report", "dashboard"], ["task-file", "plan-item"], ["project", "project-root"],
288
291
  ["intake-file", "intake-stdin", "context-file"], ["question-file", "question-stdin"], ["answer-file", "answer-stdin"],
289
- ["summary-file", "summary-stdin"], ["task-file", "task-stdin"]
292
+ ["summary-file", "summary-stdin"], ["task-file", "task-stdin"], ["reason", "reason-file"]
290
293
  ]) {
291
294
  const present = aliases.filter(name => parsed.options.has(name));
292
295
  if (present.length > 1)
293
296
  reject(`Use only one of ${present.map(name => `--${name}`).join(", ")}`);
294
297
  }
295
- if (contract.values.includes("retry-check") && parsed.options.has("retry-check") !== parsed.options.has("reason"))
296
- reject("--retry-check and --reason must be supplied together");
298
+ if (contract.values.includes("retry-check") && parsed.options.has("retry-check") !== (parsed.options.has("reason") || parsed.options.has("reason-file")))
299
+ reject("--retry-check requires exactly one of --reason or --reason-file");
297
300
  // Retain the dispatcher's historic offsets for nested action positions.
298
301
  const family = parsed.positional[0];
299
302
  const singleWord = ["status", "version", "transition"].includes(family);
package/dist/cli/help.js CHANGED
@@ -233,6 +233,10 @@ Usage:
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
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
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
+ Path aliases @project, @workspace and @run are supported only in declared CLI path parameters and require a uniquely bound context. File-writing tools use physical paths. Both --option value and --option=value are accepted; quoted or escaped aliases are normalized identically. Pre-RUN bootstrap paths and external schema directories require physical paths.
239
+ An aggregate CODE/CODE-REVIEW failure may return continuation.kind=repair_required: its repair Work is already registered. End the coordinator turn and let the controller dispatch the fresh child; do not recreate the repair. A recovery retry uses --retry-check <receipt> --reason-file <recovery.json>, containing only {"reason":"actual environment recovery evidence"}. Legacy --reason remains accepted, but cannot be combined with --reason-file. A needs_input continuation is not yet executable: write its requested file first, then use command_after_input.
236
240
  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
241
  dd-flow stage unblock <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --work <WORK-ID> --json
238
242
  dd-flow stage fanout status <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --json
@@ -1,6 +1,6 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import { performance } from "node:perf_hooks";
3
- import { parseCommandArgs, parseLifecycleCommand, lifecycleCommandFromPayload } from "../services/lifecycle-command.js";
3
+ import { parseCommandArgs, parseLifecycleCommand, lifecycleCommandFromPayload, continuationCommand } from "../services/lifecycle-command.js";
4
4
  import { errorRecord, AppError } from "../shared/errors.js";
5
5
  import { parseJsonObject } from "../shared/json.js";
6
6
  const workerFile = fileURLToPath(import.meta.url);
@@ -28,7 +28,7 @@ export async function runHookIngress(args) {
28
28
  }
29
29
  const payload = parseJsonObject(stdin || "{}", "native hook stdin");
30
30
  const command = lifecycleCommandFromPayload(payload);
31
- if (event !== "PreToolUse" || !command || parseLifecycleCommand(command).kind === "none") {
31
+ if (event !== "PreToolUse" || !command || (parseLifecycleCommand(command).kind === "none" && !continuationCommand(command))) {
32
32
  process.stdout.write("{}\n");
33
33
  return 0;
34
34
  }
@@ -1,8 +1,10 @@
1
1
  import fs from "node:fs";
2
+ import { resolveCommandPaths, resolveCommandProjectRoot } from "../services/command-context.js";
3
+ import { assertAuxiliaryCommandScope } from "../services/lifecycle-invocations.js";
2
4
  import path from "node:path";
3
5
  import { randomUUID } from "node:crypto";
4
6
  import { quote } from "shell-quote";
5
- import { observeLifecycleCommand } from "../services/lifecycle-invocations.js";
7
+ import { observeLifecycleCommand, observedLifecycleNativeEvent } from "../services/lifecycle-invocations.js";
6
8
  import { verifyCodexHookDelivery } from "../services/codex-hook-delivery.js";
7
9
  import { assertLifecycleInvocationCurrent, awaitLifecycleInvocation, expandLifecycleInvocationArgs, issueLifecycleInvocation, prepareLifecycleIssuance, lifecycleInvocationScope, observedLifecycleDiagnostic, observedLifecycleInvocation, resolveObservedLifecycleArgs, settleLifecycleInvocation, settleLifecyclePreparationRejection, settleLifecycleRejection, validateLifecyclePathAliases } from "../services/lifecycle-invocations.js";
8
10
  import { createContext, createRouterContext } from "../runtime/context.js";
@@ -132,12 +134,13 @@ export async function runCli(args, io = defaultIo, env = process.env) {
132
134
  const lifecycleCommand = parseLifecycleCommand(publicCommand);
133
135
  if (lifecycleCommand.kind === "standalone")
134
136
  validateLifecyclePathAliases(output.args, lifecycleCommand.invocation.operation);
135
- const observedInvocation = observedLifecycleInvocation(routerContext, publicCommand);
137
+ const observedInvocation = observedLifecycleInvocation(routerContext, publicCommand, env.DD_FLOW_DAEMON_ID);
136
138
  if (!observedInvocation && !suppliedInvocationId && env.DD_FLOW_DAEMON_ID && lifecycleCommand.kind === "standalone") {
137
139
  const diagnostic = observedLifecycleDiagnostic(routerContext, publicCommand, env.DD_FLOW_DAEMON_ID);
138
140
  if (diagnostic)
139
141
  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
- throw new AppError("invocation_receipt_missing", "Managed lifecycle command has no committed native receipt", 1, { effect: "no_effect", recoverable: false });
142
+ const nativeEvent = observedLifecycleNativeEvent(routerContext, publicCommand, env.DD_FLOW_DAEMON_ID);
143
+ 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 });
141
144
  }
142
145
  const invocationId = observedInvocation?.id ?? suppliedInvocationId;
143
146
  let boundInvocationArgs;
@@ -170,7 +173,7 @@ export async function runCli(args, io = defaultIo, env = process.env) {
170
173
  const retainedOutput = observedInvocation ? parseOutputOptions(expandedInvocationArgs) : null;
171
174
  if (retainedOutput?.responseFile)
172
175
  output.responseFile = retainedOutput.responseFile;
173
- const invocationArgs = retainedOutput?.args ?? expandedInvocationArgs;
176
+ let invocationArgs = retainedOutput?.args ?? expandedInvocationArgs;
174
177
  emitHumanProgress(io, output, progress, "start");
175
178
  if (output.progressJsonl) {
176
179
  writeProgressJsonl(io, "start", `Starting ${output.args.slice(0, 3).join(" ")}`);
@@ -178,7 +181,9 @@ export async function runCli(args, io = defaultIo, env = process.env) {
178
181
  }
179
182
  // Route by retained authority, so a mistyped project path reaches the CLI
180
183
  // validator instead of selecting a different engine or failing preflight.
181
- const scopeProjectRoot = invocationScope?.projectRoot ?? resolveProjectRootBeforeRouting(output.args, env);
184
+ const scopeProjectRoot = invocationScope?.projectRoot ?? resolveCommandProjectRoot(routerContext, output.args, resolveProjectRootBeforeRouting(output.args, env));
185
+ assertAuxiliaryCommandScope({ ...routerContext, env }, publicCommand, scopeProjectRoot);
186
+ invocationArgs = resolveCommandPaths(routerContext, invocationArgs, scopeProjectRoot);
182
187
  const routedArgs = output.responseFile ? [...invocationArgs, "--response-file", output.responseFile] : invocationArgs;
183
188
  const routed = await routeArgsThroughEngine(routerContext, output.json ? [...routedArgs, "--json", ...(output.progressJsonl ? ["--progress-jsonl"] : [])] : routedArgs, io, io.stdin, env, scopeProjectRoot);
184
189
  if (routed?.routed && !routed.inProcess) {
@@ -236,8 +241,11 @@ export async function runCli(args, io = defaultIo, env = process.env) {
236
241
  preparedInput = await prepareCliInput(invocationArgs, dispatchContext, io, scopeProjectRoot, output.responseFile);
237
242
  if (!retainedOutcome)
238
243
  preflightCliCompatibility(dispatchContext, invocationArgs, env, scopeProjectRoot);
239
- if (!retainedOutcome)
240
- settleHook = observeLifecycleCommand(dispatchContext, quote(["dd-flow", ...dispatchArgs]));
244
+ if (!retainedOutcome) {
245
+ const command = lifecycleCommand.kind === "none" ? publicCommand : quote(["dd-flow", ...dispatchArgs]);
246
+ const event = lifecycleCommand.kind === "none" && env.DD_FLOW_DAEMON_ID ? observedLifecycleNativeEvent(dispatchContext, command, env.DD_FLOW_DAEMON_ID) : null;
247
+ settleHook = observeLifecycleCommand(dispatchContext, command, event ?? undefined);
248
+ }
241
249
  const result = retainedOutcome ? retainedOutcome.result : await dispatch(dispatchArgs, dispatchContext, io, scopeProjectRoot, classification, output, preparedInput);
242
250
  const completedHook = settleHook;
243
251
  settleHook = undefined;
@@ -266,6 +274,23 @@ export async function runCli(args, io = defaultIo, env = process.env) {
266
274
  }
267
275
  catch (caught) {
268
276
  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 } });
277
+ if (!settleHook && !invocationSettlement && env.DD_FLOW_DAEMON_ID) {
278
+ let earlyContext;
279
+ try {
280
+ const command = quote(["dd-flow", ...output.args]);
281
+ const event = observedLifecycleNativeEvent(createRouterContext(env), command, env.DD_FLOW_DAEMON_ID);
282
+ if (event) {
283
+ earlyContext = createContext({ ...env }, "hook");
284
+ observeLifecycleCommand(earlyContext, command, event)({ error });
285
+ }
286
+ }
287
+ catch (persistence) {
288
+ error.details.lifecycle_diagnostic_error = String(persistence);
289
+ }
290
+ finally {
291
+ earlyContext?.db.close?.();
292
+ }
293
+ }
269
294
  if (diagnosticHome && (!isAppError(caught) || error.code === "storage_write_failed")) {
270
295
  try {
271
296
  const directory = path.join(diagnosticHome, "diagnostics", "cli");
@@ -746,6 +771,14 @@ async function prepareCliInput(...args) {
746
771
  async function prepareCliInputValue(args, context, io, scopeProjectRoot, responseFile = null, route = prepareCommandRoute(args)) {
747
772
  const { family, command, parsed } = route;
748
773
  const prepared = { route };
774
+ if (optionalOption(parsed, "reason-file")) {
775
+ const value = prepareJsonFile(requiredOption(parsed, "reason-file"), "Check recovery evidence").value;
776
+ if (!value || typeof value.reason !== "string" || !value.reason.trim() || Object.keys(value).some(key => key !== "reason"))
777
+ throw new AppError("validation", "Recovery evidence must be a JSON object with one nonempty reason", 2);
778
+ prepared.retryReason = value.reason;
779
+ }
780
+ else if (optionalOption(parsed, "retry-check"))
781
+ prepared.retryReason = optionalOption(parsed, "reason");
749
782
  if (route.key === "project register")
750
783
  resolveProjectRoot(requiredOption(parsed, "root"));
751
784
  if (route.key === "project archive")
@@ -1182,7 +1215,7 @@ async function prepareCliInputValue(args, context, io, scopeProjectRoot, respons
1182
1215
  }
1183
1216
  }
1184
1217
  if (family === "work" && command === "finish") {
1185
- const retryReceiptId = optionalOption(parsed, "retry-check"), retryReason = optionalOption(parsed, "reason");
1218
+ const retryReceiptId = optionalOption(parsed, "retry-check"), retryReason = prepared.retryReason;
1186
1219
  if (Boolean(retryReceiptId) !== Boolean(retryReason))
1187
1220
  throw new AppError("usage", "--retry-check and --reason must be supplied together", 2);
1188
1221
  const resultFile = optionalOption(parsed, "result-file");
@@ -1207,8 +1240,8 @@ async function prepareCliInputValue(args, context, io, scopeProjectRoot, respons
1207
1240
  throw new AppError("usage", "Stage status belongs in the semantic input file", 2);
1208
1241
  if (["result-file", "semantic-file", "data", "result-stdin"].filter(key => hasOption(parsed, key)).length > 1)
1209
1242
  throw new AppError("usage", "Use exactly one result input: --result-file, --semantic-file, --data or --result-stdin", 2);
1210
- if (Boolean(optionalOption(parsed, "retry-check")) !== Boolean(optionalOption(parsed, "reason")))
1211
- throw new AppError("usage", "--retry-check and --reason must be supplied together", 2);
1243
+ if (Boolean(optionalOption(parsed, "retry-check")) !== Boolean(prepared.retryReason))
1244
+ throw new AppError("usage", "--retry-check requires recovery evidence", 2);
1212
1245
  const resultStdin = hasOption(parsed, "result-stdin");
1213
1246
  const semanticFile = optionalOption(parsed, "semantic-file") ?? optionalOption(parsed, "data");
1214
1247
  const resultFile = optionalOption(parsed, "result-file") ?? semanticFile;
@@ -1226,8 +1259,8 @@ async function prepareCliInputValue(args, context, io, scopeProjectRoot, respons
1226
1259
  };
1227
1260
  const acceptStageInputs = (allowed) => {
1228
1261
  const options = allowed.split(" ");
1229
- for (const name of ["dir", "semantic-file", "data", "result-file", "result-stdin", "outcome", "decision-file", "verification-file", "retry-check", "reason", "request", "work"]) {
1230
- if (hasOption(parsed, name) && !options.includes(name))
1262
+ for (const name of ["dir", "semantic-file", "data", "result-file", "result-stdin", "outcome", "decision-file", "verification-file", "retry-check", "reason", "reason-file", "request", "work"]) {
1263
+ if (hasOption(parsed, name) && !options.includes(name === "reason-file" ? "reason" : name))
1231
1264
  throw new AppError("usage", `stage finish ${stage} does not accept --${name} for this flow`, 2, { parameter: name });
1232
1265
  }
1233
1266
  };
@@ -1284,7 +1317,7 @@ async function prepareCliInputValue(args, context, io, scopeProjectRoot, respons
1284
1317
  requiredOption(parsed, "request");
1285
1318
  requiredOption(parsed, "work");
1286
1319
  try {
1287
- prepared.mergeFinish = prepareVnextMergeFinish(context, { projectRoot, runId, requestId: requiredOption(parsed, "request"), workId: requiredOption(parsed, "work"), ...(optionalOption(parsed, "retry-check") ? { retryCheckId: optionalOption(parsed, "retry-check"), retryReason: optionalOption(parsed, "reason") } : {}) });
1320
+ prepared.mergeFinish = prepareVnextMergeFinish(context, { projectRoot, runId, requestId: requiredOption(parsed, "request"), workId: requiredOption(parsed, "work"), ...(optionalOption(parsed, "retry-check") ? { retryCheckId: optionalOption(parsed, "retry-check"), retryReason: prepared.retryReason } : {}) });
1288
1321
  }
1289
1322
  catch (error) {
1290
1323
  return prepareError(error, { parameter: "MERGE finish" });
@@ -1556,7 +1589,7 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
1556
1589
  const workId = requiredPosition(parsed, 0, "work-id");
1557
1590
  const projectRoot = requiredScopeProjectRoot(scopeProjectRoot, "work finish");
1558
1591
  assertWorkLifecycleOwner(context, { workId, projectRoot, operation: "work_finish", ...(optionalOption(parsed, "hook-event-id") ? { hookEventId: optionalOption(parsed, "hook-event-id") } : {}) });
1559
- const retryReceiptId = optionalOption(parsed, "retry-check"), retryReason = optionalOption(parsed, "reason");
1592
+ const retryReceiptId = optionalOption(parsed, "retry-check"), retryReason = prepared.retryReason;
1560
1593
  if (Boolean(retryReceiptId) !== Boolean(retryReason))
1561
1594
  throw new AppError("usage", "--retry-check and --reason must be supplied together", 2);
1562
1595
  const result = prepared.workFinish?.result ?? (resultFile ? prepareTextFile(resultFile, "Work result").text : await readStdin(io.stdin));
@@ -1680,7 +1713,7 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
1680
1713
  const resultFile = optionalOption(parsed, "result-file") ?? semanticFile;
1681
1714
  const resultStdin = hasOption(parsed, "result-stdin");
1682
1715
  const retryCheckId = optionalOption(parsed, "retry-check");
1683
- const retryReason = optionalOption(parsed, "reason");
1716
+ const retryReason = prepared.retryReason;
1684
1717
  const hookEventId = optionalOption(parsed, "hook-event-id");
1685
1718
  if (retryCheckId && !retryReason?.trim())
1686
1719
  throw new AppError("usage", "--retry-check requires --reason explaining the recovered environment", 2);
@@ -1,14 +1,14 @@
1
1
  import path from "node:path";
2
2
  import { callDaemon } from "../harness-runtime/lib/dd-codex-daemon.mjs";
3
3
  import { AppError } from "../shared/errors.js";
4
- import { commandOption, lifecycleCommandFromPayload, parseLifecycleCommand } from "./lifecycle-command.js";
4
+ import { commandOption, lifecycleCommandFromPayload, parseLifecycleCommand, continuationCommand } from "./lifecycle-command.js";
5
5
  /** The hook asks the existing daemon about native evidence; it cannot mint it. */
6
6
  export async function verifyCodexHookDelivery(env, payload) {
7
7
  const command = lifecycleCommandFromPayload(payload);
8
8
  const parsed = command ? parseLifecycleCommand(command) : { kind: "none" };
9
- if (parsed.kind === "none")
9
+ if (parsed.kind === "none" && !(command && continuationCommand(command)))
10
10
  return undefined;
11
- const managed = commandOption(parsed.invocation, "invocation-id") || env.DD_FLOW_DAEMON_ID;
11
+ const managed = (parsed.kind !== "none" && commandOption(parsed.invocation, "invocation-id")) || env.DD_FLOW_DAEMON_ID;
12
12
  if (!managed)
13
13
  return undefined; // Explicit legacy/unmanaged transport.
14
14
  if (!env.DD_FLOW_CODEX_STATE_DIR || !env.DD_FLOW_DAEMON_ID)
@@ -0,0 +1,80 @@
1
+ import path from "node:path";
2
+ import { commandInputs, physicalPathOptions } from "../cli/command-inputs.js";
3
+ import { parseCommandArgs } from "./lifecycle-command.js";
4
+ import { assertPathWithin, canonicalPath } from "../storage/paths.js";
5
+ import { AppError } from "../shared/errors.js";
6
+ import { resolveWorkReference } from "../storage/work-references.js";
7
+ export function normalizedPathAlias(value) {
8
+ return value.replace(/^\\+(?=@(?:project|workspace|run)(?:\/|$))/, "");
9
+ }
10
+ export function resolveContextualPaths(args, options, roots) {
11
+ const resolve = (option, original) => {
12
+ if (!options.has(option))
13
+ return original;
14
+ const value = normalizedPathAlias(original);
15
+ if (!value.startsWith("@"))
16
+ return value;
17
+ const match = /^@(project|workspace|run)(?:\/(.*))?$/.exec(value);
18
+ if (!match || !roots[match[1]])
19
+ throw new AppError("usage", `Unknown or unavailable path alias: ${value}`, 2, { phase: "prepare", effect: "no_effect", recoverable: true, parameter: option });
20
+ const root = roots[match[1]];
21
+ return assertPathWithin(root, path.join(root, match[2] ?? ""), `${match[1]}_alias`);
22
+ };
23
+ return args.map((value, index) => {
24
+ const equal = /^--([^=]+)=(.*)$/s.exec(value);
25
+ if (equal)
26
+ return `--${equal[1]}=${resolve(equal[1], equal[2])}`;
27
+ const option = args[index - 1]?.match(/^--([^=]+)$/)?.[1];
28
+ return option ? resolve(option, value) : value;
29
+ });
30
+ }
31
+ /** Contextual paths are route semantics, not lifecycle invocation authority. */
32
+ export function resolveCommandProjectRoot(context, args, explicit) {
33
+ if (explicit && !normalizedPathAlias(explicit).startsWith("@"))
34
+ return canonicalPath(explicit, false);
35
+ if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'runs'"))
36
+ return null;
37
+ const parsed = parseCommandArgs(args);
38
+ const key = Object.keys(commandInputs).filter(key => key.split(" ").every((part, i) => parsed.positional[i] === part)).sort((a, b) => b.length - a.length)[0];
39
+ const reference = parsed.options.get("run")?.[0] ?? (key?.startsWith("stage ") ? parsed.positional[key.split(" ").length] : undefined);
40
+ if (reference) {
41
+ const rows = context.db.all("SELECT project_root FROM runs WHERE id = ? OR short_id = ?", [reference, reference]);
42
+ if (rows.length === 1)
43
+ return rows[0].project_root;
44
+ if (rows.length > 1)
45
+ throw new AppError("usage", "RUN reference requires an explicit project scope", 2, { phase: "prepare", effect: "no_effect" });
46
+ }
47
+ const work = parsed.options.get("parent")?.[0] ?? (key?.startsWith("work ") && !key.startsWith("work repair") ? parsed.positional[key.split(" ").length] : undefined);
48
+ if (work) {
49
+ const resolved = resolveWorkReference(context, work);
50
+ return context.db.get("SELECT root FROM projects WHERE id = ?", [resolved.project_id])?.root ?? null;
51
+ }
52
+ return null;
53
+ }
54
+ export function resolveCommandPaths(context, args, projectRoot) {
55
+ const parsed = parseCommandArgs(args);
56
+ const key = Object.keys(commandInputs).filter(key => key.split(" ").every((part, i) => parsed.positional[i] === part)).sort((a, b) => b.length - a.length)[0];
57
+ const options = new Set(key ? commandInputs[key]?.contextualPaths ?? [] : []);
58
+ if (!args.some(value => /(?:^|=)\\*@/.test(value)))
59
+ return args;
60
+ for (const [option, values] of parsed.options) {
61
+ if (physicalPathOptions.has(option) && !options.has(option) && values.some(value => normalizedPathAlias(value).startsWith("@")))
62
+ throw new AppError("usage", `--${option} requires a physical path on this route`, 2, { phase: "prepare", effect: "no_effect", recoverable: true, parameter: option });
63
+ }
64
+ const project = projectRoot ? context.db.get("SELECT id FROM projects WHERE root = ?", [projectRoot]) : undefined;
65
+ const reference = parsed.options.get("run")?.[0] ?? (key?.startsWith("stage ") ? parsed.positional[key.split(" ").length] : undefined);
66
+ let run;
67
+ if (reference && project) {
68
+ const rows = context.db.all("SELECT id, workspace_root, run_root FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [project.id, reference, reference]);
69
+ if (rows.length !== 1)
70
+ throw new AppError("usage", "Path aliases require one RUN in the selected project", 2, { phase: "prepare", effect: "no_effect" });
71
+ run = rows[0];
72
+ }
73
+ const work = parsed.options.get("parent")?.[0] ?? (key?.startsWith("work ") && !key.startsWith("work repair") ? parsed.positional[key.split(" ").length] : undefined);
74
+ if (work && project) {
75
+ const resolved = resolveWorkReference(context, work, { projectId: project.id, ...(run ? { runId: run.id } : {}) });
76
+ if (!run)
77
+ run = context.db.get("SELECT id, workspace_root, run_root FROM runs WHERE project_id = ? AND id = ?", [project.id, resolved.run_id]);
78
+ }
79
+ return resolveContextualPaths(args, options, { project: projectRoot, workspace: run?.workspace_root, run: run?.run_root });
80
+ }
@@ -0,0 +1,20 @@
1
+ import { AppError } from "../shared/errors.js";
2
+ import { isRegisteredRepair } from "./repair-intents.js";
3
+ import { stageAttempt } from "./stage-work-graph.js";
4
+ /** Only the settled coordinator turn can explain a repeated continuation.
5
+ * A stale error from another stage/turn or an error followed by success is not
6
+ * authority. Never inspect the assistant's prose for this decision. */
7
+ export function assertNoCorrelatedRejection(context, scope) {
8
+ const rows = context.db.all(`SELECT outcome_json FROM hook_events
9
+ WHERE project_id = ? AND json_extract(outcome_json, '$.run_id') = ?
10
+ AND json_extract(outcome_json, '$.generation') = ? AND json_extract(outcome_json, '$.stage') = ?
11
+ AND json_extract(outcome_json, '$.controller_turn') = ? AND json_extract(outcome_json, '$.attempt') = ? ORDER BY id`, [scope.projectId, scope.runId, scope.generation, scope.stage, scope.turn, stageAttempt(context, scope.projectId, scope.runId, scope.stage)]);
12
+ const latest = new Map();
13
+ for (const row of rows) {
14
+ const value = JSON.parse(row.outcome_json);
15
+ latest.set(value.operation, value);
16
+ }
17
+ for (const value of latest.values())
18
+ if (value.error && !isRegisteredRepair(context, value.error.details.continuation, scope))
19
+ throw new AppError(value.error.code, value.error.message, 1, { ...value.error.details, operation: value.operation, hook_event_id: value.hook_event_id, continuation_scope: scope });
20
+ }
@@ -28,6 +28,8 @@ export async function nextControllerFanout(context, input) {
28
28
  let fanout = getVnextFanoutStatus(context, input).orchestration;
29
29
  if (!fanout)
30
30
  return { kind: "none" };
31
+ if (fanout.state === "compatibility_required")
32
+ throw new AppError("stage_graph_compatibility_required", "The retained graph cannot be dispatched by this controller; use the RUN's pinned engine", 1, { stage: input.stage, run_id: input.runId, graph: fanout });
31
33
  if (children.length) {
32
34
  const reconciled = await reconcileVnextFanout(context, { ...input, observations });
33
35
  const issues = reconciled.issues.filter(issue => issue.code !== "child_check_in_progress");
@@ -43,7 +45,7 @@ export async function nextControllerFanout(context, input) {
43
45
  if (fanout.state === "dispatch_required" || fanout.state === "capacity_required")
44
46
  return { kind: "wait", reason: "qualified_capacity_required" };
45
47
  }
46
- const prompt = (text) => ({ kind: "prompt", prompt: text, fingerprint: JSON.stringify({ stage: input.stage, parent: fanout.parent_work_id, works: fanout.works, text }) });
48
+ const prompt = (text) => ({ kind: "prompt", prompt: text, fingerprint: JSON.stringify({ stage: input.stage, parent: fanout.parent_work_id, state: fanout.state, graph: fanout.graph_revision }) });
47
49
  if (fanout.state === "coordinator_required")
48
50
  return prompt(`Continue the current ${input.stage} Stage using its authoritative packet. Materialize only its required Work graph with real dependencies. Do not launch children, finish the Stage, or start a successor. Return after registering the graph; the runtime will dispatch its ready Work.`);
49
51
  if (fanout.works.failed || fanout.works.cancelled)
@@ -73,7 +75,7 @@ export async function nextControllerFanout(context, input) {
73
75
  return { kind: "wait", reason: "work_receipts_pending" };
74
76
  if (fanout.works.created)
75
77
  throw new AppError("fanout_graph_stalled", "Created Work has no ready or running dependency path", 1);
76
- return prompt(`All declared ${input.stage} child Work has settled (${fanout.works.completed} completed). Read the accepted result receipts and continue this Stage's semantic decision under its original packet. A rejected stage finish may return either a repair creation command or an already-registered repair Work. Execute only a returned repair creation command, then return. If it reports repair_required with an existing repair Work/start_command, do not invoke that start_command in this coordinator Session: return immediately so the controller can launch a fresh child. Do not start a successor Stage.`);
78
+ return prompt(`All declared ${input.stage} child Work has settled (${fanout.works.completed} completed). Read the accepted result receipts and continue this Stage's semantic decision under its original packet. A rejected stage finish with repair_required has already registered its repair Work. End this Turn immediately so the controller can launch a fresh child; do not create another repair or invoke its start_command in this coordinator Session. Do not start a successor Stage.`);
77
79
  }
78
80
  /** Normalize provider-observed topology only; never parse assistant prose. */
79
81
  export function controllerNativeChildren(receipt, rootSessionId) {
@@ -12,7 +12,7 @@ import { prepareTextFile, prepareJsonText, prepareError } from "../cli/input-pre
12
12
  import { nativeSessionIdentity, storageSessionId } from "./session-identity.js";
13
13
  import { assertRecoveryHookCurrent } from "./run-recovery.js";
14
14
  import { observeLifecycleInvocation } from "./lifecycle-invocations.js";
15
- import { commandHasOption, commandOption, commandPosition, shellSuffixOffset, parseLifecycleCommand, lifecycleCommandFromPayload } from "./lifecycle-command.js";
15
+ import { commandHasOption, commandOption, commandPosition, shellSuffixOffset, parseLifecycleCommand, lifecycleCommandFromPayload, continuationCommand } from "./lifecycle-command.js";
16
16
  import { registerProject, requireProjectByRoot } from "./projects.js";
17
17
  import { activeFlowSessionsForProject, recordFlowSessionObservation } from "./sessions.js";
18
18
  const defaultProfileName = "default";
@@ -309,13 +309,13 @@ export function handleCodexHook(context, input) {
309
309
  if (!command)
310
310
  return { ok: true, observed: false, reason: "non_bash_tool" };
311
311
  const lifecycle = lifecycleFacts(command);
312
- if (!lifecycle)
312
+ if (!lifecycle && !continuationCommand(command))
313
313
  return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
314
314
  const parentProviderSessionId = rawChildProviderSessionId ? codexNativeParentId(payload, rawChildProviderSessionId) : null;
315
315
  // Resume legitimately receives an answer through stdin. It is matched by
316
316
  // immutable lifecycle arguments below; never reject or rewrite that pipe.
317
- const { bootstrapStageStart } = lifecycle;
318
- const invocationId = commandOption(lifecycle.invocation, "invocation-id");
317
+ const bootstrapStageStart = lifecycle?.bootstrapStageStart;
318
+ const invocationId = lifecycle ? commandOption(lifecycle.invocation, "invocation-id") : undefined;
319
319
  if (invocationId || context.env.DD_FLOW_DAEMON_ID) {
320
320
  const proof = context.codexHookProof;
321
321
  if (!proof || proof.daemonId !== context.env.DD_FLOW_DAEMON_ID || proof.rootSessionId !== rootProviderSessionId
@@ -337,7 +337,7 @@ export function handleCodexHook(context, input) {
337
337
  throw new AppError("project_not_registered", "Managed lifecycle invocation has no registered project", 1, { project_root: nativeProjectRoot ?? null });
338
338
  return { ok: true, observed: false, reason: "unrelated_cwd" };
339
339
  }
340
- let matchKey = lifecycleMatchKey(lifecycle, project.root);
340
+ let matchKey = lifecycle ? lifecycleMatchKey(lifecycle, project.root) : null;
341
341
  // Codex retains the root session_id for a thread-spawned child, while the
342
342
  // payload transcript belongs to the current agent_id. Keep lineage separate
343
343
  // from ownership: otherwise a child overwrites the root transcript binding.
@@ -396,6 +396,10 @@ export function handleCodexHook(context, input) {
396
396
  ...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("codex-desktop", parentProviderSessionId) } : {}),
397
397
  protocol_id: null
398
398
  };
399
+ if (!lifecycle) {
400
+ const persisted = context.db.writeTransaction(() => persist());
401
+ return { ...common, observed: persisted.inserted, duplicate: !persisted.inserted };
402
+ }
399
403
  if (invocationId || context.env.DD_FLOW_DAEMON_ID) {
400
404
  const daemonId = stringValue(context.env.DD_FLOW_DAEMON_ID);
401
405
  const toolCallId = stringValue(payload.tool_use_id) ?? stringValue(payload.event_id) ?? stringValue(payload.delivery_id) ?? stringValue(payload.id);
@@ -517,9 +521,9 @@ function recordZcodeLifecycle(context, input, invocationId) {
517
521
  if (!command || (toolName && toolName !== "Bash" && toolName !== "bash" && toolName !== "execute"))
518
522
  return { ok: true, observed: false, reason: "non_bash_tool" };
519
523
  const lifecycle = lifecycleFacts(command);
520
- if (!lifecycle)
524
+ if (!lifecycle && !continuationCommand(command))
521
525
  return { ok: true, observed: false, reason: "event_not_participating" };
522
- if (!invocationId && commandOption(lifecycle.invocation, "invocation-id"))
526
+ if (!invocationId && lifecycle && commandOption(lifecycle.invocation, "invocation-id"))
523
527
  return { ok: true, observed: false, reason: "cli_invocation_owns_admission" };
524
528
  const expectedRoot = resolveProjectRoot(input.projectRoot);
525
529
  const meta = objectRecord(hook._meta);
@@ -539,13 +543,13 @@ function recordZcodeLifecycle(context, input, invocationId) {
539
543
  throw new AppError("zcode_hook_event_missing", "ZCode native hook has no tool-use ID", 1);
540
544
  const eventKey = crypto.createHash("sha256").update(JSON.stringify(["zcode-acp", daemonId, providerSessionId, toolUseId])).digest("hex");
541
545
  const rewritten = invocationId ? command : commandWithHookEvent(command, eventKey);
542
- if (lifecycle.bootstrapStageStart)
546
+ if (lifecycle?.bootstrapStageStart)
543
547
  registerProject(context, { root: expectedRoot });
544
548
  const project = requireProjectByRoot(context, expectedRoot);
545
549
  const sessionId = storageSessionId(nativeSessionIdentity("zcode-acp", providerSessionId));
546
550
  const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", parentProviderSessionId)) : null;
547
551
  const observedProfile = objectRecord(ddZcode.observedProfile);
548
- let matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
552
+ let matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
549
553
  const eventNameForReceipt = invocationId ? "ToolCallObserved" : "PreToolUse";
550
554
  const persist = (scope) => {
551
555
  if (scope)
@@ -563,6 +567,8 @@ function recordZcodeLifecycle(context, input, invocationId) {
563
567
  const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, harness: "zcode-acp", session: nativeSessionIdentity("zcode-acp", providerSessionId), ...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("zcode-acp", parentProviderSessionId) } : {}), daemon_id: daemonId };
564
568
  return { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: rewritten } } };
565
569
  };
570
+ if (!lifecycle)
571
+ return context.db.writeTransaction(() => persist());
566
572
  let persistedResult;
567
573
  const receipt = observeLifecycleInvocation(context, {
568
574
  ...(invocationId ? { id: invocationId } : {}), command,
@@ -592,11 +598,11 @@ export function handleGrokEvent(context, input) {
592
598
  return { ok: true, observed: false, reason: "non_bash_tool" };
593
599
  }
594
600
  const lifecycle = lifecycleFacts(command);
595
- if (!lifecycle)
601
+ if (!lifecycle && !continuationCommand(command))
596
602
  return { ok: true, observed: false, reason: "event_not_participating" };
597
- const { bootstrapStageStart } = lifecycle;
603
+ const bootstrapStageStart = lifecycle?.bootstrapStageStart;
598
604
  const expectedRoot = resolveProjectRoot(input.projectRoot);
599
- const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
605
+ const matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
600
606
  if (bootstrapStageStart)
601
607
  registerProject(context, { root: expectedRoot });
602
608
  const project = requireProjectByRoot(context, expectedRoot);
@@ -692,10 +698,10 @@ function handleControlledToolEvent(context, input, event, harness) {
692
698
  return { ok: true, observed: false, reason: command ? "non_bash_tool" : "event_not_participating" };
693
699
  }
694
700
  const lifecycle = lifecycleFacts(command);
695
- if (!lifecycle)
701
+ if (!lifecycle && !continuationCommand(command))
696
702
  return { ok: true, observed: false, reason: "event_not_participating" };
697
- const { bootstrapStageStart } = lifecycle;
698
- const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
703
+ const bootstrapStageStart = lifecycle?.bootstrapStageStart;
704
+ const matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
699
705
  if (bootstrapStageStart)
700
706
  registerProject(context, { root: expectedRoot });
701
707
  const project = requireProjectByRoot(context, expectedRoot);
@@ -12,6 +12,33 @@ const redirectionOperators = new Set(["<", ">", ">>", ">|", "<&", ">&", "<<<"]);
12
12
  export function parseLifecycleCommand(command) {
13
13
  return parseLifecycleCommandInner(command, false);
14
14
  }
15
+ /** Exact diagnostic routes; these deliberately confer no lifecycle authority. */
16
+ export function continuationCommand(command) {
17
+ let entries;
18
+ try {
19
+ entries = parse(command, name => `$${name}`);
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ if (!entries.every(entry => typeof entry === "string"))
25
+ return null;
26
+ const words = entries;
27
+ const start = words.findIndex(word => ["stage", "work"].includes(word));
28
+ if (start < 1)
29
+ return null;
30
+ const launcher = words.slice(0, start);
31
+ while (launcher[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(launcher[0]))
32
+ launcher.shift();
33
+ const direct = launcher.length === 1 && (path.basename(launcher[0]) === "dd-flow" || launcher[0] === "$DD_FLOW_BIN");
34
+ const node = launcher.length === 2 && path.basename(launcher[0]) === "node" && (path.basename(launcher[1]) === "cli.js" || launcher[1] === "$DD_FLOW_BIN");
35
+ if (!direct && !node)
36
+ return null;
37
+ const args = parseCommandArgs(words.slice(start));
38
+ const route = args.positional.slice(0, 3).join(" ");
39
+ const operation = route === "work repair add" ? "work_repair_add" : args.positional[0] === "stage" && args.positional[1] === "block" ? "stage_block" : null;
40
+ return operation ? { operation, args } : null;
41
+ }
15
42
  /** Preserve the historical Desktop contract: execute the payload, not its one-command shell wrapper. */
16
43
  export function unwrapShellCommand(command) {
17
44
  let entries;