@deksden-com/dd-flow-cli 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +666 -0
  2. package/README.md +7 -2
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +88 -10
  5. package/dist/cli/run-cli.js +523 -28
  6. package/dist/domain/stage-catalog.js +22 -0
  7. package/dist/domain/validation.js +1 -1
  8. package/dist/schemas/code-review-decision.schema.json +26 -0
  9. package/dist/schemas/code-review-result.schema.json +14 -0
  10. package/dist/schemas/code-verification.schema.json +14 -0
  11. package/dist/schemas/code-work-batch.schema.json +24 -0
  12. package/dist/schemas/code-work-result.schema.json +16 -0
  13. package/dist/schemas/compatibility.schema.json +32 -0
  14. package/dist/schemas/flow-contract.schema.json +9 -5
  15. package/dist/schemas/flow-run.schema.json +16 -123
  16. package/dist/schemas/plan-aspect-map.schema.json +22 -0
  17. package/dist/schemas/plan-review-decision.schema.json +14 -0
  18. package/dist/schemas/plan-review-result.schema.json +42 -0
  19. package/dist/schemas/protocol-plan.schema.json +15 -182
  20. package/dist/schemas/stage-finish-input.schema.json +16 -2
  21. package/dist/schemas/stage-report.schema.json +8 -7
  22. package/dist/schemas/stage-start-response.schema.json +4 -2
  23. package/dist/schemas/status-report.schema.json +76 -0
  24. package/dist/schemas/vnext-protocol-plan.schema.json +37 -0
  25. package/dist/schemas/vnext-protocolize-result.schema.json +29 -0
  26. package/dist/schemas/vnext-specify.schema.json +45 -0
  27. package/dist/services/branch-context.js +1 -1
  28. package/dist/services/cleanup.js +8 -8
  29. package/dist/services/cli-operation-classifier.js +10 -2
  30. package/dist/services/code-checks.js +244 -0
  31. package/dist/services/config.js +7 -1
  32. package/dist/services/dashboard.js +12 -12
  33. package/dist/services/engines.js +1 -1
  34. package/dist/services/eval-snapshots.js +404 -0
  35. package/dist/services/hooks.js +774 -18
  36. package/dist/services/ids.js +16 -6
  37. package/dist/services/lanes.js +1 -1
  38. package/dist/services/merge-queue.js +5 -5
  39. package/dist/services/merge-worker.js +2 -2
  40. package/dist/services/migrations.js +2 -2
  41. package/dist/services/plan-runtime.js +1 -1
  42. package/dist/services/projects.js +4 -4
  43. package/dist/services/prompts.js +17 -11
  44. package/dist/services/protocols.js +8 -8
  45. package/dist/services/run-projection.js +49 -13
  46. package/dist/services/runs.js +504 -51
  47. package/dist/services/schema-validation.js +21 -3
  48. package/dist/services/sessions.js +51 -12
  49. package/dist/services/stage-blocker.js +57 -0
  50. package/dist/services/stage-context.js +90 -0
  51. package/dist/services/stage-lifecycle.js +198 -75
  52. package/dist/services/stage-pause.js +175 -0
  53. package/dist/services/stage-report-renderer.js +65 -0
  54. package/dist/services/usage.js +526 -18
  55. package/dist/services/vnext-code-review.js +305 -0
  56. package/dist/services/vnext-code.js +686 -0
  57. package/dist/services/vnext-contracts.js +1 -0
  58. package/dist/services/vnext-execution-profile.js +27 -0
  59. package/dist/services/vnext-fanout.js +79 -0
  60. package/dist/services/vnext-plan-review.js +499 -0
  61. package/dist/services/vnext-plan.js +552 -0
  62. package/dist/services/vnext-protocolize.js +542 -0
  63. package/dist/services/vnext-specify.js +595 -0
  64. package/dist/services/vnext-workspace-policy.js +87 -0
  65. package/dist/services/work-registry.js +522 -0
  66. package/dist/services/worktrees.js +58 -37
  67. package/dist/storage/database.js +263 -34
  68. package/dist/storage/paths.js +47 -1
  69. package/package.json +12 -12
@@ -10,12 +10,13 @@ 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
15
  import { resolveCanonRoot } from "./canon.js";
16
16
  import { bindCurrentEngineToRun } from "./engines.js";
17
- const runSchemaId = "dd-flow/flow-run@2";
18
- const runtimeSchemaId = "dd-flow/flow-run@2";
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";
19
20
  const runIdType = "RUN";
20
21
  const allowedRunFlowKinds = [
21
22
  "mb_sdlc",
@@ -27,6 +28,8 @@ const allowedRunFlowKinds = [
27
28
  "mb-distill",
28
29
  "mb-upgrade-review",
29
30
  "mb-sdlc-review",
31
+ "vnext_specify",
32
+ "vnext_protocolize",
30
33
  "review",
31
34
  "custom"
32
35
  ];
@@ -43,6 +46,9 @@ export function startFlowRun(context, input) {
43
46
  const runId = nextRunId(context, project.id, slug);
44
47
  const { shortId } = parseFullEntityId(runId);
45
48
  const now = context.now();
49
+ const executionProfile = (flowKind === "vnext_protocolize" || flowKind === "vnext_specify")
50
+ ? snapshotVnextExecutionProfile(projectRoot)
51
+ : undefined;
46
52
  const flowFlags = resolveFlowFlags(flowContract, {
47
53
  flowKind,
48
54
  ...(input.preset ? { preset: input.preset } : {}),
@@ -90,7 +96,6 @@ export function startFlowRun(context, input) {
90
96
  },
91
97
  stage_runs: [],
92
98
  sessions: [],
93
- 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") },
94
99
  status: "running",
95
100
  verdict: "pending",
96
101
  next_action: input.nextAction ?? null,
@@ -103,21 +108,30 @@ export function startFlowRun(context, input) {
103
108
  snapshot_checksum: flowFlags.snapshot_checksum,
104
109
  reconciliation_status: "reconciled",
105
110
  flow_flags: flowFlags,
106
- current_stage: null,
107
- attempts: [],
108
- session_coverage: { status: "unavailable", expected: [], observed: [], missing: [] },
109
- 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
+ },
110
123
  timeline_path: "timeline.jsonl"
111
124
  };
112
125
  ensureDir(path.dirname(runtimePath));
113
126
  ensureDir(runHome);
114
127
  bindCurrentEngineToRun(context, { projectRoot, runId, runHome });
115
- writeJsonFile(runtimePath, runtimeSnapshotForIndex(index, 1));
116
- context.db.run(`INSERT INTO flow_runs
128
+ const persistedIndex = persistedVnextIndex(index);
129
+ writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, 1));
130
+ context.db.run(`INSERT INTO runs
117
131
  (id, short_id, slug, project_id, project_root, workspace_root, flow_kind, subject_type, subject_id,
118
- 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,
119
133
  index_json, created_at, updated_at, completed_at)
120
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`, [
134
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`, [
121
135
  runId,
122
136
  shortId,
123
137
  slug,
@@ -134,9 +148,10 @@ export function startFlowRun(context, input) {
134
148
  runHome,
135
149
  runtimePath,
136
150
  runHome,
151
+ runHome,
137
152
  "home_run_v2",
138
153
  "dd_flow_home",
139
- JSON.stringify(index),
154
+ JSON.stringify(persistedIndex),
140
155
  now,
141
156
  now
142
157
  ]);
@@ -156,6 +171,43 @@ export function startFlowRun(context, input) {
156
171
  });
157
172
  return { ok: true, run: flowRunSummary(requireRunById(context, project.id, runId)), index };
158
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
+ }
159
211
  function loadUpgradeFlowContract(context, projectRoot) {
160
212
  const canon = resolveCanonRoot(context, context.env.DD_MEMORYBANK ? { explicitRoot: context.env.DD_MEMORYBANK } : {});
161
213
  if (!canon.ok || !canon.canon) {
@@ -174,6 +226,67 @@ export function getFlowRunStatus(context, input) {
174
226
  const runtime = readRuntimeSnapshot(run.runtime_path);
175
227
  return { ok: true, run: flowRunSummary(run), index, ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, run, index) };
176
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 };
289
+ }
177
290
  export function getFlowRunFlagsStatus(context, input) {
178
291
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
179
292
  const run = resolveRun(context, project.id, input.runId);
@@ -319,7 +432,7 @@ export function reviseFlowRunFlags(context, input) {
319
432
  export function listFlowRuns(context, input) {
320
433
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
321
434
  const runs = context.db
322
- .all(`SELECT * FROM flow_runs
435
+ .all(`SELECT * FROM runs
323
436
  WHERE project_id = ?
324
437
  ORDER BY updated_at DESC, id DESC`, [project.id])
325
438
  .map(flowRunSummary);
@@ -363,16 +476,11 @@ export function attachFlowRunStage(context, input) {
363
476
  payload: { run_id: run.id, stage, dir, status }
364
477
  });
365
478
  appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_attached", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
366
- if (status === "running") {
367
- context.db.run("UPDATE flow_sessions SET current_stage = ?, updated_at = ? WHERE project_id = ? AND run_id = ?", [
368
- stage,
369
- now,
370
- project.id,
371
- run.id
372
- ]);
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")
373
483
  refreshRunSessionProjection(context, project.id, run.id);
374
- checkpointRunUsage(context, { projectId: project.id, runId: run.id, checkpoint: "stage_started", stage, stageAttempt: stageRun.attempt ?? null });
375
- }
376
484
  const updatedRun = requireRunById(context, project.id, run.id);
377
485
  return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
378
486
  }
@@ -396,11 +504,23 @@ export function completeFlowRunStage(context, input) {
396
504
  ...(input.report ? { report: input.report } : {}),
397
505
  ...(input.aliases && input.aliases.length > 0 ? { artifact_aliases: input.aliases } : {}),
398
506
  updated_at: now,
399
- ...(status === "done" || status === "blocked" || status === "failed" || status === "skipped"
507
+ ...(status === "done" || status === "waiting_for_user" || status === "blocked" || status === "failed" || status === "skipped"
400
508
  ? { completed_at: now, duration_ms: durationMs(existing.started_at ?? null, now), git: gitFacts(run.workspace_root) }
401
509
  : {})
402
510
  };
403
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
+ }
404
524
  index.current_stage = stage;
405
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 }));
406
526
  index.updated_at = now;
@@ -417,16 +537,147 @@ export function completeFlowRunStage(context, input) {
417
537
  payload: { run_id: run.id, stage, status, stage_report: input.stageReport ?? null, data: input.data ?? null }
418
538
  });
419
539
  appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_completed", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
420
- checkpointRunUsage(context, { projectId: project.id, runId: run.id, checkpoint: "stage_finished", stage, stageAttempt: stageRun.attempt ?? null });
421
540
  const updatedRun = requireRunById(context, project.id, run.id);
422
541
  return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
423
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
+ }
424
668
  export function completeFlowRun(context, input) {
425
669
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
426
670
  const run = resolveRun(context, project.id, input.runId);
427
671
  const index = authoritativeIndex(run);
428
672
  const now = context.now();
429
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
+ }
430
681
  index.status = status;
431
682
  index.verdict = input.verdict ?? (status === "done" ? "accepted" : status);
432
683
  index.next_action = input.nextAction ?? null;
@@ -443,27 +694,80 @@ export function completeFlowRun(context, input) {
443
694
  }
444
695
  persistRunState(context, project, run, index);
445
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
+ };
446
704
  refreshRunSessionProjection(context, project.id, run.id);
447
705
  appendAudit(context, {
448
706
  projectId: project.id,
449
- eventType: "flow_run.completed",
450
- 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 } : {}) }
451
709
  });
452
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_completed", run_id: run.id, status });
453
- 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 });
454
727
  const updatedRun = requireRunById(context, project.id, run.id);
455
728
  return { ok: true, run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
456
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
+ }
457
761
  function closeRunOrchestrators(context, projectId, run, status, now) {
458
762
  if (!["done", "blocked", "cancelled", "failed"].includes(status))
459
763
  return;
460
- const active = context.db.all(`SELECT session_id FROM flow_sessions
764
+ const active = context.db.all(`SELECT session_id FROM sessions
461
765
  WHERE project_id = ? AND run_id = ? AND session_kind = 'orchestrator'
462
766
  AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [projectId, run.id]);
463
767
  if (active.length === 0)
464
768
  return;
465
769
  const reason = `run_${status}`;
466
- 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 = ?
467
771
  WHERE project_id = ? AND run_id = ? AND session_kind = 'orchestrator'
468
772
  AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [reason, now, now, projectId, run.id]);
469
773
  context.db.run(`UPDATE flow_session_segments SET ended_at = ?
@@ -528,7 +832,7 @@ export function getFlowRunTimeline(context, input) {
528
832
  if (!hidden.has("sessions"))
529
833
  report.sessions = sessions;
530
834
  if (!hidden.has("usage"))
531
- 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` };
532
836
  if (!hidden.has("artifacts"))
533
837
  report.artifacts = index.stage_runs.map((stage) => ({
534
838
  stage: stage.stage,
@@ -540,16 +844,51 @@ export function getFlowRunTimeline(context, input) {
540
844
  }));
541
845
  return report;
542
846
  }
543
- export function getFlowRunUsage(context, input) {
847
+ export function getStoredFlowRunUsage(context, input) {
544
848
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
545
849
  const run = resolveRun(context, project.id, input.runId);
546
- return usageForRun(context, { projectId: project.id, runId: run.id, groupBy: input.groupBy ?? "session" });
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) {
867
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
868
+ const run = resolveRun(context, project.id, input.runId);
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 };
547
882
  }
548
883
  export function appendFlowRunTimelineEvent(context, projectId, runId, event) {
549
884
  const run = requireRunById(context, projectId, runId);
550
885
  appendRunTimeline(runArtifactRoot(run), { run_id: run.id, ...event });
551
886
  }
552
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);
553
892
  if (run.status === "discarded") {
554
893
  return buildRunFlowGuidance({
555
894
  stageRuns: index.stage_runs,
@@ -564,7 +903,9 @@ function guidanceForRun(context, run, index) {
564
903
  const state = readProtocolRuntimeState(context, protocol).state;
565
904
  return buildRunFlowGuidance({
566
905
  stageRuns: index.stage_runs,
567
- protocolStage: state.stage,
906
+ protocolStage: state.status === "waiting_for_user" || state.status === "blocked" || state.status === "failed"
907
+ ? state.status
908
+ : state.stage,
568
909
  ...(state.flow_contract ? { contract: state.flow_contract } : {}),
569
910
  runId: run.id,
570
911
  runDir: index.run_home?.relative_path ?? path.posix.join("runs", run.id)
@@ -577,6 +918,62 @@ function guidanceForRun(context, run, index) {
577
918
  }
578
919
  return buildRunFlowGuidance({ stageRuns: index.stage_runs, runId: run.id, runDir: index.run_home?.relative_path ?? path.posix.join("runs", run.id) });
579
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
+ }
580
977
  function persistRunState(context, project, run, index, options = {}) {
581
978
  const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, project.id, run.id);
582
979
  const previousRuntime = readRuntimeSnapshot(runtimePath);
@@ -586,16 +983,17 @@ function persistRunState(context, project, run, index, options = {}) {
586
983
  if (options.flagRevision) {
587
984
  history.push(options.flagRevision);
588
985
  }
986
+ const persistedIndex = persistedVnextIndex(index);
589
987
  ensureDir(path.dirname(runtimePath));
590
- writeJsonFile(runtimePath, runtimeSnapshotForIndex(index, runtimeRevision, history));
591
- context.db.run(`UPDATE flow_runs
988
+ writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, runtimeRevision, history));
989
+ context.db.run(`UPDATE runs
592
990
  SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?,
593
991
  updated_at = ?, completed_at = ?
594
992
  WHERE project_id = ? AND id = ?`, [
595
993
  index.status,
596
994
  index.verdict,
597
995
  index.next_action,
598
- JSON.stringify(index),
996
+ JSON.stringify(persistedIndex),
599
997
  runtimePath,
600
998
  runtimePath,
601
999
  index.updated_at,
@@ -604,6 +1002,58 @@ function persistRunState(context, project, run, index, options = {}) {
604
1002
  run.id
605
1003
  ]);
606
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
+ }
607
1057
  function appendRunTimeline(runHome, event) {
608
1058
  ensureDir(runHome);
609
1059
  const timelinePath = path.join(runHome, "timeline.jsonl");
@@ -644,11 +1094,13 @@ function readRuntimeSnapshot(file) {
644
1094
  }
645
1095
  }
646
1096
  function authoritativeIndex(run) {
647
- const runtime = readRuntimeSnapshot(run.runtime_path);
648
- if (!runtime) {
649
- 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) });
650
1103
  }
651
- return { ...runtime, schema_id: runSchemaId };
652
1104
  }
653
1105
  function sanitizeTimelineValue(value, key) {
654
1106
  if (key && /(secret|password|authorization|cookie|api[_-]?key|private[_-]?key|access[_-]?token|refresh[_-]?token|transcript|prompt|tool[_-]?output|document[_-]?content)/i.test(key)) {
@@ -691,7 +1143,7 @@ function resolveRun(context, projectId, idOrAlias) {
691
1143
  return requireRunById(context, projectId, idOrAlias);
692
1144
  }
693
1145
  if (isShortEntityId(idOrAlias)) {
694
- 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", [
695
1147
  projectId,
696
1148
  idOrAlias
697
1149
  ]);
@@ -706,14 +1158,14 @@ function resolveRun(context, projectId, idOrAlias) {
706
1158
  throw new AppError("not_found", `Run is not registered: ${idOrAlias}`, 1, { run_id: idOrAlias });
707
1159
  }
708
1160
  function requireRunById(context, projectId, runId) {
709
- 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]);
710
1162
  if (!run) {
711
1163
  throw new AppError("not_found", `Run is not registered: ${runId}`, 1, { run_id: runId });
712
1164
  }
713
1165
  return run;
714
1166
  }
715
1167
  function nextRunId(context, projectId, slug) {
716
- 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]);
717
1169
  const max = rows.reduce((value, row) => {
718
1170
  try {
719
1171
  const parsed = parseFullEntityId(row.id);
@@ -763,19 +1215,19 @@ function parseRunFlowKind(value) {
763
1215
  return normalized;
764
1216
  }
765
1217
  function parseRunStatus(value) {
766
- if (!["running", "done", "blocked", "cancelled", "failed"].includes(value)) {
1218
+ if (!["running", "paused", "waiting_for_user", "done", "blocked", "cancelled", "failed"].includes(value)) {
767
1219
  throw new AppError("validation", "run status is not supported", 2, {
768
1220
  status: value,
769
- allowed: ["running", "done", "blocked", "cancelled", "failed"]
1221
+ allowed: ["running", "paused", "waiting_for_user", "done", "blocked", "cancelled", "failed"]
770
1222
  });
771
1223
  }
772
1224
  return value;
773
1225
  }
774
1226
  function parseStageStatus(value) {
775
- if (!["pending", "running", "done", "blocked", "skipped", "failed"].includes(value)) {
1227
+ if (!["pending", "running", "paused", "waiting_for_user", "done", "blocked", "skipped", "failed"].includes(value)) {
776
1228
  throw new AppError("validation", "stage status is not supported", 2, {
777
1229
  status: value,
778
- allowed: ["pending", "running", "done", "blocked", "skipped", "failed"]
1230
+ allowed: ["pending", "running", "paused", "waiting_for_user", "done", "blocked", "skipped", "failed"]
779
1231
  });
780
1232
  }
781
1233
  return value;
@@ -841,12 +1293,13 @@ function writeJsonFile(file, value) {
841
1293
  function runArtifactRoot(run) {
842
1294
  return run.run_home_path ?? path.dirname(run.runtime_path);
843
1295
  }
844
- function gitFacts(workspaceRoot) {
1296
+ export function gitFacts(workspaceRoot) {
845
1297
  const read = (args) => {
846
1298
  const result = spawnSync("git", ["-C", workspaceRoot, ...args.split(" ")], { encoding: "utf8" });
847
1299
  return result.status === 0 ? result.stdout.trim() || null : null;
848
1300
  };
849
- 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;
850
1303
  return {
851
1304
  branch: read("branch --show-current"),
852
1305
  head: read("rev-parse HEAD"),