@deksden-com/dd-flow-cli 0.1.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 (35) hide show
  1. package/README.md +274 -0
  2. package/dist/cli/help.js +308 -0
  3. package/dist/cli/run-cli.js +945 -0
  4. package/dist/cli.js +4 -0
  5. package/dist/domain/contracts.js +57 -0
  6. package/dist/domain/entity-ids.js +47 -0
  7. package/dist/domain/flow-contract.js +233 -0
  8. package/dist/domain/validation.js +91 -0
  9. package/dist/protocol/local-files.js +141 -0
  10. package/dist/runtime/context.js +11 -0
  11. package/dist/schemas/code-stage-report.schema.json +181 -0
  12. package/dist/schemas/flow-run-index.schema.json +129 -0
  13. package/dist/schemas/mb-upgrade-review-data.schema.json +813 -0
  14. package/dist/schemas/memorybank-permissions-preflight.schema.json +154 -0
  15. package/dist/schemas/merge-stage-report.schema.json +135 -0
  16. package/dist/services/audit.js +19 -0
  17. package/dist/services/cleanup.js +310 -0
  18. package/dist/services/config.js +143 -0
  19. package/dist/services/dashboard.js +436 -0
  20. package/dist/services/hooks.js +929 -0
  21. package/dist/services/lanes.js +327 -0
  22. package/dist/services/memory-permissions.js +344 -0
  23. package/dist/services/merge-queue.js +333 -0
  24. package/dist/services/plans.js +149 -0
  25. package/dist/services/projects.js +286 -0
  26. package/dist/services/protocols.js +606 -0
  27. package/dist/services/runs.js +359 -0
  28. package/dist/services/schema-validation.js +185 -0
  29. package/dist/services/sessions.js +365 -0
  30. package/dist/services/worktrees.js +204 -0
  31. package/dist/shared/errors.js +14 -0
  32. package/dist/shared/json.js +17 -0
  33. package/dist/storage/database.js +325 -0
  34. package/dist/storage/paths.js +56 -0
  35. package/package.json +44 -0
@@ -0,0 +1,929 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { parse, stringify } from "smol-toml";
6
+ import { AppError } from "../shared/errors.js";
7
+ import { parseJsonObject } from "../shared/json.js";
8
+ import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
9
+ import { appendAudit } from "./audit.js";
10
+ import { requireProjectByRoot } from "./projects.js";
11
+ import { activeFlowSessionsForProject, confirmPendingFlowSessionBinding, flowSessionById, flowSessionPayloadFromRegisterCommand, recordPendingFlowSessionBinding, updateFlowSessionContinuation } from "./sessions.js";
12
+ const defaultProfileName = "default";
13
+ const maxSanitizedSummaryLength = 1600;
14
+ const stopLoopLimit = 3;
15
+ const sharedEntries = [
16
+ "auth.json",
17
+ "auth.bk",
18
+ "sessions",
19
+ "archived_sessions",
20
+ "session_index.jsonl",
21
+ "history.jsonl",
22
+ "plugins",
23
+ "skills",
24
+ "cache",
25
+ "models_cache.json",
26
+ "prompts",
27
+ "rules",
28
+ "vendor_imports",
29
+ "AGENTS.md",
30
+ "installation_id",
31
+ "keybindings.json",
32
+ "browser",
33
+ "computer-use",
34
+ "generated_images",
35
+ "shell_snapshots",
36
+ "memories"
37
+ ];
38
+ export function planCodexHome(context, input) {
39
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
40
+ const profile = resolveProfileName(input.profile);
41
+ const paths = resolveHomePaths(context, project, profile, input);
42
+ return {
43
+ ok: true,
44
+ project_root: project.root,
45
+ profile,
46
+ profile_id: profileId(project.root, profile),
47
+ source_home: paths.sourceHome,
48
+ target_home: paths.targetHome,
49
+ config_path: paths.configPath,
50
+ hooks_path: paths.hooksPath,
51
+ owned_files: ["config.toml", "hooks.json", ".dd-flow-home.json", "log/", ".tmp/", "tmp/"],
52
+ shared_entries: sharedEntries,
53
+ exists: fs.existsSync(paths.targetHome),
54
+ codex_env: { CODEX_HOME: paths.targetHome }
55
+ };
56
+ }
57
+ export function initCodexHome(context, input) {
58
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
59
+ const profile = resolveProfileName(input.profile);
60
+ const paths = resolveHomePaths(context, project, profile, input);
61
+ const now = context.now();
62
+ ensureDir(paths.targetHome);
63
+ ensureDir(path.join(paths.targetHome, "log"));
64
+ ensureDir(path.join(paths.targetHome, ".tmp"));
65
+ ensureDir(path.join(paths.targetHome, "tmp"));
66
+ const linked = createSharedLinks(paths.sourceHome, paths.targetHome);
67
+ const config = readTomlIfExists(path.join(paths.sourceHome, "config.toml"));
68
+ fs.writeFileSync(paths.configPath, stringify(withManagedConfig(config, paths.sourceHome, paths.targetHome)));
69
+ writeJsonFile(paths.hooksPath, expectedHooksConfig(project.root));
70
+ writeJsonFile(path.join(paths.targetHome, ".dd-flow-home.json"), {
71
+ managed_by: "dd-flow-cli",
72
+ schema_version: "0.1.0",
73
+ project_id: project.id,
74
+ project_root: project.root,
75
+ profile,
76
+ source_home: paths.sourceHome,
77
+ target_home: paths.targetHome,
78
+ created_at: now
79
+ });
80
+ upsertHomeProfile(context, project, profile, paths, "ready", "none");
81
+ appendAudit(context, {
82
+ projectId: project.id,
83
+ eventType: "codex_home.initialized",
84
+ payload: {
85
+ project_id: project.id,
86
+ profile,
87
+ source_home: paths.sourceHome,
88
+ target_home: paths.targetHome,
89
+ linked_entries: linked.linked,
90
+ skipped_entries: linked.skipped
91
+ }
92
+ });
93
+ return {
94
+ ok: true,
95
+ profile: homeProfile(context, project.id, profile),
96
+ linked_entries: linked.linked,
97
+ skipped_entries: linked.skipped,
98
+ codex_env: { CODEX_HOME: paths.targetHome }
99
+ };
100
+ }
101
+ export function getCodexHomeStatus(context, input) {
102
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
103
+ const profile = resolveProfileName(input.profile);
104
+ const record = homeProfile(context, project.id, profile);
105
+ if (!record) {
106
+ return { ok: true, installed: false, profile, status: "not_initialized" };
107
+ }
108
+ const configStatus = managedConfigStatus(record);
109
+ const hooksStatus = hookFileStatus(record.hooks_path, project.root);
110
+ const drift = configStatus.drift_status === "none" && hooksStatus.drift_status === "none" ? "none" : "drifted";
111
+ const now = context.now();
112
+ context.db.run(`UPDATE codex_home_profiles
113
+ SET last_checked_at = ?, last_drift_status = ?, updated_at = ?
114
+ WHERE project_id = ? AND profile = ?`, [now, drift, now, project.id, profile]);
115
+ return {
116
+ ok: true,
117
+ installed: fs.existsSync(record.target_home),
118
+ profile,
119
+ home: homeProfile(context, project.id, profile),
120
+ config: configStatus,
121
+ hooks: hooksStatus,
122
+ shared_entries: sharedEntryStatus(record.source_home, record.target_home)
123
+ };
124
+ }
125
+ export function printCodexHomeEnv(context, input) {
126
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
127
+ const profile = resolveProfileName(input.profile);
128
+ const record = requireHomeProfile(context, project.id, profile);
129
+ if (!fs.existsSync(record.target_home) || !fs.statSync(record.target_home).isDirectory()) {
130
+ throw new AppError("codex_home_unavailable", "Managed Codex home target does not exist", 1, {
131
+ target_home: record.target_home,
132
+ next_action: "Run dd-flow codex home init first."
133
+ });
134
+ }
135
+ return {
136
+ ok: true,
137
+ profile,
138
+ env: { CODEX_HOME: record.target_home },
139
+ command_prefix: `CODEX_HOME=${JSON.stringify(record.target_home)}`
140
+ };
141
+ }
142
+ export function removeCodexHome(context, input) {
143
+ if (!["keep-shared", "remove-owned"].includes(input.mode)) {
144
+ throw new AppError("validation", "--mode must be keep-shared or remove-owned", 2);
145
+ }
146
+ const mode = input.mode;
147
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
148
+ const profile = resolveProfileName(input.profile);
149
+ const record = homeProfile(context, project.id, profile);
150
+ if (!record) {
151
+ return { ok: true, removed: false, profile, status: "not_initialized" };
152
+ }
153
+ const removedEntries = [];
154
+ for (const relative of ["config.toml", "hooks.json", ".dd-flow-home.json", "log", ".tmp", "tmp"]) {
155
+ const target = path.join(record.target_home, relative);
156
+ if (fs.existsSync(target)) {
157
+ fs.rmSync(target, { recursive: true, force: true });
158
+ removedEntries.push(relative);
159
+ }
160
+ }
161
+ if (mode === "remove-owned") {
162
+ removeSharedSymlinks(record.target_home);
163
+ removeDirIfEmpty(record.target_home);
164
+ }
165
+ context.db.run(`UPDATE codex_home_profiles SET status = 'removed', updated_at = ? WHERE project_id = ? AND profile = ?`, [context.now(), project.id, profile]);
166
+ appendAudit(context, {
167
+ projectId: project.id,
168
+ eventType: "codex_home.removed",
169
+ payload: { project_id: project.id, profile, mode, removed: removedEntries }
170
+ });
171
+ return { ok: true, removed: true, profile, mode, removed_entries: removedEntries };
172
+ }
173
+ export function printCodexHooks(context, input) {
174
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
175
+ const target = resolveHookTarget(input.target);
176
+ const profile = resolveProfileName(input.profile);
177
+ return {
178
+ ok: true,
179
+ project_root: project.root,
180
+ target,
181
+ profile: target === "isolated" ? profile : null,
182
+ hooks_json: expectedHooksConfig(project.root)
183
+ };
184
+ }
185
+ export function getCodexHooksStatus(context, input) {
186
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
187
+ const target = resolveHookTarget(input.target);
188
+ const location = resolveHookLocation(context, project, target, resolveProfileName(input.profile));
189
+ if (!location.available) {
190
+ return {
191
+ ok: true,
192
+ installed: false,
193
+ target,
194
+ profile: location.profile,
195
+ drift_status: "unavailable",
196
+ unavailable: location.unavailable
197
+ };
198
+ }
199
+ const status = hookFileStatus(location.hooksPath, project.root);
200
+ const recorded = hookInstallation(context, project.id, installationScope(target, location.profile));
201
+ const driftStatus = status.installed ? "none" : recorded?.installed === 1 ? "drifted" : status.drift_status;
202
+ return {
203
+ ok: true,
204
+ target,
205
+ profile: location.profile,
206
+ hooks_path: location.hooksPath,
207
+ installed: status.installed,
208
+ drift_status: driftStatus,
209
+ expected_hash: hashObject(expectedHooksConfig(project.root)),
210
+ recorded
211
+ };
212
+ }
213
+ export function installCodexHooks(context, input) {
214
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
215
+ const target = resolveHookTarget(input.target);
216
+ const location = requireHookLocation(context, project, target, resolveProfileName(input.profile));
217
+ if (target === "default" && !input.yes) {
218
+ throw new AppError("confirmation_required", "Direct default Codex home mutation requires --yes", 1, {
219
+ target,
220
+ hooks_path: location.hooksPath
221
+ });
222
+ }
223
+ const existing = readJsonIfExists(location.hooksPath) ?? {};
224
+ const backup = target === "default" && fs.existsSync(location.hooksPath) ? backupFile(location.hooksPath, context.now()) : null;
225
+ const nextConfig = mergeHooksConfig(existing, project.root);
226
+ ensureDir(path.dirname(location.hooksPath));
227
+ writeJsonFile(location.hooksPath, nextConfig);
228
+ recordHookInstallation(context, project.id, installationScope(target, location.profile), location.hooksPath, true, "none");
229
+ appendAudit(context, {
230
+ projectId: project.id,
231
+ eventType: "codex_hooks.installed",
232
+ payload: { project_id: project.id, target, profile: location.profile, hooks_path: location.hooksPath, backup }
233
+ });
234
+ return {
235
+ ok: true,
236
+ installed: true,
237
+ target,
238
+ profile: location.profile,
239
+ hooks_path: location.hooksPath,
240
+ backup,
241
+ expected_hash: hashObject(expectedHooksConfig(project.root))
242
+ };
243
+ }
244
+ export function removeCodexHooks(context, input) {
245
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
246
+ const target = resolveHookTarget(input.target);
247
+ const location = requireHookLocation(context, project, target, resolveProfileName(input.profile));
248
+ if (target === "default" && !input.yes) {
249
+ throw new AppError("confirmation_required", "Direct default Codex home mutation requires --yes", 1, {
250
+ target,
251
+ hooks_path: location.hooksPath
252
+ });
253
+ }
254
+ const existing = readJsonIfExists(location.hooksPath) ?? {};
255
+ const hadManaged = hasManagedHooks(existing, project.root);
256
+ const backup = target === "default" && fs.existsSync(location.hooksPath) ? backupFile(location.hooksPath, context.now()) : null;
257
+ const nextConfig = removeManagedHooks(existing, project.root);
258
+ ensureDir(path.dirname(location.hooksPath));
259
+ writeJsonFile(location.hooksPath, nextConfig);
260
+ recordHookInstallation(context, project.id, installationScope(target, location.profile), location.hooksPath, false, "not_installed");
261
+ appendAudit(context, {
262
+ projectId: project.id,
263
+ eventType: "codex_hooks.removed",
264
+ payload: { project_id: project.id, target, profile: location.profile, hooks_path: location.hooksPath, backup, had_managed: hadManaged }
265
+ });
266
+ return { ok: true, installed: false, target, profile: location.profile, hooks_path: location.hooksPath, backup, had_managed: hadManaged };
267
+ }
268
+ export function handleCodexHook(context, input) {
269
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
270
+ const payload = parseJsonObject(input.stdin || "{}", "codex hook stdin");
271
+ const eventName = input.event || stringValue(payload.hook_event_name) || "Unknown";
272
+ const sessionId = stringValue(payload.session_id);
273
+ const turnId = stringValue(payload.turn_id);
274
+ const toolName = stringValue(payload.tool_name) ?? toolNameFromPayload(payload);
275
+ const command = commandFromPayload(payload);
276
+ 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, {
296
+ projectId: project.id,
297
+ protocolId,
298
+ sessionId: sessionId ?? null,
299
+ turnId: turnId ?? null,
300
+ eventName,
301
+ toolName: toolName ?? null,
302
+ status: "observed",
303
+ payload
304
+ });
305
+ if (eventName === "Stop") {
306
+ return stopDecision(context, project, sessionId ?? null);
307
+ }
308
+ if (preToolUseDecision) {
309
+ return preToolUseDecision;
310
+ }
311
+ return hookContinue(eventName, protocolId);
312
+ }
313
+ export function hookStatusForProject(context, projectId) {
314
+ return context.db.all(`SELECT scope, config_path, content_hash, installed, drift_status, updated_at
315
+ FROM hook_installations WHERE project_id = ? ORDER BY updated_at DESC`, [projectId]);
316
+ }
317
+ export function codexHomeProfilesForProject(context, projectId) {
318
+ return context.db.all(`SELECT id, profile, source_home, target_home, status, last_checked_at, last_drift_status, updated_at
319
+ FROM codex_home_profiles WHERE project_id = ? ORDER BY updated_at DESC`, [projectId]);
320
+ }
321
+ export function activeCodexSessionBindingsForProject(context, projectId) {
322
+ return context.db.all(`SELECT session_id, protocol_id, handshake_id, cwd, transcript_path, status, continuation_count, updated_at
323
+ FROM codex_session_bindings WHERE project_id = ? AND status = 'active' ORDER BY updated_at DESC`, [projectId]);
324
+ }
325
+ export function activeFlowSessionBindingsForProject(context, projectId) {
326
+ return activeFlowSessionsForProject(context, projectId).map((session) => ({
327
+ session_id: session.session_id,
328
+ flow_kind: session.flow_kind,
329
+ status: session.status,
330
+ protocol_id: session.protocol_id,
331
+ worker_id: session.worker_id,
332
+ workspace_path: session.workspace_path,
333
+ continuation_policy: session.continuation_policy,
334
+ current_stage: session.current_stage,
335
+ next_action: session.next_action,
336
+ continuation_count: session.continuation_count,
337
+ updated_at: session.updated_at
338
+ }));
339
+ }
340
+ export function codexHookEventsForProject(context, projectId) {
341
+ return context.db.all(`SELECT protocol_id, session_id, turn_id, event_name, tool_name, status, sanitized_summary, created_at
342
+ FROM codex_hook_events WHERE project_id = ? ORDER BY created_at DESC, id DESC LIMIT 20`, [projectId]);
343
+ }
344
+ function resolveProfileName(profile) {
345
+ return sanitizeProfile(profile ?? defaultProfileName);
346
+ }
347
+ function sanitizeProfile(value) {
348
+ const normalized = value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
349
+ return normalized || defaultProfileName;
350
+ }
351
+ function profileId(projectRoot, profile) {
352
+ const projectName = sanitizeProfile(path.basename(projectRoot));
353
+ return `${projectName}-${profile}-${hashString(projectRoot).slice(0, 10)}`;
354
+ }
355
+ function resolveHomePaths(context, project, profile, input) {
356
+ const sourceHome = path.resolve(input.sourceHome ?? context.env.CODEX_HOME ?? path.join(os.homedir(), ".codex"));
357
+ const targetHome = path.resolve(input.targetHome ?? path.join(context.ddFlowHome, "codex-homes", profileId(project.root, profile)));
358
+ return {
359
+ sourceHome,
360
+ targetHome,
361
+ configPath: path.join(targetHome, "config.toml"),
362
+ hooksPath: path.join(targetHome, "hooks.json")
363
+ };
364
+ }
365
+ function defaultCodexHome(context) {
366
+ return path.resolve(context.env.CODEX_HOME ?? path.join(os.homedir(), ".codex"));
367
+ }
368
+ function readTomlIfExists(file) {
369
+ if (!fs.existsSync(file)) {
370
+ return {};
371
+ }
372
+ try {
373
+ return parse(fs.readFileSync(file, "utf8"));
374
+ }
375
+ catch (error) {
376
+ throw new AppError("validation", `Invalid TOML in ${file}: ${String(error)}`, 2);
377
+ }
378
+ }
379
+ function withManagedConfig(config, sourceHome, targetHome) {
380
+ const next = { ...config };
381
+ const features = objectTable(next.features);
382
+ features.hooks = true;
383
+ next.features = features;
384
+ next.sqlite_home = sourceHome;
385
+ next.log_dir = path.join(targetHome, "log");
386
+ delete next.hooks;
387
+ return next;
388
+ }
389
+ function objectTable(value) {
390
+ return value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {};
391
+ }
392
+ function createSharedLinks(sourceHome, targetHome) {
393
+ const linked = [];
394
+ const skipped = [];
395
+ for (const entry of sharedEntries) {
396
+ const source = path.join(sourceHome, entry);
397
+ const target = path.join(targetHome, entry);
398
+ if (!fs.existsSync(source)) {
399
+ skipped.push({ entry, reason: "source_missing" });
400
+ continue;
401
+ }
402
+ if (fs.existsSync(target)) {
403
+ if (fs.lstatSync(target).isSymbolicLink() && fs.readlinkSync(target) === source) {
404
+ linked.push(entry);
405
+ }
406
+ else {
407
+ skipped.push({ entry, reason: "target_exists" });
408
+ }
409
+ continue;
410
+ }
411
+ ensureDir(path.dirname(target));
412
+ fs.symlinkSync(source, target);
413
+ linked.push(entry);
414
+ }
415
+ return { linked, skipped };
416
+ }
417
+ function removeSharedSymlinks(targetHome) {
418
+ for (const entry of sharedEntries) {
419
+ const target = path.join(targetHome, entry);
420
+ if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink()) {
421
+ fs.rmSync(target);
422
+ }
423
+ }
424
+ }
425
+ function removeDirIfEmpty(dir) {
426
+ if (fs.existsSync(dir) && fs.statSync(dir).isDirectory() && fs.readdirSync(dir).length === 0) {
427
+ fs.rmdirSync(dir);
428
+ }
429
+ }
430
+ function sharedEntryStatus(sourceHome, targetHome) {
431
+ return sharedEntries.map((entry) => {
432
+ const source = path.join(sourceHome, entry);
433
+ const target = path.join(targetHome, entry);
434
+ return {
435
+ entry,
436
+ source_exists: fs.existsSync(source),
437
+ linked: fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink()
438
+ };
439
+ });
440
+ }
441
+ function expectedHooksConfig(projectRoot) {
442
+ return {
443
+ 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")] }]
448
+ }
449
+ };
450
+ }
451
+ function hookEntry(matcher, command, statusMessage) {
452
+ return { matcher, hooks: [commandHook(command, statusMessage)] };
453
+ }
454
+ function commandHook(command, statusMessage) {
455
+ return { type: "command", command, timeout: 5, statusMessage };
456
+ }
457
+ function hookCommand(event, projectRoot) {
458
+ return `dd-flow codex hook handle --event ${event} --project-root ${JSON.stringify(projectRoot)} --json`;
459
+ }
460
+ function resolveHookTarget(target) {
461
+ if (!target || target === "isolated") {
462
+ return "isolated";
463
+ }
464
+ if (target === "default") {
465
+ return "default";
466
+ }
467
+ throw new AppError("validation", "--target must be isolated or default", 2);
468
+ }
469
+ function resolveHookLocation(context, project, target, profile) {
470
+ if (target === "default") {
471
+ return { available: true, target, profile, hooksPath: path.join(defaultCodexHome(context), "hooks.json") };
472
+ }
473
+ const record = homeProfile(context, project.id, profile);
474
+ if (!record || !fs.existsSync(record.target_home)) {
475
+ return {
476
+ available: false,
477
+ target,
478
+ profile,
479
+ unavailable: {
480
+ code: "codex_home_unavailable",
481
+ next_action: "Run dd-flow codex home init before isolated hook installation."
482
+ }
483
+ };
484
+ }
485
+ return { available: true, target, profile, hooksPath: record.hooks_path };
486
+ }
487
+ function requireHookLocation(context, project, target, profile) {
488
+ const location = resolveHookLocation(context, project, target, profile);
489
+ if (!location.available) {
490
+ throw new AppError(String(location.unavailable.code), "Codex hook target is unavailable", 1, location.unavailable);
491
+ }
492
+ return location;
493
+ }
494
+ function hookFileStatus(hooksPath, projectRoot) {
495
+ const existing = readJsonIfExists(hooksPath);
496
+ const installed = existing ? hasExpectedHooks(existing, projectRoot) : false;
497
+ const managed = existing ? hasManagedHooks(existing, projectRoot) : false;
498
+ return {
499
+ installed,
500
+ drift_status: installed ? "none" : managed ? "drifted" : "not_installed",
501
+ hooks_path: hooksPath
502
+ };
503
+ }
504
+ function managedConfigStatus(record) {
505
+ if (!fs.existsSync(record.config_path)) {
506
+ return { parseable: false, hooks_enabled: false, sqlite_home: false, drift_status: "missing" };
507
+ }
508
+ const config = readTomlIfExists(record.config_path);
509
+ const features = objectTable(config.features);
510
+ const hooksEnabled = features.hooks === true;
511
+ const sqliteHome = config.sqlite_home === record.source_home;
512
+ const hasHooksSection = Boolean(config.hooks);
513
+ return {
514
+ parseable: true,
515
+ hooks_enabled: hooksEnabled,
516
+ sqlite_home: sqliteHome,
517
+ drift_status: hooksEnabled && sqliteHome && !hasHooksSection ? "none" : "drifted"
518
+ };
519
+ }
520
+ function mergeHooksConfig(existing, projectRoot) {
521
+ const withoutManaged = removeManagedHooks(existing, projectRoot);
522
+ const hooks = objectRecord(withoutManaged.hooks);
523
+ const expectedHooks = objectRecord(expectedHooksConfig(projectRoot).hooks);
524
+ for (const [event, entries] of Object.entries(expectedHooks)) {
525
+ hooks[event] = [...arrayValue(hooks[event]), ...arrayValue(entries)];
526
+ }
527
+ return { ...withoutManaged, hooks };
528
+ }
529
+ function removeManagedHooks(existing, projectRoot) {
530
+ const hooks = objectRecord(existing.hooks);
531
+ const nextHooks = {};
532
+ for (const [event, entries] of Object.entries(hooks)) {
533
+ const filtered = arrayValue(entries).filter((entry) => !entryHasManagedCommand(entry, projectRoot));
534
+ if (filtered.length > 0) {
535
+ nextHooks[event] = filtered;
536
+ }
537
+ }
538
+ return { ...existing, hooks: nextHooks };
539
+ }
540
+ function hasExpectedHooks(existing, projectRoot) {
541
+ const commands = collectHookCommands(existing);
542
+ return expectedCommands(projectRoot).every((command) => commands.includes(command));
543
+ }
544
+ function hasManagedHooks(existing, projectRoot) {
545
+ return collectHookCommands(existing).some((command) => command.includes("dd-flow codex hook handle") &&
546
+ (command.includes(`--project-root ${JSON.stringify(projectRoot)}`) || command.includes(projectRoot)));
547
+ }
548
+ function expectedCommands(projectRoot) {
549
+ return ["SessionStart", "PreToolUse", "PostToolUse", "Stop"].map((event) => hookCommand(event, projectRoot));
550
+ }
551
+ function entryHasManagedCommand(entry, projectRoot) {
552
+ return collectHookCommands(entry).some((command) => command.includes("dd-flow codex hook handle") &&
553
+ (command.includes(`--project-root ${JSON.stringify(projectRoot)}`) || command.includes(projectRoot)));
554
+ }
555
+ function collectHookCommands(value) {
556
+ if (Array.isArray(value)) {
557
+ return value.flatMap(collectHookCommands);
558
+ }
559
+ if (value && typeof value === "object") {
560
+ return Object.entries(value).flatMap(([key, child]) => {
561
+ if (key === "command" && typeof child === "string") {
562
+ return [child];
563
+ }
564
+ return collectHookCommands(child);
565
+ });
566
+ }
567
+ return [];
568
+ }
569
+ function objectRecord(value) {
570
+ return value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {};
571
+ }
572
+ function arrayValue(value) {
573
+ return Array.isArray(value) ? value : [];
574
+ }
575
+ function upsertHomeProfile(context, project, profile, paths, status, driftStatus) {
576
+ const now = context.now();
577
+ context.db.run(`INSERT INTO codex_home_profiles
578
+ (id, project_id, profile, source_home, target_home, config_path, hooks_path, status, created_at, updated_at, last_checked_at, last_drift_status)
579
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)
580
+ ON CONFLICT(project_id, profile) DO UPDATE SET
581
+ source_home = excluded.source_home,
582
+ target_home = excluded.target_home,
583
+ config_path = excluded.config_path,
584
+ hooks_path = excluded.hooks_path,
585
+ status = excluded.status,
586
+ updated_at = excluded.updated_at,
587
+ last_drift_status = excluded.last_drift_status`, [
588
+ profileId(project.root, profile),
589
+ project.id,
590
+ profile,
591
+ paths.sourceHome,
592
+ paths.targetHome,
593
+ paths.configPath,
594
+ paths.hooksPath,
595
+ status,
596
+ now,
597
+ now,
598
+ driftStatus
599
+ ]);
600
+ }
601
+ function homeProfile(context, projectId, profile) {
602
+ return context.db.get("SELECT * FROM codex_home_profiles WHERE project_id = ? AND profile = ?", [projectId, profile]);
603
+ }
604
+ function requireHomeProfile(context, projectId, profile) {
605
+ const record = homeProfile(context, projectId, profile);
606
+ if (!record) {
607
+ throw new AppError("codex_home_unavailable", "Managed Codex home profile is not initialized", 1, {
608
+ profile,
609
+ next_action: "Run dd-flow codex home init first."
610
+ });
611
+ }
612
+ return record;
613
+ }
614
+ function hookInstallation(context, projectId, scope) {
615
+ return context.db.get(`SELECT scope, config_path, content_hash, installed, drift_status, updated_at
616
+ FROM hook_installations WHERE project_id = ? AND scope = ?`, [projectId, scope]);
617
+ }
618
+ function recordHookInstallation(context, projectId, scope, configPath, installed, driftStatus) {
619
+ const now = context.now();
620
+ context.db.run(`INSERT INTO hook_installations
621
+ (project_id, scope, config_path, content_hash, installed, drift_status, created_at, updated_at)
622
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
623
+ ON CONFLICT(project_id, scope) DO UPDATE SET
624
+ config_path = excluded.config_path,
625
+ content_hash = excluded.content_hash,
626
+ installed = excluded.installed,
627
+ drift_status = excluded.drift_status,
628
+ updated_at = excluded.updated_at`, [projectId, scope, configPath, hashObject(readJsonIfExists(configPath) ?? {}), installed ? 1 : 0, driftStatus, now, now]);
629
+ }
630
+ function installationScope(target, profile) {
631
+ return target === "isolated" ? `isolated:${profile}` : "default";
632
+ }
633
+ function upsertSessionBindingFromPayload(context, project, sessionId, payload) {
634
+ const existing = sessionBinding(context, project.id, sessionId);
635
+ const now = context.now();
636
+ const cwd = stringValue(payload.cwd) ?? existing?.cwd ?? null;
637
+ const transcriptPath = stringValue(payload.transcript_path) ?? existing?.transcript_path ?? null;
638
+ context.db.run(`INSERT INTO codex_session_bindings
639
+ (session_id, project_id, protocol_id, handshake_id, cwd, transcript_path, status, created_at, updated_at)
640
+ VALUES (?, ?, NULL, NULL, ?, ?, 'active', ?, ?)
641
+ ON CONFLICT(session_id, project_id) DO UPDATE SET
642
+ cwd = COALESCE(excluded.cwd, cwd),
643
+ transcript_path = COALESCE(excluded.transcript_path, transcript_path),
644
+ status = 'active',
645
+ updated_at = excluded.updated_at`, [sessionId, project.id, cwd, transcriptPath, now, now]);
646
+ return sessionBinding(context, project.id, sessionId);
647
+ }
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
+ function sessionBinding(context, projectId, sessionId) {
673
+ return context.db.get("SELECT * FROM codex_session_bindings WHERE project_id = ? AND session_id = ?", [projectId, sessionId]);
674
+ }
675
+ function recordHookEvent(context, input) {
676
+ 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
679
+ input.projectId,
680
+ input.protocolId,
681
+ input.sessionId,
682
+ input.turnId,
683
+ input.eventName,
684
+ input.toolName,
685
+ input.status,
686
+ sanitizedSummary(input.payload),
687
+ context.now()
688
+ ]);
689
+ }
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, 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, 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, protocolId) {
714
+ if (!protocolId) {
715
+ return null;
716
+ }
717
+ const protocol = context.db.get("SELECT id, status, stage, next_action FROM protocols WHERE id = ?", [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, protocolId) {
727
+ const protocol = context.db.get("SELECT status, stage FROM protocols WHERE id = ?", [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
+ }
746
+ 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)\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 {};
855
+ }
856
+ function commandFromPayload(payload) {
857
+ const direct = stringValue(payload.command);
858
+ if (direct) {
859
+ return direct;
860
+ }
861
+ const toolInput = objectRecord(payload.tool_input);
862
+ return stringValue(toolInput.command) ?? stringValue(toolInput.cmd);
863
+ }
864
+ function toolNameFromPayload(payload) {
865
+ const toolInput = objectRecord(payload.tool_input);
866
+ return stringValue(toolInput.name);
867
+ }
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
+ function sanitizedSummary(payload) {
881
+ const summary = JSON.stringify(sanitizeValue(payload));
882
+ return summary.length > maxSanitizedSummaryLength ? `${summary.slice(0, maxSanitizedSummaryLength)}...` : summary;
883
+ }
884
+ function sanitizeValue(value) {
885
+ if (Array.isArray(value)) {
886
+ return value.map(sanitizeValue);
887
+ }
888
+ if (value && typeof value === "object") {
889
+ const next = {};
890
+ for (const [key, child] of Object.entries(value)) {
891
+ next[key] = secretKey(key) ? "<redacted>" : sanitizeValue(child);
892
+ }
893
+ return next;
894
+ }
895
+ if (typeof value === "string") {
896
+ return value
897
+ .replace(/(token|secret|password|api[_-]?key)=\S+/gi, "$1=<redacted>")
898
+ .replace(/Bearer\s+[A-Za-z0-9._-]+/g, "Bearer <redacted>");
899
+ }
900
+ return value;
901
+ }
902
+ function secretKey(key) {
903
+ return /(token|secret|password|api[_-]?key|authorization|auth)/i.test(key);
904
+ }
905
+ function stringValue(value) {
906
+ return typeof value === "string" && value.length > 0 ? value : undefined;
907
+ }
908
+ function readJsonIfExists(file) {
909
+ if (!fs.existsSync(file)) {
910
+ return undefined;
911
+ }
912
+ return parseJsonObject(fs.readFileSync(file, "utf8"), file);
913
+ }
914
+ function writeJsonFile(file, value) {
915
+ ensureDir(path.dirname(file));
916
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
917
+ }
918
+ function backupFile(file, now) {
919
+ const suffix = now.replace(/[:.]/g, "-");
920
+ const backup = `${file}.${suffix}.bak`;
921
+ fs.copyFileSync(file, backup);
922
+ return backup;
923
+ }
924
+ function hashObject(value) {
925
+ return hashString(JSON.stringify(value));
926
+ }
927
+ function hashString(value) {
928
+ return crypto.createHash("sha256").update(value).digest("hex");
929
+ }