@deksden-com/dd-flow-cli 0.9.0-beta.123 → 0.9.0-beta.127

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 (122) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +33 -1
  3. package/dist/build-info.json +7 -7
  4. package/dist/cli/argument-values.js +38 -0
  5. package/dist/cli/command-inputs.js +4 -3
  6. package/dist/cli/help.js +1 -0
  7. package/dist/cli/hook-ingress.js +1 -1
  8. package/dist/cli/run-cli.js +126 -193
  9. package/dist/cli/runtime-maintenance-diagnostics.js +46 -0
  10. package/dist/cli/runtime-maintenance-ingress.js +51 -0
  11. package/dist/cli/runtime-process.js +128 -0
  12. package/dist/cli.js +8 -5
  13. package/dist/domain/stage-catalog.js +1 -1
  14. package/dist/harness-runtime/bin/dd-agy.mjs +3 -2
  15. package/dist/harness-runtime/bin/dd-codex.mjs +3 -2
  16. package/dist/harness-runtime/bin/dd-droid.mjs +3 -2
  17. package/dist/harness-runtime/bin/dd-grok.mjs +16 -6
  18. package/dist/harness-runtime/bin/dd-opencode.mjs +3 -2
  19. package/dist/harness-runtime/bin/dd-zcode.mjs +20 -16
  20. package/dist/harness-runtime/lib/adapter-timeouts.d.mts +5 -0
  21. package/dist/harness-runtime/lib/adapter-timeouts.mjs +34 -0
  22. package/dist/harness-runtime/lib/codex-capacity-policy.d.mts +10 -0
  23. package/dist/harness-runtime/lib/codex-capacity-policy.mjs +89 -0
  24. package/dist/harness-runtime/lib/daemon-observation.d.mts +2 -0
  25. package/dist/harness-runtime/lib/daemon-observation.mjs +163 -0
  26. package/dist/harness-runtime/lib/daemon-operations.mjs +159 -15
  27. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +215 -86
  28. package/dist/harness-runtime/lib/dd-agy.mjs +11 -13
  29. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +47 -23
  30. package/dist/harness-runtime/lib/dd-codex.mjs +257 -99
  31. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +32 -30
  32. package/dist/harness-runtime/lib/dd-droid.mjs +117 -31
  33. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +96 -38
  34. package/dist/harness-runtime/lib/dd-grok.mjs +15 -13
  35. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +54 -20
  36. package/dist/harness-runtime/lib/dd-opencode.mjs +44 -14
  37. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +81 -76
  38. package/dist/harness-runtime/lib/dd-zcode.mjs +204 -116
  39. package/dist/harness-runtime/lib/driver-recovery.mjs +12 -2
  40. package/dist/harness-runtime/lib/lease-renewal.d.mts +8 -0
  41. package/dist/harness-runtime/lib/lease-renewal.mjs +23 -0
  42. package/dist/harness-runtime/lib/lifecycle-command.d.mts +3 -3
  43. package/dist/harness-runtime/lib/lifecycle-command.mjs +74 -20
  44. package/dist/harness-runtime/lib/managed-daemon.d.mts +11 -0
  45. package/dist/harness-runtime/lib/managed-daemon.mjs +321 -65
  46. package/dist/harness-runtime/lib/native-children.d.mts +13 -0
  47. package/dist/harness-runtime/lib/native-children.mjs +78 -0
  48. package/dist/harness-runtime/lib/native-hook-command.d.mts +4 -0
  49. package/dist/harness-runtime/lib/native-hook-command.mjs +115 -11
  50. package/dist/harness-runtime/lib/native-streams.mjs +45 -0
  51. package/dist/harness-runtime/lib/observation-clock.d.mts +18 -0
  52. package/dist/harness-runtime/lib/observation-clock.mjs +73 -17
  53. package/dist/harness-runtime/lib/operation-errors.d.mts +14 -0
  54. package/dist/harness-runtime/lib/operation-errors.mjs +105 -8
  55. package/dist/harness-runtime/lib/operation-progress.d.mts +7 -0
  56. package/dist/harness-runtime/lib/operation-progress.mjs +112 -0
  57. package/dist/harness-runtime/lib/process-json.mjs +136 -38
  58. package/dist/harness-runtime/lib/tool-observations.d.mts +1 -0
  59. package/dist/harness-runtime/lib/tool-observations.mjs +5 -2
  60. package/dist/harness-runtime/lib/zcode-usage.mjs +3 -2
  61. package/dist/runtime/context.js +8 -4
  62. package/dist/schemas/flow-run.schema.json +1 -0
  63. package/dist/schemas/plan-review-decision.schema.json +3 -3
  64. package/dist/schemas/run-control-receipt.schema.json +19 -0
  65. package/dist/services/code-checks.js +93 -27
  66. package/dist/services/codex-capacity.js +251 -74
  67. package/dist/services/codex-hook-delivery.js +2 -2
  68. package/dist/services/compatibility-preflight.js +4 -1
  69. package/dist/services/controller-fanout.js +9 -75
  70. package/dist/services/engines.js +70 -20
  71. package/dist/services/eval-snapshots.js +179 -40
  72. package/dist/services/external-work-launch.js +21 -9
  73. package/dist/services/harness-adapter.js +195 -25
  74. package/dist/services/hooks.js +65 -21
  75. package/dist/services/lifecycle-invocations.js +404 -32
  76. package/dist/services/managed-lease-monitor.js +128 -0
  77. package/dist/services/managed-processes.js +117 -53
  78. package/dist/services/merge-server.js +66 -23
  79. package/dist/services/recovery-observation-budget.js +67 -7
  80. package/dist/services/recovery-snapshot-database.js +7 -4
  81. package/dist/services/review-deferral.js +23 -0
  82. package/dist/services/run-control-receipt.js +1 -1
  83. package/dist/services/run-control-worker.js +177 -118
  84. package/dist/services/run-control.js +99 -8
  85. package/dist/services/run-controller-adapter.js +177 -51
  86. package/dist/services/run-controller-capture.js +14 -2
  87. package/dist/services/run-controller-process.js +11 -4
  88. package/dist/services/run-controller-recovery.js +17 -0
  89. package/dist/services/run-controller-state.js +68 -7
  90. package/dist/services/run-controller.js +207 -107
  91. package/dist/services/run-fork.js +8 -3
  92. package/dist/services/run-recovery-guard.js +75 -0
  93. package/dist/services/run-recovery-runtime.js +21 -30
  94. package/dist/services/run-recovery.js +17 -13
  95. package/dist/services/runs.js +40 -7
  96. package/dist/services/runtime-budget.js +32 -15
  97. package/dist/services/runtime-scope-control.js +8 -0
  98. package/dist/services/runtime-scope-resume.js +30 -8
  99. package/dist/services/runtime-scope-worker.js +118 -73
  100. package/dist/services/runtime-service.js +6 -7
  101. package/dist/services/scope-writer-cleanup.js +3 -2
  102. package/dist/services/stage-context.js +135 -0
  103. package/dist/services/stage-lifecycle.js +3 -15
  104. package/dist/services/stage-pause.js +3 -2
  105. package/dist/services/usage.js +14 -3
  106. package/dist/services/verification-matrix.js +30 -1
  107. package/dist/services/vnext-code-review.js +26 -13
  108. package/dist/services/vnext-code.js +10 -8
  109. package/dist/services/vnext-contracts.js +10 -1
  110. package/dist/services/vnext-execution-profile.js +75 -2
  111. package/dist/services/vnext-fanout.js +5 -0
  112. package/dist/services/vnext-merge.js +30 -11
  113. package/dist/services/vnext-plan-review.js +102 -35
  114. package/dist/services/vnext-plan.js +9 -7
  115. package/dist/services/vnext-protocolize.js +15 -9
  116. package/dist/services/vnext-specify.js +6 -6
  117. package/dist/services/work-registry.js +43 -6
  118. package/dist/services/workspace-bootstrap.js +14 -3
  119. package/dist/shared/errors.js +11 -6
  120. package/dist/storage/agent-output.js +26 -0
  121. package/dist/storage/database.js +80 -21
  122. package/package.json +7 -4
@@ -8,9 +8,9 @@ import { shortEntityReference } from "../shared/entity-references.js";
8
8
  import { resolveWorkReference } from "../storage/work-references.js";
9
9
  import { commandOption, commandPosition, continuationCommand, lifecycleCommandFromPayload, parseLifecycleCommand, shellSuffixOffset } from "./lifecycle-command.js";
10
10
  import { requireProjectByRoot } from "./projects.js";
11
- import { assertRunMutationAllowed, assertStageSettlementAllowed, assertWorkSettlementAllowed, recoveryGuard } from "./run-recovery.js";
11
+ import { assertRunMutationAllowed, assertStageSettlementAllowed, assertWorkSettlementAllowed, recoveryGuard, recoveryPacketReceipt } from "./run-recovery.js";
12
12
  import { readNativeDaemonState, requireManagedDaemonBinding } from "./managed-daemon-binding.js";
13
- import { managedProcessStatus } from "./managed-processes.js";
13
+ import { managedProcessStatus, processIsAlive } from "./managed-processes.js";
14
14
  import { appendEvent } from "./run-controller-state.js";
15
15
  import { recoverCommittedWorkStart } from "./work-registry.js";
16
16
  import { observeCommandHook } from "./hooks.js";
@@ -205,6 +205,7 @@ function withoutInvocationId(command, aliases) {
205
205
  return `${prefix ? `${prefix} ` : ""}${executable}${argv.length ? ` ${quoteModelFacingArgs(argv)}` : ""}${suffix ? ` ${suffix}` : ""}`.trim();
206
206
  }
207
207
  function publicInvocationCommand(context, row, command) {
208
+ assertPrivateStageStartContract(row, command);
208
209
  if (!commandOption(invocation(command), "project-root"))
209
210
  command = retainedCommandWithoutInvocationId(row);
210
211
  const rendered = renderInvocationCommand(row, command);
@@ -213,6 +214,24 @@ function publicInvocationCommand(context, row, command) {
213
214
  context.captureLifecycleAssignment?.(row.id);
214
215
  return withoutInvocationId(rendered, { project: scope.projectRoot, ...(run?.workspace_root ? { workspace: run.workspace_root } : {}), ...(run?.run_root ? { run: run.run_root } : {}) });
215
216
  }
217
+ /** Public fingerprints recognize a command; they do not replace its private
218
+ * controller contract when an issuer renders the same public operation. */
219
+ function assertPrivateStageStartContract(row, command) {
220
+ const actual = invocation(command), retained = invocation(row.command);
221
+ if (actual.operation !== "stage_start")
222
+ return;
223
+ const lifecycleAssignment = { issuer: "dd-flow", phase: "issuance", scope: JSON.parse(row.scope_json) };
224
+ if (commandPosition(actual, 0) !== commandPosition(retained, 0))
225
+ throw new AppError("invocation_argument_mismatch", "Retained Stage entry has a different RUN target", 1, { invocation_id: row.id, phase: "prepare", effect: "no_effect", recoverable: false, lifecycle_assignment: lifecycleAssignment });
226
+ for (const key of ["context-file", "context-sha256", "response-file", "require-session-binding"]) {
227
+ const supplied = commandOption(actual, key), expected = commandOption(retained, key);
228
+ const normalized = (value) => ["context-file", "response-file"].includes(key) && path.isAbsolute(value) ? path.normalize(value) : normalizedInvocationValue(key, value);
229
+ if ((supplied === undefined) !== (expected === undefined)
230
+ || supplied !== undefined && normalized(supplied) !== normalized(expected)) {
231
+ throw new AppError("invocation_argument_mismatch", `Retained Stage entry has a different --${key} contract`, 1, { invocation_id: row.id, parameter: key, phase: "prepare", effect: "no_effect", recoverable: false, lifecycle_assignment: lifecycleAssignment });
232
+ }
233
+ }
234
+ }
216
235
  /** Bind issued commands to the exact bytes sent to the model, not to an
217
236
  * internal marker which is intentionally absent from that prompt. */
218
237
  export function bindLifecyclePacketAssignments(context, prompt, ids) {
@@ -479,6 +498,14 @@ export function observeLifecycleCommand(context, command, nativeEvent) {
479
498
  }
480
499
  throw persistence;
481
500
  }
501
+ // Serializers intentionally clone JSON-safe diagnostics. These response
502
+ // fields are the deliberate API overlay on the original rejection, only
503
+ // after its exact durable hook outcome has committed.
504
+ if (outcome.error && error)
505
+ for (const field of ["hook_retry_signature", "retry_command", "retry_instruction", "recoverable", "repeated_without_progress"]) {
506
+ if (Object.prototype.hasOwnProperty.call(error.details, field))
507
+ outcome.error.details[field] = error.details[field];
508
+ }
482
509
  };
483
510
  }
484
511
  function lifecycleDiagnostics(context, projectId, runId, generation) {
@@ -509,7 +536,7 @@ export function lifecycleRetryCommands(context, stage) {
509
536
  }
510
537
  export function managedInvocationContext(context, input) {
511
538
  const env = { ...context.env, DD_FLOW_CURRENT_INVOCATION: undefined, DD_FLOW_INVOCATION_SCOPE: undefined };
512
- if (!["zcode-acp", "codex-desktop", "grok-acp", "antigravity-cli"].includes(input.harness))
539
+ if (!["zcode-acp", "codex-desktop", "grok-acp", "antigravity-cli", "droid-cli", "opencode-server"].includes(input.harness))
513
540
  return { ...context, env };
514
541
  const project = requireProjectByRoot(context, input.projectRoot);
515
542
  const { state, process: daemon } = requireManagedDaemonBinding(context, { stateDir: input.stateDir, projectId: project.id, runId: input.runId });
@@ -547,6 +574,7 @@ export function managedLifecycleCommand(context, command, options = {}) {
547
574
  if (retained.scope_json !== JSON.stringify(scope)) {
548
575
  throw new AppError("invocation_scope_mismatch", "Lifecycle attempt belongs to another managed scope", 1, { invocation_id: suppliedId });
549
576
  }
577
+ assertPrivateStageStartContract(retained, command);
550
578
  checkCommand(retained, command);
551
579
  assertRenderableInvocation(retained);
552
580
  return publicInvocationCommand(context, retained, command);
@@ -742,12 +770,24 @@ export function assertLifecycleInvocationCurrent(context, scope, command) {
742
770
  }
743
771
  function checkCommand(row, command) {
744
772
  const parsed = invocation(command);
773
+ assertSuppliedLifecycleTarget(row, parsed);
745
774
  const scope = JSON.parse(row.scope_json);
746
775
  if (commandOption(parsed, "invocation-id") !== row.id || fingerprint(parsed) !== row.fingerprint
747
776
  || path.resolve(commandOption(parsed, "project-root") ?? "") !== scope.projectRoot) {
748
777
  throw new AppError("invocation_command_mismatch", "Lifecycle attempt belongs to another command or project", 1, { invocation_id: row.id });
749
778
  }
750
779
  }
780
+ /** Public commands may omit the coordinator RUN. A supplied target remains
781
+ * input to validate, not text to silently overwrite with retained authority. */
782
+ function assertSuppliedLifecycleTarget(row, actual) {
783
+ if (!["stage_start", "stage_finish", "recovery_accept"].includes(actual.operation) || actual.args.options.has("bootstrap"))
784
+ return;
785
+ const supplied = commandPosition(actual, 0), expected = commandPosition(invocation(row.command), 0);
786
+ if (supplied === undefined)
787
+ return;
788
+ if (!expected || ![expected, shortEntityReference(expected)].includes(supplied))
789
+ throw new AppError("invocation_argument_mismatch", "Supplied RUN target differs from its retained lifecycle assignment", 2, { invocation_id: row.id, parameter: "run", phase: "prepare", effect: "no_effect", recoverable: true });
790
+ }
751
791
  /** Explicit issuance; callers retain this command. Read-only show/status must
752
792
  * return that retained value, not issue a replacement on every observation. */
753
793
  export function prepareLifecycleIssuance(command, value) {
@@ -770,7 +810,7 @@ export function issueLifecycleInvocation(context, command, scope) {
770
810
  const id = crypto.randomUUID();
771
811
  const prepared = commandWithInvocationId(command, id);
772
812
  // Validate the actual inserted argv, including quoted heredoc boundaries.
773
- const row = { id, scope_json: JSON.stringify(scope), fingerprint: fingerprint(parsed) };
813
+ const row = { id, command: prepared, scope_json: JSON.stringify(scope), fingerprint: fingerprint(parsed) };
774
814
  checkCommand(row, prepared);
775
815
  context.db.run("INSERT INTO lifecycle_invocations (id, scope_json, command, fingerprint, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'issued', ?, ?)", [id, row.scope_json, prepared, row.fingerprint, context.now(), context.now()]);
776
816
  return { id, command: prepared };
@@ -810,19 +850,32 @@ function checkNativeIdentity(scope, identity, id) {
810
850
  }
811
851
  }
812
852
  function managedNativeOwner(context, identity, harness, projectId) {
853
+ const nativeRoot = (stateDir, sessionId) => {
854
+ try {
855
+ const state = readNativeDaemonState(stateDir);
856
+ if (state.daemon_id !== identity.daemonId)
857
+ return null;
858
+ const native = state.sessions?.find(native => typeof native === "string" ? native === sessionId : native.adapter_session_id === sessionId || native.provider_session_id === sessionId);
859
+ return typeof native === "string" ? native : native?.root_provider_session_id ?? native?.provider_session_id ?? null;
860
+ }
861
+ catch {
862
+ return null;
863
+ }
864
+ };
813
865
  const owners = [];
814
866
  const hasTable = (name) => Boolean(context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", [name]));
815
867
  if (hasTable("run_controllers")) {
816
- const controllers = context.db.all("SELECT c.run_id, r.status AS run_status, c.status, c.generation, c.state_json FROM run_controllers c JOIN runs r ON r.project_id = c.project_id AND r.id = c.run_id WHERE c.project_id = ?", [projectId]);
868
+ const controllers = context.db.all("SELECT c.controller_id, c.run_id, r.status AS run_status, c.status, c.generation, c.state_json FROM run_controllers c JOIN runs r ON r.project_id = c.project_id AND r.id = c.run_id WHERE c.project_id = ?", [projectId]);
817
869
  for (const controller of controllers) {
818
- if (!controller.state_json.includes(identity.rootSessionId))
819
- continue;
820
870
  const state = JSON.parse(controller.state_json);
821
871
  for (const [index, session] of (state.sessions ?? []).entries()) {
822
- if (session.id !== identity.rootSessionId)
872
+ if (typeof session.state_dir !== "string")
873
+ continue;
874
+ const root = nativeRoot(session.state_dir, session.id);
875
+ if (root !== identity.rootSessionId)
823
876
  continue;
824
877
  owners.push({ runId: controller.run_id, rootSessionId: session.id,
825
- stateDir: session.state_dir, harness: runtimeHarness(session.profile), generation: controller.generation,
878
+ stateDir: session.state_dir, harness: runtimeHarness(session.profile), generation: controller.generation, ownerId: controller.controller_id,
826
879
  active: ["running", "paused", "waiting_for_user", "blocked"].includes(controller.run_status)
827
880
  && !["completed", "cancelled", "superseded"].includes(controller.status)
828
881
  && state.current_session === index && session.stopped !== true });
@@ -830,8 +883,10 @@ function managedNativeOwner(context, identity, harness, projectId) {
830
883
  }
831
884
  }
832
885
  if (hasTable("work_launches")) {
833
- const launches = context.db.all("SELECT w.run_id, r.status AS run_status, l.work_id, l.operation_id, l.status, l.state_dir, l.provider_session_id, l.profile_json FROM work_launches l JOIN works w ON w.work_id = l.work_id JOIN runs r ON r.project_id = w.project_id AND r.id = w.run_id WHERE w.project_id = ? AND l.provider_session_id = ?", [projectId, identity.rootSessionId]);
886
+ const launches = context.db.all("SELECT w.run_id, r.status AS run_status, l.work_id, l.operation_id, l.status, l.state_dir, l.provider_session_id, l.profile_json FROM work_launches l JOIN works w ON w.work_id = l.work_id JOIN runs r ON r.project_id = w.project_id AND r.id = w.run_id WHERE w.project_id = ?", [projectId]);
834
887
  for (const launch of launches) {
888
+ if (nativeRoot(launch.state_dir, launch.provider_session_id) !== identity.rootSessionId)
889
+ continue;
835
890
  // Cooperative pause/drain may retain an admitted Work settlement while
836
891
  // the RUN is paused; only terminal RUN states retire its native root.
837
892
  const active = ["running", "paused", "waiting_for_user", "blocked"].includes(launch.run_status)
@@ -849,8 +904,11 @@ function managedNativeOwner(context, identity, harness, projectId) {
849
904
  }
850
905
  }
851
906
  if (hasTable("merge_requests")) {
852
- const requests = context.db.all("SELECT m.merge_request_id, m.run_id, r.status AS run_status, m.status, m.executor_work_id, m.dispatch_owner, m.dispatch_lease_token, m.adapter_receipt_json FROM merge_requests m JOIN runs r ON r.project_id = m.project_id AND r.id = m.run_id WHERE m.project_id = ? AND m.execution_route = 'server' AND CASE WHEN json_valid(m.adapter_receipt_json) THEN json_extract(m.adapter_receipt_json, '$.session_id') = ? ELSE 0 END", [projectId, identity.rootSessionId]);
907
+ const requests = context.db.all("SELECT m.merge_request_id, m.run_id, r.status AS run_status, m.status, m.executor_work_id, m.dispatch_owner, m.dispatch_lease_token, m.adapter_receipt_json FROM merge_requests m JOIN runs r ON r.project_id = m.project_id AND r.id = m.run_id WHERE m.project_id = ? AND m.execution_route = 'server' AND json_valid(m.adapter_receipt_json)", [projectId]);
853
908
  for (const request of requests) {
909
+ const retained = JSON.parse(request.adapter_receipt_json);
910
+ if (!retained.session_id || !retained.state_dir || nativeRoot(retained.state_dir, retained.session_id) !== identity.rootSessionId)
911
+ continue;
854
912
  const active = ["running", "paused", "waiting_for_user", "blocked"].includes(request.run_status)
855
913
  && ["dispatching", "active", "waiting_user", "action_required", "recovery_required"].includes(request.status);
856
914
  if (!active)
@@ -883,13 +941,16 @@ function managedNativeOwner(context, identity, harness, projectId) {
883
941
  harness: runtimeHarness(receipt.profile), ownerId: owner.owner_id, active });
884
942
  }
885
943
  }
944
+ if (owners.length > 1)
945
+ throw new AppError("invocation_scope_unproven", "Native root has more than one persisted owner", 1, { effect: "no_effect" });
886
946
  for (const owner of owners) {
887
947
  if (!owner.active)
888
948
  throw new AppError("invocation_scope_unproven", "Managed native root is no longer active", 1, { effect: "no_effect" });
889
949
  if (owner.harness !== harness)
890
950
  throw new AppError("invocation_scope_unproven", "Native root belongs to another managed harness", 1, { effect: "no_effect" });
891
951
  const binding = requireManagedDaemonBinding(context, { stateDir: owner.stateDir, projectId, runId: owner.runId, ...(owner.ownerId ? { ownerId: owner.ownerId } : {}) });
892
- if (binding.process.kind !== (harness === "grok-acp" ? "grok-daemon" : "agy-daemon")) {
952
+ const kind = { "grok-acp": "grok-daemon", "antigravity-cli": "agy-daemon", "zcode-acp": "zcode-daemon", "codex-desktop": "codex-daemon", "droid-cli": "droid-daemon", "opencode-server": "opencode-daemon" }[harness];
953
+ if (binding.process.kind !== kind) {
893
954
  throw new AppError("invocation_scope_unproven", "Native daemon kind differs from its persisted harness", 1, { effect: "no_effect" });
894
955
  }
895
956
  if (binding.state.daemon_id !== identity.daemonId)
@@ -900,17 +961,266 @@ function managedNativeOwner(context, identity, harness, projectId) {
900
961
  if (!root || !Number.isSafeInteger(generation) || generation < 0 || owner.generation !== undefined && generation !== owner.generation) {
901
962
  throw new AppError("invocation_scope_unproven", "Managed native root or RUN generation is not persisted", 1, { effect: "no_effect" });
902
963
  }
903
- return { projectId, runId: owner.runId, generation };
964
+ return { projectId, runId: owner.runId, generation, ...(owner.ownerId ? { ownerId: owner.ownerId } : {}) };
904
965
  }
905
966
  return null;
906
967
  }
968
+ /** Manual recovery retains native receipt authority, not controller issuance.
969
+ * Recognize only the existing pinned recovery dispatch and its live root. */
970
+ export function manualRecoveryInvocationScope(context, projectRoot, identity, harness) {
971
+ if (identity.sessionId !== identity.rootSessionId || identity.parentSessionId !== null
972
+ || !context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'run_recovery_bindings'"))
973
+ return null;
974
+ const project = context.db.get("SELECT id,root FROM projects WHERE root = ?", [canonicalPath(projectRoot)]);
975
+ if (!project)
976
+ return null;
977
+ let resolved = null;
978
+ for (const record of managedProcessStatus(context)) {
979
+ if (record.project_id !== project.id || !record.run_id || !record.kind.endsWith("-daemon"))
980
+ continue;
981
+ const metadata = JSON.parse(record.metadata_json);
982
+ if (typeof metadata.state_dir !== "string")
983
+ continue;
984
+ let state;
985
+ try {
986
+ state = readNativeDaemonState(metadata.state_dir);
987
+ }
988
+ catch {
989
+ continue;
990
+ }
991
+ if (state.daemon_id !== identity.daemonId)
992
+ continue;
993
+ const guard = recoveryGuard(context, project.id, record.run_id);
994
+ if (!guard || !["resuming", "recovered"].includes(guard.status) || metadata.run_generation !== guard.generation)
995
+ return null;
996
+ const run = context.db.get("SELECT status FROM runs WHERE project_id = ? AND id = ?", [project.id, record.run_id]);
997
+ if (!run || !["running", "paused", "waiting_for_user", "blocked"].includes(run.status))
998
+ return null;
999
+ if (context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'run_controllers'")
1000
+ && context.db.get("SELECT 1 FROM run_controllers WHERE project_id = ? AND run_id = ? AND generation = ? AND status NOT IN ('completed','cancelled','superseded')", [project.id, record.run_id, guard.generation]))
1001
+ return null;
1002
+ const binding = context.db.get("SELECT * FROM run_recovery_bindings WHERE recovery_id = ?", [guard.recovery_id]);
1003
+ if (!binding || binding.daemon_id !== identity.daemonId || binding.provider_session_id !== identity.rootSessionId || binding.harness !== harness
1004
+ || binding.status !== (guard.status === "resuming" ? "prepared" : "accepted") || !binding.runtime_dispatch_json)
1005
+ return null;
1006
+ const dispatch = JSON.parse(binding.runtime_dispatch_json);
1007
+ if (dispatch.process_id !== record.id || dispatch.owner_id !== record.owner_id || dispatch.state_dir !== metadata.state_dir
1008
+ || dispatch.daemon_id !== identity.daemonId || record.state !== "running" || record.lease_expires_at <= context.now() || !processIsAlive(record))
1009
+ return null;
1010
+ const physical = requireManagedDaemonBinding(context, { stateDir: metadata.state_dir, projectId: project.id, runId: record.run_id, ownerId: dispatch.owner_id });
1011
+ const kind = { "codex-desktop": "codex-daemon", "zcode-acp": "zcode-daemon", "grok-acp": "grok-daemon", "antigravity-cli": "agy-daemon", "droid-cli": "droid-daemon", "opencode-server": "opencode-daemon" }[harness];
1012
+ if (physical.process.id !== dispatch.process_id || physical.process.kind !== kind || !physical.state.sessions?.some(native => typeof native === "string" ? native === identity.rootSessionId
1013
+ : native.provider_session_id === identity.rootSessionId && (native.root_provider_session_id ?? native.provider_session_id) === identity.rootSessionId))
1014
+ return null;
1015
+ const packet = recoveryPacketReceipt(binding).packet;
1016
+ if (packet.run_id !== record.run_id || packet.generation !== guard.generation || packet.recovery_id !== guard.recovery_id)
1017
+ return null;
1018
+ if (resolved)
1019
+ throw new AppError("invocation_scope_unproven", "Manual recovery native root has more than one physical owner", 1, { effect: "no_effect" });
1020
+ resolved = { projectRoot: project.root, runId: record.run_id, generation: guard.generation, daemonId: identity.daemonId, rootSessionId: identity.rootSessionId };
1021
+ }
1022
+ return resolved;
1023
+ }
1024
+ /** Hook-only recovery commands must carry their own target, not acquire one
1025
+ * through assignment expansion. Existing recovery/Work guards own mutation. */
1026
+ export function assertManualRecoveryInvocationTarget(context, scope, command) {
1027
+ const parsed = invocation(command);
1028
+ const projectRoot = commandOption(parsed, "project-root");
1029
+ if (!["recovery_accept", "stage_start", "stage_finish", "stage_pause", "stage_resume", "work_start", "work_finish", "work_fail", "merge_apply", "merge_repair"].includes(parsed.operation)
1030
+ || parsed.args.options.has("bootstrap") || !commandPosition(parsed, 0) || !projectRoot
1031
+ || projectRoot !== "@project" && (!path.isAbsolute(projectRoot) || canonicalPath(projectRoot) !== scope.projectRoot)) {
1032
+ throw new AppError("invocation_scope_mismatch", "Manual recovery requires an explicit owned target and project", 1, { effect: "no_effect" });
1033
+ }
1034
+ if (parsed.operation === "recovery_accept" && commandOption(parsed, "recovery-id") !== recoveryGuard(context, requireProjectByRoot(context, scope.projectRoot).id, scope.runId)?.recovery_id)
1035
+ throw new AppError("invocation_scope_mismatch", "Recovery acknowledgement targets another recovery generation", 1, { effect: "no_effect" });
1036
+ assertLifecycleInvocationCurrent(context, scope, command);
1037
+ }
1038
+ /** Resolve scope from the physical runtime owner, never from a command match. */
1039
+ export function managedNativeInvocationScope(context, projectRoot, identity, harness) {
1040
+ const project = context.db.get("SELECT id,root FROM projects WHERE root = ?", [canonicalPath(projectRoot)]);
1041
+ // Bootstrap native hooks precede project registration. They may retain
1042
+ // evidence, but have no managed RUN authority to resolve yet.
1043
+ if (!project)
1044
+ return null;
1045
+ const owner = managedNativeOwner(context, identity, harness, project.id);
1046
+ if (owner)
1047
+ return { projectRoot: project.root, daemonId: identity.daemonId, rootSessionId: identity.rootSessionId, runId: owner.runId, generation: owner.generation };
1048
+ const manualRecovery = manualRecoveryInvocationScope(context, project.root, identity, harness);
1049
+ if (manualRecovery)
1050
+ return manualRecovery;
1051
+ for (const record of managedProcessStatus(context)) {
1052
+ const metadata = JSON.parse(record.metadata_json);
1053
+ if (!record.kind.endsWith("-daemon") || typeof metadata.state_dir !== "string")
1054
+ continue;
1055
+ let state;
1056
+ try {
1057
+ state = readNativeDaemonState(metadata.state_dir);
1058
+ }
1059
+ catch {
1060
+ continue;
1061
+ }
1062
+ if (state.daemon_id === identity.daemonId && state.config?.runtime_owner)
1063
+ throw new AppError("invocation_scope_unproven", "Managed daemon has no retained native owner", 1, { effect: "no_effect" });
1064
+ }
1065
+ return null;
1066
+ }
1067
+ export function cliManagedInvocationScope(context) {
1068
+ const daemonId = context.env.DD_FLOW_DAEMON_ID;
1069
+ if (!daemonId)
1070
+ return null;
1071
+ const scopes = [];
1072
+ for (const record of managedProcessStatus(context)) {
1073
+ const metadata = JSON.parse(record.metadata_json);
1074
+ if (!record.kind.endsWith("-daemon") || typeof metadata.state_dir !== "string")
1075
+ continue;
1076
+ let state;
1077
+ try {
1078
+ state = readNativeDaemonState(metadata.state_dir);
1079
+ }
1080
+ catch {
1081
+ continue;
1082
+ }
1083
+ if (state.daemon_id !== daemonId || !record.project_id || !record.run_id)
1084
+ continue;
1085
+ const harness = { "codex-daemon": "codex-desktop", "zcode-daemon": "zcode-acp", "grok-daemon": "grok-acp", "agy-daemon": "antigravity-cli", "droid-daemon": "droid-cli", "opencode-daemon": "opencode-server" }[record.kind];
1086
+ const project = context.db.get("SELECT root FROM projects WHERE id = ?", [record.project_id]);
1087
+ if (!harness || !project)
1088
+ continue;
1089
+ requireManagedDaemonBinding(context, { stateDir: metadata.state_dir, projectId: record.project_id, runId: record.run_id });
1090
+ const roots = new Set((state.sessions ?? []).flatMap(native => typeof native === "string" ? [native] : native.root_provider_session_id ? [native.root_provider_session_id] : native.provider_session_id ? [native.provider_session_id] : []));
1091
+ for (const rootSessionId of roots) {
1092
+ let scope;
1093
+ try {
1094
+ scope = managedNativeInvocationScope(context, project.root, { daemonId, rootSessionId, sessionId: rootSessionId, parentSessionId: null, toolCallId: "cli-scope-inspection" }, harness);
1095
+ }
1096
+ catch (error) {
1097
+ if (error instanceof AppError && error.code === "invocation_scope_unproven")
1098
+ continue;
1099
+ throw error;
1100
+ }
1101
+ if (scope && !scopes.some(prior => JSON.stringify(prior) === JSON.stringify(scope)))
1102
+ scopes.push(scope);
1103
+ }
1104
+ }
1105
+ if (scopes.length !== 1)
1106
+ throw new AppError("invocation_scope_unproven", "CLI has no unique persisted current managed native scope", 1, { effect: "no_effect" });
1107
+ return scopes[0];
1108
+ }
1109
+ export function isControllerManagedInvocation(context, projectRoot, runId) {
1110
+ const configured = context.env.DD_FLOW_INVOCATION_SCOPE;
1111
+ if (!configured)
1112
+ return false;
1113
+ const scope = JSON.parse(configured);
1114
+ if (context.env.DD_FLOW_CURRENT_INVOCATION) {
1115
+ const current = load(context, context.env.DD_FLOW_CURRENT_INVOCATION);
1116
+ const identity = current.identity_json && JSON.parse(current.identity_json);
1117
+ if (identity && identity[2] !== scope.rootSessionId)
1118
+ throw new AppError("invocation_scope_unproven", "A native child cannot issue coordinator Stage successors", 1);
1119
+ }
1120
+ if (scope.projectRoot !== canonicalPath(projectRoot) || scope.runId !== runId)
1121
+ throw new AppError("invocation_scope_mismatch", "Successor differs from retained lifecycle scope", 1);
1122
+ const projectId = requireProjectByRoot(context, projectRoot).id;
1123
+ const rows = context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'run_controllers'")
1124
+ ? context.db.all("SELECT controller_id,generation,status,state_json FROM run_controllers WHERE project_id = ? AND run_id = ?", [projectId, runId]) : [];
1125
+ let matches = 0;
1126
+ for (const row of rows) {
1127
+ const state = JSON.parse(row.state_json);
1128
+ const session = state.sessions?.[state.current_session];
1129
+ if (!session)
1130
+ continue;
1131
+ let nativeState;
1132
+ try {
1133
+ nativeState = readNativeDaemonState(session.state_dir);
1134
+ }
1135
+ catch {
1136
+ continue;
1137
+ }
1138
+ if (nativeState.daemon_id !== scope.daemonId)
1139
+ continue;
1140
+ const binding = requireManagedDaemonBinding(context, { stateDir: session.state_dir, projectId, runId, ownerId: row.controller_id });
1141
+ if (binding.state.daemon_id !== scope.daemonId)
1142
+ continue;
1143
+ const native = binding.state.sessions?.find(native => typeof native === "string" ? native === session.id && native === scope.rootSessionId
1144
+ : (native.adapter_session_id === session.id || native.provider_session_id === session.id) && (native.root_provider_session_id ?? native.provider_session_id) === scope.rootSessionId);
1145
+ const generation = JSON.parse(binding.process.metadata_json).run_generation;
1146
+ if (!native || session.stopped || ["completed", "cancelled", "superseded"].includes(row.status) || row.generation !== scope.generation || generation !== scope.generation)
1147
+ throw new AppError("invocation_scope_unproven", "Controller successor owner is stale or unproven", 1);
1148
+ matches += 1;
1149
+ }
1150
+ if (matches > 1)
1151
+ throw new AppError("invocation_scope_unproven", "Stage successor has multiple coordinator owners", 1);
1152
+ if (matches === 1)
1153
+ return true;
1154
+ for (const record of managedProcessStatus(context)) {
1155
+ const metadata = JSON.parse(record.metadata_json);
1156
+ if (!record.kind.endsWith("-daemon") || typeof metadata.state_dir !== "string")
1157
+ continue;
1158
+ let state;
1159
+ try {
1160
+ state = readNativeDaemonState(metadata.state_dir);
1161
+ }
1162
+ catch {
1163
+ continue;
1164
+ }
1165
+ if (state.daemon_id !== scope.daemonId)
1166
+ continue;
1167
+ if (/^run:.+:(?:work|merge):/.test(record.owner_id))
1168
+ return false;
1169
+ throw new AppError("invocation_scope_unproven", "Managed coordinator has no retained current controller Session", 1);
1170
+ }
1171
+ throw new AppError("invocation_scope_unproven", "Managed successor has no proven physical owner", 1);
1172
+ }
907
1173
  /** recordReceipt is a synchronous SQL-only hook ingress callback. Native
908
1174
  * adapters must prepare any filesystem or process evidence before calling. */
909
1175
  export function observeLifecycleInvocation(context, input) {
910
1176
  storage(context);
1177
+ const projectRoot = input.projectId ? context.db.get("SELECT root FROM projects WHERE id = ?", [input.projectId])?.root : undefined;
1178
+ const provenScope = projectRoot && input.harness ? managedNativeInvocationScope(context, projectRoot, input.identity, input.harness) : null;
1179
+ const owner = provenScope && input.harness && input.projectId ? managedNativeOwner(context, input.identity, input.harness, input.projectId) : null;
1180
+ const ownerSnapshot = () => {
1181
+ if (!provenScope?.runId || !input.projectId)
1182
+ return null;
1183
+ const rows = [context.db.get("SELECT status FROM runs WHERE project_id = ? AND id = ?", [input.projectId, provenScope.runId])];
1184
+ if (context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'run_controllers'"))
1185
+ rows.push(context.db.all("SELECT controller_id,generation,status,owner_token,state_json FROM run_controllers WHERE project_id = ? AND run_id = ? AND controller_id = ? ORDER BY controller_id", [input.projectId, provenScope.runId, owner?.ownerId ?? ""]).map(row => {
1186
+ const state = JSON.parse(row.state_json), session = state.sessions?.[state.current_session];
1187
+ return [row.controller_id, row.generation, row.status, row.owner_token, state.current_session, session && [session.id, session.state_dir, session.stopped, session.owner]];
1188
+ }));
1189
+ for (const [table, query] of [
1190
+ ["work_launches", "SELECT l.status,l.provider_session_id,l.profile_json FROM work_launches l JOIN works w ON w.work_id = l.work_id WHERE w.project_id = ? AND w.run_id = ? AND CASE WHEN json_valid(l.profile_json) THEN json_extract(l.profile_json, '$.runtime_adapter.owner.owner_id') END = ? ORDER BY l.operation_id"],
1191
+ ["merge_requests", "SELECT status,dispatch_owner,dispatch_lease_token,adapter_receipt_json FROM merge_requests WHERE project_id = ? AND run_id = ? AND (dispatch_owner = ? OR CASE WHEN json_valid(adapter_receipt_json) THEN json_extract(adapter_receipt_json, '$.owner.owner_id') END = ?) ORDER BY merge_request_id"]
1192
+ ])
1193
+ if (context.db.get("SELECT 1 FROM sqlite_master WHERE name = ?", [table]))
1194
+ rows.push(context.db.all(query, table === "merge_requests" ? [input.projectId, provenScope.runId, owner?.ownerId ?? "", owner?.ownerId ?? ""] : [input.projectId, provenScope.runId, owner?.ownerId ?? ""]));
1195
+ if (context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'run_recovery_bindings'"))
1196
+ rows.push(context.db.all("SELECT g.recovery_id,g.generation,g.status,b.* FROM run_recovery_guards g JOIN run_recovery_bindings b ON b.recovery_id = g.recovery_id WHERE g.project_id = ? AND g.run_id = ? ORDER BY g.generation", [input.projectId, provenScope.runId]));
1197
+ return JSON.stringify(rows);
1198
+ };
1199
+ const retainedOwner = ownerSnapshot();
911
1200
  const supplied = input.id ? context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [input.id]) : undefined;
912
1201
  const analysis = parseLifecycleCommand(input.command);
913
1202
  if (analysis.kind === "none" || analysis.supported === false) {
1203
+ let issuedBarrier;
1204
+ const singleLeaf = analysis.kind !== "none" && (analysis.invocations?.length ?? 1) === 1 && !(analysis.kind === "compound" && analysis.reason === "multiple_lifecycle_invocations");
1205
+ const refused = singleLeaf ? context.db.all("SELECT * FROM lifecycle_invocations WHERE status = 'issued'").filter(row => {
1206
+ const scope = JSON.parse(row.scope_json);
1207
+ if (scope.daemonId !== input.identity.daemonId || scope.rootSessionId !== input.identity.rootSessionId || provenScope && row.scope_json !== JSON.stringify(provenScope))
1208
+ return false;
1209
+ try {
1210
+ return fingerprint(invocation(canonicalManagedCommand(context, analysis.invocation.command, scope))) === row.fingerprint;
1211
+ }
1212
+ catch {
1213
+ return false;
1214
+ }
1215
+ }) : [];
1216
+ const correction = supplied ?? (refused.length === 1 ? refused[0] : undefined);
1217
+ if (singleLeaf && correction && correction.status === "issued" && (correction.deadline === null || correction.deadline > Date.now())) {
1218
+ const scope = JSON.parse(correction.scope_json);
1219
+ checkNativeIdentity(scope, input.identity, correction.id);
1220
+ assertSuppliedLifecycleTarget(correction, invocation(canonicalManagedCommand(context, analysis.invocation.command, scope)));
1221
+ if ((!provenScope || correction.scope_json === JSON.stringify(provenScope)) && fingerprint(invocation(canonicalManagedCommand(context, analysis.invocation.command, scope))) === correction.fingerprint)
1222
+ issuedBarrier = { issued_invocation_id: correction.id, cli_admission_barrier: true };
1223
+ }
914
1224
  const eventKey = input.recordReceipt();
915
1225
  if (analysis.kind !== "none") {
916
1226
  const retained = context.db.get("SELECT outcome_json FROM hook_events WHERE event_key = ?", [eventKey]);
@@ -933,7 +1243,7 @@ export function observeLifecycleInvocation(context, input) {
933
1243
  }), eventKey]);
934
1244
  }
935
1245
  if (analysis.kind !== "none")
936
- throw new AppError("lifecycle_shell_syntax_invalid", "This shell form cannot bind exactly one lifecycle execution; use the exact issued lifecycle command without the unsupported shell context", 1, { phase: "prepare", effect: "no_effect", recoverable: true, disposition: "pre_cli_shell_refusal", reason: analysis.kind === "compound" ? analysis.reason : "unsupported_lifecycle_command", event_key: eventKey });
1246
+ throw new AppError("lifecycle_shell_syntax_invalid", "This shell form cannot bind exactly one lifecycle execution; use the exact issued lifecycle command without the unsupported shell context", 1, { phase: "prepare", effect: "no_effect", recoverable: true, disposition: "pre_cli_shell_refusal", reason: analysis.kind === "compound" ? analysis.reason : "unsupported_lifecycle_command", event_key: eventKey, ...issuedBarrier });
937
1247
  return { eventKey, duplicate: false, invocationId: null };
938
1248
  }
939
1249
  // Admit the one parsed leaf, never execute or fingerprint the prefix/suffix.
@@ -942,26 +1252,39 @@ export function observeLifecycleInvocation(context, input) {
942
1252
  throw new AppError("invocation_directory_mismatch", "Bound lifecycle execution cwd must be absolute", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
943
1253
  const deliveredIdentity = JSON.stringify([input.identity.daemonId, input.identity.rootSessionId, input.identity.sessionId, input.identity.parentSessionId, input.identity.toolCallId,
944
1254
  ...(input.executionCwd === undefined ? [] : [{ executionCwd: canonicalPath(input.executionCwd) }])]);
945
- const candidates = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('issued','observed','executing','settled') ORDER BY rowid DESC")
1255
+ const matching = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('issued','observed','executing','settled') ORDER BY rowid DESC")
946
1256
  .filter(row => {
947
- if (["executing", "settled"].includes(row.status) && row.identity_json !== deliveredIdentity)
948
- return false;
949
1257
  const scope = JSON.parse(row.scope_json);
950
1258
  if (scope.daemonId !== input.identity.daemonId || scope.rootSessionId !== input.identity.rootSessionId)
951
1259
  return false;
1260
+ if (provenScope && row.scope_json !== JSON.stringify(provenScope))
1261
+ return false;
952
1262
  try {
953
- return fingerprint(invocation(canonicalManagedCommand(context, leafCommand, scope))) === row.fingerprint;
1263
+ if (fingerprint(invocation(canonicalManagedCommand(context, leafCommand, scope))) !== row.fingerprint)
1264
+ return false;
954
1265
  }
955
1266
  catch {
956
1267
  return false;
957
1268
  }
1269
+ if (row.identity_json && row.identity_json !== deliveredIdentity
1270
+ && JSON.stringify(JSON.parse(row.identity_json).slice(0, 5)) === JSON.stringify(JSON.parse(deliveredIdentity).slice(0, 5))) {
1271
+ throw new AppError("invocation_identity_mismatch", "The same native tool call changed its bound execution route", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
1272
+ }
1273
+ if (row.status === "executing" && row.identity_json !== deliveredIdentity)
1274
+ return false;
1275
+ if (row.status === "settled" && row.outcome_json && JSON.parse(row.outcome_json).error && row.identity_json !== deliveredIdentity)
1276
+ return false;
1277
+ return true;
958
1278
  });
1279
+ const candidates = lifecycleCandidates(matching, deliveredIdentity);
959
1280
  // A known explicit ID remains a compatibility path. A missing or mistyped
960
1281
  // public ID is corrected only by an exact operation/target match inside the
961
1282
  // trusted native root. Never fuzzy-match UUID text or select a global latest.
962
1283
  const initial = supplied ?? (candidates.length === 1 ? candidates[0] : undefined);
963
- const owner = ["grok-acp", "antigravity-cli"].includes(input.harness ?? "") && input.projectId
964
- ? managedNativeOwner(context, input.identity, input.harness, input.projectId) : null;
1284
+ if (!supplied && candidates.length > 1) {
1285
+ input.recordReceipt();
1286
+ 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), ...(provenScope ? { lifecycle_assignment: { issuer: "dd-flow", scope: provenScope } } : {}) });
1287
+ }
965
1288
  if (owner) {
966
1289
  if (!initial)
967
1290
  throw new AppError("invocation_assignment_missing", "Managed native lifecycle call has no issued assignment", 1, { effect: "no_effect", recoverable: false });
@@ -971,24 +1294,33 @@ export function observeLifecycleInvocation(context, input) {
971
1294
  }
972
1295
  }
973
1296
  if (!initial) {
974
- const eventKey = input.recordReceipt();
975
- if (candidates.length > 1)
976
- 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) });
977
- return { eventKey, duplicate: false, invocationId: null };
1297
+ if (provenScope)
1298
+ assertManualRecoveryInvocationTarget(context, provenScope, leafCommand);
1299
+ return context.db.writeTransaction(() => {
1300
+ if (retainedOwner !== ownerSnapshot())
1301
+ throw new AppError("invocation_scope_unproven", "Native owner changed before hook-only recovery receipt", 1, { effect: "no_effect" });
1302
+ const eventKey = input.recordReceipt();
1303
+ return { eventKey, duplicate: false, invocationId: null };
1304
+ });
978
1305
  }
979
1306
  const resolved = input.id === initial.id ? {} : { invocationId: initial.id };
1307
+ assertSuppliedLifecycleTarget(initial, invocation(canonicalManagedCommand(context, leafCommand, JSON.parse(initial.scope_json))));
1308
+ const scope = JSON.parse(initial.scope_json);
1309
+ checkNativeIdentity(scope, input.identity, initial.id);
980
1310
  // A completed invocation is a read, even from a new native tool call after
981
1311
  // reattach. Do not manufacture a new receipt or reject that read as a second
982
1312
  // executor; the CLI returns the immutable stored outcome without dispatch.
983
1313
  if (initial.status === "settled")
984
1314
  return { eventKey: initial.event_key, duplicate: true, ...resolved };
985
- const scope = JSON.parse(initial.scope_json);
986
1315
  const project = requireProjectByRoot(context, scope.projectRoot);
987
1316
  const identity = input.identity;
988
- checkNativeIdentity(scope, identity, initial.id);
989
1317
  const identityJson = deliveredIdentity;
990
1318
  return context.db.writeTransaction(() => {
991
1319
  const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [initial.id]);
1320
+ if (retainedOwner !== ownerSnapshot())
1321
+ throw new AppError("invocation_scope_unproven", "Native owner changed before admission receipt", 1, { effect: "no_effect" });
1322
+ if (row.scope_json !== initial.scope_json)
1323
+ throw new AppError("invocation_scope_mismatch", "Lifecycle scope changed before native receipt", 1, { effect: "no_effect" });
992
1324
  if (row.status === "settled")
993
1325
  return { eventKey: row.event_key, duplicate: true, ...resolved };
994
1326
  // Observation records native facts only. CLI owns deadline, argument and
@@ -1025,8 +1357,6 @@ export function observeLifecycleInvocation(context, input) {
1025
1357
  /** Correlate exact native argv for diagnostics, independently of assignment
1026
1358
  * matching. A hook-added event key must name the same persisted native call. */
1027
1359
  export function observedLifecycleNativeEvent(context, command, daemonId) {
1028
- if (!context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'hook_events'"))
1029
- return null;
1030
1360
  const signature = (value) => {
1031
1361
  const parsed = parseLifecycleCommand(value);
1032
1362
  const candidate = parsed.kind !== "none" && parsed.supported ? parsed.invocation : continuationCommand(value);
@@ -1038,6 +1368,8 @@ export function observedLifecycleNativeEvent(context, command, daemonId) {
1038
1368
  const expected = signature(command);
1039
1369
  if (!expected)
1040
1370
  return null;
1371
+ if (!context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'hook_events'"))
1372
+ return null;
1041
1373
  const parsed = parseLifecycleCommand(command);
1042
1374
  const eventKey = parsed.kind === "standalone" ? commandOption(parsed.invocation, "hook-event-id") : undefined;
1043
1375
  const matches = context.db.all(daemonId
@@ -1086,20 +1418,29 @@ export function observedLifecycleDiagnostic(context, command, daemonId, invocati
1086
1418
  /** Resolve only a command that a native hook has already bound to an issued
1087
1419
  * attempt. This is the CLI-side half of admission for model-facing commands
1088
1420
  * that intentionally omit the internal UUID. */
1089
- export function observedLifecycleInvocation(context, command, daemonId) {
1421
+ export function observedLifecycleInvocation(context, command, scopeConstraint, correlation) {
1090
1422
  if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
1091
1423
  return null;
1092
1424
  const analysis = parseLifecycleCommand(command);
1093
1425
  if (analysis.kind === "none" || analysis.supported === false)
1094
1426
  return null;
1095
1427
  const leafCommand = analysis.invocation.command;
1428
+ const suppliedId = commandOption(analysis.invocation, "invocation-id");
1429
+ const retainedId = suppliedId && context.db.get("SELECT id FROM lifecycle_invocations WHERE id = ?", [suppliedId]) ? suppliedId : undefined;
1096
1430
  // `issued` is a rendezvous, not authority: the CLI still blocks in
1097
1431
  // awaitLifecycleInvocation until the native observer commits the receipt.
1098
- const matches = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('issued','observed','executing') ORDER BY rowid DESC")
1432
+ const matching = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('issued','observed','executing','settled') ORDER BY rowid DESC")
1099
1433
  .filter(row => {
1100
1434
  try {
1435
+ const explicitId = retainedId;
1436
+ const eventKey = commandOption(analysis.invocation, "hook-event-id") ?? correlation?.eventKey;
1437
+ if (explicitId && row.id !== explicitId || eventKey && row.event_key !== eventKey)
1438
+ return false;
1439
+ if (row.status === "settled" && (!row.outcome_json || JSON.parse(row.outcome_json).error) && !explicitId && !eventKey && !correlation?.identity)
1440
+ return false;
1101
1441
  const scope = JSON.parse(row.scope_json);
1102
- if (daemonId && scope.daemonId !== daemonId)
1442
+ const constraint = typeof scopeConstraint === "string" ? { daemonId: scopeConstraint } : scopeConstraint;
1443
+ if (constraint && Object.entries(constraint).some(([key, value]) => scope[key] !== value))
1103
1444
  return false;
1104
1445
  return fingerprint(invocation(canonicalManagedCommand(context, leafCommand, scope))) === row.fingerprint;
1105
1446
  }
@@ -1107,12 +1448,40 @@ export function observedLifecycleInvocation(context, command, daemonId) {
1107
1448
  return false;
1108
1449
  }
1109
1450
  });
1451
+ const identity = correlation?.identity;
1452
+ const matches = lifecycleCandidates(matching, identity ? JSON.stringify([identity.daemonId, identity.rootSessionId, identity.sessionId, identity.parentSessionId, identity.toolCallId]) : undefined, true);
1110
1453
  if (!matches.length)
1111
1454
  return null;
1112
1455
  if (matches.length !== 1)
1113
1456
  throw new AppError("invocation_ambiguous", "More than one observed lifecycle attempt matches this command", 1, { effect: "no_effect", matches: matches.map(row => row.id) });
1114
1457
  return { id: matches[0].id, scope: JSON.parse(matches[0].scope_json) };
1115
1458
  }
1459
+ /** A new native call may consume a successor, but never a historical result.
1460
+ * Exact native delivery replays its immutable assignment. CLI lookup without
1461
+ * native correlation remains ambiguous when history and a successor coexist. */
1462
+ function lifecycleCandidates(rows, identity, lookup = false) {
1463
+ if (identity) {
1464
+ const replay = rows.filter(row => {
1465
+ if (!row.identity_json)
1466
+ return false;
1467
+ if (row.identity_json === identity)
1468
+ return true;
1469
+ if (!lookup)
1470
+ return false;
1471
+ try {
1472
+ const retained = JSON.parse(row.identity_json);
1473
+ return Array.isArray(retained) && JSON.stringify(retained.slice(0, 5)) === identity;
1474
+ }
1475
+ catch {
1476
+ return false;
1477
+ }
1478
+ });
1479
+ if (replay.length)
1480
+ return replay;
1481
+ }
1482
+ const active = rows.filter(row => row.status !== "settled");
1483
+ return (!lookup || identity) && active.length ? active : rows;
1484
+ }
1116
1485
  /** Restore private assignment arguments without discarding the provider's
1117
1486
  * verified public input. The fingerprint deliberately projects semantic
1118
1487
  * values such as a Work failure reason and a Work result-file path. */
@@ -1121,6 +1490,7 @@ export function resolveObservedLifecycleArgs(context, id, actualArgs) {
1121
1490
  if (!row)
1122
1491
  throw new AppError("invocation_unknown", "Lifecycle invocation is not registered", 1, { invocation_id: id });
1123
1492
  const retained = invocation(row.command), actual = invocation(`${launchExpression(retained)} ${quoteModelFacingArgs(actualArgs)}`);
1493
+ assertSuppliedLifecycleTarget(row, actual);
1124
1494
  if (fingerprint(actual) !== row.fingerprint)
1125
1495
  throw new AppError("invocation_command_mismatch", "Lifecycle command does not match its issued assignment", 2, { phase: "prepare", effect: "no_effect", recoverable: true });
1126
1496
  for (const key of runtimeOwnedOptions[retained.operation] ?? []) {
@@ -1369,7 +1739,9 @@ export async function awaitLifecycleInvocation(context, input) {
1369
1739
  if (typeof route === "string" && canonicalPath(process.cwd()) !== canonicalPath(route))
1370
1740
  throw new AppError("invocation_directory_mismatch", "Reached CLI cwd differs from the bound shell execution route", 1, { phase: "prepare", effect: "no_effect", recoverable: false, expected: route, actual: process.cwd(), actual_provenance: "cli_process_cwd" });
1371
1741
  }
1372
- const claimed = context.db.run("UPDATE lifecycle_invocations SET status = 'executing', updated_at = ? WHERE id = ? AND status = 'observed'", [context.now(), input.id]);
1742
+ // Another native observation can replace an unclaimed receipt. Claim
1743
+ // exactly the identity/route checked above, or reload and validate it.
1744
+ const claimed = context.db.run("UPDATE lifecycle_invocations SET status = 'executing', updated_at = ? WHERE id = ? AND status = 'observed' AND event_key IS ? AND identity_json IS ?", [context.now(), input.id, row.event_key, row.identity_json]);
1373
1745
  if (claimed.changes === 1)
1374
1746
  return { eventKey: row.event_key, replay: false };
1375
1747
  continue;