@deksden-com/dd-flow-cli 0.4.2 → 0.6.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 (53) hide show
  1. package/CHANGELOG.md +33 -4
  2. package/README.md +2 -2
  3. package/dist/build-info.json +6 -6
  4. package/dist/cli/help.js +16 -4
  5. package/dist/cli/run-cli.js +42 -17
  6. package/dist/domain/flow-contract.js +82 -3
  7. package/dist/domain/session-coverage.js +88 -0
  8. package/dist/domain/validation.js +56 -28
  9. package/dist/protocol/local-files.js +1 -16
  10. package/dist/schemas/code-stage-report.schema.json +2 -2
  11. package/dist/schemas/flow-contract.schema.json +152 -94
  12. package/dist/schemas/flow-run.schema.json +129 -23
  13. package/dist/schemas/mb-upgrade-review-data.schema.json +2 -2
  14. package/dist/schemas/memorybank-permissions-preflight.schema.json +13 -73
  15. package/dist/schemas/merge-stage-report.schema.json +2 -2
  16. package/dist/schemas/plan-stage-report.schema.json +38 -335
  17. package/dist/schemas/project-flow-pack-manifest.schema.json +4 -4
  18. package/dist/schemas/protocol-plan.schema.json +197 -0
  19. package/dist/schemas/release-impact.schema.json +9 -5
  20. package/dist/schemas/session-usage.schema.json +16 -0
  21. package/dist/schemas/stage-finish-input.schema.json +20 -0
  22. package/dist/schemas/stage-prompt.schema.json +35 -0
  23. package/dist/schemas/stage-report.schema.json +20 -0
  24. package/dist/schemas/stage-start-response.schema.json +30 -0
  25. package/dist/schemas/timeline-event.schema.json +29 -0
  26. package/dist/schemas/worktrunk-workspace.schema.json +19 -0
  27. package/dist/services/branch-context.js +9 -4
  28. package/dist/services/cleanup.js +77 -0
  29. package/dist/services/dashboard.js +51 -26
  30. package/dist/services/engines.js +84 -18
  31. package/dist/services/hooks.js +101 -250
  32. package/dist/services/memory-permissions.js +77 -69
  33. package/dist/services/migrations.js +1 -1
  34. package/dist/services/plan-runtime.js +124 -0
  35. package/dist/services/plans.js +22 -84
  36. package/dist/services/projects.js +2 -1
  37. package/dist/services/prompts.js +26 -21
  38. package/dist/services/protocols.js +29 -25
  39. package/dist/services/run-projection.js +93 -13
  40. package/dist/services/runs.js +128 -61
  41. package/dist/services/schema-validation.js +168 -7
  42. package/dist/services/sessions.js +97 -73
  43. package/dist/services/stage-lifecycle.js +737 -0
  44. package/dist/services/tooling.js +285 -0
  45. package/dist/services/usage.js +183 -30
  46. package/dist/services/version-status.js +1 -1
  47. package/dist/services/worktrees.js +88 -39
  48. package/dist/storage/database.js +72 -30
  49. package/dist/storage/paths.js +0 -9
  50. package/package.json +14 -13
  51. package/tools/worktrunk-manifest.json +34 -0
  52. package/dist/schemas/flow-run-index-v3.schema.json +0 -203
  53. package/dist/schemas/flow-run-index.schema.json +0 -175
@@ -8,10 +8,9 @@ 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 { requireProjectByRoot } from "./projects.js";
11
- import { activeFlowSessionsForProject, confirmPendingFlowSessionBinding, flowSessionById, flowSessionPayloadFromRegisterCommand, recordPendingFlowSessionBinding, updateFlowSessionContinuation } from "./sessions.js";
11
+ import { activeFlowSessionsForProject, bindObservedFlowSession, flowSessionPayloadFromRegisterCommand, recordFlowSessionObservation } from "./sessions.js";
12
12
  const defaultProfileName = "default";
13
13
  const maxSanitizedSummaryLength = 1600;
14
- const stopLoopLimit = 3;
15
14
  const sharedEntries = [
16
15
  "auth.json",
17
16
  "auth.bk",
@@ -197,7 +196,7 @@ export function getCodexHooksStatus(context, input) {
197
196
  };
198
197
  }
199
198
  const status = hookFileStatus(location.hooksPath, project.root);
200
- const recorded = hookInstallation(context, project.id, installationScope(target, location.profile));
199
+ const recorded = hookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath));
201
200
  const driftStatus = status.installed ? "none" : recorded?.installed === 1 ? "drifted" : status.drift_status;
202
201
  return {
203
202
  ok: true,
@@ -225,7 +224,7 @@ export function installCodexHooks(context, input) {
225
224
  const nextConfig = mergeHooksConfig(existing, project.root);
226
225
  ensureDir(path.dirname(location.hooksPath));
227
226
  writeJsonFile(location.hooksPath, nextConfig);
228
- recordHookInstallation(context, project.id, installationScope(target, location.profile), location.hooksPath, true, "none");
227
+ recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, true, "none");
229
228
  appendAudit(context, {
230
229
  projectId: project.id,
231
230
  eventType: "codex_hooks.installed",
@@ -257,7 +256,7 @@ export function removeCodexHooks(context, input) {
257
256
  const nextConfig = removeManagedHooks(existing, project.root);
258
257
  ensureDir(path.dirname(location.hooksPath));
259
258
  writeJsonFile(location.hooksPath, nextConfig);
260
- recordHookInstallation(context, project.id, installationScope(target, location.profile), location.hooksPath, false, "not_installed");
259
+ recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, false, "not_installed");
261
260
  appendAudit(context, {
262
261
  projectId: project.id,
263
262
  eventType: "codex_hooks.removed",
@@ -266,49 +265,75 @@ export function removeCodexHooks(context, input) {
266
265
  return { ok: true, installed: false, target, profile: location.profile, hooks_path: location.hooksPath, backup, had_managed: hadManaged };
267
266
  }
268
267
  export function handleCodexHook(context, input) {
269
- const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
270
268
  const payload = parseJsonObject(input.stdin || "{}", "codex hook stdin");
271
269
  const eventName = input.event || stringValue(payload.hook_event_name) || "Unknown";
270
+ if (eventName !== "PreToolUse") {
271
+ return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
272
+ }
272
273
  const sessionId = stringValue(payload.session_id);
273
274
  const turnId = stringValue(payload.turn_id);
274
275
  const toolName = stringValue(payload.tool_name) ?? toolNameFromPayload(payload);
275
276
  const command = commandFromPayload(payload);
277
+ if (!command)
278
+ return { ok: true, observed: false, reason: "non_bash_tool" };
279
+ const flowPayload = flowSessionPayloadFromRegisterCommand(command);
280
+ const bootstrapStageStart = /\bdd-flow\s+stage\s+start\b/.test(command) && /(?:^|\s)--bootstrap(?:\s|$)/.test(command);
281
+ if (!flowPayload && !bootstrapStageStart)
282
+ return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
283
+ const project = projectForHook(context, input.projectRoot, stringValue(payload.cwd));
284
+ if (!project)
285
+ return { ok: true, observed: false, reason: "unrelated_cwd" };
276
286
  const binding = sessionId ? upsertSessionBindingFromPayload(context, project, sessionId, payload) : undefined;
277
- const preToolUseDecision = eventName === "PreToolUse" && command
278
- ? preToolUseGuard(context, project, sessionId ?? null, payload, command)
279
- : undefined;
280
- if (eventName === "PreToolUse" && sessionId && command) {
281
- recordPendingFlowSessionBinding(context, project, {
282
- sessionId,
283
- command,
284
- cwd: stringValue(payload.cwd),
285
- transcriptPath: stringValue(payload.transcript_path),
286
- turnId: turnId ?? undefined
287
- });
288
- }
289
- const confirmedFlowSession = eventName === "PostToolUse" && sessionId && toolSucceeded(payload)
290
- ? confirmPendingFlowSessionBinding(context, project, { sessionId })
287
+ const observedSession = flowPayload
288
+ ? bindObservedFlowSession(context, project, flowPayload, sessionId ?? flowPayload.session_id ?? undefined)
291
289
  : undefined;
292
- const existingFlowSession = sessionId ? flowSessionById(context, project.id, sessionId) : undefined;
293
- const boundProtocol = eventName === "PostToolUse" && sessionId ? bindProtocolFromToolEvent(context, project, sessionId, payload) : binding;
294
- const protocolId = confirmedFlowSession?.protocol_id ?? existingFlowSession?.protocol_id ?? boundProtocol?.protocol_id ?? binding?.protocol_id ?? null;
295
- recordHookEvent(context, {
290
+ const effectiveSessionId = observedSession?.session_id ?? sessionId ?? null;
291
+ const protocolId = observedSession?.protocol_id ?? binding?.protocol_id ?? null;
292
+ const eventKey = hookEventKey(payload, eventName, toolName, command);
293
+ const inserted = recordHookEvent(context, {
296
294
  projectId: project.id,
297
295
  protocolId,
298
- sessionId: sessionId ?? null,
296
+ sessionId: effectiveSessionId,
299
297
  turnId: turnId ?? null,
300
298
  eventName,
301
299
  toolName: toolName ?? null,
302
300
  status: "observed",
303
- payload
301
+ payload,
302
+ eventKey
304
303
  });
305
- if (eventName === "Stop") {
306
- return stopDecision(context, project, sessionId ?? null);
307
- }
308
- if (preToolUseDecision) {
309
- return preToolUseDecision;
304
+ if (effectiveSessionId) {
305
+ recordFlowSessionObservation(context, {
306
+ projectId: project.id,
307
+ sessionId: effectiveSessionId,
308
+ runId: observedSession?.run_id ?? null,
309
+ protocolId,
310
+ cwd: stringValue(payload.cwd) ?? null,
311
+ toolName: toolName ?? null,
312
+ eventKey
313
+ });
310
314
  }
311
- return hookContinue(eventName, protocolId);
315
+ return {
316
+ ok: true,
317
+ observed: inserted,
318
+ duplicate: !inserted,
319
+ event_key: eventKey,
320
+ session_id: effectiveSessionId,
321
+ protocol_id: protocolId,
322
+ ...(sessionId
323
+ ? {
324
+ hookSpecificOutput: {
325
+ hookEventName: "PreToolUse",
326
+ permissionDecision: "allow",
327
+ updatedInput: { command: commandWithSessionId(command, sessionId) }
328
+ }
329
+ }
330
+ : {})
331
+ };
332
+ }
333
+ function commandWithSessionId(command, sessionId) {
334
+ if (/(?:^|\s)--session-id(?:=|\s)/.test(command))
335
+ return command;
336
+ return `${command} --session-id ${JSON.stringify(sessionId)}`;
312
337
  }
313
338
  export function hookStatusForProject(context, projectId) {
314
339
  return context.db.all(`SELECT scope, config_path, content_hash, installed, drift_status, updated_at
@@ -438,13 +463,10 @@ function sharedEntryStatus(sourceHome, targetHome) {
438
463
  };
439
464
  });
440
465
  }
441
- function expectedHooksConfig(projectRoot) {
466
+ function expectedHooksConfig(_projectRoot) {
442
467
  return {
443
468
  hooks: {
444
- SessionStart: [hookEntry("startup|resume", hookCommand("SessionStart", projectRoot), "dd-flow session binding")],
445
- PreToolUse: [hookEntry("Bash", hookCommand("PreToolUse", projectRoot), "dd-flow command guard")],
446
- PostToolUse: [hookEntry("Bash", hookCommand("PostToolUse", projectRoot), "dd-flow command observer")],
447
- Stop: [{ hooks: [commandHook(hookCommand("Stop", projectRoot), "dd-flow continuation guard")] }]
469
+ PreToolUse: [hookEntry("Bash", hookCommand("PreToolUse"), "dd-flow runtime observation")]
448
470
  }
449
471
  };
450
472
  }
@@ -454,8 +476,8 @@ function hookEntry(matcher, command, statusMessage) {
454
476
  function commandHook(command, statusMessage) {
455
477
  return { type: "command", command, timeout: 5, statusMessage };
456
478
  }
457
- function hookCommand(event, projectRoot) {
458
- return `dd-flow codex hook handle --event ${event} --project-root ${JSON.stringify(projectRoot)} --json`;
479
+ function hookCommand(event) {
480
+ return `PATH="\${HOME}/Library/pnpm:/opt/homebrew/bin:/usr/local/bin:\${PATH}" dd-flow codex hook handle --event ${event} --json`;
459
481
  }
460
482
  function resolveHookTarget(target) {
461
483
  if (!target || target === "isolated") {
@@ -493,7 +515,7 @@ function requireHookLocation(context, project, target, profile) {
493
515
  }
494
516
  function hookFileStatus(hooksPath, projectRoot) {
495
517
  const existing = readJsonIfExists(hooksPath);
496
- const installed = existing ? hasExpectedHooks(existing, projectRoot) : false;
518
+ const installed = existing ? hasExpectedHooks(existing) : false;
497
519
  const managed = existing ? hasManagedHooks(existing, projectRoot) : false;
498
520
  return {
499
521
  installed,
@@ -537,20 +559,20 @@ function removeManagedHooks(existing, projectRoot) {
537
559
  }
538
560
  return { ...existing, hooks: nextHooks };
539
561
  }
540
- function hasExpectedHooks(existing, projectRoot) {
562
+ function hasExpectedHooks(existing) {
541
563
  const commands = collectHookCommands(existing);
542
- return expectedCommands(projectRoot).every((command) => commands.includes(command));
564
+ return expectedCommands().every((command) => commands.includes(command));
543
565
  }
544
566
  function hasManagedHooks(existing, projectRoot) {
545
567
  return collectHookCommands(existing).some((command) => command.includes("dd-flow codex hook handle") &&
546
- (command.includes(`--project-root ${JSON.stringify(projectRoot)}`) || command.includes(projectRoot)));
568
+ (command.includes("--event PreToolUse") || command.includes(`--project-root ${JSON.stringify(projectRoot)}`) || command.includes(projectRoot)));
547
569
  }
548
- function expectedCommands(projectRoot) {
549
- return ["SessionStart", "PreToolUse", "PostToolUse", "Stop"].map((event) => hookCommand(event, projectRoot));
570
+ function expectedCommands() {
571
+ return [hookCommand("PreToolUse")];
550
572
  }
551
573
  function entryHasManagedCommand(entry, projectRoot) {
552
574
  return collectHookCommands(entry).some((command) => command.includes("dd-flow codex hook handle") &&
553
- (command.includes(`--project-root ${JSON.stringify(projectRoot)}`) || command.includes(projectRoot)));
575
+ (command.includes("--event PreToolUse") || command.includes(`--project-root ${JSON.stringify(projectRoot)}`) || command.includes(projectRoot)));
554
576
  }
555
577
  function collectHookCommands(value) {
556
578
  if (Array.isArray(value)) {
@@ -627,8 +649,8 @@ function recordHookInstallation(context, projectId, scope, configPath, installed
627
649
  drift_status = excluded.drift_status,
628
650
  updated_at = excluded.updated_at`, [projectId, scope, configPath, hashObject(readJsonIfExists(configPath) ?? {}), installed ? 1 : 0, driftStatus, now, now]);
629
651
  }
630
- function installationScope(target, profile) {
631
- return target === "isolated" ? `isolated:${profile}` : "default";
652
+ function installationScope(target, profile, hooksPath) {
653
+ return target === "isolated" ? `isolated:${profile}` : `default:${path.dirname(hooksPath)}`;
632
654
  }
633
655
  function upsertSessionBindingFromPayload(context, project, sessionId, payload) {
634
656
  const existing = sessionBinding(context, project.id, sessionId);
@@ -645,213 +667,54 @@ function upsertSessionBindingFromPayload(context, project, sessionId, payload) {
645
667
  updated_at = excluded.updated_at`, [sessionId, project.id, cwd, transcriptPath, now, now]);
646
668
  return sessionBinding(context, project.id, sessionId);
647
669
  }
648
- function bindProtocolFromToolEvent(context, project, sessionId, payload) {
649
- if (!toolSucceeded(payload)) {
650
- return sessionBinding(context, project.id, sessionId);
651
- }
652
- const command = commandFromPayload(payload);
653
- const handshakeId = command ? handshakeFromCommand(command) : null;
654
- if (!handshakeId) {
655
- return sessionBinding(context, project.id, sessionId);
656
- }
657
- const protocol = context.db.get("SELECT id, handshake_id FROM protocols WHERE project_id = ? AND (handshake_id = ? OR id = ?) ORDER BY updated_at DESC LIMIT 1", [project.id, handshakeId, handshakeId.startsWith("PRT-") ? handshakeId : `PRT-${handshakeId}`]);
658
- if (!protocol) {
659
- return sessionBinding(context, project.id, sessionId);
660
- }
661
- context.db.run(`UPDATE codex_session_bindings
662
- SET protocol_id = ?, handshake_id = ?, status = 'active', updated_at = ?
663
- WHERE project_id = ? AND session_id = ?`, [protocol.id, protocol.handshake_id, context.now(), project.id, sessionId]);
664
- appendAudit(context, {
665
- protocolId: protocol.id,
666
- projectId: project.id,
667
- eventType: "codex_session.bound",
668
- payload: { project_id: project.id, protocol_id: protocol.id, handshake_id: protocol.handshake_id, session_id: sessionId }
669
- });
670
- return sessionBinding(context, project.id, sessionId);
671
- }
672
670
  function sessionBinding(context, projectId, sessionId) {
673
671
  return context.db.get("SELECT * FROM codex_session_bindings WHERE project_id = ? AND session_id = ?", [projectId, sessionId]);
674
672
  }
675
673
  function recordHookEvent(context, input) {
674
+ const existing = context.db.get("SELECT id FROM codex_hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
675
+ if (existing)
676
+ return false;
676
677
  context.db.run(`INSERT INTO codex_hook_events
677
- (project_id, protocol_id, session_id, turn_id, event_name, tool_name, status, sanitized_summary, created_at)
678
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
678
+ (project_id, protocol_id, session_id, turn_id, event_key, event_name, tool_name, status, sanitized_summary, created_at)
679
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
679
680
  input.projectId,
680
681
  input.protocolId,
681
682
  input.sessionId,
682
683
  input.turnId,
684
+ input.eventKey,
683
685
  input.eventName,
684
686
  input.toolName,
685
687
  input.status,
686
688
  sanitizedSummary(input.payload),
687
689
  context.now()
688
690
  ]);
691
+ return true;
689
692
  }
690
- function stopDecision(context, project, sessionId) {
691
- if (!sessionId) {
692
- return {};
693
- }
694
- const session = flowSessionById(context, project.id, sessionId);
695
- if (!session || ["waiting_user", "blocked", "stopping", "stopped", "closed"].includes(session.status)) {
696
- return {};
697
- }
698
- if (session.protocol_id && protocolAllowsStop(context, project.id, session.protocol_id)) {
699
- return {};
700
- }
701
- if (session.flow_kind === "merge_worker" && session.continuation_policy === "merge_queue") {
702
- return stopContinuationDecision(context, project.id, sessionId, `merge-worker:${session.worker_id ?? session.session_id}:${session.next_action ?? "wait-next"}`, `Continue dd-flow merge worker ${session.worker_id ?? session.session_id}: run .memory-bank/dd-flow/merge-start.md and continue dd-flow merge-queue wait-next from the registered merge workspace. Use dd-flow session stop-worker to stop this worker.`, { limit: null });
703
- }
704
- if (session.flow_kind === "merge_job" && session.continuation_policy === "merge_job") {
705
- return stopContinuationDecision(context, project.id, sessionId, `merge-job:${session.protocol_id ?? session.session_id}:${session.next_action ?? "none"}`, `Continue dd-flow merge job ${session.protocol_id ?? ""}: complete merge work, close the protocol, and report terminal state.`);
706
- }
707
- const nextAction = session.next_action ?? nextActionFromProtocol(context, project.id, session.protocol_id);
708
- if (!nextAction || nextAction === "none" || session.continuation_policy === "none") {
709
- return {};
710
- }
711
- return stopContinuationDecision(context, project.id, sessionId, `${session.flow_kind}:${session.protocol_id ?? session.session_id}:${nextAction}`, `Continue dd-flow ${session.flow_kind}${session.protocol_id ? ` ${session.protocol_id}` : ""}: ${nextAction}`);
712
- }
713
- function nextActionFromProtocol(context, projectId, protocolId) {
714
- if (!protocolId) {
715
- return null;
716
- }
717
- const protocol = context.db.get("SELECT id, status, stage, next_action FROM protocols WHERE project_id = ? AND id = ?", [projectId, protocolId]);
718
- if (!protocol) {
719
- return null;
720
- }
721
- if (["waiting_for_user", "blocked", "closed", "cancelled"].includes(protocol.stage) || ["waiting_for_user", "blocked", "closed", "cancelled"].includes(protocol.status)) {
722
- return null;
723
- }
724
- return protocol.next_action;
725
- }
726
- function protocolAllowsStop(context, projectId, protocolId) {
727
- const protocol = context.db.get("SELECT status, stage FROM protocols WHERE project_id = ? AND id = ?", [projectId, protocolId]);
728
- return Boolean(protocol && (["closed", "cancelled"].includes(protocol.status) || ["closed", "cancelled"].includes(protocol.stage)));
729
- }
730
- function stopContinuationDecision(context, projectId, sessionId, actionKey, reason, options = {}) {
731
- const nextCount = updateFlowSessionContinuation(context, projectId, sessionId, actionKey);
732
- const limit = options.limit === undefined ? stopLoopLimit : options.limit;
733
- if (limit !== null && nextCount > limit) {
734
- return {};
735
- }
736
- return { decision: "block", reason };
737
- }
738
- function preToolUseGuard(context, project, sessionId, payload, command) {
739
- const cwd = stringValue(payload.cwd);
740
- const blockedTarget = cwd ? selfWorktreeRemovalTarget(command, cwd) : null;
741
- if (!blockedTarget) {
742
- const roleDecision = mergeRoleGuard(context, project, sessionId, command);
743
- if (roleDecision) {
744
- return roleDecision;
745
- }
693
+ function projectForHook(context, explicitRoot, cwd) {
694
+ if (explicitRoot)
695
+ return requireProjectByRoot(context, resolveProjectRoot(explicitRoot));
696
+ if (!cwd)
746
697
  return undefined;
747
- }
748
- return {
749
- decision: "block",
750
- reason: `Refusing to remove the current Codex session worktree (${blockedTarget}). Restart or run cleanup from the stable project root or a separate cleanup session.`
751
- };
752
- }
753
- function mergeRoleGuard(context, project, sessionId, command) {
754
- const session = sessionId ? flowSessionById(context, project.id, sessionId) : undefined;
755
- const mergeSessionKinds = ["merge_worker", "merge_job"];
756
- const isMergeRole = session ? mergeSessionKinds.includes(session.flow_kind) : false;
757
- const registerPayload = safeFlowSessionPayloadFromCommand(command);
758
- if (registerPayload && mergeSessionKinds.includes(registerPayload.flow_kind)) {
759
- if (session && !mergeSessionKinds.includes(session.flow_kind)) {
760
- return blockMergeRole("A non-merge Codex session cannot re-register itself as a merge worker/job.");
761
- }
762
- if (registerPayload.flow_kind === "merge_job" && !claimedMergeJobMatches(context, project.id, registerPayload.protocol_id, registerPayload.worker_id)) {
763
- return blockMergeRole("A merge_job session requires an already claimed merge queue job for the same protocol and worker_id.");
764
- }
765
- }
766
- const mergeQueueAction = mergeQueueMutation(command);
767
- if (mergeQueueAction) {
768
- if (mergeQueueAction === "next" || mergeQueueAction === "wait-next") {
769
- return session?.flow_kind === "merge_worker" ? undefined : blockMergeRole("Only a registered merge_worker session may claim or wait for merge queue jobs.");
770
- }
771
- return session?.flow_kind === "merge_job" ? undefined : blockMergeRole("Only a registered merge_job session may complete or fail a claimed merge queue job.");
772
- }
773
- if (mergeLaneLockMutation(command)) {
774
- return isMergeRole ? undefined : blockMergeRole("Only registered merge sessions may mutate the merge lane lock.");
775
- }
776
- if (/\bgit\s+merge\b/.test(command)) {
777
- return session?.flow_kind === "merge_job" ? undefined : blockMergeRole("Only a registered merge_job session may run git merge.");
778
- }
779
- return undefined;
780
- }
781
- function blockMergeRole(reason) {
782
- return { decision: "block", reason };
783
- }
784
- function safeFlowSessionPayloadFromCommand(command) {
785
- try {
786
- return flowSessionPayloadFromRegisterCommand(command);
787
- }
788
- catch {
789
- return undefined;
790
- }
791
- }
792
- function claimedMergeJobMatches(context, projectId, protocolId, workerId) {
793
- if (!protocolId || !workerId) {
794
- return false;
795
- }
796
- const job = context.db.get("SELECT status, claimed_by_session_id FROM merge_queue WHERE project_id = ? AND protocol_id = ?", [projectId, protocolId]);
797
- return job?.status === "claimed" && job.claimed_by_session_id === workerId;
798
- }
799
- function mergeQueueMutation(command) {
800
- const match = command.match(/\bdd-flow\s+merge-queue\s+(next|wait-next|complete|fail)\b/);
801
- return match?.[1] ?? null;
802
- }
803
- function mergeLaneLockMutation(command) {
804
- return /\bdd-flow\s+lane\s+lock\s+(acquire|heartbeat|release|wait|wait-acquire)\b/.test(command) && /(?:--lane(?:\s+|=)(?:"merge"|'merge'|merge)\b)/.test(command);
805
- }
806
- function selfWorktreeRemovalTarget(command, cwd) {
807
- if (!/\bgit\s+worktree\s+remove\b/.test(command)) {
808
- return null;
809
- }
810
- const cwdPath = normalizePathForGuard(cwd, cwd);
811
- for (const target of gitWorktreeRemoveTargets(command)) {
812
- const targetPath = normalizePathForGuard(target, cwd);
813
- if (cwdPath === targetPath || cwdPath.startsWith(`${targetPath}${path.sep}`)) {
814
- return targetPath;
815
- }
816
- }
817
- return null;
818
- }
819
- function gitWorktreeRemoveTargets(command) {
820
- const targets = [];
821
- const pattern = /\bgit\s+worktree\s+remove\b([^;&|]*)/g;
822
- for (const match of command.matchAll(pattern)) {
823
- const args = shellishTokens(match[1] ?? "");
824
- for (const arg of args) {
825
- if (arg.startsWith("-")) {
826
- continue;
827
- }
828
- targets.push(arg);
829
- break;
830
- }
831
- }
832
- return targets;
833
- }
834
- function shellishTokens(input) {
835
- const tokens = [];
836
- const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g;
837
- for (const match of input.matchAll(pattern)) {
838
- tokens.push(match[1] ?? match[2] ?? match[3] ?? "");
839
- }
840
- return tokens.filter((token) => token.length > 0);
841
- }
842
- function normalizePathForGuard(value, cwd) {
843
- const absolute = path.isAbsolute(value) ? value : path.resolve(cwd, value);
844
- try {
845
- return fs.realpathSync(absolute);
846
- }
847
- catch {
848
- return path.resolve(absolute);
849
- }
850
- }
851
- function hookContinue(eventName, protocolId) {
852
- void eventName;
853
- void protocolId;
854
- return {};
698
+ const candidate = fs.existsSync(cwd) ? fs.realpathSync(cwd) : path.resolve(cwd);
699
+ const projects = context.db.all("SELECT * FROM projects WHERE status = 'active'");
700
+ return projects
701
+ .filter((project) => {
702
+ const relative = path.relative(project.root, candidate);
703
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
704
+ })
705
+ .sort((left, right) => right.root.length - left.root.length)[0];
706
+ }
707
+ function hookEventKey(payload, eventName, toolName, command) {
708
+ const explicit = stringValue(payload.tool_use_id) ?? stringValue(payload.event_id) ?? stringValue(payload.delivery_id) ?? stringValue(payload.id);
709
+ if (explicit)
710
+ return explicit;
711
+ return crypto.createHash("sha256").update(JSON.stringify({
712
+ event: eventName,
713
+ session: stringValue(payload.session_id),
714
+ turn: stringValue(payload.turn_id),
715
+ tool: toolName ?? null,
716
+ command
717
+ })).digest("hex");
855
718
  }
856
719
  function commandFromPayload(payload) {
857
720
  const direct = stringValue(payload.command);
@@ -865,18 +728,6 @@ function toolNameFromPayload(payload) {
865
728
  const toolInput = objectRecord(payload.tool_input);
866
729
  return stringValue(toolInput.name);
867
730
  }
868
- function toolSucceeded(payload) {
869
- const response = objectRecord(payload.tool_response);
870
- const status = stringValue(payload.status) ?? stringValue(response.status);
871
- if (!status) {
872
- return true;
873
- }
874
- return ["success", "succeeded", "ok", "0"].includes(status.toLowerCase());
875
- }
876
- function handshakeFromCommand(command) {
877
- const match = command.match(/\bdd-flow\s+protocol\s+register\s+([^\s]+)/);
878
- return match?.[1] ?? null;
879
- }
880
731
  function sanitizedSummary(payload) {
881
732
  const summary = JSON.stringify(sanitizeValue(payload));
882
733
  return summary.length > maxSanitizedSummaryLength ? `${summary.slice(0, maxSanitizedSummaryLength)}...` : summary;