@deksden-com/dd-flow-cli 0.9.0-beta.11 → 0.9.0-beta.110

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 (162) hide show
  1. package/CHANGELOG.md +520 -0
  2. package/README.md +68 -0
  3. package/dist/build-info.json +8 -8
  4. package/dist/cli/command-inputs.js +305 -0
  5. package/dist/cli/help.js +89 -13
  6. package/dist/cli/hook-ingress.js +77 -0
  7. package/dist/cli/input-preparation.js +116 -0
  8. package/dist/cli/native-hook-ingress.js +48 -0
  9. package/dist/cli/run-cli.js +1465 -384
  10. package/dist/cli.js +8 -2
  11. package/dist/harness-runtime/DD-ZCODE.md +99 -0
  12. package/dist/harness-runtime/bin/dd-agy.mjs +52 -0
  13. package/dist/harness-runtime/bin/dd-codex.mjs +26 -0
  14. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  15. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  16. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  17. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  18. package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
  19. package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
  20. package/dist/harness-runtime/lib/daemon-operations.mjs +226 -0
  21. package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
  22. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +500 -0
  23. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  24. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  25. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +149 -0
  26. package/dist/harness-runtime/lib/dd-codex.mjs +563 -0
  27. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  28. package/dist/harness-runtime/lib/dd-droid.mjs +475 -0
  29. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +388 -0
  30. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  31. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +219 -0
  32. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  33. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +645 -0
  34. package/dist/harness-runtime/lib/dd-zcode.mjs +982 -0
  35. package/dist/harness-runtime/lib/delegation-instructions.d.mts +13 -0
  36. package/dist/harness-runtime/lib/delegation-instructions.mjs +144 -0
  37. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  38. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  39. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  40. package/dist/harness-runtime/lib/managed-daemon.mjs +327 -0
  41. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  42. package/dist/harness-runtime/lib/native-hook-command.d.mts +3 -0
  43. package/dist/harness-runtime/lib/native-hook-command.mjs +108 -0
  44. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  45. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  46. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  47. package/dist/harness-runtime/lib/process-json.mjs +71 -0
  48. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  49. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  50. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  51. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  52. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  53. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  54. package/dist/harness-runtime/lib/zcode-usage.mjs +54 -0
  55. package/dist/protocol/local-files.js +18 -9
  56. package/dist/runtime/context.js +2 -2
  57. package/dist/schemas/agent-profile.schema.json +1 -1
  58. package/dist/schemas/code-review-result.schema.json +1 -1
  59. package/dist/schemas/code-work-batch.schema.json +3 -3
  60. package/dist/schemas/code-work-result.schema.json +3 -3
  61. package/dist/schemas/plan-review-result.schema.json +2 -2
  62. package/dist/schemas/run-control-receipt.schema.json +99 -0
  63. package/dist/schemas/run-control-request.schema.json +36 -0
  64. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  65. package/dist/services/canon.js +11 -8
  66. package/dist/services/cleanup.js +132 -39
  67. package/dist/services/cli-operation-classifier.js +22 -24
  68. package/dist/services/code-checks.js +589 -106
  69. package/dist/services/codex-hook-delivery.js +28 -0
  70. package/dist/services/command-context.js +80 -0
  71. package/dist/services/config.js +11 -8
  72. package/dist/services/continuation-outcome.js +20 -0
  73. package/dist/services/controller-fanout.js +173 -0
  74. package/dist/services/dashboard.js +103 -31
  75. package/dist/services/engines.js +121 -76
  76. package/dist/services/eval-snapshots.js +994 -97
  77. package/dist/services/execution-policy.js +181 -0
  78. package/dist/services/external-work-launch.js +166 -0
  79. package/dist/services/harness-adapter.js +147 -24
  80. package/dist/services/harness-config.js +2 -0
  81. package/dist/services/hooks.js +702 -327
  82. package/dist/services/lanes.js +78 -65
  83. package/dist/services/lifecycle-command.js +113 -18
  84. package/dist/services/lifecycle-contract.js +30 -0
  85. package/dist/services/lifecycle-invocations.js +1379 -0
  86. package/dist/services/managed-daemon-binding.js +38 -0
  87. package/dist/services/managed-processes.js +302 -34
  88. package/dist/services/merge-queue.js +228 -149
  89. package/dist/services/merge-server.js +66 -28
  90. package/dist/services/migrations.js +37 -16
  91. package/dist/services/native-daemon-history.js +49 -0
  92. package/dist/services/native-session-control.js +39 -0
  93. package/dist/services/plan-runtime.js +26 -11
  94. package/dist/services/plans.js +28 -25
  95. package/dist/services/projects.js +20 -15
  96. package/dist/services/prompts.js +25 -16
  97. package/dist/services/protocols.js +160 -86
  98. package/dist/services/recovery-observation-budget.js +61 -0
  99. package/dist/services/recovery-snapshot-database.js +142 -0
  100. package/dist/services/repair-intents.js +40 -0
  101. package/dist/services/review-copy.js +136 -0
  102. package/dist/services/run-control-receipt.js +56 -0
  103. package/dist/services/run-control-worker.js +351 -0
  104. package/dist/services/run-control.js +888 -0
  105. package/dist/services/run-controller-adapter.js +231 -0
  106. package/dist/services/run-controller-capture.js +167 -0
  107. package/dist/services/run-controller-process.js +180 -0
  108. package/dist/services/run-controller-recovery.js +221 -0
  109. package/dist/services/run-controller-state.js +36 -0
  110. package/dist/services/run-controller.js +798 -0
  111. package/dist/services/run-engine-bindings.js +1 -1
  112. package/dist/services/run-fork.js +142 -0
  113. package/dist/services/run-observations.js +112 -0
  114. package/dist/services/run-projection.js +22 -64
  115. package/dist/services/run-recovery-runtime.js +80 -0
  116. package/dist/services/run-recovery.js +345 -0
  117. package/dist/services/runs.js +536 -181
  118. package/dist/services/runtime-budget.js +413 -0
  119. package/dist/services/runtime-command.js +43 -0
  120. package/dist/services/runtime-scope-capture.js +52 -0
  121. package/dist/services/runtime-scope-control.js +460 -0
  122. package/dist/services/runtime-scope-resume.js +577 -0
  123. package/dist/services/runtime-scope-stop.js +159 -0
  124. package/dist/services/runtime-scope-worker.js +242 -0
  125. package/dist/services/runtime-service.js +97 -0
  126. package/dist/services/schema-validation.js +10 -8
  127. package/dist/services/scope-writer-cleanup.js +26 -0
  128. package/dist/services/sessions.js +35 -69
  129. package/dist/services/stage-blocker.js +17 -7
  130. package/dist/services/stage-context.js +50 -19
  131. package/dist/services/stage-lifecycle.js +100 -103
  132. package/dist/services/stage-pause.js +88 -75
  133. package/dist/services/stage-report-renderer.js +116 -6
  134. package/dist/services/stage-work-graph.js +35 -0
  135. package/dist/services/usage.js +35 -98
  136. package/dist/services/vnext-code-review.js +197 -84
  137. package/dist/services/vnext-code.js +278 -251
  138. package/dist/services/vnext-execution-profile.js +5 -3
  139. package/dist/services/vnext-fanout.js +172 -17
  140. package/dist/services/vnext-merge.js +345 -111
  141. package/dist/services/vnext-plan-review.js +171 -111
  142. package/dist/services/vnext-plan.js +180 -87
  143. package/dist/services/vnext-protocolize.js +320 -74
  144. package/dist/services/vnext-specify.js +115 -77
  145. package/dist/services/work-registry.js +1001 -187
  146. package/dist/services/workspace-bootstrap.js +76 -0
  147. package/dist/services/workspace-files.js +83 -0
  148. package/dist/services/worktrees.js +88 -36
  149. package/dist/shared/entity-references.js +8 -0
  150. package/dist/shared/errors.js +12 -0
  151. package/dist/storage/database.js +692 -35
  152. package/dist/storage/foreign-key-transaction.js +39 -0
  153. package/dist/storage/immutable-input.js +45 -0
  154. package/dist/storage/import-guard.js +102 -0
  155. package/dist/storage/native-cleanup.js +52 -0
  156. package/dist/storage/paths.js +17 -4
  157. package/dist/storage/projection-lock.js +73 -0
  158. package/dist/storage/work-references.js +23 -0
  159. package/dist/storage/writer-contract.js +77 -0
  160. package/dist/storage/writer-migration.js +85 -0
  161. package/package.json +24 -13
  162. package/tools/repair-paused-run-status.mjs +84 -0
@@ -2,18 +2,23 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
- import { parse, stringify } from "smol-toml";
5
+ import { parse, stringify, TomlError } from "smol-toml";
6
6
  import { AppError } from "../shared/errors.js";
7
+ import { entityReferenceVariants } from "../shared/entity-references.js";
7
8
  import { parseJsonObject } from "../shared/json.js";
8
- import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
9
+ import { canonicalPath, ensureDir, resolveProjectRoot } from "../storage/paths.js";
10
+ import { resolveWorkReference } from "../storage/work-references.js";
9
11
  import { appendAudit } from "./audit.js";
12
+ import { prepareTextFile, prepareJsonText, prepareError } from "../cli/input-preparation.js";
10
13
  import { nativeSessionIdentity, storageSessionId } from "./session-identity.js";
11
- import { commandHasOption, commandOption, commandPosition, parseLifecycleCommand, unwrapShellCommand } from "./lifecycle-command.js";
14
+ import { assertRecoveryHookCurrent } from "./run-recovery.js";
15
+ import { observeLifecycleInvocation, observedLifecycleInvocation } from "./lifecycle-invocations.js";
16
+ import { commandHasOption, commandOption, commandPosition, shellSuffixOffset, parseLifecycleCommand, lifecycleCommandFromPayload, continuationCommand } from "./lifecycle-command.js";
12
17
  import { registerProject, requireProjectByRoot } from "./projects.js";
13
- import { activeFlowSessionsForProject, bindObservedFlowSession, flowSessionPayloadFromRegisterCommand, recordFlowSessionObservation } from "./sessions.js";
18
+ import { activeFlowSessionsForProject, recordFlowSessionObservation } from "./sessions.js";
14
19
  const defaultProfileName = "default";
15
20
  const maxSanitizedSummaryLength = 1600;
16
- const sharedEntries = [
21
+ export const codexHomeSharedEntries = [
17
22
  "auth.json",
18
23
  "auth.bk",
19
24
  "sessions",
@@ -50,22 +55,28 @@ export function planCodexHome(context, input) {
50
55
  config_path: paths.configPath,
51
56
  hooks_path: paths.hooksPath,
52
57
  owned_files: ["config.toml", "hooks.json", ".dd-flow-home.json", "log/", ".tmp/", "tmp/"],
53
- shared_entries: sharedEntries,
58
+ shared_entries: codexHomeSharedEntries,
54
59
  exists: fs.existsSync(paths.targetHome),
55
60
  codex_env: { CODEX_HOME: paths.targetHome }
56
61
  };
57
62
  }
58
- export function initCodexHome(context, input) {
63
+ export function prepareCodexHome(context, input) {
59
64
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
60
65
  const profile = resolveProfileName(input.profile);
61
66
  const paths = resolveHomePaths(context, project, profile, input);
67
+ if (canonicalPath(paths.sourceHome, false) === canonicalPath(paths.targetHome, false))
68
+ throw new AppError("validation", "Managed Codex home must differ from its source home", 2);
69
+ const config = readTomlIfExists(path.join(paths.sourceHome, "config.toml"));
70
+ return { project, profile, paths, config };
71
+ }
72
+ export function initCodexHome(context, input, prepared = prepareCodexHome(context, input)) {
73
+ const { project, profile, paths, config } = prepared;
62
74
  const now = context.now();
63
75
  ensureDir(paths.targetHome);
64
76
  ensureDir(path.join(paths.targetHome, "log"));
65
77
  ensureDir(path.join(paths.targetHome, ".tmp"));
66
78
  ensureDir(path.join(paths.targetHome, "tmp"));
67
79
  const linked = createSharedLinks(paths.sourceHome, paths.targetHome);
68
- const config = readTomlIfExists(path.join(paths.sourceHome, "config.toml"));
69
80
  fs.writeFileSync(paths.configPath, stringify(withManagedConfig(config, paths.sourceHome, paths.targetHome)));
70
81
  writeJsonFile(paths.hooksPath, expectedHooksConfig(project.root));
71
82
  writeJsonFile(path.join(paths.targetHome, ".dd-flow-home.json"), {
@@ -136,7 +147,7 @@ export function printCodexHomeEnv(context, input) {
136
147
  command_prefix: `CODEX_HOME=${JSON.stringify(record.target_home)}`
137
148
  };
138
149
  }
139
- export function removeCodexHome(context, input) {
150
+ export function prepareCodexHomeRemoval(context, input) {
140
151
  if (!["keep-shared", "remove-owned"].includes(input.mode)) {
141
152
  throw new AppError("validation", "--mode must be keep-shared or remove-owned", 2);
142
153
  }
@@ -144,9 +155,30 @@ export function removeCodexHome(context, input) {
144
155
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
145
156
  const profile = resolveProfileName(input.profile);
146
157
  const record = homeProfile(context, project.id, profile);
158
+ if (record && record.status !== "removed" && fs.existsSync(record.target_home)) {
159
+ let marker;
160
+ try {
161
+ const value = JSON.parse(fs.readFileSync(path.join(record.target_home, ".dd-flow-home.json"), "utf8"));
162
+ if (!value || typeof value !== "object" || Array.isArray(value))
163
+ throw new Error("Invalid marker");
164
+ marker = value;
165
+ }
166
+ catch {
167
+ throw new AppError("runtime_artifact_invalid", "Managed Codex home ownership marker is missing or invalid; refusing removal", 1, { target_home: record.target_home });
168
+ }
169
+ if (marker.managed_by !== "dd-flow-cli" || marker.project_id !== project.id || marker.profile !== profile || marker.target_home !== record.target_home || marker.source_home !== record.source_home || canonicalPath(record.target_home, false) === canonicalPath(record.source_home, false)) {
170
+ throw new AppError("runtime_artifact_invalid", "Managed Codex home ownership does not match its registration; refusing removal", 1, { target_home: record.target_home });
171
+ }
172
+ }
173
+ return { project, profile, record, mode };
174
+ }
175
+ export function removeCodexHome(context, input) {
176
+ const { project, profile, record, mode } = prepareCodexHomeRemoval(context, input);
147
177
  if (!record) {
148
178
  return { ok: true, removed: false, profile, status: "not_initialized" };
149
179
  }
180
+ if (record.status === "removed")
181
+ return { ok: true, removed: false, profile, status: "already_removed" };
150
182
  const removedEntries = [];
151
183
  for (const relative of ["config.toml", "hooks.json", ".dd-flow-home.json", "log", ".tmp", "tmp"]) {
152
184
  const target = path.join(record.target_home, relative);
@@ -207,7 +239,7 @@ export function getCodexHooksStatus(context, input) {
207
239
  recorded
208
240
  };
209
241
  }
210
- export function installCodexHooks(context, input) {
242
+ export function prepareCodexHooks(context, input) {
211
243
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
212
244
  const target = resolveHookTarget(input.target);
213
245
  const location = requireHookLocation(context, project, target, resolveProfileName(input.profile));
@@ -217,9 +249,16 @@ export function installCodexHooks(context, input) {
217
249
  hooks_path: location.hooksPath
218
250
  });
219
251
  }
220
- const existing = readJsonIfExists(location.hooksPath) ?? {};
221
- const backup = target === "default" && fs.existsSync(location.hooksPath) ? backupFile(location.hooksPath, context.now()) : null;
252
+ const text = fs.existsSync(location.hooksPath) ? prepareTextFile(location.hooksPath, "hooks configuration").text : null;
253
+ const existing = text === null ? {} : prepareJsonText(text, { file: location.hooksPath }).value;
254
+ if (!existing || typeof existing !== "object" || Array.isArray(existing) || (existing.hooks !== undefined && (!existing.hooks || typeof existing.hooks !== "object" || Array.isArray(existing.hooks) || Object.values(existing.hooks).some(entries => !Array.isArray(entries)))))
255
+ return prepareError(new AppError("validation", "Hooks configuration requires an object with event arrays", 2), { file: location.hooksPath });
256
+ return { project, target, location, existing, text };
257
+ }
258
+ export function installCodexHooks(context, input, prepared = prepareCodexHooks(context, input)) {
259
+ const { project, target, location, existing, text } = prepared;
222
260
  const nextConfig = mergeHooksConfig(existing, project.root);
261
+ const backup = target === "default" && text !== null ? backupFile(location.hooksPath, context.now(), text) : null;
223
262
  ensureDir(path.dirname(location.hooksPath));
224
263
  writeJsonFile(location.hooksPath, nextConfig);
225
264
  recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, true, "none");
@@ -238,20 +277,11 @@ export function installCodexHooks(context, input) {
238
277
  expected_hash: hashObject(expectedHooksConfig(project.root))
239
278
  };
240
279
  }
241
- export function removeCodexHooks(context, input) {
242
- const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
243
- const target = resolveHookTarget(input.target);
244
- const location = requireHookLocation(context, project, target, resolveProfileName(input.profile));
245
- if (target === "default" && !input.yes) {
246
- throw new AppError("confirmation_required", "Direct default Codex home mutation requires --yes", 1, {
247
- target,
248
- hooks_path: location.hooksPath
249
- });
250
- }
251
- const existing = readJsonIfExists(location.hooksPath) ?? {};
280
+ export function removeCodexHooks(context, input, prepared = prepareCodexHooks(context, input)) {
281
+ const { project, target, location, existing, text } = prepared;
252
282
  const hadManaged = hasManagedHooks(existing, project.root);
253
- const backup = target === "default" && fs.existsSync(location.hooksPath) ? backupFile(location.hooksPath, context.now()) : null;
254
283
  const nextConfig = removeManagedHooks(existing, project.root);
284
+ const backup = target === "default" && text !== null ? backupFile(location.hooksPath, context.now(), text) : null;
255
285
  ensureDir(path.dirname(location.hooksPath));
256
286
  writeJsonFile(location.hooksPath, nextConfig);
257
287
  recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, false, "not_installed");
@@ -274,98 +304,143 @@ export function handleCodexHook(context, input) {
274
304
  // therefore the only valid identity for a fresh Work.
275
305
  const rawChildProviderSessionId = stringValue(payload.agent_id) ?? null;
276
306
  const providerSessionId = rawChildProviderSessionId ?? rootProviderSessionId;
277
- const parentProviderSessionId = rawChildProviderSessionId ? codexNativeParentId(payload, rawChildProviderSessionId) : null;
278
307
  const turnId = stringValue(payload.turn_id);
279
308
  const toolName = stringValue(payload.tool_name) ?? toolNameFromPayload(payload);
280
309
  const command = lifecycleCommandFromPayload(payload);
281
310
  if (!command)
282
311
  return { ok: true, observed: false, reason: "non_bash_tool" };
283
312
  const lifecycle = lifecycleFacts(command);
284
- if (!lifecycle)
313
+ if (!lifecycle && !continuationCommand(command))
285
314
  return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
315
+ const parentProviderSessionId = rawChildProviderSessionId ? codexNativeParentId(payload, rawChildProviderSessionId) : null;
286
316
  // Resume legitimately receives an answer through stdin. It is matched by
287
317
  // immutable lifecycle arguments below; never reject or rewrite that pipe.
288
- if (lifecycle.analysis.kind === "compound") {
289
- return {
290
- ok: false,
291
- observed: false,
292
- reason: "compound_lifecycle_command",
293
- message: "Run the dd-flow lifecycle invocation as the first technical action and as one standalone Bash command in this same Session.",
294
- standalone_command: lifecycle.invocation.command,
295
- hookSpecificOutput: {
296
- hookEventName: "PreToolUse",
297
- permissionDecision: "deny",
298
- permissionDecisionReason: `dd-flow lifecycle commands must be standalone. Retry in this same Session: ${lifecycle.invocation.command}`
299
- }
300
- };
318
+ const bootstrapStageStart = lifecycle?.bootstrapStageStart;
319
+ const invocationId = lifecycle ? commandOption(lifecycle.invocation, "invocation-id") : undefined;
320
+ if (invocationId || context.env.DD_FLOW_DAEMON_ID) {
321
+ const proof = context.codexHookProof;
322
+ if (!proof || proof.daemonId !== context.env.DD_FLOW_DAEMON_ID || proof.rootSessionId !== rootProviderSessionId
323
+ || proof.sessionId !== providerSessionId || proof.parentSessionId !== parentProviderSessionId
324
+ || proof.toolCallId !== payload.tool_use_id || proof.command !== command) {
325
+ throw new AppError("native_hook_unproven", "Managed Codex admission requires its active native hook delivery, not a manual handler call", 1, { ...(invocationId ? { invocation_id: invocationId } : {}), effect: "no_effect" });
326
+ }
301
327
  }
302
- const { flowPayload, stageStart, stageResume, workStart, bootstrapStageStart, commandProjectRoot } = lifecycle;
303
- const matchKey = commandProjectRoot ? lifecycleMatchKey(lifecycle, commandProjectRoot) : null;
328
+ const nativeProjectRoot = context.codexHookProof?.projectRoot ?? input.projectRoot;
304
329
  // A bootstrap stage is the first stateful command for a new materialized project.
305
330
  // Its PreToolUse event arrives before stage start can register that project.
306
- if (bootstrapStageStart && commandProjectRoot) {
307
- registerProject(context, { root: commandProjectRoot });
331
+ const bootstrapRoot = nativeProjectRoot ?? stringValue(payload.cwd);
332
+ if (bootstrapStageStart && bootstrapRoot) {
333
+ registerProject(context, { root: bootstrapRoot });
308
334
  }
309
- const project = projectForHook(context, input.projectRoot ?? commandProjectRoot, stringValue(payload.cwd));
310
- if (!project)
335
+ const project = projectForHook(context, nativeProjectRoot, stringValue(payload.cwd));
336
+ if (!project) {
337
+ if (invocationId)
338
+ throw new AppError("project_not_registered", "Managed lifecycle invocation has no registered project", 1, { project_root: nativeProjectRoot ?? null });
311
339
  return { ok: true, observed: false, reason: "unrelated_cwd" };
312
- const binding = rootProviderSessionId ? upsertSessionBindingFromPayload(context, project, rootProviderSessionId, payload) : undefined;
340
+ }
341
+ let matchKey = lifecycle ? lifecycleMatchKey(lifecycle, project.root) : null;
342
+ // Codex retains the root session_id for a thread-spawned child, while the
343
+ // payload transcript belongs to the current agent_id. Keep lineage separate
344
+ // from ownership: otherwise a child overwrites the root transcript binding.
345
+ const eventKey = hookEventKey(payload, eventName, toolName, command);
313
346
  const storageId = providerSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", providerSessionId)) : null;
314
347
  const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", parentProviderSessionId)) : null;
315
- const observedSession = flowPayload
316
- ? bindObservedFlowSession(context, project, {
317
- ...flowPayload,
318
- harness: "codex-desktop",
319
- provider_session_id: providerSessionId ?? flowPayload.provider_session_id ?? null,
320
- parent_session_id: parentSessionId
321
- }, storageId ?? undefined)
322
- : undefined;
323
- const effectiveSessionId = observedSession?.session_id ?? storageId;
324
- const protocolId = observedSession?.protocol_id ?? binding?.protocol_id ?? null;
325
- const eventKey = hookEventKey(payload, eventName, toolName, command);
326
- const inserted = recordHookEvent(context, {
327
- projectId: project.id,
328
- protocolId,
329
- harness: "codex-desktop",
330
- providerSessionId: providerSessionId ?? null,
331
- parentSessionId,
332
- sessionId: effectiveSessionId,
333
- agentId: rawChildProviderSessionId ?? null,
334
- turnId: turnId ?? null,
335
- eventName,
336
- toolName: toolName ?? null,
337
- status: "observed",
338
- payload,
339
- eventKey,
340
- matchKey,
341
- transcriptPath: binding?.transcript_path ?? null,
342
- cwd: stringValue(payload.cwd) ?? null
343
- });
344
- if (effectiveSessionId) {
345
- recordFlowSessionObservation(context, {
348
+ const eventCwd = stringValue(payload.cwd) ?? project.root;
349
+ assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "codex-desktop", providerSessionId: providerSessionId ?? null, parentSessionId, daemonId: stringValue(context.env.DD_FLOW_DAEMON_ID) ?? null, sessionId: storageId, turnId: turnId ?? null, eventName, toolName: toolName ?? null, matchKey, cwd: eventCwd });
350
+ const bindingOwnerId = providerSessionId ?? rootProviderSessionId;
351
+ // Resolve legacy transcript discovery before acquiring the receipt write lock.
352
+ const transcriptPath = stringValue(payload.transcript_path)
353
+ ?? (bindingOwnerId ? sessionBinding(context, project.id, bindingOwnerId)?.transcript_path ?? locateCodexTranscript(bindingOwnerId, context.env.CODEX_HOME) : null);
354
+ const persist = (scope) => {
355
+ if (scope)
356
+ matchKey = lifecycleMatchKey(lifecycleFacts(scope.command), project.root, scope);
357
+ const binding = bindingOwnerId ? upsertSessionBindingFromPayload(context, project, bindingOwnerId, payload, transcriptPath) : undefined;
358
+ const effectiveSessionId = storageId;
359
+ const protocolId = binding?.protocol_id ?? null;
360
+ const inserted = recordHookEvent(context, {
346
361
  projectId: project.id,
347
- sessionId: effectiveSessionId,
348
- runId: observedSession?.run_id ?? null,
349
362
  protocolId,
350
- cwd: stringValue(payload.cwd) ?? null,
363
+ harness: "codex-desktop",
364
+ providerSessionId: providerSessionId ?? null,
365
+ parentSessionId,
366
+ // Pinned by the owning app-server process, never derived from current RUN
367
+ // state or an agent-supplied shell command/payload.
368
+ daemonId: stringValue(context.env.DD_FLOW_DAEMON_ID) ?? null,
369
+ sessionId: effectiveSessionId,
370
+ agentId: rawChildProviderSessionId ?? null,
371
+ turnId: turnId ?? null,
372
+ eventName,
351
373
  toolName: toolName ?? null,
352
- eventKey
374
+ status: "observed",
375
+ payload,
376
+ eventKey,
377
+ matchKey,
378
+ transcriptPath: binding?.transcript_path ?? null,
379
+ cwd: stringValue(payload.cwd) ?? project.root
353
380
  });
354
- }
355
- return {
381
+ if (effectiveSessionId) {
382
+ recordFlowSessionObservation(context, {
383
+ projectId: project.id,
384
+ sessionId: effectiveSessionId,
385
+ protocolId,
386
+ cwd: eventCwd,
387
+ toolName: toolName ?? null,
388
+ eventKey
389
+ });
390
+ }
391
+ return { inserted, protocolId };
392
+ };
393
+ const common = {
356
394
  ok: true,
357
- observed: inserted,
358
- duplicate: !inserted,
359
395
  event_key: eventKey,
360
396
  session: providerSessionId ? nativeSessionIdentity("codex-desktop", providerSessionId) : null,
361
397
  ...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("codex-desktop", parentProviderSessionId) } : {}),
362
- protocol_id: protocolId,
363
- ...(providerSessionId && (flowPayload || stageStart || stageResume || workStart)
398
+ protocol_id: null
399
+ };
400
+ if (!lifecycle) {
401
+ const persisted = context.db.writeTransaction(() => persist());
402
+ return { ...common, observed: persisted.inserted, duplicate: !persisted.inserted };
403
+ }
404
+ if (invocationId || context.env.DD_FLOW_DAEMON_ID) {
405
+ const daemonId = stringValue(context.env.DD_FLOW_DAEMON_ID);
406
+ const toolCallId = stringValue(payload.tool_use_id) ?? stringValue(payload.event_id) ?? stringValue(payload.delivery_id) ?? stringValue(payload.id);
407
+ if (!daemonId || !rootProviderSessionId || !providerSessionId || !toolCallId)
408
+ throw new AppError("codex_identity_missing", "Managed Codex lifecycle hook has no complete native invocation identity", 1, { invocation_id: invocationId });
409
+ const receipt = observeLifecycleInvocation(context, {
410
+ ...(invocationId ? { id: invocationId } : {}),
411
+ command,
412
+ harness: "codex-desktop",
413
+ identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId, toolCallId },
414
+ recordReceipt: scope => { persist(scope); return eventKey; }
415
+ });
416
+ const stored = context.db.get("SELECT protocol_id FROM hook_events WHERE project_id = ? AND event_key = ?", [project.id, receipt.eventKey]);
417
+ const admittedInvocationId = receipt.invocationId ?? invocationId;
418
+ if (admittedInvocationId)
419
+ return { ...common, protocol_id: stored?.protocol_id ?? null, observed: !receipt.duplicate, duplicate: receipt.duplicate, event_key: receipt.eventKey, invocation_id: admittedInvocationId };
420
+ return {
421
+ ...common,
422
+ protocol_id: stored?.protocol_id ?? null,
423
+ observed: !receipt.duplicate,
424
+ duplicate: receipt.duplicate,
425
+ hookSpecificOutput: {
426
+ hookEventName: "PreToolUse",
427
+ permissionDecision: "allow",
428
+ updatedInput: { ...objectRecord(payload.tool_input), command: commandWithHookEvent(command, eventKey) }
429
+ }
430
+ };
431
+ }
432
+ const persisted = persist();
433
+ return {
434
+ ...common,
435
+ protocol_id: persisted.protocolId,
436
+ observed: persisted.inserted,
437
+ duplicate: !persisted.inserted,
438
+ ...(providerSessionId
364
439
  ? {
365
440
  hookSpecificOutput: {
366
441
  hookEventName: "PreToolUse",
367
442
  permissionDecision: "allow",
368
- updatedInput: { command: commandWithHookEvent(command, eventKey) }
443
+ updatedInput: { ...objectRecord(payload.tool_input), command: commandWithHookEvent(command, eventKey) }
369
444
  }
370
445
  }
371
446
  : {})
@@ -377,7 +452,24 @@ function codexNativeParentId(payload, childId) {
377
452
  if (!transcript || !path.isAbsolute(transcript))
378
453
  return null;
379
454
  try {
380
- const firstLine = fs.readFileSync(transcript, "utf8").split("\n", 1)[0];
455
+ const descriptor = fs.openSync(transcript, "r");
456
+ const chunks = [];
457
+ try {
458
+ for (;;) {
459
+ const buffer = Buffer.alloc(4096);
460
+ const length = fs.readSync(descriptor, buffer, 0, buffer.length, null);
461
+ if (!length)
462
+ break;
463
+ const newline = buffer.subarray(0, length).indexOf(10);
464
+ chunks.push(buffer.subarray(0, newline < 0 ? length : newline));
465
+ if (newline >= 0)
466
+ break;
467
+ }
468
+ }
469
+ finally {
470
+ fs.closeSync(descriptor);
471
+ }
472
+ const firstLine = Buffer.concat(chunks).toString("utf8");
381
473
  if (!firstLine)
382
474
  return null;
383
475
  const record = JSON.parse(firstLine);
@@ -389,117 +481,110 @@ function codexNativeParentId(payload, childId) {
389
481
  return null;
390
482
  }
391
483
  }
392
- /** Convert a zcode-acp Bash tool notification into the same trusted lifecycle receipt used by Codex hooks. */
484
+ /** ACP is asynchronous evidence. Only an explicitly issued invocation may use
485
+ * it as the receipt that its CLI waits for; this is not a PreToolUse hook. */
393
486
  export function handleZcodeEvent(context, input) {
394
487
  const notification = parseJsonObject(input.stdin || "{}", "zcode ACP notification");
395
- if (notification.method !== "session/update")
488
+ const params = objectRecord(notification.params), update = objectRecord(params.update);
489
+ if (notification.method !== "session/update" || update.sessionUpdate !== "tool_call")
396
490
  return { ok: true, observed: false, reason: "event_not_participating" };
397
- const params = objectRecord(notification.params);
398
- const update = objectRecord(params.update);
399
- if (update.sessionUpdate !== "tool_call")
400
- return { ok: true, observed: false, reason: "event_not_participating" };
401
- const updateMeta = objectRecord(update._meta);
402
- const claudeCode = objectRecord(updateMeta.claudeCode);
403
- const runtime = objectRecord(updateMeta.zcodeRuntime);
404
- const toolName = stringValue(claudeCode.toolName) ?? stringValue(update.kind);
405
- const rawInput = objectRecord(update.rawInput);
491
+ const rawInput = objectRecord(update.rawInput), meta = objectRecord(update._meta), native = objectRecord(meta.zcodeRuntime);
492
+ const toolName = stringValue(objectRecord(meta.claudeCode).toolName);
493
+ if (toolName !== "Bash" && toolName !== "bash" && toolName !== "execute")
494
+ return { ok: true, observed: false, reason: "non_bash_tool" };
406
495
  const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd);
407
- if (!command || (toolName && toolName !== "Bash" && toolName !== "bash" && update.kind !== "execute")) {
496
+ const parsed = command ? parseLifecycleCommand(command) : { kind: "none" };
497
+ if (parsed.kind === "none")
498
+ return { ok: true, observed: false, reason: "acp_evidence_only" };
499
+ const controlled = objectRecord(objectRecord(notification._meta).ddZcode);
500
+ const providerSessionId = stringValue(native.childSessionId) ?? stringValue(controlled.rootProviderSessionId);
501
+ return recordZcodeLifecycle(context, { projectRoot: input.projectRoot, stdin: JSON.stringify({
502
+ session_id: providerSessionId, tool_use_id: update.toolCallId, tool_name: toolName, tool_input: rawInput,
503
+ agent_id: native.agentId, _meta: { ddZcode: controlled }
504
+ }) }, commandOption(parsed.invocation, "invocation-id"));
505
+ }
506
+ /**
507
+ * Accept the synchronous native ZCode PreToolUse callback. Invocation-ID
508
+ * commands instead rendezvous with their ACP event while the CLI awaits
509
+ * admission; the native hook must not create a competing receipt for them.
510
+ */
511
+ export function handleZcodeHook(context, input) {
512
+ return recordZcodeLifecycle(context, input);
513
+ }
514
+ function recordZcodeLifecycle(context, input, invocationId) {
515
+ const hook = parseJsonObject(input.stdin || "{}", "ZCode hook event");
516
+ const eventName = stringValue(hook.hook_event_name) ?? stringValue(hook.hookEventName);
517
+ if (eventName && eventName !== "PreToolUse" && eventName !== "pre_tool_use")
518
+ return { ok: true, observed: false, reason: "event_not_participating" };
519
+ const toolName = stringValue(hook.tool_name) ?? stringValue(hook.toolName);
520
+ const rawInput = objectRecord(hook.tool_input ?? hook.toolInput);
521
+ const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(hook.command);
522
+ if (!command || (toolName && toolName !== "Bash" && toolName !== "bash" && toolName !== "execute"))
408
523
  return { ok: true, observed: false, reason: "non_bash_tool" };
409
- }
410
524
  const lifecycle = lifecycleFacts(command);
411
- if (!lifecycle)
525
+ if (!lifecycle && !continuationCommand(command))
412
526
  return { ok: true, observed: false, reason: "event_not_participating" };
413
- if (lifecycle.analysis.kind === "compound") {
414
- throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", 1, {
415
- standalone_command: lifecycle.invocation.command
416
- });
417
- }
418
- const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
527
+ if (!invocationId && lifecycle && commandOption(lifecycle.invocation, "invocation-id"))
528
+ return { ok: true, observed: false, reason: "cli_invocation_owns_admission" };
419
529
  const expectedRoot = resolveProjectRoot(input.projectRoot);
420
- if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
421
- throw new AppError("project_mismatch", "ZCode lifecycle command project root does not match the controlled workspace", 1, {
422
- expected: expectedRoot,
423
- actual: commandProjectRoot
424
- });
530
+ const meta = objectRecord(hook._meta);
531
+ const ddZcode = objectRecord(meta.ddZcode);
532
+ const providerSessionId = stringValue(hook.session_id) ?? stringValue(hook.sessionId);
533
+ const rootProviderSessionId = stringValue(ddZcode.rootProviderSessionId);
534
+ const parentProviderSessionId = stringValue(ddZcode.parentProviderSessionId);
535
+ const daemonId = stringValue(ddZcode.daemonId);
536
+ if (!providerSessionId || !rootProviderSessionId || !daemonId) {
537
+ throw new AppError("zcode_identity_missing", "ZCode native hook has no controlled Session identity", 1);
425
538
  }
426
- const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
427
- if (bootstrapStageStart)
539
+ if (providerSessionId === rootProviderSessionId ? parentProviderSessionId : !parentProviderSessionId) {
540
+ throw new AppError("zcode_identity_missing", "ZCode native hook has inconsistent Session ancestry", 1);
541
+ }
542
+ const toolUseId = stringValue(hook.tool_use_id) ?? stringValue(hook.toolUseId) ?? stringValue(hook.event_id) ?? stringValue(hook.eventId);
543
+ if (!toolUseId)
544
+ throw new AppError("zcode_hook_event_missing", "ZCode native hook has no tool-use ID", 1);
545
+ const eventKey = crypto.createHash("sha256").update(JSON.stringify(["zcode-acp", daemonId, providerSessionId, toolUseId])).digest("hex");
546
+ const rewritten = invocationId ? command : commandWithHookEvent(command, eventKey);
547
+ if (lifecycle?.bootstrapStageStart)
428
548
  registerProject(context, { root: expectedRoot });
429
549
  const project = requireProjectByRoot(context, expectedRoot);
430
- const envelopeMeta = objectRecord(notification._meta);
431
- const ddZcode = objectRecord(envelopeMeta.ddZcode);
432
- const observedProfile = objectRecord(ddZcode.observedProfile);
433
- const rootProviderSessionId = stringValue(ddZcode.rootProviderSessionId) ?? stringValue(params.sessionId);
434
- const daemonId = stringValue(ddZcode.daemonId);
435
- if (!rootProviderSessionId)
436
- throw new AppError("zcode_identity_missing", "ZCode ACP event has no root provider Session ID", 1);
437
- const childProviderSessionId = stringValue(runtime.childSessionId);
438
- const providerSessionId = childProviderSessionId ?? rootProviderSessionId;
439
550
  const sessionId = storageSessionId(nativeSessionIdentity("zcode-acp", providerSessionId));
440
- const parentSessionId = childProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", rootProviderSessionId)) : null;
441
- const agentId = stringValue(runtime.agentId);
442
- const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
443
- ...flowPayload,
444
- harness: "zcode-acp",
445
- provider_session_id: providerSessionId,
446
- agent_id: agentId ?? null,
447
- parent_session_id: parentSessionId
448
- }, sessionId) : undefined;
449
- const eventKey = stringValue(update.toolCallId) ?? crypto.createHash("sha256").update(JSON.stringify({
450
- harness: "zcode-acp", providerSessionId, command
451
- })).digest("hex");
452
- const payload = {
453
- session_id: providerSessionId,
454
- agent_id: agentId,
455
- cwd: expectedRoot,
456
- tool_name: toolName ?? "Bash",
457
- tool_input: rawInput,
458
- command,
459
- provider: stringValue(observedProfile.provider),
460
- model: stringValue(observedProfile.model),
461
- reasoning: stringValue(observedProfile.reasoning),
462
- mode: stringValue(observedProfile.mode),
463
- agent_type: stringValue(runtime.source)
551
+ const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", parentProviderSessionId)) : null;
552
+ const observedProfile = objectRecord(ddZcode.observedProfile);
553
+ const observedCwd = stringValue(hook.cwd) && path.isAbsolute(String(hook.cwd)) ? resolveProjectRoot(String(hook.cwd)) : null;
554
+ let matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
555
+ const eventNameForReceipt = invocationId ? "ToolCallObserved" : "PreToolUse";
556
+ const persist = (scope) => {
557
+ if (scope)
558
+ matchKey = lifecycleMatchKey(lifecycleFacts(scope.command), expectedRoot, scope);
559
+ assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "zcode-acp", providerSessionId, parentSessionId, daemonId, sessionId, turnId: null, eventName: eventNameForReceipt, toolName: toolName ?? "Bash", matchKey, cwd: observedCwd });
560
+ const payload = { ...hook, session_id: providerSessionId, cwd: observedCwd, tool_name: toolName ?? "Bash", tool_input: rawInput,
561
+ command, provider: stringValue(observedProfile.provider), model: stringValue(observedProfile.model), reasoning: stringValue(observedProfile.reasoning), mode: stringValue(observedProfile.mode) };
562
+ const inserted = recordHookEvent(context, {
563
+ projectId: project.id, protocolId: null, harness: "zcode-acp", providerSessionId, parentSessionId,
564
+ daemonId, sessionId, agentId: stringValue(hook.agent_id) ?? stringValue(hook.agentId) ?? null, turnId: null,
565
+ eventName: eventNameForReceipt, toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey,
566
+ transcriptPath: stringValue(hook.transcript_path) ?? null, cwd: observedCwd
567
+ });
568
+ recordFlowSessionObservation(context, { projectId: project.id, sessionId, cwd: observedCwd, toolName: toolName ?? "Bash", eventKey });
569
+ const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, harness: "zcode-acp", session: nativeSessionIdentity("zcode-acp", providerSessionId), ...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("zcode-acp", parentProviderSessionId) } : {}), daemon_id: daemonId };
570
+ return { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: rewritten } } };
464
571
  };
465
- const inserted = recordHookEvent(context, {
466
- projectId: project.id,
467
- protocolId: observedSession?.protocol_id ?? null,
572
+ if (!lifecycle)
573
+ return context.db.writeTransaction(() => persist());
574
+ let persistedResult;
575
+ const receipt = observeLifecycleInvocation(context, {
576
+ ...(invocationId ? { id: invocationId } : {}), command,
468
577
  harness: "zcode-acp",
469
- providerSessionId,
470
- parentSessionId,
471
- daemonId: daemonId ?? null,
472
- sessionId,
473
- agentId: agentId ?? null,
474
- turnId: null,
475
- eventName: "PreToolUse",
476
- toolName: toolName ?? "Bash",
477
- status: "observed",
478
- payload,
479
- eventKey,
480
- matchKey,
481
- transcriptPath: null,
482
- cwd: expectedRoot
483
- });
484
- recordFlowSessionObservation(context, {
485
- projectId: project.id,
486
- sessionId,
487
- runId: observedSession?.run_id ?? null,
488
- protocolId: observedSession?.protocol_id ?? null,
489
- cwd: expectedRoot,
490
- toolName: toolName ?? "Bash",
491
- eventKey
578
+ identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId },
579
+ recordReceipt: scope => { persistedResult = persist(scope); return eventKey; }
492
580
  });
493
- return {
494
- ok: true,
495
- observed: inserted,
496
- duplicate: !inserted,
497
- event_key: eventKey,
498
- harness: "zcode-acp",
499
- session: nativeSessionIdentity("zcode-acp", providerSessionId),
500
- ...(childProviderSessionId ? { parent_session: nativeSessionIdentity("zcode-acp", rootProviderSessionId) } : {}),
501
- daemon_id: daemonId ?? null
502
- };
581
+ const admittedInvocationId = receipt.invocationId ?? invocationId;
582
+ if (admittedInvocationId)
583
+ return { ok: true, observed: !receipt.duplicate, duplicate: receipt.duplicate, event_key: receipt.eventKey, invocation_id: admittedInvocationId };
584
+ // Without an issued managed attempt this is still ordinary hook evidence.
585
+ // Preserve persist()'s replay result instead of overwriting it with the
586
+ // admission layer's non-authoritative duplicate flag.
587
+ return persistedResult ?? persist();
503
588
  }
504
589
  /** Convert a Grok Build PreToolUse hook into a trusted lifecycle receipt. */
505
590
  export function handleGrokEvent(context, input) {
@@ -515,27 +600,18 @@ export function handleGrokEvent(context, input) {
515
600
  return { ok: true, observed: false, reason: "non_bash_tool" };
516
601
  }
517
602
  const lifecycle = lifecycleFacts(command);
518
- if (!lifecycle)
603
+ if (!lifecycle && !continuationCommand(command))
519
604
  return { ok: true, observed: false, reason: "event_not_participating" };
520
- if (lifecycle.analysis.kind === "compound") {
521
- throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone Grok Build tool call", 1, {
522
- standalone_command: lifecycle.invocation.command
523
- });
524
- }
525
- const { flowPayload, stageStart, stageResume, workStart, bootstrapStageStart, commandProjectRoot } = lifecycle;
605
+ const bootstrapStageStart = lifecycle?.bootstrapStageStart;
526
606
  const expectedRoot = resolveProjectRoot(input.projectRoot);
527
- if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
528
- throw new AppError("project_mismatch", "Grok Build lifecycle command project root does not match the controlled workspace", 1, {
529
- expected: expectedRoot, actual: commandProjectRoot
530
- });
531
- }
532
- const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
607
+ let matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
533
608
  if (bootstrapStageStart)
534
609
  registerProject(context, { root: expectedRoot });
535
610
  const project = requireProjectByRoot(context, expectedRoot);
536
611
  const meta = objectRecord(hook._meta);
537
612
  const ddGrok = objectRecord(meta.ddGrok);
538
613
  const observedProfile = objectRecord(ddGrok.observedProfile);
614
+ const observedCwd = stringValue(hook.cwd) && path.isAbsolute(String(hook.cwd)) ? resolveProjectRoot(String(hook.cwd)) : null;
539
615
  const rootProviderSessionId = stringValue(ddGrok.rootProviderSessionId);
540
616
  const providerSessionId = stringValue(hook.session_id) ?? stringValue(hook.sessionId);
541
617
  if (!rootProviderSessionId || !providerSessionId)
@@ -545,41 +621,121 @@ export function handleGrokEvent(context, input) {
545
621
  const parentSessionId = isChild ? storageSessionId(nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId)) : null;
546
622
  const daemonId = stringValue(ddGrok.daemonId);
547
623
  const agentId = stringValue(hook.agent_id) ?? stringValue(hook.agentId);
548
- const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
549
- ...flowPayload, harness: "grok-acp", provider_session_id: providerSessionId, agent_id: agentId ?? null, parent_session_id: parentSessionId
550
- }, sessionId) : undefined;
551
624
  const eventKey = stringValue(hook.event_id) ?? stringValue(hook.eventId) ?? crypto.createHash("sha256").update(JSON.stringify({
552
- harness: "grok-acp", providerSessionId, command, turn: hook.turn_id ?? hook.turnId ?? null
625
+ harness: "grok-acp", providerSessionId, toolUseId: hook.tool_use_id ?? hook.toolUseId ?? null,
626
+ promptId: hook.prompt_id ?? hook.promptId ?? null, command, turn: hook.turn_id ?? hook.turnId ?? null
553
627
  })).digest("hex");
554
- const payload = {
555
- ...hook, session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
556
- command, provider: stringValue(observedProfile.provider), model: stringValue(observedProfile.model), reasoning: stringValue(observedProfile.reasoning), mode: stringValue(observedProfile.mode), agent_type: stringValue(hook.subagent_type) ?? stringValue(hook.subagentType)
628
+ const persist = (scope) => {
629
+ if (scope)
630
+ matchKey = lifecycleMatchKey(lifecycleFacts(scope.command), expectedRoot, scope);
631
+ assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "grok-acp", providerSessionId, parentSessionId, daemonId: daemonId ?? null, sessionId, turnId: stringValue(hook.turn_id) ?? stringValue(hook.turnId) ?? null, eventName: "PreToolUse", toolName: toolName ?? "Bash", matchKey, cwd: observedCwd });
632
+ const payload = {
633
+ ...hook, session_id: providerSessionId, agent_id: agentId, cwd: observedCwd, tool_name: toolName ?? "Bash", tool_input: rawInput,
634
+ command, provider: stringValue(observedProfile.provider), model: stringValue(observedProfile.model), reasoning: stringValue(observedProfile.reasoning), mode: stringValue(observedProfile.mode), agent_type: stringValue(hook.subagent_type) ?? stringValue(hook.subagentType)
635
+ };
636
+ const inserted = recordHookEvent(context, {
637
+ projectId: project.id, protocolId: null, harness: "grok-acp", providerSessionId, parentSessionId,
638
+ daemonId: daemonId ?? null, sessionId, agentId: agentId ?? null, turnId: stringValue(hook.turn_id) ?? stringValue(hook.turnId) ?? null,
639
+ eventName: "PreToolUse", toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey, transcriptPath: null, cwd: observedCwd
640
+ });
641
+ recordFlowSessionObservation(context, { projectId: project.id, sessionId, cwd: observedCwd, toolName: toolName ?? "Bash", eventKey });
642
+ return inserted;
557
643
  };
558
- const inserted = recordHookEvent(context, {
559
- projectId: project.id, protocolId: observedSession?.protocol_id ?? null, harness: "grok-acp", providerSessionId, parentSessionId,
560
- daemonId: daemonId ?? null, sessionId, agentId: agentId ?? null, turnId: stringValue(hook.turn_id) ?? stringValue(hook.turnId) ?? null,
561
- eventName: "PreToolUse", toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey, transcriptPath: null, cwd: expectedRoot
562
- });
563
- recordFlowSessionObservation(context, {
564
- projectId: project.id, sessionId, runId: observedSession?.run_id ?? null, protocolId: observedSession?.protocol_id ?? null,
565
- cwd: expectedRoot, toolName: toolName ?? "Bash", eventKey
644
+ const result = (inserted) => ({ ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
645
+ ...(isChild ? { parent_session: nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId) } : {}), daemon_id: daemonId ?? null });
646
+ if (!lifecycle)
647
+ return { ...result(context.db.writeTransaction(() => persist())), hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, eventKey) } } };
648
+ let inserted = false;
649
+ const receipt = observeLifecycleInvocation(context, {
650
+ command, harness: "grok-acp", projectId: project.id,
651
+ identity: { daemonId: daemonId ?? "", rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId, toolCallId: eventKey },
652
+ recordReceipt: scope => { inserted = persist(scope); return eventKey; }
566
653
  });
567
- const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
568
- ...(isChild ? { parent_session: nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId) } : {}), daemon_id: daemonId ?? null };
569
- return (flowPayload || stageStart || stageResume || workStart)
570
- ? { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...(rawInput ?? {}), command: commandWithHookEvent(command, eventKey) } } }
571
- : result;
654
+ if (receipt.invocationId)
655
+ return { ...result(inserted), invocation_id: receipt.invocationId, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, eventKey) } } };
656
+ return { ...result(inserted), hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, eventKey) } } };
572
657
  }
573
658
  /** Convert a controlled OpenCode tool hook into the shared lifecycle receipt. */
574
659
  export function handleOpenCodeEvent(context, input) {
575
660
  const event = parseJsonObject(input.stdin || "{}", "OpenCode tool event");
576
- const agy = event.source_harness === "antigravity-cli";
577
- const harness = agy ? "antigravity-cli" : "opencode-server";
578
661
  if (event.schema_id !== "dd-flow/opencode-tool-event@1")
579
662
  throw new AppError("unsupported_opencode_event_schema", "Unsupported OpenCode event schema", 1);
663
+ return handleControlledToolEvent(context, input, event, event.source_harness === "antigravity-cli" ? "antigravity-cli" : "opencode-server");
664
+ }
665
+ /** Accept adapter-observed Factory identity, never a model-supplied Session ID. */
666
+ export function handleDroidEvent(context, input) {
667
+ const event = parseJsonObject(input.stdin || "{}", "Droid tool event");
668
+ if (event.schema_id !== "dd-flow/droid-tool-event@1")
669
+ throw new AppError("unsupported_droid_event_schema", "Unsupported Droid event schema", 1);
670
+ return handleControlledToolEvent(context, input, event, "droid-cli");
671
+ }
672
+ /** Stable project identity is not the native Session's execution workspace. */
673
+ function controlledLifecycleCwd(context, projectRoot, facts, providerSessionId, auxiliary, issuedRunId) {
674
+ if (facts?.bootstrapStageStart)
675
+ return projectRoot;
676
+ const project = context.db.get("SELECT id FROM projects WHERE root = ?", [projectRoot]);
677
+ if (!project)
678
+ return projectRoot;
679
+ let work;
680
+ if (facts?.workId) {
681
+ try {
682
+ work = resolveWorkReference(context, facts.workId, { projectId: project.id });
683
+ }
684
+ catch (error) {
685
+ if (!(error instanceof AppError) || !["not_found", "ambiguous_work_alias"].includes(error.code))
686
+ throw error;
687
+ }
688
+ }
689
+ const reference = work?.run_id ?? facts?.runId ?? auxiliary?.args.options.get("run")?.[0] ?? auxiliary?.args.positional[2] ?? issuedRunId;
690
+ if (!reference)
691
+ return projectRoot;
692
+ const run = context.db.get("SELECT id, run_root, workspace_root FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [project.id, reference, reference]);
693
+ if (!run)
694
+ return projectRoot; // Hook ingress does not validate a mistyped CLI target.
695
+ if (work) {
696
+ // PROTOCOLIZE provisions the feature checkout after its coordinator
697
+ // Session has launched in the stable project root. Its transition Work
698
+ // must keep that physical route until the Stage is complete.
699
+ if (context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'stage_work_memberships'")
700
+ && context.db.get("SELECT 1 FROM stage_work_memberships WHERE project_id = ? AND run_id = ? AND work_id = ? AND stage IN ('specify','protocolize') LIMIT 1", [project.id, run.id, work.work_id]))
701
+ return projectRoot;
702
+ const merge = context.db.get("SELECT target_workspace FROM merge_requests WHERE executor_work_id = ? AND run_id = ?", [work.work_id, run.id]);
703
+ if (merge)
704
+ return resolveProjectRoot(merge.target_workspace);
705
+ if (context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'work_launches'")) {
706
+ const launch = context.db.get("SELECT provider_session_id, profile_json FROM work_launches WHERE work_id = ? AND status IN ('launching', 'start_requested')", [work.work_id]);
707
+ if (launch?.provider_session_id === providerSessionId && run.run_root) {
708
+ const copy = JSON.parse(launch.profile_json).review_copy?.root;
709
+ if (copy) {
710
+ const target = path.resolve(run.run_root, copy);
711
+ const relative = path.relative(run.run_root, target);
712
+ if (relative === ".." || relative.startsWith(`..${path.sep}`))
713
+ throw new AppError("read_only_workspace_unbound", "Review copy escapes its owning RUN", 1);
714
+ return resolveProjectRoot(target);
715
+ }
716
+ }
717
+ }
718
+ }
719
+ if (facts?.stageName === "specify" || facts?.stageName === "protocolize")
720
+ return projectRoot;
721
+ if (facts?.stageName === "merge") {
722
+ const pending = context.db.all("SELECT target_workspace FROM merge_requests WHERE project_id = ? AND run_id = ? AND status IN ('dispatching','active','action_required') ORDER BY updated_at DESC", [project.id, run.id]);
723
+ const targets = [...new Set(pending.map(item => resolveProjectRoot(item.target_workspace)))];
724
+ if (targets.length > 1)
725
+ throw new AppError("merge_workspace_ambiguous", "Current MERGE requests disagree on the native execution workspace", 1, { run_id: run.id, target_workspaces: targets });
726
+ if (targets.length === 1)
727
+ return targets[0];
728
+ }
729
+ return resolveProjectRoot(run.workspace_root);
730
+ }
731
+ function handleControlledToolEvent(context, input, event, harness) {
732
+ const agy = harness === "antigravity-cli";
733
+ const droid = harness === "droid-cli";
734
+ const label = droid ? "Droid" : agy ? "Antigravity" : "OpenCode";
735
+ const errorPrefix = droid ? "droid" : agy ? "agy" : "opencode";
580
736
  const phase = stringValue(event.phase);
581
737
  if (phase !== "before" && phase !== "after")
582
- throw new AppError("unsupported_opencode_event_schema", "OpenCode event phase must be before or after", 1);
738
+ throw new AppError(`unsupported_${errorPrefix}_event_schema`, `${label} event phase must be before or after`, 1);
583
739
  const session = objectRecord(event.session);
584
740
  const profile = objectRecord(event.profile);
585
741
  const rawInput = objectRecord(event.input);
@@ -587,16 +743,16 @@ export function handleOpenCodeEvent(context, input) {
587
743
  const nativeParentId = stringValue(session.parent_provider_session_id);
588
744
  const daemonId = stringValue(event.daemon_id);
589
745
  const toolCallId = stringValue(event.tool_call_id);
590
- const eventId = stringValue(event.event_id) ?? toolCallId;
746
+ const eventId = stringValue(event.event_id) ?? (droid ? undefined : toolCallId);
591
747
  const toolName = stringValue(event.tool);
592
748
  const directory = stringValue(session.directory);
593
- if (!providerSessionId || !daemonId || !toolCallId || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
594
- throw new AppError("opencode_session_identity_invalid", "OpenCode event is missing trusted physical identity", 1);
749
+ if (!providerSessionId || !daemonId || (!droid && !toolCallId) || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
750
+ throw new AppError(`${errorPrefix}_session_identity_invalid`, `${label} event is missing trusted physical identity`, 1);
595
751
  }
596
752
  const expectedRoot = resolveProjectRoot(input.projectRoot);
597
- if (resolveProjectRoot(directory) !== expectedRoot) {
598
- throw new AppError(agy ? "agy_directory_mismatch" : "opencode_directory_mismatch", `${agy ? "Antigravity" : "OpenCode"} Session directory does not match the controlled workspace`, 1, { expected: expectedRoot, actual: directory });
599
- }
753
+ const observedCwd = resolveProjectRoot(directory);
754
+ if (droid && nativeParentId === providerSessionId)
755
+ throw new AppError("droid_session_identity_invalid", "A Droid Session cannot parent itself", 1);
600
756
  const sessionId = storageSessionId(nativeSessionIdentity(harness, providerSessionId));
601
757
  const parentSessionId = nativeParentId ? storageSessionId(nativeSessionIdentity(harness, nativeParentId)) : null;
602
758
  const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
@@ -606,67 +762,107 @@ export function handleOpenCodeEvent(context, input) {
606
762
  const inserted = recordHookEvent(context, {
607
763
  projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
608
764
  daemonId, sessionId, agentId: stringValue(session.agent_id) ?? null, turnId: stringValue(event.message_id) ?? null,
609
- eventName: "PostToolUse", toolName, status: "observed", payload: { session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName, status: objectRecord(event.outcome).status },
610
- eventKey: baseKey, matchKey: null, transcriptPath: null, cwd: expectedRoot
765
+ eventName: "PostToolUse", toolName, status: "observed", payload: { session_id: providerSessionId, cwd: observedCwd, tool_name: toolName, status: objectRecord(event.outcome).status },
766
+ eventKey: baseKey, matchKey: null, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: observedCwd
611
767
  });
612
768
  return { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}) };
613
769
  }
614
- if (!command || !["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"].includes(toolName)) {
770
+ if (!command || !(droid ? ["Execute"] : ["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"]).includes(toolName)) {
615
771
  return { ok: true, observed: false, reason: command ? "non_bash_tool" : "event_not_participating" };
616
772
  }
617
773
  const lifecycle = lifecycleFacts(command);
618
- if (!lifecycle)
774
+ const auxiliary = continuationCommand(command);
775
+ if (!lifecycle && !auxiliary)
619
776
  return { ok: true, observed: false, reason: "event_not_participating" };
620
- if (lifecycle.analysis.kind === "compound") {
621
- throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${agy ? "Antigravity" : "OpenCode"} shell tool call`, 1, { standalone_command: lifecycle.invocation.command });
622
- }
623
- const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
624
- if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
625
- throw new AppError("project_mismatch", "OpenCode lifecycle command project root does not match the controlled workspace", 1, { expected: expectedRoot, actual: commandProjectRoot });
777
+ const issuedRunId = lifecycle && !lifecycle.runId && !lifecycle.workId && !lifecycle.bootstrapStageStart
778
+ ? observedLifecycleInvocation(context, command, daemonId)?.scope.runId : null;
779
+ const expectedCwd = controlledLifecycleCwd(context, expectedRoot, lifecycle, providerSessionId, auxiliary, issuedRunId);
780
+ if (observedCwd !== expectedCwd) {
781
+ throw new AppError(`${errorPrefix}_directory_mismatch`, `${label} ${agy ? "launch route" : "Session directory"} does not match the assigned execution workspace`, 1, { expected: expectedCwd, actual: observedCwd, ...(agy ? { actual_provenance: stringValue(event.cwd_provenance) ?? "unknown" } : {}) });
626
782
  }
627
- const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
783
+ const bootstrapStageStart = lifecycle?.bootstrapStageStart;
784
+ let matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
628
785
  if (bootstrapStageStart)
629
786
  registerProject(context, { root: expectedRoot });
630
787
  const project = requireProjectByRoot(context, expectedRoot);
788
+ if (droid) {
789
+ const existing = context.db.get("SELECT harness, provider_session_id, parent_session_id, daemon_id, match_key, tool_name FROM hook_events WHERE project_id = ? AND event_key = ?", [project.id, baseKey]);
790
+ if (existing && (existing.harness !== harness || existing.provider_session_id !== providerSessionId || existing.parent_session_id !== parentSessionId || existing.daemon_id !== daemonId || existing.match_key !== matchKey || existing.tool_name !== toolName)) {
791
+ throw new AppError("droid_event_replay_mismatch", "Droid event ID was already observed with different lifecycle identity", 1);
792
+ }
793
+ }
631
794
  const agentId = stringValue(session.agent_id);
632
- const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
633
- ...flowPayload, harness, provider_session_id: providerSessionId, agent_id: agentId ?? null,
634
- parent_session_id: parentSessionId, provider: stringValue(profile.provider) ?? null, model: stringValue(profile.model) ?? null,
635
- reasoning: stringValue(profile.variant) ?? null, mode: stringValue(profile.agent) ?? null, agent_type: stringValue(profile.agent) ?? null,
636
- metadata: { opencode_version: stringValue(event.server_version) ?? null, api_fingerprint: stringValue(event.api_fingerprint) ?? null, plugin_sha256: stringValue(event.plugin_sha256) ?? null, native_project_id: stringValue(session.project_id) ?? null }
637
- }, sessionId) : undefined;
638
- const payload = {
639
- session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName, tool_input: rawInput, command,
640
- provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(profile.variant),
641
- mode: stringValue(profile.agent), agent_type: stringValue(profile.agent)
795
+ const persist = (scope) => {
796
+ if (scope)
797
+ matchKey = lifecycleMatchKey(lifecycleFacts(scope.command), expectedRoot, scope);
798
+ assertHookEventReplay(context, { projectId: project.id, eventKey: baseKey, harness, providerSessionId, parentSessionId, daemonId, sessionId, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName, matchKey, cwd: observedCwd });
799
+ const payload = {
800
+ session_id: providerSessionId, agent_id: agentId, cwd: observedCwd, tool_name: toolName, tool_input: rawInput, command,
801
+ ...(agy ? { cwd_provenance: stringValue(event.cwd_provenance) ?? "unknown" } : {}),
802
+ provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(droid ? profile.reasoning : profile.variant),
803
+ mode: stringValue(droid ? profile.mode : profile.agent), agent_type: stringValue(droid ? profile.agent_type : profile.agent)
804
+ };
805
+ const inserted = recordHookEvent(context, {
806
+ projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
807
+ daemonId, sessionId, agentId: agentId ?? null, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName,
808
+ status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: observedCwd
809
+ });
810
+ recordFlowSessionObservation(context, { projectId: project.id, sessionId, cwd: observedCwd, toolName, eventKey: baseKey });
811
+ return inserted;
642
812
  };
643
- const inserted = recordHookEvent(context, {
644
- projectId: project.id, protocolId: observedSession?.protocol_id ?? null, harness, providerSessionId, parentSessionId,
645
- daemonId, sessionId, agentId: agentId ?? null, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName,
646
- status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: null, cwd: expectedRoot
647
- });
648
- recordFlowSessionObservation(context, { projectId: project.id, sessionId, runId: observedSession?.run_id ?? null, protocolId: observedSession?.protocol_id ?? null, cwd: expectedRoot, toolName, eventKey: baseKey });
649
- const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}), daemon_id: daemonId };
650
- return agy ? result : { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, baseKey) } } };
813
+ const result = (inserted) => ({ ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}), daemon_id: daemonId });
814
+ if (agy && lifecycle) {
815
+ let inserted = false;
816
+ const receipt = observeLifecycleInvocation(context, {
817
+ command, harness, projectId: project.id, identity: { daemonId, rootSessionId: nativeParentId ?? providerSessionId, sessionId: providerSessionId, parentSessionId: nativeParentId ?? null, toolCallId: toolCallId },
818
+ recordReceipt: scope => { inserted = persist(scope); return baseKey; }
819
+ });
820
+ return { ...result(inserted), ...(receipt.invocationId ? { invocation_id: receipt.invocationId } : {}) };
821
+ }
822
+ const inserted = context.db.writeTransaction(() => persist());
823
+ return agy ? result(inserted) : { ...result(inserted), hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, baseKey) } } };
651
824
  }
652
825
  /** Convert an Antigravity CLI tool hook into the shared lifecycle receipt. */
653
826
  export function handleAgyEvent(context, input) {
654
827
  const event = parseJsonObject(input.stdin || "{}", "Antigravity tool event");
655
828
  if (event.schema_id !== "dd-flow/agy-tool-event@1")
656
829
  throw new AppError("unsupported_agy_event_schema", "Unsupported Antigravity event schema", 1);
657
- const workspacePaths = Array.isArray(event.workspace_paths) ? event.workspace_paths : [];
658
- const directory = workspacePaths.find((value) => typeof value === "string" && path.isAbsolute(value));
830
+ // workspacePaths lists available workspaces; its first element is not a
831
+ // native selected-cwd observation. The daemon's spawn cwd is a separate,
832
+ // launch-bound route, and the list only corroborates its membership.
833
+ const workspacePaths = Array.isArray(event.workspace_paths)
834
+ ? event.workspace_paths.filter((value) => typeof value === "string" && path.isAbsolute(value)) : [];
835
+ const launchCwd = stringValue(event.launch_cwd);
836
+ const qualifiedLaunchCwd = launchCwd && path.isAbsolute(launchCwd) ? launchCwd : undefined;
837
+ const directory = qualifiedLaunchCwd ?? (workspacePaths.length === 1 ? workspacePaths[0] : undefined);
838
+ if (!directory && workspacePaths.length > 1)
839
+ throw new AppError("agy_workspace_route_unproven", "Antigravity did not identify the active route among multiple workspaces", 1, { workspace_paths: workspacePaths });
840
+ if (directory && qualifiedLaunchCwd && workspacePaths.length
841
+ && !workspacePaths.some(value => resolveProjectRoot(value) === resolveProjectRoot(directory))) {
842
+ throw new AppError("agy_workspace_route_unproven", "Antigravity launch cwd is absent from the native workspace list", 1, { launch_cwd: directory, workspace_paths: workspacePaths });
843
+ }
659
844
  const providerSessionId = stringValue(event.conversation_id);
660
845
  const daemonId = stringValue(event.daemon_id);
661
846
  const eventId = stringValue(event.event_id);
662
847
  const toolName = stringValue(event.tool);
663
848
  if (!directory || !providerSessionId || !daemonId || !eventId || !toolName)
664
849
  throw new AppError("agy_session_identity_invalid", "Antigravity event is missing trusted physical identity", 1);
850
+ const rawInput = objectRecord(event.input);
851
+ const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
852
+ if (command && (lifecycleFacts(command) || continuationCommand(command)) && !qualifiedLaunchCwd) {
853
+ throw new AppError("agy_workspace_route_unproven", "Managed Antigravity call has no launch-bound execution cwd", 1, { phase: "prepare", effect: "no_effect", recoverable: false, workspace_paths: workspacePaths });
854
+ }
855
+ const invalidStep = !Number.isSafeInteger(event.step_index) || Number(event.step_index) < 0;
856
+ const invalidExecution = event.execution_num !== undefined && (!Number.isSafeInteger(event.execution_num) || Number(event.execution_num) < 0);
857
+ if (event.phase === "before" && command && (lifecycleFacts(command) || continuationCommand(command)) && (invalidStep || invalidExecution)) {
858
+ throw new AppError("agy_native_call_identity_missing", "Participating Antigravity lifecycle calls require a native step identity and a valid execution identity when provided", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
859
+ }
665
860
  const translated = {
666
861
  schema_id: "dd-flow/opencode-tool-event@1", source_harness: "antigravity-cli", phase: event.phase, event_id: eventId,
667
862
  daemon_id: daemonId, tool_call_id: eventId, tool: toolName, input: event.input,
668
863
  message_id: event.step_index === undefined ? null : String(event.step_index),
669
864
  session: { provider_session_id: providerSessionId, parent_provider_session_id: event.parent_conversation_id, directory },
865
+ cwd_provenance: qualifiedLaunchCwd ? "launch_bound" : "workspace_paths_singleton",
670
866
  profile: { provider: objectRecord(event.profile).provider, model: objectRecord(event.profile).model, variant: objectRecord(event.profile).reasoning, agent: objectRecord(event.profile).mode },
671
867
  outcome: { status: stringValue(event.error) ? "error" : "completed" }
672
868
  };
@@ -694,15 +890,56 @@ export function isolatedDdFlowHomeFromHookStdin(stdin) {
694
890
  * hook contract intentionally narrow: unwrap only that whole-command form,
695
891
  * then apply the same lifecycle parser to its actual shell payload.
696
892
  */
697
- function lifecycleCommandFromPayload(payload) {
698
- const command = commandFromPayload(payload);
699
- return command ? unwrapShellCommand(command) : undefined;
700
- }
701
- function commandWithHookEvent(command, eventKey) {
893
+ /**
894
+ * Adds the receipt to the command that the shell will execute. Heredoc input
895
+ * belongs to that command too, but an option appended after its delimiter is a
896
+ * second shell command rather than an argument, so insert it before `<<`.
897
+ */
898
+ export function commandWithHookEvent(command, eventKey) {
702
899
  const analysis = parseLifecycleCommand(command);
703
- if (analysis.kind !== "standalone" || !analysis.invocation.rewriteSafe || commandHasOption(analysis.invocation, "hook-event-id"))
900
+ if (analysis.kind !== "standalone")
901
+ return command;
902
+ if (commandHasOption(analysis.invocation, "hook-event-id")) {
704
903
  return command;
705
- return `${command.trim()} --hook-event-id ${JSON.stringify(eventKey)}`;
904
+ }
905
+ const option = `--hook-event-id '${eventKey.replaceAll("'", "'\\''")}'`;
906
+ if (analysis.invocation.rewriteSafe) {
907
+ const index = shellSuffixOffset(command);
908
+ return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`.trimEnd();
909
+ }
910
+ if (!analysis.invocation.wrapped && analysis.invocation.heredoc && allowsStdinLifecycleCompound(lifecycleFacts(command))) {
911
+ const index = firstHeredoc(command);
912
+ if (index >= 0)
913
+ return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`;
914
+ }
915
+ return command;
916
+ }
917
+ function firstHeredoc(command) {
918
+ let quoteState = null;
919
+ let escaped = false;
920
+ for (let index = 0; index < command.length - 1; index += 1) {
921
+ const char = command[index];
922
+ if (escaped) {
923
+ escaped = false;
924
+ continue;
925
+ }
926
+ if (char === "\\" && quoteState !== "'") {
927
+ escaped = true;
928
+ continue;
929
+ }
930
+ if (quoteState) {
931
+ if (char === quoteState)
932
+ quoteState = null;
933
+ continue;
934
+ }
935
+ if (char === "'" || char === '"') {
936
+ quoteState = char;
937
+ continue;
938
+ }
939
+ if (char === "<" && command[index + 1] === "<" && command[index + 2] !== "<")
940
+ return index;
941
+ }
942
+ return -1;
706
943
  }
707
944
  /**
708
945
  * Recognise the small shell surface used by generated dd-flow lifecycle
@@ -723,25 +960,51 @@ function lifecycleFacts(command) {
723
960
  return null;
724
961
  const invocation = analysis.invocation;
725
962
  const stageStart = invocation.operation === "stage_start";
963
+ const stageFinish = invocation.operation === "stage_finish";
964
+ const stagePause = invocation.operation === "stage_pause";
726
965
  const stageResume = invocation.operation === "stage_resume";
727
966
  const workStart = invocation.operation === "work_start";
967
+ const workFinish = invocation.operation === "work_finish";
968
+ const workFail = invocation.operation === "work_fail";
728
969
  return {
729
970
  analysis,
730
971
  invocation,
731
- flowPayload: flowSessionPayloadFromRegisterCommand(invocation),
732
972
  stageStart,
973
+ stageFinish,
974
+ stagePause,
733
975
  stageResume,
734
- workStart,
976
+ workStart, workFinish, workFail,
735
977
  bootstrapStageStart: stageStart && commandHasOption(invocation, "bootstrap"),
736
978
  commandProjectRoot: commandOption(invocation, "project-root"),
737
- stageName: stageStart || stageResume ? commandOption(invocation, "stage") : undefined,
738
- runId: stageStart || stageResume ? commandPosition(invocation, 0) : undefined,
739
- workId: stageResume ? commandOption(invocation, "work") : workStart ? commandPosition(invocation, 0) : undefined,
979
+ stageName: stageStart || stageFinish || stagePause || stageResume ? commandOption(invocation, "stage") : undefined,
980
+ runId: stageStart || stageFinish || stagePause || stageResume ? commandPosition(invocation, 0) : undefined,
981
+ workId: stagePause || stageResume ? commandOption(invocation, "work") : workStart || workFinish || workFail ? commandPosition(invocation, 0) : undefined,
740
982
  contextSha256: commandOption(invocation, "context-sha256")
741
983
  };
742
984
  }
743
- function lifecycleMatchKey(facts, projectRoot) {
744
- const { invocation, bootstrapStageStart, stageStart, stageResume, workStart, stageName, runId, workId, contextSha256 } = facts;
985
+ // Worker completion and the two HITL commands intentionally take their
986
+ // payload from stdin. Their generated pipe/heredoc is one lifecycle command,
987
+ // not a second authority. Everything else remains standalone-only.
988
+ function allowsStdinLifecycleCompound(facts) {
989
+ const operation = facts.invocation.operation;
990
+ return facts.analysis.kind === "standalone" && facts.invocation.heredoc && !facts.invocation.wrapped
991
+ && ((operation === "work_finish" && commandHasOption(facts.invocation, "result-stdin"))
992
+ || (operation === "stage_start" && commandHasOption(facts.invocation, "intake-stdin"))
993
+ || (operation === "stage_pause" && commandHasOption(facts.invocation, "question-stdin"))
994
+ || (operation === "stage_resume" && commandHasOption(facts.invocation, "answer-stdin")));
995
+ }
996
+ function lifecycleMatchKey(facts, projectRoot, scope) {
997
+ // Observe the actual argument without validating it. A mistyped project
998
+ // must not leave a receipt matching a later, corrected non-rewriting call.
999
+ if (!scope && facts.commandProjectRoot && canonicalPath(facts.commandProjectRoot, false) !== canonicalPath(projectRoot, false))
1000
+ return null;
1001
+ const { invocation, bootstrapStageStart, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, stageName, runId, workId, contextSha256 } = facts;
1002
+ const effectiveRoot = scope?.projectRoot ?? projectRoot;
1003
+ const effectiveRunId = scope?.runId ?? runId;
1004
+ if (invocation.operation === "recovery_accept") {
1005
+ const run = commandPosition(invocation, 0), recovery = commandOption(invocation, "recovery-id");
1006
+ return (scope?.runId ?? run) && recovery ? recoveryAcceptMatchKey((scope?.runId ?? run), recovery, effectiveRoot) : null;
1007
+ }
745
1008
  if (bootstrapStageStart)
746
1009
  return bootstrapMatchKey({
747
1010
  projectRoot,
@@ -749,14 +1012,100 @@ function lifecycleMatchKey(facts, projectRoot) {
749
1012
  intakeMode: contextSha256 ? "context" : commandHasOption(invocation, "intake-stdin") ? "stdin" : "file",
750
1013
  ...(contextSha256 ? { contextSha256 } : {})
751
1014
  });
752
- if (stageStart && runId && stageName)
753
- return stageStartMatchKey(runId, stageName, projectRoot, contextSha256);
754
- if (stageResume && runId && stageName && workId)
755
- return stageResumeMatchKey(runId, stageName, workId, projectRoot);
1015
+ if (stageStart && effectiveRunId && stageName)
1016
+ return stageStartMatchKey(effectiveRunId, stageName, effectiveRoot, contextSha256);
1017
+ if (stageFinish && effectiveRunId && stageName)
1018
+ return stageLifecycleMatchKey("stage_finish", effectiveRunId, stageName, effectiveRoot);
1019
+ if (stagePause && effectiveRunId && stageName && workId)
1020
+ return stageLifecycleMatchKey("stage_pause", effectiveRunId, stageName, effectiveRoot, workId);
1021
+ if (stageResume && effectiveRunId && stageName && workId)
1022
+ return stageResumeMatchKey(effectiveRunId, stageName, workId, effectiveRoot);
756
1023
  if (workStart && workId)
757
- return workStartMatchKey(workId, projectRoot);
1024
+ return workStartMatchKey(workId, effectiveRoot, commandOption(invocation, "recovery-id"));
1025
+ if (workFinish && workId)
1026
+ return workLifecycleMatchKey("work_finish", workId, effectiveRoot);
1027
+ if (workFail && workId)
1028
+ return workLifecycleMatchKey("work_fail", workId, effectiveRoot);
758
1029
  return null;
759
1030
  }
1031
+ /** Observe exact immutable command attribution before reading model-supplied
1032
+ * result files. This does not claim or authorize the lifecycle mutation. */
1033
+ export function observeCommandHook(context, command) {
1034
+ const facts = lifecycleFacts(command);
1035
+ if (!facts || facts.analysis.kind !== "standalone" || !facts.commandProjectRoot)
1036
+ return;
1037
+ const project = context.db.get("SELECT id FROM projects WHERE root = ?", [resolveProjectRoot(facts.commandProjectRoot)]);
1038
+ if (!project)
1039
+ return; // Bootstrap has not registered a project yet.
1040
+ const matchKey = lifecycleMatchKey(facts, facts.commandProjectRoot);
1041
+ if (!matchKey)
1042
+ return;
1043
+ let key = commandOption(facts.invocation, "hook-event-id");
1044
+ if (!key && commandOption(facts.invocation, "invocation-id"))
1045
+ return;
1046
+ if (!key) {
1047
+ try {
1048
+ key = findRecentMatchingHookEvent(context, { projectId: project.id, matchKey, errorCode: "trusted_session_binding_required", operation: facts.invocation.operation }).eventKey;
1049
+ }
1050
+ catch {
1051
+ return;
1052
+ } // Dispatch still owns its mandatory admission error.
1053
+ }
1054
+ const event = context.db.get("SELECT id FROM hook_events WHERE project_id = ? AND event_key = ? AND match_key = ? AND status = 'observed'", [project.id, key, matchKey]);
1055
+ if (event)
1056
+ context.observeLifecycleHook?.(project.id, event.id);
1057
+ }
1058
+ export function stageLifecycleMatchKey(operation, runId, stage, projectRoot, workId) {
1059
+ return crypto.createHash("sha256").update(JSON.stringify({ operation, run_id: runId, stage, project_root: resolveProjectRoot(projectRoot), ...(workId ? { work_id: workId } : {}) })).digest("hex");
1060
+ }
1061
+ export function recoveryAcceptMatchKey(runId, recoveryId, projectRoot) {
1062
+ return crypto.createHash("sha256").update(JSON.stringify({ operation: "recovery_accept", run_id: runId, recovery_id: recoveryId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
1063
+ }
1064
+ export function workLifecycleMatchKey(operation, workId, projectRoot) {
1065
+ return crypto.createHash("sha256").update(JSON.stringify({ operation, work_id: workId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
1066
+ }
1067
+ function workLifecycleMatchKeys(operation, workId, projectRoot) {
1068
+ return entityReferenceVariants(workId).map((reference) => workLifecycleMatchKey(operation, reference, projectRoot));
1069
+ }
1070
+ function stageLifecycleMatchKeys(operation, runId, stage, projectRoot, workId) {
1071
+ const runReferences = entityReferenceVariants(runId);
1072
+ const workReferences = workId ? entityReferenceVariants(workId) : [undefined];
1073
+ return [...new Set(runReferences.flatMap((runReference) => workReferences.map((workReference) => stageLifecycleMatchKey(operation, runReference, stage, projectRoot, workReference))))];
1074
+ }
1075
+ /** Claims a Work terminal command only after its exact hook receipt has proved
1076
+ * the physical Session that issued it. Work code performs the owner check. */
1077
+ export function claimWorkLifecycleHookEvent(context, input) {
1078
+ const matchKeys = workLifecycleMatchKeys(input.operation, input.workId, input.projectRoot);
1079
+ const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
1080
+ projectId: input.projectId, matchKey: matchKeys, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
1081
+ }).eventKey;
1082
+ const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
1083
+ if (!event?.session_id || event.status !== "observed" || !event.match_key || !matchKeys.includes(event.match_key)) {
1084
+ const reason = !event ? "event_missing" : !event.session_id ? "identity_missing" : event.status !== "observed" ? "event_consumed" : "target_mismatch";
1085
+ throw new AppError("trusted_session_binding_required", `${input.operation.replace("_", " ")} requires one fresh matching PreToolUse hook event`, 1, { work_id: input.workId, event_key: eventKey, reason, observed_status: event?.status ?? null });
1086
+ }
1087
+ const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
1088
+ if (claimed.changes !== 1)
1089
+ throw new AppError("trusted_session_binding_required", "matching Work lifecycle hook event was already claimed", 1, { work_id: input.workId });
1090
+ return hookSessionIdentity(context, input.projectId, eventKey);
1091
+ }
1092
+ /** Claims a lifecycle command only after its exact hook receipt has proved the
1093
+ * physical Session that issued it. Stage code performs the Work-owner check. */
1094
+ export function claimStageLifecycleHookEvent(context, input) {
1095
+ const matchKeys = stageLifecycleMatchKeys(input.operation, input.runId, input.stage, input.projectRoot, input.workId);
1096
+ const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
1097
+ projectId: input.projectId, matchKey: matchKeys, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
1098
+ }).eventKey;
1099
+ const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
1100
+ if (!event?.session_id || event.status !== "observed" || !event.match_key || !matchKeys.includes(event.match_key)) {
1101
+ const reason = !event ? "event_missing" : !event.session_id ? "identity_missing" : event.status !== "observed" ? "event_consumed" : "target_mismatch";
1102
+ throw new AppError("trusted_session_binding_required", `${input.operation.replace("_", " ")} requires one fresh matching PreToolUse hook event`, 1, { run_id: input.runId, stage: input.stage, event_key: eventKey, reason, observed_status: event?.status ?? null });
1103
+ }
1104
+ const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
1105
+ if (claimed.changes !== 1)
1106
+ throw new AppError("trusted_session_binding_required", "matching lifecycle hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
1107
+ return hookSessionIdentity(context, input.projectId, eventKey);
1108
+ }
760
1109
  /** Trusted session identity is created by PreToolUse, never supplied by an agent. */
761
1110
  export function sessionIdForHookEvent(context, projectId, eventKey) {
762
1111
  const event = context.db.get("SELECT session_id FROM hook_events WHERE project_id = ? AND event_key = ?", [projectId, eventKey]);
@@ -772,6 +1121,7 @@ export function hookSessionIdentity(context, projectId, eventKey) {
772
1121
  WHERE he.project_id = ? AND he.event_key = ?`, [projectId, eventKey]);
773
1122
  if (!event?.session_id)
774
1123
  throw new AppError("hook_event_not_found", "stage start requires a trusted PreToolUse hook event", 1, { event_key: eventKey });
1124
+ context.observeLifecycleHook?.(projectId, event.id);
775
1125
  return {
776
1126
  hookEventId: event.id,
777
1127
  harness: event.harness,
@@ -808,21 +1158,30 @@ export function claimRecentMatchingHookEvent(context, input) {
808
1158
  /** Finds the trusted event that a stage- or Work-specific claimant will atomically claim. */
809
1159
  export function findRecentMatchingHookEvent(context, input) {
810
1160
  const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
811
- const find = () => context.db.get(`SELECT id, event_key FROM hook_events
812
- WHERE project_id = ? AND match_key = ? AND status = 'observed' AND created_at >= ?
813
- ORDER BY id DESC LIMIT 1`, [input.projectId, input.matchKey, freshAfter]);
814
- let event = find();
815
- // Some harnesses publish nested-agent tool events concurrently with command
816
- // execution. Give the trusted event a short bounded window to reach SQLite.
1161
+ const matchKeys = Array.isArray(input.matchKey) ? [...new Set(input.matchKey)] : [input.matchKey];
1162
+ const placeholders = matchKeys.map(() => "?").join(", ");
1163
+ const find = () => context.db.all(`SELECT id, event_key FROM hook_events
1164
+ WHERE project_id = ? AND match_key IN (${placeholders}) AND status = 'observed' AND created_at >= ?
1165
+ AND NOT (COALESCE(json_extract(outcome_json, '$.effect'), '') = 'no_effect'
1166
+ AND COALESCE(json_extract(outcome_json, '$.disposition'), '') IN ('correctable', 'fatal'))
1167
+ AND harness IN ('codex-desktop', 'antigravity-cli')
1168
+ AND (? IS NULL OR session_id = ?)
1169
+ ORDER BY id DESC LIMIT 2`, [input.projectId, ...matchKeys, freshAfter, input.sessionId ?? null, input.sessionId ?? null]);
1170
+ let events = find();
1171
+ // Only the qualified non-rewriting transports may correlate the original
1172
+ // command: Codex Desktop and Antigravity. New harnesses fail closed by default.
817
1173
  const deadline = Date.now() + 250;
818
- while (!event?.event_key && Date.now() < deadline) {
1174
+ while (events.length === 0 && Date.now() < deadline) {
819
1175
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
820
- event = find();
1176
+ events = find();
821
1177
  }
822
- if (!event?.event_key) {
823
- throw new AppError(input.errorCode, `${input.operation} requires one matching harness lifecycle event. Stay in this Session and retry the exact standalone lifecycle command; do not create a new Session.`, 1);
1178
+ if (events.length === 0 || !events[0]?.event_key) {
1179
+ throw new AppError(input.errorCode, `${input.operation} requires a trusted lifecycle receipt. Check native hook delivery; managed commands are admitted from committed native identity, not from model-supplied invocation IDs or updatedInput.`, 1, { reason: "event_missing" });
824
1180
  }
825
- return { id: event.id, eventKey: event.event_key };
1181
+ if (events.length > 1) {
1182
+ throw new AppError("ambiguous_lifecycle_receipt", `${input.operation} has more than one fresh matching lifecycle event; stop and let the controller reconcile native delivery`, 1, { reason: "multiple_live_events", match_keys: matchKeys, event_keys: events.map((event) => event.event_key) });
1183
+ }
1184
+ return { id: events[0].id, eventKey: events[0].event_key };
826
1185
  }
827
1186
  /** Fallback for Desktop paths that execute the original command after a hook rewrite. */
828
1187
  export function claimBootstrapHookEvent(context, input) {
@@ -845,9 +1204,9 @@ export function bootstrapMatchKey(input) {
845
1204
  })).digest("hex");
846
1205
  }
847
1206
  export function claimWorkStartHookEvent(context, input) {
848
- const expected = new Set([workStartMatchKey(input.workId, input.projectRoot), workStartMatchKey(shortWorkReference(input.workId), input.projectRoot)]);
1207
+ const expected = entityReferenceVariants(input.workId).map((reference) => workStartMatchKey(reference, input.projectRoot, input.recoveryId));
849
1208
  const event = context.db.get("SELECT id, session_id, agent_id, turn_id, transcript_path, model, agent_type, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
850
- if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.has(event.match_key)) {
1209
+ if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.includes(event.match_key)) {
851
1210
  throw new AppError("trusted_work_launch_required", "work start requires one fresh matching PreToolUse event", 1, { work_id: input.workId, event_key: input.eventKey });
852
1211
  }
853
1212
  const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
@@ -855,15 +1214,16 @@ export function claimWorkStartHookEvent(context, input) {
855
1214
  throw new AppError("trusted_work_launch_required", "matching work-start hook event was already claimed", 1, { work_id: input.workId });
856
1215
  return hookSessionIdentity(context, input.projectId, input.eventKey);
857
1216
  }
858
- function shortWorkReference(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id; }
859
1217
  /** Claims the exact non-bootstrap stage entry which starts a coordinator Work. */
860
1218
  export function assertStageStartHookEvent(context, input) {
861
- const expected = stageStartMatchKey(input.runId, input.stage, input.projectRoot, input.contextSha256);
1219
+ const expected = entityReferenceVariants(input.runId).map((reference) => stageStartMatchKey(reference, input.stage, input.projectRoot, input.contextSha256));
862
1220
  const event = context.db.get("SELECT session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
863
- if (!event?.session_id || event.status !== "observed" || event.match_key !== expected) {
864
- throw new AppError("trusted_stage_launch_required", "stage start requires one fresh matching PreToolUse event", 1, { run_id: input.runId, stage: input.stage, event_key: input.eventKey, expected_match_key: expected, observed_match_key: event?.match_key ?? null, observed_status: event?.status ?? null });
1221
+ if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.includes(event.match_key)) {
1222
+ throw new AppError("trusted_stage_launch_required", "stage start requires one fresh matching PreToolUse event", 1, { run_id: input.runId, stage: input.stage, event_key: input.eventKey, expected_match_keys: expected, observed_match_key: event?.match_key ?? null, observed_status: event?.status ?? null });
865
1223
  }
866
- return hookSessionIdentity(context, input.projectId, input.eventKey);
1224
+ const identity = hookSessionIdentity(context, input.projectId, input.eventKey);
1225
+ assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
1226
+ return identity;
867
1227
  }
868
1228
  export function claimStageStartHookEvent(context, input) {
869
1229
  assertStageStartHookEvent(context, input);
@@ -893,24 +1253,25 @@ export function stageResumeMatchKey(runId, stage, workId, projectRoot) {
893
1253
  }
894
1254
  /** Claims the resume command observed in the Session that received the user answer. */
895
1255
  export function claimStageResumeHookEvent(context, input) {
896
- const matchKey = stageResumeMatchKey(input.runId, input.stage, input.workId, input.projectRoot);
897
- const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
898
- const event = input.eventKey
899
- ? context.db.get("SELECT id, event_key, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey])
900
- : context.db.get("SELECT id, event_key, session_id, match_key, status FROM hook_events WHERE project_id = ? AND session_id = ? AND match_key = ? AND status = 'observed' AND created_at >= ? ORDER BY id DESC LIMIT 1", [input.projectId, input.sessionId ?? "", matchKey, freshAfter]);
901
- if (!event?.event_key || !event.session_id || event.match_key !== matchKey || event.status !== "observed") {
1256
+ const matchKeys = entityReferenceVariants(input.runId).flatMap((runReference) => entityReferenceVariants(input.workId).map((workReference) => stageResumeMatchKey(runReference, input.stage, workReference, input.projectRoot)));
1257
+ const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, { projectId: input.projectId, matchKey: matchKeys, sessionId: input.sessionId ?? "", errorCode: "trusted_stage_resume_required", operation: "stage resume" }).eventKey;
1258
+ const event = context.db.get("SELECT id, event_key, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
1259
+ if (!event?.event_key || !event.session_id || !event.match_key || !matchKeys.includes(event.match_key) || event.status !== "observed") {
902
1260
  throw new AppError("trusted_stage_resume_required", "stage resume requires one fresh matching PreToolUse event", 1, { run_id: input.runId, stage: input.stage, work_id: input.workId });
903
1261
  }
1262
+ const identity = hookSessionIdentity(context, input.projectId, event.event_key);
1263
+ assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
904
1264
  const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
905
1265
  if (claimed.changes !== 1)
906
1266
  throw new AppError("trusted_stage_resume_required", "matching stage-resume hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
907
- return hookSessionIdentity(context, input.projectId, event.event_key);
1267
+ return identity;
908
1268
  }
909
- export function workStartMatchKey(workId, projectRoot) {
1269
+ export function workStartMatchKey(workId, projectRoot, recoveryId) {
910
1270
  return crypto.createHash("sha256").update(JSON.stringify({
911
1271
  operation: "work_start",
912
1272
  work_id: workId,
913
- project_root: resolveProjectRoot(projectRoot)
1273
+ project_root: resolveProjectRoot(projectRoot),
1274
+ ...(recoveryId ? { recovery_id: recoveryId } : {})
914
1275
  })).digest("hex");
915
1276
  }
916
1277
  export function hookStatusForProject(context, projectId) {
@@ -950,6 +1311,8 @@ export function codexHookEventsForProject(context, projectId) {
950
1311
  FROM hook_events WHERE project_id = ? ORDER BY created_at DESC, id DESC LIMIT 20`, [projectId]);
951
1312
  }
952
1313
  function resolveProfileName(profile) {
1314
+ if (profile !== undefined && (typeof profile !== "string" || !profile.trim()))
1315
+ throw new AppError("validation", "--profile must be nonempty text", 2);
953
1316
  return sanitizeProfile(profile ?? defaultProfileName);
954
1317
  }
955
1318
  function sanitizeProfile(value) {
@@ -977,11 +1340,12 @@ function readTomlIfExists(file) {
977
1340
  if (!fs.existsSync(file)) {
978
1341
  return {};
979
1342
  }
1343
+ const { text } = prepareTextFile(file, "Codex config");
980
1344
  try {
981
- return parse(fs.readFileSync(file, "utf8"));
1345
+ return parse(text);
982
1346
  }
983
1347
  catch (error) {
984
- throw new AppError("validation", `Invalid TOML in ${file}: ${String(error)}`, 2);
1348
+ throw new AppError("validation", `Invalid TOML in ${file}`, 2, { file, ...(error instanceof TomlError ? { line: error.line, column: error.column } : {}) });
985
1349
  }
986
1350
  }
987
1351
  function withManagedConfig(config, sourceHome, targetHome) {
@@ -1000,7 +1364,7 @@ function objectTable(value) {
1000
1364
  function createSharedLinks(sourceHome, targetHome) {
1001
1365
  const linked = [];
1002
1366
  const skipped = [];
1003
- for (const entry of sharedEntries) {
1367
+ for (const entry of codexHomeSharedEntries) {
1004
1368
  const source = path.join(sourceHome, entry);
1005
1369
  const target = path.join(targetHome, entry);
1006
1370
  if (!fs.existsSync(source)) {
@@ -1023,7 +1387,7 @@ function createSharedLinks(sourceHome, targetHome) {
1023
1387
  return { linked, skipped };
1024
1388
  }
1025
1389
  function removeSharedSymlinks(targetHome) {
1026
- for (const entry of sharedEntries) {
1390
+ for (const entry of codexHomeSharedEntries) {
1027
1391
  const target = path.join(targetHome, entry);
1028
1392
  if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink()) {
1029
1393
  fs.rmSync(target);
@@ -1036,7 +1400,7 @@ function removeDirIfEmpty(dir) {
1036
1400
  }
1037
1401
  }
1038
1402
  function sharedEntryStatus(sourceHome, targetHome) {
1039
- return sharedEntries.map((entry) => {
1403
+ return codexHomeSharedEntries.map((entry) => {
1040
1404
  const source = path.join(sourceHome, entry);
1041
1405
  const target = path.join(targetHome, entry);
1042
1406
  return {
@@ -1057,10 +1421,14 @@ function hookEntry(matcher, command, statusMessage) {
1057
1421
  return { matcher, hooks: [commandHook(command, statusMessage)] };
1058
1422
  }
1059
1423
  function commandHook(command, statusMessage) {
1060
- return { type: "command", command, timeout: 5, statusMessage };
1424
+ // Hook admission is synchronous (the CLI must see a committed receipt), but
1425
+ // the old 5s budget was shorter than a cold CLI/store open under fan-out.
1426
+ // Startup owns migrations; this budget only covers the bounded hot path.
1427
+ return { type: "command", command, timeout: 30, statusMessage };
1061
1428
  }
1062
1429
  function hookCommand(event) {
1063
- const handler = `PATH="\${HOME}/Library/pnpm:/opt/homebrew/bin:/usr/local/bin:\${PATH}" dd-flow codex hook handle --event ${event} --json`;
1430
+ const args = `codex hook handle --event ${event} --json`;
1431
+ const handler = `if [ -n "\${DD_FLOW_BIN:-}" ]; then "$DD_FLOW_BIN" ${args}; else PATH="\${DD_FLOW_HOME:+\${DD_FLOW_HOME}/bin:}\${PATH}:\${HOME}/Library/pnpm:/opt/homebrew/bin:/usr/local/bin" dd-flow ${args}; fi`;
1064
1432
  return `/bin/bash -c 'log_dir="\${DD_FLOW_HOME:-\${HOME}/.dd-flow}/logs"; mkdir -p "$log_dir"; log="$log_dir/codex-hook.log"; printf "%s event=${event} phase=start pid=%s\\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$$" >>"$log"; ${handler} 2> >(tee -a "$log" >&2); status=$?; printf "%s event=${event} phase=finish pid=%s exit=%s\\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$$" "$status" >>"$log"; exit "$status"'`;
1065
1433
  }
1066
1434
  function resolveHookTarget(target) {
@@ -1236,11 +1604,10 @@ function recordHookInstallation(context, projectId, scope, configPath, installed
1236
1604
  function installationScope(target, profile, hooksPath) {
1237
1605
  return target === "isolated" ? `isolated:${profile}` : `default:${path.dirname(hooksPath)}`;
1238
1606
  }
1239
- function upsertSessionBindingFromPayload(context, project, sessionId, payload) {
1607
+ function upsertSessionBindingFromPayload(context, project, sessionId, payload, transcriptPath) {
1240
1608
  const existing = sessionBinding(context, project.id, sessionId);
1241
1609
  const now = context.now();
1242
1610
  const cwd = stringValue(payload.cwd) ?? existing?.cwd ?? null;
1243
- const transcriptPath = stringValue(payload.transcript_path) ?? existing?.transcript_path ?? locateCodexTranscript(sessionId, context.env.CODEX_HOME);
1244
1611
  context.db.run(`INSERT INTO codex_session_bindings
1245
1612
  (session_id, project_id, protocol_id, handshake_id, cwd, transcript_path, status, created_at, updated_at)
1246
1613
  VALUES (?, ?, NULL, NULL, ?, ?, 'active', ?, ?)
@@ -1294,12 +1661,11 @@ function sessionBinding(context, projectId, sessionId) {
1294
1661
  return context.db.get("SELECT * FROM codex_session_bindings WHERE project_id = ? AND session_id = ?", [projectId, sessionId]);
1295
1662
  }
1296
1663
  function recordHookEvent(context, input) {
1297
- const existing = context.db.get("SELECT id FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
1298
- if (existing)
1664
+ if (assertHookEventReplay(context, input))
1299
1665
  return false;
1300
1666
  context.db.run(`INSERT INTO hook_events
1301
- (project_id, protocol_id, harness, provider_session_id, parent_session_id, daemon_id, session_id, agent_id, turn_id, transcript_path, provider, model, reasoning, mode, agent_type, cwd, event_key, match_key, event_name, tool_name, status, sanitized_summary, created_at)
1302
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1667
+ (project_id, protocol_id, harness, provider_session_id, parent_session_id, daemon_id, session_id, agent_id, turn_id, transcript_path, provider, model, reasoning, mode, agent_type, cwd, event_key, match_key, event_name, tool_name, status, sanitized_summary, created_at)
1668
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1303
1669
  input.projectId,
1304
1670
  input.protocolId,
1305
1671
  input.harness,
@@ -1326,6 +1692,23 @@ function recordHookEvent(context, input) {
1326
1692
  ]);
1327
1693
  return true;
1328
1694
  }
1695
+ /** A duplicate delivery is harmless only when all durable identity facts match. */
1696
+ function assertHookEventReplay(context, input) {
1697
+ const existing = context.db.get(`SELECT harness, provider_session_id, parent_session_id, daemon_id, session_id, turn_id, event_name, tool_name, match_key, cwd
1698
+ FROM hook_events WHERE project_id = ? AND event_key = ?`, [input.projectId, input.eventKey]);
1699
+ if (existing) {
1700
+ const observed = {
1701
+ harness: input.harness, provider_session_id: input.providerSessionId, parent_session_id: input.parentSessionId,
1702
+ daemon_id: input.daemonId ?? null, session_id: input.sessionId, turn_id: input.turnId,
1703
+ event_name: input.eventName, tool_name: input.toolName, match_key: input.matchKey, cwd: input.cwd
1704
+ };
1705
+ if (Object.entries(observed).some(([key, value]) => existing[key] !== value)) {
1706
+ throw new AppError("hook_event_replay_mismatch", "Lifecycle event ID was already observed with different immutable identity", 1, { event_key: input.eventKey });
1707
+ }
1708
+ return true;
1709
+ }
1710
+ return false;
1711
+ }
1329
1712
  function projectForHook(context, explicitRoot, cwd) {
1330
1713
  if (explicitRoot)
1331
1714
  return requireProjectByRoot(context, resolveProjectRoot(explicitRoot));
@@ -1352,14 +1735,6 @@ function hookEventKey(payload, eventName, toolName, command) {
1352
1735
  command
1353
1736
  })).digest("hex");
1354
1737
  }
1355
- function commandFromPayload(payload) {
1356
- const direct = stringValue(payload.command);
1357
- if (direct) {
1358
- return direct;
1359
- }
1360
- const toolInput = objectRecord(payload.tool_input);
1361
- return stringValue(toolInput.command) ?? stringValue(toolInput.cmd);
1362
- }
1363
1738
  function toolNameFromPayload(payload) {
1364
1739
  const toolInput = objectRecord(payload.tool_input);
1365
1740
  return stringValue(toolInput.name);
@@ -1402,10 +1777,10 @@ function writeJsonFile(file, value) {
1402
1777
  ensureDir(path.dirname(file));
1403
1778
  fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
1404
1779
  }
1405
- function backupFile(file, now) {
1780
+ function backupFile(file, now, text) {
1406
1781
  const suffix = now.replace(/[:.]/g, "-");
1407
1782
  const backup = `${file}.${suffix}.bak`;
1408
- fs.copyFileSync(file, backup);
1783
+ fs.writeFileSync(backup, text, { mode: 0o600 });
1409
1784
  return backup;
1410
1785
  }
1411
1786
  function hashObject(value) {