@deksden-com/dd-flow-cli 0.9.0-beta.75 → 0.9.0-beta.79

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.
Files changed (68) hide show
  1. package/dist/build-info.json +6 -6
  2. package/dist/cli/command-inputs.js +300 -0
  3. package/dist/cli/help.js +1 -1
  4. package/dist/cli/hook-ingress.js +82 -0
  5. package/dist/cli/input-preparation.js +115 -0
  6. package/dist/cli/run-cli.js +1015 -423
  7. package/dist/cli.js +6 -2
  8. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  9. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -2
  10. package/dist/harness-runtime/lib/dd-codex.mjs +137 -13
  11. package/dist/harness-runtime/lib/dd-droid.mjs +2 -2
  12. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
  13. package/dist/harness-runtime/lib/delegation-instructions.mjs +13 -4
  14. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  15. package/dist/services/canon.js +11 -8
  16. package/dist/services/cleanup.js +49 -8
  17. package/dist/services/cli-operation-classifier.js +12 -24
  18. package/dist/services/codex-hook-delivery.js +28 -0
  19. package/dist/services/config.js +11 -8
  20. package/dist/services/controller-fanout.js +2 -2
  21. package/dist/services/dashboard.js +94 -31
  22. package/dist/services/engines.js +75 -61
  23. package/dist/services/eval-snapshots.js +138 -38
  24. package/dist/services/execution-policy.js +5 -12
  25. package/dist/services/hooks.js +226 -203
  26. package/dist/services/lanes.js +60 -52
  27. package/dist/services/lifecycle-command.js +13 -1
  28. package/dist/services/lifecycle-invocations.js +285 -94
  29. package/dist/services/managed-processes.js +158 -18
  30. package/dist/services/merge-queue.js +173 -98
  31. package/dist/services/migrations.js +13 -8
  32. package/dist/services/plan-runtime.js +19 -8
  33. package/dist/services/plans.js +28 -25
  34. package/dist/services/projects.js +11 -2
  35. package/dist/services/prompts.js +23 -16
  36. package/dist/services/protocols.js +54 -19
  37. package/dist/services/recovery-observation-budget.js +1 -1
  38. package/dist/services/run-controller-adapter.js +31 -20
  39. package/dist/services/run-controller-state.js +5 -0
  40. package/dist/services/run-controller.js +102 -56
  41. package/dist/services/run-fork.js +36 -22
  42. package/dist/services/run-recovery.js +32 -17
  43. package/dist/services/runs.js +110 -38
  44. package/dist/services/runtime-budget.js +120 -48
  45. package/dist/services/runtime-scope-capture.js +2 -2
  46. package/dist/services/runtime-scope-control.js +2 -2
  47. package/dist/services/runtime-scope-resume.js +34 -12
  48. package/dist/services/runtime-scope-worker.js +49 -16
  49. package/dist/services/runtime-service.js +8 -7
  50. package/dist/services/schema-validation.js +9 -7
  51. package/dist/services/sessions.js +35 -69
  52. package/dist/services/stage-blocker.js +17 -7
  53. package/dist/services/stage-context.js +42 -16
  54. package/dist/services/stage-lifecycle.js +82 -100
  55. package/dist/services/stage-pause.js +54 -24
  56. package/dist/services/vnext-code-review.js +82 -32
  57. package/dist/services/vnext-code.js +78 -45
  58. package/dist/services/vnext-fanout.js +13 -12
  59. package/dist/services/vnext-merge.js +68 -35
  60. package/dist/services/vnext-plan-review.js +84 -41
  61. package/dist/services/vnext-plan.js +91 -70
  62. package/dist/services/vnext-protocolize.js +116 -50
  63. package/dist/services/vnext-specify.js +70 -57
  64. package/dist/services/work-registry.js +195 -92
  65. package/dist/services/worktrees.js +85 -35
  66. package/dist/storage/database.js +104 -10
  67. package/dist/storage/paths.js +17 -4
  68. package/package.json +1 -1
@@ -13,25 +13,38 @@ import { appendEvent } from "./run-controller-state.js";
13
13
  import { recoverCommittedWorkStart } from "./work-registry.js";
14
14
  import { observeCommandHook } from "./hooks.js";
15
15
  import { runtimeHarness } from "./execution-policy.js";
16
+ import { assertPathWithin } from "../storage/paths.js";
17
+ import { contextualPathOptionsForLifecycle } from "../cli/command-inputs.js";
16
18
  // Conclusive result/check rejections, never transport, ownership, registry or
17
19
  // unknown-effect failures. These validators reject before accepting the result.
18
20
  export function isRetryableLifecycleRejection(code, details = {}) {
19
- if (details.recoverable === false || details.effect === "unknown" || details.effect === "committed")
21
+ if (code.startsWith("native_hook_") || code === "invocation_receipt_timeout")
22
+ return false;
23
+ if (code === "fresh_session_required" || details.recoverable === false || details.handoff_required === true || details.effect === "unknown" || details.effect === "committed")
20
24
  return false;
21
- return details.recoverable === true || ["validation", "schema_validation", "work_checks_failed", "code_gate_failed", "code_review_gate_failed", "merge_gate_failed",
25
+ if (code === "validation" || code === "schema_validation")
26
+ return details.phase === "prepare" && details.effect === "no_effect" && details.recoverable === true;
27
+ return details.recoverable === true || ["work_checks_failed", "code_gate_failed", "code_review_gate_failed", "merge_gate_failed",
22
28
  "review_repair_incomplete", "plan_review_incomplete", "semantic_result_invalid",
23
29
  "review_evidence_invalid", "evidence_obligation_unknown", "invalid_evidence_ref",
24
30
  "evidence_ref_missing", "evidence_line_out_of_range", "document_update_missing",
25
31
  "document_update_not_materialized", "review_repair_no_change"].includes(code)
26
32
  || (code === "invalid_work_state" && details.retryable_no_effect === true);
27
33
  }
34
+ /** A correctable rejection either publishes an exact replacement command or
35
+ * proves that the controller must hand the existing Work to another Session.
36
+ * The latter is deliberately not retryable in the caller that just failed. */
37
+ export function isCorrectableLifecycleRejection(code, details = {}) {
38
+ if (details.recoverable === false || details.effect === "unknown" || details.effect === "committed")
39
+ return false;
40
+ return isRetryableLifecycleRejection(code, details)
41
+ || (code === "fresh_session_required" && details.handoff_required === true
42
+ && details.recoverable === true);
43
+ }
28
44
  function storage(context) {
29
- context.db.execSchema(`CREATE TABLE IF NOT EXISTS lifecycle_invocations (
30
- id TEXT PRIMARY KEY, scope_json TEXT NOT NULL, command TEXT NOT NULL,
31
- fingerprint TEXT NOT NULL, status TEXT NOT NULL,
32
- event_key TEXT UNIQUE, identity_json TEXT, deadline INTEGER, outcome_json TEXT,
33
- created_at TEXT NOT NULL, updated_at TEXT NOT NULL
34
- )`);
45
+ if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'")) {
46
+ throw new AppError("invocation_storage_unprepared", "Lifecycle invocation storage is not prepared; start dd-flow normally before issuing native work", 1);
47
+ }
35
48
  }
36
49
  function invocation(command) {
37
50
  const parsed = parseLifecycleCommand(command);
@@ -44,17 +57,101 @@ function invocation(command) {
44
57
  // the exact operation and literal argv. Existing lifecycle validation owns data.
45
58
  function fingerprint(parsed) {
46
59
  const options = [...parsed.args.options].filter(([key]) => !["invocation-id", "hook-event-id", "json", "progress-jsonl", "response-file"].includes(key))
47
- .map(([key, values]) => [key, key === "reason" ? ["<semantic-reason>"] : values])
60
+ .map(([key, values]) => [key, key === "reason" ? ["<semantic-reason>"] : values.map(value => normalizedInvocationValue(key, value))])
48
61
  .sort(([a], [b]) => a.localeCompare(b));
49
- return crypto.createHash("sha256").update(JSON.stringify([parsed.operation, parsed.args.positional, options])).digest("hex");
62
+ return crypto.createHash("sha256").update(JSON.stringify([parsed.operation, parsed.args.positional.map(value => normalizedInvocationValue("positional", value)), options])).digest("hex");
63
+ }
64
+ function normalizedInvocationValue(key, value) {
65
+ if (key === "project-root" && (path.isAbsolute(value) || value === "@project"))
66
+ return "@project";
67
+ if (["result-file", "decision-file", "verification-file"].includes(key))
68
+ return "<validated-input-path>";
69
+ if (value.startsWith("@project") || value.startsWith("@workspace") || value.startsWith("@run"))
70
+ return value.replace(/\\/g, "/");
71
+ if (["result-file", "decision-file", "verification-file", "context-file"].includes(key) && path.isAbsolute(value)) {
72
+ const runRelative = value.replace(/\\/g, "/").match(/\/(?:runs\/)?RUN-[^/]+\/(.+)$/)?.[1];
73
+ if (runRelative)
74
+ return `@run/${runRelative}`;
75
+ }
76
+ const entity = /^(PRJ|RUN|WRK)-\d{3,}(?:-|$)/.exec(value)?.[0]?.replace(/-$/, "");
77
+ if (entity)
78
+ return entity;
79
+ return /(?:^|\/)(RCP-\d{3,})$/.exec(value)?.[1] ?? value;
80
+ }
81
+ function withoutInvocationId(command, aliases) {
82
+ const parsed = invocation(command);
83
+ const argv = [...parsed.argv];
84
+ const at = argv.indexOf("--invocation-id");
85
+ if (at >= 0)
86
+ argv.splice(at, 2);
87
+ for (let index = 0; index < argv.length; index += 1) {
88
+ const option = argv[index - 1]?.replace(/^--/, "");
89
+ if (option === "project-root")
90
+ argv[index] = "@project";
91
+ else if (option && aliases && ["result-file", "decision-file", "verification-file", "context-file"].includes(option) && path.isAbsolute(argv[index])) {
92
+ for (const [name, root] of [["run", aliases.run], ["workspace", aliases.workspace], ["project", aliases.project]]) {
93
+ if (!root)
94
+ continue;
95
+ const relative = path.relative(root, argv[index]);
96
+ if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) {
97
+ argv[index] = `@${name}${relative ? `/${relative.replaceAll(path.sep, "/")}` : ""}`;
98
+ break;
99
+ }
100
+ }
101
+ }
102
+ else if (!argv[index - 1]?.startsWith("--") && !argv[index].startsWith("--"))
103
+ argv[index] = normalizedInvocationValue("positional", argv[index]);
104
+ }
105
+ const env = Object.fromEntries(Object.entries(parsed.env).filter(([key]) => !["DD_FLOW_INVOCATION_SCOPE", "DD_FLOW_HOME", "DD_FLOW_BIN"].includes(key)));
106
+ const executable = parsed.env.DD_FLOW_BIN || parsed.executable === "$DD_FLOW_BIN" ? '"$DD_FLOW_BIN"' : quote([parsed.executable]);
107
+ const prefix = Object.entries(env).map(([key, value]) => `${key}=${quote([value])}`).join(" ");
108
+ const suffix = command.slice(shellSuffixOffset(command)).trimStart();
109
+ return `${prefix ? `${prefix} ` : ""}${executable}${argv.length ? ` ${quote(argv)}` : ""}${suffix ? ` ${suffix}` : ""}`.trim();
110
+ }
111
+ function publicInvocationCommand(context, row, command) {
112
+ const rendered = renderInvocationCommand(row, command);
113
+ const scope = JSON.parse(row.scope_json);
114
+ const run = scope.runId ? context.db.get("SELECT workspace_root, run_root FROM runs WHERE id = ?", [scope.runId]) : undefined;
115
+ return withoutInvocationId(rendered, { project: scope.projectRoot, ...(run?.workspace_root ? { workspace: run.workspace_root } : {}), ...(run?.run_root ? { run: run.run_root } : {}) });
116
+ }
117
+ /** Resolve the small, declared alias vocabulary used by model-facing lifecycle commands.
118
+ * Both native-hook admission and CLI execution call this function so they cannot
119
+ * disagree about the command that an alias denotes. */
120
+ export function expandLifecycleInvocationArgs(context, args, scope, operation) {
121
+ const pathOptions = contextualPathOptionsForLifecycle(operation);
122
+ const run = scope.runId ? context.db.get("SELECT id, short_id, workspace_root, run_root FROM runs WHERE id = ?", [scope.runId]) : undefined;
123
+ const roots = { project: scope.projectRoot, workspace: run?.workspace_root, run: run?.run_root };
124
+ return args.map((value, index) => {
125
+ if (run && !args[index - 1]?.startsWith("--") && [run.short_id, normalizedInvocationValue("positional", run.id)].includes(value) && !operation.startsWith("work_"))
126
+ return run.id;
127
+ const option = args[index - 1]?.replace(/^--/, "");
128
+ if (!option || !pathOptions.has(option) || !value.startsWith("@"))
129
+ return value;
130
+ const match = /^@(project|workspace|run)(?:\/(.*))?$/.exec(value);
131
+ if (!match)
132
+ throw new AppError("usage", `Unknown path alias: ${value}`, 2, { phase: "prepare", effect: "no_effect", recoverable: true, parameter: option });
133
+ const root = roots[match[1]];
134
+ if (!root)
135
+ throw new AppError("usage", `Path alias @${match[1]} is unavailable without a bound RUN`, 2, { phase: "prepare", effect: "no_effect", recoverable: true, parameter: option });
136
+ return assertPathWithin(root, path.join(root, match[2] ?? ""), `${match[1]}_alias`);
137
+ });
138
+ }
139
+ function canonicalManagedCommand(context, command, scope) {
140
+ const parsed = invocation(command);
141
+ const argv = expandLifecycleInvocationArgs(context, parsed.argv, scope, parsed.operation);
142
+ const env = { ...parsed.env, DD_FLOW_INVOCATION_SCOPE: JSON.stringify(scope) };
143
+ const prefix = Object.entries(env).map(([key, value]) => `${key}=${quote([value])}`).join(" ");
144
+ const executable = parsed.executable === "$DD_FLOW_BIN" ? '"$DD_FLOW_BIN"' : quote([parsed.executable]);
145
+ const suffix = command.slice(shellSuffixOffset(command)).trimStart();
146
+ return `${prefix} ${executable}${argv.length ? ` ${quote(argv)}` : ""}${suffix ? ` ${suffix}` : ""}`.trim();
50
147
  }
51
148
  function load(context, id) {
52
149
  if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'")) {
53
- throw new AppError("invocation_unknown", "This runtime has no issued lifecycle attempts", 1, { invocation_id: id });
150
+ throw new AppError("invocation_unknown", "This runtime has no issued lifecycle attempt for the requested operation; use a current runtime-issued command without adding an invocation ID", 1, { invocation_id: id, effect: "no_effect" });
54
151
  }
55
152
  const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [id]);
56
153
  if (!row)
57
- throw new AppError("invocation_unknown", "Lifecycle attempt was not issued by this runtime", 1, { invocation_id: id });
154
+ throw new AppError("invocation_unknown", "Lifecycle attempt was not issued by this runtime; use a current runtime-issued command without adding an invocation ID", 1, { invocation_id: id, effect: "no_effect" });
58
155
  return row;
59
156
  }
60
157
  export function lifecycleInvocationScope(context, id) {
@@ -96,7 +193,7 @@ export function assertLifecycleOutcomes(context, scope) {
96
193
  ORDER BY a.rowid`, [scope.projectRoot, scope.runId, scope.generation]);
97
194
  for (const row of rows) {
98
195
  const error = (row.outcome_json ? JSON.parse(row.outcome_json) : null)?.error;
99
- if (typeof error?.code === "string" && !isRetryableLifecycleRejection(error.code, error.details))
196
+ if (typeof error?.code === "string" && !isCorrectableLifecycleRejection(error.code, error.details))
100
197
  throw new AppError(error.code, typeof error.message === "string" ? error.message : "Mandatory lifecycle failed", 1, { ...(error.details ?? {}), invocation_id: row.id });
101
198
  }
102
199
  }
@@ -171,14 +268,16 @@ export function observeLifecycleCommand(context, command) {
171
268
  if (event)
172
269
  context.observeLifecycleHook(project.id, event.id);
173
270
  }
271
+ if (!anchor)
272
+ throw new AppError("invocation_receipt_missing", "Managed lifecycle command has no committed native receipt", 1, { invocation_id: invocationId, effect: "no_effect" });
174
273
  }
175
274
  return outcome => {
176
275
  delete context.observeLifecycleHook;
177
276
  if (!anchor)
178
277
  return;
179
278
  const error = outcome.error ? errorRecord(outcome.error) : undefined;
180
- const retryable = error && isRetryableLifecycleRejection(error.code, error.details);
181
- const record = { ...anchor, ...(error ? { error } : outcome), effect: error ? error.details.effect ?? (retryable ? "no_effect" : "unknown") : "committed", disposition: error ? retryable ? "correctable" : "fatal" : "completed" };
279
+ const correctable = error && isCorrectableLifecycleRejection(error.code, error.details);
280
+ const record = { ...anchor, ...(error ? { error } : outcome), effect: error ? error.details.effect ?? (correctable ? "no_effect" : "unknown") : "committed", disposition: error ? correctable ? "correctable" : "fatal" : "completed" };
182
281
  try {
183
282
  context.db.writeTransaction(() => {
184
283
  const retained = context.db.get("SELECT outcome_json FROM hook_events WHERE id = ?", [anchor.hook_event_id]);
@@ -226,35 +325,40 @@ export function lifecycleRetryCommands(context, stage) {
226
325
  const scope = context.env.DD_FLOW_INVOCATION_SCOPE;
227
326
  if (!scope || !context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
228
327
  return [];
229
- return context.db.all("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND status = 'settled' ORDER BY rowid", [scope])
230
- .flatMap((row) => {
231
- try {
232
- const outcome = row.outcome_json ? JSON.parse(row.outcome_json) : null;
233
- const retry = outcome?.error?.details?.retry_command;
234
- const parsed = invocation(row.command);
235
- if (typeof retry !== "string" || parsed.operation !== "stage_finish" || commandOption(parsed, "stage") !== stage)
328
+ return [...new Set(context.db.all("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND status = 'settled' ORDER BY rowid", [scope])
329
+ .flatMap((row) => {
330
+ try {
331
+ const outcome = row.outcome_json ? JSON.parse(row.outcome_json) : null;
332
+ const retry = outcome?.error?.details?.retry_command;
333
+ const parsed = invocation(row.command);
334
+ if (typeof retry !== "string" || parsed.operation !== "stage_finish" || commandOption(parsed, "stage") !== stage)
335
+ return [];
336
+ const latest = context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [row.scope_json, row.fingerprint]);
337
+ return latest?.status === "issued" && (latest.deadline === null || latest.deadline > Date.now()) ? [retry] : [];
338
+ }
339
+ catch {
236
340
  return [];
237
- const successor = load(context, commandOption(invocation(retry), "invocation-id") ?? "");
238
- const latest = context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [row.scope_json, row.fingerprint]);
239
- return successor.id === latest?.id && successor.status === "issued" && (successor.deadline === null || successor.deadline > Date.now()) ? [retry] : [];
240
- }
241
- catch {
242
- return [];
243
- }
244
- });
341
+ }
342
+ }))];
245
343
  }
246
344
  export function managedInvocationContext(context, input) {
247
345
  const env = { ...context.env, DD_FLOW_CURRENT_INVOCATION: undefined, DD_FLOW_INVOCATION_SCOPE: undefined };
248
- if (input.harness !== "zcode-acp")
346
+ if (input.harness !== "zcode-acp" && input.harness !== "codex-desktop")
249
347
  return { ...context, env };
250
348
  const project = requireProjectByRoot(context, input.projectRoot);
251
349
  const { state, process: daemon } = requireManagedDaemonBinding(context, { stateDir: input.stateDir, projectId: project.id, runId: input.runId });
252
350
  if (JSON.parse(daemon.metadata_json).run_generation !== input.generation)
253
351
  throw new AppError("invocation_generation_stale", "Managed daemon belongs to another RUN generation", 1);
254
- const session = state.sessions?.find(session => session.adapter_session_id === input.sessionId || session.provider_session_id === input.sessionId);
255
- if (!state.daemon_id || !session?.root_provider_session_id)
256
- throw new AppError("invocation_scope_unproven", "Managed ZCode Session has no persisted native root binding", 1);
257
- const scope = { projectRoot: project.root, daemonId: state.daemon_id, rootSessionId: session.root_provider_session_id, runId: input.runId, generation: input.generation };
352
+ const session = state.sessions?.find(session => typeof session === "string" ? session === input.sessionId : session.adapter_session_id === input.sessionId || session.provider_session_id === input.sessionId);
353
+ // Codex owns a single root Session per controller and persists that root as
354
+ // a string. Child agent_id values arrive later through the native hook and
355
+ // are checked against this root scope; they are not controller Sessions.
356
+ const rootSessionId = input.harness === "codex-desktop"
357
+ ? (typeof session === "string" ? session : session?.root_provider_session_id ?? session?.provider_session_id ?? null)
358
+ : (typeof session === "string" ? null : session?.root_provider_session_id ?? null);
359
+ if (!state.daemon_id || !rootSessionId)
360
+ throw new AppError("invocation_scope_unproven", "Managed native Session has no persisted native root binding", 1);
361
+ const scope = { projectRoot: project.root, daemonId: state.daemon_id, rootSessionId, runId: input.runId, generation: input.generation };
258
362
  env.DD_FLOW_INVOCATION_SCOPE = JSON.stringify(scope);
259
363
  return { ...context, env };
260
364
  }
@@ -265,6 +369,7 @@ export function managedLifecycleCommand(context, command) {
265
369
  if (!configured)
266
370
  return command;
267
371
  const scope = JSON.parse(configured);
372
+ command = canonicalManagedCommand(context, command, scope);
268
373
  const parsed = invocation(command);
269
374
  const suppliedId = commandOption(parsed, "invocation-id");
270
375
  // An invocation id is authority, never decorative text. In particular,
@@ -277,24 +382,25 @@ export function managedLifecycleCommand(context, command) {
277
382
  }
278
383
  checkCommand(retained, command);
279
384
  assertRenderableInvocation(retained);
280
- return command;
385
+ return publicInvocationCommand(context, retained, command);
281
386
  }
282
387
  if (!context.db.writable || context.env.DD_FLOW_INVOCATION_READONLY === "1") {
283
388
  const retained = context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'")
284
389
  ? context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [JSON.stringify(scope), fingerprint(parsed)]) : undefined;
285
390
  if (!retained)
286
- throw new AppError("invocation_command_unprepared", "The controller has not issued this lifecycle command yet. End this Turn and await its next assignment with the exact command; do not generate or reuse an invocation ID.", 1);
287
- return renderInvocationCommand(retained, command);
391
+ throw new AppError("invocation_command_unprepared", "The controller has not issued this lifecycle operation yet. End this Turn and await its next assignment; do not add or reuse an internal invocation ID.", 1);
392
+ return publicInvocationCommand(context, retained, command);
288
393
  }
289
394
  storage(context);
290
395
  return context.db.writeTransaction(() => {
291
396
  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)]);
292
397
  if (prior && prior.id !== context.env.DD_FLOW_CURRENT_INVOCATION) {
293
- return renderInvocationCommand(prior, command);
398
+ return publicInvocationCommand(context, prior, command);
294
399
  }
295
400
  if (context.env.DD_FLOW_INVOCATION_READONLY === "1")
296
401
  throw new AppError("invocation_command_unprepared", "No prepared lifecycle command is available; read-only inspection cannot issue one", 1);
297
- return issueLifecycleInvocation(context, command, scope).command;
402
+ const issued = issueLifecycleInvocation(context, command, scope);
403
+ return publicInvocationCommand(context, load(context, issued.id), command);
298
404
  });
299
405
  }
300
406
  export function retryLifecycleInvocationCommand(context, id) {
@@ -304,8 +410,8 @@ export function retryLifecycleInvocationCommand(context, id) {
304
410
  return successorLifecycleInvocationCommand(context, prior);
305
411
  }
306
412
  /** A predecessor may publish exactly one successor only while the caller has
307
- * proved that no lifecycle effect was admitted. This is shared by ordinary
308
- * command validation and the native pre-execution rejection path. */
413
+ * proved that no lifecycle effect was admitted. Shared by CLI argument
414
+ * validation and conclusive lifecycle/result rejection, never by hooks. */
309
415
  function successorLifecycleInvocationCommand(context, prior) {
310
416
  const parsed = invocation(prior.command);
311
417
  const argv = [...parsed.argv];
@@ -322,44 +428,17 @@ function successorLifecycleInvocationCommand(context, prior) {
322
428
  const command = `${prior.command.slice(0, markerAt)}${prior.command.slice(markerAt + marker.length)}`;
323
429
  return managedLifecycleCommand({ ...context, env: { ...context.env, DD_FLOW_INVOCATION_SCOPE: prior.scope_json, DD_FLOW_CURRENT_INVOCATION: prior.id } }, command);
324
430
  }
325
- /** A native observer can reject a malformed tool call before the CLI claims
326
- * execution. Persist that fact and its sole replacement atomically: waiting
327
- * callers must never time out merely because the observer rejected syntax. */
328
- export function settleLifecycleNoEffectRejection(context, id, command, error, identity) {
329
- storage(context);
330
- context.db.beginWriteTransaction();
331
- try {
332
- const prior = load(context, id);
333
- checkCommand(prior, command);
334
- const scope = JSON.parse(prior.scope_json);
335
- checkNativeIdentity(scope, identity, id);
336
- if (prior.status === "settled") {
337
- context.db.exec("COMMIT");
338
- return;
339
- }
340
- if (scope.runId)
341
- assertLifecycleInvocationCurrent(context, scope, command);
342
- if (prior.status !== "issued")
343
- throw new AppError("invocation_rejection_conflict", "A lifecycle rejection arrived after execution admission", 1, { invocation_id: id });
344
- settleUnadmittedInvocation(context, prior, error);
345
- context.db.exec("COMMIT");
346
- }
347
- catch (error) {
348
- context.db.exec("ROLLBACK");
349
- throw error;
350
- }
351
- }
352
431
  // Caller holds the write transaction and has checked authority. Issued (or
353
432
  // legacy expired) attempts have never admitted an effect; observed/executing
354
433
  // attempts must never receive an automatic replacement.
355
434
  function settleUnadmittedInvocation(context, prior, error) {
356
435
  if (!["issued", "expired"].includes(prior.status))
357
436
  throw new AppError("invocation_rejection_conflict", "Attempt already admitted native execution", 1);
358
- const details = { ...error.details, effect: "no_effect", recoverable: true, retry_command: successorLifecycleInvocationCommand(context, prior),
359
- retry_instruction: "Use retry_command as one standalone lifecycle command. The rejected command did not change lifecycle state." };
437
+ const details = { ...error.details, effect: "no_effect", recoverable: false };
360
438
  if (context.db.run("UPDATE lifecycle_invocations SET status = 'settled', outcome_json = ?, updated_at = ? WHERE id = ? AND status = ?", [JSON.stringify({ error: { ...error, details } }), context.now(), prior.id, prior.status]).changes !== 1) {
361
439
  throw new AppError("invocation_settlement_conflict", "Lifecycle rejection could not be committed", 1, { invocation_id: prior.id });
362
440
  }
441
+ return details;
363
442
  }
364
443
  const receiptTimeout = (id) => ({ code: "invocation_receipt_timeout", message: "No native confirmation before the receipt deadline; lifecycle state was not changed", exitCode: 1, details: { invocation_id: id } });
365
444
  /** Settle a conclusive rejection and publish its only legal successor atomically.
@@ -383,6 +462,12 @@ export function settleLifecycleRejection(context, id, error) {
383
462
  details.storage_retry = 1;
384
463
  details.retry_instruction = "Correct only the evidenced defect, then use retry_command with the required result input. Reissuing the old command only reads its retained outcome.";
385
464
  }
465
+ else if (isCorrectableLifecycleRejection(error.code, error.details)) {
466
+ details.effect = "no_effect";
467
+ details.recoverable = true;
468
+ details.handoff_required = true;
469
+ details.recovery_instruction = "Do not retry this command in the current Session. End the coordinator Turn; the controller will launch the already-registered Work in a fresh child Session.";
470
+ }
386
471
  settleLifecycleInvocation(context, id, { error: { ...error, details } });
387
472
  context.db.exec("COMMIT");
388
473
  Object.assign(error.details, details);
@@ -477,14 +562,23 @@ function checkCommand(row, command) {
477
562
  }
478
563
  /** Explicit issuance; callers retain this command. Read-only show/status must
479
564
  * return that retained value, not issue a replacement on every observation. */
480
- export function issueLifecycleInvocation(context, command, scope) {
481
- storage(context);
565
+ export function prepareLifecycleIssuance(command, value) {
566
+ if (typeof command !== "string" || !command.trim() || !value || typeof value !== "object" || Array.isArray(value)) {
567
+ throw new AppError("invocation_scope_invalid", "Lifecycle issuance requires command and managed scope", 1);
568
+ }
569
+ const scope = value;
482
570
  const parsed = invocation(command);
483
- if (!scope.daemonId || !scope.rootSessionId || !Number.isInteger(scope.generation) || scope.generation < 0
484
- || !path.isAbsolute(scope.projectRoot) || commandOption(parsed, "project-root") !== scope.projectRoot
571
+ if (typeof scope.daemonId !== "string" || !scope.daemonId.trim() || typeof scope.rootSessionId !== "string" || !scope.rootSessionId.trim() || !Number.isSafeInteger(scope.generation) || scope.generation < 0
572
+ || (scope.runId !== null && (typeof scope.runId !== "string" || !scope.runId.trim()))
573
+ || typeof scope.projectRoot !== "string" || !path.isAbsolute(scope.projectRoot) || commandOption(parsed, "project-root") !== scope.projectRoot
485
574
  || commandOption(parsed, "invocation-id") || commandOption(parsed, "hook-event-id")) {
486
575
  throw new AppError("invocation_scope_invalid", "Lifecycle issuance requires explicit managed scope and a new command", 1);
487
576
  }
577
+ return { command, scope, parsed };
578
+ }
579
+ export function issueLifecycleInvocation(context, command, scope) {
580
+ const { parsed } = prepareLifecycleIssuance(command, scope);
581
+ storage(context);
488
582
  const id = crypto.randomUUID();
489
583
  const prepared = commandWithInvocationId(command, id);
490
584
  // Validate the actual inserted argv, including quoted heredoc boundaries.
@@ -527,56 +621,153 @@ function checkNativeIdentity(scope, identity, id) {
527
621
  }
528
622
  }
529
623
  export function observeLifecycleInvocation(context, input) {
530
- const initial = load(context, input.id);
531
- checkCommand(initial, input.command);
624
+ storage(context);
625
+ const supplied = input.id ? context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [input.id]) : undefined;
626
+ const analysis = parseLifecycleCommand(input.command);
627
+ if (analysis.kind !== "standalone" || analysis.invocation.wrapped)
628
+ return { eventKey: input.recordReceipt(), duplicate: false, invocationId: null };
629
+ const candidates = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('issued','observed') ORDER BY rowid DESC")
630
+ .filter(row => {
631
+ const scope = JSON.parse(row.scope_json);
632
+ if (scope.daemonId !== input.identity.daemonId || scope.rootSessionId !== input.identity.rootSessionId)
633
+ return false;
634
+ try {
635
+ return fingerprint(invocation(canonicalManagedCommand(context, input.command, scope))) === row.fingerprint;
636
+ }
637
+ catch {
638
+ return false;
639
+ }
640
+ });
641
+ // A known explicit ID remains a compatibility path. A missing or mistyped
642
+ // public ID is corrected only by an exact operation/target match inside the
643
+ // trusted native root. Never fuzzy-match UUID text or select a global latest.
644
+ const initial = supplied ?? (candidates.length === 1 ? candidates[0] : undefined);
645
+ if (!initial) {
646
+ const eventKey = input.recordReceipt();
647
+ if (candidates.length > 1)
648
+ throw new AppError("invocation_ambiguous", "More than one issued lifecycle attempt matches this native command", 1, { effect: "no_effect", matches: candidates.map(row => row.id) });
649
+ return { eventKey, duplicate: false, invocationId: null };
650
+ }
651
+ const resolved = input.id === initial.id ? {} : { invocationId: initial.id };
532
652
  // A completed invocation is a read, even from a new native tool call after
533
653
  // reattach. Do not manufacture a new receipt or reject that read as a second
534
654
  // executor; the CLI returns the immutable stored outcome without dispatch.
535
655
  if (initial.status === "settled")
536
- return { eventKey: initial.event_key, duplicate: true };
656
+ return { eventKey: initial.event_key, duplicate: true, ...resolved };
537
657
  const scope = JSON.parse(initial.scope_json);
658
+ const project = requireProjectByRoot(context, scope.projectRoot);
538
659
  const identity = input.identity;
539
- checkNativeIdentity(scope, identity, input.id);
660
+ checkNativeIdentity(scope, identity, initial.id);
540
661
  const identityJson = JSON.stringify([identity.daemonId, identity.rootSessionId, identity.sessionId, identity.parentSessionId, identity.toolCallId]);
541
662
  context.db.beginWriteTransaction();
542
663
  try {
543
- const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [input.id]);
664
+ const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [initial.id]);
544
665
  if (row.status === "settled") {
545
666
  context.db.exec("COMMIT");
546
- return { eventKey: row.event_key, duplicate: true };
667
+ return { eventKey: row.event_key, duplicate: true, ...resolved };
547
668
  }
669
+ // Observation records native facts only. CLI owns deadline, argument and
670
+ // RUN-state validation, including decisions about retrying an invocation.
548
671
  if (row.status === "expired" || (row.deadline !== null && row.deadline <= Date.now() && !row.event_key)) {
549
- if (scope.runId)
550
- assertLifecycleInvocationCurrent(context, scope, input.command);
551
- settleUnadmittedInvocation(context, row, receiptTimeout(input.id));
552
672
  context.db.exec("COMMIT");
553
- return { eventKey: row.event_key, duplicate: true };
673
+ return { eventKey: row.event_key, duplicate: true, ...resolved };
554
674
  }
555
- if (row.identity_json) {
556
- if (row.identity_json !== identityJson)
557
- throw new AppError("invocation_identity_conflict", "Another native tool call already owns this attempt", 1, { invocation_id: input.id });
675
+ if (row.identity_json === identityJson) {
558
676
  context.db.exec("COMMIT");
559
- return { eventKey: row.event_key, duplicate: true };
677
+ return { eventKey: row.event_key, duplicate: true, ...resolved };
560
678
  }
561
- const eventKey = input.recordReceipt();
562
- const receipt = eventKey && context.db.get("SELECT provider_session_id, daemon_id, status FROM hook_events WHERE event_key = ? AND harness = 'zcode-acp' AND cwd = ?", [eventKey, scope.projectRoot]);
679
+ const eventKey = input.recordReceipt(scope);
680
+ // A native hook may execute from a provider worktree while the issued
681
+ // lifecycle scope is anchored to the stable project root. Project
682
+ // identity is the canonical boundary; comparing raw cwd would reject a
683
+ // valid receipt before the CLI can run (Codex does this in particular).
684
+ const receipt = eventKey && context.db.get("SELECT provider_session_id, daemon_id, status, harness FROM hook_events WHERE event_key = ? AND harness = ? AND project_id = ?", [eventKey, input.harness ?? "zcode-acp", project.id]);
563
685
  if (!receipt || receipt.provider_session_id !== identity.sessionId || receipt.daemon_id !== identity.daemonId || receipt.status !== "observed") {
564
686
  throw new AppError("invocation_receipt_missing", "Native observer did not persist the matching unclaimed receipt", 1);
565
687
  }
566
- context.db.run("UPDATE lifecycle_invocations SET identity_json = ?, event_key = ?, status = 'observed', updated_at = ? WHERE id = ? AND status = 'issued'", [identityJson, eventKey, context.now(), input.id]);
688
+ // The hook observes intent; it does not own execution. A native shell may
689
+ // reject a tool call before the CLI process exists (for example because
690
+ // its workdir is invalid), after which repeating the same issued command
691
+ // is safe. Keep the newest receipt until the CLI claims the attempt. The
692
+ // observed -> executing compare-and-swap remains the exactly-once boundary.
693
+ const updated = context.db.run("UPDATE lifecycle_invocations SET identity_json = ?, event_key = ?, status = 'observed', updated_at = ? WHERE id = ? AND status IN ('issued', 'observed')", [identityJson, eventKey, context.now(), initial.id]);
567
694
  context.db.exec("COMMIT");
568
- return { eventKey, duplicate: false };
695
+ // Once execution has been claimed, later PreToolUse observations are
696
+ // retained as hook evidence but cannot replace the executor's receipt.
697
+ return { eventKey: updated.changes === 1 ? eventKey : row.event_key, duplicate: updated.changes !== 1, ...resolved };
569
698
  }
570
699
  catch (error) {
571
700
  context.db.exec("ROLLBACK");
572
701
  throw error;
573
702
  }
574
703
  }
704
+ /** Resolve only a command that a native hook has already bound to an issued
705
+ * attempt. This is the CLI-side half of admission for model-facing commands
706
+ * that intentionally omit the internal UUID. */
707
+ export function observedLifecycleInvocation(context, command) {
708
+ if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
709
+ return null;
710
+ const analysis = parseLifecycleCommand(command);
711
+ if (analysis.kind !== "standalone" || analysis.invocation.wrapped)
712
+ return null;
713
+ const matches = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('observed','executing') ORDER BY rowid DESC")
714
+ .filter(row => {
715
+ try {
716
+ const scope = JSON.parse(row.scope_json);
717
+ return fingerprint(invocation(canonicalManagedCommand(context, command, scope))) === row.fingerprint;
718
+ }
719
+ catch {
720
+ return false;
721
+ }
722
+ });
723
+ if (!matches.length)
724
+ return null;
725
+ if (matches.length !== 1)
726
+ throw new AppError("invocation_ambiguous", "More than one observed lifecycle attempt matches this command", 1, { effect: "no_effect", matches: matches.map(row => row.id) });
727
+ return { id: matches[0].id, scope: JSON.parse(matches[0].scope_json) };
728
+ }
575
729
  /** One caller owns execution; a lost process leaves an explicit unknown
576
730
  * outcome, never an automatic replay. Existing recovery decides the next step. */
577
731
  export async function awaitLifecycleInvocation(context, input) {
578
732
  const initial = load(context, input.id);
579
- checkCommand(initial, input.command);
733
+ try {
734
+ if (initial.status === "settled") {
735
+ checkCommand(initial, input.command);
736
+ return { outcome: JSON.parse(initial.outcome_json), replay: true };
737
+ }
738
+ input.validateArgs?.();
739
+ checkCommand(initial, input.command);
740
+ // A terminal command is immutable evidence. Do not require a now-gone
741
+ // caller payload merely to replay its recorded result.
742
+ if (["issued", "observed"].includes(initial.status))
743
+ await input.prepare?.();
744
+ }
745
+ catch (error) {
746
+ if (!(error instanceof AppError) || !(["usage", "invocation_command_mismatch", "invocation_command_invalid"].includes(error.code) || error.details.phase === "prepare"))
747
+ throw error;
748
+ const rejectionDetails = context.db.writeTransaction(() => {
749
+ const row = load(context, input.id);
750
+ if (row.status === "settled") {
751
+ const details = row.outcome_json ? JSON.parse(row.outcome_json)?.error?.details : undefined;
752
+ return details?.effect === "no_effect" && details.retry_command ? details : { effect: "unknown", recoverable: false };
753
+ }
754
+ if (!["issued", "observed"].includes(row.status)) {
755
+ return { effect: "unknown", recoverable: false };
756
+ }
757
+ const scope = JSON.parse(row.scope_json);
758
+ if (scope.runId)
759
+ assertLifecycleInvocationCurrent(context, scope, row.command);
760
+ // No executor has claimed this attempt. Atomically retain the rejected
761
+ // call and issue one corrected command; old IDs only replay the outcome.
762
+ const details = { ...error.details, effect: "no_effect", recoverable: true,
763
+ retry_command: successorLifecycleInvocationCommand(context, row),
764
+ retry_instruction: "Execute retry_command verbatim in this same Session. The rejected call changed no lifecycle state." };
765
+ context.db.run("UPDATE lifecycle_invocations SET status = 'settled', outcome_json = ?, updated_at = ? WHERE id = ?", [JSON.stringify({ error: { ...errorRecord(error), details } }), context.now(), row.id]);
766
+ return details;
767
+ });
768
+ Object.assign(error.details, rejectionDetails);
769
+ throw error;
770
+ }
580
771
  const timeoutMs = input.timeoutMs ?? 30000;
581
772
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60000)
582
773
  throw new AppError("invocation_timeout_invalid", "Receipt timeout must be within 1..60000 ms", 1);