@deksden-com/dd-flow-cli 0.4.2 → 0.6.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 (53) hide show
  1. package/CHANGELOG.md +33 -4
  2. package/README.md +2 -2
  3. package/dist/build-info.json +6 -6
  4. package/dist/cli/help.js +16 -4
  5. package/dist/cli/run-cli.js +42 -17
  6. package/dist/domain/flow-contract.js +82 -3
  7. package/dist/domain/session-coverage.js +88 -0
  8. package/dist/domain/validation.js +56 -28
  9. package/dist/protocol/local-files.js +1 -16
  10. package/dist/schemas/code-stage-report.schema.json +2 -2
  11. package/dist/schemas/flow-contract.schema.json +152 -94
  12. package/dist/schemas/flow-run.schema.json +129 -23
  13. package/dist/schemas/mb-upgrade-review-data.schema.json +2 -2
  14. package/dist/schemas/memorybank-permissions-preflight.schema.json +13 -73
  15. package/dist/schemas/merge-stage-report.schema.json +2 -2
  16. package/dist/schemas/plan-stage-report.schema.json +38 -335
  17. package/dist/schemas/project-flow-pack-manifest.schema.json +4 -4
  18. package/dist/schemas/protocol-plan.schema.json +197 -0
  19. package/dist/schemas/release-impact.schema.json +9 -5
  20. package/dist/schemas/session-usage.schema.json +16 -0
  21. package/dist/schemas/stage-finish-input.schema.json +20 -0
  22. package/dist/schemas/stage-prompt.schema.json +35 -0
  23. package/dist/schemas/stage-report.schema.json +20 -0
  24. package/dist/schemas/stage-start-response.schema.json +30 -0
  25. package/dist/schemas/timeline-event.schema.json +29 -0
  26. package/dist/schemas/worktrunk-workspace.schema.json +19 -0
  27. package/dist/services/branch-context.js +9 -4
  28. package/dist/services/cleanup.js +77 -0
  29. package/dist/services/dashboard.js +51 -26
  30. package/dist/services/engines.js +84 -18
  31. package/dist/services/hooks.js +101 -250
  32. package/dist/services/memory-permissions.js +77 -69
  33. package/dist/services/migrations.js +1 -1
  34. package/dist/services/plan-runtime.js +124 -0
  35. package/dist/services/plans.js +22 -84
  36. package/dist/services/projects.js +2 -1
  37. package/dist/services/prompts.js +26 -21
  38. package/dist/services/protocols.js +29 -25
  39. package/dist/services/run-projection.js +93 -13
  40. package/dist/services/runs.js +128 -61
  41. package/dist/services/schema-validation.js +168 -7
  42. package/dist/services/sessions.js +97 -73
  43. package/dist/services/stage-lifecycle.js +737 -0
  44. package/dist/services/tooling.js +285 -0
  45. package/dist/services/usage.js +183 -30
  46. package/dist/services/version-status.js +1 -1
  47. package/dist/services/worktrees.js +88 -39
  48. package/dist/storage/database.js +72 -30
  49. package/dist/storage/paths.js +0 -9
  50. package/package.json +14 -13
  51. package/tools/worktrunk-manifest.json +34 -0
  52. package/dist/schemas/flow-run-index-v3.schema.json +0 -203
  53. package/dist/schemas/flow-run-index.schema.json +0 -175
@@ -5,17 +5,15 @@ import { spawnSync } from "node:child_process";
5
5
  import { formatFullId, isFullEntityId, isShortEntityId, parseFullEntityId } from "../domain/entity-ids.js";
6
6
  import { checksumForFlowFlagValues, isFlowFlagDowngrade, loadProjectFlowContract, resolveFlowFlags } from "../domain/flow-contract.js";
7
7
  import { AppError } from "../shared/errors.js";
8
- import { ensureDir, projectRunHome, projectRunIndexPath, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
8
+ import { ensureDir, projectRunHome, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
9
9
  import { appendAudit } from "./audit.js";
10
10
  import { registerProject, requireProjectByRoot } from "./projects.js";
11
11
  import { buildRunFlowGuidance } from "./flow-guidance.js";
12
12
  import { readProtocolRuntimeState, requireProtocol } from "./protocols.js";
13
13
  import { checkpointRunUsage, usageForRun } from "./usage.js";
14
14
  import { refreshRunSessionProjection } from "./run-projection.js";
15
- const runSchemaId = "dd-flow/flow-run-index@3";
16
- const legacyRunSchemaId = "dd-flow/flow-run-index@2";
17
- const oldestRunSchemaId = "dd-flow/flow-run-index@1";
18
- const runtimeSchemaId = "dd-flow/flow-run@1";
15
+ const runSchemaId = "dd-flow/flow-run@2";
16
+ const runtimeSchemaId = "dd-flow/flow-run@2";
19
17
  const runIdType = "RUN";
20
18
  const allowedRunFlowKinds = [
21
19
  "mb_sdlc",
@@ -51,7 +49,6 @@ export function startFlowRun(context, input) {
51
49
  now
52
50
  });
53
51
  const runHome = projectRunHome(context.ddFlowHome, project.id, runId);
54
- const runIndexAbsolute = projectRunIndexPath(context.ddFlowHome, project.id, runId);
55
52
  const runDirRelative = path.posix.join("runs", runId);
56
53
  const runtimePath = projectRunJsonPath(context.ddFlowHome, project.id, runId);
57
54
  const index = {
@@ -60,16 +57,19 @@ export function startFlowRun(context, input) {
60
57
  short_id: shortId,
61
58
  flow_kind: flowKind,
62
59
  subject: {
63
- type: requiredPlain(input.subjectType, "subject-type"),
64
- id: requiredPlain(input.subjectId, "subject-id")
60
+ id: requiredPlain(input.subjectId, "subject-id"),
61
+ title: requiredPlain(input.subjectId, "subject-id")
65
62
  },
66
63
  project: {
67
64
  id: project.id,
68
- root: project.root
65
+ title: path.basename(project.root)
69
66
  },
70
67
  workspace: {
71
- root: workspaceRoot,
72
- run_dir: runDirRelative
68
+ project_root: project.root,
69
+ workspace_path: workspaceRoot,
70
+ branch: gitFacts(workspaceRoot).branch ?? "unknown",
71
+ base: gitFacts(workspaceRoot).head ?? "unknown",
72
+ manager: "official-worktrunk"
73
73
  },
74
74
  run_home: {
75
75
  storage: "dd_flow_home",
@@ -86,7 +86,7 @@ export function startFlowRun(context, input) {
86
86
  },
87
87
  stage_runs: [],
88
88
  sessions: [],
89
- artifacts: [],
89
+ artifacts: { stage_prompt: path.join(runHome, "stage-prompt.md"), stage_report_json: path.join(runHome, "stage-report.json"), stage_report_md: path.join(runHome, "stage-report.md"), stage_report_html: path.join(runHome, "stage-report.html"), summary: path.join(runHome, "summary.md") },
90
90
  status: "running",
91
91
  verdict: "pending",
92
92
  next_action: input.nextAction ?? null,
@@ -98,12 +98,16 @@ export function startFlowRun(context, input) {
98
98
  snapshot_revision: flowFlags.snapshot_revision,
99
99
  snapshot_checksum: flowFlags.snapshot_checksum,
100
100
  reconciliation_status: "reconciled",
101
- flow_flags: flowFlags
101
+ flow_flags: flowFlags,
102
+ current_stage: null,
103
+ attempts: [],
104
+ session_coverage: { status: "unavailable", expected: [], observed: [], missing: [] },
105
+ usage_coverage: { status: "unavailable", expected: [], observed: [], missing: [] },
106
+ timeline_path: "timeline.jsonl"
102
107
  };
103
108
  ensureDir(path.dirname(runtimePath));
104
109
  ensureDir(runHome);
105
110
  writeJsonFile(runtimePath, runtimeSnapshotForIndex(index, 1));
106
- writeJsonFile(runIndexAbsolute, index);
107
111
  context.db.run(`INSERT INTO flow_runs
108
112
  (id, short_id, slug, project_id, project_root, workspace_root, flow_kind, subject_type, subject_id,
109
113
  status, verdict, next_action, runtime_path, run_dir, run_index_path, run_home_path, layout_version, artifact_root_kind,
@@ -116,14 +120,14 @@ export function startFlowRun(context, input) {
116
120
  project.root,
117
121
  workspaceRoot,
118
122
  flowKind,
119
- index.subject.type,
123
+ requiredPlain(input.subjectType, "subject-type"),
120
124
  index.subject.id,
121
125
  index.status,
122
126
  index.verdict,
123
127
  index.next_action,
124
128
  runtimePath,
125
129
  runHome,
126
- runIndexAbsolute,
130
+ runtimePath,
127
131
  runHome,
128
132
  "home_run_v2",
129
133
  "dd_flow_home",
@@ -152,14 +156,14 @@ export function getFlowRunStatus(context, input) {
152
156
  const run = resolveRun(context, project.id, input.runId);
153
157
  refreshRunSessionProjection(context, project.id, run.id);
154
158
  const refreshedRun = requireRunById(context, project.id, run.id);
155
- const index = parseRunIndex(refreshedRun.index_json);
159
+ const index = authoritativeIndex(refreshedRun);
156
160
  const runtime = readRuntimeSnapshot(refreshedRun.runtime_path);
157
161
  return { ok: true, run: flowRunSummary(refreshedRun), index, ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, refreshedRun, index) };
158
162
  }
159
163
  export function getFlowRunFlagsStatus(context, input) {
160
164
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
161
165
  const run = resolveRun(context, project.id, input.runId);
162
- const index = parseRunIndex(run.index_json);
166
+ const index = authoritativeIndex(run);
163
167
  const runtime = readRuntimeSnapshot(run.runtime_path);
164
168
  const flags = runtime?.flow_flags ?? index.flow_flags;
165
169
  if (!flags) {
@@ -199,7 +203,7 @@ export function reviseFlowRunFlags(context, input) {
199
203
  // Read the authority only after the write lock: two callers with the same
200
204
  // expected revision must not both pass the compare-and-set check.
201
205
  const lockedRun = requireRunById(context, project.id, run.id);
202
- const index = parseRunIndex(lockedRun.index_json);
206
+ const index = authoritativeIndex(lockedRun);
203
207
  const runtime = readRuntimeSnapshot(lockedRun.runtime_path);
204
208
  const current = runtime?.flow_flags ?? index.flow_flags;
205
209
  if (!current) {
@@ -268,7 +272,7 @@ export function reviseFlowRunFlags(context, input) {
268
272
  flow_flags: next,
269
273
  ...(reason ? { reason } : {})
270
274
  };
271
- persistRunIndex(context, project, run, index, {
275
+ persistRunState(context, project, run, index, {
272
276
  flagRevision: { revision: next.snapshot_revision, checksum: next.snapshot_checksum, at: now, idempotency_key: idempotencyKey }
273
277
  });
274
278
  context.db.run(`INSERT INTO flow_run_flag_mutations
@@ -310,7 +314,7 @@ export function listFlowRuns(context, input) {
310
314
  export function attachFlowRunStage(context, input) {
311
315
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
312
316
  const run = resolveRun(context, project.id, input.runId);
313
- const index = parseRunIndex(run.index_json);
317
+ const index = authoritativeIndex(run);
314
318
  const now = context.now();
315
319
  const stage = requiredPlain(input.stage, "stage");
316
320
  const dir = requiredStageDir(input.dir);
@@ -325,13 +329,20 @@ export function attachFlowRunStage(context, input) {
325
329
  dir,
326
330
  status,
327
331
  ...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : existing?.data_schema_id ? { data_schema_id: existing.data_schema_id } : {}),
328
- updated_at: now,
329
- ...(status === "running" && !existing?.started_at ? { started_at: now } : {}),
330
- ...(status === "running" ? { attempt: nextAttempt(existing) } : {})
332
+ updated_at: now
331
333
  };
334
+ if (status === "running") {
335
+ stageRun.started_at = now;
336
+ delete stageRun.completed_at;
337
+ stageRun.duration_ms = null;
338
+ stageRun.git = gitFacts(run.workspace_root);
339
+ stageRun.attempt = nextAttempt(existing);
340
+ }
332
341
  upsertStage(index, stageRun);
342
+ index.current_stage = stage;
343
+ index.attempts = index.stage_runs.map((item) => ({ stage: item.stage, attempt_number: Number((item.attempt ?? "try-001").replace("try-", "")) || 1, root: item.dir, archive: null, status: item.status, started_at: item.started_at ?? now, finished_at: item.completed_at ?? null }));
333
344
  index.updated_at = now;
334
- persistRunIndex(context, project, run, index);
345
+ persistRunState(context, project, run, index);
335
346
  appendAudit(context, {
336
347
  projectId: project.id,
337
348
  eventType: "flow_run.stage_attached",
@@ -354,7 +365,7 @@ export function attachFlowRunStage(context, input) {
354
365
  export function completeFlowRunStage(context, input) {
355
366
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
356
367
  const run = resolveRun(context, project.id, input.runId);
357
- const index = parseRunIndex(run.index_json);
368
+ const index = authoritativeIndex(run);
358
369
  const now = context.now();
359
370
  const stage = requiredPlain(input.stage, "stage");
360
371
  const status = parseStageStatus(input.status);
@@ -371,11 +382,20 @@ export function completeFlowRunStage(context, input) {
371
382
  ...(input.report ? { report: input.report } : {}),
372
383
  ...(input.aliases && input.aliases.length > 0 ? { artifact_aliases: input.aliases } : {}),
373
384
  updated_at: now,
374
- ...(status === "done" || status === "blocked" || status === "failed" || status === "skipped" ? { completed_at: now } : {})
385
+ ...(status === "done" || status === "blocked" || status === "failed" || status === "skipped"
386
+ ? { completed_at: now, duration_ms: durationMs(existing.started_at ?? null, now), git: gitFacts(run.workspace_root) }
387
+ : {})
375
388
  };
376
389
  upsertStage(index, stageRun);
390
+ index.current_stage = stage;
391
+ index.attempts = index.stage_runs.map((item) => ({ stage: item.stage, attempt_number: Number((item.attempt ?? "try-001").replace("try-", "")) || 1, root: item.dir, archive: null, status: item.status, started_at: item.started_at ?? now, finished_at: item.completed_at ?? null }));
377
392
  index.updated_at = now;
378
- persistRunIndex(context, project, run, index);
393
+ index.execution = {
394
+ project_root: index.execution?.project_root ?? run.project_root,
395
+ workspace_root: index.execution?.workspace_root ?? run.workspace_root,
396
+ git: gitFacts(run.workspace_root)
397
+ };
398
+ persistRunState(context, project, run, index);
379
399
  refreshRunSessionProjection(context, project.id, run.id);
380
400
  appendAudit(context, {
381
401
  projectId: project.id,
@@ -390,7 +410,7 @@ export function completeFlowRunStage(context, input) {
390
410
  export function completeFlowRun(context, input) {
391
411
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
392
412
  const run = resolveRun(context, project.id, input.runId);
393
- const index = parseRunIndex(run.index_json);
413
+ const index = authoritativeIndex(run);
394
414
  const now = context.now();
395
415
  const status = parseRunStatus(input.status);
396
416
  index.status = status;
@@ -399,8 +419,16 @@ export function completeFlowRun(context, input) {
399
419
  index.updated_at = now;
400
420
  if (["done", "blocked", "cancelled", "failed"].includes(status)) {
401
421
  index.completed_at = now;
422
+ index.finished_at = now;
423
+ index.duration_ms = durationMs(index.started_at ?? run.created_at, now);
424
+ index.execution = {
425
+ project_root: index.execution?.project_root ?? run.project_root,
426
+ workspace_root: index.execution?.workspace_root ?? run.workspace_root,
427
+ git: gitFacts(run.workspace_root)
428
+ };
402
429
  }
403
- persistRunIndex(context, project, run, index);
430
+ persistRunState(context, project, run, index);
431
+ closeRunOrchestrators(context, project.id, run, status, now);
404
432
  refreshRunSessionProjection(context, project.id, run.id);
405
433
  appendAudit(context, {
406
434
  projectId: project.id,
@@ -412,11 +440,35 @@ export function completeFlowRun(context, input) {
412
440
  const updatedRun = requireRunById(context, project.id, run.id);
413
441
  return { ok: true, run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
414
442
  }
443
+ function closeRunOrchestrators(context, projectId, run, status, now) {
444
+ if (!["done", "blocked", "cancelled", "failed"].includes(status))
445
+ return;
446
+ const active = context.db.all(`SELECT session_id FROM flow_sessions
447
+ WHERE project_id = ? AND run_id = ? AND session_kind = 'orchestrator'
448
+ AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [projectId, run.id]);
449
+ if (active.length === 0)
450
+ return;
451
+ const reason = `run_${status}`;
452
+ context.db.run(`UPDATE flow_sessions SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
453
+ WHERE project_id = ? AND run_id = ? AND session_kind = 'orchestrator'
454
+ AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [reason, now, now, projectId, run.id]);
455
+ context.db.run(`UPDATE flow_session_segments SET ended_at = ?
456
+ WHERE project_id = ? AND run_id = ? AND session_id IN (${active.map(() => "?").join(", ")}) AND ended_at IS NULL`, [now, projectId, run.id, ...active.map((session) => session.session_id)]);
457
+ for (const session of active) {
458
+ appendRunTimeline(runArtifactRoot(run), {
459
+ at: now,
460
+ type: "session_stopped",
461
+ run_id: run.id,
462
+ session_id: session.session_id,
463
+ reason
464
+ });
465
+ }
466
+ }
415
467
  const timelineSections = ["stages", "events", "sessions", "usage", "artifacts"];
416
468
  export function getFlowRunTimeline(context, input) {
417
469
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
418
470
  const run = resolveRun(context, project.id, input.runId);
419
- const index = parseRunIndex(run.index_json);
471
+ const index = authoritativeIndex(run);
420
472
  const file = path.join(runArtifactRoot(run), "timeline.jsonl");
421
473
  const events = fs.existsSync(file)
422
474
  ? fs.readFileSync(file, "utf8").split(/\r?\n/).flatMap((line) => { try {
@@ -426,7 +478,10 @@ export function getFlowRunTimeline(context, input) {
426
478
  return [];
427
479
  } })
428
480
  : [];
429
- const hidden = parseHiddenTimelineSections(input.hiddenSections ?? []);
481
+ if ((input.hiddenSections ?? []).length > 0) {
482
+ throw new AppError("validation", "Timeline sections are always included", 2, { hidden_sections: input.hiddenSections });
483
+ }
484
+ const hidden = new Set();
430
485
  const sessions = index.sessions;
431
486
  const elapsedMs = durationMs(index.started_at ?? run.created_at, index.completed_at ?? null);
432
487
  const report = {
@@ -481,6 +536,14 @@ export function appendFlowRunTimelineEvent(context, projectId, runId, event) {
481
536
  appendRunTimeline(runArtifactRoot(run), { run_id: run.id, ...event });
482
537
  }
483
538
  function guidanceForRun(context, run, index) {
539
+ if (run.status === "discarded") {
540
+ return buildRunFlowGuidance({
541
+ stageRuns: index.stage_runs,
542
+ protocolStage: "cancelled",
543
+ runId: run.id,
544
+ runDir: index.run_home?.relative_path ?? path.posix.join("runs", run.id)
545
+ });
546
+ }
484
547
  if (run.subject_type === "protocol") {
485
548
  try {
486
549
  const protocol = requireProtocol(context, run.subject_id, run.project_id);
@@ -490,7 +553,7 @@ function guidanceForRun(context, run, index) {
490
553
  protocolStage: state.stage,
491
554
  ...(state.flow_contract ? { contract: state.flow_contract } : {}),
492
555
  runId: run.id,
493
- runDir: index.run_home?.relative_path ?? index.workspace.run_dir
556
+ runDir: index.run_home?.relative_path ?? path.posix.join("runs", run.id)
494
557
  });
495
558
  }
496
559
  catch (error) {
@@ -498,11 +561,10 @@ function guidanceForRun(context, run, index) {
498
561
  throw error;
499
562
  }
500
563
  }
501
- return buildRunFlowGuidance({ stageRuns: index.stage_runs, runId: run.id, runDir: index.run_home?.relative_path ?? index.workspace.run_dir });
564
+ return buildRunFlowGuidance({ stageRuns: index.stage_runs, runId: run.id, runDir: index.run_home?.relative_path ?? path.posix.join("runs", run.id) });
502
565
  }
503
- function persistRunIndex(context, project, run, index, options = {}) {
566
+ function persistRunState(context, project, run, index, options = {}) {
504
567
  const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, project.id, run.id);
505
- const runIndexPath = run.run_index_path || projectRunIndexPath(context.ddFlowHome, project.id, run.id);
506
568
  const previousRuntime = readRuntimeSnapshot(runtimePath);
507
569
  const runtimeRevision = Math.max(index.runtime_revision ?? 0, previousRuntime?.runtime_revision ?? 0) + 1;
508
570
  index.runtime_revision = runtimeRevision;
@@ -511,9 +573,7 @@ function persistRunIndex(context, project, run, index, options = {}) {
511
573
  history.push(options.flagRevision);
512
574
  }
513
575
  ensureDir(path.dirname(runtimePath));
514
- ensureDir(path.dirname(runIndexPath));
515
576
  writeJsonFile(runtimePath, runtimeSnapshotForIndex(index, runtimeRevision, history));
516
- writeJsonFile(runIndexPath, index);
517
577
  context.db.run(`UPDATE flow_runs
518
578
  SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?,
519
579
  updated_at = ?, completed_at = ?
@@ -523,7 +583,7 @@ function persistRunIndex(context, project, run, index, options = {}) {
523
583
  index.next_action,
524
584
  JSON.stringify(index),
525
585
  runtimePath,
526
- runIndexPath,
586
+ runtimePath,
527
587
  index.updated_at,
528
588
  index.completed_at ?? null,
529
589
  project.id,
@@ -539,10 +599,12 @@ function appendRunTimeline(runHome, event) {
539
599
  const last = lines.length > 0 ? JSON.parse(lines[lines.length - 1]) : undefined;
540
600
  sequence = typeof last?.sequence === "number" ? last.sequence : lines.length;
541
601
  }
602
+ const safeEvent = sanitizeTimelineValue(event);
542
603
  const enriched = {
543
604
  event_id: crypto.randomUUID(),
544
605
  sequence: sequence + 1,
545
- ...event
606
+ at: typeof safeEvent.at === "string" ? safeEvent.at : new Date().toISOString(),
607
+ ...safeEvent
546
608
  };
547
609
  fs.appendFileSync(timelinePath, `${JSON.stringify(enriched)}\n`);
548
610
  }
@@ -552,7 +614,6 @@ function runtimeSnapshotForIndex(index, runtimeRevision, flagRevisionHistory = [
552
614
  return {
553
615
  ...rest,
554
616
  schema_id: runtimeSchemaId,
555
- run_index_schema_id: index.schema_id,
556
617
  runtime_revision: runtimeRevision,
557
618
  ...(flagRevisionHistory.length > 0 ? { flag_revision_history: flagRevisionHistory.slice(-32) } : {})
558
619
  };
@@ -568,18 +629,26 @@ function readRuntimeSnapshot(file) {
568
629
  return undefined;
569
630
  }
570
631
  }
571
- function parseHiddenTimelineSections(values) {
572
- const hidden = new Set();
573
- for (const value of values) {
574
- if (!timelineSections.includes(value)) {
575
- throw new AppError("validation", "--hide must name a timeline section", 2, {
576
- value,
577
- allowed: timelineSections
578
- });
579
- }
580
- hidden.add(value);
632
+ function authoritativeIndex(run) {
633
+ const runtime = readRuntimeSnapshot(run.runtime_path);
634
+ if (!runtime) {
635
+ throw new AppError("validation", "RUN authority is missing or invalid", 2, { run_id: run.id, path: run.runtime_path });
636
+ }
637
+ return { ...runtime, schema_id: runSchemaId };
638
+ }
639
+ function sanitizeTimelineValue(value, key) {
640
+ if (key && /(secret|password|authorization|cookie|api[_-]?key|private[_-]?key|access[_-]?token|refresh[_-]?token|transcript|prompt|tool[_-]?output|document[_-]?content)/i.test(key)) {
641
+ return "[REDACTED]";
642
+ }
643
+ if (typeof value === "string") {
644
+ return value.length > 512 ? `${value.slice(0, 512)}…[TRUNCATED]` : value;
581
645
  }
582
- return hidden;
646
+ if (Array.isArray(value))
647
+ return value.map((item) => sanitizeTimelineValue(item));
648
+ if (value && typeof value === "object") {
649
+ return Object.fromEntries(Object.entries(value).map(([entryKey, entryValue]) => [entryKey, sanitizeTimelineValue(entryValue, entryKey)]));
650
+ }
651
+ return value;
583
652
  }
584
653
  function durationMs(startedAt, completedAt) {
585
654
  if (!startedAt || !completedAt)
@@ -669,13 +738,6 @@ function flowRunSummary(run) {
669
738
  completed_at: run.completed_at
670
739
  };
671
740
  }
672
- function parseRunIndex(text) {
673
- const index = JSON.parse(text);
674
- if (index.schema_id !== runSchemaId && index.schema_id !== legacyRunSchemaId && index.schema_id !== oldestRunSchemaId) {
675
- throw new AppError("validation", `Invalid run index schema: ${String(index.schema_id)}`, 2);
676
- }
677
- return index;
678
- }
679
741
  function parseRunFlowKind(value) {
680
742
  const normalized = value === "mb-sdlc" ? "mb_sdlc" : value;
681
743
  if (!allowedRunFlowKinds.includes(normalized)) {
@@ -763,12 +825,17 @@ function writeJsonFile(file, value) {
763
825
  fs.renameSync(tmpFile, file);
764
826
  }
765
827
  function runArtifactRoot(run) {
766
- return path.dirname(run.run_index_path);
828
+ return run.run_home_path ?? path.dirname(run.runtime_path);
767
829
  }
768
830
  function gitFacts(workspaceRoot) {
769
831
  const read = (args) => {
770
832
  const result = spawnSync("git", ["-C", workspaceRoot, ...args.split(" ")], { encoding: "utf8" });
771
833
  return result.status === 0 ? result.stdout.trim() || null : null;
772
834
  };
773
- return { branch: read("branch --show-current"), head: read("rev-parse HEAD") };
835
+ const status = read("status --porcelain --untracked-files=all");
836
+ return {
837
+ branch: read("branch --show-current"),
838
+ head: read("rev-parse HEAD"),
839
+ status: status === null ? "unavailable" : status.length > 0 ? "dirty" : "clean"
840
+ };
774
841
  }
@@ -1,9 +1,65 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { Ajv } from "ajv/dist/ajv.js";
5
6
  import { normalizeFlowContract } from "../domain/flow-contract.js";
6
7
  import { AppError } from "../shared/errors.js";
8
+ export function captureMemoryBankBaseline(input) {
9
+ const projectRoot = path.resolve(input.projectRoot);
10
+ const paths = normalizeMemoryBankPaths(projectRoot, input.paths);
11
+ if (paths.length === 0) {
12
+ throw new AppError("validation", "Memory Bank baseline requires at least one exact target path", 2);
13
+ }
14
+ return {
15
+ schema_id: "dd-flow/memory-bank-baseline@1",
16
+ project_root: projectRoot,
17
+ paths: Object.fromEntries(paths.map((relativePath) => [relativePath, snapshotMemoryBankPath(projectRoot, relativePath)]))
18
+ };
19
+ }
20
+ export function validateMemoryBankDelta(input) {
21
+ const projectRoot = path.resolve(input.projectRoot);
22
+ if (input.baseline.schema_id !== "dd-flow/memory-bank-baseline@1" || path.resolve(input.baseline.project_root) !== projectRoot) {
23
+ throw new AppError("validation", "Memory Bank baseline does not belong to this project root", 2, {
24
+ baseline_root: input.baseline.project_root,
25
+ project_root: projectRoot
26
+ });
27
+ }
28
+ const paths = normalizeMemoryBankPaths(projectRoot, Object.keys(input.baseline.paths));
29
+ if (paths.length === 0) {
30
+ throw new AppError("validation", "Memory Bank baseline has no exact target paths", 2);
31
+ }
32
+ const after = Object.fromEntries(paths.map((relativePath) => [relativePath, snapshotMemoryBankPath(projectRoot, relativePath)]));
33
+ const snapshots = paths.map((relativePath) => {
34
+ const before = input.baseline.paths[relativePath];
35
+ const current = after[relativePath];
36
+ if (!before || !current) {
37
+ throw new AppError("validation", `Memory Bank baseline snapshot is incomplete: ${relativePath}`, 2);
38
+ }
39
+ return { relativePath, before, after: current };
40
+ });
41
+ const changedSnapshots = snapshots.filter(({ before, after: current }) => !sameMemoryBankSnapshot(before, current));
42
+ const changed = changedSnapshots.map(({ relativePath }) => relativePath);
43
+ const added = changedSnapshots.filter(({ before, after: current }) => !before.exists && current.exists).map(({ relativePath }) => relativePath);
44
+ const removed = changedSnapshots.filter(({ before, after: current }) => before.exists && !current.exists).map(({ relativePath }) => relativePath);
45
+ const findings = [];
46
+ for (const { relativePath, after: snapshot } of changedSnapshots) {
47
+ if (!snapshot.exists) {
48
+ findings.push({ path: relativePath, severity: "error", code: "deleted_target", message: "Tracked Memory Bank target was deleted" });
49
+ continue;
50
+ }
51
+ findings.push(...validateChangedMemoryFile(projectRoot, relativePath));
52
+ }
53
+ return {
54
+ schema_id: "dd-flow/memory-bank-delta-validation@1",
55
+ ok: findings.length === 0,
56
+ project_root: projectRoot,
57
+ checked_paths: paths,
58
+ baseline: input.baseline.paths,
59
+ delta: { changed, added, removed },
60
+ findings
61
+ };
62
+ }
7
63
  const schemaNamePattern = /^[a-z0-9][a-z0-9-]*$/;
8
64
  const mandatoryMbUpgradeReviewAspectIds = [
9
65
  "01-goal-and-delivery",
@@ -37,7 +93,7 @@ export function validateSchema(options) {
37
93
  }
38
94
  const filePath = path.resolve(options.file);
39
95
  const data = readJson(filePath, "input file");
40
- const schemaResolution = resolveSchema(options, data);
96
+ const schemaResolution = resolveSchema(options);
41
97
  const schema = readJson(schemaResolution.path, "schema");
42
98
  const ajv = new Ajv({ allErrors: true, strict: false, validateFormats: false });
43
99
  const validate = ajv.compile(schema);
@@ -59,12 +115,8 @@ export function validateSchema(options) {
59
115
  errors: []
60
116
  };
61
117
  }
62
- function resolveSchema(options, data) {
63
- const dataRecord = asRecord(data);
64
- const dataSchemaId = dataRecord ? stringValue(dataRecord, "schema_id") : undefined;
65
- const fileNames = options.schemaName === "flow-run-index" && dataSchemaId === "dd-flow/flow-run-index@3"
66
- ? ["flow-run-index-v3.schema.json", "flow-run-index.schema.json"]
67
- : [`${options.schemaName}.schema.json`];
118
+ function resolveSchema(options) {
119
+ const fileNames = [`${options.schemaName}.schema.json`];
68
120
  const projectRoot = path.resolve(options.projectRoot ?? process.cwd());
69
121
  const roots = [
70
122
  ...(options.schemaDir ? [{ directory: path.resolve(options.schemaDir), source: "schema_dir" }] : []),
@@ -262,6 +314,115 @@ function validateMbSdlcReviewReport(root) {
262
314
  });
263
315
  return errors;
264
316
  }
317
+ function normalizeMemoryBankPaths(projectRoot, inputs) {
318
+ return [...new Set(inputs.map((input) => {
319
+ const absolute = resolveMemoryBankPath(projectRoot, input);
320
+ const relative = path.relative(projectRoot, absolute);
321
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
322
+ throw new AppError("validation", "Memory Bank target must remain inside project root", 2, { target: input });
323
+ }
324
+ return relative;
325
+ }))].sort();
326
+ }
327
+ function resolveMemoryBankPath(projectRoot, input) {
328
+ const absolute = path.isAbsolute(input) ? path.resolve(input) : path.resolve(projectRoot, input);
329
+ const relative = path.relative(projectRoot, absolute);
330
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
331
+ throw new AppError("validation", "Memory Bank target must remain inside project root", 2, { target: input });
332
+ }
333
+ const existing = nearestExistingPath(absolute);
334
+ if (existing) {
335
+ const realRoot = fs.realpathSync(projectRoot);
336
+ const realExisting = fs.realpathSync(existing);
337
+ const realResolved = path.resolve(realExisting, path.relative(existing, absolute));
338
+ const realRelative = path.relative(realRoot, realResolved);
339
+ if (realRelative.startsWith("..") || path.isAbsolute(realRelative)) {
340
+ throw new AppError("validation", "Memory Bank target escapes project root through a symlink", 2, { target: input });
341
+ }
342
+ }
343
+ return absolute;
344
+ }
345
+ function nearestExistingPath(target) {
346
+ let current = target;
347
+ while (!fs.existsSync(current)) {
348
+ const parent = path.dirname(current);
349
+ if (parent === current)
350
+ return undefined;
351
+ current = parent;
352
+ }
353
+ return current;
354
+ }
355
+ function snapshotMemoryBankPath(projectRoot, relativePath) {
356
+ const absolute = resolveMemoryBankPath(projectRoot, relativePath);
357
+ if (!fs.existsSync(absolute))
358
+ return { exists: false, bytes: null, sha256: null };
359
+ let stat;
360
+ let content;
361
+ try {
362
+ stat = fs.statSync(absolute);
363
+ if (!stat.isFile())
364
+ throw new Error("target is not a file");
365
+ content = fs.readFileSync(absolute);
366
+ }
367
+ catch (error) {
368
+ throw new AppError("validation", `Cannot snapshot Memory Bank target: ${relativePath}`, 2, { cause: String(error) });
369
+ }
370
+ return {
371
+ exists: true,
372
+ bytes: content.byteLength,
373
+ sha256: crypto.createHash("sha256").update(content).digest("hex")
374
+ };
375
+ }
376
+ function sameMemoryBankSnapshot(before, after) {
377
+ return Boolean(before)
378
+ && before?.exists === after.exists
379
+ && before?.bytes === after.bytes
380
+ && before?.sha256 === after.sha256;
381
+ }
382
+ function validateChangedMemoryFile(projectRoot, relativePath) {
383
+ const absolute = resolveMemoryBankPath(projectRoot, relativePath);
384
+ let content;
385
+ try {
386
+ content = fs.readFileSync(absolute, "utf8");
387
+ }
388
+ catch (error) {
389
+ return [{ path: relativePath, severity: "error", code: "unreadable_target", message: String(error) }];
390
+ }
391
+ const findings = [];
392
+ if (path.extname(relativePath).toLowerCase() === ".json") {
393
+ try {
394
+ JSON.parse(content);
395
+ }
396
+ catch (error) {
397
+ findings.push({ path: relativePath, severity: "error", code: "invalid_json", message: String(error) });
398
+ }
399
+ }
400
+ const markdownLinkPattern = /\]\((<[^>]+>|[^)\s]+)(?:\s+["'][^)]*["'])?\)/g;
401
+ for (const match of content.matchAll(markdownLinkPattern)) {
402
+ const capturedTarget = match[1];
403
+ if (!capturedTarget)
404
+ continue;
405
+ const rawTarget = capturedTarget.replace(/^<|>$/g, "");
406
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(rawTarget))
407
+ continue;
408
+ const linkTarget = rawTarget.split(/[?#]/, 1)[0];
409
+ if (!linkTarget)
410
+ continue;
411
+ let resolved;
412
+ try {
413
+ const linkedAbsolute = path.resolve(path.dirname(path.join(projectRoot, relativePath)), linkTarget);
414
+ resolved = resolveMemoryBankPath(projectRoot, path.relative(projectRoot, linkedAbsolute));
415
+ }
416
+ catch (error) {
417
+ findings.push({ path: relativePath, severity: "error", code: "path_escape", message: String(error) });
418
+ continue;
419
+ }
420
+ if (!fs.existsSync(resolved)) {
421
+ findings.push({ path: relativePath, severity: "error", code: "missing_link", message: `Linked path does not exist: ${linkTarget}` });
422
+ }
423
+ }
424
+ return findings;
425
+ }
265
426
  function asRecord(value) {
266
427
  return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
267
428
  }