@deksden-com/dd-flow-cli 0.9.0-beta.7 → 0.9.0-beta.74

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 (121) hide show
  1. package/CHANGELOG.md +396 -0
  2. package/README.md +65 -0
  3. package/dist/build-info.json +10 -10
  4. package/dist/cli/help.js +81 -9
  5. package/dist/cli/run-cli.js +442 -46
  6. package/dist/harness-runtime/bin/dd-agy.mjs +37 -0
  7. package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
  8. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  9. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  10. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  11. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  12. package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
  13. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +423 -0
  14. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  15. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +141 -0
  16. package/dist/harness-runtime/lib/dd-codex.mjs +438 -0
  17. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  18. package/dist/harness-runtime/lib/dd-droid.mjs +470 -0
  19. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +274 -0
  20. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  21. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +198 -0
  22. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  23. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
  24. package/dist/harness-runtime/lib/dd-zcode.mjs +910 -0
  25. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  26. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  27. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  28. package/dist/harness-runtime/lib/managed-daemon.mjs +260 -0
  29. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  30. package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
  31. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  32. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  33. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  34. package/dist/harness-runtime/lib/process-json.mjs +70 -0
  35. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  36. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  37. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  38. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  39. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  40. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  41. package/dist/runtime/context.js +3 -1
  42. package/dist/schemas/agent-profile.schema.json +1 -1
  43. package/dist/schemas/code-review-result.schema.json +2 -2
  44. package/dist/schemas/code-work-batch.schema.json +4 -3
  45. package/dist/schemas/code-work-result.schema.json +4 -4
  46. package/dist/schemas/harness-config.schema.json +23 -0
  47. package/dist/schemas/plan-review-result.schema.json +2 -2
  48. package/dist/schemas/run-control-receipt.schema.json +99 -0
  49. package/dist/schemas/run-control-request.schema.json +36 -0
  50. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  51. package/dist/services/cleanup.js +8 -5
  52. package/dist/services/cli-operation-classifier.js +11 -1
  53. package/dist/services/code-checks.js +543 -96
  54. package/dist/services/controller-fanout.js +120 -0
  55. package/dist/services/dashboard.js +9 -0
  56. package/dist/services/engines.js +50 -19
  57. package/dist/services/eval-snapshots.js +639 -60
  58. package/dist/services/execution-policy.js +177 -0
  59. package/dist/services/external-work-launch.js +110 -0
  60. package/dist/services/harness-adapter.js +147 -24
  61. package/dist/services/harness-config.js +68 -0
  62. package/dist/services/hooks.js +413 -168
  63. package/dist/services/lanes.js +1 -1
  64. package/dist/services/lifecycle-command.js +74 -6
  65. package/dist/services/lifecycle-invocations.js +637 -0
  66. package/dist/services/managed-daemon-binding.js +38 -0
  67. package/dist/services/managed-processes.js +138 -22
  68. package/dist/services/merge-queue.js +6 -6
  69. package/dist/services/merge-server.js +75 -29
  70. package/dist/services/migrations.js +1 -1
  71. package/dist/services/native-daemon-history.js +49 -0
  72. package/dist/services/native-session-control.js +39 -0
  73. package/dist/services/projects.js +7 -3
  74. package/dist/services/prompts.js +4 -2
  75. package/dist/services/protocols.js +11 -2
  76. package/dist/services/recovery-observation-budget.js +61 -0
  77. package/dist/services/recovery-snapshot-database.js +107 -0
  78. package/dist/services/run-control-receipt.js +56 -0
  79. package/dist/services/run-control-worker.js +363 -0
  80. package/dist/services/run-control.js +876 -0
  81. package/dist/services/run-controller-adapter.js +211 -0
  82. package/dist/services/run-controller-capture.js +134 -0
  83. package/dist/services/run-controller-process.js +189 -0
  84. package/dist/services/run-controller-recovery.js +222 -0
  85. package/dist/services/run-controller-state.js +31 -0
  86. package/dist/services/run-controller.js +733 -0
  87. package/dist/services/run-engine-bindings.js +20 -62
  88. package/dist/services/run-fork.js +124 -0
  89. package/dist/services/run-observations.js +112 -0
  90. package/dist/services/run-recovery-runtime.js +69 -0
  91. package/dist/services/run-recovery.js +327 -0
  92. package/dist/services/runs.js +280 -40
  93. package/dist/services/runtime-budget.js +362 -0
  94. package/dist/services/runtime-scope-capture.js +50 -0
  95. package/dist/services/runtime-scope-control.js +450 -0
  96. package/dist/services/runtime-scope-resume.js +543 -0
  97. package/dist/services/runtime-scope-stop.js +99 -0
  98. package/dist/services/runtime-scope-worker.js +210 -0
  99. package/dist/services/runtime-service.js +96 -0
  100. package/dist/services/schema-validation.js +12 -12
  101. package/dist/services/stage-context.js +2 -2
  102. package/dist/services/stage-lifecycle.js +32 -10
  103. package/dist/services/stage-pause.js +25 -13
  104. package/dist/services/usage.js +37 -93
  105. package/dist/services/vnext-code-review.js +149 -89
  106. package/dist/services/vnext-code.js +223 -215
  107. package/dist/services/vnext-execution-profile.js +5 -3
  108. package/dist/services/vnext-fanout.js +135 -21
  109. package/dist/services/vnext-merge.js +258 -89
  110. package/dist/services/vnext-plan-review.js +75 -64
  111. package/dist/services/vnext-plan.js +77 -29
  112. package/dist/services/vnext-protocolize.js +21 -14
  113. package/dist/services/vnext-specify.js +26 -16
  114. package/dist/services/work-registry.js +592 -154
  115. package/dist/services/workspace-bootstrap.js +76 -0
  116. package/dist/shared/errors.js +12 -0
  117. package/dist/storage/database.js +341 -31
  118. package/dist/storage/writer-contract.js +77 -0
  119. package/dist/storage/writer-migration.js +102 -0
  120. package/package.json +20 -13
  121. package/tools/repair-paused-run-status.mjs +59 -0
@@ -8,12 +8,14 @@ import { parseJsonObject } from "../shared/json.js";
8
8
  import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
9
9
  import { appendAudit } from "./audit.js";
10
10
  import { nativeSessionIdentity, storageSessionId } from "./session-identity.js";
11
- import { commandHasOption, commandOption, commandPosition, parseLifecycleCommand, unwrapShellCommand } from "./lifecycle-command.js";
11
+ import { assertRecoveryHookCurrent } from "./run-recovery.js";
12
+ import { observeLifecycleInvocation, settleLifecycleNoEffectRejection } from "./lifecycle-invocations.js";
13
+ import { commandHasOption, commandOption, commandPosition, shellSuffixOffset, parseLifecycleCommand, unwrapShellCommand } from "./lifecycle-command.js";
12
14
  import { registerProject, requireProjectByRoot } from "./projects.js";
13
15
  import { activeFlowSessionsForProject, bindObservedFlowSession, flowSessionPayloadFromRegisterCommand, recordFlowSessionObservation } from "./sessions.js";
14
16
  const defaultProfileName = "default";
15
17
  const maxSanitizedSummaryLength = 1600;
16
- const sharedEntries = [
18
+ export const codexHomeSharedEntries = [
17
19
  "auth.json",
18
20
  "auth.bk",
19
21
  "sessions",
@@ -50,7 +52,7 @@ export function planCodexHome(context, input) {
50
52
  config_path: paths.configPath,
51
53
  hooks_path: paths.hooksPath,
52
54
  owned_files: ["config.toml", "hooks.json", ".dd-flow-home.json", "log/", ".tmp/", "tmp/"],
53
- shared_entries: sharedEntries,
55
+ shared_entries: codexHomeSharedEntries,
54
56
  exists: fs.existsSync(paths.targetHome),
55
57
  codex_env: { CODEX_HOME: paths.targetHome }
56
58
  };
@@ -268,7 +270,13 @@ export function handleCodexHook(context, input) {
268
270
  if (eventName !== "PreToolUse") {
269
271
  return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
270
272
  }
271
- const sessionId = stringValue(payload.session_id);
273
+ const rootProviderSessionId = stringValue(payload.session_id) ?? null;
274
+ // Codex deliberately retains its legacy root session_id inside a
275
+ // thread-spawned subagent. `agent_id` is the current child Thread id and
276
+ // therefore the only valid identity for a fresh Work.
277
+ const rawChildProviderSessionId = stringValue(payload.agent_id) ?? null;
278
+ const providerSessionId = rawChildProviderSessionId ?? rootProviderSessionId;
279
+ const parentProviderSessionId = rawChildProviderSessionId ? codexNativeParentId(payload, rawChildProviderSessionId) : null;
272
280
  const turnId = stringValue(payload.turn_id);
273
281
  const toolName = stringValue(payload.tool_name) ?? toolNameFromPayload(payload);
274
282
  const command = lifecycleCommandFromPayload(payload);
@@ -293,32 +301,57 @@ export function handleCodexHook(context, input) {
293
301
  }
294
302
  };
295
303
  }
296
- const { flowPayload, stageStart, stageResume, workStart, bootstrapStageStart, commandProjectRoot } = lifecycle;
304
+ const { flowPayload, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, bootstrapStageStart, commandProjectRoot } = lifecycle;
297
305
  const matchKey = commandProjectRoot ? lifecycleMatchKey(lifecycle, commandProjectRoot) : null;
298
306
  // A bootstrap stage is the first stateful command for a new materialized project.
299
307
  // Its PreToolUse event arrives before stage start can register that project.
300
308
  if (bootstrapStageStart && commandProjectRoot) {
301
309
  registerProject(context, { root: commandProjectRoot });
302
310
  }
303
- const project = projectForHook(context, input.projectRoot ?? commandProjectRoot, stringValue(payload.cwd));
311
+ let project;
312
+ try {
313
+ project = projectForHook(context, input.projectRoot ?? commandProjectRoot, stringValue(payload.cwd));
314
+ }
315
+ catch (error) {
316
+ // A terminal command outside a registered flow gets no trusted receipt;
317
+ // the CLI will fail closed if it is actually executed. This keeps hooks
318
+ // inert for unrelated shell-parser probes and stale project directories.
319
+ if (stageFinish || stagePause || workFinish || workFail)
320
+ return { ok: true, observed: false, reason: "project_not_registered" };
321
+ throw error;
322
+ }
304
323
  if (!project)
305
324
  return { ok: true, observed: false, reason: "unrelated_cwd" };
306
- const binding = sessionId ? upsertSessionBindingFromPayload(context, project, sessionId, payload) : undefined;
307
- const storageId = sessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", sessionId)) : null;
325
+ // Codex retains the root session_id for a thread-spawned child, while the
326
+ // payload transcript belongs to the current agent_id. Keep lineage separate
327
+ // from ownership: otherwise a child overwrites the root transcript binding.
328
+ const eventKey = hookEventKey(payload, eventName, toolName, command);
329
+ const storageId = providerSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", providerSessionId)) : null;
330
+ const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", parentProviderSessionId)) : null;
331
+ 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: stringValue(payload.cwd) ?? null });
332
+ const bindingOwnerId = providerSessionId ?? rootProviderSessionId;
333
+ const binding = bindingOwnerId ? upsertSessionBindingFromPayload(context, project, bindingOwnerId, payload) : undefined;
308
334
  const observedSession = flowPayload
309
- ? bindObservedFlowSession(context, project, { ...flowPayload, harness: "codex-desktop", provider_session_id: sessionId ?? flowPayload.provider_session_id ?? null }, storageId ?? undefined)
335
+ ? bindObservedFlowSession(context, project, {
336
+ ...flowPayload,
337
+ harness: "codex-desktop",
338
+ provider_session_id: providerSessionId ?? flowPayload.provider_session_id ?? null,
339
+ parent_session_id: parentSessionId
340
+ }, storageId ?? undefined)
310
341
  : undefined;
311
342
  const effectiveSessionId = observedSession?.session_id ?? storageId;
312
343
  const protocolId = observedSession?.protocol_id ?? binding?.protocol_id ?? null;
313
- const eventKey = hookEventKey(payload, eventName, toolName, command);
314
344
  const inserted = recordHookEvent(context, {
315
345
  projectId: project.id,
316
346
  protocolId,
317
347
  harness: "codex-desktop",
318
- providerSessionId: sessionId ?? null,
319
- parentSessionId: null,
348
+ providerSessionId: providerSessionId ?? null,
349
+ parentSessionId,
350
+ // Pinned by the owning app-server process, never derived from current RUN
351
+ // state or an agent-supplied shell command/payload.
352
+ daemonId: stringValue(context.env.DD_FLOW_DAEMON_ID) ?? null,
320
353
  sessionId: effectiveSessionId,
321
- agentId: stringValue(payload.agent_id) ?? null,
354
+ agentId: rawChildProviderSessionId ?? null,
322
355
  turnId: turnId ?? null,
323
356
  eventName,
324
357
  toolName: toolName ?? null,
@@ -345,9 +378,10 @@ export function handleCodexHook(context, input) {
345
378
  observed: inserted,
346
379
  duplicate: !inserted,
347
380
  event_key: eventKey,
348
- session: sessionId ? nativeSessionIdentity("codex-desktop", sessionId) : null,
381
+ session: providerSessionId ? nativeSessionIdentity("codex-desktop", providerSessionId) : null,
382
+ ...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("codex-desktop", parentProviderSessionId) } : {}),
349
383
  protocol_id: protocolId,
350
- ...(sessionId && (flowPayload || stageStart || stageResume || workStart)
384
+ ...(providerSessionId && (flowPayload || stageStart || stageFinish || stagePause || stageResume || workStart || workFinish || workFail || lifecycle.invocation.operation === "recovery_accept")
351
385
  ? {
352
386
  hookSpecificOutput: {
353
387
  hookEventName: "PreToolUse",
@@ -358,117 +392,138 @@ export function handleCodexHook(context, input) {
358
392
  : {})
359
393
  };
360
394
  }
361
- /** Convert a zcode-acp Bash tool notification into the same trusted lifecycle receipt used by Codex hooks. */
395
+ /** The hook protocol does not carry the immediate parent. Recover it only from this child's own native transcript. */
396
+ function codexNativeParentId(payload, childId) {
397
+ const transcript = stringValue(payload.transcript_path);
398
+ if (!transcript || !path.isAbsolute(transcript))
399
+ return null;
400
+ try {
401
+ const firstLine = fs.readFileSync(transcript, "utf8").split("\n", 1)[0];
402
+ if (!firstLine)
403
+ return null;
404
+ const record = JSON.parse(firstLine);
405
+ return record.type === "session_meta" && record.payload?.id === childId && typeof record.payload.parent_thread_id === "string"
406
+ ? record.payload.parent_thread_id
407
+ : null;
408
+ }
409
+ catch {
410
+ return null;
411
+ }
412
+ }
413
+ /** ACP is asynchronous evidence. Only an explicitly issued invocation may use
414
+ * it as the receipt that its CLI waits for; this is not a PreToolUse hook. */
362
415
  export function handleZcodeEvent(context, input) {
363
416
  const notification = parseJsonObject(input.stdin || "{}", "zcode ACP notification");
364
- if (notification.method !== "session/update")
417
+ const params = objectRecord(notification.params), update = objectRecord(params.update);
418
+ if (notification.method !== "session/update" || update.sessionUpdate !== "tool_call")
365
419
  return { ok: true, observed: false, reason: "event_not_participating" };
366
- const params = objectRecord(notification.params);
367
- const update = objectRecord(params.update);
368
- if (update.sessionUpdate !== "tool_call")
369
- return { ok: true, observed: false, reason: "event_not_participating" };
370
- const updateMeta = objectRecord(update._meta);
371
- const claudeCode = objectRecord(updateMeta.claudeCode);
372
- const runtime = objectRecord(updateMeta.zcodeRuntime);
373
- const toolName = stringValue(claudeCode.toolName) ?? stringValue(update.kind);
374
- const rawInput = objectRecord(update.rawInput);
420
+ const rawInput = objectRecord(update.rawInput), meta = objectRecord(update._meta), native = objectRecord(meta.zcodeRuntime);
421
+ const toolName = stringValue(objectRecord(meta.claudeCode).toolName);
422
+ if (toolName !== "Bash" && toolName !== "bash" && toolName !== "execute")
423
+ return { ok: true, observed: false, reason: "non_bash_tool" };
375
424
  const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd);
376
- if (!command || (toolName && toolName !== "Bash" && toolName !== "bash" && update.kind !== "execute")) {
425
+ const parsed = command ? parseLifecycleCommand(command) : { kind: "none" };
426
+ if (parsed.kind === "none" || !commandOption(parsed.invocation, "invocation-id"))
427
+ return { ok: true, observed: false, reason: "acp_evidence_only" };
428
+ const controlled = objectRecord(objectRecord(notification._meta).ddZcode);
429
+ const providerSessionId = stringValue(native.childSessionId) ?? stringValue(controlled.rootProviderSessionId);
430
+ return recordZcodeLifecycle(context, { projectRoot: input.projectRoot, stdin: JSON.stringify({
431
+ session_id: providerSessionId, tool_use_id: update.toolCallId, tool_name: toolName, tool_input: rawInput,
432
+ agent_id: native.agentId, _meta: { ddZcode: controlled }
433
+ }) }, commandOption(parsed.invocation, "invocation-id"));
434
+ }
435
+ /**
436
+ * Accept the synchronous native ZCode PreToolUse callback. Invocation-ID
437
+ * commands instead rendezvous with their ACP event while the CLI awaits
438
+ * admission; the native hook must not create a competing receipt for them.
439
+ */
440
+ export function handleZcodeHook(context, input) {
441
+ return recordZcodeLifecycle(context, input);
442
+ }
443
+ function recordZcodeLifecycle(context, input, invocationId) {
444
+ const hook = parseJsonObject(input.stdin || "{}", "ZCode hook event");
445
+ const eventName = stringValue(hook.hook_event_name) ?? stringValue(hook.hookEventName);
446
+ if (eventName && eventName !== "PreToolUse" && eventName !== "pre_tool_use")
447
+ return { ok: true, observed: false, reason: "event_not_participating" };
448
+ const toolName = stringValue(hook.tool_name) ?? stringValue(hook.toolName);
449
+ const rawInput = objectRecord(hook.tool_input ?? hook.toolInput);
450
+ const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(hook.command);
451
+ if (!command || (toolName && toolName !== "Bash" && toolName !== "bash" && toolName !== "execute"))
377
452
  return { ok: true, observed: false, reason: "non_bash_tool" };
378
- }
379
453
  const lifecycle = lifecycleFacts(command);
380
454
  if (!lifecycle)
381
455
  return { ok: true, observed: false, reason: "event_not_participating" };
382
- if (lifecycle.analysis.kind === "compound") {
383
- throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", 1, {
384
- standalone_command: lifecycle.invocation.command
385
- });
386
- }
387
- const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
456
+ if (!invocationId && commandOption(lifecycle.invocation, "invocation-id"))
457
+ return { ok: true, observed: false, reason: "cli_invocation_owns_admission" };
388
458
  const expectedRoot = resolveProjectRoot(input.projectRoot);
389
- if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
390
- throw new AppError("project_mismatch", "ZCode lifecycle command project root does not match the controlled workspace", 1, {
391
- expected: expectedRoot,
392
- actual: commandProjectRoot
393
- });
459
+ if (lifecycle.commandProjectRoot && resolveProjectRoot(lifecycle.commandProjectRoot) !== expectedRoot) {
460
+ throw new AppError("project_mismatch", "ZCode lifecycle command project root does not match the controlled workspace", 1, { expected: expectedRoot, actual: lifecycle.commandProjectRoot });
394
461
  }
395
- const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
396
- if (bootstrapStageStart)
462
+ const meta = objectRecord(hook._meta);
463
+ const ddZcode = objectRecord(meta.ddZcode);
464
+ const providerSessionId = stringValue(hook.session_id) ?? stringValue(hook.sessionId);
465
+ const rootProviderSessionId = stringValue(ddZcode.rootProviderSessionId);
466
+ const parentProviderSessionId = stringValue(ddZcode.parentProviderSessionId);
467
+ const daemonId = stringValue(ddZcode.daemonId);
468
+ if (!providerSessionId || !rootProviderSessionId || !daemonId) {
469
+ throw new AppError("zcode_identity_missing", "ZCode native hook has no controlled Session identity", 1);
470
+ }
471
+ if (providerSessionId === rootProviderSessionId ? parentProviderSessionId : !parentProviderSessionId) {
472
+ throw new AppError("zcode_identity_missing", "ZCode native hook has inconsistent Session ancestry", 1);
473
+ }
474
+ const toolUseId = stringValue(hook.tool_use_id) ?? stringValue(hook.toolUseId) ?? stringValue(hook.event_id) ?? stringValue(hook.eventId);
475
+ if (!toolUseId)
476
+ throw new AppError("zcode_hook_event_missing", "ZCode native hook has no tool-use ID", 1);
477
+ if (lifecycle.analysis.kind === "compound") {
478
+ if (lifecycle.analysis.reason !== "shell_composition")
479
+ throw new AppError("compound_lifecycle_command", "Ambiguous shell cannot authorize a lifecycle replacement", 1, { reason: lifecycle.analysis.reason });
480
+ if (invocationId) {
481
+ const error = { code: "compound_lifecycle_command", message: "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", exitCode: 1,
482
+ details: { standalone_command: lifecycle.invocation.command } };
483
+ settleLifecycleNoEffectRejection(context, invocationId, lifecycle.invocation.command, error, { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId });
484
+ return { ok: true, observed: false, invocation_id: invocationId, no_effect: true, error };
485
+ }
486
+ throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", 1, { standalone_command: lifecycle.invocation.command });
487
+ }
488
+ const eventKey = crypto.createHash("sha256").update(JSON.stringify(["zcode-acp", daemonId, providerSessionId, toolUseId])).digest("hex");
489
+ const rewritten = invocationId ? command : commandWithHookEvent(command, eventKey);
490
+ if (!invocationId && rewritten === command && commandOption(lifecycle.invocation, "hook-event-id") !== eventKey)
491
+ throw new AppError("zcode_hook_rewrite_unsupported", "ZCode requires a direct lifecycle command whose exact receipt can be delivered", 1);
492
+ if (lifecycle.bootstrapStageStart)
397
493
  registerProject(context, { root: expectedRoot });
398
494
  const project = requireProjectByRoot(context, expectedRoot);
399
- const envelopeMeta = objectRecord(notification._meta);
400
- const ddZcode = objectRecord(envelopeMeta.ddZcode);
401
- const observedProfile = objectRecord(ddZcode.observedProfile);
402
- const rootProviderSessionId = stringValue(ddZcode.rootProviderSessionId) ?? stringValue(params.sessionId);
403
- const daemonId = stringValue(ddZcode.daemonId);
404
- if (!rootProviderSessionId)
405
- throw new AppError("zcode_identity_missing", "ZCode ACP event has no root provider Session ID", 1);
406
- const childProviderSessionId = stringValue(runtime.childSessionId);
407
- const providerSessionId = childProviderSessionId ?? rootProviderSessionId;
408
495
  const sessionId = storageSessionId(nativeSessionIdentity("zcode-acp", providerSessionId));
409
- const parentSessionId = childProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", rootProviderSessionId)) : null;
410
- const agentId = stringValue(runtime.agentId);
411
- const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
412
- ...flowPayload,
413
- harness: "zcode-acp",
414
- provider_session_id: providerSessionId,
415
- agent_id: agentId ?? null,
416
- parent_session_id: parentSessionId
417
- }, sessionId) : undefined;
418
- const eventKey = stringValue(update.toolCallId) ?? crypto.createHash("sha256").update(JSON.stringify({
419
- harness: "zcode-acp", providerSessionId, command
420
- })).digest("hex");
421
- const payload = {
422
- session_id: providerSessionId,
423
- agent_id: agentId,
424
- cwd: expectedRoot,
425
- tool_name: toolName ?? "Bash",
426
- tool_input: rawInput,
427
- command,
428
- provider: stringValue(observedProfile.provider),
429
- model: stringValue(observedProfile.model),
430
- reasoning: stringValue(observedProfile.reasoning),
431
- mode: stringValue(observedProfile.mode),
432
- agent_type: stringValue(runtime.source)
496
+ const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", parentProviderSessionId)) : null;
497
+ const observedProfile = objectRecord(ddZcode.observedProfile);
498
+ const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
499
+ const eventNameForReceipt = invocationId ? "ToolCallObserved" : "PreToolUse";
500
+ const persist = () => {
501
+ assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "zcode-acp", providerSessionId, parentSessionId, daemonId, sessionId, turnId: null, eventName: eventNameForReceipt, toolName: toolName ?? "Bash", matchKey, cwd: expectedRoot });
502
+ const observedSession = lifecycle.flowPayload ? bindObservedFlowSession(context, project, {
503
+ ...lifecycle.flowPayload, harness: "zcode-acp", provider_session_id: providerSessionId,
504
+ parent_session_id: parentSessionId, provider: stringValue(observedProfile.provider) ?? null,
505
+ model: stringValue(observedProfile.model) ?? null, reasoning: stringValue(observedProfile.reasoning) ?? null,
506
+ mode: stringValue(observedProfile.mode) ?? null, agent_id: stringValue(hook.agent_id) ?? stringValue(hook.agentId) ?? null
507
+ }, sessionId) : undefined;
508
+ const payload = { ...hook, session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
509
+ command, provider: stringValue(observedProfile.provider), model: stringValue(observedProfile.model), reasoning: stringValue(observedProfile.reasoning), mode: stringValue(observedProfile.mode) };
510
+ const inserted = recordHookEvent(context, {
511
+ projectId: project.id, protocolId: observedSession?.protocol_id ?? null, harness: "zcode-acp", providerSessionId, parentSessionId,
512
+ daemonId, sessionId, agentId: stringValue(hook.agent_id) ?? stringValue(hook.agentId) ?? null, turnId: null,
513
+ eventName: eventNameForReceipt, toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey,
514
+ transcriptPath: stringValue(hook.transcript_path) ?? null, cwd: expectedRoot
515
+ });
516
+ recordFlowSessionObservation(context, { projectId: project.id, sessionId, runId: observedSession?.run_id ?? null, protocolId: observedSession?.protocol_id ?? null, cwd: expectedRoot, toolName: toolName ?? "Bash", eventKey });
517
+ 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 };
518
+ return { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: rewritten } } };
433
519
  };
434
- const inserted = recordHookEvent(context, {
435
- projectId: project.id,
436
- protocolId: observedSession?.protocol_id ?? null,
437
- harness: "zcode-acp",
438
- providerSessionId,
439
- parentSessionId,
440
- daemonId: daemonId ?? null,
441
- sessionId,
442
- agentId: agentId ?? null,
443
- turnId: null,
444
- eventName: "PreToolUse",
445
- toolName: toolName ?? "Bash",
446
- status: "observed",
447
- payload,
448
- eventKey,
449
- matchKey,
450
- transcriptPath: null,
451
- cwd: expectedRoot
520
+ if (!invocationId)
521
+ return persist();
522
+ const receipt = observeLifecycleInvocation(context, {
523
+ id: invocationId, command, identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId },
524
+ recordReceipt: () => { persist(); return eventKey; }
452
525
  });
453
- recordFlowSessionObservation(context, {
454
- projectId: project.id,
455
- sessionId,
456
- runId: observedSession?.run_id ?? null,
457
- protocolId: observedSession?.protocol_id ?? null,
458
- cwd: expectedRoot,
459
- toolName: toolName ?? "Bash",
460
- eventKey
461
- });
462
- return {
463
- ok: true,
464
- observed: inserted,
465
- duplicate: !inserted,
466
- event_key: eventKey,
467
- harness: "zcode-acp",
468
- session: nativeSessionIdentity("zcode-acp", providerSessionId),
469
- ...(childProviderSessionId ? { parent_session: nativeSessionIdentity("zcode-acp", rootProviderSessionId) } : {}),
470
- daemon_id: daemonId ?? null
471
- };
526
+ return { ok: true, observed: !receipt.duplicate, duplicate: receipt.duplicate, event_key: receipt.eventKey, invocation_id: invocationId };
472
527
  }
473
528
  /** Convert a Grok Build PreToolUse hook into a trusted lifecycle receipt. */
474
529
  export function handleGrokEvent(context, input) {
@@ -491,7 +546,7 @@ export function handleGrokEvent(context, input) {
491
546
  standalone_command: lifecycle.invocation.command
492
547
  });
493
548
  }
494
- const { flowPayload, stageStart, stageResume, workStart, bootstrapStageStart, commandProjectRoot } = lifecycle;
549
+ const { flowPayload, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, bootstrapStageStart, commandProjectRoot } = lifecycle;
495
550
  const expectedRoot = resolveProjectRoot(input.projectRoot);
496
551
  if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
497
552
  throw new AppError("project_mismatch", "Grok Build lifecycle command project root does not match the controlled workspace", 1, {
@@ -514,12 +569,13 @@ export function handleGrokEvent(context, input) {
514
569
  const parentSessionId = isChild ? storageSessionId(nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId)) : null;
515
570
  const daemonId = stringValue(ddGrok.daemonId);
516
571
  const agentId = stringValue(hook.agent_id) ?? stringValue(hook.agentId);
517
- const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
518
- ...flowPayload, harness: "grok-acp", provider_session_id: providerSessionId, agent_id: agentId ?? null, parent_session_id: parentSessionId
519
- }, sessionId) : undefined;
520
572
  const eventKey = stringValue(hook.event_id) ?? stringValue(hook.eventId) ?? crypto.createHash("sha256").update(JSON.stringify({
521
573
  harness: "grok-acp", providerSessionId, command, turn: hook.turn_id ?? hook.turnId ?? null
522
574
  })).digest("hex");
575
+ 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: expectedRoot });
576
+ const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
577
+ ...flowPayload, harness: "grok-acp", provider_session_id: providerSessionId, agent_id: agentId ?? null, parent_session_id: parentSessionId
578
+ }, sessionId) : undefined;
523
579
  const payload = {
524
580
  ...hook, session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
525
581
  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)
@@ -535,20 +591,32 @@ export function handleGrokEvent(context, input) {
535
591
  });
536
592
  const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
537
593
  ...(isChild ? { parent_session: nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId) } : {}), daemon_id: daemonId ?? null };
538
- return (flowPayload || stageStart || stageResume || workStart)
594
+ return (flowPayload || stageStart || stageFinish || stagePause || stageResume || workStart || workFinish || workFail || lifecycle.invocation.operation === "recovery_accept")
539
595
  ? { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...(rawInput ?? {}), command: commandWithHookEvent(command, eventKey) } } }
540
596
  : result;
541
597
  }
542
598
  /** Convert a controlled OpenCode tool hook into the shared lifecycle receipt. */
543
599
  export function handleOpenCodeEvent(context, input) {
544
600
  const event = parseJsonObject(input.stdin || "{}", "OpenCode tool event");
545
- const agy = event.source_harness === "antigravity-cli";
546
- const harness = agy ? "antigravity-cli" : "opencode-server";
547
601
  if (event.schema_id !== "dd-flow/opencode-tool-event@1")
548
602
  throw new AppError("unsupported_opencode_event_schema", "Unsupported OpenCode event schema", 1);
603
+ return handleControlledToolEvent(context, input, event, event.source_harness === "antigravity-cli" ? "antigravity-cli" : "opencode-server");
604
+ }
605
+ /** Accept adapter-observed Factory identity, never a model-supplied Session ID. */
606
+ export function handleDroidEvent(context, input) {
607
+ const event = parseJsonObject(input.stdin || "{}", "Droid tool event");
608
+ if (event.schema_id !== "dd-flow/droid-tool-event@1")
609
+ throw new AppError("unsupported_droid_event_schema", "Unsupported Droid event schema", 1);
610
+ return handleControlledToolEvent(context, input, event, "droid-cli");
611
+ }
612
+ function handleControlledToolEvent(context, input, event, harness) {
613
+ const agy = harness === "antigravity-cli";
614
+ const droid = harness === "droid-cli";
615
+ const label = droid ? "Droid" : agy ? "Antigravity" : "OpenCode";
616
+ const errorPrefix = droid ? "droid" : agy ? "agy" : "opencode";
549
617
  const phase = stringValue(event.phase);
550
618
  if (phase !== "before" && phase !== "after")
551
- throw new AppError("unsupported_opencode_event_schema", "OpenCode event phase must be before or after", 1);
619
+ throw new AppError(`unsupported_${errorPrefix}_event_schema`, `${label} event phase must be before or after`, 1);
552
620
  const session = objectRecord(event.session);
553
621
  const profile = objectRecord(event.profile);
554
622
  const rawInput = objectRecord(event.input);
@@ -556,16 +624,18 @@ export function handleOpenCodeEvent(context, input) {
556
624
  const nativeParentId = stringValue(session.parent_provider_session_id);
557
625
  const daemonId = stringValue(event.daemon_id);
558
626
  const toolCallId = stringValue(event.tool_call_id);
559
- const eventId = stringValue(event.event_id) ?? toolCallId;
627
+ const eventId = stringValue(event.event_id) ?? (droid ? undefined : toolCallId);
560
628
  const toolName = stringValue(event.tool);
561
629
  const directory = stringValue(session.directory);
562
- if (!providerSessionId || !daemonId || !toolCallId || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
563
- throw new AppError("opencode_session_identity_invalid", "OpenCode event is missing trusted physical identity", 1);
630
+ if (!providerSessionId || !daemonId || (!droid && !toolCallId) || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
631
+ throw new AppError(`${errorPrefix}_session_identity_invalid`, `${label} event is missing trusted physical identity`, 1);
564
632
  }
565
633
  const expectedRoot = resolveProjectRoot(input.projectRoot);
566
634
  if (resolveProjectRoot(directory) !== expectedRoot) {
567
- 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 });
635
+ throw new AppError(`${errorPrefix}_directory_mismatch`, `${label} Session directory does not match the controlled workspace`, 1, { expected: expectedRoot, actual: directory });
568
636
  }
637
+ if (droid && nativeParentId === providerSessionId)
638
+ throw new AppError("droid_session_identity_invalid", "A Droid Session cannot parent itself", 1);
569
639
  const sessionId = storageSessionId(nativeSessionIdentity(harness, providerSessionId));
570
640
  const parentSessionId = nativeParentId ? storageSessionId(nativeSessionIdentity(harness, nativeParentId)) : null;
571
641
  const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
@@ -576,43 +646,50 @@ export function handleOpenCodeEvent(context, input) {
576
646
  projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
577
647
  daemonId, sessionId, agentId: stringValue(session.agent_id) ?? null, turnId: stringValue(event.message_id) ?? null,
578
648
  eventName: "PostToolUse", toolName, status: "observed", payload: { session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName, status: objectRecord(event.outcome).status },
579
- eventKey: baseKey, matchKey: null, transcriptPath: null, cwd: expectedRoot
649
+ eventKey: baseKey, matchKey: null, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: expectedRoot
580
650
  });
581
651
  return { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}) };
582
652
  }
583
- if (!command || !["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"].includes(toolName)) {
653
+ if (!command || !(droid ? ["Execute"] : ["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"]).includes(toolName)) {
584
654
  return { ok: true, observed: false, reason: command ? "non_bash_tool" : "event_not_participating" };
585
655
  }
586
656
  const lifecycle = lifecycleFacts(command);
587
657
  if (!lifecycle)
588
658
  return { ok: true, observed: false, reason: "event_not_participating" };
589
659
  if (lifecycle.analysis.kind === "compound") {
590
- 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 });
660
+ throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${label} shell tool call`, 1, { standalone_command: lifecycle.invocation.command });
591
661
  }
592
662
  const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
593
663
  if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
594
- throw new AppError("project_mismatch", "OpenCode lifecycle command project root does not match the controlled workspace", 1, { expected: expectedRoot, actual: commandProjectRoot });
664
+ throw new AppError("project_mismatch", `${label} lifecycle command project root does not match the controlled workspace`, 1, { expected: expectedRoot, actual: commandProjectRoot });
595
665
  }
596
666
  const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
597
667
  if (bootstrapStageStart)
598
668
  registerProject(context, { root: expectedRoot });
599
669
  const project = requireProjectByRoot(context, expectedRoot);
670
+ if (droid) {
671
+ 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]);
672
+ 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)) {
673
+ throw new AppError("droid_event_replay_mismatch", "Droid event ID was already observed with different lifecycle identity", 1);
674
+ }
675
+ }
600
676
  const agentId = stringValue(session.agent_id);
677
+ assertHookEventReplay(context, { projectId: project.id, eventKey: baseKey, harness, providerSessionId, parentSessionId, daemonId, sessionId, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName, matchKey, cwd: expectedRoot });
601
678
  const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
602
679
  ...flowPayload, harness, provider_session_id: providerSessionId, agent_id: agentId ?? null,
603
680
  parent_session_id: parentSessionId, provider: stringValue(profile.provider) ?? null, model: stringValue(profile.model) ?? null,
604
- reasoning: stringValue(profile.variant) ?? null, mode: stringValue(profile.agent) ?? null, agent_type: stringValue(profile.agent) ?? null,
605
- 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 }
681
+ reasoning: stringValue(droid ? profile.reasoning : profile.variant) ?? null, mode: stringValue(droid ? profile.mode : profile.agent) ?? null, agent_type: stringValue(droid ? profile.agent_type : profile.agent) ?? null,
682
+ metadata: droid ? { droid_version: stringValue(event.runtime_version) ?? null } : { 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 }
606
683
  }, sessionId) : undefined;
607
684
  const payload = {
608
685
  session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName, tool_input: rawInput, command,
609
- provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(profile.variant),
610
- mode: stringValue(profile.agent), agent_type: stringValue(profile.agent)
686
+ provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(droid ? profile.reasoning : profile.variant),
687
+ mode: stringValue(droid ? profile.mode : profile.agent), agent_type: stringValue(droid ? profile.agent_type : profile.agent)
611
688
  };
612
689
  const inserted = recordHookEvent(context, {
613
690
  projectId: project.id, protocolId: observedSession?.protocol_id ?? null, harness, providerSessionId, parentSessionId,
614
691
  daemonId, sessionId, agentId: agentId ?? null, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName,
615
- status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: null, cwd: expectedRoot
692
+ status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: expectedRoot
616
693
  });
617
694
  recordFlowSessionObservation(context, { projectId: project.id, sessionId, runId: observedSession?.run_id ?? null, protocolId: observedSession?.protocol_id ?? null, cwd: expectedRoot, toolName, eventKey: baseKey });
618
695
  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 };
@@ -667,11 +744,58 @@ function lifecycleCommandFromPayload(payload) {
667
744
  const command = commandFromPayload(payload);
668
745
  return command ? unwrapShellCommand(command) : undefined;
669
746
  }
670
- function commandWithHookEvent(command, eventKey) {
747
+ /**
748
+ * Adds the receipt to the command that the shell will execute. Heredoc input
749
+ * belongs to that command too, but an option appended after its delimiter is a
750
+ * second shell command rather than an argument, so insert it before `<<`.
751
+ */
752
+ export function commandWithHookEvent(command, eventKey) {
671
753
  const analysis = parseLifecycleCommand(command);
672
- if (analysis.kind !== "standalone" || !analysis.invocation.rewriteSafe || commandHasOption(analysis.invocation, "hook-event-id"))
754
+ if (analysis.kind !== "standalone")
755
+ return command;
756
+ if (commandHasOption(analysis.invocation, "hook-event-id")) {
757
+ if (commandOption(analysis.invocation, "hook-event-id") !== eventKey)
758
+ throw new AppError("hook_event_mismatch", "The command specifies a receipt from another tool invocation", 1);
673
759
  return command;
674
- return `${command.trim()} --hook-event-id ${JSON.stringify(eventKey)}`;
760
+ }
761
+ const option = `--hook-event-id '${eventKey.replaceAll("'", "'\\''")}'`;
762
+ if (analysis.invocation.rewriteSafe) {
763
+ const index = shellSuffixOffset(command);
764
+ return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`.trimEnd();
765
+ }
766
+ if (!analysis.invocation.wrapped && analysis.invocation.heredoc && allowsStdinLifecycleCompound(lifecycleFacts(command))) {
767
+ const index = firstHeredoc(command);
768
+ if (index >= 0)
769
+ return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`;
770
+ }
771
+ return command;
772
+ }
773
+ function firstHeredoc(command) {
774
+ let quoteState = null;
775
+ let escaped = false;
776
+ for (let index = 0; index < command.length - 1; index += 1) {
777
+ const char = command[index];
778
+ if (escaped) {
779
+ escaped = false;
780
+ continue;
781
+ }
782
+ if (char === "\\" && quoteState !== "'") {
783
+ escaped = true;
784
+ continue;
785
+ }
786
+ if (quoteState) {
787
+ if (char === quoteState)
788
+ quoteState = null;
789
+ continue;
790
+ }
791
+ if (char === "'" || char === '"') {
792
+ quoteState = char;
793
+ continue;
794
+ }
795
+ if (char === "<" && command[index + 1] === "<" && command[index + 2] !== "<")
796
+ return index;
797
+ }
798
+ return -1;
675
799
  }
676
800
  /**
677
801
  * Recognise the small shell surface used by generated dd-flow lifecycle
@@ -692,25 +816,46 @@ function lifecycleFacts(command) {
692
816
  return null;
693
817
  const invocation = analysis.invocation;
694
818
  const stageStart = invocation.operation === "stage_start";
819
+ const stageFinish = invocation.operation === "stage_finish";
820
+ const stagePause = invocation.operation === "stage_pause";
695
821
  const stageResume = invocation.operation === "stage_resume";
696
822
  const workStart = invocation.operation === "work_start";
823
+ const workFinish = invocation.operation === "work_finish";
824
+ const workFail = invocation.operation === "work_fail";
697
825
  return {
698
826
  analysis,
699
827
  invocation,
700
828
  flowPayload: flowSessionPayloadFromRegisterCommand(invocation),
701
829
  stageStart,
830
+ stageFinish,
831
+ stagePause,
702
832
  stageResume,
703
- workStart,
833
+ workStart, workFinish, workFail,
704
834
  bootstrapStageStart: stageStart && commandHasOption(invocation, "bootstrap"),
705
835
  commandProjectRoot: commandOption(invocation, "project-root"),
706
- stageName: stageStart || stageResume ? commandOption(invocation, "stage") : undefined,
707
- runId: stageStart || stageResume ? commandPosition(invocation, 0) : undefined,
708
- workId: stageResume ? commandOption(invocation, "work") : workStart ? commandPosition(invocation, 0) : undefined,
836
+ stageName: stageStart || stageFinish || stagePause || stageResume ? commandOption(invocation, "stage") : undefined,
837
+ runId: stageStart || stageFinish || stagePause || stageResume ? commandPosition(invocation, 0) : undefined,
838
+ workId: stagePause || stageResume ? commandOption(invocation, "work") : workStart || workFinish || workFail ? commandPosition(invocation, 0) : undefined,
709
839
  contextSha256: commandOption(invocation, "context-sha256")
710
840
  };
711
841
  }
842
+ // Worker completion and the two HITL commands intentionally take their
843
+ // payload from stdin. Their generated pipe/heredoc is one lifecycle command,
844
+ // not a second authority. Everything else remains standalone-only.
845
+ function allowsStdinLifecycleCompound(facts) {
846
+ const operation = facts.invocation.operation;
847
+ return facts.analysis.kind === "standalone" && facts.invocation.heredoc && !facts.invocation.wrapped
848
+ && ((operation === "work_finish" && commandHasOption(facts.invocation, "result-stdin"))
849
+ || (operation === "stage_start" && commandHasOption(facts.invocation, "intake-stdin"))
850
+ || (operation === "stage_pause" && commandHasOption(facts.invocation, "question-stdin"))
851
+ || (operation === "stage_resume" && commandHasOption(facts.invocation, "answer-stdin")));
852
+ }
712
853
  function lifecycleMatchKey(facts, projectRoot) {
713
- const { invocation, bootstrapStageStart, stageStart, stageResume, workStart, stageName, runId, workId, contextSha256 } = facts;
854
+ const { invocation, bootstrapStageStart, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, stageName, runId, workId, contextSha256 } = facts;
855
+ if (invocation.operation === "recovery_accept") {
856
+ const run = commandPosition(invocation, 0), recovery = commandOption(invocation, "recovery-id");
857
+ return run && recovery ? recoveryAcceptMatchKey(run, recovery, projectRoot) : null;
858
+ }
714
859
  if (bootstrapStageStart)
715
860
  return bootstrapMatchKey({
716
861
  projectRoot,
@@ -720,12 +865,86 @@ function lifecycleMatchKey(facts, projectRoot) {
720
865
  });
721
866
  if (stageStart && runId && stageName)
722
867
  return stageStartMatchKey(runId, stageName, projectRoot, contextSha256);
868
+ if (stageFinish && runId && stageName)
869
+ return stageLifecycleMatchKey("stage_finish", runId, stageName, projectRoot);
870
+ if (stagePause && runId && stageName && workId)
871
+ return stageLifecycleMatchKey("stage_pause", runId, stageName, projectRoot, workId);
723
872
  if (stageResume && runId && stageName && workId)
724
873
  return stageResumeMatchKey(runId, stageName, workId, projectRoot);
725
874
  if (workStart && workId)
726
- return workStartMatchKey(workId, projectRoot);
875
+ return workStartMatchKey(workId, projectRoot, commandOption(invocation, "recovery-id"));
876
+ if (workFinish && workId)
877
+ return workLifecycleMatchKey("work_finish", workId, projectRoot);
878
+ if (workFail && workId)
879
+ return workLifecycleMatchKey("work_fail", workId, projectRoot);
727
880
  return null;
728
881
  }
882
+ /** Observe exact immutable command attribution before reading model-supplied
883
+ * result files. This does not claim or authorize the lifecycle mutation. */
884
+ export function observeCommandHook(context, command) {
885
+ const facts = lifecycleFacts(command);
886
+ if (!facts || facts.analysis.kind !== "standalone" || !facts.commandProjectRoot)
887
+ return;
888
+ const project = context.db.get("SELECT id FROM projects WHERE root = ?", [resolveProjectRoot(facts.commandProjectRoot)]);
889
+ if (!project)
890
+ return; // Bootstrap has not registered a project yet.
891
+ const matchKey = lifecycleMatchKey(facts, facts.commandProjectRoot);
892
+ if (!matchKey)
893
+ return;
894
+ let key = commandOption(facts.invocation, "hook-event-id");
895
+ if (!key) {
896
+ try {
897
+ key = findRecentMatchingHookEvent(context, { projectId: project.id, matchKey, errorCode: "trusted_session_binding_required", operation: facts.invocation.operation }).eventKey;
898
+ }
899
+ catch {
900
+ return;
901
+ } // Dispatch still owns its mandatory admission error.
902
+ }
903
+ 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]);
904
+ if (event)
905
+ context.observeLifecycleHook?.(project.id, event.id);
906
+ }
907
+ export function stageLifecycleMatchKey(operation, runId, stage, projectRoot, workId) {
908
+ return crypto.createHash("sha256").update(JSON.stringify({ operation, run_id: runId, stage, project_root: resolveProjectRoot(projectRoot), ...(workId ? { work_id: workId } : {}) })).digest("hex");
909
+ }
910
+ export function recoveryAcceptMatchKey(runId, recoveryId, projectRoot) {
911
+ return crypto.createHash("sha256").update(JSON.stringify({ operation: "recovery_accept", run_id: runId, recovery_id: recoveryId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
912
+ }
913
+ export function workLifecycleMatchKey(operation, workId, projectRoot) {
914
+ return crypto.createHash("sha256").update(JSON.stringify({ operation, work_id: workId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
915
+ }
916
+ /** Claims a Work terminal command only after its exact hook receipt has proved
917
+ * the physical Session that issued it. Work code performs the owner check. */
918
+ export function claimWorkLifecycleHookEvent(context, input) {
919
+ const matchKey = workLifecycleMatchKey(input.operation, input.workId, input.projectRoot);
920
+ const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
921
+ projectId: input.projectId, matchKey, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
922
+ }).eventKey;
923
+ const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
924
+ if (!event?.session_id || event.status !== "observed" || event.match_key !== matchKey) {
925
+ 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 });
926
+ }
927
+ const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
928
+ if (claimed.changes !== 1)
929
+ throw new AppError("trusted_session_binding_required", "matching Work lifecycle hook event was already claimed", 1, { work_id: input.workId });
930
+ return hookSessionIdentity(context, input.projectId, eventKey);
931
+ }
932
+ /** Claims a lifecycle command only after its exact hook receipt has proved the
933
+ * physical Session that issued it. Stage code performs the Work-owner check. */
934
+ export function claimStageLifecycleHookEvent(context, input) {
935
+ const matchKey = stageLifecycleMatchKey(input.operation, input.runId, input.stage, input.projectRoot, input.workId);
936
+ const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
937
+ projectId: input.projectId, matchKey, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
938
+ }).eventKey;
939
+ const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
940
+ if (!event?.session_id || event.status !== "observed" || event.match_key !== matchKey) {
941
+ 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 });
942
+ }
943
+ const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
944
+ if (claimed.changes !== 1)
945
+ throw new AppError("trusted_session_binding_required", "matching lifecycle hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
946
+ return hookSessionIdentity(context, input.projectId, eventKey);
947
+ }
729
948
  /** Trusted session identity is created by PreToolUse, never supplied by an agent. */
730
949
  export function sessionIdForHookEvent(context, projectId, eventKey) {
731
950
  const event = context.db.get("SELECT session_id FROM hook_events WHERE project_id = ? AND event_key = ?", [projectId, eventKey]);
@@ -741,6 +960,7 @@ export function hookSessionIdentity(context, projectId, eventKey) {
741
960
  WHERE he.project_id = ? AND he.event_key = ?`, [projectId, eventKey]);
742
961
  if (!event?.session_id)
743
962
  throw new AppError("hook_event_not_found", "stage start requires a trusted PreToolUse hook event", 1, { event_key: eventKey });
963
+ context.observeLifecycleHook?.(projectId, event.id);
744
964
  return {
745
965
  hookEventId: event.id,
746
966
  harness: event.harness,
@@ -777,21 +997,26 @@ export function claimRecentMatchingHookEvent(context, input) {
777
997
  /** Finds the trusted event that a stage- or Work-specific claimant will atomically claim. */
778
998
  export function findRecentMatchingHookEvent(context, input) {
779
999
  const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
780
- const find = () => context.db.get(`SELECT id, event_key FROM hook_events
1000
+ const find = () => context.db.all(`SELECT id, event_key FROM hook_events
781
1001
  WHERE project_id = ? AND match_key = ? AND status = 'observed' AND created_at >= ?
782
- ORDER BY id DESC LIMIT 1`, [input.projectId, input.matchKey, freshAfter]);
783
- let event = find();
784
- // Some harnesses publish nested-agent tool events concurrently with command
785
- // execution. Give the trusted event a short bounded window to reach SQLite.
1002
+ AND harness IN ('codex-desktop', 'antigravity-cli')
1003
+ AND (? IS NULL OR session_id = ?)
1004
+ ORDER BY id DESC LIMIT 2`, [input.projectId, input.matchKey, freshAfter, input.sessionId ?? null, input.sessionId ?? null]);
1005
+ let events = find();
1006
+ // Only the qualified non-rewriting transports may correlate the original
1007
+ // command: Codex Desktop and Antigravity. New harnesses fail closed by default.
786
1008
  const deadline = Date.now() + 250;
787
- while (!event?.event_key && Date.now() < deadline) {
1009
+ while (events.length === 0 && Date.now() < deadline) {
788
1010
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
789
- event = find();
1011
+ events = find();
1012
+ }
1013
+ if (events.length === 0 || !events[0]?.event_key) {
1014
+ throw new AppError(input.errorCode, `${input.operation} requires a trusted lifecycle receipt. Check native hook delivery and updatedInput; managed ZCode requires its issued --invocation-id.`, 1);
790
1015
  }
791
- if (!event?.event_key) {
792
- 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);
1016
+ if (events.length > 1) {
1017
+ throw new AppError("ambiguous_lifecycle_receipt", `${input.operation} has more than one fresh matching lifecycle event; provide its exact hook event ID`, 1, { match_key: input.matchKey, event_keys: events.map((event) => event.event_key) });
793
1018
  }
794
- return { id: event.id, eventKey: event.event_key };
1019
+ return { id: events[0].id, eventKey: events[0].event_key };
795
1020
  }
796
1021
  /** Fallback for Desktop paths that execute the original command after a hook rewrite. */
797
1022
  export function claimBootstrapHookEvent(context, input) {
@@ -814,7 +1039,7 @@ export function bootstrapMatchKey(input) {
814
1039
  })).digest("hex");
815
1040
  }
816
1041
  export function claimWorkStartHookEvent(context, input) {
817
- const expected = new Set([workStartMatchKey(input.workId, input.projectRoot), workStartMatchKey(shortWorkReference(input.workId), input.projectRoot)]);
1042
+ const expected = new Set([workStartMatchKey(input.workId, input.projectRoot, input.recoveryId), workStartMatchKey(shortWorkReference(input.workId), input.projectRoot, input.recoveryId)]);
818
1043
  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]);
819
1044
  if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.has(event.match_key)) {
820
1045
  throw new AppError("trusted_work_launch_required", "work start requires one fresh matching PreToolUse event", 1, { work_id: input.workId, event_key: input.eventKey });
@@ -832,7 +1057,9 @@ export function assertStageStartHookEvent(context, input) {
832
1057
  if (!event?.session_id || event.status !== "observed" || event.match_key !== expected) {
833
1058
  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 });
834
1059
  }
835
- return hookSessionIdentity(context, input.projectId, input.eventKey);
1060
+ const identity = hookSessionIdentity(context, input.projectId, input.eventKey);
1061
+ assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
1062
+ return identity;
836
1063
  }
837
1064
  export function claimStageStartHookEvent(context, input) {
838
1065
  assertStageStartHookEvent(context, input);
@@ -863,23 +1090,24 @@ export function stageResumeMatchKey(runId, stage, workId, projectRoot) {
863
1090
  /** Claims the resume command observed in the Session that received the user answer. */
864
1091
  export function claimStageResumeHookEvent(context, input) {
865
1092
  const matchKey = stageResumeMatchKey(input.runId, input.stage, input.workId, input.projectRoot);
866
- const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
867
- const event = input.eventKey
868
- ? 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])
869
- : 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]);
1093
+ const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, { projectId: input.projectId, matchKey, sessionId: input.sessionId ?? "", errorCode: "trusted_stage_resume_required", operation: "stage resume" }).eventKey;
1094
+ 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]);
870
1095
  if (!event?.event_key || !event.session_id || event.match_key !== matchKey || event.status !== "observed") {
871
1096
  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 });
872
1097
  }
1098
+ const identity = hookSessionIdentity(context, input.projectId, event.event_key);
1099
+ assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
873
1100
  const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
874
1101
  if (claimed.changes !== 1)
875
1102
  throw new AppError("trusted_stage_resume_required", "matching stage-resume hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
876
- return hookSessionIdentity(context, input.projectId, event.event_key);
1103
+ return identity;
877
1104
  }
878
- export function workStartMatchKey(workId, projectRoot) {
1105
+ export function workStartMatchKey(workId, projectRoot, recoveryId) {
879
1106
  return crypto.createHash("sha256").update(JSON.stringify({
880
1107
  operation: "work_start",
881
1108
  work_id: workId,
882
- project_root: resolveProjectRoot(projectRoot)
1109
+ project_root: resolveProjectRoot(projectRoot),
1110
+ ...(recoveryId ? { recovery_id: recoveryId } : {})
883
1111
  })).digest("hex");
884
1112
  }
885
1113
  export function hookStatusForProject(context, projectId) {
@@ -969,7 +1197,7 @@ function objectTable(value) {
969
1197
  function createSharedLinks(sourceHome, targetHome) {
970
1198
  const linked = [];
971
1199
  const skipped = [];
972
- for (const entry of sharedEntries) {
1200
+ for (const entry of codexHomeSharedEntries) {
973
1201
  const source = path.join(sourceHome, entry);
974
1202
  const target = path.join(targetHome, entry);
975
1203
  if (!fs.existsSync(source)) {
@@ -992,7 +1220,7 @@ function createSharedLinks(sourceHome, targetHome) {
992
1220
  return { linked, skipped };
993
1221
  }
994
1222
  function removeSharedSymlinks(targetHome) {
995
- for (const entry of sharedEntries) {
1223
+ for (const entry of codexHomeSharedEntries) {
996
1224
  const target = path.join(targetHome, entry);
997
1225
  if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink()) {
998
1226
  fs.rmSync(target);
@@ -1005,7 +1233,7 @@ function removeDirIfEmpty(dir) {
1005
1233
  }
1006
1234
  }
1007
1235
  function sharedEntryStatus(sourceHome, targetHome) {
1008
- return sharedEntries.map((entry) => {
1236
+ return codexHomeSharedEntries.map((entry) => {
1009
1237
  const source = path.join(sourceHome, entry);
1010
1238
  const target = path.join(targetHome, entry);
1011
1239
  return {
@@ -1029,7 +1257,8 @@ function commandHook(command, statusMessage) {
1029
1257
  return { type: "command", command, timeout: 5, statusMessage };
1030
1258
  }
1031
1259
  function hookCommand(event) {
1032
- const handler = `PATH="\${HOME}/Library/pnpm:/opt/homebrew/bin:/usr/local/bin:\${PATH}" dd-flow codex hook handle --event ${event} --json`;
1260
+ const args = `codex hook handle --event ${event} --json`;
1261
+ 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`;
1033
1262
  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"'`;
1034
1263
  }
1035
1264
  function resolveHookTarget(target) {
@@ -1263,12 +1492,11 @@ function sessionBinding(context, projectId, sessionId) {
1263
1492
  return context.db.get("SELECT * FROM codex_session_bindings WHERE project_id = ? AND session_id = ?", [projectId, sessionId]);
1264
1493
  }
1265
1494
  function recordHookEvent(context, input) {
1266
- const existing = context.db.get("SELECT id FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
1267
- if (existing)
1495
+ if (assertHookEventReplay(context, input))
1268
1496
  return false;
1269
1497
  context.db.run(`INSERT INTO hook_events
1270
- (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)
1271
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1498
+ (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)
1499
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1272
1500
  input.projectId,
1273
1501
  input.protocolId,
1274
1502
  input.harness,
@@ -1295,6 +1523,23 @@ function recordHookEvent(context, input) {
1295
1523
  ]);
1296
1524
  return true;
1297
1525
  }
1526
+ /** A duplicate delivery is harmless only when all durable identity facts match. */
1527
+ function assertHookEventReplay(context, input) {
1528
+ 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
1529
+ FROM hook_events WHERE project_id = ? AND event_key = ?`, [input.projectId, input.eventKey]);
1530
+ if (existing) {
1531
+ const observed = {
1532
+ harness: input.harness, provider_session_id: input.providerSessionId, parent_session_id: input.parentSessionId,
1533
+ daemon_id: input.daemonId ?? null, session_id: input.sessionId, turn_id: input.turnId,
1534
+ event_name: input.eventName, tool_name: input.toolName, match_key: input.matchKey, cwd: input.cwd
1535
+ };
1536
+ if (Object.entries(observed).some(([key, value]) => existing[key] !== value)) {
1537
+ throw new AppError("hook_event_replay_mismatch", "Lifecycle event ID was already observed with different immutable identity", 1, { event_key: input.eventKey });
1538
+ }
1539
+ return true;
1540
+ }
1541
+ return false;
1542
+ }
1298
1543
  function projectForHook(context, explicitRoot, cwd) {
1299
1544
  if (explicitRoot)
1300
1545
  return requireProjectByRoot(context, resolveProjectRoot(explicitRoot));