@deksden-com/dd-flow-cli 0.4.0 → 0.4.1

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 (43) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +25 -9
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +45 -29
  5. package/dist/cli/run-cli.js +229 -49
  6. package/dist/domain/entity-ids.js +4 -4
  7. package/dist/domain/flow-contract.js +502 -36
  8. package/dist/domain/validation.js +34 -0
  9. package/dist/protocol/local-files.js +8 -6
  10. package/dist/schemas/code-stage-report.schema.json +197 -2
  11. package/dist/schemas/flow-contract.schema.json +126 -0
  12. package/dist/schemas/flow-run-index-v3.schema.json +203 -0
  13. package/dist/schemas/flow-run-index.schema.json +22 -2
  14. package/dist/schemas/flow-run.schema.json +36 -0
  15. package/dist/schemas/merge-stage-report.schema.json +213 -2
  16. package/dist/schemas/plan-stage-report.schema.json +156 -2
  17. package/dist/schemas/release-impact.schema.json +16 -0
  18. package/dist/services/audit.js +3 -3
  19. package/dist/services/branch-context.js +17 -5
  20. package/dist/services/canon.js +0 -1
  21. package/dist/services/cleanup.js +6 -6
  22. package/dist/services/cli-operation-classifier.js +1 -1
  23. package/dist/services/compatibility-preflight.js +3 -75
  24. package/dist/services/dashboard.js +48 -11
  25. package/dist/services/engines.js +123 -13
  26. package/dist/services/hooks.js +6 -6
  27. package/dist/services/ids.js +40 -49
  28. package/dist/services/merge-queue.js +33 -26
  29. package/dist/services/merge-worker.js +2 -1
  30. package/dist/services/migrations.js +64 -0
  31. package/dist/services/plans.js +23 -16
  32. package/dist/services/projects.js +2 -2
  33. package/dist/services/prompts.js +322 -0
  34. package/dist/services/protocols.js +77 -46
  35. package/dist/services/run-projection.js +80 -0
  36. package/dist/services/runs.js +360 -22
  37. package/dist/services/schema-validation.js +35 -12
  38. package/dist/services/sessions.js +81 -3
  39. package/dist/services/status.js +32 -1
  40. package/dist/services/usage.js +233 -0
  41. package/dist/services/worktrees.js +24 -19
  42. package/dist/storage/database.js +223 -9
  43. package/package.json +1 -1
@@ -1,14 +1,21 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
3
5
  import { formatFullId, isFullEntityId, isShortEntityId, parseFullEntityId } from "../domain/entity-ids.js";
6
+ import { checksumForFlowFlagValues, isFlowFlagDowngrade, loadProjectFlowContract, resolveFlowFlags } from "../domain/flow-contract.js";
4
7
  import { AppError } from "../shared/errors.js";
5
8
  import { ensureDir, projectRunHome, projectRunIndexPath, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
6
9
  import { appendAudit } from "./audit.js";
7
10
  import { registerProject, requireProjectByRoot } from "./projects.js";
8
11
  import { buildRunFlowGuidance } from "./flow-guidance.js";
9
12
  import { readProtocolRuntimeState, requireProtocol } from "./protocols.js";
10
- const runSchemaId = "dd-flow/flow-run-index@2";
11
- const legacyRunSchemaId = "dd-flow/flow-run-index@1";
13
+ import { checkpointRunUsage, usageForRun } from "./usage.js";
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";
12
19
  const runIdType = "RUN";
13
20
  const allowedRunFlowKinds = [
14
21
  "mb_sdlc",
@@ -25,14 +32,24 @@ const allowedRunFlowKinds = [
25
32
  ];
26
33
  export function startFlowRun(context, input) {
27
34
  const projectRoot = resolveProjectRoot(input.projectRoot);
35
+ const flowContract = loadProjectFlowContract(projectRoot);
28
36
  registerProject(context, { root: projectRoot });
29
37
  const project = requireProjectByRoot(context, projectRoot);
30
38
  const workspaceRoot = resolveWorkspaceRoot(input.workspaceRoot ?? projectRoot);
31
39
  const flowKind = parseRunFlowKind(input.flowKind);
32
40
  const slug = normalizeSlug(input.slug);
33
- const runId = nextRunId(context, slug);
41
+ const runId = nextRunId(context, project.id, slug);
34
42
  const { shortId } = parseFullEntityId(runId);
35
43
  const now = context.now();
44
+ const flowFlags = resolveFlowFlags(flowContract, {
45
+ flowKind,
46
+ ...(input.preset ? { preset: input.preset } : {}),
47
+ ...(input.taskProfile !== undefined ? { taskProfile: input.taskProfile } : {}),
48
+ ...(input.protocolOverrides ? { protocolOverrides: input.protocolOverrides } : {}),
49
+ ...(input.runOverrides ? { runOverrides: input.runOverrides } : {}),
50
+ snapshotRevision: 1,
51
+ now
52
+ });
36
53
  const runHome = projectRunHome(context.ddFlowHome, project.id, runId);
37
54
  const runIndexAbsolute = projectRunIndexPath(context.ddFlowHome, project.id, runId);
38
55
  const runDirRelative = path.posix.join("runs", runId);
@@ -61,7 +78,8 @@ export function startFlowRun(context, input) {
61
78
  },
62
79
  execution: {
63
80
  project_root: project.root,
64
- workspace_root: workspaceRoot
81
+ workspace_root: workspaceRoot,
82
+ git: gitFacts(workspaceRoot)
65
83
  },
66
84
  legacy: {
67
85
  project_tasks_run_dir: null
@@ -73,11 +91,18 @@ export function startFlowRun(context, input) {
73
91
  verdict: "pending",
74
92
  next_action: input.nextAction ?? null,
75
93
  created_at: now,
76
- updated_at: now
94
+ updated_at: now,
95
+ started_at: now,
96
+ timing_status: "measured",
97
+ runtime_revision: 1,
98
+ snapshot_revision: flowFlags.snapshot_revision,
99
+ snapshot_checksum: flowFlags.snapshot_checksum,
100
+ reconciliation_status: "reconciled",
101
+ flow_flags: flowFlags
77
102
  };
78
103
  ensureDir(path.dirname(runtimePath));
79
104
  ensureDir(runHome);
80
- writeJsonFile(runtimePath, index);
105
+ writeJsonFile(runtimePath, runtimeSnapshotForIndex(index, 1));
81
106
  writeJsonFile(runIndexAbsolute, index);
82
107
  context.db.run(`INSERT INTO flow_runs
83
108
  (id, short_id, slug, project_id, project_root, workspace_root, flow_kind, subject_type, subject_id,
@@ -111,13 +136,167 @@ export function startFlowRun(context, input) {
111
136
  eventType: "flow_run.started",
112
137
  payload: { run_id: runId, flow_kind: flowKind, subject: index.subject, workspace_root: workspaceRoot, run_home: runHome }
113
138
  });
139
+ appendRunTimeline(runHome, { at: now, type: "run_started", run_id: runId });
140
+ appendRunTimeline(runHome, {
141
+ at: now,
142
+ type: "flow_flags_resolved",
143
+ run_id: runId,
144
+ snapshot_revision: flowFlags.snapshot_revision,
145
+ snapshot_checksum: flowFlags.snapshot_checksum,
146
+ preset: flowFlags.preset.applied
147
+ });
114
148
  return { ok: true, run: flowRunSummary(requireRunById(context, project.id, runId)), index };
115
149
  }
116
150
  export function getFlowRunStatus(context, input) {
151
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
152
+ const run = resolveRun(context, project.id, input.runId);
153
+ refreshRunSessionProjection(context, project.id, run.id);
154
+ const refreshedRun = requireRunById(context, project.id, run.id);
155
+ const index = parseRunIndex(refreshedRun.index_json);
156
+ const runtime = readRuntimeSnapshot(refreshedRun.runtime_path);
157
+ return { ok: true, run: flowRunSummary(refreshedRun), index, ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, refreshedRun, index) };
158
+ }
159
+ export function getFlowRunFlagsStatus(context, input) {
117
160
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
118
161
  const run = resolveRun(context, project.id, input.runId);
119
162
  const index = parseRunIndex(run.index_json);
120
- return { ok: true, run: flowRunSummary(run), index, flow_guidance: guidanceForRun(context, run, index) };
163
+ const runtime = readRuntimeSnapshot(run.runtime_path);
164
+ const flags = runtime?.flow_flags ?? index.flow_flags;
165
+ if (!flags) {
166
+ return {
167
+ ok: true,
168
+ run_id: run.id,
169
+ resolution_status: "legacy_incomplete",
170
+ flow_flags: null,
171
+ snapshot_revision: null,
172
+ snapshot_checksum: null
173
+ };
174
+ }
175
+ return {
176
+ ok: true,
177
+ run_id: run.id,
178
+ resolution_status: flags.resolution_status,
179
+ snapshot_revision: flags.snapshot_revision,
180
+ snapshot_checksum: flags.snapshot_checksum,
181
+ flow_flags: flags,
182
+ runtime_revision: runtime?.runtime_revision ?? index.runtime_revision ?? null
183
+ };
184
+ }
185
+ export function reviseFlowRunFlags(context, input) {
186
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
187
+ const run = resolveRun(context, project.id, input.runId);
188
+ const idempotencyKey = requiredPlain(input.idempotencyKey, "idempotency-key");
189
+ context.db.exec("BEGIN IMMEDIATE");
190
+ let committed = false;
191
+ try {
192
+ const existingMutation = context.db.get("SELECT result_json FROM flow_run_flag_mutations WHERE project_id = ? AND run_id = ? AND idempotency_key = ?", [project.id, run.id, idempotencyKey]);
193
+ if (existingMutation) {
194
+ const result = { ...JSON.parse(existingMutation.result_json), idempotent: true };
195
+ context.db.exec("COMMIT");
196
+ committed = true;
197
+ return result;
198
+ }
199
+ // Read the authority only after the write lock: two callers with the same
200
+ // expected revision must not both pass the compare-and-set check.
201
+ const lockedRun = requireRunById(context, project.id, run.id);
202
+ const index = parseRunIndex(lockedRun.index_json);
203
+ const runtime = readRuntimeSnapshot(lockedRun.runtime_path);
204
+ const current = runtime?.flow_flags ?? index.flow_flags;
205
+ if (!current) {
206
+ throw new AppError("validation", "Legacy RUN has no flow-flag snapshot; revise is unavailable", 2, { run_id: run.id });
207
+ }
208
+ if (!Number.isInteger(input.expectedRevision) || input.expectedRevision < 1) {
209
+ throw new AppError("validation", "expected-revision must be a positive integer", 2, {
210
+ expected_revision: input.expectedRevision
211
+ });
212
+ }
213
+ if (current.snapshot_revision !== input.expectedRevision) {
214
+ throw new AppError("conflict", "Flow-flag snapshot revision is stale", 1, {
215
+ run_id: run.id,
216
+ expected_revision: input.expectedRevision,
217
+ actual_revision: current.snapshot_revision
218
+ });
219
+ }
220
+ const now = context.now();
221
+ const contract = loadProjectFlowContract(lockedRun.project_root);
222
+ const next = resolveFlowFlags(contract, {
223
+ flowKind: index.flow_kind,
224
+ preset: input.preset ?? current.preset.applied,
225
+ runOverrides: input.flags,
226
+ snapshotRevision: input.expectedRevision + 1,
227
+ now
228
+ });
229
+ for (const [key, value] of Object.entries(current.values)) {
230
+ if (!(key in input.flags) && !input.preset)
231
+ next.values[key] = value;
232
+ }
233
+ const downgrades = Object.entries(current.values).flatMap(([key, currentValue]) => {
234
+ const candidate = next.values[key];
235
+ return candidate && isFlowFlagDowngrade(key, candidate.value, currentValue.value)
236
+ ? [{ key, currentValue, candidate }]
237
+ : [];
238
+ });
239
+ const reason = input.reason?.trim();
240
+ const floorDowngrades = downgrades.filter(({ currentValue }) => currentValue.source.kind === "escalation");
241
+ if (floorDowngrades.length > 0) {
242
+ throw new AppError("validation", "Flow-flag downgrade would cross an applied mandatory floor", 2, {
243
+ flags: floorDowngrades.map(({ key, currentValue, candidate }) => ({ flag: key, current: currentValue.value, requested: candidate.value }))
244
+ });
245
+ }
246
+ if (downgrades.length > 0 && (!input.allowDowngrade || !reason)) {
247
+ throw new AppError("validation", "Flow-flag downgrade requires --allow-downgrade and --reason", 2, {
248
+ flags: downgrades.map(({ key, currentValue, candidate }) => ({ flag: key, current: currentValue.value, requested: candidate.value }))
249
+ });
250
+ }
251
+ if (downgrades.length > 0 && reason) {
252
+ for (const { candidate } of downgrades) {
253
+ candidate.rationale = `${candidate.rationale}; explicit downgrade revision: ${reason}`;
254
+ candidate.source = { ...candidate.source, ref: `${candidate.source.ref}#${idempotencyKey}`, revision: next.snapshot_revision };
255
+ }
256
+ }
257
+ next.snapshot_checksum = checksumForFlowFlagValues(next.values);
258
+ index.flow_flags = next;
259
+ index.snapshot_revision = next.snapshot_revision;
260
+ index.snapshot_checksum = next.snapshot_checksum;
261
+ index.reconciliation_status = "reconciled";
262
+ index.updated_at = now;
263
+ const result = {
264
+ ok: true,
265
+ run_id: run.id,
266
+ snapshot_revision: next.snapshot_revision,
267
+ snapshot_checksum: next.snapshot_checksum,
268
+ flow_flags: next,
269
+ ...(reason ? { reason } : {})
270
+ };
271
+ persistRunIndex(context, project, run, index, {
272
+ flagRevision: { revision: next.snapshot_revision, checksum: next.snapshot_checksum, at: now, idempotency_key: idempotencyKey }
273
+ });
274
+ context.db.run(`INSERT INTO flow_run_flag_mutations
275
+ (project_id, run_id, idempotency_key, expected_revision, resulting_revision, result_json, created_at)
276
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [project.id, run.id, idempotencyKey, input.expectedRevision, next.snapshot_revision, JSON.stringify(result), now]);
277
+ context.db.exec("COMMIT");
278
+ committed = true;
279
+ appendAudit(context, {
280
+ projectId: project.id,
281
+ eventType: "flow_run.flags_revised",
282
+ payload: { run_id: run.id, snapshot_revision: next.snapshot_revision, snapshot_checksum: next.snapshot_checksum, ...(reason ? { reason } : {}) }
283
+ });
284
+ appendRunTimeline(runArtifactRoot(run), {
285
+ at: now,
286
+ type: "flow_flags_revised",
287
+ run_id: run.id,
288
+ snapshot_revision: next.snapshot_revision,
289
+ snapshot_checksum: next.snapshot_checksum,
290
+ idempotency_key: idempotencyKey,
291
+ ...(reason ? { reason } : {})
292
+ });
293
+ return result;
294
+ }
295
+ catch (error) {
296
+ if (!committed)
297
+ context.db.exec("ROLLBACK");
298
+ throw error;
299
+ }
121
300
  }
122
301
  export function listFlowRuns(context, input) {
123
302
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -146,7 +325,9 @@ export function attachFlowRunStage(context, input) {
146
325
  dir,
147
326
  status,
148
327
  ...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : existing?.data_schema_id ? { data_schema_id: existing.data_schema_id } : {}),
149
- updated_at: now
328
+ updated_at: now,
329
+ ...(status === "running" && !existing?.started_at ? { started_at: now } : {}),
330
+ ...(status === "running" ? { attempt: nextAttempt(existing) } : {})
150
331
  };
151
332
  upsertStage(index, stageRun);
152
333
  index.updated_at = now;
@@ -156,6 +337,17 @@ export function attachFlowRunStage(context, input) {
156
337
  eventType: "flow_run.stage_attached",
157
338
  payload: { run_id: run.id, stage, dir, status }
158
339
  });
340
+ appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_attached", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
341
+ if (status === "running") {
342
+ context.db.run("UPDATE flow_sessions SET current_stage = ?, updated_at = ? WHERE project_id = ? AND run_id = ?", [
343
+ stage,
344
+ now,
345
+ project.id,
346
+ run.id
347
+ ]);
348
+ refreshRunSessionProjection(context, project.id, run.id);
349
+ checkpointRunUsage(context, { projectId: project.id, runId: run.id, checkpoint: "stage_started", stage, stageAttempt: stageRun.attempt ?? null });
350
+ }
159
351
  const updatedRun = requireRunById(context, project.id, run.id);
160
352
  return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
161
353
  }
@@ -178,16 +370,20 @@ export function completeFlowRunStage(context, input) {
178
370
  ...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : {}),
179
371
  ...(input.report ? { report: input.report } : {}),
180
372
  ...(input.aliases && input.aliases.length > 0 ? { artifact_aliases: input.aliases } : {}),
181
- updated_at: now
373
+ updated_at: now,
374
+ ...(status === "done" || status === "blocked" || status === "failed" || status === "skipped" ? { completed_at: now } : {})
182
375
  };
183
376
  upsertStage(index, stageRun);
184
377
  index.updated_at = now;
185
378
  persistRunIndex(context, project, run, index);
379
+ refreshRunSessionProjection(context, project.id, run.id);
186
380
  appendAudit(context, {
187
381
  projectId: project.id,
188
382
  eventType: "flow_run.stage_completed",
189
383
  payload: { run_id: run.id, stage, status, stage_report: input.stageReport ?? null, data: input.data ?? null }
190
384
  });
385
+ appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_completed", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
386
+ checkpointRunUsage(context, { projectId: project.id, runId: run.id, checkpoint: "stage_finished", stage, stageAttempt: stageRun.attempt ?? null });
191
387
  const updatedRun = requireRunById(context, project.id, run.id);
192
388
  return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
193
389
  }
@@ -205,18 +401,89 @@ export function completeFlowRun(context, input) {
205
401
  index.completed_at = now;
206
402
  }
207
403
  persistRunIndex(context, project, run, index);
404
+ refreshRunSessionProjection(context, project.id, run.id);
208
405
  appendAudit(context, {
209
406
  projectId: project.id,
210
407
  eventType: "flow_run.completed",
211
408
  payload: { run_id: run.id, status, verdict: index.verdict, next_action: index.next_action }
212
409
  });
410
+ appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_completed", run_id: run.id, status });
411
+ checkpointRunUsage(context, { projectId: project.id, runId: run.id, checkpoint: "run_finished" });
213
412
  const updatedRun = requireRunById(context, project.id, run.id);
214
413
  return { ok: true, run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
215
414
  }
415
+ const timelineSections = ["stages", "events", "sessions", "usage", "artifacts"];
416
+ export function getFlowRunTimeline(context, input) {
417
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
418
+ const run = resolveRun(context, project.id, input.runId);
419
+ const index = parseRunIndex(run.index_json);
420
+ const file = path.join(runArtifactRoot(run), "timeline.jsonl");
421
+ const events = fs.existsSync(file)
422
+ ? fs.readFileSync(file, "utf8").split(/\r?\n/).flatMap((line) => { try {
423
+ return line.trim() ? [JSON.parse(line)] : [];
424
+ }
425
+ catch {
426
+ return [];
427
+ } })
428
+ : [];
429
+ const hidden = parseHiddenTimelineSections(input.hiddenSections ?? []);
430
+ const sessions = index.sessions;
431
+ const elapsedMs = durationMs(index.started_at ?? run.created_at, index.completed_at ?? null);
432
+ const report = {
433
+ ok: true,
434
+ schema_id: "dd-flow/run-timeline@2",
435
+ run_id: run.id,
436
+ timing_status: index.timing_status ?? "legacy_incomplete",
437
+ hidden_sections: [...hidden],
438
+ available_sections: timelineSections,
439
+ summary: {
440
+ flow_kind: index.flow_kind,
441
+ subject: index.subject,
442
+ status: index.status,
443
+ verdict: index.verdict,
444
+ started_at: index.started_at ?? null,
445
+ completed_at: index.completed_at ?? null,
446
+ elapsed_ms: elapsedMs,
447
+ stage_count: index.stage_runs.length,
448
+ event_count: events.length,
449
+ session_count: sessions.length
450
+ }
451
+ };
452
+ if (!hidden.has("stages"))
453
+ report.stages = index.stage_runs.map((stage) => ({
454
+ ...stage,
455
+ elapsed_ms: durationMs(stage.started_at ?? null, stage.completed_at ?? null)
456
+ }));
457
+ if (!hidden.has("events"))
458
+ report.events = events;
459
+ if (!hidden.has("sessions"))
460
+ report.sessions = sessions;
461
+ if (!hidden.has("usage"))
462
+ report.usage = usageForRun(context, { projectId: project.id, runId: run.id, groupBy: "session" });
463
+ if (!hidden.has("artifacts"))
464
+ report.artifacts = index.stage_runs.map((stage) => ({
465
+ stage: stage.stage,
466
+ dir: stage.dir,
467
+ stage_report: stage.stage_report ?? null,
468
+ data: stage.data ?? null,
469
+ report: stage.report ?? null,
470
+ artifact_aliases: stage.artifact_aliases ?? []
471
+ }));
472
+ return report;
473
+ }
474
+ export function getFlowRunUsage(context, input) {
475
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
476
+ const run = resolveRun(context, project.id, input.runId);
477
+ return usageForRun(context, { projectId: project.id, runId: run.id, groupBy: input.groupBy ?? "session" });
478
+ }
479
+ export function appendFlowRunTimelineEvent(context, projectId, runId, event) {
480
+ const run = requireRunById(context, projectId, runId);
481
+ appendRunTimeline(runArtifactRoot(run), { run_id: run.id, ...event });
482
+ }
216
483
  function guidanceForRun(context, run, index) {
217
484
  if (run.subject_type === "protocol") {
218
485
  try {
219
- const protocol = requireProtocol(context, run.subject_id);
486
+ const protocol = requireProtocol(context, run.subject_id, run.project_id);
220
487
  const state = readProtocolRuntimeState(context, protocol).state;
221
488
  return buildRunFlowGuidance({
222
489
  stageRuns: index.stage_runs,
@@ -233,12 +500,19 @@ function guidanceForRun(context, run, index) {
233
500
  }
234
501
  return buildRunFlowGuidance({ stageRuns: index.stage_runs, runId: run.id, runDir: index.run_home?.relative_path ?? index.workspace.run_dir });
235
502
  }
236
- function persistRunIndex(context, project, run, index) {
503
+ function persistRunIndex(context, project, run, index, options = {}) {
237
504
  const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, project.id, run.id);
238
505
  const runIndexPath = run.run_index_path || projectRunIndexPath(context.ddFlowHome, project.id, run.id);
506
+ const previousRuntime = readRuntimeSnapshot(runtimePath);
507
+ const runtimeRevision = Math.max(index.runtime_revision ?? 0, previousRuntime?.runtime_revision ?? 0) + 1;
508
+ index.runtime_revision = runtimeRevision;
509
+ const history = [...(previousRuntime?.flag_revision_history ?? [])];
510
+ if (options.flagRevision) {
511
+ history.push(options.flagRevision);
512
+ }
239
513
  ensureDir(path.dirname(runtimePath));
240
514
  ensureDir(path.dirname(runIndexPath));
241
- writeJsonFile(runtimePath, index);
515
+ writeJsonFile(runtimePath, runtimeSnapshotForIndex(index, runtimeRevision, history));
242
516
  writeJsonFile(runIndexPath, index);
243
517
  context.db.run(`UPDATE flow_runs
244
518
  SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?,
@@ -256,6 +530,69 @@ function persistRunIndex(context, project, run, index) {
256
530
  run.id
257
531
  ]);
258
532
  }
533
+ function appendRunTimeline(runHome, event) {
534
+ ensureDir(runHome);
535
+ const timelinePath = path.join(runHome, "timeline.jsonl");
536
+ let sequence = 0;
537
+ if (fs.existsSync(timelinePath)) {
538
+ const lines = fs.readFileSync(timelinePath, "utf8").trim().split(/\r?\n/).filter(Boolean);
539
+ const last = lines.length > 0 ? JSON.parse(lines[lines.length - 1]) : undefined;
540
+ sequence = typeof last?.sequence === "number" ? last.sequence : lines.length;
541
+ }
542
+ const enriched = {
543
+ event_id: crypto.randomUUID(),
544
+ sequence: sequence + 1,
545
+ ...event
546
+ };
547
+ fs.appendFileSync(timelinePath, `${JSON.stringify(enriched)}\n`);
548
+ }
549
+ function runtimeSnapshotForIndex(index, runtimeRevision, flagRevisionHistory = []) {
550
+ const rest = { ...index };
551
+ Reflect.deleteProperty(rest, "schema_id");
552
+ return {
553
+ ...rest,
554
+ schema_id: runtimeSchemaId,
555
+ run_index_schema_id: index.schema_id,
556
+ runtime_revision: runtimeRevision,
557
+ ...(flagRevisionHistory.length > 0 ? { flag_revision_history: flagRevisionHistory.slice(-32) } : {})
558
+ };
559
+ }
560
+ function readRuntimeSnapshot(file) {
561
+ if (!file || !fs.existsSync(file))
562
+ return undefined;
563
+ try {
564
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
565
+ return value.schema_id === runtimeSchemaId ? value : undefined;
566
+ }
567
+ catch {
568
+ return undefined;
569
+ }
570
+ }
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);
581
+ }
582
+ return hidden;
583
+ }
584
+ function durationMs(startedAt, completedAt) {
585
+ if (!startedAt || !completedAt)
586
+ return null;
587
+ const value = Date.parse(completedAt) - Date.parse(startedAt);
588
+ return Number.isFinite(value) && value >= 0 ? value : null;
589
+ }
590
+ function nextAttempt(existing) {
591
+ if (!existing?.attempt)
592
+ return "try-001";
593
+ const number = Number(existing.attempt.replace("try-", ""));
594
+ return `try-${String(Number.isFinite(number) ? number + 1 : 1).padStart(3, "0")}`;
595
+ }
259
596
  function upsertStage(index, stageRun) {
260
597
  const position = index.stage_runs.findIndex((item) => item.stage === stageRun.stage);
261
598
  if (position === -1) {
@@ -292,8 +629,8 @@ function requireRunById(context, projectId, runId) {
292
629
  }
293
630
  return run;
294
631
  }
295
- function nextRunId(context, slug) {
296
- const rows = context.db.all("SELECT id FROM flow_runs WHERE id LIKE 'RUN-%'");
632
+ function nextRunId(context, projectId, slug) {
633
+ const rows = context.db.all("SELECT id FROM flow_runs WHERE project_id = ? AND id LIKE 'RUN-%'", [projectId]);
297
634
  const max = rows.reduce((value, row) => {
298
635
  try {
299
636
  const parsed = parseFullEntityId(row.id);
@@ -303,13 +640,7 @@ function nextRunId(context, slug) {
303
640
  return value;
304
641
  }
305
642
  }, 0);
306
- for (let sequence = max + 1; sequence <= 999; sequence += 1) {
307
- const id = formatFullId(runIdType, sequence, slug);
308
- if (!context.db.get("SELECT id FROM flow_runs WHERE id = ?", [id])) {
309
- return id;
310
- }
311
- }
312
- throw new AppError("validation", "RUN id sequence exhausted", 2);
643
+ return formatFullId(runIdType, max + 1, slug);
313
644
  }
314
645
  function flowRunSummary(run) {
315
646
  return {
@@ -340,7 +671,7 @@ function flowRunSummary(run) {
340
671
  }
341
672
  function parseRunIndex(text) {
342
673
  const index = JSON.parse(text);
343
- if (index.schema_id !== runSchemaId && index.schema_id !== legacyRunSchemaId) {
674
+ if (index.schema_id !== runSchemaId && index.schema_id !== legacyRunSchemaId && index.schema_id !== oldestRunSchemaId) {
344
675
  throw new AppError("validation", `Invalid run index schema: ${String(index.schema_id)}`, 2);
345
676
  }
346
677
  return index;
@@ -434,3 +765,10 @@ function writeJsonFile(file, value) {
434
765
  function runArtifactRoot(run) {
435
766
  return path.dirname(run.run_index_path);
436
767
  }
768
+ function gitFacts(workspaceRoot) {
769
+ const read = (args) => {
770
+ const result = spawnSync("git", ["-C", workspaceRoot, ...args.split(" ")], { encoding: "utf8" });
771
+ return result.status === 0 ? result.stdout.trim() || null : null;
772
+ };
773
+ return { branch: read("branch --show-current"), head: read("rev-parse HEAD") };
774
+ }
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { Ajv } from "ajv/dist/ajv.js";
5
+ import { normalizeFlowContract } from "../domain/flow-contract.js";
5
6
  import { AppError } from "../shared/errors.js";
6
7
  const schemaNamePattern = /^[a-z0-9][a-z0-9-]*$/;
7
8
  const mandatoryMbUpgradeReviewAspectIds = [
@@ -34,15 +35,15 @@ export function validateSchema(options) {
34
35
  if (!schemaNamePattern.test(options.schemaName)) {
35
36
  throw new AppError("usage", "--schema must be a schema name such as mb-upgrade-review-data", 2);
36
37
  }
37
- const schemaResolution = resolveSchema(options);
38
- const schema = readJson(schemaResolution.path, "schema");
39
38
  const filePath = path.resolve(options.file);
40
39
  const data = readJson(filePath, "input file");
40
+ const schemaResolution = resolveSchema(options, data);
41
+ const schema = readJson(schemaResolution.path, "schema");
41
42
  const ajv = new Ajv({ allErrors: true, strict: false, validateFormats: false });
42
43
  const validate = ajv.compile(schema);
43
44
  const valid = validate(data);
44
45
  const errors = valid ? [] : (validate.errors ?? []).map(formatAjvError);
45
- const semanticErrors = validateSemanticSchema(options.schemaName, data);
46
+ const semanticErrors = valid ? validateSemanticSchema(options.schemaName, data) : [];
46
47
  const allErrors = [...errors, ...semanticErrors];
47
48
  if (allErrors.length > 0) {
48
49
  throw new AppError("schema_validation", `${path.basename(filePath)} does not match schema`, 2, {
@@ -58,16 +59,23 @@ export function validateSchema(options) {
58
59
  errors: []
59
60
  };
60
61
  }
61
- function resolveSchema(options) {
62
- const fileName = `${options.schemaName}.schema.json`;
63
- const candidates = [];
64
- if (options.schemaDir) {
65
- candidates.push({ path: path.join(path.resolve(options.schemaDir), fileName), source: "schema_dir" });
66
- }
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`];
67
68
  const projectRoot = path.resolve(options.projectRoot ?? process.cwd());
68
- candidates.push({ path: path.join(projectRoot, ".memory-bank", "dd-flow", "schemas", fileName), source: "project" });
69
- candidates.push({ path: path.join(projectRoot, "dd-flow", "schemas", fileName), source: "canonical" });
70
- candidates.push({ path: path.join(bundledSchemaDir(), fileName), source: "bundled" });
69
+ const roots = [
70
+ ...(options.schemaDir ? [{ directory: path.resolve(options.schemaDir), source: "schema_dir" }] : []),
71
+ { directory: path.join(projectRoot, ".memory-bank", "dd-flow", "schemas"), source: "project" },
72
+ { directory: path.join(projectRoot, "dd-flow", "schemas"), source: "canonical" },
73
+ { directory: bundledSchemaDir(), source: "bundled" }
74
+ ];
75
+ const candidates = roots.flatMap((root) => fileNames.map((fileName) => ({
76
+ path: path.join(root.directory, fileName),
77
+ source: root.source
78
+ })));
71
79
  const found = candidates.find((candidate) => fs.existsSync(candidate.path));
72
80
  if (!found) {
73
81
  throw new AppError("schema_not_found", `Schema not found: ${options.schemaName}`, 2, {
@@ -121,6 +129,21 @@ function validateSemanticSchema(schemaName, data) {
121
129
  if (schemaName === "mb-sdlc-review-report") {
122
130
  return validateMbSdlcReviewReport(root);
123
131
  }
132
+ if (schemaName === "flow-contract") {
133
+ try {
134
+ normalizeFlowContract(root);
135
+ return [];
136
+ }
137
+ catch (error) {
138
+ if (!(error instanceof AppError))
139
+ throw error;
140
+ return [{
141
+ path: typeof error.details.path === "string" ? error.details.path : "/",
142
+ message: error.message,
143
+ keyword: "dd-flow/flow-contract-semantic"
144
+ }];
145
+ }
146
+ }
124
147
  return [];
125
148
  }
126
149
  function validateMbUpgradeReviewData(root) {