@deksden-com/dd-flow-cli 0.7.0 → 0.8.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 (69) hide show
  1. package/CHANGELOG.md +666 -0
  2. package/README.md +7 -2
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +88 -10
  5. package/dist/cli/run-cli.js +523 -28
  6. package/dist/domain/stage-catalog.js +22 -0
  7. package/dist/domain/validation.js +1 -1
  8. package/dist/schemas/code-review-decision.schema.json +26 -0
  9. package/dist/schemas/code-review-result.schema.json +14 -0
  10. package/dist/schemas/code-verification.schema.json +14 -0
  11. package/dist/schemas/code-work-batch.schema.json +24 -0
  12. package/dist/schemas/code-work-result.schema.json +16 -0
  13. package/dist/schemas/compatibility.schema.json +32 -0
  14. package/dist/schemas/flow-contract.schema.json +9 -5
  15. package/dist/schemas/flow-run.schema.json +16 -123
  16. package/dist/schemas/plan-aspect-map.schema.json +22 -0
  17. package/dist/schemas/plan-review-decision.schema.json +14 -0
  18. package/dist/schemas/plan-review-result.schema.json +42 -0
  19. package/dist/schemas/protocol-plan.schema.json +15 -182
  20. package/dist/schemas/stage-finish-input.schema.json +16 -2
  21. package/dist/schemas/stage-report.schema.json +8 -7
  22. package/dist/schemas/stage-start-response.schema.json +4 -2
  23. package/dist/schemas/status-report.schema.json +76 -0
  24. package/dist/schemas/vnext-protocol-plan.schema.json +37 -0
  25. package/dist/schemas/vnext-protocolize-result.schema.json +29 -0
  26. package/dist/schemas/vnext-specify.schema.json +45 -0
  27. package/dist/services/branch-context.js +1 -1
  28. package/dist/services/cleanup.js +8 -8
  29. package/dist/services/cli-operation-classifier.js +10 -2
  30. package/dist/services/code-checks.js +244 -0
  31. package/dist/services/config.js +7 -1
  32. package/dist/services/dashboard.js +12 -12
  33. package/dist/services/engines.js +1 -1
  34. package/dist/services/eval-snapshots.js +404 -0
  35. package/dist/services/hooks.js +774 -18
  36. package/dist/services/ids.js +16 -6
  37. package/dist/services/lanes.js +1 -1
  38. package/dist/services/merge-queue.js +5 -5
  39. package/dist/services/merge-worker.js +2 -2
  40. package/dist/services/migrations.js +2 -2
  41. package/dist/services/plan-runtime.js +1 -1
  42. package/dist/services/projects.js +4 -4
  43. package/dist/services/prompts.js +17 -11
  44. package/dist/services/protocols.js +8 -8
  45. package/dist/services/run-projection.js +49 -13
  46. package/dist/services/runs.js +504 -51
  47. package/dist/services/schema-validation.js +21 -3
  48. package/dist/services/sessions.js +51 -12
  49. package/dist/services/stage-blocker.js +57 -0
  50. package/dist/services/stage-context.js +90 -0
  51. package/dist/services/stage-lifecycle.js +198 -75
  52. package/dist/services/stage-pause.js +175 -0
  53. package/dist/services/stage-report-renderer.js +65 -0
  54. package/dist/services/usage.js +526 -18
  55. package/dist/services/vnext-code-review.js +305 -0
  56. package/dist/services/vnext-code.js +686 -0
  57. package/dist/services/vnext-contracts.js +1 -0
  58. package/dist/services/vnext-execution-profile.js +27 -0
  59. package/dist/services/vnext-fanout.js +79 -0
  60. package/dist/services/vnext-plan-review.js +499 -0
  61. package/dist/services/vnext-plan.js +552 -0
  62. package/dist/services/vnext-protocolize.js +542 -0
  63. package/dist/services/vnext-specify.js +595 -0
  64. package/dist/services/vnext-workspace-policy.js +87 -0
  65. package/dist/services/work-registry.js +522 -0
  66. package/dist/services/worktrees.js +58 -37
  67. package/dist/storage/database.js +263 -34
  68. package/dist/storage/paths.js +47 -1
  69. 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,6 +5,74 @@ 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
+ /** 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
+ }
8
76
  export function getDatabase(ddFlowHome, mode = "initialize") {
9
77
  const dbPath = path.join(ddFlowHome, "db.sqlite");
10
78
  const exists = fs.existsSync(dbPath);
@@ -15,9 +83,9 @@ export function getDatabase(ddFlowHome, mode = "initialize") {
15
83
  ensureDir(ddFlowHome);
16
84
  const db = mode === "read_existing" ? new DatabaseSync(dbPath, { readOnly: true }) : new DatabaseSync(dbPath);
17
85
  configureDatabase(db, mode);
18
- // Existing storage is never schema-migrated by an ordinary initialize open.
19
- // Only the explicit migrate handle is allowed to execute DDL on it.
20
- if (!exists || mode === "migrate")
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")
21
89
  migrate(db);
22
90
  if (mode === "read_existing")
23
91
  db.exec("PRAGMA query_only = ON");
@@ -25,6 +93,7 @@ export function getDatabase(ddFlowHome, mode = "initialize") {
25
93
  return {
26
94
  path: dbPath,
27
95
  writable: mode !== "read_existing",
96
+ close: () => db.close?.(),
28
97
  exec: (sql) => db.exec(sql),
29
98
  run: (sql, params = []) => db.prepare(sql).run(...params),
30
99
  get: (sql, params = []) => db.prepare(sql).get(...params),
@@ -269,9 +338,18 @@ function migrate(db) {
269
338
  updated_at TEXT NOT NULL
270
339
  );
271
340
 
272
- CREATE TABLE IF NOT EXISTS flow_sessions (
341
+ CREATE TABLE IF NOT EXISTS sessions (
273
342
  session_id TEXT NOT NULL,
274
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,
275
353
  project_root TEXT NOT NULL,
276
354
  flow_kind TEXT NOT NULL,
277
355
  status TEXT NOT NULL,
@@ -296,7 +374,7 @@ function migrate(db) {
296
374
  FOREIGN KEY(project_id) REFERENCES projects(id)
297
375
  );
298
376
 
299
- CREATE TABLE IF NOT EXISTS flow_runs (
377
+ CREATE TABLE IF NOT EXISTS runs (
300
378
  id TEXT NOT NULL,
301
379
  short_id TEXT NOT NULL,
302
380
  slug TEXT NOT NULL,
@@ -313,6 +391,7 @@ function migrate(db) {
313
391
  run_dir TEXT NOT NULL,
314
392
  run_index_path TEXT NOT NULL,
315
393
  run_home_path TEXT,
394
+ run_root TEXT,
316
395
  layout_version TEXT,
317
396
  artifact_root_kind TEXT,
318
397
  index_json TEXT NOT NULL,
@@ -324,10 +403,76 @@ function migrate(db) {
324
403
  );
325
404
 
326
405
  CREATE INDEX IF NOT EXISTS idx_flow_runs_project_updated
327
- ON flow_runs(project_id, updated_at DESC);
406
+ ON runs(project_id, updated_at DESC);
328
407
 
329
408
  CREATE INDEX IF NOT EXISTS idx_flow_runs_short_id
330
- 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);
331
476
 
332
477
  CREATE TABLE IF NOT EXISTS flow_run_flag_mutations (
333
478
  project_id TEXT NOT NULL,
@@ -341,7 +486,7 @@ function migrate(db) {
341
486
  FOREIGN KEY(project_id) REFERENCES projects(id)
342
487
  );
343
488
 
344
- CREATE TABLE IF NOT EXISTS flow_run_usage_snapshots (
489
+ CREATE TABLE IF NOT EXISTS usage (
345
490
  id TEXT PRIMARY KEY,
346
491
  project_id TEXT NOT NULL,
347
492
  run_id TEXT NOT NULL,
@@ -359,6 +504,10 @@ function migrate(db) {
359
504
  output_tokens INTEGER,
360
505
  reasoning_output_tokens INTEGER,
361
506
  source_kind TEXT NOT NULL,
507
+ source_locator TEXT,
508
+ source_sha256 TEXT,
509
+ source_size_bytes INTEGER,
510
+ source_mtime_ms INTEGER,
362
511
  token_event_at TEXT,
363
512
  turn_id TEXT,
364
513
  turn_attribution TEXT NOT NULL,
@@ -370,14 +519,55 @@ function migrate(db) {
370
519
  );
371
520
 
372
521
  CREATE INDEX IF NOT EXISTS idx_flow_run_usage_snapshots_run
373
- 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);
374
523
 
375
- 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 (
376
554
  id INTEGER PRIMARY KEY AUTOINCREMENT,
377
555
  project_id TEXT NOT NULL,
378
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,
379
561
  session_id TEXT,
562
+ agent_id TEXT,
380
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,
381
571
  event_key TEXT,
382
572
  event_name TEXT NOT NULL,
383
573
  tool_name TEXT,
@@ -460,32 +650,71 @@ function migrate(db) {
460
650
  ensureColumn(db, "merge_queue", "completed_at", "ALTER TABLE merge_queue ADD COLUMN completed_at TEXT");
461
651
  ensureColumn(db, "codex_session_bindings", "continuation_count", "ALTER TABLE codex_session_bindings ADD COLUMN continuation_count INTEGER NOT NULL DEFAULT 0");
462
652
  ensureColumn(db, "codex_session_bindings", "last_action_hash", "ALTER TABLE codex_session_bindings ADD COLUMN last_action_hash TEXT");
463
- ensureColumn(db, "flow_sessions", "current_stage", "ALTER TABLE flow_sessions ADD COLUMN current_stage TEXT");
464
- ensureColumn(db, "flow_sessions", "next_action", "ALTER TABLE flow_sessions ADD COLUMN next_action TEXT");
465
- ensureColumn(db, "flow_sessions", "metadata_json", "ALTER TABLE flow_sessions ADD COLUMN metadata_json TEXT NOT NULL DEFAULT '{}'");
466
- ensureColumn(db, "flow_sessions", "run_id", "ALTER TABLE flow_sessions ADD COLUMN run_id TEXT");
467
- ensureColumn(db, "flow_sessions", "parent_session_id", "ALTER TABLE flow_sessions ADD COLUMN parent_session_id TEXT");
468
- ensureColumn(db, "flow_sessions", "role", "ALTER TABLE flow_sessions ADD COLUMN role TEXT");
469
- ensureColumn(db, "flow_sessions", "aspect_id", "ALTER TABLE flow_sessions ADD COLUMN aspect_id TEXT");
470
- ensureColumn(db, "flow_sessions", "plan_item_id", "ALTER TABLE flow_sessions ADD COLUMN plan_item_id TEXT");
471
- ensureColumn(db, "flow_sessions", "session_kind", "ALTER TABLE flow_sessions ADD COLUMN session_kind TEXT");
472
- ensureColumn(db, "flow_sessions", "coverage_units_json", "ALTER TABLE flow_sessions ADD COLUMN coverage_units_json TEXT NOT NULL DEFAULT '[]'");
473
- ensureColumn(db, "flow_run_usage_snapshots", "cache_read_input_tokens", "ALTER TABLE flow_run_usage_snapshots ADD COLUMN cache_read_input_tokens INTEGER");
474
- ensureColumn(db, "flow_run_usage_snapshots", "cache_write_input_tokens", "ALTER TABLE flow_run_usage_snapshots ADD COLUMN cache_write_input_tokens INTEGER");
475
- ensureColumn(db, "flow_run_usage_snapshots", "uncached_input_tokens", "ALTER TABLE flow_run_usage_snapshots ADD COLUMN uncached_input_tokens INTEGER");
476
- 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();
477
- ensureColumn(db, "flow_runs", "run_home_path", "ALTER TABLE flow_runs ADD COLUMN run_home_path TEXT");
478
- ensureColumn(db, "flow_runs", "layout_version", "ALTER TABLE flow_runs ADD COLUMN layout_version TEXT");
479
- ensureColumn(db, "flow_runs", "artifact_root_kind", "ALTER TABLE flow_runs ADD COLUMN artifact_root_kind TEXT");
480
- 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'");
481
709
  ensureColumn(db, "flow_jobs", "updated_at", "ALTER TABLE flow_jobs ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''");
482
- 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)");
483
712
  ensureColumn(db, "lane_waiters", "expires_at", "ALTER TABLE lane_waiters ADD COLUMN expires_at TEXT");
484
713
  db.exec(`
485
714
  CREATE INDEX IF NOT EXISTS idx_flow_runs_project_updated
486
- ON flow_runs(project_id, updated_at DESC);
715
+ ON runs(project_id, updated_at DESC);
487
716
  CREATE INDEX IF NOT EXISTS idx_flow_runs_short_id
488
- ON flow_runs(short_id);
717
+ ON runs(short_id);
489
718
  `);
490
719
  }
491
720
  function migrateProjectScopedIdentity(db) {
@@ -500,7 +729,7 @@ function migrateProjectScopedIdentity(db) {
500
729
 
501
730
  ALTER TABLE merge_queue RENAME TO merge_queue_legacy_global_identity;
502
731
  ALTER TABLE worktree_records RENAME TO worktree_records_legacy_global_identity;
503
- ALTER TABLE flow_runs RENAME TO flow_runs_legacy_global_identity;
732
+ ALTER TABLE runs RENAME TO flow_runs_legacy_global_identity;
504
733
  ALTER TABLE protocols RENAME TO protocols_legacy_global_identity;
505
734
 
506
735
  CREATE TABLE protocols (
@@ -581,7 +810,7 @@ function migrateProjectScopedIdentity(db) {
581
810
  status, last_command_result_json, created_at, updated_at, closed_at
582
811
  FROM worktree_records_legacy_global_identity;
583
812
 
584
- CREATE TABLE flow_runs (
813
+ CREATE TABLE runs (
585
814
  id TEXT NOT NULL,
586
815
  short_id TEXT NOT NULL,
587
816
  slug TEXT NOT NULL,
@@ -607,7 +836,7 @@ function migrateProjectScopedIdentity(db) {
607
836
  PRIMARY KEY(project_id, id),
608
837
  FOREIGN KEY(project_id) REFERENCES projects(id)
609
838
  );
610
- INSERT INTO flow_runs
839
+ INSERT INTO runs
611
840
  (id, short_id, slug, project_id, project_root, workspace_root, flow_kind,
612
841
  subject_type, subject_id, status, verdict, next_action, runtime_path,
613
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,15 +16,6 @@
16
16
  "node": ">=26.0.0",
17
17
  "pnpm": ">=10.0.0"
18
18
  },
19
- "scripts": {
20
- "build": "tsc -p tsconfig.build.json && node scripts/generate-build-info.mjs && node scripts/copy-assets.mjs",
21
- "typecheck": "tsc --noEmit",
22
- "lint": "eslint . --max-warnings=0",
23
- "test": "vitest run",
24
- "changeset": "changeset",
25
- "version-packages": "changeset version",
26
- "release": "pnpm run build && changeset publish"
27
- },
28
19
  "devDependencies": {
29
20
  "@changesets/cli": "^2.31.0",
30
21
  "@eslint/js": "^9.39.1",
@@ -42,5 +33,14 @@
42
33
  "publishConfig": {
43
34
  "access": "public"
44
35
  },
45
- "license": "MIT"
46
- }
36
+ "license": "MIT",
37
+ "scripts": {
38
+ "build": "tsc -p tsconfig.build.json && node scripts/generate-build-info.mjs && node scripts/copy-assets.mjs",
39
+ "typecheck": "tsc --noEmit",
40
+ "lint": "eslint . --max-warnings=0",
41
+ "test": "vitest run",
42
+ "changeset": "changeset",
43
+ "version-packages": "changeset version",
44
+ "release": "pnpm run build && changeset publish"
45
+ }
46
+ }