@deksden-com/dd-flow-cli 0.6.0 → 0.8.0-beta.135

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 (78) hide show
  1. package/CHANGELOG.md +636 -0
  2. package/README.md +13 -1
  3. package/dist/build-info.json +10 -10
  4. package/dist/cli/help.js +108 -18
  5. package/dist/cli/run-cli.js +626 -49
  6. package/dist/domain/flow-contract.js +11 -0
  7. package/dist/domain/stage-catalog.js +22 -0
  8. package/dist/runtime/context.js +8 -2
  9. package/dist/schemas/code-review-decision.schema.json +26 -0
  10. package/dist/schemas/code-review-result.schema.json +14 -0
  11. package/dist/schemas/code-stage-report.schema.json +7 -2
  12. package/dist/schemas/code-verification.schema.json +14 -0
  13. package/dist/schemas/code-work-batch.schema.json +24 -0
  14. package/dist/schemas/code-work-result.schema.json +16 -0
  15. package/dist/schemas/engine-manifest.schema.json +22 -0
  16. package/dist/schemas/flow-contract.schema.json +6 -3
  17. package/dist/schemas/flow-run.schema.json +16 -122
  18. package/dist/schemas/mb-upgrade-migration-report.schema.json +3 -1
  19. package/dist/schemas/merge-stage-report-legacy-0.4.2.schema.json +24 -0
  20. package/dist/schemas/plan-aspect-map.schema.json +22 -0
  21. package/dist/schemas/plan-review-decision.schema.json +14 -0
  22. package/dist/schemas/plan-review-result.schema.json +42 -0
  23. package/dist/schemas/protocol-plan.schema.json +15 -182
  24. package/dist/schemas/run-engine-binding.schema.json +37 -0
  25. package/dist/schemas/stage-finish-input.schema.json +16 -2
  26. package/dist/schemas/stage-prompt.schema.json +4 -4
  27. package/dist/schemas/stage-report.schema.json +8 -7
  28. package/dist/schemas/vnext-protocol-plan.schema.json +37 -0
  29. package/dist/schemas/vnext-protocolize-result.schema.json +29 -0
  30. package/dist/schemas/vnext-specify.schema.json +45 -0
  31. package/dist/services/branch-context.js +1 -1
  32. package/dist/services/canon.js +15 -1
  33. package/dist/services/cleanup.js +8 -8
  34. package/dist/services/cli-operation-classifier.js +60 -8
  35. package/dist/services/code-checks.js +244 -0
  36. package/dist/services/compatibility-preflight.js +1 -1
  37. package/dist/services/config.js +7 -1
  38. package/dist/services/dashboard.js +14 -14
  39. package/dist/services/engines.js +408 -30
  40. package/dist/services/eval-snapshots.js +404 -0
  41. package/dist/services/hooks.js +775 -23
  42. package/dist/services/ids.js +16 -6
  43. package/dist/services/lanes.js +1 -5
  44. package/dist/services/merge-queue.js +53 -5
  45. package/dist/services/merge-worker.js +5 -6
  46. package/dist/services/migrations.js +307 -44
  47. package/dist/services/plan-runtime.js +5 -5
  48. package/dist/services/plans.js +5 -3
  49. package/dist/services/projects.js +4 -4
  50. package/dist/services/prompts.js +1 -1
  51. package/dist/services/protocols.js +31 -10
  52. package/dist/services/run-engine-bindings.js +157 -0
  53. package/dist/services/run-projection.js +49 -13
  54. package/dist/services/runs.js +525 -58
  55. package/dist/services/schema-validation.js +116 -2
  56. package/dist/services/sessions.js +51 -12
  57. package/dist/services/stage-blocker.js +57 -0
  58. package/dist/services/stage-context.js +90 -0
  59. package/dist/services/stage-lifecycle.js +288 -77
  60. package/dist/services/stage-pause.js +175 -0
  61. package/dist/services/stage-report-renderer.js +65 -0
  62. package/dist/services/status.js +8 -3
  63. package/dist/services/usage.js +526 -18
  64. package/dist/services/vnext-code-review.js +308 -0
  65. package/dist/services/vnext-code.js +616 -0
  66. package/dist/services/vnext-contracts.js +1 -0
  67. package/dist/services/vnext-execution-profile.js +27 -0
  68. package/dist/services/vnext-fanout.js +79 -0
  69. package/dist/services/vnext-plan-review.js +499 -0
  70. package/dist/services/vnext-plan.js +576 -0
  71. package/dist/services/vnext-protocolize.js +542 -0
  72. package/dist/services/vnext-specify.js +595 -0
  73. package/dist/services/vnext-workspace-policy.js +87 -0
  74. package/dist/services/work-registry.js +499 -0
  75. package/dist/services/worktrees.js +58 -37
  76. package/dist/storage/database.js +292 -42
  77. package/dist/storage/paths.js +47 -1
  78. package/package.json +12 -12
@@ -37,53 +37,67 @@ export function createWorktreeRecord(context, input) {
37
37
  worktree_path: existing.worktree_path
38
38
  });
39
39
  }
40
+ const created = createManagedWorktree(context, { projectRoot: protocol.project_root, branch: input.branch, base: input.base, path: input.path });
41
+ recordManagedWorktree(context, { protocolId: protocol.id, projectRoot: protocol.project_root, branch: input.branch, base: input.base, created });
42
+ return { ok: true, worktree: activeWorktreeRecord(context, protocol.project_id, protocol.id) };
43
+ }
44
+ /** Creates the checkout before a PRT exists. PROTOCOLIZE materializes the PRT only inside this checkout. */
45
+ export function createManagedWorktree(context, input) {
46
+ const projectRoot = resolveProjectRoot(input.projectRoot);
40
47
  const worktrunk = ensureManagedWorktrunk(context);
41
- const command = [worktrunk.bin, "-C", protocol.project_root, "switch", "--create", input.branch, "--base", input.base, "--no-cd", "--format=json", "-y"];
48
+ // Worktrunk owns creation; the CLI supplies its project-scoped service path
49
+ // as a one-shot config override rather than relying on a developer's user config.
50
+ const command = [worktrunk.bin, "-C", projectRoot, "--config-set", `worktree-path = ${JSON.stringify(path.resolve(input.path))}`, "switch", "--create", input.branch, "--base", input.base, "--no-cd", "--format=json", "-y"];
42
51
  const result = runExternal(command);
43
- if (result.exit_code !== 0) {
52
+ if (result.exit_code !== 0)
44
53
  throw new AppError("worktrunk_command_unavailable", "Worktrunk switch failed", 1, { command: sanitizeCommand(command), result });
45
- }
46
54
  const response = parseWorktrunkResponse(result.stdout, command);
47
- const worktreePath = response.workspace_path;
48
- if (isTrackedProjectPath(protocol.project_root, worktreePath)) {
49
- throw new AppError("worktree_active", "Worktrunk returned a workspace inside tracked project files", 1, { path: worktreePath });
50
- }
55
+ if (isTrackedProjectPath(projectRoot, response.workspace_path))
56
+ throw new AppError("worktree_active", "Worktrunk returned a workspace inside tracked project files", 1, { path: response.workspace_path });
57
+ return { worktreePath: response.workspace_path, worktrunk, command, result, response };
58
+ }
59
+ /** Attaches an already-created managed checkout to a newly materialized PRT. */
60
+ export function recordManagedWorktree(context, input) {
61
+ const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
62
+ const existing = activeWorktreeRecord(context, protocol.project_id, protocol.id);
63
+ if (existing)
64
+ throw new AppError("worktree_already_exists", "Protocol already has an active worktree record", 1, { protocol_id: protocol.id, worktree_path: existing.worktree_path });
51
65
  const now = context.now();
52
66
  const state = readProtocolRuntimeState(context, protocol).state;
53
67
  context.db.run(`INSERT INTO worktree_records
54
68
  (protocol_id, project_id, integration_branch, feature_branch, base_ref, worktree_path,
55
69
  worktrunk_metadata_json, bootstrap_status, status, last_command_result_json, created_at, updated_at, closed_at)
56
- VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', 'active', ?, ?, ?, NULL)`, [
57
- protocol.id,
58
- protocol.project_id,
59
- typeof state.workspace.integration_branch === "string" ? state.workspace.integration_branch : null,
60
- input.branch,
61
- input.base,
62
- worktreePath,
63
- JSON.stringify({ bin: worktrunk.bin, version: worktrunk.version, command, response }),
64
- JSON.stringify(result),
65
- now,
66
- now
67
- ]);
70
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, NULL)`, [protocol.id, protocol.project_id, typeof state.workspace.integration_branch === "string" ? state.workspace.integration_branch : null, input.branch, input.base, input.created.worktreePath, JSON.stringify({ bin: input.created.worktrunk.bin, version: input.created.worktrunk.version, command: input.created.command, response: input.created.response }), input.bootstrap?.status ?? "pending", JSON.stringify(input.bootstrap ?? input.created.result), now, now]);
68
71
  persistProtocolState(context, protocol, {
69
72
  ...state,
70
- workspace: {
71
- ...state.workspace,
72
- feature_branch: input.branch,
73
- worktree_path: worktreePath,
74
- base_commit: input.base,
75
- worktrunk: { bin: worktrunk.bin, version: worktrunk.version, response },
76
- bootstrap: { status: typeof response.bootstrap_status === "string" ? response.bootstrap_status : "pending" }
77
- },
73
+ workspace: { ...state.workspace, feature_branch: input.branch, worktree_path: input.created.worktreePath, base_commit: input.base, worktrunk: { bin: input.created.worktrunk.bin, version: input.created.worktrunk.version, response: input.created.response }, bootstrap: input.bootstrap ?? { status: typeof input.created.response.bootstrap_status === "string" ? input.created.response.bootstrap_status : "pending" } },
78
74
  updated_at: now
79
75
  });
80
- appendAudit(context, {
81
- protocolId: protocol.id,
82
- projectId: protocol.project_id,
83
- eventType: "worktree.created",
84
- payload: { protocol_id: protocol.id, feature_branch: input.branch, base: input.base, path: worktreePath }
85
- });
86
- return { ok: true, worktree: activeWorktreeRecord(context, protocol.project_id, protocol.id) };
76
+ appendAudit(context, { protocolId: protocol.id, projectId: protocol.project_id, eventType: "worktree.created", payload: { protocol_id: protocol.id, feature_branch: input.branch, base: input.base, path: input.created.worktreePath } });
77
+ }
78
+ /** Bootstrap a just-created RUN workspace before a durable PRT exists. */
79
+ export function bootstrapManagedWorktree(context, input) {
80
+ const projectRoot = resolveProjectRoot(input.projectRoot);
81
+ const worktreePath = path.resolve(input.worktreePath);
82
+ const includeFile = path.join(projectRoot, ".worktreeinclude");
83
+ const configFile = path.join(projectRoot, ".config", "wt.toml");
84
+ if (!fs.existsSync(includeFile) || !fs.existsSync(configFile)) {
85
+ throw new AppError("bootstrap_unavailable", "Project-owned Worktrunk bootstrap files are required", 1, { include_file: includeFile, config_file: configFile });
86
+ }
87
+ const worktrunk = ensureManagedWorktrunk(context);
88
+ const commands = [
89
+ [worktrunk.bin, "-C", worktreePath, "step", "copy-ignored", "--require-include"],
90
+ [worktrunk.bin, "-C", worktreePath, "hook", "pre-start", "--yes"]
91
+ ];
92
+ const results = [];
93
+ for (const command of commands) {
94
+ const result = runExternal(command);
95
+ results.push({ command: sanitizeCommand(command), ...result });
96
+ if (result.exit_code !== 0) {
97
+ throw new AppError("bootstrap_failed", "Project-owned Worktrunk bootstrap failed", 1, { command: sanitizeCommand(command), result, results });
98
+ }
99
+ }
100
+ return { status: "succeeded", commands: results };
87
101
  }
88
102
  export function getWorktreeStatus(context, input) {
89
103
  const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
@@ -222,13 +236,20 @@ function parseWorktrunkResponse(stdout, command) {
222
236
  throw new AppError("worktrunk_invalid_response", "Worktrunk JSON response must be an object", 1, { command: sanitizeCommand(command) });
223
237
  }
224
238
  const response = parsed;
225
- if (typeof response.workspace_path !== "string" || !path.isAbsolute(response.workspace_path)) {
226
- throw new AppError("worktrunk_invalid_response", "Worktrunk response must contain an absolute workspace_path", 1, {
239
+ // `wt switch --format=json` returns `path` in the pinned 0.72 release.
240
+ // Normalize provider output once; the rest of dd-flow uses workspace_path.
241
+ const workspacePath = typeof response.workspace_path === "string"
242
+ ? response.workspace_path
243
+ : typeof response.path === "string"
244
+ ? response.path
245
+ : null;
246
+ if (!workspacePath || !path.isAbsolute(workspacePath)) {
247
+ throw new AppError("worktrunk_invalid_response", "Worktrunk response must contain an absolute path or workspace_path", 1, {
227
248
  command: sanitizeCommand(command),
228
249
  response
229
250
  });
230
251
  }
231
- return { ...response, workspace_path: path.resolve(response.workspace_path) };
252
+ return { ...response, workspace_path: path.resolve(workspacePath) };
232
253
  }
233
254
  function markBootstrapFailed(context, protocol, record, failure) {
234
255
  const now = context.now();
@@ -5,27 +5,117 @@ import { ensureDir } from "./paths.js";
5
5
  import { AppError } from "../shared/errors.js";
6
6
  const require = createRequire(import.meta.url);
7
7
  const { DatabaseSync } = require("node:sqlite");
8
- export function getDatabase(ddFlowHome) {
9
- ensureDir(ddFlowHome);
8
+ /** Creates the single Work/Session authority used by a fresh vNext beta runtime. */
9
+ export function ensureVnextWorkStorage(db) {
10
+ const legacy = db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('vnext_works', 'vnext_agent_turns', 'flow_agent_turns') LIMIT 1");
11
+ if (legacy)
12
+ throw new AppError("beta_storage_recreate_required", "This beta requires a fresh DD_FLOW_HOME; the previous Work runtime storage is not compatible", 1);
13
+ const existing = db.get("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'works'");
14
+ if (existing && (!/task TEXT NOT NULL/.test(existing.sql) || /launch_token|adapter_session_id/.test(existing.sql))) {
15
+ throw new AppError("beta_storage_recreate_required", "This beta requires a fresh DD_FLOW_HOME; the existing works schema is not compatible", 1);
16
+ }
17
+ db.exec(`
18
+ CREATE TABLE IF NOT EXISTS works (
19
+ work_id TEXT PRIMARY KEY,
20
+ project_id TEXT NOT NULL,
21
+ run_id TEXT NOT NULL,
22
+ parent_work_id TEXT,
23
+ task TEXT NOT NULL,
24
+ launch_policy TEXT NOT NULL DEFAULT 'reuse_allowed',
25
+ result_schema TEXT,
26
+ payload_json TEXT,
27
+ depends_on_json TEXT NOT NULL DEFAULT '[]',
28
+ status TEXT NOT NULL,
29
+ result TEXT,
30
+ created_at TEXT NOT NULL,
31
+ started_at TEXT,
32
+ updated_at TEXT NOT NULL,
33
+ completed_at TEXT,
34
+ FOREIGN KEY(project_id) REFERENCES projects(id),
35
+ FOREIGN KEY(project_id, run_id) REFERENCES runs(project_id, id),
36
+ FOREIGN KEY(parent_work_id) REFERENCES works(work_id)
37
+ );
38
+ CREATE INDEX IF NOT EXISTS idx_works_run ON works(project_id, run_id, updated_at DESC);
39
+ CREATE TABLE IF NOT EXISTS work_sessions (
40
+ id TEXT PRIMARY KEY,
41
+ work_id TEXT NOT NULL,
42
+ session_id TEXT NOT NULL,
43
+ hook_event_id TEXT,
44
+ status TEXT NOT NULL,
45
+ prompt_path TEXT NOT NULL,
46
+ result_path TEXT,
47
+ created_at TEXT NOT NULL,
48
+ updated_at TEXT NOT NULL,
49
+ completed_at TEXT,
50
+ FOREIGN KEY(work_id) REFERENCES works(work_id)
51
+ );
52
+ CREATE INDEX IF NOT EXISTS idx_work_sessions_work ON work_sessions(work_id, created_at DESC);
53
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_work_sessions_open ON work_sessions(work_id) WHERE status = 'running';
54
+ CREATE TABLE IF NOT EXISTS check_receipts (
55
+ id TEXT PRIMARY KEY,
56
+ project_id TEXT NOT NULL,
57
+ run_id TEXT NOT NULL,
58
+ work_id TEXT,
59
+ scope TEXT NOT NULL,
60
+ declaration_id TEXT NOT NULL,
61
+ command TEXT NOT NULL,
62
+ status TEXT NOT NULL,
63
+ exit_code INTEGER,
64
+ stdout_path TEXT NOT NULL,
65
+ stderr_path TEXT NOT NULL,
66
+ receipt_path TEXT NOT NULL,
67
+ workspace_fingerprint TEXT NOT NULL,
68
+ artifacts_json TEXT NOT NULL,
69
+ started_at TEXT NOT NULL,
70
+ finished_at TEXT NOT NULL,
71
+ FOREIGN KEY(work_id) REFERENCES works(work_id)
72
+ );
73
+ CREATE INDEX IF NOT EXISTS idx_check_receipts_run ON check_receipts(project_id, run_id, started_at);
74
+ `);
75
+ }
76
+ export function getDatabase(ddFlowHome, mode = "initialize") {
10
77
  const dbPath = path.join(ddFlowHome, "db.sqlite");
11
- const db = new DatabaseSync(dbPath);
12
- configureDatabase(db);
13
- migrate(db);
78
+ const exists = fs.existsSync(dbPath);
79
+ if (mode === "read_existing" && !exists) {
80
+ return emptyReadOnlyDatabase(dbPath);
81
+ }
82
+ if (!exists || mode === "migrate")
83
+ ensureDir(ddFlowHome);
84
+ const db = mode === "read_existing" ? new DatabaseSync(dbPath, { readOnly: true }) : new DatabaseSync(dbPath);
85
+ configureDatabase(db, mode);
86
+ // Internal additive schema changes must be available to every write command.
87
+ // Higher-level Memory Bank migrations remain explicit in services/migrations.
88
+ if (mode !== "read_existing")
89
+ migrate(db);
90
+ if (mode === "read_existing")
91
+ db.exec("PRAGMA query_only = ON");
14
92
  db.exec("PRAGMA foreign_keys = ON");
15
93
  return {
16
94
  path: dbPath,
95
+ writable: mode !== "read_existing",
96
+ close: () => db.close?.(),
17
97
  exec: (sql) => db.exec(sql),
18
98
  run: (sql, params = []) => db.prepare(sql).run(...params),
19
99
  get: (sql, params = []) => db.prepare(sql).get(...params),
20
100
  all: (sql, params = []) => db.prepare(sql).all(...params)
21
101
  };
22
102
  }
23
- function configureDatabase(db) {
24
- db.exec(`
25
- PRAGMA busy_timeout = 4000;
26
- PRAGMA journal_mode = WAL;
27
- PRAGMA synchronous = NORMAL;
28
- `);
103
+ function emptyReadOnlyDatabase(dbPath) {
104
+ return {
105
+ path: dbPath,
106
+ writable: false,
107
+ exec: () => { throw new AppError("read_only_storage", "Read-only access cannot write absent dd-flow storage", 1, { path: dbPath }); },
108
+ run: () => { throw new AppError("read_only_storage", "Read-only access cannot write absent dd-flow storage", 1, { path: dbPath }); },
109
+ get: () => undefined,
110
+ all: () => []
111
+ };
112
+ }
113
+ function configureDatabase(db, mode) {
114
+ db.exec("PRAGMA busy_timeout = 4000");
115
+ if (mode !== "read_existing") {
116
+ db.exec("PRAGMA journal_mode = WAL");
117
+ db.exec("PRAGMA synchronous = NORMAL");
118
+ }
29
119
  }
30
120
  function migrate(db) {
31
121
  db.exec(`
@@ -248,9 +338,18 @@ function migrate(db) {
248
338
  updated_at TEXT NOT NULL
249
339
  );
250
340
 
251
- CREATE TABLE IF NOT EXISTS flow_sessions (
341
+ CREATE TABLE IF NOT EXISTS sessions (
252
342
  session_id TEXT NOT NULL,
253
343
  project_id TEXT NOT NULL,
344
+ harness TEXT NOT NULL DEFAULT 'codex-desktop',
345
+ provider_session_id TEXT,
346
+ agent_id TEXT,
347
+ parent_id TEXT,
348
+ provider TEXT,
349
+ model TEXT,
350
+ reasoning TEXT,
351
+ mode TEXT,
352
+ agent_type TEXT,
254
353
  project_root TEXT NOT NULL,
255
354
  flow_kind TEXT NOT NULL,
256
355
  status TEXT NOT NULL,
@@ -275,7 +374,7 @@ function migrate(db) {
275
374
  FOREIGN KEY(project_id) REFERENCES projects(id)
276
375
  );
277
376
 
278
- CREATE TABLE IF NOT EXISTS flow_runs (
377
+ CREATE TABLE IF NOT EXISTS runs (
279
378
  id TEXT NOT NULL,
280
379
  short_id TEXT NOT NULL,
281
380
  slug TEXT NOT NULL,
@@ -292,6 +391,7 @@ function migrate(db) {
292
391
  run_dir TEXT NOT NULL,
293
392
  run_index_path TEXT NOT NULL,
294
393
  run_home_path TEXT,
394
+ run_root TEXT,
295
395
  layout_version TEXT,
296
396
  artifact_root_kind TEXT,
297
397
  index_json TEXT NOT NULL,
@@ -303,10 +403,76 @@ function migrate(db) {
303
403
  );
304
404
 
305
405
  CREATE INDEX IF NOT EXISTS idx_flow_runs_project_updated
306
- ON flow_runs(project_id, updated_at DESC);
406
+ ON runs(project_id, updated_at DESC);
307
407
 
308
408
  CREATE INDEX IF NOT EXISTS idx_flow_runs_short_id
309
- ON flow_runs(short_id);
409
+ ON runs(short_id);
410
+
411
+ CREATE TABLE IF NOT EXISTS works (
412
+ work_id TEXT PRIMARY KEY,
413
+ project_id TEXT NOT NULL,
414
+ run_id TEXT NOT NULL,
415
+ parent_work_id TEXT,
416
+ task TEXT NOT NULL,
417
+ launch_policy TEXT NOT NULL DEFAULT 'reuse_allowed',
418
+ result_schema TEXT,
419
+ payload_json TEXT,
420
+ depends_on_json TEXT NOT NULL DEFAULT '[]',
421
+ status TEXT NOT NULL,
422
+ result TEXT,
423
+ created_at TEXT NOT NULL,
424
+ started_at TEXT,
425
+ updated_at TEXT NOT NULL,
426
+ completed_at TEXT,
427
+ FOREIGN KEY(project_id) REFERENCES projects(id),
428
+ FOREIGN KEY(project_id, run_id) REFERENCES runs(project_id, id),
429
+ FOREIGN KEY(parent_work_id) REFERENCES works(work_id)
430
+ );
431
+
432
+ CREATE INDEX IF NOT EXISTS idx_works_run
433
+ ON works(project_id, run_id, updated_at DESC);
434
+
435
+ CREATE TABLE IF NOT EXISTS work_sessions (
436
+ id TEXT PRIMARY KEY,
437
+ work_id TEXT NOT NULL,
438
+ session_id TEXT NOT NULL,
439
+ hook_event_id TEXT,
440
+ status TEXT NOT NULL,
441
+ prompt_path TEXT NOT NULL,
442
+ result_path TEXT,
443
+ created_at TEXT NOT NULL,
444
+ updated_at TEXT NOT NULL,
445
+ completed_at TEXT,
446
+ FOREIGN KEY(work_id) REFERENCES works(work_id)
447
+ );
448
+
449
+ CREATE INDEX IF NOT EXISTS idx_work_sessions_work
450
+ ON work_sessions(work_id, created_at DESC);
451
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_work_sessions_open
452
+ ON work_sessions(work_id) WHERE status = 'running';
453
+
454
+ CREATE TABLE IF NOT EXISTS check_receipts (
455
+ id TEXT PRIMARY KEY,
456
+ project_id TEXT NOT NULL,
457
+ run_id TEXT NOT NULL,
458
+ work_id TEXT,
459
+ scope TEXT NOT NULL,
460
+ declaration_id TEXT NOT NULL,
461
+ command TEXT NOT NULL,
462
+ status TEXT NOT NULL,
463
+ exit_code INTEGER,
464
+ stdout_path TEXT NOT NULL,
465
+ stderr_path TEXT NOT NULL,
466
+ receipt_path TEXT NOT NULL,
467
+ workspace_fingerprint TEXT NOT NULL,
468
+ artifacts_json TEXT NOT NULL,
469
+ started_at TEXT NOT NULL,
470
+ finished_at TEXT NOT NULL,
471
+ FOREIGN KEY(work_id) REFERENCES works(work_id)
472
+ );
473
+
474
+ CREATE INDEX IF NOT EXISTS idx_check_receipts_run
475
+ ON check_receipts(project_id, run_id, started_at);
310
476
 
311
477
  CREATE TABLE IF NOT EXISTS flow_run_flag_mutations (
312
478
  project_id TEXT NOT NULL,
@@ -320,7 +486,7 @@ function migrate(db) {
320
486
  FOREIGN KEY(project_id) REFERENCES projects(id)
321
487
  );
322
488
 
323
- CREATE TABLE IF NOT EXISTS flow_run_usage_snapshots (
489
+ CREATE TABLE IF NOT EXISTS usage (
324
490
  id TEXT PRIMARY KEY,
325
491
  project_id TEXT NOT NULL,
326
492
  run_id TEXT NOT NULL,
@@ -338,6 +504,10 @@ function migrate(db) {
338
504
  output_tokens INTEGER,
339
505
  reasoning_output_tokens INTEGER,
340
506
  source_kind TEXT NOT NULL,
507
+ source_locator TEXT,
508
+ source_sha256 TEXT,
509
+ source_size_bytes INTEGER,
510
+ source_mtime_ms INTEGER,
341
511
  token_event_at TEXT,
342
512
  turn_id TEXT,
343
513
  turn_attribution TEXT NOT NULL,
@@ -349,14 +519,55 @@ function migrate(db) {
349
519
  );
350
520
 
351
521
  CREATE INDEX IF NOT EXISTS idx_flow_run_usage_snapshots_run
352
- ON flow_run_usage_snapshots(project_id, run_id, session_id, observed_at, id);
522
+ ON usage(project_id, run_id, session_id, observed_at, id);
353
523
 
354
- CREATE TABLE IF NOT EXISTS codex_hook_events (
524
+ CREATE TABLE IF NOT EXISTS harness_usage_snapshots (
525
+ id TEXT PRIMARY KEY,
526
+ project_id TEXT NOT NULL,
527
+ harness TEXT NOT NULL,
528
+ provider_session_id TEXT NOT NULL,
529
+ daemon_id TEXT,
530
+ observed_at TEXT NOT NULL,
531
+ total_tokens INTEGER NOT NULL,
532
+ input_tokens INTEGER,
533
+ cache_read_input_tokens INTEGER,
534
+ cache_write_input_tokens INTEGER,
535
+ output_tokens INTEGER,
536
+ reasoning_output_tokens INTEGER,
537
+ request_count INTEGER,
538
+ error_count INTEGER,
539
+ tool_calls INTEGER,
540
+ tool_failures INTEGER,
541
+ tool_by_name_json TEXT,
542
+ source_kind TEXT NOT NULL DEFAULT 'zcode_session_usage_v1',
543
+ usage_scope TEXT NOT NULL DEFAULT 'physical_session',
544
+ completeness TEXT NOT NULL DEFAULT 'complete',
545
+ created_at TEXT NOT NULL,
546
+ UNIQUE(project_id, harness, provider_session_id, observed_at),
547
+ FOREIGN KEY(project_id) REFERENCES projects(id)
548
+ );
549
+
550
+ CREATE INDEX IF NOT EXISTS idx_harness_usage_provider
551
+ ON harness_usage_snapshots(project_id, harness, provider_session_id, observed_at, id);
552
+
553
+ CREATE TABLE IF NOT EXISTS hook_events (
355
554
  id INTEGER PRIMARY KEY AUTOINCREMENT,
356
555
  project_id TEXT NOT NULL,
357
556
  protocol_id TEXT,
557
+ harness TEXT NOT NULL DEFAULT 'codex-desktop',
558
+ provider_session_id TEXT,
559
+ parent_session_id TEXT,
560
+ daemon_id TEXT,
358
561
  session_id TEXT,
562
+ agent_id TEXT,
359
563
  turn_id TEXT,
564
+ transcript_path TEXT,
565
+ provider TEXT,
566
+ model TEXT,
567
+ reasoning TEXT,
568
+ mode TEXT,
569
+ agent_type TEXT,
570
+ cwd TEXT,
360
571
  event_key TEXT,
361
572
  event_name TEXT NOT NULL,
362
573
  tool_name TEXT,
@@ -439,32 +650,71 @@ function migrate(db) {
439
650
  ensureColumn(db, "merge_queue", "completed_at", "ALTER TABLE merge_queue ADD COLUMN completed_at TEXT");
440
651
  ensureColumn(db, "codex_session_bindings", "continuation_count", "ALTER TABLE codex_session_bindings ADD COLUMN continuation_count INTEGER NOT NULL DEFAULT 0");
441
652
  ensureColumn(db, "codex_session_bindings", "last_action_hash", "ALTER TABLE codex_session_bindings ADD COLUMN last_action_hash TEXT");
442
- ensureColumn(db, "flow_sessions", "current_stage", "ALTER TABLE flow_sessions ADD COLUMN current_stage TEXT");
443
- ensureColumn(db, "flow_sessions", "next_action", "ALTER TABLE flow_sessions ADD COLUMN next_action TEXT");
444
- ensureColumn(db, "flow_sessions", "metadata_json", "ALTER TABLE flow_sessions ADD COLUMN metadata_json TEXT NOT NULL DEFAULT '{}'");
445
- ensureColumn(db, "flow_sessions", "run_id", "ALTER TABLE flow_sessions ADD COLUMN run_id TEXT");
446
- ensureColumn(db, "flow_sessions", "parent_session_id", "ALTER TABLE flow_sessions ADD COLUMN parent_session_id TEXT");
447
- ensureColumn(db, "flow_sessions", "role", "ALTER TABLE flow_sessions ADD COLUMN role TEXT");
448
- ensureColumn(db, "flow_sessions", "aspect_id", "ALTER TABLE flow_sessions ADD COLUMN aspect_id TEXT");
449
- ensureColumn(db, "flow_sessions", "plan_item_id", "ALTER TABLE flow_sessions ADD COLUMN plan_item_id TEXT");
450
- ensureColumn(db, "flow_sessions", "session_kind", "ALTER TABLE flow_sessions ADD COLUMN session_kind TEXT");
451
- ensureColumn(db, "flow_sessions", "coverage_units_json", "ALTER TABLE flow_sessions ADD COLUMN coverage_units_json TEXT NOT NULL DEFAULT '[]'");
452
- ensureColumn(db, "flow_run_usage_snapshots", "cache_read_input_tokens", "ALTER TABLE flow_run_usage_snapshots ADD COLUMN cache_read_input_tokens INTEGER");
453
- ensureColumn(db, "flow_run_usage_snapshots", "cache_write_input_tokens", "ALTER TABLE flow_run_usage_snapshots ADD COLUMN cache_write_input_tokens INTEGER");
454
- ensureColumn(db, "flow_run_usage_snapshots", "uncached_input_tokens", "ALTER TABLE flow_run_usage_snapshots ADD COLUMN uncached_input_tokens INTEGER");
455
- db.prepare("UPDATE flow_run_usage_snapshots SET cache_read_input_tokens = cached_input_tokens WHERE cache_read_input_tokens IS NULL AND cached_input_tokens IS NOT NULL").run();
456
- ensureColumn(db, "flow_runs", "run_home_path", "ALTER TABLE flow_runs ADD COLUMN run_home_path TEXT");
457
- ensureColumn(db, "flow_runs", "layout_version", "ALTER TABLE flow_runs ADD COLUMN layout_version TEXT");
458
- ensureColumn(db, "flow_runs", "artifact_root_kind", "ALTER TABLE flow_runs ADD COLUMN artifact_root_kind TEXT");
459
- ensureColumn(db, "codex_hook_events", "event_key", "ALTER TABLE codex_hook_events ADD COLUMN event_key TEXT");
653
+ ensureColumn(db, "sessions", "current_stage", "ALTER TABLE sessions ADD COLUMN current_stage TEXT");
654
+ ensureColumn(db, "sessions", "next_action", "ALTER TABLE sessions ADD COLUMN next_action TEXT");
655
+ ensureColumn(db, "sessions", "metadata_json", "ALTER TABLE sessions ADD COLUMN metadata_json TEXT NOT NULL DEFAULT '{}'");
656
+ ensureColumn(db, "sessions", "run_id", "ALTER TABLE sessions ADD COLUMN run_id TEXT");
657
+ ensureColumn(db, "sessions", "provider_session_id", "ALTER TABLE sessions ADD COLUMN provider_session_id TEXT");
658
+ ensureColumn(db, "sessions", "harness", "ALTER TABLE sessions ADD COLUMN harness TEXT NOT NULL DEFAULT 'codex-desktop'");
659
+ ensureColumn(db, "sessions", "agent_id", "ALTER TABLE sessions ADD COLUMN agent_id TEXT");
660
+ ensureColumn(db, "sessions", "provider", "ALTER TABLE sessions ADD COLUMN provider TEXT");
661
+ ensureColumn(db, "sessions", "parent_id", "ALTER TABLE sessions ADD COLUMN parent_id TEXT");
662
+ ensureColumn(db, "sessions", "model", "ALTER TABLE sessions ADD COLUMN model TEXT");
663
+ ensureColumn(db, "sessions", "reasoning", "ALTER TABLE sessions ADD COLUMN reasoning TEXT");
664
+ ensureColumn(db, "sessions", "mode", "ALTER TABLE sessions ADD COLUMN mode TEXT");
665
+ ensureColumn(db, "sessions", "agent_type", "ALTER TABLE sessions ADD COLUMN agent_type TEXT");
666
+ ensureColumn(db, "sessions", "parent_session_id", "ALTER TABLE sessions ADD COLUMN parent_session_id TEXT");
667
+ ensureColumn(db, "sessions", "role", "ALTER TABLE sessions ADD COLUMN role TEXT");
668
+ ensureColumn(db, "sessions", "aspect_id", "ALTER TABLE sessions ADD COLUMN aspect_id TEXT");
669
+ ensureColumn(db, "sessions", "plan_item_id", "ALTER TABLE sessions ADD COLUMN plan_item_id TEXT");
670
+ ensureColumn(db, "sessions", "session_kind", "ALTER TABLE sessions ADD COLUMN session_kind TEXT");
671
+ ensureColumn(db, "sessions", "coverage_units_json", "ALTER TABLE sessions ADD COLUMN coverage_units_json TEXT NOT NULL DEFAULT '[]'");
672
+ ensureColumn(db, "usage", "cache_read_input_tokens", "ALTER TABLE usage ADD COLUMN cache_read_input_tokens INTEGER");
673
+ ensureColumn(db, "usage", "cache_write_input_tokens", "ALTER TABLE usage ADD COLUMN cache_write_input_tokens INTEGER");
674
+ ensureColumn(db, "usage", "uncached_input_tokens", "ALTER TABLE usage ADD COLUMN uncached_input_tokens INTEGER");
675
+ ensureColumn(db, "usage", "source_locator", "ALTER TABLE usage ADD COLUMN source_locator TEXT");
676
+ ensureColumn(db, "usage", "source_sha256", "ALTER TABLE usage ADD COLUMN source_sha256 TEXT");
677
+ ensureColumn(db, "usage", "source_size_bytes", "ALTER TABLE usage ADD COLUMN source_size_bytes INTEGER");
678
+ ensureColumn(db, "usage", "source_mtime_ms", "ALTER TABLE usage ADD COLUMN source_mtime_ms INTEGER");
679
+ db.prepare("UPDATE usage SET cache_read_input_tokens = cached_input_tokens WHERE cache_read_input_tokens IS NULL AND cached_input_tokens IS NOT NULL").run();
680
+ ensureColumn(db, "runs", "run_home_path", "ALTER TABLE runs ADD COLUMN run_home_path TEXT");
681
+ ensureColumn(db, "runs", "run_root", "ALTER TABLE runs ADD COLUMN run_root TEXT");
682
+ ensureColumn(db, "runs", "layout_version", "ALTER TABLE runs ADD COLUMN layout_version TEXT");
683
+ ensureColumn(db, "runs", "artifact_root_kind", "ALTER TABLE runs ADD COLUMN artifact_root_kind TEXT");
684
+ ensureColumn(db, "works", "payload_json", "ALTER TABLE works ADD COLUMN payload_json TEXT");
685
+ ensureColumn(db, "check_receipts", "declaration_id", "ALTER TABLE check_receipts ADD COLUMN declaration_id TEXT NOT NULL DEFAULT 'CHK-LEGACY'");
686
+ ensureColumn(db, "check_receipts", "workspace_fingerprint", "ALTER TABLE check_receipts ADD COLUMN workspace_fingerprint TEXT NOT NULL DEFAULT ''");
687
+ ensureColumn(db, "check_receipts", "artifacts_json", "ALTER TABLE check_receipts ADD COLUMN artifacts_json TEXT NOT NULL DEFAULT '[]'");
688
+ ensureColumn(db, "hook_events", "event_key", "ALTER TABLE hook_events ADD COLUMN event_key TEXT");
689
+ ensureColumn(db, "hook_events", "match_key", "ALTER TABLE hook_events ADD COLUMN match_key TEXT");
690
+ ensureColumn(db, "hook_events", "claimed_at", "ALTER TABLE hook_events ADD COLUMN claimed_at TEXT");
691
+ ensureColumn(db, "hook_events", "agent_id", "ALTER TABLE hook_events ADD COLUMN agent_id TEXT");
692
+ ensureColumn(db, "hook_events", "transcript_path", "ALTER TABLE hook_events ADD COLUMN transcript_path TEXT");
693
+ ensureColumn(db, "hook_events", "provider", "ALTER TABLE hook_events ADD COLUMN provider TEXT");
694
+ ensureColumn(db, "hook_events", "model", "ALTER TABLE hook_events ADD COLUMN model TEXT");
695
+ ensureColumn(db, "hook_events", "reasoning", "ALTER TABLE hook_events ADD COLUMN reasoning TEXT");
696
+ ensureColumn(db, "hook_events", "mode", "ALTER TABLE hook_events ADD COLUMN mode TEXT");
697
+ ensureColumn(db, "hook_events", "agent_type", "ALTER TABLE hook_events ADD COLUMN agent_type TEXT");
698
+ ensureColumn(db, "hook_events", "harness", "ALTER TABLE hook_events ADD COLUMN harness TEXT NOT NULL DEFAULT 'codex-desktop'");
699
+ ensureColumn(db, "hook_events", "provider_session_id", "ALTER TABLE hook_events ADD COLUMN provider_session_id TEXT");
700
+ ensureColumn(db, "hook_events", "parent_session_id", "ALTER TABLE hook_events ADD COLUMN parent_session_id TEXT");
701
+ ensureColumn(db, "hook_events", "daemon_id", "ALTER TABLE hook_events ADD COLUMN daemon_id TEXT");
702
+ ensureColumn(db, "hook_events", "cwd", "ALTER TABLE hook_events ADD COLUMN cwd TEXT");
703
+ ensureColumn(db, "harness_usage_snapshots", "tool_calls", "ALTER TABLE harness_usage_snapshots ADD COLUMN tool_calls INTEGER");
704
+ ensureColumn(db, "harness_usage_snapshots", "tool_failures", "ALTER TABLE harness_usage_snapshots ADD COLUMN tool_failures INTEGER");
705
+ ensureColumn(db, "harness_usage_snapshots", "tool_by_name_json", "ALTER TABLE harness_usage_snapshots ADD COLUMN tool_by_name_json TEXT");
706
+ ensureColumn(db, "harness_usage_snapshots", "source_kind", "ALTER TABLE harness_usage_snapshots ADD COLUMN source_kind TEXT NOT NULL DEFAULT 'zcode_session_usage_v1'");
707
+ ensureColumn(db, "harness_usage_snapshots", "usage_scope", "ALTER TABLE harness_usage_snapshots ADD COLUMN usage_scope TEXT NOT NULL DEFAULT 'physical_session'");
708
+ ensureColumn(db, "harness_usage_snapshots", "completeness", "ALTER TABLE harness_usage_snapshots ADD COLUMN completeness TEXT NOT NULL DEFAULT 'complete'");
460
709
  ensureColumn(db, "flow_jobs", "updated_at", "ALTER TABLE flow_jobs ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''");
461
- db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_codex_hook_events_event_key ON codex_hook_events(project_id, event_key) WHERE event_key IS NOT NULL");
710
+ db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_codex_hook_events_event_key ON hook_events(project_id, event_key) WHERE event_key IS NOT NULL");
711
+ db.exec("CREATE INDEX IF NOT EXISTS idx_codex_hook_events_bootstrap_claim ON hook_events(project_id, session_id, match_key, status, created_at DESC)");
462
712
  ensureColumn(db, "lane_waiters", "expires_at", "ALTER TABLE lane_waiters ADD COLUMN expires_at TEXT");
463
713
  db.exec(`
464
714
  CREATE INDEX IF NOT EXISTS idx_flow_runs_project_updated
465
- ON flow_runs(project_id, updated_at DESC);
715
+ ON runs(project_id, updated_at DESC);
466
716
  CREATE INDEX IF NOT EXISTS idx_flow_runs_short_id
467
- ON flow_runs(short_id);
717
+ ON runs(short_id);
468
718
  `);
469
719
  }
470
720
  function migrateProjectScopedIdentity(db) {
@@ -479,7 +729,7 @@ function migrateProjectScopedIdentity(db) {
479
729
 
480
730
  ALTER TABLE merge_queue RENAME TO merge_queue_legacy_global_identity;
481
731
  ALTER TABLE worktree_records RENAME TO worktree_records_legacy_global_identity;
482
- ALTER TABLE flow_runs RENAME TO flow_runs_legacy_global_identity;
732
+ ALTER TABLE runs RENAME TO flow_runs_legacy_global_identity;
483
733
  ALTER TABLE protocols RENAME TO protocols_legacy_global_identity;
484
734
 
485
735
  CREATE TABLE protocols (
@@ -560,7 +810,7 @@ function migrateProjectScopedIdentity(db) {
560
810
  status, last_command_result_json, created_at, updated_at, closed_at
561
811
  FROM worktree_records_legacy_global_identity;
562
812
 
563
- CREATE TABLE flow_runs (
813
+ CREATE TABLE runs (
564
814
  id TEXT NOT NULL,
565
815
  short_id TEXT NOT NULL,
566
816
  slug TEXT NOT NULL,
@@ -586,7 +836,7 @@ function migrateProjectScopedIdentity(db) {
586
836
  PRIMARY KEY(project_id, id),
587
837
  FOREIGN KEY(project_id) REFERENCES projects(id)
588
838
  );
589
- INSERT INTO flow_runs
839
+ INSERT INTO runs
590
840
  (id, short_id, slug, project_id, project_root, workspace_root, flow_kind,
591
841
  subject_type, subject_id, status, verdict, next_action, runtime_path,
592
842
  run_dir, run_index_path, run_home_path, layout_version,
@@ -3,18 +3,64 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { AppError } from "../shared/errors.js";
5
5
  export function resolveDdFlowHome(env) {
6
+ // Keep the operator spelling stable in generated commands; containment checks
7
+ // canonicalize paths separately when filesystem identity matters.
6
8
  return path.resolve(env.DD_FLOW_HOME ?? path.join(os.homedir(), ".dd-flow"));
7
9
  }
8
10
  export function ensureDir(dir) {
9
11
  fs.mkdirSync(dir, { recursive: true });
10
12
  }
11
13
  export function resolveProjectRoot(root) {
12
- const absolute = path.resolve(root);
14
+ const absolute = canonicalPath(root, false);
13
15
  if (!fs.existsSync(absolute)) {
14
16
  throw new AppError("not_found", `Project root does not exist: ${absolute}`, 1);
15
17
  }
16
18
  return fs.realpathSync(absolute);
17
19
  }
20
+ /** Resolve an existing path to its filesystem identity, or normalize a future path. */
21
+ export function canonicalPath(value, mustExist = true) {
22
+ const absolute = path.resolve(value);
23
+ if (fs.existsSync(absolute))
24
+ return fs.realpathSync(absolute);
25
+ if (mustExist)
26
+ throw new AppError("not_found", `Path does not exist: ${absolute}`, 1, { path: absolute });
27
+ const parent = path.dirname(absolute);
28
+ if (!fs.existsSync(parent))
29
+ return absolute;
30
+ return path.join(fs.realpathSync(parent), path.basename(absolute));
31
+ }
32
+ export function assertPathWithin(root, candidate, label = "path") {
33
+ const canonicalRoot = canonicalPath(root);
34
+ const canonicalCandidate = canonicalPath(candidate, false);
35
+ const relative = path.relative(canonicalRoot, canonicalCandidate);
36
+ if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)))
37
+ return canonicalCandidate;
38
+ throw new AppError("validation", `${label} must stay inside ${canonicalRoot}`, 2, { root: canonicalRoot, [label]: canonicalCandidate });
39
+ }
40
+ export function runReference(runId, relativePath) {
41
+ const normalized = relativePath.replaceAll(path.sep, "/").replace(/^\/+/, "");
42
+ if (!normalized || normalized.split("/").includes(".."))
43
+ throw new AppError("validation", "RUN reference must be a safe relative path", 2, { relative_path: relativePath });
44
+ return `run://${runId}/${normalized}`;
45
+ }
46
+ export function resolveRunReference(value, runId, runRoot) {
47
+ const prefix = `run://${runId}/`;
48
+ if (!value.startsWith(prefix))
49
+ return value;
50
+ return assertPathWithin(runRoot, path.join(runRoot, value.slice(prefix.length)), "run_reference");
51
+ }
52
+ export function resolveRunReferences(value, runId, runRoot) {
53
+ return value.replace(new RegExp(`run://${escapeRegExp(runId)}/([^\\s)]+)`, "g"), (reference) => resolveRunReference(reference, runId, runRoot));
54
+ }
55
+ export function writeJsonAtomic(file, value) {
56
+ ensureDir(path.dirname(file));
57
+ const temporary = `${file}.tmp-${process.pid}-${Date.now()}`;
58
+ fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`);
59
+ fs.renameSync(temporary, file);
60
+ }
61
+ function escapeRegExp(value) {
62
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
63
+ }
18
64
  export function protocolDir(projectRoot, protocolId) {
19
65
  return path.join(projectRoot, ".memory-bank", "protocol", protocolId);
20
66
  }