@deksden-com/dd-flow-cli 0.9.0-beta.88 → 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,11 @@
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
+
3
9
  ## 0.9.0-beta.88
4
10
 
5
11
  ### Patch Changes
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.88",
4
- "cli_commit": "6cd7da3104a366d4b2866fa31ef8635949e79143",
5
- "built_at": "2026-09-21T00:22:16.324Z",
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
@@ -235,6 +235,8 @@ Usage:
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
236
 
237
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.
238
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
239
241
  dd-flow stage unblock <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --work <WORK-ID> --json
240
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,4 +1,6 @@
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";
@@ -171,7 +173,7 @@ export async function runCli(args, io = defaultIo, env = process.env) {
171
173
  const retainedOutput = observedInvocation ? parseOutputOptions(expandedInvocationArgs) : null;
172
174
  if (retainedOutput?.responseFile)
173
175
  output.responseFile = retainedOutput.responseFile;
174
- const invocationArgs = retainedOutput?.args ?? expandedInvocationArgs;
176
+ let invocationArgs = retainedOutput?.args ?? expandedInvocationArgs;
175
177
  emitHumanProgress(io, output, progress, "start");
176
178
  if (output.progressJsonl) {
177
179
  writeProgressJsonl(io, "start", `Starting ${output.args.slice(0, 3).join(" ")}`);
@@ -179,7 +181,9 @@ export async function runCli(args, io = defaultIo, env = process.env) {
179
181
  }
180
182
  // Route by retained authority, so a mistyped project path reaches the CLI
181
183
  // validator instead of selecting a different engine or failing preflight.
182
- 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);
183
187
  const routedArgs = output.responseFile ? [...invocationArgs, "--response-file", output.responseFile] : invocationArgs;
184
188
  const routed = await routeArgsThroughEngine(routerContext, output.json ? [...routedArgs, "--json", ...(output.progressJsonl ? ["--progress-jsonl"] : [])] : routedArgs, io, io.stdin, env, scopeProjectRoot);
185
189
  if (routed?.routed && !routed.inProcess) {
@@ -237,8 +241,11 @@ export async function runCli(args, io = defaultIo, env = process.env) {
237
241
  preparedInput = await prepareCliInput(invocationArgs, dispatchContext, io, scopeProjectRoot, output.responseFile);
238
242
  if (!retainedOutcome)
239
243
  preflightCliCompatibility(dispatchContext, invocationArgs, env, scopeProjectRoot);
240
- if (!retainedOutcome)
241
- 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
+ }
242
249
  const result = retainedOutcome ? retainedOutcome.result : await dispatch(dispatchArgs, dispatchContext, io, scopeProjectRoot, classification, output, preparedInput);
243
250
  const completedHook = settleHook;
244
251
  settleHook = undefined;
@@ -764,6 +771,14 @@ async function prepareCliInput(...args) {
764
771
  async function prepareCliInputValue(args, context, io, scopeProjectRoot, responseFile = null, route = prepareCommandRoute(args)) {
765
772
  const { family, command, parsed } = route;
766
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");
767
782
  if (route.key === "project register")
768
783
  resolveProjectRoot(requiredOption(parsed, "root"));
769
784
  if (route.key === "project archive")
@@ -1200,7 +1215,7 @@ async function prepareCliInputValue(args, context, io, scopeProjectRoot, respons
1200
1215
  }
1201
1216
  }
1202
1217
  if (family === "work" && command === "finish") {
1203
- const retryReceiptId = optionalOption(parsed, "retry-check"), retryReason = optionalOption(parsed, "reason");
1218
+ const retryReceiptId = optionalOption(parsed, "retry-check"), retryReason = prepared.retryReason;
1204
1219
  if (Boolean(retryReceiptId) !== Boolean(retryReason))
1205
1220
  throw new AppError("usage", "--retry-check and --reason must be supplied together", 2);
1206
1221
  const resultFile = optionalOption(parsed, "result-file");
@@ -1225,8 +1240,8 @@ async function prepareCliInputValue(args, context, io, scopeProjectRoot, respons
1225
1240
  throw new AppError("usage", "Stage status belongs in the semantic input file", 2);
1226
1241
  if (["result-file", "semantic-file", "data", "result-stdin"].filter(key => hasOption(parsed, key)).length > 1)
1227
1242
  throw new AppError("usage", "Use exactly one result input: --result-file, --semantic-file, --data or --result-stdin", 2);
1228
- if (Boolean(optionalOption(parsed, "retry-check")) !== Boolean(optionalOption(parsed, "reason")))
1229
- 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);
1230
1245
  const resultStdin = hasOption(parsed, "result-stdin");
1231
1246
  const semanticFile = optionalOption(parsed, "semantic-file") ?? optionalOption(parsed, "data");
1232
1247
  const resultFile = optionalOption(parsed, "result-file") ?? semanticFile;
@@ -1244,8 +1259,8 @@ async function prepareCliInputValue(args, context, io, scopeProjectRoot, respons
1244
1259
  };
1245
1260
  const acceptStageInputs = (allowed) => {
1246
1261
  const options = allowed.split(" ");
1247
- for (const name of ["dir", "semantic-file", "data", "result-file", "result-stdin", "outcome", "decision-file", "verification-file", "retry-check", "reason", "request", "work"]) {
1248
- 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))
1249
1264
  throw new AppError("usage", `stage finish ${stage} does not accept --${name} for this flow`, 2, { parameter: name });
1250
1265
  }
1251
1266
  };
@@ -1302,7 +1317,7 @@ async function prepareCliInputValue(args, context, io, scopeProjectRoot, respons
1302
1317
  requiredOption(parsed, "request");
1303
1318
  requiredOption(parsed, "work");
1304
1319
  try {
1305
- 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 } : {}) });
1306
1321
  }
1307
1322
  catch (error) {
1308
1323
  return prepareError(error, { parameter: "MERGE finish" });
@@ -1574,7 +1589,7 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
1574
1589
  const workId = requiredPosition(parsed, 0, "work-id");
1575
1590
  const projectRoot = requiredScopeProjectRoot(scopeProjectRoot, "work finish");
1576
1591
  assertWorkLifecycleOwner(context, { workId, projectRoot, operation: "work_finish", ...(optionalOption(parsed, "hook-event-id") ? { hookEventId: optionalOption(parsed, "hook-event-id") } : {}) });
1577
- const retryReceiptId = optionalOption(parsed, "retry-check"), retryReason = optionalOption(parsed, "reason");
1592
+ const retryReceiptId = optionalOption(parsed, "retry-check"), retryReason = prepared.retryReason;
1578
1593
  if (Boolean(retryReceiptId) !== Boolean(retryReason))
1579
1594
  throw new AppError("usage", "--retry-check and --reason must be supplied together", 2);
1580
1595
  const result = prepared.workFinish?.result ?? (resultFile ? prepareTextFile(resultFile, "Work result").text : await readStdin(io.stdin));
@@ -1698,7 +1713,7 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
1698
1713
  const resultFile = optionalOption(parsed, "result-file") ?? semanticFile;
1699
1714
  const resultStdin = hasOption(parsed, "result-stdin");
1700
1715
  const retryCheckId = optionalOption(parsed, "retry-check");
1701
- const retryReason = optionalOption(parsed, "reason");
1716
+ const retryReason = prepared.retryReason;
1702
1717
  const hookEventId = optionalOption(parsed, "hook-event-id");
1703
1718
  if (retryCheckId && !retryReason?.trim())
1704
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;