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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +636 -0
  2. package/README.md +13 -1
  3. package/dist/build-info.json +10 -10
  4. package/dist/cli/help.js +108 -18
  5. package/dist/cli/run-cli.js +626 -49
  6. package/dist/domain/flow-contract.js +11 -0
  7. package/dist/domain/stage-catalog.js +22 -0
  8. package/dist/runtime/context.js +8 -2
  9. package/dist/schemas/code-review-decision.schema.json +26 -0
  10. package/dist/schemas/code-review-result.schema.json +14 -0
  11. package/dist/schemas/code-stage-report.schema.json +7 -2
  12. package/dist/schemas/code-verification.schema.json +14 -0
  13. package/dist/schemas/code-work-batch.schema.json +24 -0
  14. package/dist/schemas/code-work-result.schema.json +16 -0
  15. package/dist/schemas/engine-manifest.schema.json +22 -0
  16. package/dist/schemas/flow-contract.schema.json +6 -3
  17. package/dist/schemas/flow-run.schema.json +16 -122
  18. package/dist/schemas/mb-upgrade-migration-report.schema.json +3 -1
  19. package/dist/schemas/merge-stage-report-legacy-0.4.2.schema.json +24 -0
  20. package/dist/schemas/plan-aspect-map.schema.json +22 -0
  21. package/dist/schemas/plan-review-decision.schema.json +14 -0
  22. package/dist/schemas/plan-review-result.schema.json +42 -0
  23. package/dist/schemas/protocol-plan.schema.json +15 -182
  24. package/dist/schemas/run-engine-binding.schema.json +37 -0
  25. package/dist/schemas/stage-finish-input.schema.json +16 -2
  26. package/dist/schemas/stage-prompt.schema.json +4 -4
  27. package/dist/schemas/stage-report.schema.json +8 -7
  28. package/dist/schemas/vnext-protocol-plan.schema.json +37 -0
  29. package/dist/schemas/vnext-protocolize-result.schema.json +29 -0
  30. package/dist/schemas/vnext-specify.schema.json +45 -0
  31. package/dist/services/branch-context.js +1 -1
  32. package/dist/services/canon.js +15 -1
  33. package/dist/services/cleanup.js +8 -8
  34. package/dist/services/cli-operation-classifier.js +60 -8
  35. package/dist/services/code-checks.js +244 -0
  36. package/dist/services/compatibility-preflight.js +1 -1
  37. package/dist/services/config.js +7 -1
  38. package/dist/services/dashboard.js +14 -14
  39. package/dist/services/engines.js +408 -30
  40. package/dist/services/eval-snapshots.js +404 -0
  41. package/dist/services/hooks.js +775 -23
  42. package/dist/services/ids.js +16 -6
  43. package/dist/services/lanes.js +1 -5
  44. package/dist/services/merge-queue.js +53 -5
  45. package/dist/services/merge-worker.js +5 -6
  46. package/dist/services/migrations.js +307 -44
  47. package/dist/services/plan-runtime.js +5 -5
  48. package/dist/services/plans.js +5 -3
  49. package/dist/services/projects.js +4 -4
  50. package/dist/services/prompts.js +1 -1
  51. package/dist/services/protocols.js +31 -10
  52. package/dist/services/run-engine-bindings.js +157 -0
  53. package/dist/services/run-projection.js +49 -13
  54. package/dist/services/runs.js +525 -58
  55. package/dist/services/schema-validation.js +116 -2
  56. package/dist/services/sessions.js +51 -12
  57. package/dist/services/stage-blocker.js +57 -0
  58. package/dist/services/stage-context.js +90 -0
  59. package/dist/services/stage-lifecycle.js +288 -77
  60. package/dist/services/stage-pause.js +175 -0
  61. package/dist/services/stage-report-renderer.js +65 -0
  62. package/dist/services/status.js +8 -3
  63. package/dist/services/usage.js +526 -18
  64. package/dist/services/vnext-code-review.js +308 -0
  65. package/dist/services/vnext-code.js +616 -0
  66. package/dist/services/vnext-contracts.js +1 -0
  67. package/dist/services/vnext-execution-profile.js +27 -0
  68. package/dist/services/vnext-fanout.js +79 -0
  69. package/dist/services/vnext-plan-review.js +499 -0
  70. package/dist/services/vnext-plan.js +576 -0
  71. package/dist/services/vnext-protocolize.js +542 -0
  72. package/dist/services/vnext-specify.js +595 -0
  73. package/dist/services/vnext-workspace-policy.js +87 -0
  74. package/dist/services/work-registry.js +499 -0
  75. package/dist/services/worktrees.js +58 -37
  76. package/dist/storage/database.js +292 -42
  77. package/dist/storage/paths.js +47 -1
  78. package/package.json +12 -12
@@ -3,17 +3,20 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { formatFullId, isFullEntityId, isShortEntityId, parseFullEntityId } from "../domain/entity-ids.js";
6
- import { checksumForFlowFlagValues, isFlowFlagDowngrade, loadProjectFlowContract, resolveFlowFlags } from "../domain/flow-contract.js";
6
+ import { checksumForFlowFlagValues, isFlowFlagDowngrade, loadCanonicalFlowContract, loadProjectFlowContract, resolveFlowFlags } from "../domain/flow-contract.js";
7
7
  import { AppError } from "../shared/errors.js";
8
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
- import { checkpointRunUsage, usageForRun } from "./usage.js";
13
+ import { recalculateRunUsage } from "./usage.js";
14
14
  import { refreshRunSessionProjection } from "./run-projection.js";
15
- const runSchemaId = "dd-flow/flow-run@2";
16
- const runtimeSchemaId = "dd-flow/flow-run@2";
15
+ import { resolveCanonRoot } from "./canon.js";
16
+ import { bindCurrentEngineToRun } from "./engines.js";
17
+ import { executionProfilePath, loadVnextExecutionProfile } from "./vnext-execution-profile.js";
18
+ const runSchemaId = "dd-flow/flow-run@3";
19
+ const runtimeSchemaId = "dd-flow/flow-run@3";
17
20
  const runIdType = "RUN";
18
21
  const allowedRunFlowKinds = [
19
22
  "mb_sdlc",
@@ -25,12 +28,16 @@ const allowedRunFlowKinds = [
25
28
  "mb-distill",
26
29
  "mb-upgrade-review",
27
30
  "mb-sdlc-review",
31
+ "vnext_specify",
32
+ "vnext_protocolize",
28
33
  "review",
29
34
  "custom"
30
35
  ];
31
36
  export function startFlowRun(context, input) {
32
37
  const projectRoot = resolveProjectRoot(input.projectRoot);
33
- const flowContract = loadProjectFlowContract(projectRoot);
38
+ const flowContract = input.flowKind === "mb-upgrade"
39
+ ? loadUpgradeFlowContract(context, projectRoot)
40
+ : loadProjectFlowContract(projectRoot);
34
41
  registerProject(context, { root: projectRoot });
35
42
  const project = requireProjectByRoot(context, projectRoot);
36
43
  const workspaceRoot = resolveWorkspaceRoot(input.workspaceRoot ?? projectRoot);
@@ -39,6 +46,9 @@ export function startFlowRun(context, input) {
39
46
  const runId = nextRunId(context, project.id, slug);
40
47
  const { shortId } = parseFullEntityId(runId);
41
48
  const now = context.now();
49
+ const executionProfile = (flowKind === "vnext_protocolize" || flowKind === "vnext_specify")
50
+ ? snapshotVnextExecutionProfile(projectRoot)
51
+ : undefined;
42
52
  const flowFlags = resolveFlowFlags(flowContract, {
43
53
  flowKind,
44
54
  ...(input.preset ? { preset: input.preset } : {}),
@@ -86,7 +96,6 @@ export function startFlowRun(context, input) {
86
96
  },
87
97
  stage_runs: [],
88
98
  sessions: [],
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
99
  status: "running",
91
100
  verdict: "pending",
92
101
  next_action: input.nextAction ?? null,
@@ -99,20 +108,30 @@ export function startFlowRun(context, input) {
99
108
  snapshot_checksum: flowFlags.snapshot_checksum,
100
109
  reconciliation_status: "reconciled",
101
110
  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: [] },
111
+ settings: {
112
+ plan_review: { mode: executionProfile?.settings.plan_review_mode ?? "auto", source: "default", reason: "Project execution profile default.", updated_at: now },
113
+ code_review: { mode: executionProfile?.settings.code_review_mode ?? "auto", source: "default", reason: "Project execution profile default.", updated_at: now }
114
+ },
115
+ ...(executionProfile ? { execution_profile: executionProfile } : {}),
116
+ artifacts: {
117
+ stage_prompt: "stage-prompt.md",
118
+ stage_report_json: "stage-report.json",
119
+ stage_report_md: "stage-report.md",
120
+ stage_report_html: "stage-report.html",
121
+ summary: "summary.md"
122
+ },
106
123
  timeline_path: "timeline.jsonl"
107
124
  };
108
125
  ensureDir(path.dirname(runtimePath));
109
126
  ensureDir(runHome);
110
- writeJsonFile(runtimePath, runtimeSnapshotForIndex(index, 1));
111
- context.db.run(`INSERT INTO flow_runs
127
+ bindCurrentEngineToRun(context, { projectRoot, runId, runHome });
128
+ const persistedIndex = persistedVnextIndex(index);
129
+ writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, 1));
130
+ context.db.run(`INSERT INTO runs
112
131
  (id, short_id, slug, project_id, project_root, workspace_root, flow_kind, subject_type, subject_id,
113
- status, verdict, next_action, runtime_path, run_dir, run_index_path, run_home_path, layout_version, artifact_root_kind,
132
+ status, verdict, next_action, runtime_path, run_dir, run_index_path, run_home_path, run_root, layout_version, artifact_root_kind,
114
133
  index_json, created_at, updated_at, completed_at)
115
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`, [
134
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`, [
116
135
  runId,
117
136
  shortId,
118
137
  slug,
@@ -129,9 +148,10 @@ export function startFlowRun(context, input) {
129
148
  runHome,
130
149
  runtimePath,
131
150
  runHome,
151
+ runHome,
132
152
  "home_run_v2",
133
153
  "dd_flow_home",
134
- JSON.stringify(index),
154
+ JSON.stringify(persistedIndex),
135
155
  now,
136
156
  now
137
157
  ]);
@@ -151,14 +171,121 @@ export function startFlowRun(context, input) {
151
171
  });
152
172
  return { ok: true, run: flowRunSummary(requireRunById(context, project.id, runId)), index };
153
173
  }
174
+ function snapshotVnextExecutionProfile(projectRoot) {
175
+ const profile = loadVnextExecutionProfile(projectRoot);
176
+ const sourcePath = executionProfilePath(projectRoot);
177
+ return {
178
+ schema_id: "dd-flow/run-execution-profile@1",
179
+ source_path: sourcePath,
180
+ source_checksum: crypto.createHash("sha256").update(fs.readFileSync(sourcePath)).digest("hex"),
181
+ settings: {
182
+ stage_session_mode: profile.stage_session_mode,
183
+ plan_review_mode: profile.plan_review_mode,
184
+ code_review_mode: profile.code_review_mode,
185
+ stop_target: profile.stop_target,
186
+ code_bootstrap: profile.code_bootstrap
187
+ }
188
+ };
189
+ }
190
+ /**
191
+ * A vNext route may materialize a feature checkout after its semantic
192
+ * PROTOCOLIZE decision. Keep project identity stable while moving the RUN's
193
+ * concrete write workspace before PLAN is allowed to start.
194
+ */
195
+ export function rebindFlowRunWorkspace(context, input) {
196
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
197
+ const run = resolveRun(context, project.id, input.runId);
198
+ const workspaceRoot = resolveWorkspaceRoot(input.workspaceRoot);
199
+ const index = authoritativeIndex(run);
200
+ const now = context.now();
201
+ const facts = gitFacts(workspaceRoot);
202
+ index.workspace = { ...index.workspace, workspace_path: workspaceRoot, branch: facts.branch ?? "unknown", base: facts.head ?? "unknown", manager: "official-worktrunk" };
203
+ index.execution = { ...index.execution, project_root: project.root, workspace_root: workspaceRoot, git: facts };
204
+ index.updated_at = now;
205
+ context.db.run("UPDATE runs SET workspace_root = ? WHERE project_id = ? AND id = ?", [workspaceRoot, project.id, run.id]);
206
+ persistRunState(context, project, { ...run, workspace_root: workspaceRoot }, index);
207
+ appendAudit(context, { projectId: project.id, eventType: "flow_run.workspace_rebound", payload: { run_id: run.id, workspace_root: workspaceRoot, reason: input.reason } });
208
+ appendRunTimeline(runArtifactRoot(run), { at: now, type: "workspace_rebound", run_id: run.id, workspace_root: workspaceRoot, reason: input.reason, git: facts });
209
+ return { ok: true, run_id: run.id, project_root: project.root, workspace_root: workspaceRoot, git: facts };
210
+ }
211
+ function loadUpgradeFlowContract(context, projectRoot) {
212
+ const canon = resolveCanonRoot(context, context.env.DD_MEMORYBANK ? { explicitRoot: context.env.DD_MEMORYBANK } : {});
213
+ if (!canon.ok || !canon.canon) {
214
+ throw new AppError("canon_unavailable", "mb-upgrade RUN requires the pinned canonical Memory Bank", 1, {
215
+ project_root: projectRoot,
216
+ blockers: canon.blockers,
217
+ bootstrap: canon.bootstrap
218
+ });
219
+ }
220
+ return loadCanonicalFlowContract(canon.canon.flow_root);
221
+ }
154
222
  export function getFlowRunStatus(context, input) {
155
223
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
156
224
  const run = resolveRun(context, project.id, input.runId);
157
- refreshRunSessionProjection(context, project.id, run.id);
158
- const refreshedRun = requireRunById(context, project.id, run.id);
159
- const index = authoritativeIndex(refreshedRun);
160
- const runtime = readRuntimeSnapshot(refreshedRun.runtime_path);
161
- return { ok: true, run: flowRunSummary(refreshedRun), index, ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, refreshedRun, index) };
225
+ const index = authoritativeIndex(run);
226
+ const runtime = readRuntimeSnapshot(run.runtime_path);
227
+ return { ok: true, run: flowRunSummary(run), index, ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, run, index) };
228
+ }
229
+ export function getFlowRunConfig(context, input) {
230
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
231
+ const run = resolveRun(context, project.id, input.runId);
232
+ const index = authoritativeIndex(run);
233
+ return { ok: true, run_id: run.id, settings: runSettings(index) };
234
+ }
235
+ /** RUN variables are one small shared context bag; policy values are projected, not duplicated. */
236
+ export function getFlowRunVariables(context, input) {
237
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
238
+ const run = resolveRun(context, project.id, input.runId);
239
+ const variables = materializedRunVariables(authoritativeIndex(run));
240
+ if (input.key) {
241
+ if (!(input.key in variables))
242
+ throw new AppError("not_found", "RUN variable is not set", 1, { key: input.key, run_id: run.id });
243
+ return { ok: true, run_id: run.id, key: input.key, value: variables[input.key] };
244
+ }
245
+ return { ok: true, run_id: run.id, variables };
246
+ }
247
+ export function setFlowRunVariable(context, input) {
248
+ if (!input.key.startsWith("user."))
249
+ throw new AppError("validation", "Only user.* RUN variables may be set directly", 2, { key: input.key });
250
+ return setRunVariable(context, input, input.value);
251
+ }
252
+ /** Controllers own policy.* and runtime.* values; agents never call this directly. */
253
+ export function setRuntimeRunVariable(context, input) {
254
+ if (!input.key.startsWith("runtime."))
255
+ throw new AppError("validation", "Controller runtime variables must use runtime.*", 2, { key: input.key });
256
+ return setRunVariable(context, input, input.value);
257
+ }
258
+ export function setFlowRunConfig(context, input) {
259
+ if (input.key !== "plan_review.mode" && input.key !== "code_review.mode") {
260
+ throw new AppError("validation", `Unknown RUN config key: ${input.key}`, 2);
261
+ }
262
+ if (!["auto", "off", "standard", "deep"].includes(input.value)) {
263
+ throw new AppError("validation", "--value for review mode must be auto, off, standard, or deep", 2);
264
+ }
265
+ const reason = requiredPlain(input.reason, "reason");
266
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
267
+ const run = resolveRun(context, project.id, input.runId);
268
+ const index = authoritativeIndex(run);
269
+ const stageName = input.key === "plan_review.mode" ? "plan-review" : "code-review";
270
+ if (index.stage_runs.some((stage) => stage.stage === stageName)) {
271
+ throw new AppError("invalid_run_config_state", `${input.key} is frozen after ${stageName} starts`, 2, { run_id: run.id });
272
+ }
273
+ const now = context.now();
274
+ const settings = runSettings(index);
275
+ const previous = input.key === "plan_review.mode" ? settings.plan_review : settings.code_review;
276
+ const mode = input.value;
277
+ const source = input.source ?? "user_instruction";
278
+ if (previous.mode === mode && previous.reason === reason && previous.source === source) {
279
+ return { ok: true, run_id: run.id, settings, idempotent: true };
280
+ }
281
+ index.settings = input.key === "plan_review.mode"
282
+ ? { ...settings, plan_review: { mode, source, reason, updated_at: now } }
283
+ : { ...settings, code_review: { mode, source, reason, updated_at: now } };
284
+ index.updated_at = now;
285
+ persistRunState(context, project, run, index);
286
+ appendAudit(context, { projectId: project.id, eventType: "flow_run.config_set", payload: { run_id: run.id, key: input.key, value: mode, source, reason } });
287
+ appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_config_set", run_id: run.id, key: input.key, value: mode, source, reason });
288
+ return { ok: true, run_id: run.id, settings: runSettings(index), idempotent: false };
162
289
  }
163
290
  export function getFlowRunFlagsStatus(context, input) {
164
291
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -305,7 +432,7 @@ export function reviseFlowRunFlags(context, input) {
305
432
  export function listFlowRuns(context, input) {
306
433
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
307
434
  const runs = context.db
308
- .all(`SELECT * FROM flow_runs
435
+ .all(`SELECT * FROM runs
309
436
  WHERE project_id = ?
310
437
  ORDER BY updated_at DESC, id DESC`, [project.id])
311
438
  .map(flowRunSummary);
@@ -349,16 +476,11 @@ export function attachFlowRunStage(context, input) {
349
476
  payload: { run_id: run.id, stage, dir, status }
350
477
  });
351
478
  appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_attached", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
352
- if (status === "running") {
353
- context.db.run("UPDATE flow_sessions SET current_stage = ?, updated_at = ? WHERE project_id = ? AND run_id = ?", [
354
- stage,
355
- now,
356
- project.id,
357
- run.id
358
- ]);
479
+ // A stage transition must not rewrite every Session in the RUN. A RUN may
480
+ // contain independent reviewer/worker sessions; their stage is set by the
481
+ // stage/work binding that actually owns that Session.
482
+ if (status === "running")
359
483
  refreshRunSessionProjection(context, project.id, run.id);
360
- checkpointRunUsage(context, { projectId: project.id, runId: run.id, checkpoint: "stage_started", stage, stageAttempt: stageRun.attempt ?? null });
361
- }
362
484
  const updatedRun = requireRunById(context, project.id, run.id);
363
485
  return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
364
486
  }
@@ -382,11 +504,23 @@ export function completeFlowRunStage(context, input) {
382
504
  ...(input.report ? { report: input.report } : {}),
383
505
  ...(input.aliases && input.aliases.length > 0 ? { artifact_aliases: input.aliases } : {}),
384
506
  updated_at: now,
385
- ...(status === "done" || status === "blocked" || status === "failed" || status === "skipped"
507
+ ...(status === "done" || status === "waiting_for_user" || status === "blocked" || status === "failed" || status === "skipped"
386
508
  ? { completed_at: now, duration_ms: durationMs(existing.started_at ?? null, now), git: gitFacts(run.workspace_root) }
387
509
  : {})
388
510
  };
389
511
  upsertStage(index, stageRun);
512
+ if (status === "waiting_for_user") {
513
+ index.status = "waiting_for_user";
514
+ index.verdict = "waiting_for_user";
515
+ }
516
+ else if (status === "blocked" || status === "failed") {
517
+ index.status = status;
518
+ index.verdict = status;
519
+ }
520
+ else if (status === "done" && index.status === "waiting_for_user") {
521
+ index.status = "running";
522
+ index.verdict = "running";
523
+ }
390
524
  index.current_stage = stage;
391
525
  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 }));
392
526
  index.updated_at = now;
@@ -403,16 +537,147 @@ export function completeFlowRunStage(context, input) {
403
537
  payload: { run_id: run.id, stage, status, stage_report: input.stageReport ?? null, data: input.data ?? null }
404
538
  });
405
539
  appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_completed", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
406
- checkpointRunUsage(context, { projectId: project.id, runId: run.id, checkpoint: "stage_finished", stage, stageAttempt: stageRun.attempt ?? null });
407
540
  const updatedRun = requireRunById(context, project.id, run.id);
408
541
  return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
409
542
  }
543
+ /** Pause one live stage without completing it or creating a new attempt. */
544
+ export function pauseFlowRunStage(context, input) {
545
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
546
+ const run = resolveRun(context, project.id, input.runId);
547
+ const index = authoritativeIndex(run);
548
+ const stage = requiredPlain(input.stage, "stage");
549
+ const stageRun = index.stage_runs.find((item) => item.stage === stage);
550
+ if (!stageRun || stageRun.status !== "running") {
551
+ throw new AppError("invalid_stage_state", "Only a running stage can pause", 1, { run_id: run.id, stage, status: stageRun?.status ?? "missing" });
552
+ }
553
+ const now = context.now();
554
+ stageRun.status = "paused";
555
+ stageRun.updated_at = now;
556
+ stageRun.pause = {
557
+ id: input.pauseId,
558
+ reason: "waiting_for_user",
559
+ work_id: input.workId,
560
+ question_path: input.questionPath,
561
+ answer_path: null,
562
+ paused_at: now,
563
+ resumed_at: null
564
+ };
565
+ index.pause = stageRun.pause;
566
+ index.status = operationalRunStatus(context, project.id, run.id);
567
+ index.verdict = index.status === "paused" ? "paused" : "running";
568
+ index.next_action = "await_user_answer";
569
+ index.current_stage = stage;
570
+ index.updated_at = now;
571
+ 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 }));
572
+ persistRunState(context, project, run, index);
573
+ refreshRunSessionProjection(context, project.id, run.id);
574
+ appendAudit(context, { projectId: project.id, eventType: "flow_run.stage_paused", payload: { run_id: run.id, stage, work_id: input.workId, pause_id: input.pauseId, reason: "waiting_for_user" } });
575
+ appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_waiting_for_user", run_id: run.id, stage, work_id: input.workId, pause_id: input.pauseId, question_path: input.questionPath });
576
+ return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
577
+ }
578
+ /** Resume the same stage, Work and attempt after a user answer is recorded. */
579
+ export function resumeFlowRunStage(context, input) {
580
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
581
+ const run = resolveRun(context, project.id, input.runId);
582
+ const index = authoritativeIndex(run);
583
+ const stage = requiredPlain(input.stage, "stage");
584
+ const stageRun = index.stage_runs.find((item) => item.stage === stage);
585
+ if (!stageRun || stageRun.status !== "paused" || !stageRun.pause || stageRun.pause.work_id !== input.workId) {
586
+ throw new AppError("invalid_stage_state", "Stage resume requires its matching paused Work", 1, { run_id: run.id, stage, work_id: input.workId, status: stageRun?.status ?? "missing" });
587
+ }
588
+ const now = context.now();
589
+ const pause = stageRun.pause;
590
+ const pausedMs = durationMs(pause.paused_at, now) ?? 0;
591
+ stageRun.status = "running";
592
+ stageRun.updated_at = now;
593
+ stageRun.paused_ms = (stageRun.paused_ms ?? 0) + pausedMs;
594
+ delete stageRun.pause;
595
+ index.pause = undefined;
596
+ index.status = "running";
597
+ index.verdict = "running";
598
+ index.next_action = `continue_${stage}`;
599
+ index.current_stage = stage;
600
+ index.updated_at = now;
601
+ 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 }));
602
+ persistRunState(context, project, run, index);
603
+ refreshRunSessionProjection(context, project.id, run.id);
604
+ appendAudit(context, { projectId: project.id, eventType: "flow_run.stage_resumed", payload: { run_id: run.id, stage, work_id: input.workId, pause_id: pause.id, answer_path: input.answerPath } });
605
+ appendRunTimeline(runArtifactRoot(run), { at: now, type: "user_answer_received", run_id: run.id, stage, work_id: input.workId, pause_id: pause.id, answer_path: input.answerPath });
606
+ appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_resumed", run_id: run.id, stage, work_id: input.workId, pause_id: pause.id, paused_ms: pausedMs });
607
+ return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
608
+ }
609
+ /** Pause the current stage for a non-user runtime blocker. */
610
+ export function blockFlowRunStage(context, input) {
611
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
612
+ const run = resolveRun(context, project.id, input.runId);
613
+ const index = authoritativeIndex(run);
614
+ const stage = requiredPlain(input.stage, "stage");
615
+ const stageRun = index.stage_runs.find((item) => item.stage === stage);
616
+ if (!stageRun || stageRun.status !== "running")
617
+ throw new AppError("invalid_stage_state", "Only a running stage can be blocked", 1, { run_id: run.id, stage, status: stageRun?.status ?? "missing" });
618
+ const now = context.now();
619
+ stageRun.status = "blocked";
620
+ stageRun.updated_at = now;
621
+ stageRun.blocker = { kind: input.kind, code: input.code, summary: input.summary, retryable: input.retryable, work_id: input.workId, blocked_at: now, resumed_at: null };
622
+ index.blocker = stageRun.blocker;
623
+ index.status = "paused";
624
+ index.verdict = "paused";
625
+ index.next_action = "resolve_blocker_then_unblock_same_stage";
626
+ index.current_stage = stage;
627
+ index.updated_at = now;
628
+ persistRunState(context, project, run, index);
629
+ refreshRunSessionProjection(context, project.id, run.id);
630
+ appendAudit(context, { projectId: project.id, eventType: "flow_run.stage_blocked", payload: { run_id: run.id, stage, work_id: input.workId, kind: input.kind, code: input.code, retryable: input.retryable } });
631
+ appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_blocked", run_id: run.id, stage, work_id: input.workId, kind: input.kind, code: input.code, summary: input.summary, retryable: input.retryable });
632
+ return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
633
+ }
634
+ /** Continue the same stage after its non-user blocker was repaired externally. */
635
+ export function unblockFlowRunStage(context, input) {
636
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
637
+ const run = resolveRun(context, project.id, input.runId);
638
+ const index = authoritativeIndex(run);
639
+ const stage = requiredPlain(input.stage, "stage");
640
+ const stageRun = index.stage_runs.find((item) => item.stage === stage);
641
+ if (!stageRun || stageRun.status !== "blocked" || !stageRun.blocker || stageRun.blocker.work_id !== input.workId)
642
+ throw new AppError("invalid_stage_state", "Stage unblock requires its matching runtime blocker", 1, { run_id: run.id, stage, work_id: input.workId, status: stageRun?.status ?? "missing" });
643
+ const now = context.now();
644
+ const blocker = stageRun.blocker;
645
+ const blockedMs = durationMs(blocker.blocked_at, now) ?? 0;
646
+ stageRun.status = "running";
647
+ stageRun.updated_at = now;
648
+ stageRun.paused_ms = (stageRun.paused_ms ?? 0) + blockedMs;
649
+ delete stageRun.blocker;
650
+ index.blocker = undefined;
651
+ index.status = "running";
652
+ index.verdict = "running";
653
+ index.next_action = `continue_${stage}`;
654
+ index.current_stage = stage;
655
+ index.updated_at = now;
656
+ persistRunState(context, project, run, index);
657
+ refreshRunSessionProjection(context, project.id, run.id);
658
+ appendAudit(context, { projectId: project.id, eventType: "flow_run.stage_unblocked", payload: { run_id: run.id, stage, work_id: input.workId, code: blocker.code } });
659
+ appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_unblocked", run_id: run.id, stage, work_id: input.workId, code: blocker.code, blocked_ms: blockedMs });
660
+ return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
661
+ }
662
+ function operationalRunStatus(context, projectId, runId) {
663
+ const unfinished = context.db.all("SELECT work_id, status, parent_work_id FROM works WHERE project_id = ? AND run_id = ? AND status IN ('created', 'running', 'paused')", [projectId, runId]);
664
+ const parents = new Set(unfinished.map((work) => work.parent_work_id).filter((id) => Boolean(id)));
665
+ const leaves = unfinished.filter((work) => !parents.has(work.work_id));
666
+ return leaves.length > 0 && leaves.every((work) => work.status === "paused") ? "paused" : "running";
667
+ }
410
668
  export function completeFlowRun(context, input) {
411
669
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
412
670
  const run = resolveRun(context, project.id, input.runId);
413
671
  const index = authoritativeIndex(run);
414
672
  const now = context.now();
415
673
  const status = parseRunStatus(input.status);
674
+ if (!['done', 'blocked', 'cancelled', 'failed'].includes(status)) {
675
+ throw new AppError('validation', 'completeFlowRun accepts terminal statuses only; use advanceFlowRun for a live RUN', 2, { status });
676
+ }
677
+ if (input.manualOverrideReason && (status === "cancelled" || status === "failed")) {
678
+ closeOpenStagesForOverride(index, status, now);
679
+ closeOpenWorksForOverride(context, project.id, run.id, status, now);
680
+ }
416
681
  index.status = status;
417
682
  index.verdict = input.verdict ?? (status === "done" ? "accepted" : status);
418
683
  index.next_action = input.nextAction ?? null;
@@ -429,27 +694,80 @@ export function completeFlowRun(context, input) {
429
694
  }
430
695
  persistRunState(context, project, run, index);
431
696
  closeRunOrchestrators(context, project.id, run, status, now);
697
+ const usageProjection = recalculateRunUsage(context, { projectId: project.id, runId: run.id });
698
+ const usage = {
699
+ status: usageProjection.status ?? "unavailable",
700
+ session_count: Array.isArray(usageProjection.sessions) ? usageProjection.sessions.length : 0,
701
+ coverage: usageProjection.coverage ?? {},
702
+ session_reconciliation: usageProjection.session_reconciliation ?? {}
703
+ };
432
704
  refreshRunSessionProjection(context, project.id, run.id);
433
705
  appendAudit(context, {
434
706
  projectId: project.id,
435
- eventType: "flow_run.completed",
436
- payload: { run_id: run.id, status, verdict: index.verdict, next_action: index.next_action }
707
+ eventType: input.manualOverrideReason ? "flow_run.manual_override" : "flow_run.completed",
708
+ payload: { run_id: run.id, status, verdict: index.verdict, next_action: index.next_action, ...(input.manualOverrideReason ? { reason: input.manualOverrideReason } : {}) }
437
709
  });
438
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_completed", run_id: run.id, status });
439
- checkpointRunUsage(context, { projectId: project.id, runId: run.id, checkpoint: "run_finished" });
710
+ appendRunTimeline(runArtifactRoot(run), { at: now, type: input.manualOverrideReason ? "run_manual_override" : "run_completed", run_id: run.id, status, ...(input.manualOverrideReason ? { reason: input.manualOverrideReason } : {}) });
711
+ const updatedRun = requireRunById(context, project.id, run.id);
712
+ return { ok: true, run: flowRunSummary(updatedRun), index, usage, flow_guidance: guidanceForRun(context, updatedRun, index) };
713
+ }
714
+ /** Update a live RUN between stages without manufacturing terminal evidence. */
715
+ export function advanceFlowRun(context, input) {
716
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
717
+ const run = resolveRun(context, project.id, input.runId);
718
+ const index = authoritativeIndex(run);
719
+ const now = context.now();
720
+ index.status = input.status;
721
+ index.verdict = input.verdict;
722
+ index.next_action = input.nextAction ?? null;
723
+ index.updated_at = now;
724
+ persistRunState(context, project, run, index);
725
+ appendAudit(context, { projectId: project.id, eventType: "flow_run.progressed", payload: { run_id: run.id, status: input.status, verdict: input.verdict, next_action: index.next_action } });
726
+ appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_progressed", run_id: run.id, status: input.status, next_action: index.next_action });
440
727
  const updatedRun = requireRunById(context, project.id, run.id);
441
728
  return { ok: true, run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
442
729
  }
730
+ function closeOpenStagesForOverride(index, status, now) {
731
+ const stageStatus = status === "cancelled" ? "skipped" : "failed";
732
+ for (const stage of index.stage_runs) {
733
+ if (stage.status !== "running" && stage.status !== "pending")
734
+ continue;
735
+ stage.status = stageStatus;
736
+ stage.updated_at = now;
737
+ stage.completed_at = now;
738
+ stage.duration_ms = durationMs(stage.started_at ?? null, now);
739
+ }
740
+ index.attempts = index.stage_runs.map((stage) => ({
741
+ stage: stage.stage,
742
+ attempt_number: Number((stage.attempt ?? "try-001").replace("try-", "")) || 1,
743
+ root: stage.dir,
744
+ archive: null,
745
+ status: stage.status,
746
+ started_at: stage.started_at ?? now,
747
+ finished_at: stage.completed_at ?? null
748
+ }));
749
+ }
750
+ function closeOpenWorksForOverride(context, projectId, runId, status, now) {
751
+ const workStatus = status === "cancelled" ? "cancelled" : "failed";
752
+ context.db.run(`UPDATE works
753
+ SET status = ?, updated_at = ?, completed_at = ?
754
+ WHERE project_id = ? AND run_id = ?
755
+ AND status IN ('created', 'running')`, [workStatus, now, now, projectId, runId]);
756
+ context.db.run(`UPDATE work_sessions
757
+ SET status = ?, updated_at = ?, completed_at = ?
758
+ WHERE work_id IN (SELECT work_id FROM works WHERE project_id = ? AND run_id = ?)
759
+ AND status <> 'completed'`, [workStatus, now, now, projectId, runId]);
760
+ }
443
761
  function closeRunOrchestrators(context, projectId, run, status, now) {
444
762
  if (!["done", "blocked", "cancelled", "failed"].includes(status))
445
763
  return;
446
- const active = context.db.all(`SELECT session_id FROM flow_sessions
764
+ const active = context.db.all(`SELECT session_id FROM sessions
447
765
  WHERE project_id = ? AND run_id = ? AND session_kind = 'orchestrator'
448
766
  AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [projectId, run.id]);
449
767
  if (active.length === 0)
450
768
  return;
451
769
  const reason = `run_${status}`;
452
- context.db.run(`UPDATE flow_sessions SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
770
+ context.db.run(`UPDATE sessions SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
453
771
  WHERE project_id = ? AND run_id = ? AND session_kind = 'orchestrator'
454
772
  AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [reason, now, now, projectId, run.id]);
455
773
  context.db.run(`UPDATE flow_session_segments SET ended_at = ?
@@ -514,7 +832,7 @@ export function getFlowRunTimeline(context, input) {
514
832
  if (!hidden.has("sessions"))
515
833
  report.sessions = sessions;
516
834
  if (!hidden.has("usage"))
517
- report.usage = usageForRun(context, { projectId: project.id, runId: run.id, groupBy: "session" });
835
+ report.usage = { status: "available_via_stat_usage", command: `dd-flow stat usage --run ${run.id} --project-root ${JSON.stringify(run.project_root)} --json` };
518
836
  if (!hidden.has("artifacts"))
519
837
  report.artifacts = index.stage_runs.map((stage) => ({
520
838
  stage: stage.stage,
@@ -526,16 +844,51 @@ export function getFlowRunTimeline(context, input) {
526
844
  }));
527
845
  return report;
528
846
  }
529
- export function getFlowRunUsage(context, input) {
847
+ export function getStoredFlowRunUsage(context, input) {
848
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
849
+ const run = resolveRun(context, project.id, input.runId);
850
+ const index = authoritativeIndex(run);
851
+ const stageNames = input.stage?.split(",").map((value) => value.trim()).filter(Boolean) ?? [];
852
+ const stageRuns = stageNames.map((name) => index.stage_runs.find((item) => item.stage === name));
853
+ if (stageNames.length && stageRuns.some((stage) => !stage?.started_at))
854
+ throw new AppError("not_found", "RUN stage timing is unavailable", 1, { run_id: run.id, stages: stageNames });
855
+ const starts = stageRuns.map((stage) => stage.started_at).sort();
856
+ const finishes = stageRuns.map((stage) => stage.completed_at).filter((value) => Boolean(value)).sort();
857
+ const usage = recalculateRunUsage(context, {
858
+ projectId: project.id,
859
+ runId: run.id,
860
+ ...(input.sessionId ? { sessionId: input.sessionId } : {}),
861
+ ...(starts.length ? { stage: { name: stageNames.join(","), startedAt: starts[0], completedAt: finishes.length === stageRuns.length ? finishes.at(-1) : null } } : {})
862
+ });
863
+ refreshRunSessionProjection(context, project.id, run.id);
864
+ return usage;
865
+ }
866
+ export function getFlowRunSessions(context, input) {
530
867
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
531
868
  const run = resolveRun(context, project.id, input.runId);
532
- return usageForRun(context, { projectId: project.id, runId: run.id, groupBy: input.groupBy ?? "session" });
869
+ const sessions = context.db.all(`SELECT s.session_id, s.harness, s.provider_session_id, s.parent_session_id,
870
+ s.agent_id, s.worker_id, s.provider, s.model, s.reasoning, s.mode,
871
+ s.agent_type, s.transcript_path,
872
+ COUNT(ws.id) AS work_link_count
873
+ FROM sessions s
874
+ JOIN work_sessions ws ON ws.session_id = s.session_id
875
+ JOIN works w ON w.work_id = ws.work_id
876
+ WHERE w.project_id = ? AND w.run_id = ?
877
+ GROUP BY s.session_id, s.harness, s.provider_session_id, s.parent_session_id,
878
+ s.agent_id, s.worker_id, s.provider, s.model, s.reasoning, s.mode,
879
+ s.agent_type, s.transcript_path
880
+ ORDER BY s.created_at, s.session_id`, [project.id, run.id]);
881
+ return { ok: true, schema_id: "dd-flow/run-sessions@3", run_id: run.id, sessions };
533
882
  }
534
883
  export function appendFlowRunTimelineEvent(context, projectId, runId, event) {
535
884
  const run = requireRunById(context, projectId, runId);
536
885
  appendRunTimeline(runArtifactRoot(run), { run_id: run.id, ...event });
537
886
  }
538
887
  function guidanceForRun(context, run, index) {
888
+ if (run.flow_kind === "vnext_specify")
889
+ return vnextSpecifyGuidance(run, index);
890
+ if (run.flow_kind === "vnext_protocolize")
891
+ return vnextProtocolizeGuidance(run, index);
539
892
  if (run.status === "discarded") {
540
893
  return buildRunFlowGuidance({
541
894
  stageRuns: index.stage_runs,
@@ -550,7 +903,9 @@ function guidanceForRun(context, run, index) {
550
903
  const state = readProtocolRuntimeState(context, protocol).state;
551
904
  return buildRunFlowGuidance({
552
905
  stageRuns: index.stage_runs,
553
- protocolStage: state.stage,
906
+ protocolStage: state.status === "waiting_for_user" || state.status === "blocked" || state.status === "failed"
907
+ ? state.status
908
+ : state.stage,
554
909
  ...(state.flow_contract ? { contract: state.flow_contract } : {}),
555
910
  runId: run.id,
556
911
  runDir: index.run_home?.relative_path ?? path.posix.join("runs", run.id)
@@ -563,6 +918,62 @@ function guidanceForRun(context, run, index) {
563
918
  }
564
919
  return buildRunFlowGuidance({ stageRuns: index.stage_runs, runId: run.id, runDir: index.run_home?.relative_path ?? path.posix.join("runs", run.id) });
565
920
  }
921
+ function vnextProtocolizeGuidance(run, index) {
922
+ const specifyDone = index.stage_runs.some((stage) => stage.stage === "specify" && stage.status === "done");
923
+ const protocolizeDone = index.stage_runs.some((stage) => stage.stage === "protocolize" && stage.status === "done");
924
+ const planDone = index.stage_runs.some((stage) => stage.stage === "plan" && stage.status === "done");
925
+ const planReviewDone = index.stage_runs.some((stage) => stage.stage === "plan-review" && stage.status === "done");
926
+ const codeDone = index.stage_runs.some((stage) => stage.stage === "code" && stage.status === "done");
927
+ const codeReviewDone = index.stage_runs.some((stage) => stage.stage === "code-review" && stage.status === "done");
928
+ const nextStage = !specifyDone ? "specify" : !protocolizeDone ? "protocolize" : !planDone ? "plan" : !planReviewDone ? "plan-review" : !codeDone ? "code" : !codeReviewDone ? "code-review" : null;
929
+ const currentStage = index.current_stage ?? [...index.stage_runs].reverse().find((stage) => stage.status === "running" || stage.status === "done")?.stage ?? nextStage;
930
+ const terminal = run.status === "done" || run.status === "cancelled" || run.status === "failed" || run.status === "discarded";
931
+ const paused = run.status === "paused";
932
+ const blocked = paused && Boolean(index.blocker);
933
+ return {
934
+ current_stage: currentStage,
935
+ lifecycle: { flow: "mb_sdlc_vnext_protocolize", stage: currentStage, substage: null, status: run.status, terminal, source: "vnext_run" },
936
+ allowed_next_stages: terminal || paused || !nextStage ? [] : [nextStage],
937
+ recommended_next_action: terminal || !nextStage ? "none" : blocked ? "resolve_blocker_then_unblock_same_stage" : paused ? "await_user_answer_then_resume_same_stage" : `start_${nextStage.replace("-", "_")}`,
938
+ recommended_prompt: terminal || !nextStage ? "none" : blocked ? "unblock_command_from_blocker" : paused ? "resume_command_from_pause" : `.memory-bank/dd-flow/vnext/${nextStage}.md`,
939
+ required_predecessor_evidence: !specifyDone ? [] : !protocolizeDone ? ["01-specify/specify.json"] : !planDone ? ["01-specify/specify.json", "02-protocolize/protocolize-result.json"] : !planReviewDone ? ["03-plan/stage-report.json"] : !codeDone ? ["04-plan-review/stage-report.json"] : ["05-code/stage-report.json"],
940
+ guards: [],
941
+ blocked_if_missing: []
942
+ };
943
+ }
944
+ function vnextSpecifyGuidance(run, index) {
945
+ const terminal = ["done", "failed", "cancelled", "discarded"].includes(run.status);
946
+ const next = run.status === "paused" && index.blocker
947
+ ? "resolve_blocker_then_unblock_same_stage"
948
+ : run.status === "paused"
949
+ ? "await_user_answer_then_resume_same_stage"
950
+ : run.status === "waiting_for_user"
951
+ ? "await_user_answer"
952
+ : run.status === "done"
953
+ ? "start_protocolize"
954
+ : run.status === "running"
955
+ ? "complete_work_session"
956
+ : terminal
957
+ ? "none"
958
+ : "inspect_run_state";
959
+ return {
960
+ current_stage: "specify",
961
+ lifecycle: {
962
+ flow: "mb_sdlc_vnext_specify",
963
+ stage: "specify",
964
+ substage: null,
965
+ status: run.status,
966
+ terminal,
967
+ source: "vnext_run"
968
+ },
969
+ allowed_next_stages: terminal ? [] : ["specify"],
970
+ recommended_next_action: next,
971
+ recommended_prompt: terminal ? "none" : ".memory-bank/dd-flow/vnext/specify.md",
972
+ required_predecessor_evidence: [],
973
+ guards: [],
974
+ blocked_if_missing: []
975
+ };
976
+ }
566
977
  function persistRunState(context, project, run, index, options = {}) {
567
978
  const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, project.id, run.id);
568
979
  const previousRuntime = readRuntimeSnapshot(runtimePath);
@@ -572,16 +983,17 @@ function persistRunState(context, project, run, index, options = {}) {
572
983
  if (options.flagRevision) {
573
984
  history.push(options.flagRevision);
574
985
  }
986
+ const persistedIndex = persistedVnextIndex(index);
575
987
  ensureDir(path.dirname(runtimePath));
576
- writeJsonFile(runtimePath, runtimeSnapshotForIndex(index, runtimeRevision, history));
577
- context.db.run(`UPDATE flow_runs
988
+ writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, runtimeRevision, history));
989
+ context.db.run(`UPDATE runs
578
990
  SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?,
579
991
  updated_at = ?, completed_at = ?
580
992
  WHERE project_id = ? AND id = ?`, [
581
993
  index.status,
582
994
  index.verdict,
583
995
  index.next_action,
584
- JSON.stringify(index),
996
+ JSON.stringify(persistedIndex),
585
997
  runtimePath,
586
998
  runtimePath,
587
999
  index.updated_at,
@@ -590,6 +1002,58 @@ function persistRunState(context, project, run, index, options = {}) {
590
1002
  run.id
591
1003
  ]);
592
1004
  }
1005
+ /** vNext stores derived stage/work state only once: stage_runs, Works and Sessions. */
1006
+ function persistedVnextIndex(index) {
1007
+ if (index.flow_kind !== "vnext_specify" && index.flow_kind !== "vnext_protocolize")
1008
+ return index;
1009
+ const persisted = structuredClone(index);
1010
+ delete persisted.current_stage;
1011
+ delete persisted.attempts;
1012
+ delete persisted.workers;
1013
+ delete persisted.session_coverage;
1014
+ delete persisted.usage_coverage;
1015
+ return persisted;
1016
+ }
1017
+ function runSettings(index) {
1018
+ const planReview = index.settings?.plan_review;
1019
+ const codeReview = index.settings?.code_review;
1020
+ if (planReview && codeReview)
1021
+ return { plan_review: planReview, code_review: codeReview };
1022
+ return {
1023
+ plan_review: {
1024
+ mode: "auto",
1025
+ source: "default",
1026
+ reason: "No explicit review preference was recorded.",
1027
+ updated_at: index.created_at
1028
+ },
1029
+ code_review: codeReview ?? {
1030
+ mode: index.execution_profile?.settings.code_review_mode ?? "auto",
1031
+ source: "default",
1032
+ reason: "No explicit review preference was recorded.",
1033
+ updated_at: index.created_at
1034
+ }
1035
+ };
1036
+ }
1037
+ function materializedRunVariables(index) {
1038
+ const settings = runSettings(index);
1039
+ return {
1040
+ "policy.plan_review.requested_mode": settings.plan_review.mode,
1041
+ "policy.code_review.requested_mode": settings.code_review.mode,
1042
+ ...(index.variables ?? {})
1043
+ };
1044
+ }
1045
+ function setRunVariable(context, input, value) {
1046
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
1047
+ const run = resolveRun(context, project.id, input.runId);
1048
+ const index = authoritativeIndex(run);
1049
+ if (index.variables?.[input.key] === value)
1050
+ return { ok: true, run_id: run.id, key: input.key, value, idempotent: true };
1051
+ index.variables = { ...(index.variables ?? {}), [input.key]: value };
1052
+ index.updated_at = context.now();
1053
+ persistRunState(context, project, run, index);
1054
+ appendRunTimeline(runArtifactRoot(run), { at: index.updated_at, type: "run_variable_set", run_id: run.id, key: input.key });
1055
+ return { ok: true, run_id: run.id, key: input.key, value, idempotent: false };
1056
+ }
593
1057
  function appendRunTimeline(runHome, event) {
594
1058
  ensureDir(runHome);
595
1059
  const timelinePath = path.join(runHome, "timeline.jsonl");
@@ -630,11 +1094,13 @@ function readRuntimeSnapshot(file) {
630
1094
  }
631
1095
  }
632
1096
  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 });
1097
+ try {
1098
+ const state = JSON.parse(run.index_json);
1099
+ return { ...state, schema_id: runSchemaId };
1100
+ }
1101
+ catch (error) {
1102
+ throw new AppError("validation", "RUN mechanical state in SQLite is missing or invalid", 2, { run_id: run.id, cause: String(error) });
636
1103
  }
637
- return { ...runtime, schema_id: runSchemaId };
638
1104
  }
639
1105
  function sanitizeTimelineValue(value, key) {
640
1106
  if (key && /(secret|password|authorization|cookie|api[_-]?key|private[_-]?key|access[_-]?token|refresh[_-]?token|transcript|prompt|tool[_-]?output|document[_-]?content)/i.test(key)) {
@@ -677,7 +1143,7 @@ function resolveRun(context, projectId, idOrAlias) {
677
1143
  return requireRunById(context, projectId, idOrAlias);
678
1144
  }
679
1145
  if (isShortEntityId(idOrAlias)) {
680
- const matches = context.db.all("SELECT * FROM flow_runs WHERE project_id = ? AND short_id = ? ORDER BY updated_at DESC", [
1146
+ const matches = context.db.all("SELECT * FROM runs WHERE project_id = ? AND short_id = ? ORDER BY updated_at DESC", [
681
1147
  projectId,
682
1148
  idOrAlias
683
1149
  ]);
@@ -692,14 +1158,14 @@ function resolveRun(context, projectId, idOrAlias) {
692
1158
  throw new AppError("not_found", `Run is not registered: ${idOrAlias}`, 1, { run_id: idOrAlias });
693
1159
  }
694
1160
  function requireRunById(context, projectId, runId) {
695
- const run = context.db.get("SELECT * FROM flow_runs WHERE project_id = ? AND id = ?", [projectId, runId]);
1161
+ const run = context.db.get("SELECT * FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
696
1162
  if (!run) {
697
1163
  throw new AppError("not_found", `Run is not registered: ${runId}`, 1, { run_id: runId });
698
1164
  }
699
1165
  return run;
700
1166
  }
701
1167
  function nextRunId(context, projectId, slug) {
702
- const rows = context.db.all("SELECT id FROM flow_runs WHERE project_id = ? AND id LIKE 'RUN-%'", [projectId]);
1168
+ const rows = context.db.all("SELECT id FROM runs WHERE project_id = ? AND id LIKE 'RUN-%'", [projectId]);
703
1169
  const max = rows.reduce((value, row) => {
704
1170
  try {
705
1171
  const parsed = parseFullEntityId(row.id);
@@ -749,19 +1215,19 @@ function parseRunFlowKind(value) {
749
1215
  return normalized;
750
1216
  }
751
1217
  function parseRunStatus(value) {
752
- if (!["running", "done", "blocked", "cancelled", "failed"].includes(value)) {
1218
+ if (!["running", "paused", "waiting_for_user", "done", "blocked", "cancelled", "failed"].includes(value)) {
753
1219
  throw new AppError("validation", "run status is not supported", 2, {
754
1220
  status: value,
755
- allowed: ["running", "done", "blocked", "cancelled", "failed"]
1221
+ allowed: ["running", "paused", "waiting_for_user", "done", "blocked", "cancelled", "failed"]
756
1222
  });
757
1223
  }
758
1224
  return value;
759
1225
  }
760
1226
  function parseStageStatus(value) {
761
- if (!["pending", "running", "done", "blocked", "skipped", "failed"].includes(value)) {
1227
+ if (!["pending", "running", "paused", "waiting_for_user", "done", "blocked", "skipped", "failed"].includes(value)) {
762
1228
  throw new AppError("validation", "stage status is not supported", 2, {
763
1229
  status: value,
764
- allowed: ["pending", "running", "done", "blocked", "skipped", "failed"]
1230
+ allowed: ["pending", "running", "paused", "waiting_for_user", "done", "blocked", "skipped", "failed"]
765
1231
  });
766
1232
  }
767
1233
  return value;
@@ -827,12 +1293,13 @@ function writeJsonFile(file, value) {
827
1293
  function runArtifactRoot(run) {
828
1294
  return run.run_home_path ?? path.dirname(run.runtime_path);
829
1295
  }
830
- function gitFacts(workspaceRoot) {
1296
+ export function gitFacts(workspaceRoot) {
831
1297
  const read = (args) => {
832
1298
  const result = spawnSync("git", ["-C", workspaceRoot, ...args.split(" ")], { encoding: "utf8" });
833
1299
  return result.status === 0 ? result.stdout.trim() || null : null;
834
1300
  };
835
- const status = read("status --porcelain --untracked-files=all");
1301
+ const statusResult = spawnSync("git", ["-C", workspaceRoot, "status", "--porcelain", "--untracked-files=all"], { encoding: "utf8" });
1302
+ const status = statusResult.status === 0 ? statusResult.stdout.trim() : null;
836
1303
  return {
837
1304
  branch: read("branch --show-current"),
838
1305
  head: read("rev-parse HEAD"),