@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.
- package/CHANGELOG.md +27 -4
- package/dist/build-info.json +6 -6
- package/dist/cli/help.js +14 -3
- package/dist/cli/run-cli.js +32 -16
- package/dist/domain/flow-contract.js +81 -2
- package/dist/domain/validation.js +56 -28
- package/dist/protocol/local-files.js +1 -16
- package/dist/schemas/code-stage-report.schema.json +2 -2
- package/dist/schemas/flow-contract.schema.json +150 -94
- package/dist/schemas/flow-run.schema.json +129 -23
- package/dist/schemas/mb-upgrade-review-data.schema.json +2 -2
- package/dist/schemas/memorybank-permissions-preflight.schema.json +13 -73
- package/dist/schemas/merge-stage-report.schema.json +2 -2
- package/dist/schemas/plan-stage-report.schema.json +38 -335
- package/dist/schemas/project-flow-pack-manifest.schema.json +4 -4
- package/dist/schemas/protocol-plan.schema.json +197 -0
- package/dist/schemas/release-impact.schema.json +9 -5
- package/dist/schemas/session-usage.schema.json +16 -0
- package/dist/schemas/stage-finish-input.schema.json +20 -0
- package/dist/schemas/stage-prompt.schema.json +31 -0
- package/dist/schemas/stage-report.schema.json +20 -0
- package/dist/schemas/stage-start-response.schema.json +29 -0
- package/dist/schemas/timeline-event.schema.json +29 -0
- package/dist/schemas/worktrunk-workspace.schema.json +19 -0
- package/dist/services/branch-context.js +9 -4
- package/dist/services/dashboard.js +51 -26
- package/dist/services/engines.js +84 -18
- package/dist/services/hooks.js +80 -246
- package/dist/services/memory-permissions.js +77 -69
- package/dist/services/plan-runtime.js +124 -0
- package/dist/services/plans.js +22 -84
- package/dist/services/projects.js +2 -1
- package/dist/services/prompts.js +26 -21
- package/dist/services/protocols.js +29 -25
- package/dist/services/run-projection.js +77 -11
- package/dist/services/runs.js +95 -61
- package/dist/services/schema-validation.js +168 -7
- package/dist/services/sessions.js +132 -68
- package/dist/services/stage-lifecycle.js +572 -0
- package/dist/services/tooling.js +285 -0
- package/dist/services/usage.js +183 -30
- package/dist/services/version-status.js +1 -1
- package/dist/services/worktrees.js +88 -39
- package/dist/storage/database.js +72 -30
- package/dist/storage/paths.js +0 -9
- package/package.json +14 -13
- package/tools/worktrunk-manifest.json +34 -0
- package/dist/schemas/flow-run-index-v3.schema.json +0 -203
- package/dist/schemas/flow-run-index.schema.json +0 -175
package/dist/services/engines.js
CHANGED
|
@@ -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
|
-
|
|
68
|
-
|
|
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) =>
|
|
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:
|
|
279
|
-
mode: "
|
|
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
|
|
635
|
+
export function engineSnapshotChecksum(root) {
|
|
596
636
|
const files = [];
|
|
597
637
|
collectFiles(root, root, files);
|
|
598
|
-
|
|
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
|
}
|
package/dist/services/hooks.js
CHANGED
|
@@ -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,
|
|
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
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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:
|
|
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 (
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
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(
|
|
449
|
+
function expectedHooksConfig(_projectRoot) {
|
|
442
450
|
return {
|
|
443
451
|
hooks: {
|
|
444
|
-
|
|
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
|
|
458
|
-
return `dd-flow codex hook handle --event ${event} --
|
|
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
|
|
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
|
|
545
|
+
function hasExpectedHooks(existing) {
|
|
541
546
|
const commands = collectHookCommands(existing);
|
|
542
|
-
return expectedCommands(
|
|
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(
|
|
549
|
-
return ["
|
|
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
|
|
691
|
-
if (
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
const
|
|
758
|
-
if (
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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;
|