@deksden-com/dd-flow-cli 0.4.2 → 0.5.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 (49) hide show
  1. package/CHANGELOG.md +27 -4
  2. package/dist/build-info.json +6 -6
  3. package/dist/cli/help.js +14 -3
  4. package/dist/cli/run-cli.js +32 -16
  5. package/dist/domain/flow-contract.js +81 -2
  6. package/dist/domain/validation.js +56 -28
  7. package/dist/protocol/local-files.js +1 -16
  8. package/dist/schemas/code-stage-report.schema.json +2 -2
  9. package/dist/schemas/flow-contract.schema.json +150 -94
  10. package/dist/schemas/flow-run.schema.json +129 -23
  11. package/dist/schemas/mb-upgrade-review-data.schema.json +2 -2
  12. package/dist/schemas/memorybank-permissions-preflight.schema.json +13 -73
  13. package/dist/schemas/merge-stage-report.schema.json +2 -2
  14. package/dist/schemas/plan-stage-report.schema.json +38 -335
  15. package/dist/schemas/project-flow-pack-manifest.schema.json +4 -4
  16. package/dist/schemas/protocol-plan.schema.json +197 -0
  17. package/dist/schemas/release-impact.schema.json +9 -5
  18. package/dist/schemas/session-usage.schema.json +16 -0
  19. package/dist/schemas/stage-finish-input.schema.json +20 -0
  20. package/dist/schemas/stage-prompt.schema.json +31 -0
  21. package/dist/schemas/stage-report.schema.json +20 -0
  22. package/dist/schemas/stage-start-response.schema.json +29 -0
  23. package/dist/schemas/timeline-event.schema.json +29 -0
  24. package/dist/schemas/worktrunk-workspace.schema.json +19 -0
  25. package/dist/services/branch-context.js +9 -4
  26. package/dist/services/dashboard.js +51 -26
  27. package/dist/services/engines.js +84 -18
  28. package/dist/services/hooks.js +80 -246
  29. package/dist/services/memory-permissions.js +77 -69
  30. package/dist/services/plan-runtime.js +124 -0
  31. package/dist/services/plans.js +22 -84
  32. package/dist/services/projects.js +2 -1
  33. package/dist/services/prompts.js +26 -21
  34. package/dist/services/protocols.js +29 -25
  35. package/dist/services/run-projection.js +77 -11
  36. package/dist/services/runs.js +95 -61
  37. package/dist/services/schema-validation.js +168 -7
  38. package/dist/services/sessions.js +132 -68
  39. package/dist/services/stage-lifecycle.js +572 -0
  40. package/dist/services/tooling.js +285 -0
  41. package/dist/services/usage.js +183 -30
  42. package/dist/services/version-status.js +1 -1
  43. package/dist/services/worktrees.js +88 -39
  44. package/dist/storage/database.js +72 -30
  45. package/dist/storage/paths.js +0 -9
  46. package/package.json +14 -13
  47. package/tools/worktrunk-manifest.json +34 -0
  48. package/dist/schemas/flow-run-index-v3.schema.json +0 -203
  49. package/dist/schemas/flow-run-index.schema.json +0 -175
@@ -1,7 +1,8 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
- import { spawn } from "node:child_process";
5
+ import { spawn, spawnSync } from "node:child_process";
5
6
  import { createRequire } from "node:module";
6
7
  import { getCliBuildInfo } from "./build-info.js";
7
8
  import { AppError } from "../shared/errors.js";
@@ -34,12 +35,12 @@ export function installCurrentEngine(context, input = {}) {
34
35
  const target = engineVersionRoot(context.ddFlowHome, build.package_name, build.version);
35
36
  const manifestPath = path.join(target, "engine.json");
36
37
  const existing = readManifest(manifestPath);
37
- if (existing && !input.force && manifestHealthy(existing)) {
38
+ if (existing && !input.force && (input.forRouting || manifestHealthy(existing))) {
38
39
  return {
39
40
  ok: true,
40
41
  action: "engine_install",
41
42
  changed: false,
42
- engine: manifestSummary(existing),
43
+ ...(input.forRouting ? {} : { engine: manifestSummary(existing) }),
43
44
  path: target
44
45
  };
45
46
  }
@@ -47,12 +48,12 @@ export function installCurrentEngine(context, input = {}) {
47
48
  acquireInstallLock(lockDir);
48
49
  try {
49
50
  const afterLock = readManifest(manifestPath);
50
- if (afterLock && !input.force && manifestHealthy(afterLock)) {
51
+ if (afterLock && !input.force && (input.forRouting || manifestHealthy(afterLock))) {
51
52
  return {
52
53
  ok: true,
53
54
  action: "engine_install",
54
55
  changed: false,
55
- engine: manifestSummary(afterLock),
56
+ ...(input.forRouting ? {} : { engine: manifestSummary(afterLock) }),
56
57
  path: target
57
58
  };
58
59
  }
@@ -64,14 +65,21 @@ export function installCurrentEngine(context, input = {}) {
64
65
  copyPackageSnapshot(packageRoot, tmp);
65
66
  const manifest = buildManifest(context, build.package_name, build.version, packageRoot, target, tmp);
66
67
  fs.writeFileSync(path.join(tmp, "engine.json"), `${JSON.stringify(manifest, null, 2)}\n`);
67
- fs.rmSync(target, { recursive: true, force: true });
68
- fs.renameSync(tmp, target);
68
+ const stagedManifest = requireManifest(path.join(tmp, "engine.json"));
69
+ const stagedDiagnostics = manifestDiagnostics({ ...stagedManifest, package_root: tmp, snapshot_root: tmp }, { checkEntrypoint: !input.forRouting });
70
+ if (stagedDiagnostics.length > 0) {
71
+ throw new AppError("engine_install_failed", "Staged dd-flow engine failed its integrity or dependency health check", 1, {
72
+ diagnostics: stagedDiagnostics,
73
+ path: tmp
74
+ });
75
+ }
76
+ activateEngineAtomically(tmp, target);
69
77
  const installed = requireManifest(path.join(target, "engine.json"));
70
78
  return {
71
79
  ok: true,
72
80
  action: "engine_install",
73
81
  changed: true,
74
- engine: manifestSummary(installed),
82
+ ...(input.forRouting ? {} : { engine: manifestSummary(installed) }),
75
83
  path: target
76
84
  };
77
85
  }
@@ -121,8 +129,8 @@ export function doctorEngines(context, input = {}) {
121
129
  export function routeArgsThroughEngine(context, args, io, stdin, env, resolvedProjectRoot) {
122
130
  if (isEngineMode(env) || isRouterNativeCommand(args))
123
131
  return Promise.resolve(null);
124
- installCurrentEngine(context);
125
- const selection = selectEngine(context, { projectRoot: resolvedProjectRoot ?? resolveOperationProjectRoot(context, args) ?? undefined });
132
+ installCurrentEngine(context, { forRouting: true });
133
+ const selection = selectEngine(context, { projectRoot: resolvedProjectRoot ?? resolveOperationProjectRoot(context, args) ?? undefined }, { allowCurrentInProcess: true });
126
134
  if (selection.status === "missing") {
127
135
  const classification = classifyCliOperation(args, env);
128
136
  if (classification.mode === "read_only_diagnostics" || classification.mode === "mb_upgrade") {
@@ -148,7 +156,7 @@ export function engineRoutingMetadata(env) {
148
156
  routed_from: env.DD_FLOW_ROUTED_FROM ?? null
149
157
  };
150
158
  }
151
- export function selectEngine(context, input) {
159
+ export function selectEngine(context, input, options = {}) {
152
160
  const compatibility = readCompatibilityForProject(input.projectRoot);
153
161
  const build = getCliBuildInfo();
154
162
  const packageName = stringValue(compatibility?.engine?.package_name) ?? build.package_name;
@@ -158,7 +166,11 @@ export function selectEngine(context, input) {
158
166
  const manifests = readInstalledManifests(engineStoreRoot(context.ddFlowHome))
159
167
  .filter((manifest) => manifest.package_name === packageName)
160
168
  .filter((manifest) => satisfiesRange(manifest.package_version, requiredRange))
161
- .filter((manifest) => manifestHealthy(manifest))
169
+ .filter((manifest) => options.allowCurrentInProcess
170
+ && manifest.package_name === build.package_name
171
+ && manifest.package_version === build.version
172
+ ? true
173
+ : manifestHealthy(manifest))
162
174
  .sort((a, b) => compareSemverStrings(b.package_version, a.package_version));
163
175
  const selected = manifests[0] ?? null;
164
176
  return {
@@ -275,13 +287,13 @@ function buildManifest(context, packageName, version, packageRoot, snapshotRoot,
275
287
  },
276
288
  integrity: {
277
289
  source: "package_snapshot",
278
- checksum: fileListChecksum(checksumRoot),
279
- mode: "file_list"
290
+ checksum: engineSnapshotChecksum(checksumRoot),
291
+ mode: "full_content"
280
292
  }
281
293
  };
282
294
  }
283
295
  function copyPackageSnapshot(packageRoot, target) {
284
- for (const item of ["dist", "package.json", "README.md", "CHANGELOG.md"]) {
296
+ for (const item of ["dist", "package.json", "README.md", "CHANGELOG.md", "tools"]) {
285
297
  const from = path.join(packageRoot, item);
286
298
  if (!fs.existsSync(from))
287
299
  continue;
@@ -441,7 +453,7 @@ function readManifest(file) {
441
453
  function manifestHealthy(manifest) {
442
454
  return manifestDiagnostics(manifest).length === 0;
443
455
  }
444
- function manifestDiagnostics(manifest) {
456
+ function manifestDiagnostics(manifest, options = {}) {
445
457
  const diagnostics = [];
446
458
  if (manifest.schema_id !== engineManifestSchemaId)
447
459
  diagnostics.push("schema_id_mismatch");
@@ -455,6 +467,34 @@ function manifestDiagnostics(manifest) {
455
467
  diagnostics.push("entrypoint_outside_snapshot");
456
468
  else if (!fs.existsSync(entrypoint))
457
469
  diagnostics.push("entrypoint_missing");
470
+ if (!manifest.integrity || manifest.integrity.mode !== "full_content" || !manifest.integrity.checksum) {
471
+ diagnostics.push("content_checksum_missing");
472
+ }
473
+ else if (fs.existsSync(snapshotRoot)) {
474
+ try {
475
+ if (engineSnapshotChecksum(snapshotRoot) !== manifest.integrity.checksum)
476
+ diagnostics.push("content_checksum_mismatch");
477
+ }
478
+ catch {
479
+ diagnostics.push("snapshot_unreadable");
480
+ }
481
+ }
482
+ if (options.checkEntrypoint !== false && entrypoint && fs.existsSync(entrypoint) && !diagnostics.includes("entrypoint_outside_snapshot")) {
483
+ const health = spawnSync(process.execPath, [entrypoint, "--version", "--json"], {
484
+ cwd: snapshotRoot,
485
+ env: {
486
+ ...process.env,
487
+ DD_FLOW_ENGINE_MODE: "1",
488
+ DD_FLOW_ENGINE_HOME: snapshotRoot,
489
+ DD_FLOW_ENGINE_HEALTHCHECK: "1"
490
+ },
491
+ encoding: "utf8",
492
+ stdio: ["ignore", "pipe", "pipe"],
493
+ timeout: 15_000
494
+ });
495
+ if (health.status !== 0)
496
+ diagnostics.push("entrypoint_dependency_load_failed");
497
+ }
458
498
  return diagnostics;
459
499
  }
460
500
  function manifestSummary(manifest) {
@@ -592,10 +632,17 @@ function findPackageRoot(start) {
592
632
  current = parent;
593
633
  }
594
634
  }
595
- function fileListChecksum(root) {
635
+ export function engineSnapshotChecksum(root) {
596
636
  const files = [];
597
637
  collectFiles(root, root, files);
598
- return Buffer.from(files.sort().join("\n")).toString("base64url");
638
+ const checksum = crypto.createHash("sha256");
639
+ for (const file of files.sort()) {
640
+ checksum.update(file);
641
+ checksum.update("\0");
642
+ checksum.update(fs.readFileSync(path.join(root, file)));
643
+ checksum.update("\0");
644
+ }
645
+ return checksum.digest("hex");
599
646
  }
600
647
  function collectFiles(root, current, files) {
601
648
  for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
@@ -608,6 +655,25 @@ function collectFiles(root, current, files) {
608
655
  files.push(path.relative(root, full));
609
656
  }
610
657
  }
658
+ function activateEngineAtomically(staging, target) {
659
+ const previous = `${target}.previous-${process.pid}-${Date.now()}`;
660
+ const hadPrevious = fs.existsSync(target);
661
+ if (hadPrevious)
662
+ fs.renameSync(target, previous);
663
+ try {
664
+ fs.renameSync(staging, target);
665
+ }
666
+ catch (error) {
667
+ if (hadPrevious && !fs.existsSync(target) && fs.existsSync(previous))
668
+ fs.renameSync(previous, target);
669
+ throw new AppError("engine_install_failed", "Atomic engine activation failed; previous engine was preserved", 1, {
670
+ target,
671
+ cause: String(error)
672
+ });
673
+ }
674
+ if (hadPrevious)
675
+ fs.rmSync(previous, { recursive: true, force: true });
676
+ }
611
677
  function stringValue(value) {
612
678
  return typeof value === "string" && value.length > 0 ? value : null;
613
679
  }
@@ -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",
@@ -266,49 +265,58 @@ 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
+ if (!flowPayload)
281
+ return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
282
+ const project = projectForHook(context, input.projectRoot, stringValue(payload.cwd));
283
+ if (!project)
284
+ return { ok: true, observed: false, reason: "unrelated_cwd" };
276
285
  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 })
291
- : 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, {
286
+ const observedSession = bindObservedFlowSession(context, project, flowPayload, sessionId ?? flowPayload.session_id ?? undefined);
287
+ const effectiveSessionId = observedSession?.session_id ?? sessionId ?? null;
288
+ const protocolId = observedSession?.protocol_id ?? binding?.protocol_id ?? null;
289
+ const eventKey = hookEventKey(payload, eventName, toolName, command);
290
+ const inserted = recordHookEvent(context, {
296
291
  projectId: project.id,
297
292
  protocolId,
298
- sessionId: sessionId ?? null,
293
+ sessionId: effectiveSessionId,
299
294
  turnId: turnId ?? null,
300
295
  eventName,
301
296
  toolName: toolName ?? null,
302
297
  status: "observed",
303
- payload
298
+ payload,
299
+ eventKey
304
300
  });
305
- if (eventName === "Stop") {
306
- return stopDecision(context, project, sessionId ?? null);
307
- }
308
- if (preToolUseDecision) {
309
- return preToolUseDecision;
301
+ if (effectiveSessionId) {
302
+ recordFlowSessionObservation(context, {
303
+ projectId: project.id,
304
+ sessionId: effectiveSessionId,
305
+ runId: observedSession?.run_id ?? null,
306
+ protocolId,
307
+ cwd: stringValue(payload.cwd) ?? null,
308
+ toolName: toolName ?? null,
309
+ eventKey
310
+ });
310
311
  }
311
- return hookContinue(eventName, protocolId);
312
+ return {
313
+ ok: true,
314
+ observed: inserted,
315
+ duplicate: !inserted,
316
+ event_key: eventKey,
317
+ session_id: effectiveSessionId,
318
+ protocol_id: protocolId
319
+ };
312
320
  }
313
321
  export function hookStatusForProject(context, projectId) {
314
322
  return context.db.all(`SELECT scope, config_path, content_hash, installed, drift_status, updated_at
@@ -438,13 +446,10 @@ function sharedEntryStatus(sourceHome, targetHome) {
438
446
  };
439
447
  });
440
448
  }
441
- function expectedHooksConfig(projectRoot) {
449
+ function expectedHooksConfig(_projectRoot) {
442
450
  return {
443
451
  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")] }]
452
+ PreToolUse: [hookEntry("Bash", hookCommand("PreToolUse"), "dd-flow runtime observation")]
448
453
  }
449
454
  };
450
455
  }
@@ -454,8 +459,8 @@ function hookEntry(matcher, command, statusMessage) {
454
459
  function commandHook(command, statusMessage) {
455
460
  return { type: "command", command, timeout: 5, statusMessage };
456
461
  }
457
- function hookCommand(event, projectRoot) {
458
- return `dd-flow codex hook handle --event ${event} --project-root ${JSON.stringify(projectRoot)} --json`;
462
+ function hookCommand(event) {
463
+ return `dd-flow codex hook handle --event ${event} --json`;
459
464
  }
460
465
  function resolveHookTarget(target) {
461
466
  if (!target || target === "isolated") {
@@ -493,7 +498,7 @@ function requireHookLocation(context, project, target, profile) {
493
498
  }
494
499
  function hookFileStatus(hooksPath, projectRoot) {
495
500
  const existing = readJsonIfExists(hooksPath);
496
- const installed = existing ? hasExpectedHooks(existing, projectRoot) : false;
501
+ const installed = existing ? hasExpectedHooks(existing) : false;
497
502
  const managed = existing ? hasManagedHooks(existing, projectRoot) : false;
498
503
  return {
499
504
  installed,
@@ -537,20 +542,20 @@ function removeManagedHooks(existing, projectRoot) {
537
542
  }
538
543
  return { ...existing, hooks: nextHooks };
539
544
  }
540
- function hasExpectedHooks(existing, projectRoot) {
545
+ function hasExpectedHooks(existing) {
541
546
  const commands = collectHookCommands(existing);
542
- return expectedCommands(projectRoot).every((command) => commands.includes(command));
547
+ return expectedCommands().every((command) => commands.includes(command));
543
548
  }
544
549
  function hasManagedHooks(existing, projectRoot) {
545
550
  return collectHookCommands(existing).some((command) => command.includes("dd-flow codex hook handle") &&
546
- (command.includes(`--project-root ${JSON.stringify(projectRoot)}`) || command.includes(projectRoot)));
551
+ (command.includes("--event PreToolUse") || command.includes(`--project-root ${JSON.stringify(projectRoot)}`) || command.includes(projectRoot)));
547
552
  }
548
- function expectedCommands(projectRoot) {
549
- return ["SessionStart", "PreToolUse", "PostToolUse", "Stop"].map((event) => hookCommand(event, projectRoot));
553
+ function expectedCommands() {
554
+ return [hookCommand("PreToolUse")];
550
555
  }
551
556
  function entryHasManagedCommand(entry, projectRoot) {
552
557
  return collectHookCommands(entry).some((command) => command.includes("dd-flow codex hook handle") &&
553
- (command.includes(`--project-root ${JSON.stringify(projectRoot)}`) || command.includes(projectRoot)));
558
+ (command.includes("--event PreToolUse") || command.includes(`--project-root ${JSON.stringify(projectRoot)}`) || command.includes(projectRoot)));
554
559
  }
555
560
  function collectHookCommands(value) {
556
561
  if (Array.isArray(value)) {
@@ -645,213 +650,54 @@ function upsertSessionBindingFromPayload(context, project, sessionId, payload) {
645
650
  updated_at = excluded.updated_at`, [sessionId, project.id, cwd, transcriptPath, now, now]);
646
651
  return sessionBinding(context, project.id, sessionId);
647
652
  }
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
653
  function sessionBinding(context, projectId, sessionId) {
673
654
  return context.db.get("SELECT * FROM codex_session_bindings WHERE project_id = ? AND session_id = ?", [projectId, sessionId]);
674
655
  }
675
656
  function recordHookEvent(context, input) {
657
+ const existing = context.db.get("SELECT id FROM codex_hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
658
+ if (existing)
659
+ return false;
676
660
  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 (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
661
+ (project_id, protocol_id, session_id, turn_id, event_key, event_name, tool_name, status, sanitized_summary, created_at)
662
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
679
663
  input.projectId,
680
664
  input.protocolId,
681
665
  input.sessionId,
682
666
  input.turnId,
667
+ input.eventKey,
683
668
  input.eventName,
684
669
  input.toolName,
685
670
  input.status,
686
671
  sanitizedSummary(input.payload),
687
672
  context.now()
688
673
  ]);
674
+ return true;
689
675
  }
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
- }
676
+ function projectForHook(context, explicitRoot, cwd) {
677
+ if (explicitRoot)
678
+ return requireProjectByRoot(context, resolveProjectRoot(explicitRoot));
679
+ if (!cwd)
746
680
  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 {};
681
+ const candidate = fs.existsSync(cwd) ? fs.realpathSync(cwd) : path.resolve(cwd);
682
+ const projects = context.db.all("SELECT * FROM projects WHERE status = 'active'");
683
+ return projects
684
+ .filter((project) => {
685
+ const relative = path.relative(project.root, candidate);
686
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
687
+ })
688
+ .sort((left, right) => right.root.length - left.root.length)[0];
689
+ }
690
+ function hookEventKey(payload, eventName, toolName, command) {
691
+ const explicit = stringValue(payload.event_id) ?? stringValue(payload.delivery_id) ?? stringValue(payload.id);
692
+ if (explicit)
693
+ return explicit;
694
+ return crypto.createHash("sha256").update(JSON.stringify({
695
+ event: eventName,
696
+ session: stringValue(payload.session_id),
697
+ turn: stringValue(payload.turn_id),
698
+ tool: toolName ?? null,
699
+ command
700
+ })).digest("hex");
855
701
  }
856
702
  function commandFromPayload(payload) {
857
703
  const direct = stringValue(payload.command);
@@ -865,18 +711,6 @@ function toolNameFromPayload(payload) {
865
711
  const toolInput = objectRecord(payload.tool_input);
866
712
  return stringValue(toolInput.name);
867
713
  }
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
714
  function sanitizedSummary(payload) {
881
715
  const summary = JSON.stringify(sanitizeValue(payload));
882
716
  return summary.length > maxSanitizedSummaryLength ? `${summary.slice(0, maxSanitizedSummaryLength)}...` : summary;