@deksden-com/dd-flow-cli 0.7.0 → 0.8.0

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 (69) hide show
  1. package/CHANGELOG.md +666 -0
  2. package/README.md +7 -2
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +88 -10
  5. package/dist/cli/run-cli.js +523 -28
  6. package/dist/domain/stage-catalog.js +22 -0
  7. package/dist/domain/validation.js +1 -1
  8. package/dist/schemas/code-review-decision.schema.json +26 -0
  9. package/dist/schemas/code-review-result.schema.json +14 -0
  10. package/dist/schemas/code-verification.schema.json +14 -0
  11. package/dist/schemas/code-work-batch.schema.json +24 -0
  12. package/dist/schemas/code-work-result.schema.json +16 -0
  13. package/dist/schemas/compatibility.schema.json +32 -0
  14. package/dist/schemas/flow-contract.schema.json +9 -5
  15. package/dist/schemas/flow-run.schema.json +16 -123
  16. package/dist/schemas/plan-aspect-map.schema.json +22 -0
  17. package/dist/schemas/plan-review-decision.schema.json +14 -0
  18. package/dist/schemas/plan-review-result.schema.json +42 -0
  19. package/dist/schemas/protocol-plan.schema.json +15 -182
  20. package/dist/schemas/stage-finish-input.schema.json +16 -2
  21. package/dist/schemas/stage-report.schema.json +8 -7
  22. package/dist/schemas/stage-start-response.schema.json +4 -2
  23. package/dist/schemas/status-report.schema.json +76 -0
  24. package/dist/schemas/vnext-protocol-plan.schema.json +37 -0
  25. package/dist/schemas/vnext-protocolize-result.schema.json +29 -0
  26. package/dist/schemas/vnext-specify.schema.json +45 -0
  27. package/dist/services/branch-context.js +1 -1
  28. package/dist/services/cleanup.js +8 -8
  29. package/dist/services/cli-operation-classifier.js +10 -2
  30. package/dist/services/code-checks.js +244 -0
  31. package/dist/services/config.js +7 -1
  32. package/dist/services/dashboard.js +12 -12
  33. package/dist/services/engines.js +1 -1
  34. package/dist/services/eval-snapshots.js +404 -0
  35. package/dist/services/hooks.js +774 -18
  36. package/dist/services/ids.js +16 -6
  37. package/dist/services/lanes.js +1 -1
  38. package/dist/services/merge-queue.js +5 -5
  39. package/dist/services/merge-worker.js +2 -2
  40. package/dist/services/migrations.js +2 -2
  41. package/dist/services/plan-runtime.js +1 -1
  42. package/dist/services/projects.js +4 -4
  43. package/dist/services/prompts.js +17 -11
  44. package/dist/services/protocols.js +8 -8
  45. package/dist/services/run-projection.js +49 -13
  46. package/dist/services/runs.js +504 -51
  47. package/dist/services/schema-validation.js +21 -3
  48. package/dist/services/sessions.js +51 -12
  49. package/dist/services/stage-blocker.js +57 -0
  50. package/dist/services/stage-context.js +90 -0
  51. package/dist/services/stage-lifecycle.js +198 -75
  52. package/dist/services/stage-pause.js +175 -0
  53. package/dist/services/stage-report-renderer.js +65 -0
  54. package/dist/services/usage.js +526 -18
  55. package/dist/services/vnext-code-review.js +305 -0
  56. package/dist/services/vnext-code.js +686 -0
  57. package/dist/services/vnext-contracts.js +1 -0
  58. package/dist/services/vnext-execution-profile.js +27 -0
  59. package/dist/services/vnext-fanout.js +79 -0
  60. package/dist/services/vnext-plan-review.js +499 -0
  61. package/dist/services/vnext-plan.js +552 -0
  62. package/dist/services/vnext-protocolize.js +542 -0
  63. package/dist/services/vnext-specify.js +595 -0
  64. package/dist/services/vnext-workspace-policy.js +87 -0
  65. package/dist/services/work-registry.js +522 -0
  66. package/dist/services/worktrees.js +58 -37
  67. package/dist/storage/database.js +263 -34
  68. package/dist/storage/paths.js +47 -1
  69. package/package.json +12 -12
@@ -7,7 +7,7 @@ import { AppError } from "../shared/errors.js";
7
7
  import { parseJsonObject } from "../shared/json.js";
8
8
  import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
9
9
  import { appendAudit } from "./audit.js";
10
- import { requireProjectByRoot } from "./projects.js";
10
+ import { registerProject, requireProjectByRoot } from "./projects.js";
11
11
  import { activeFlowSessionsForProject, bindObservedFlowSession, flowSessionPayloadFromRegisterCommand, recordFlowSessionObservation } from "./sessions.js";
12
12
  const defaultProfileName = "default";
13
13
  const maxSanitizedSummaryLength = 1600;
@@ -269,14 +269,56 @@ export function handleCodexHook(context, input) {
269
269
  const sessionId = stringValue(payload.session_id);
270
270
  const turnId = stringValue(payload.turn_id);
271
271
  const toolName = stringValue(payload.tool_name) ?? toolNameFromPayload(payload);
272
- const command = commandFromPayload(payload);
272
+ const command = lifecycleCommandFromPayload(payload);
273
273
  if (!command)
274
274
  return { ok: true, observed: false, reason: "non_bash_tool" };
275
+ const stageResume = /\bdd-flow\s+stage\s+resume\b/.test(command);
276
+ const stagePauseHeredoc = containsUnquotedHeredoc(command) && /\bdd-flow\s+stage\s+pause\b/.test(command);
277
+ const lifecycleShell = analyzeLifecycleShellCommand(command);
278
+ // Resume legitimately receives an answer through stdin. It is matched by
279
+ // immutable lifecycle arguments below; never reject or rewrite that pipe.
280
+ if (lifecycleShell.kind === "compound" && !stageResume && !stagePauseHeredoc) {
281
+ return {
282
+ ok: false,
283
+ observed: false,
284
+ reason: "compound_lifecycle_command",
285
+ message: "Run the dd-flow lifecycle invocation as the first technical action and as one standalone Bash command in this same Session.",
286
+ standalone_command: lifecycleShell.standaloneCommand,
287
+ hookSpecificOutput: {
288
+ hookEventName: "PreToolUse",
289
+ permissionDecision: "deny",
290
+ permissionDecisionReason: `dd-flow lifecycle commands must be standalone. Retry in this same Session: ${lifecycleShell.standaloneCommand}`
291
+ }
292
+ };
293
+ }
275
294
  const flowPayload = flowSessionPayloadFromRegisterCommand(command);
276
- const bootstrapStageStart = /\bdd-flow\s+stage\s+start\b/.test(command) && /(?:^|\s)--bootstrap(?:\s|$)/.test(command);
277
- if (!flowPayload && !bootstrapStageStart)
295
+ const stageStart = /\bdd-flow\s+stage\s+start\b/.test(command);
296
+ const workStart = /\bdd-flow\s+work\s+start\b/.test(command);
297
+ const bootstrapStageStart = stageStart && /(?:^|\s)--bootstrap(?:\s|$)/.test(command);
298
+ if (!flowPayload && !stageStart && !stageResume && !workStart)
278
299
  return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
279
- const project = projectForHook(context, input.projectRoot, stringValue(payload.cwd));
300
+ const commandProjectRoot = optionFromCommand(command, "project-root");
301
+ const stageName = stageStart ? optionFromCommand(command, "stage") : undefined;
302
+ const resumeStageName = stageResume ? optionFromCommand(command, "stage") : undefined;
303
+ const stageRunId = stageStart && !bootstrapStageStart ? positionalFromCommand(command, "dd-flow stage start") : undefined;
304
+ const resumeRunId = stageResume ? positionalFromCommand(command, "dd-flow stage resume") : undefined;
305
+ const resumeWorkId = stageResume ? optionFromCommand(command, "work") : undefined;
306
+ const workId = workStart ? positionalFromCommand(command, "dd-flow work start") : undefined;
307
+ const contextSha256 = optionFromCommand(command, "context-sha256") ?? undefined;
308
+ const matchKey = bootstrapStageStart && commandProjectRoot ? bootstrapMatchKey({
309
+ projectRoot: commandProjectRoot,
310
+ subject: optionFromCommand(command, "subject") ?? optionFromCommand(command, "slug") ?? "specify",
311
+ intakeMode: contextSha256 ? "context" : /(?:^|\s)--intake-stdin(?:\s|$)/.test(command) ? "stdin" : "file",
312
+ ...(contextSha256 ? { contextSha256 } : {})
313
+ }) : stageStart && stageRunId && stageName && commandProjectRoot ? stageStartMatchKey(stageRunId, stageName, commandProjectRoot, contextSha256)
314
+ : stageResume && resumeRunId && resumeStageName && resumeWorkId && commandProjectRoot ? stageResumeMatchKey(resumeRunId, resumeStageName, resumeWorkId, commandProjectRoot)
315
+ : workStart && workId && commandProjectRoot ? workStartMatchKey(workId, commandProjectRoot) : null;
316
+ // A bootstrap stage is the first stateful command for a new materialized project.
317
+ // Its PreToolUse event arrives before stage start can register that project.
318
+ if (bootstrapStageStart && commandProjectRoot) {
319
+ registerProject(context, { root: commandProjectRoot });
320
+ }
321
+ const project = projectForHook(context, input.projectRoot ?? commandProjectRoot, stringValue(payload.cwd));
280
322
  if (!project)
281
323
  return { ok: true, observed: false, reason: "unrelated_cwd" };
282
324
  const binding = sessionId ? upsertSessionBindingFromPayload(context, project, sessionId, payload) : undefined;
@@ -289,13 +331,20 @@ export function handleCodexHook(context, input) {
289
331
  const inserted = recordHookEvent(context, {
290
332
  projectId: project.id,
291
333
  protocolId,
334
+ harness: "codex-desktop",
335
+ providerSessionId: sessionId ?? null,
336
+ parentSessionId: null,
292
337
  sessionId: effectiveSessionId,
338
+ agentId: stringValue(payload.agent_id) ?? null,
293
339
  turnId: turnId ?? null,
294
340
  eventName,
295
341
  toolName: toolName ?? null,
296
342
  status: "observed",
297
343
  payload,
298
- eventKey
344
+ eventKey,
345
+ matchKey,
346
+ transcriptPath: binding?.transcript_path ?? null,
347
+ cwd: stringValue(payload.cwd) ?? null
299
348
  });
300
349
  if (effectiveSessionId) {
301
350
  recordFlowSessionObservation(context, {
@@ -315,21 +364,649 @@ export function handleCodexHook(context, input) {
315
364
  event_key: eventKey,
316
365
  session_id: effectiveSessionId,
317
366
  protocol_id: protocolId,
318
- ...(sessionId
367
+ ...(sessionId && (flowPayload || stageStart || stageResume || workStart)
319
368
  ? {
320
369
  hookSpecificOutput: {
321
370
  hookEventName: "PreToolUse",
322
371
  permissionDecision: "allow",
323
- updatedInput: { command: commandWithSessionId(command, sessionId) }
372
+ updatedInput: { command: commandWithHookEvent(command, eventKey) }
324
373
  }
325
374
  }
326
375
  : {})
327
376
  };
328
377
  }
329
- function commandWithSessionId(command, sessionId) {
330
- if (/(?:^|\s)--session-id(?:=|\s)/.test(command))
378
+ /** Convert a zcode-acp Bash tool notification into the same trusted lifecycle receipt used by Codex hooks. */
379
+ export function handleZcodeEvent(context, input) {
380
+ const notification = parseJsonObject(input.stdin || "{}", "zcode ACP notification");
381
+ if (notification.method !== "session/update")
382
+ return { ok: true, observed: false, reason: "event_not_participating" };
383
+ const params = objectRecord(notification.params);
384
+ const update = objectRecord(params.update);
385
+ if (update.sessionUpdate !== "tool_call")
386
+ return { ok: true, observed: false, reason: "event_not_participating" };
387
+ const updateMeta = objectRecord(update._meta);
388
+ const claudeCode = objectRecord(updateMeta.claudeCode);
389
+ const runtime = objectRecord(updateMeta.zcodeRuntime);
390
+ const toolName = stringValue(claudeCode.toolName) ?? stringValue(update.kind);
391
+ const rawInput = objectRecord(update.rawInput);
392
+ const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd);
393
+ if (!command || (toolName && toolName !== "Bash" && toolName !== "bash" && update.kind !== "execute")) {
394
+ return { ok: true, observed: false, reason: "non_bash_tool" };
395
+ }
396
+ const stageResume = /\bdd-flow\s+stage\s+resume\b/.test(command);
397
+ const stagePauseHeredoc = containsUnquotedHeredoc(command) && /\bdd-flow\s+stage\s+pause\b/.test(command);
398
+ const lifecycleShell = analyzeLifecycleShellCommand(command, true);
399
+ if (lifecycleShell.kind === "compound" && !stageResume && !stagePauseHeredoc) {
400
+ throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", 1, {
401
+ standalone_command: lifecycleShell.standaloneCommand
402
+ });
403
+ }
404
+ const flowPayload = flowSessionPayloadFromRegisterCommand(command);
405
+ const stageStart = /\bdd-flow\s+stage\s+start\b/.test(command);
406
+ const workStart = /\bdd-flow\s+work\s+start\b/.test(command);
407
+ const bootstrapStageStart = stageStart && /(?:^|\s)--bootstrap(?:\s|$)/.test(command);
408
+ if (!flowPayload && !stageStart && !stageResume && !workStart) {
409
+ return { ok: true, observed: false, reason: "event_not_participating" };
410
+ }
411
+ const commandProjectRoot = optionFromCommand(command, "project-root");
412
+ const expectedRoot = resolveProjectRoot(input.projectRoot);
413
+ if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
414
+ throw new AppError("project_mismatch", "ZCode lifecycle command project root does not match the controlled workspace", 1, {
415
+ expected: expectedRoot,
416
+ actual: commandProjectRoot
417
+ });
418
+ }
419
+ const stageName = stageStart ? optionFromCommand(command, "stage") : undefined;
420
+ const resumeStageName = stageResume ? optionFromCommand(command, "stage") : undefined;
421
+ const stageRunId = stageStart && !bootstrapStageStart ? positionalFromCommand(command, "dd-flow stage start") : undefined;
422
+ const resumeRunId = stageResume ? positionalFromCommand(command, "dd-flow stage resume") : undefined;
423
+ const resumeWorkId = stageResume ? optionFromCommand(command, "work") : undefined;
424
+ const workId = workStart ? positionalFromCommand(command, "dd-flow work start") : undefined;
425
+ const contextSha256 = optionFromCommand(command, "context-sha256") ?? undefined;
426
+ const matchKey = bootstrapStageStart ? bootstrapMatchKey({
427
+ projectRoot: expectedRoot,
428
+ subject: optionFromCommand(command, "subject") ?? optionFromCommand(command, "slug") ?? "specify",
429
+ intakeMode: contextSha256 ? "context" : /(?:^|\s)--intake-stdin(?:\s|$)/.test(command) ? "stdin" : "file",
430
+ ...(contextSha256 ? { contextSha256 } : {})
431
+ }) : stageStart && stageRunId && stageName ? stageStartMatchKey(stageRunId, stageName, expectedRoot, contextSha256)
432
+ : stageResume && resumeRunId && resumeStageName && resumeWorkId ? stageResumeMatchKey(resumeRunId, resumeStageName, resumeWorkId, expectedRoot)
433
+ : workStart && workId ? workStartMatchKey(workId, expectedRoot) : null;
434
+ if (bootstrapStageStart)
435
+ registerProject(context, { root: expectedRoot });
436
+ const project = requireProjectByRoot(context, expectedRoot);
437
+ const envelopeMeta = objectRecord(notification._meta);
438
+ const ddZcode = objectRecord(envelopeMeta.ddZcode);
439
+ const observedProfile = objectRecord(ddZcode.observedProfile);
440
+ const rootProviderSessionId = stringValue(ddZcode.rootProviderSessionId) ?? stringValue(params.sessionId);
441
+ const daemonId = stringValue(ddZcode.daemonId);
442
+ if (!rootProviderSessionId)
443
+ throw new AppError("zcode_identity_missing", "ZCode ACP event has no root provider Session ID", 1);
444
+ const childProviderSessionId = stringValue(runtime.childSessionId);
445
+ const providerSessionId = childProviderSessionId ?? rootProviderSessionId;
446
+ const sessionId = `zcode-acp:${providerSessionId}`;
447
+ const parentSessionId = childProviderSessionId ? `zcode-acp:${rootProviderSessionId}` : null;
448
+ const agentId = stringValue(runtime.agentId);
449
+ const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
450
+ ...flowPayload,
451
+ harness: "zcode-acp",
452
+ provider_session_id: providerSessionId,
453
+ agent_id: agentId ?? null,
454
+ parent_session_id: parentSessionId
455
+ }, sessionId) : undefined;
456
+ const eventKey = stringValue(update.toolCallId) ?? crypto.createHash("sha256").update(JSON.stringify({
457
+ harness: "zcode-acp", providerSessionId, command
458
+ })).digest("hex");
459
+ const payload = {
460
+ session_id: providerSessionId,
461
+ agent_id: agentId,
462
+ cwd: expectedRoot,
463
+ tool_name: toolName ?? "Bash",
464
+ tool_input: rawInput,
465
+ command,
466
+ provider: stringValue(observedProfile.provider),
467
+ model: stringValue(observedProfile.model),
468
+ reasoning: stringValue(observedProfile.reasoning),
469
+ mode: stringValue(observedProfile.mode),
470
+ agent_type: stringValue(runtime.source)
471
+ };
472
+ const inserted = recordHookEvent(context, {
473
+ projectId: project.id,
474
+ protocolId: observedSession?.protocol_id ?? null,
475
+ harness: "zcode-acp",
476
+ providerSessionId,
477
+ parentSessionId,
478
+ daemonId: daemonId ?? null,
479
+ sessionId,
480
+ agentId: agentId ?? null,
481
+ turnId: null,
482
+ eventName: "PreToolUse",
483
+ toolName: toolName ?? "Bash",
484
+ status: "observed",
485
+ payload,
486
+ eventKey,
487
+ matchKey,
488
+ transcriptPath: null,
489
+ cwd: expectedRoot
490
+ });
491
+ recordFlowSessionObservation(context, {
492
+ projectId: project.id,
493
+ sessionId,
494
+ runId: observedSession?.run_id ?? null,
495
+ protocolId: observedSession?.protocol_id ?? null,
496
+ cwd: expectedRoot,
497
+ toolName: toolName ?? "Bash",
498
+ eventKey
499
+ });
500
+ return {
501
+ ok: true,
502
+ observed: inserted,
503
+ duplicate: !inserted,
504
+ event_key: eventKey,
505
+ harness: "zcode-acp",
506
+ session_id: sessionId,
507
+ provider_session_id: providerSessionId,
508
+ parent_session_id: parentSessionId,
509
+ daemon_id: daemonId ?? null
510
+ };
511
+ }
512
+ /** Convert a Grok Build PreToolUse hook into a trusted lifecycle receipt. */
513
+ export function handleGrokEvent(context, input) {
514
+ const hook = parseJsonObject(input.stdin || "{}", "Grok Build hook event");
515
+ const eventName = stringValue(hook.hook_event_name) ?? stringValue(hook.hookEventName);
516
+ if (eventName && eventName !== "PreToolUse" && eventName !== "pre_tool_use") {
517
+ return { ok: true, observed: false, reason: "event_not_participating" };
518
+ }
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 && !["Bash", "bash", "run_terminal_command", "RunTerminalCommand"].includes(toolName))) {
523
+ return { ok: true, observed: false, reason: "non_bash_tool" };
524
+ }
525
+ const stageResume = /\bdd-flow\s+stage\s+resume\b/.test(command);
526
+ const stagePauseHeredoc = containsUnquotedHeredoc(command) && /\bdd-flow\s+stage\s+pause\b/.test(command);
527
+ const lifecycleShell = analyzeLifecycleShellCommand(command, true);
528
+ if (lifecycleShell.kind === "compound" && !stageResume && !stagePauseHeredoc) {
529
+ throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone Grok Build tool call", 1, {
530
+ standalone_command: lifecycleShell.standaloneCommand
531
+ });
532
+ }
533
+ const flowPayload = flowSessionPayloadFromRegisterCommand(command);
534
+ const stageStart = /\bdd-flow\s+stage\s+start\b/.test(command);
535
+ const workStart = /\bdd-flow\s+work\s+start\b/.test(command);
536
+ const bootstrapStageStart = stageStart && /(?:^|\s)--bootstrap(?:\s|$)/.test(command);
537
+ if (!flowPayload && !stageStart && !stageResume && !workStart)
538
+ return { ok: true, observed: false, reason: "event_not_participating" };
539
+ const commandProjectRoot = optionFromCommand(command, "project-root");
540
+ const expectedRoot = resolveProjectRoot(input.projectRoot);
541
+ if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
542
+ throw new AppError("project_mismatch", "Grok Build lifecycle command project root does not match the controlled workspace", 1, {
543
+ expected: expectedRoot, actual: commandProjectRoot
544
+ });
545
+ }
546
+ const stageName = stageStart ? optionFromCommand(command, "stage") : undefined;
547
+ const resumeStageName = stageResume ? optionFromCommand(command, "stage") : undefined;
548
+ const stageRunId = stageStart && !bootstrapStageStart ? positionalFromCommand(command, "dd-flow stage start") : undefined;
549
+ const resumeRunId = stageResume ? positionalFromCommand(command, "dd-flow stage resume") : undefined;
550
+ const resumeWorkId = stageResume ? optionFromCommand(command, "work") : undefined;
551
+ const workId = workStart ? positionalFromCommand(command, "dd-flow work start") : undefined;
552
+ const contextSha256 = optionFromCommand(command, "context-sha256") ?? undefined;
553
+ const matchKey = bootstrapStageStart ? bootstrapMatchKey({
554
+ projectRoot: expectedRoot,
555
+ subject: optionFromCommand(command, "subject") ?? optionFromCommand(command, "slug") ?? "specify",
556
+ intakeMode: contextSha256 ? "context" : /(?:^|\s)--intake-stdin(?:\s|$)/.test(command) ? "stdin" : "file",
557
+ ...(contextSha256 ? { contextSha256 } : {})
558
+ }) : stageStart && stageRunId && stageName ? stageStartMatchKey(stageRunId, stageName, expectedRoot, contextSha256)
559
+ : stageResume && resumeRunId && resumeStageName && resumeWorkId ? stageResumeMatchKey(resumeRunId, resumeStageName, resumeWorkId, expectedRoot)
560
+ : workStart && workId ? workStartMatchKey(workId, expectedRoot) : null;
561
+ if (bootstrapStageStart)
562
+ registerProject(context, { root: expectedRoot });
563
+ const project = requireProjectByRoot(context, expectedRoot);
564
+ const meta = objectRecord(hook._meta);
565
+ const ddGrok = objectRecord(meta.ddGrok);
566
+ const observedProfile = objectRecord(ddGrok.observedProfile);
567
+ const rootProviderSessionId = stringValue(ddGrok.rootProviderSessionId);
568
+ const providerSessionId = stringValue(hook.session_id) ?? stringValue(hook.sessionId);
569
+ if (!rootProviderSessionId || !providerSessionId)
570
+ throw new AppError("grok_identity_missing", "Grok Build hook has no trusted Session ID", 1);
571
+ const isChild = providerSessionId !== rootProviderSessionId;
572
+ const sessionId = `grok-acp:${providerSessionId}`;
573
+ const parentSessionId = isChild ? `grok-acp:${stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId}` : null;
574
+ const daemonId = stringValue(ddGrok.daemonId);
575
+ const agentId = stringValue(hook.agent_id) ?? stringValue(hook.agentId);
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;
579
+ const eventKey = stringValue(hook.event_id) ?? stringValue(hook.eventId) ?? crypto.createHash("sha256").update(JSON.stringify({
580
+ harness: "grok-acp", providerSessionId, command, turn: hook.turn_id ?? hook.turnId ?? null
581
+ })).digest("hex");
582
+ const payload = {
583
+ ...hook, session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
584
+ 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)
585
+ };
586
+ const inserted = recordHookEvent(context, {
587
+ projectId: project.id, protocolId: observedSession?.protocol_id ?? null, harness: "grok-acp", providerSessionId, parentSessionId,
588
+ daemonId: daemonId ?? null, sessionId, agentId: agentId ?? null, turnId: stringValue(hook.turn_id) ?? stringValue(hook.turnId) ?? null,
589
+ eventName: "PreToolUse", toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey, transcriptPath: null, cwd: expectedRoot
590
+ });
591
+ recordFlowSessionObservation(context, {
592
+ projectId: project.id, sessionId, runId: observedSession?.run_id ?? null, protocolId: observedSession?.protocol_id ?? null,
593
+ cwd: expectedRoot, toolName: toolName ?? "Bash", eventKey
594
+ });
595
+ const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, harness: "grok-acp", session_id: sessionId,
596
+ provider_session_id: providerSessionId, parent_session_id: parentSessionId, daemon_id: daemonId ?? null };
597
+ return (flowPayload || stageStart || stageResume || workStart)
598
+ ? { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...(rawInput ?? {}), command: commandWithHookEvent(command, eventKey) } } }
599
+ : result;
600
+ }
601
+ /** Convert a controlled OpenCode tool hook into the shared lifecycle receipt. */
602
+ export function handleOpenCodeEvent(context, input) {
603
+ const event = parseJsonObject(input.stdin || "{}", "OpenCode tool event");
604
+ const agy = event.source_harness === "antigravity-cli";
605
+ const harness = agy ? "antigravity-cli" : "opencode-server";
606
+ if (event.schema_id !== "dd-flow/opencode-tool-event@1")
607
+ throw new AppError("unsupported_opencode_event_schema", "Unsupported OpenCode event schema", 1);
608
+ const phase = stringValue(event.phase);
609
+ if (phase !== "before" && phase !== "after")
610
+ throw new AppError("unsupported_opencode_event_schema", "OpenCode event phase must be before or after", 1);
611
+ const session = objectRecord(event.session);
612
+ const profile = objectRecord(event.profile);
613
+ const rawInput = objectRecord(event.input);
614
+ const providerSessionId = stringValue(session.provider_session_id);
615
+ const nativeParentId = stringValue(session.parent_provider_session_id);
616
+ const daemonId = stringValue(event.daemon_id);
617
+ const toolCallId = stringValue(event.tool_call_id);
618
+ const eventId = stringValue(event.event_id) ?? toolCallId;
619
+ const toolName = stringValue(event.tool);
620
+ const directory = stringValue(session.directory);
621
+ if (!providerSessionId || !daemonId || !toolCallId || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
622
+ throw new AppError("opencode_session_identity_invalid", "OpenCode event is missing trusted physical identity", 1);
623
+ }
624
+ const expectedRoot = resolveProjectRoot(input.projectRoot);
625
+ if (resolveProjectRoot(directory) !== expectedRoot) {
626
+ 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 });
627
+ }
628
+ const sessionId = `${harness}:${providerSessionId}`;
629
+ const parentSessionId = nativeParentId ? `${harness}:${nativeParentId}` : null;
630
+ const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
631
+ const baseKey = `${eventId}:${phase}`;
632
+ if (phase === "after") {
633
+ const project = requireProjectByRoot(context, expectedRoot);
634
+ const inserted = recordHookEvent(context, {
635
+ projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
636
+ daemonId, sessionId, agentId: stringValue(session.agent_id) ?? null, turnId: stringValue(event.message_id) ?? null,
637
+ eventName: "PostToolUse", toolName, status: "observed", payload: { session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName, status: objectRecord(event.outcome).status },
638
+ eventKey: baseKey, matchKey: null, transcriptPath: null, cwd: expectedRoot
639
+ });
640
+ return { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session_id: sessionId };
641
+ }
642
+ if (!command || !["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"].includes(toolName)) {
643
+ return { ok: true, observed: false, reason: command ? "non_bash_tool" : "event_not_participating" };
644
+ }
645
+ const stageResume = /\bdd-flow\s+stage\s+resume\b/.test(command);
646
+ const lifecycleShell = analyzeLifecycleShellCommand(command, true);
647
+ const agyBootstrapHeredoc = agy && containsUnquotedHeredoc(command) && /\bdd-flow\s+stage\s+start\b/.test(command) && /(?:^|\s)--bootstrap(?:\s|$)/.test(command);
648
+ const stagePauseHeredoc = containsUnquotedHeredoc(command) && /\bdd-flow\s+stage\s+pause\b/.test(command);
649
+ if (lifecycleShell.kind === "compound" && !stageResume && !agyBootstrapHeredoc && !stagePauseHeredoc) {
650
+ throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${agy ? "Antigravity" : "OpenCode"} shell tool call`, 1, { standalone_command: lifecycleShell.standaloneCommand });
651
+ }
652
+ const flowPayload = flowSessionPayloadFromRegisterCommand(command);
653
+ const stageStart = /\bdd-flow\s+stage\s+start\b/.test(command);
654
+ const workStart = /\bdd-flow\s+work\s+start\b/.test(command);
655
+ const bootstrapStageStart = stageStart && /(?:^|\s)--bootstrap(?:\s|$)/.test(command);
656
+ if (!flowPayload && !stageStart && !stageResume && !workStart)
657
+ return { ok: true, observed: false, reason: "event_not_participating" };
658
+ const commandProjectRoot = optionFromCommand(command, "project-root");
659
+ if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
660
+ throw new AppError("project_mismatch", "OpenCode lifecycle command project root does not match the controlled workspace", 1, { expected: expectedRoot, actual: commandProjectRoot });
661
+ }
662
+ const stageName = stageStart ? optionFromCommand(command, "stage") : undefined;
663
+ const resumeStageName = stageResume ? optionFromCommand(command, "stage") : undefined;
664
+ const stageRunId = stageStart && !bootstrapStageStart ? positionalFromCommand(command, "dd-flow stage start") : undefined;
665
+ const resumeRunId = stageResume ? positionalFromCommand(command, "dd-flow stage resume") : undefined;
666
+ const resumeWorkId = stageResume ? optionFromCommand(command, "work") : undefined;
667
+ const workId = workStart ? positionalFromCommand(command, "dd-flow work start") : undefined;
668
+ const contextSha256 = optionFromCommand(command, "context-sha256") ?? undefined;
669
+ const matchKey = bootstrapStageStart ? bootstrapMatchKey({
670
+ projectRoot: expectedRoot,
671
+ subject: optionFromCommand(command, "subject") ?? optionFromCommand(command, "slug") ?? "specify",
672
+ intakeMode: contextSha256 ? "context" : /(?:^|\s)--intake-stdin(?:\s|$)/.test(command) ? "stdin" : "file",
673
+ ...(contextSha256 ? { contextSha256 } : {})
674
+ }) : stageStart && stageRunId && stageName ? stageStartMatchKey(stageRunId, stageName, expectedRoot, contextSha256)
675
+ : stageResume && resumeRunId && resumeStageName && resumeWorkId ? stageResumeMatchKey(resumeRunId, resumeStageName, resumeWorkId, expectedRoot)
676
+ : workStart && workId ? workStartMatchKey(workId, expectedRoot) : null;
677
+ if (bootstrapStageStart)
678
+ registerProject(context, { root: expectedRoot });
679
+ const project = requireProjectByRoot(context, expectedRoot);
680
+ const agentId = stringValue(session.agent_id);
681
+ const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
682
+ ...flowPayload, harness, provider_session_id: providerSessionId, agent_id: agentId ?? null,
683
+ parent_session_id: parentSessionId, provider: stringValue(profile.provider) ?? null, model: stringValue(profile.model) ?? null,
684
+ reasoning: stringValue(profile.variant) ?? null, mode: stringValue(profile.agent) ?? null, agent_type: stringValue(profile.agent) ?? null,
685
+ 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 }
686
+ }, sessionId) : undefined;
687
+ const payload = {
688
+ session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName, tool_input: rawInput, command,
689
+ provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(profile.variant),
690
+ mode: stringValue(profile.agent), agent_type: stringValue(profile.agent)
691
+ };
692
+ const inserted = recordHookEvent(context, {
693
+ projectId: project.id, protocolId: observedSession?.protocol_id ?? null, harness, providerSessionId, parentSessionId,
694
+ daemonId, sessionId, agentId: agentId ?? null, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName,
695
+ status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: null, cwd: expectedRoot
696
+ });
697
+ recordFlowSessionObservation(context, { projectId: project.id, sessionId, runId: observedSession?.run_id ?? null, protocolId: observedSession?.protocol_id ?? null, cwd: expectedRoot, toolName, eventKey: baseKey });
698
+ const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session_id: sessionId, provider_session_id: providerSessionId, parent_session_id: parentSessionId, daemon_id: daemonId };
699
+ return agy ? result : { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, baseKey) } } };
700
+ }
701
+ /** Convert an Antigravity CLI tool hook into the shared lifecycle receipt. */
702
+ export function handleAgyEvent(context, input) {
703
+ const event = parseJsonObject(input.stdin || "{}", "Antigravity tool event");
704
+ if (event.schema_id !== "dd-flow/agy-tool-event@1")
705
+ throw new AppError("unsupported_agy_event_schema", "Unsupported Antigravity event schema", 1);
706
+ const workspacePaths = Array.isArray(event.workspace_paths) ? event.workspace_paths : [];
707
+ const directory = workspacePaths.find((value) => typeof value === "string" && path.isAbsolute(value));
708
+ const providerSessionId = stringValue(event.conversation_id);
709
+ const daemonId = stringValue(event.daemon_id);
710
+ const eventId = stringValue(event.event_id);
711
+ const toolName = stringValue(event.tool);
712
+ if (!directory || !providerSessionId || !daemonId || !eventId || !toolName)
713
+ throw new AppError("agy_session_identity_invalid", "Antigravity event is missing trusted physical identity", 1);
714
+ const translated = {
715
+ schema_id: "dd-flow/opencode-tool-event@1", source_harness: "antigravity-cli", phase: event.phase, event_id: eventId,
716
+ daemon_id: daemonId, tool_call_id: eventId, tool: toolName, input: event.input,
717
+ message_id: event.step_index === undefined ? null : String(event.step_index),
718
+ session: { provider_session_id: providerSessionId, parent_provider_session_id: event.parent_conversation_id, directory },
719
+ profile: { provider: objectRecord(event.profile).provider, model: objectRecord(event.profile).model, variant: objectRecord(event.profile).reasoning, agent: objectRecord(event.profile).mode },
720
+ outcome: { status: stringValue(event.error) ? "error" : "completed" }
721
+ };
722
+ return handleOpenCodeEvent(context, { projectRoot: input.projectRoot, stdin: JSON.stringify(translated) });
723
+ }
724
+ /**
725
+ * A Desktop hook inherits the app environment, not an inline `VAR=value`
726
+ * assignment made by the pending Bash tool call. Find the explicit
727
+ * DD_FLOW_HOME assignment for the dd-flow invocation, including a safe stdin
728
+ * pipeline such as `cat <<EOF | CODEX_HOME=... DD_FLOW_HOME=... dd-flow`.
729
+ * Other shell syntax is deliberately not evaluated here.
730
+ */
731
+ export function isolatedDdFlowHomeFromHookStdin(stdin) {
732
+ try {
733
+ const payload = parseJsonObject(stdin || "{}", "codex hook stdin");
734
+ const command = lifecycleCommandFromPayload(payload);
735
+ const direct = command?.match(/(?:^|[|;\r\n]\s*)(?:env\s+)?(?:(?!DD_FLOW_HOME=)[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|"[^"]*"|[^\s|;]+)\s+)*DD_FLOW_HOME=(['"]?)([^\s'";|]+)\1\s+dd-flow\b/);
736
+ // An exported home applies to the following pipeline command; this is the
737
+ // other form emitted by generated commands that send stdin through a pipe.
738
+ const exported = command?.match(/(?:^|[;\r\n]\s*)export\s+DD_FLOW_HOME=(['"]?)([^\s'";|]+)\1(?=\s*(?:[;\r\n]|$))/);
739
+ const home = direct?.[2] ?? exported?.[2];
740
+ return home && path.isAbsolute(home) ? path.resolve(home) : null;
741
+ }
742
+ catch {
743
+ return null;
744
+ }
745
+ }
746
+ /**
747
+ * Desktop can wrap a single Bash tool invocation in `bash -c "…"`. Keep the
748
+ * hook contract intentionally narrow: unwrap only that whole-command form,
749
+ * then apply the same lifecycle parser to its actual shell payload.
750
+ */
751
+ function lifecycleCommandFromPayload(payload) {
752
+ const command = commandFromPayload(payload);
753
+ if (!command)
754
+ return undefined;
755
+ const wrapped = command.trim().match(/^(?:\S*\/)?bash\s+-c\s+"((?:\\.|[^"\\])*)"\s*$/);
756
+ return wrapped ? wrapped[1].replace(/\\([\\"])/g, "$1") : command;
757
+ }
758
+ function commandWithHookEvent(command, eventKey) {
759
+ if (/(?:^|\s)--hook-event-id(?:=|\s)/.test(command))
760
+ return command;
761
+ // Heredoc payloads are opaque bytes from the user. Appending a flag to the
762
+ // whole Bash command changes those bytes and corrupts `stage resume` input.
763
+ if (containsUnquotedHeredoc(command))
331
764
  return command;
332
- return `${command} --session-id ${JSON.stringify(sessionId)}`;
765
+ if (/^\s*dd-flow\s+session\s+register\b/.test(command) && !/[;|&\n\r]|\$\(/.test(command))
766
+ return `${command.trim()} --hook-event-id ${JSON.stringify(eventKey)}`;
767
+ const lifecycle = analyzeLifecycleShellCommand(command, true);
768
+ if (lifecycle.kind !== "standalone")
769
+ return command;
770
+ return `${lifecycle.standaloneCommand} --hook-event-id ${JSON.stringify(eventKey)}`;
771
+ }
772
+ function containsUnquotedHeredoc(command) {
773
+ let quote = null;
774
+ let escaped = false;
775
+ for (let index = 0; index < command.length - 1; index += 1) {
776
+ const char = command[index];
777
+ if (escaped) {
778
+ escaped = false;
779
+ continue;
780
+ }
781
+ if (char === "\\" && quote !== "'") {
782
+ escaped = true;
783
+ continue;
784
+ }
785
+ if (quote) {
786
+ if (char === quote)
787
+ quote = null;
788
+ continue;
789
+ }
790
+ if (char === "'" || char === '"') {
791
+ quote = char;
792
+ continue;
793
+ }
794
+ if (char === "<" && command[index + 1] === "<")
795
+ return true;
796
+ }
797
+ return false;
798
+ }
799
+ /**
800
+ * Recognise the small shell surface used by generated dd-flow lifecycle
801
+ * commands. This intentionally does not evaluate Bash: ambiguous composition
802
+ * is rejected so a hook event can never bind the wrong command.
803
+ */
804
+ export function analyzeLifecycleShellCommand(command, includeSessionRegister = false) {
805
+ const segments = [];
806
+ let start = 0;
807
+ let quote = null;
808
+ let escaped = false;
809
+ let compound = false;
810
+ for (let index = 0; index < command.length; index += 1) {
811
+ const char = command[index];
812
+ if (escaped) {
813
+ escaped = false;
814
+ continue;
815
+ }
816
+ if (char === "\\" && quote !== "'") {
817
+ escaped = true;
818
+ continue;
819
+ }
820
+ if (quote) {
821
+ if (char === quote)
822
+ quote = null;
823
+ continue;
824
+ }
825
+ if (char === "'" || char === '"') {
826
+ quote = char;
827
+ continue;
828
+ }
829
+ const pair = command.slice(index, index + 2);
830
+ if (pair === "$(" || pair === "&&" || pair === "||") {
831
+ compound = true;
832
+ segments.push(command.slice(start, index).trim());
833
+ index += 1;
834
+ start = index + 1;
835
+ continue;
836
+ }
837
+ if (char === ";" || char === "|" || char === "&" || char === "\n" || char === "\r" || char === "(") {
838
+ compound = true;
839
+ segments.push(command.slice(start, index).trim());
840
+ start = index + 1;
841
+ }
842
+ }
843
+ segments.push(command.slice(start).trim());
844
+ // `stage resume` intentionally accepts the user's complete answer on stdin.
845
+ // We recognise it for observability but never rewrite a compound invocation.
846
+ const lifecyclePattern = includeSessionRegister
847
+ ? /\bdd-flow\s+(?:session\s+register|stage\s+(?:start|resume)|work\s+start)\b/
848
+ : /\bdd-flow\s+(?:stage\s+(?:start|resume)|work\s+start)\b/;
849
+ const lifecycle = segments.filter((segment) => lifecyclePattern.test(segment));
850
+ if (!lifecycle.length)
851
+ return { kind: "none" };
852
+ const standaloneCommand = lifecycle[0];
853
+ return compound || lifecycle.length !== 1
854
+ ? { kind: "compound", standaloneCommand }
855
+ : { kind: "standalone", standaloneCommand };
856
+ }
857
+ /** Trusted session identity is created by PreToolUse, never supplied by an agent. */
858
+ export function sessionIdForHookEvent(context, projectId, eventKey) {
859
+ const event = context.db.get("SELECT session_id FROM hook_events WHERE project_id = ? AND event_key = ?", [projectId, eventKey]);
860
+ if (!event?.session_id) {
861
+ throw new AppError("hook_event_not_found", "stage start requires a trusted PreToolUse hook event", 1, { event_key: eventKey });
862
+ }
863
+ return event.session_id;
864
+ }
865
+ /** Reads immutable identity facts already captured by PreToolUse. */
866
+ export function hookSessionIdentity(context, projectId, eventKey) {
867
+ const event = context.db.get(`SELECT he.id, he.harness, he.provider_session_id, he.parent_session_id, he.daemon_id, he.session_id, he.agent_id, he.turn_id, he.transcript_path, he.provider, he.model, he.reasoning, he.mode, he.agent_type, COALESCE(he.cwd, csb.cwd) AS cwd
868
+ FROM hook_events he LEFT JOIN codex_session_bindings csb ON csb.project_id = he.project_id AND csb.session_id = he.session_id
869
+ WHERE he.project_id = ? AND he.event_key = ?`, [projectId, eventKey]);
870
+ if (!event?.session_id)
871
+ throw new AppError("hook_event_not_found", "stage start requires a trusted PreToolUse hook event", 1, { event_key: eventKey });
872
+ return {
873
+ hookEventId: event.id,
874
+ harness: event.harness,
875
+ providerSessionId: event.provider_session_id ?? event.session_id,
876
+ parentSessionId: event.parent_session_id,
877
+ daemonId: event.daemon_id,
878
+ agentId: event.agent_id,
879
+ sessionId: event.harness === "codex-desktop" ? event.agent_id ?? event.session_id : event.session_id,
880
+ turnId: event.turn_id,
881
+ transcriptPath: event.transcript_path,
882
+ provider: event.provider,
883
+ model: event.model,
884
+ reasoning: event.reasoning,
885
+ mode: event.mode,
886
+ agentType: event.agent_type,
887
+ cwd: event.cwd
888
+ };
889
+ }
890
+ /**
891
+ * Claims the short-lived PreToolUse event for a lifecycle command when its host
892
+ * cannot apply `updatedInput` to a nested Bash invocation. The match key is
893
+ * derived from immutable command arguments; an event remains single-use.
894
+ */
895
+ export function claimRecentMatchingHookEvent(context, input) {
896
+ const event = findRecentMatchingHookEvent(context, input);
897
+ const claim = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
898
+ if (claim.changes !== 1) {
899
+ throw new AppError(input.errorCode, `matching ${input.operation} PreToolUse hook event was already claimed`, 1);
900
+ }
901
+ return event.eventKey;
902
+ }
903
+ /** Finds the trusted event that a stage- or Work-specific claimant will atomically claim. */
904
+ export function findRecentMatchingHookEvent(context, input) {
905
+ const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
906
+ const find = () => context.db.get(`SELECT id, event_key FROM hook_events
907
+ WHERE project_id = ? AND match_key = ? AND status = 'observed' AND created_at >= ?
908
+ ORDER BY id DESC LIMIT 1`, [input.projectId, input.matchKey, freshAfter]);
909
+ let event = find();
910
+ // Some harnesses publish nested-agent tool events concurrently with command
911
+ // execution. Give the trusted event a short bounded window to reach SQLite.
912
+ const deadline = Date.now() + 250;
913
+ while (!event?.event_key && Date.now() < deadline) {
914
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
915
+ event = find();
916
+ }
917
+ if (!event?.event_key) {
918
+ 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);
919
+ }
920
+ return { id: event.id, eventKey: event.event_key };
921
+ }
922
+ /** Fallback for Desktop paths that execute the original command after a hook rewrite. */
923
+ export function claimBootstrapHookEvent(context, input) {
924
+ return claimRecentMatchingHookEvent(context, {
925
+ projectId: input.projectId,
926
+ matchKey: bootstrapMatchKey(input),
927
+ errorCode: "trusted_session_binding_required",
928
+ operation: "vNext SPECIFY start"
929
+ });
930
+ }
931
+ export function bootstrapMatchKey(input) {
932
+ return crypto.createHash("sha256").update(JSON.stringify({
933
+ operation: "stage_start",
934
+ bootstrap: true,
935
+ stage: "specify",
936
+ project_root: resolveProjectRoot(input.projectRoot),
937
+ subject: input.subject,
938
+ intake_mode: input.intakeMode,
939
+ ...(input.contextSha256 ? { context_sha256: input.contextSha256 } : {})
940
+ })).digest("hex");
941
+ }
942
+ export function claimWorkStartHookEvent(context, input) {
943
+ const expected = new Set([workStartMatchKey(input.workId, input.projectRoot), workStartMatchKey(shortWorkReference(input.workId), input.projectRoot)]);
944
+ 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]);
945
+ if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.has(event.match_key)) {
946
+ throw new AppError("trusted_work_launch_required", "work start requires one fresh matching PreToolUse event", 1, { work_id: input.workId, event_key: input.eventKey });
947
+ }
948
+ const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
949
+ if (claimed.changes !== 1)
950
+ throw new AppError("trusted_work_launch_required", "matching work-start hook event was already claimed", 1, { work_id: input.workId });
951
+ return hookSessionIdentity(context, input.projectId, input.eventKey);
952
+ }
953
+ function shortWorkReference(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id; }
954
+ /** Claims the exact non-bootstrap stage entry which starts a coordinator Work. */
955
+ export function assertStageStartHookEvent(context, input) {
956
+ const expected = stageStartMatchKey(input.runId, input.stage, input.projectRoot, input.contextSha256);
957
+ const event = context.db.get("SELECT session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
958
+ if (!event?.session_id || event.status !== "observed" || event.match_key !== expected) {
959
+ 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 });
960
+ }
961
+ return hookSessionIdentity(context, input.projectId, input.eventKey);
962
+ }
963
+ export function claimStageStartHookEvent(context, input) {
964
+ assertStageStartHookEvent(context, input);
965
+ const event = context.db.get("SELECT id FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
966
+ const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
967
+ if (claimed.changes !== 1)
968
+ throw new AppError("trusted_stage_launch_required", "matching stage-start hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
969
+ return hookSessionIdentity(context, input.projectId, input.eventKey);
970
+ }
971
+ export function stageStartMatchKey(runId, stage, projectRoot, contextSha256) {
972
+ return crypto.createHash("sha256").update(JSON.stringify({
973
+ operation: "stage_start",
974
+ run_id: runId,
975
+ stage,
976
+ project_root: resolveProjectRoot(projectRoot),
977
+ ...(contextSha256 ? { context_sha256: contextSha256 } : {})
978
+ })).digest("hex");
979
+ }
980
+ export function stageResumeMatchKey(runId, stage, workId, projectRoot) {
981
+ return crypto.createHash("sha256").update(JSON.stringify({
982
+ operation: "stage_resume",
983
+ run_id: runId,
984
+ stage,
985
+ work_id: workId,
986
+ project_root: resolveProjectRoot(projectRoot)
987
+ })).digest("hex");
988
+ }
989
+ /** Claims the resume command observed in the Session that received the user answer. */
990
+ export function claimStageResumeHookEvent(context, input) {
991
+ const matchKey = stageResumeMatchKey(input.runId, input.stage, input.workId, input.projectRoot);
992
+ const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
993
+ const event = input.eventKey
994
+ ? 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])
995
+ : 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]);
996
+ if (!event?.event_key || !event.session_id || event.match_key !== matchKey || event.status !== "observed") {
997
+ 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 });
998
+ }
999
+ const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
1000
+ if (claimed.changes !== 1)
1001
+ throw new AppError("trusted_stage_resume_required", "matching stage-resume hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
1002
+ return hookSessionIdentity(context, input.projectId, event.event_key);
1003
+ }
1004
+ export function workStartMatchKey(workId, projectRoot) {
1005
+ return crypto.createHash("sha256").update(JSON.stringify({
1006
+ operation: "work_start",
1007
+ work_id: workId,
1008
+ project_root: resolveProjectRoot(projectRoot)
1009
+ })).digest("hex");
333
1010
  }
334
1011
  export function hookStatusForProject(context, projectId) {
335
1012
  return context.db.all(`SELECT scope, config_path, content_hash, installed, drift_status, updated_at
@@ -343,6 +1020,11 @@ export function activeCodexSessionBindingsForProject(context, projectId) {
343
1020
  return context.db.all(`SELECT session_id, protocol_id, handshake_id, cwd, transcript_path, status, continuation_count, updated_at
344
1021
  FROM codex_session_bindings WHERE project_id = ? AND status = 'active' ORDER BY updated_at DESC`, [projectId]);
345
1022
  }
1023
+ /** Resolve the transcript once at the hook boundary; agents never provide this path. */
1024
+ export function transcriptPathForCodexSession(context, projectRoot, sessionId) {
1025
+ const project = requireProjectByRoot(context, resolveProjectRoot(projectRoot));
1026
+ return sessionBinding(context, project.id, sessionId)?.transcript_path ?? locateCodexTranscript(sessionId, context.env.CODEX_HOME);
1027
+ }
346
1028
  export function activeFlowSessionBindingsForProject(context, projectId) {
347
1029
  return activeFlowSessionsForProject(context, projectId).map((session) => ({
348
1030
  session_id: session.session_id,
@@ -360,7 +1042,7 @@ export function activeFlowSessionBindingsForProject(context, projectId) {
360
1042
  }
361
1043
  export function codexHookEventsForProject(context, projectId) {
362
1044
  return context.db.all(`SELECT protocol_id, session_id, turn_id, event_name, tool_name, status, sanitized_summary, created_at
363
- FROM codex_hook_events WHERE project_id = ? ORDER BY created_at DESC, id DESC LIMIT 20`, [projectId]);
1045
+ FROM hook_events WHERE project_id = ? ORDER BY created_at DESC, id DESC LIMIT 20`, [projectId]);
364
1046
  }
365
1047
  function resolveProfileName(profile) {
366
1048
  return sanitizeProfile(profile ?? defaultProfileName);
@@ -473,7 +1155,8 @@ function commandHook(command, statusMessage) {
473
1155
  return { type: "command", command, timeout: 5, statusMessage };
474
1156
  }
475
1157
  function hookCommand(event) {
476
- return `PATH="\${HOME}/Library/pnpm:/opt/homebrew/bin:/usr/local/bin:\${PATH}" dd-flow codex hook handle --event ${event} --json`;
1158
+ const handler = `PATH="\${HOME}/Library/pnpm:/opt/homebrew/bin:/usr/local/bin:\${PATH}" dd-flow codex hook handle --event ${event} --json`;
1159
+ 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"'`;
477
1160
  }
478
1161
  function resolveHookTarget(target) {
479
1162
  if (!target || target === "isolated") {
@@ -652,7 +1335,7 @@ function upsertSessionBindingFromPayload(context, project, sessionId, payload) {
652
1335
  const existing = sessionBinding(context, project.id, sessionId);
653
1336
  const now = context.now();
654
1337
  const cwd = stringValue(payload.cwd) ?? existing?.cwd ?? null;
655
- const transcriptPath = stringValue(payload.transcript_path) ?? existing?.transcript_path ?? null;
1338
+ const transcriptPath = stringValue(payload.transcript_path) ?? existing?.transcript_path ?? locateCodexTranscript(sessionId, context.env.CODEX_HOME);
656
1339
  context.db.run(`INSERT INTO codex_session_bindings
657
1340
  (session_id, project_id, protocol_id, handshake_id, cwd, transcript_path, status, created_at, updated_at)
658
1341
  VALUES (?, ?, NULL, NULL, ?, ?, 'active', ?, ?)
@@ -663,21 +1346,73 @@ function upsertSessionBindingFromPayload(context, project, sessionId, payload) {
663
1346
  updated_at = excluded.updated_at`, [sessionId, project.id, cwd, transcriptPath, now, now]);
664
1347
  return sessionBinding(context, project.id, sessionId);
665
1348
  }
1349
+ function locateCodexTranscript(sessionId, configuredHome) {
1350
+ // The hook runs inside one selected CODEX_HOME. An explicit profile must
1351
+ // not also scan unrelated real homes; that breaks isolation and can attach
1352
+ // a transcript from a different client. Only an unconfigured legacy call
1353
+ // probes the two conventional locations.
1354
+ const homes = configuredHome
1355
+ ? [configuredHome]
1356
+ : [path.join(os.homedir(), ".codex"), path.join(os.homedir(), ".codex-cpa")];
1357
+ for (const home of homes) {
1358
+ for (const directory of recentCodexSessionDirectories(home)) {
1359
+ const found = findTranscript(directory, sessionId);
1360
+ if (found)
1361
+ return found;
1362
+ }
1363
+ }
1364
+ return null;
1365
+ }
1366
+ function findTranscript(root, sessionId) {
1367
+ if (!fs.existsSync(root))
1368
+ return null;
1369
+ let entries;
1370
+ try {
1371
+ entries = fs.readdirSync(root, { withFileTypes: true });
1372
+ }
1373
+ catch {
1374
+ return null;
1375
+ }
1376
+ for (const entry of entries) {
1377
+ if (entry.isFile() && entry.name.endsWith(".jsonl") && entry.name.includes(sessionId))
1378
+ return path.join(root, entry.name);
1379
+ }
1380
+ return null;
1381
+ }
1382
+ function recentCodexSessionDirectories(home) {
1383
+ return [0, 1].map((daysAgo) => {
1384
+ const date = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000);
1385
+ return path.join(home, "sessions", String(date.getUTCFullYear()), String(date.getUTCMonth() + 1).padStart(2, "0"), String(date.getUTCDate()).padStart(2, "0"));
1386
+ });
1387
+ }
666
1388
  function sessionBinding(context, projectId, sessionId) {
667
1389
  return context.db.get("SELECT * FROM codex_session_bindings WHERE project_id = ? AND session_id = ?", [projectId, sessionId]);
668
1390
  }
669
1391
  function recordHookEvent(context, input) {
670
- const existing = context.db.get("SELECT id FROM codex_hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
1392
+ const existing = context.db.get("SELECT id FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
671
1393
  if (existing)
672
1394
  return false;
673
- context.db.run(`INSERT INTO codex_hook_events
674
- (project_id, protocol_id, session_id, turn_id, event_key, event_name, tool_name, status, sanitized_summary, created_at)
675
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1395
+ context.db.run(`INSERT INTO hook_events
1396
+ (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)
1397
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
676
1398
  input.projectId,
677
1399
  input.protocolId,
1400
+ input.harness,
1401
+ input.providerSessionId,
1402
+ input.parentSessionId,
1403
+ input.daemonId ?? null,
678
1404
  input.sessionId,
1405
+ input.agentId,
679
1406
  input.turnId,
1407
+ stringValue(input.payload.transcript_path) ?? input.transcriptPath,
1408
+ stringValue(input.payload.provider) ?? null,
1409
+ stringValue(input.payload.model) ?? null,
1410
+ stringValue(input.payload.reasoning) ?? null,
1411
+ stringValue(input.payload.mode) ?? null,
1412
+ stringValue(input.payload.agent_type) ?? null,
1413
+ input.cwd,
680
1414
  input.eventKey,
1415
+ input.matchKey,
681
1416
  input.eventName,
682
1417
  input.toolName,
683
1418
  input.status,
@@ -700,6 +1435,27 @@ function projectForHook(context, explicitRoot, cwd) {
700
1435
  })
701
1436
  .sort((left, right) => right.root.length - left.root.length)[0];
702
1437
  }
1438
+ function optionFromCommand(command, name) {
1439
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1440
+ const match = command.match(new RegExp(`(?:^|\\s)--${escaped}(?:=|\\s+)("(?:[^"\\\\]|\\\\.)*"|'(?:[^'\\\\]|\\\\.)*'|[^\\s]+)`));
1441
+ return match?.[1] ? shellArgument(match[1]) : undefined;
1442
+ }
1443
+ function positionalFromCommand(command, prefix) {
1444
+ const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1445
+ const match = command.match(new RegExp(`\\b${escaped}\\s+("(?:[^"\\\\]|\\\\.)*"|'(?:[^'\\\\]|\\\\.)*'|[^\\s]+)`));
1446
+ return match?.[1] ? shellArgument(match[1]) : undefined;
1447
+ }
1448
+ function shellArgument(raw) {
1449
+ if (raw.startsWith('"')) {
1450
+ try {
1451
+ return JSON.parse(raw);
1452
+ }
1453
+ catch {
1454
+ return undefined;
1455
+ }
1456
+ }
1457
+ return raw.startsWith("'") ? raw.slice(1, -1) : raw;
1458
+ }
703
1459
  function hookEventKey(payload, eventName, toolName, command) {
704
1460
  const explicit = stringValue(payload.tool_use_id) ?? stringValue(payload.event_id) ?? stringValue(payload.delivery_id) ?? stringValue(payload.id);
705
1461
  if (explicit)