@deksden-com/dd-flow-cli 0.4.2 → 0.5.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 (49) hide show
  1. package/CHANGELOG.md +27 -4
  2. package/dist/build-info.json +6 -6
  3. package/dist/cli/help.js +14 -3
  4. package/dist/cli/run-cli.js +32 -16
  5. package/dist/domain/flow-contract.js +81 -2
  6. package/dist/domain/validation.js +56 -28
  7. package/dist/protocol/local-files.js +1 -16
  8. package/dist/schemas/code-stage-report.schema.json +2 -2
  9. package/dist/schemas/flow-contract.schema.json +150 -94
  10. package/dist/schemas/flow-run.schema.json +129 -23
  11. package/dist/schemas/mb-upgrade-review-data.schema.json +2 -2
  12. package/dist/schemas/memorybank-permissions-preflight.schema.json +13 -73
  13. package/dist/schemas/merge-stage-report.schema.json +2 -2
  14. package/dist/schemas/plan-stage-report.schema.json +38 -335
  15. package/dist/schemas/project-flow-pack-manifest.schema.json +4 -4
  16. package/dist/schemas/protocol-plan.schema.json +197 -0
  17. package/dist/schemas/release-impact.schema.json +9 -5
  18. package/dist/schemas/session-usage.schema.json +16 -0
  19. package/dist/schemas/stage-finish-input.schema.json +20 -0
  20. package/dist/schemas/stage-prompt.schema.json +31 -0
  21. package/dist/schemas/stage-report.schema.json +20 -0
  22. package/dist/schemas/stage-start-response.schema.json +29 -0
  23. package/dist/schemas/timeline-event.schema.json +29 -0
  24. package/dist/schemas/worktrunk-workspace.schema.json +19 -0
  25. package/dist/services/branch-context.js +9 -4
  26. package/dist/services/dashboard.js +51 -26
  27. package/dist/services/engines.js +84 -18
  28. package/dist/services/hooks.js +80 -246
  29. package/dist/services/memory-permissions.js +77 -69
  30. package/dist/services/plan-runtime.js +124 -0
  31. package/dist/services/plans.js +22 -84
  32. package/dist/services/projects.js +2 -1
  33. package/dist/services/prompts.js +26 -21
  34. package/dist/services/protocols.js +29 -25
  35. package/dist/services/run-projection.js +77 -11
  36. package/dist/services/runs.js +95 -61
  37. package/dist/services/schema-validation.js +168 -7
  38. package/dist/services/sessions.js +132 -68
  39. package/dist/services/stage-lifecycle.js +572 -0
  40. package/dist/services/tooling.js +285 -0
  41. package/dist/services/usage.js +183 -30
  42. package/dist/services/version-status.js +1 -1
  43. package/dist/services/worktrees.js +88 -39
  44. package/dist/storage/database.js +72 -30
  45. package/dist/storage/paths.js +0 -9
  46. package/package.json +14 -13
  47. package/tools/worktrunk-manifest.json +34 -0
  48. package/dist/schemas/flow-run-index-v3.schema.json +0 -203
  49. package/dist/schemas/flow-run-index.schema.json +0 -175
@@ -8,10 +8,11 @@ import { requireStage } from "../domain/validation.js";
8
8
  import { AppError } from "../shared/errors.js";
9
9
  import { parseJsonObject } from "../shared/json.js";
10
10
  import { ensureReadableFile } from "../storage/database.js";
11
- import { ensureDir, resolveProjectRoot, runtimePlanJsonPath, runtimeProtocolDir, runtimeStateJsonPath } from "../storage/paths.js";
11
+ import { ensureDir, planJsonPath, resolveProjectRoot, runtimeProtocolDir, runtimeStateJsonPath } from "../storage/paths.js";
12
12
  import { appendAudit, getAuditEvents } from "./audit.js";
13
13
  import { requireProjectByRoot } from "./projects.js";
14
- import { ensureRuntimeProtocolFiles, readPlanFile, readStateFile, writeState } from "../protocol/local-files.js";
14
+ import { ensureRuntimeProtocolFiles, readStateFile, writeState } from "../protocol/local-files.js";
15
+ import { planSummary, planWithProgress, readCanonicalPlan } from "./plan-runtime.js";
15
16
  import { activeCodexSessionBindingsForProject, activeFlowSessionBindingsForProject, codexHomeProfilesForProject, codexHookEventsForProject, hookStatusForProject } from "./hooks.js";
16
17
  import { buildProtocolFlowGuidance } from "./flow-guidance.js";
17
18
  import { lifecycleIsTerminalFailure, lifecycleIsTerminalSuccess, normalizeProtocolLifecycle } from "./protocol-lifecycle.js";
@@ -46,7 +47,7 @@ export function registerProtocol(context, input) {
46
47
  JSON.stringify(state.blockers),
47
48
  JSON.stringify(state.active_def),
48
49
  runtimeStateJsonPath(context.ddFlowHome, project.id, protocolId),
49
- runtimePlanJsonPath(context.ddFlowHome, project.id, protocolId),
50
+ planJsonPath(projectRoot, protocolId),
50
51
  existing?.created_at ?? now,
51
52
  now
52
53
  ];
@@ -88,7 +89,7 @@ export function getProtocolStatus(context, input) {
88
89
  const protocol = requireProtocol(context, input.protocolId, project.id);
89
90
  const runtime = readProtocolRuntimeState(context, protocol);
90
91
  const state = runtime.state;
91
- const plan = readPlanFile(protocol.plan_path);
92
+ const plan = canonicalPlanIfPresent(context, protocol);
92
93
  const runDiagnostics = protocolRunDiagnostics(context, protocol, state);
93
94
  const queue = context.db.get("SELECT * FROM merge_queue WHERE project_id = ? AND protocol_id = ?", [protocol.project_id, protocol.id]);
94
95
  const lifecycle = normalizeProtocolLifecycle({ state, queueStatus: queue?.status ?? null });
@@ -99,7 +100,7 @@ export function getProtocolStatus(context, input) {
99
100
  diagnostics: [...runtime.diagnostics, ...runDiagnostics.diagnostics, ...lifecycle.diagnostics],
100
101
  latest_run: runDiagnostics.latest_run,
101
102
  state,
102
- plan: plan ? summarizePlan(plan) : state.plan,
103
+ plan: plan ? planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }) : state.plan,
103
104
  merge_queue: queue,
104
105
  branch_context: getProtocolBranchContext(context, { protocolId: protocol.id, projectRoot: project.root }).branch_context,
105
106
  flow_guidance: buildProtocolFlowGuidance({ state, latestRun: runDiagnostics.latest_run, queueStatus: queue?.status ?? null }),
@@ -334,11 +335,15 @@ export function readyForMerge(context, input) {
334
335
  if (missing.length > 0) {
335
336
  throw new AppError("readiness_missing_fields", "Protocol is missing explicit readiness fields", 1, { missing });
336
337
  }
337
- if (state.active_def.length > 0) {
338
- throw new AppError("readiness_blocked", "Protocol has open deferrals", 1, { active_def: state.active_def });
338
+ const blockingDefs = state.active_def.filter(isBlockingDeferral);
339
+ if (blockingDefs.length > 0) {
340
+ throw new AppError("readiness_blocked", "Protocol has open blocking deferrals", 1, { active_def: blockingDefs });
339
341
  }
340
- const plan = readPlanFile(protocol.plan_path);
341
- const openRequired = plan?.items.filter((item) => item.required && !["done", "skipped"].includes(item.status)) ?? [];
342
+ const plan = canonicalPlanIfPresent(context, protocol);
343
+ const openRequired = plan
344
+ ? planWithProgress(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }).plan.items
345
+ .filter((item) => item.required && !["done", "skipped"].includes(item.status))
346
+ : [];
342
347
  if (openRequired.length > 0) {
343
348
  throw new AppError("readiness_plan_open", "Protocol has open required plan items", 1, {
344
349
  items: openRequired.map((item) => ({ id: item.id, status: item.status }))
@@ -374,6 +379,11 @@ export function readyForMerge(context, input) {
374
379
  flow_guidance: buildProtocolFlowGuidance({ state: nextState, latestRun: runDiagnostics.latest_run, queueStatus: "ready" })
375
380
  };
376
381
  }
382
+ function isBlockingDeferral(value) {
383
+ if (!value || typeof value !== "object" || Array.isArray(value))
384
+ return true;
385
+ return value.blocking !== false;
386
+ }
377
387
  export function syncProtocolFromRun(context, input) {
378
388
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
379
389
  const protocol = requireProtocol(context, input.protocolId, project.id);
@@ -738,7 +748,7 @@ export function readProtocolRuntimeState(context, protocol) {
738
748
  source: "stable_runtime_state",
739
749
  recommended_action: "legacy protocol state_path was moved to stable runtime storage"
740
750
  });
741
- const stablePlanPath = runtimePlanJsonPath(context.ddFlowHome, protocol.project_id, protocol.id);
751
+ const stablePlanPath = planJsonPath(protocol.project_root, protocol.id);
742
752
  context.db.run("UPDATE protocols SET state_path = ?, plan_path = ? WHERE project_id = ? AND id = ?", [stableStatePath, stablePlanPath, protocol.project_id, protocol.id]);
743
753
  protocol.state_path = stableStatePath;
744
754
  protocol.plan_path = stablePlanPath;
@@ -752,9 +762,9 @@ export function readProtocolRuntimeState(context, protocol) {
752
762
  recommended_action: "runtime state reconstructed from SQLite; run cleanup scan/apply if this persists"
753
763
  });
754
764
  }
755
- const plan = context.db.get("SELECT plan_json FROM plans WHERE project_id = ? AND protocol_id = ?", [protocol.project_id, protocol.id]);
756
- const planSummary = plan
757
- ? planSummaryForState(JSON.parse(plan.plan_json))
765
+ const plan = canonicalPlanIfPresent(context, protocol);
766
+ const summary = plan
767
+ ? planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path })
758
768
  : { plan_id: null, total: 0, done: 0, blocked: 0 };
759
769
  const flowContract = loadProjectFlowContract(protocol.project_root);
760
770
  const state = {
@@ -766,14 +776,14 @@ export function readProtocolRuntimeState(context, protocol) {
766
776
  next_action: protocol.next_action,
767
777
  route: JSON.parse(protocol.route_json),
768
778
  workspace: JSON.parse(protocol.workspace_json),
769
- plan: planSummary,
779
+ plan: summary,
770
780
  blockers: JSON.parse(protocol.blockers_json),
771
781
  active_def: JSON.parse(protocol.active_def_json),
772
782
  flow_contract: flowContract,
773
783
  updated_at: protocol.updated_at
774
784
  };
775
785
  const stableStatePath = runtimeStateJsonPath(context.ddFlowHome, protocol.project_id, protocol.id);
776
- const stablePlanPath = runtimePlanJsonPath(context.ddFlowHome, protocol.project_id, protocol.id);
786
+ const stablePlanPath = planJsonPath(protocol.project_root, protocol.id);
777
787
  ensureDir(path.dirname(stableStatePath));
778
788
  writeState(stableStatePath, state);
779
789
  context.db.run("UPDATE protocols SET state_path = ?, plan_path = ? WHERE project_id = ? AND id = ?", [stableStatePath, stablePlanPath, protocol.project_id, protocol.id]);
@@ -1162,16 +1172,10 @@ function protocolStatusPayload(protocol) {
1162
1172
  updated_at: protocol.updated_at
1163
1173
  };
1164
1174
  }
1165
- function summarizePlan(plan) {
1166
- return planSummaryForState(plan);
1167
- }
1168
- function planSummaryForState(plan) {
1169
- return {
1170
- plan_id: plan.plan_id,
1171
- total: plan.items.length,
1172
- done: plan.items.filter((item) => item.status === "done").length,
1173
- blocked: plan.items.filter((item) => item.status === "blocked").length
1174
- };
1175
+ function canonicalPlanIfPresent(context, protocol) {
1176
+ if (!fs.existsSync(protocol.plan_path))
1177
+ return undefined;
1178
+ return readCanonicalPlan(protocol.plan_path, protocol.id).plan;
1175
1179
  }
1176
1180
  function readinessMissingFields(state) {
1177
1181
  const missing = [];
@@ -1,29 +1,95 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  export function refreshRunSessionProjection(context, projectId, runId) {
4
- const run = context.db.get("SELECT id, runtime_path, run_index_path, index_json FROM flow_runs WHERE project_id = ? AND id = ?", [projectId, runId]);
4
+ const run = context.db.get("SELECT id, project_root, subject_type, subject_id, runtime_path, run_index_path, index_json FROM flow_runs WHERE project_id = ? AND id = ?", [projectId, runId]);
5
5
  if (!run)
6
6
  return;
7
7
  const index = JSON.parse(run.index_json);
8
8
  const sessions = context.db.all(`SELECT session_id, parent_session_id, role, session_kind, worker_id, current_stage, status,
9
9
  created_at, updated_at, stopped_at, coverage_units_json
10
10
  FROM flow_sessions WHERE project_id = ? AND run_id = ? ORDER BY created_at, session_id`, [projectId, runId]).map(sessionProjection);
11
- if (JSON.stringify(index.sessions ?? []) === JSON.stringify(sessions))
11
+ const plan = run.subject_type === "protocol" ? planProjection(context, projectId, run.subject_id, run.project_root) : {};
12
+ const workers = workerProjection(context, projectId, runId);
13
+ if (JSON.stringify(index.sessions ?? []) === JSON.stringify(sessions)
14
+ && JSON.stringify(index.plan_ref) === JSON.stringify(plan.plan_ref)
15
+ && JSON.stringify(index.plan_progress) === JSON.stringify(plan.plan_progress)
16
+ && JSON.stringify(index.workers ?? {}) === JSON.stringify(workers))
12
17
  return;
13
18
  index.sessions = sessions;
19
+ if (plan.plan_ref)
20
+ index.plan_ref = plan.plan_ref;
21
+ if (plan.plan_progress)
22
+ index.plan_progress = plan.plan_progress;
23
+ index.workers = workers;
14
24
  index.updated_at = context.now();
15
- const runtimeRevision = typeof index.runtime_revision === "number" ? index.runtime_revision + 1 : 1;
16
- index.runtime_revision = runtimeRevision;
17
25
  const runtime = readJson(run.runtime_path);
18
- if (runtime) {
19
- runtime.sessions = sessions;
20
- runtime.updated_at = index.updated_at;
21
- runtime.runtime_revision = runtimeRevision;
22
- }
23
- writeJson(run.run_index_path, index);
24
- writeJson(run.runtime_path, runtime ?? index);
26
+ const runtimeRevision = typeof runtime?.runtime_revision === "number"
27
+ ? runtime.runtime_revision + 1
28
+ : typeof index.runtime_revision === "number"
29
+ ? index.runtime_revision + 1
30
+ : 1;
31
+ index.runtime_revision = runtimeRevision;
32
+ const authoritative = runtime ?? { ...index, schema_id: "dd-flow/flow-run@2" };
33
+ authoritative.sessions = sessions;
34
+ if (plan.plan_ref)
35
+ authoritative.plan_ref = plan.plan_ref;
36
+ if (plan.plan_progress)
37
+ authoritative.plan_progress = plan.plan_progress;
38
+ authoritative.workers = workers;
39
+ authoritative.updated_at = index.updated_at;
40
+ authoritative.runtime_revision = runtimeRevision;
41
+ writeJson(run.runtime_path, authoritative);
25
42
  context.db.run("UPDATE flow_runs SET index_json = ?, updated_at = ? WHERE project_id = ? AND id = ?", [JSON.stringify(index), index.updated_at, projectId, runId]);
26
43
  }
44
+ function planProjection(context, projectId, protocolId, projectRoot) {
45
+ const binding = context.db.get("SELECT plan_path, plan_revision, plan_sha256 FROM plan_bindings WHERE project_id = ? AND protocol_id = ?", [projectId, protocolId]);
46
+ if (!binding)
47
+ return {};
48
+ const planPath = path.relative(projectRoot, binding.plan_path).split(path.sep).join("/");
49
+ const planId = readPlanId(binding.plan_path);
50
+ const progress = Object.fromEntries(context.db.all("SELECT item_id, status, summary, evidence_json FROM plan_progress WHERE project_id = ? AND protocol_id = ? ORDER BY item_id", [projectId, protocolId]).map((row) => [row.item_id, {
51
+ status: row.status,
52
+ ...(row.summary ? { summary: row.summary } : {}),
53
+ ...(parseStringArray(row.evidence_json).length > 0 ? { evidence: parseStringArray(row.evidence_json) } : {})
54
+ }]));
55
+ return {
56
+ plan_ref: {
57
+ path: planPath,
58
+ plan_id: planId,
59
+ revision: binding.plan_revision,
60
+ sha256: binding.plan_sha256
61
+ },
62
+ plan_progress: progress
63
+ };
64
+ }
65
+ function readPlanId(planPath) {
66
+ try {
67
+ const value = JSON.parse(fs.readFileSync(planPath, "utf8"));
68
+ if (value && typeof value === "object" && !Array.isArray(value) && typeof value.plan_id === "string") {
69
+ return value.plan_id;
70
+ }
71
+ }
72
+ catch {
73
+ // The canonical validator owns malformed-plan errors; projection stays bounded.
74
+ }
75
+ return "unknown-plan";
76
+ }
77
+ function workerProjection(context, projectId, runId) {
78
+ return Object.fromEntries(context.db.all("SELECT job_id, plan_item_id, status, worker_session_id FROM flow_jobs WHERE project_id = ? AND run_id = ? ORDER BY job_id", [projectId, runId]).map((job) => [job.job_id, {
79
+ units: [job.plan_item_id],
80
+ status: { pending: "registered", done: "completed" }[job.status] ?? job.status,
81
+ ...(job.worker_session_id ? { session_id: job.worker_session_id } : {})
82
+ }]));
83
+ }
84
+ function parseStringArray(value) {
85
+ try {
86
+ const parsed = JSON.parse(value);
87
+ return Array.isArray(parsed) && parsed.every((entry) => typeof entry === "string") ? parsed : [];
88
+ }
89
+ catch {
90
+ return [];
91
+ }
92
+ }
27
93
  function sessionProjection(row) {
28
94
  return {
29
95
  session_id: row.session_id,
@@ -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,15 @@ 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);
404
431
  refreshRunSessionProjection(context, project.id, run.id);
405
432
  appendAudit(context, {
406
433
  projectId: project.id,
@@ -416,7 +443,7 @@ const timelineSections = ["stages", "events", "sessions", "usage", "artifacts"];
416
443
  export function getFlowRunTimeline(context, input) {
417
444
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
418
445
  const run = resolveRun(context, project.id, input.runId);
419
- const index = parseRunIndex(run.index_json);
446
+ const index = authoritativeIndex(run);
420
447
  const file = path.join(runArtifactRoot(run), "timeline.jsonl");
421
448
  const events = fs.existsSync(file)
422
449
  ? fs.readFileSync(file, "utf8").split(/\r?\n/).flatMap((line) => { try {
@@ -426,7 +453,10 @@ export function getFlowRunTimeline(context, input) {
426
453
  return [];
427
454
  } })
428
455
  : [];
429
- const hidden = parseHiddenTimelineSections(input.hiddenSections ?? []);
456
+ if ((input.hiddenSections ?? []).length > 0) {
457
+ throw new AppError("validation", "Timeline sections are always included", 2, { hidden_sections: input.hiddenSections });
458
+ }
459
+ const hidden = new Set();
430
460
  const sessions = index.sessions;
431
461
  const elapsedMs = durationMs(index.started_at ?? run.created_at, index.completed_at ?? null);
432
462
  const report = {
@@ -490,7 +520,7 @@ function guidanceForRun(context, run, index) {
490
520
  protocolStage: state.stage,
491
521
  ...(state.flow_contract ? { contract: state.flow_contract } : {}),
492
522
  runId: run.id,
493
- runDir: index.run_home?.relative_path ?? index.workspace.run_dir
523
+ runDir: index.run_home?.relative_path ?? path.posix.join("runs", run.id)
494
524
  });
495
525
  }
496
526
  catch (error) {
@@ -498,11 +528,10 @@ function guidanceForRun(context, run, index) {
498
528
  throw error;
499
529
  }
500
530
  }
501
- return buildRunFlowGuidance({ stageRuns: index.stage_runs, runId: run.id, runDir: index.run_home?.relative_path ?? index.workspace.run_dir });
531
+ return buildRunFlowGuidance({ stageRuns: index.stage_runs, runId: run.id, runDir: index.run_home?.relative_path ?? path.posix.join("runs", run.id) });
502
532
  }
503
- function persistRunIndex(context, project, run, index, options = {}) {
533
+ function persistRunState(context, project, run, index, options = {}) {
504
534
  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
535
  const previousRuntime = readRuntimeSnapshot(runtimePath);
507
536
  const runtimeRevision = Math.max(index.runtime_revision ?? 0, previousRuntime?.runtime_revision ?? 0) + 1;
508
537
  index.runtime_revision = runtimeRevision;
@@ -511,9 +540,7 @@ function persistRunIndex(context, project, run, index, options = {}) {
511
540
  history.push(options.flagRevision);
512
541
  }
513
542
  ensureDir(path.dirname(runtimePath));
514
- ensureDir(path.dirname(runIndexPath));
515
543
  writeJsonFile(runtimePath, runtimeSnapshotForIndex(index, runtimeRevision, history));
516
- writeJsonFile(runIndexPath, index);
517
544
  context.db.run(`UPDATE flow_runs
518
545
  SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?,
519
546
  updated_at = ?, completed_at = ?
@@ -523,7 +550,7 @@ function persistRunIndex(context, project, run, index, options = {}) {
523
550
  index.next_action,
524
551
  JSON.stringify(index),
525
552
  runtimePath,
526
- runIndexPath,
553
+ runtimePath,
527
554
  index.updated_at,
528
555
  index.completed_at ?? null,
529
556
  project.id,
@@ -539,10 +566,12 @@ function appendRunTimeline(runHome, event) {
539
566
  const last = lines.length > 0 ? JSON.parse(lines[lines.length - 1]) : undefined;
540
567
  sequence = typeof last?.sequence === "number" ? last.sequence : lines.length;
541
568
  }
569
+ const safeEvent = sanitizeTimelineValue(event);
542
570
  const enriched = {
543
571
  event_id: crypto.randomUUID(),
544
572
  sequence: sequence + 1,
545
- ...event
573
+ at: typeof safeEvent.at === "string" ? safeEvent.at : new Date().toISOString(),
574
+ ...safeEvent
546
575
  };
547
576
  fs.appendFileSync(timelinePath, `${JSON.stringify(enriched)}\n`);
548
577
  }
@@ -552,7 +581,6 @@ function runtimeSnapshotForIndex(index, runtimeRevision, flagRevisionHistory = [
552
581
  return {
553
582
  ...rest,
554
583
  schema_id: runtimeSchemaId,
555
- run_index_schema_id: index.schema_id,
556
584
  runtime_revision: runtimeRevision,
557
585
  ...(flagRevisionHistory.length > 0 ? { flag_revision_history: flagRevisionHistory.slice(-32) } : {})
558
586
  };
@@ -568,18 +596,26 @@ function readRuntimeSnapshot(file) {
568
596
  return undefined;
569
597
  }
570
598
  }
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);
599
+ function authoritativeIndex(run) {
600
+ const runtime = readRuntimeSnapshot(run.runtime_path);
601
+ if (!runtime) {
602
+ throw new AppError("validation", "RUN authority is missing or invalid", 2, { run_id: run.id, path: run.runtime_path });
581
603
  }
582
- return hidden;
604
+ return { ...runtime, schema_id: runSchemaId };
605
+ }
606
+ function sanitizeTimelineValue(value, key) {
607
+ if (key && /(secret|password|authorization|cookie|api[_-]?key|private[_-]?key|access[_-]?token|refresh[_-]?token|transcript|prompt|tool[_-]?output|document[_-]?content)/i.test(key)) {
608
+ return "[REDACTED]";
609
+ }
610
+ if (typeof value === "string") {
611
+ return value.length > 512 ? `${value.slice(0, 512)}…[TRUNCATED]` : value;
612
+ }
613
+ if (Array.isArray(value))
614
+ return value.map((item) => sanitizeTimelineValue(item));
615
+ if (value && typeof value === "object") {
616
+ return Object.fromEntries(Object.entries(value).map(([entryKey, entryValue]) => [entryKey, sanitizeTimelineValue(entryValue, entryKey)]));
617
+ }
618
+ return value;
583
619
  }
584
620
  function durationMs(startedAt, completedAt) {
585
621
  if (!startedAt || !completedAt)
@@ -669,13 +705,6 @@ function flowRunSummary(run) {
669
705
  completed_at: run.completed_at
670
706
  };
671
707
  }
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
708
  function parseRunFlowKind(value) {
680
709
  const normalized = value === "mb-sdlc" ? "mb_sdlc" : value;
681
710
  if (!allowedRunFlowKinds.includes(normalized)) {
@@ -763,12 +792,17 @@ function writeJsonFile(file, value) {
763
792
  fs.renameSync(tmpFile, file);
764
793
  }
765
794
  function runArtifactRoot(run) {
766
- return path.dirname(run.run_index_path);
795
+ return run.run_home_path ?? path.dirname(run.runtime_path);
767
796
  }
768
797
  function gitFacts(workspaceRoot) {
769
798
  const read = (args) => {
770
799
  const result = spawnSync("git", ["-C", workspaceRoot, ...args.split(" ")], { encoding: "utf8" });
771
800
  return result.status === 0 ? result.stdout.trim() || null : null;
772
801
  };
773
- return { branch: read("branch --show-current"), head: read("rev-parse HEAD") };
802
+ const status = read("status --porcelain --untracked-files=all");
803
+ return {
804
+ branch: read("branch --show-current"),
805
+ head: read("rev-parse HEAD"),
806
+ status: status === null ? "unavailable" : status.length > 0 ? "dirty" : "clean"
807
+ };
774
808
  }