@deksden-com/dd-flow-cli 0.9.0-beta.88 → 0.9.0-beta.89

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.
@@ -1,4 +1,5 @@
1
1
  import { managedLifecycleCommand } from "./lifecycle-invocations.js";
2
+ import { ensureAggregateRepair, ensureRepairIntent } from "./repair-intents.js";
2
3
  import { runWorkspaceBootstrap } from "./workspace-bootstrap.js";
3
4
  import fs from "node:fs";
4
5
  import path from "node:path";
@@ -8,7 +9,8 @@ import { aggregateCheckDeclarations, effectiveCheckDeclarations, finalCodeCheckD
8
9
  import { requireProjectByRoot } from "./projects.js";
9
10
  import { appendFlowRunTimelineEvent, advanceFlowRun, attachFlowRunStage, prepareFlowRunStageAttachment, completeFlowRunStage, completeFlowRun, withStageSettlement, getFlowRunVariables, gitFacts } from "./runs.js";
10
11
  import { validateSchema } from "./schema-validation.js";
11
- import { flowCommand } from "./stage-pause.js";
12
+ import { runtimeCommand, checkRetryArgs } from "./runtime-command.js";
13
+ import { stageCycle, stageGraphScope } from "./stage-work-graph.js";
12
14
  import { validateVnextCodeHandoff } from "./vnext-plan.js";
13
15
  import { requireVnextWorkspaceRoute } from "./vnext-workspace-policy.js";
14
16
  import { readVnextSpecifyResult } from "./vnext-specify.js";
@@ -91,8 +93,8 @@ export async function startVnextCode(context, input, prepared = prepareVnextCode
91
93
  if (prepared.resumed) {
92
94
  const externalContext = applyExternalStageContext({ stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
93
95
  bindStageCoordinatorWork(context, { workId: rootWork.work_id, hookEventId: input.hookEventId, stage, promptPath, resultPath: path.join(root, "stage-report.json"), ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
94
- const orchestration = prepared.orchestration ?? writeFanoutDescriptor(root, { stage, parent_work_id: rootWork.work_id, dispatch: "none", capacity_required: true });
95
- return { ok: true, resumed: true, run_id: run.id, stage, stage_status: prepared.existingStage, prompt_path: promptPath, worker_prompt_markdown: fs.readFileSync(promptPath, "utf8"), ...(externalContext ? { external_context: externalContext } : {}), orchestration, graph: codeWorkGraph(context, project.id, run.id), next: { refresh_graph_command: `${flowCommand(context)} work ls --run ${run.id} --ready --project-root ${JSON.stringify(projectRoot)} --json`, finish_command: finishCommand(context, input.runId, projectRoot, path.join(root, "code-verification.json")) } };
96
+ const orchestration = prepared.orchestration ?? writeFanoutDescriptor(root, { stage, parent_work_id: rootWork.work_id, dispatch: "none", capacity_required: true, scope: stageGraphScope(context, project.id, run.id, stage) });
97
+ return { ok: true, resumed: true, run_id: run.id, stage, stage_status: prepared.existingStage, prompt_path: promptPath, worker_prompt_markdown: fs.readFileSync(promptPath, "utf8"), ...(externalContext ? { external_context: externalContext } : {}), orchestration, graph: codeWorkGraph(context, project.id, run.id), next: { refresh_graph_command: runtimeCommand(context, ["work", "ls", "--run", run.id, "--ready", "--project-root", projectRoot, "--json"]), finish_command: finishCommand(context, input.runId, projectRoot, path.join(root, "code-verification.json")) } };
96
98
  }
97
99
  fs.mkdirSync(root, { recursive: true });
98
100
  const readiness = await ensureCodeWorkspaceReady(context, project.id, run, root, [], input.progress);
@@ -119,7 +121,7 @@ export async function startVnextCode(context, input, prepared = prepareVnextCode
119
121
  dataSchemaId: "dd-flow/stage-report@1"
120
122
  });
121
123
  }
122
- const orchestration = writeFanoutDescriptor(root, { stage, parent_work_id: rootWork.work_id, dispatch: "none", capacity_required: true });
124
+ const orchestration = writeFanoutDescriptor(root, { stage, parent_work_id: rootWork.work_id, dispatch: "none", capacity_required: true, scope: stageGraphScope(context, project.id, run.id, stage) });
123
125
  appendFlowRunTimelineEvent(context, project.id, input.runId, { type: "code_started", work_id: rootWork.work_id, graph });
124
126
  return {
125
127
  ok: true,
@@ -133,7 +135,7 @@ export async function startVnextCode(context, input, prepared = prepareVnextCode
133
135
  graph,
134
136
  orchestration,
135
137
  readiness,
136
- next: { refresh_graph_command: `${flowCommand(context)} work ls --run ${run.id} --ready --project-root ${JSON.stringify(projectRoot)} --json`, finish_command: finishCommand(context, input.runId, projectRoot, path.join(root, "code-verification.json")) }
138
+ next: { refresh_graph_command: runtimeCommand(context, ["work", "ls", "--run", run.id, "--ready", "--project-root", projectRoot, "--json"]), finish_command: finishCommand(context, input.runId, projectRoot, path.join(root, "code-verification.json")) }
137
139
  };
138
140
  }
139
141
  export async function finishVnextCode(context, input) {
@@ -206,14 +208,14 @@ export async function finishVnextCode(context, input) {
206
208
  });
207
209
  const failed = receipts.filter((receipt) => receipt.status !== "passed");
208
210
  if (failed.length) {
211
+ const continuation = registerAggregateCodeRepair(context, { projectRoot, runId: run.id, stage, failures: failed, checks, generation });
209
212
  throw new AppError("code_gate_failed", "CODE remains running because the aggregate project gate failed", 2, {
210
213
  failures: failed,
211
214
  outcome: "repair_required",
212
215
  retry_after_workspace_change: true,
213
216
  workspace_fingerprint: failed[0].workspace_fingerprint,
214
- repair_task_file: `@run/intake/code-repair-${failed[0].id}.md`,
215
- repair_command: `${flowCommand(context)} work repair add --run ${run.id} --from-check ${failed[0].id} --origin-work <WORK-ID> --task-file @run/intake/code-repair-${failed[0].id}.md --project-root ${JSON.stringify(projectRoot)} --json`,
216
- retry_command: finishCommand(context, run.id, projectRoot, input.verificationFile, failed[0].id)
217
+ continuation,
218
+ instruction: "Repair Work is already registered. End this coordinator Turn; the controller will launch a fresh child. After it completes, update semantic verification and finish the Stage."
217
219
  });
218
220
  }
219
221
  const finalReceipts = receipts;
@@ -256,8 +258,8 @@ export async function finishVnextCode(context, input) {
256
258
  wall_clock_ms: timing.wall_clock_ms,
257
259
  git: gitFacts(run.workspace_root),
258
260
  graph: codeWorkGraph(context, project.id, run.id),
259
- session_stats_command: `${flowCommand(context)} stat run sessions ls --run ${run.id} --project-root ${JSON.stringify(projectRoot)} --json`,
260
- usage_stats_command: `${flowCommand(context)} stat usage --run ${run.id} --project-root ${JSON.stringify(projectRoot)} --json`
261
+ session_stats_command: runtimeCommand(context, ["stat", "run", "sessions", "ls", "--run", run.id, "--project-root", projectRoot, "--json"]),
262
+ usage_stats_command: runtimeCommand(context, ["stat", "usage", "--run", run.id, "--project-root", projectRoot, "--json"])
261
263
  },
262
264
  artifacts: { json: "stage-report.json", markdown: "stage-report.md", html: "stage-report.html", summary: "stage-report.md" },
263
265
  validation: { permission_scope: "known_targets_only", memory_bank_scope: "changed_files_and_links_only", status: "passed" }
@@ -307,16 +309,30 @@ export async function finishVnextCode(context, input) {
307
309
  outcome: "accepted",
308
310
  report_path: path.join(root, "stage-report.json"),
309
311
  next_action: next,
310
- ...(next === "start_code_review" ? { next: { kind: "start_stage", stage: "code-review", command: managedLifecycleCommand(context, `${flowCommand(context)} stage start ${run.id} --stage code-review --project-root ${JSON.stringify(projectRoot)} --json`) } } : next === "start_merge" ? { merge_request: mergeRequest, next: { kind: "start_stage", stage: "merge", command: managedLifecycleCommand(context, `${flowCommand(context)} stage start ${run.id} --stage merge --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`) } } : {})
312
+ ...(next === "start_code_review" ? { next: { kind: "start_stage", stage: "code-review", command: managedLifecycleCommand(context, runtimeCommand(context, ["stage", "start", run.id, "--stage", "code-review", "--project-root", projectRoot, "--json"])) } } : next === "start_merge" ? { merge_request: mergeRequest, next: { kind: "start_stage", stage: "merge", command: managedLifecycleCommand(context, runtimeCommand(context, ["stage", "start", run.id, "--stage", "merge", "--project-root", projectRoot, "--json", "--progress-jsonl"])) } } : {})
311
313
  };
312
314
  }
315
+ export function registerAggregateCodeRepair(context, input) {
316
+ const project = requireProjectByRoot(context, input.projectRoot);
317
+ const origins = codeWorks(context, project.id, input.runId).filter(work => !packet(work)?.repair && work.status === "completed");
318
+ const failedDeclarations = new Set(input.failures.flatMap(receipt => [receipt.declaration_id, ...receipt.check_refs]));
319
+ const objective = ["Investigate and repair all retained aggregate check failures while preserving accepted requirements and non-goals. If evidence proves an external blocker or a contradiction, report it; do not invent a product change.", ...input.failures.map(receipt => `Receipt: ${receipt.receipt_path}\nstdout: ${receipt.stdout_path}\nstderr: ${receipt.stderr_path}`)].join("\n\n");
320
+ try {
321
+ return ensureAggregateRepair(context, { projectId: project.id, runId: input.runId, stage: input.stage, failures: input.failures, origins: origins.map(work => work.work_id), ...(input.generation !== undefined ? { generation: input.generation } : {}) }, () => String(addVnextCodeRepair(context, { registrationOwned: true, projectRoot: input.projectRoot, runId: input.runId, stage: input.stage, ...(input.stage === "code-review" ? { reviewCycle: stageCycle(context, project.id, input.runId, input.stage) } : {}), checkReceiptId: input.failures[0].id, originWorkIds: origins.map(work => work.work_id), objective, reviewChecks: input.checks.filter(check => failedDeclarations.has(check.id) || failedDeclarations.has(check.canonical_ref ?? "")) }).repair_work_id));
322
+ }
323
+ catch (error) {
324
+ if (!(error instanceof AppError))
325
+ throw error;
326
+ throw new AppError(error.code, error.message, error.exitCode, { ...error.details, stage: input.stage, run_id: input.runId, failures: input.failures, cause: { code: input.stage === "code" ? "code_gate_failed" : "code_review_gate_failed", message: "Aggregate checks failed; repair registration was rejected" } });
327
+ }
328
+ }
313
329
  export function prepareVnextCodeRepair(context, input) {
314
330
  const projectRoot = resolveProjectRoot(input.projectRoot);
315
331
  const project = requireProjectByRoot(context, projectRoot);
316
332
  const run = requireRun(context, project.id, input.runId);
317
333
  const home = requireHome(run);
318
334
  if (input.reviewCycle) {
319
- const previous = codeWorks(context, project.id, run.id).find((work) => packet(work)?.review_cycle === input.reviewCycle);
335
+ const previous = codeWorks(context, project.id, run.id).find((work) => packet(work)?.review_cycle === input.reviewCycle && (input.checkReceiptId ? packet(work)?.repair?.check_receipt_id === input.checkReceiptId : Boolean(packet(work)?.repair?.review_findings?.length)));
320
336
  if (previous)
321
337
  return { kind: "replay", project, run, previous };
322
338
  }
@@ -434,8 +450,11 @@ export function addVnextCodeRepair(context, input, prepared = prepareVnextCodeRe
434
450
  if (prepared.kind === "replay")
435
451
  return { ok: true, run_id: run.id, repair_work_id: prepared.previous.work_id, start_command: workStartCommand(context, prepared.previous), reused: true };
436
452
  const { parentWorkId, items, key, receipt } = prepared;
437
- const registered = addWorkBatch(context, { parentWorkId, prepared: items });
438
- const id = String(registered.work_ids[key]);
453
+ const create = () => String(addWorkBatch(context, { parentWorkId, prepared: items, stage: input.stage ?? (input.reviewCycle ? "code-review" : "code"), ...(input.reviewCycle ? { cycle: input.reviewCycle } : {}) }).work_ids[key]);
454
+ const id = input.registrationOwned ? create() : ensureRepairIntent(context, {
455
+ projectId: project.id, runId: run.id, stage: input.stage ?? (input.reviewCycle ? "code-review" : "code"),
456
+ causes: input.checkReceiptId ? { receipt: input.checkReceiptId, origins: [...input.originWorkIds].sort() } : input.reviewCycle ? { review_cycle: input.reviewCycle, findings: input.reviewFindings } : { verification: input.verification, unresolved: input.semanticUnresolved, origins: [...input.originWorkIds].sort() }
457
+ }, create).work_id;
439
458
  const work = context.db.get("SELECT * FROM works WHERE work_id = ?", [id]);
440
459
  appendFlowRunTimelineEvent(context, project.id, run.id, {
441
460
  type: "code_repair_created",
@@ -448,7 +467,7 @@ export function addVnextCodeRepair(context, input, prepared = prepareVnextCodeRe
448
467
  }
449
468
  /** Retain the causal declaration for worker context; run_at still records its original gate. */
450
469
  export function selectRepairChecks(input) {
451
- const candidates = input.failedCheck ? [input.failedCheck] : input.reviewChecks ?? input.semanticChecks ?? [];
470
+ const candidates = input.failedCheck ? [input.failedCheck, ...(input.reviewChecks ?? [])] : input.reviewChecks ?? input.semanticChecks ?? [];
452
471
  const checks = candidates.map((check) => ({ ...check, purpose: `${input.semanticChecks ? "Prove the CODE verification repair" : "Prove the CODE-REVIEW repair"}: ${check.purpose}` }));
453
472
  return uniqueBy(checks, (check) => check.id);
454
473
  }
@@ -499,11 +518,11 @@ function coordinatorPrompt(context, input) {
499
518
  "</code_graph>",
500
519
  "",
501
520
  "<execution_commands>",
502
- "Do not choose a provider delegation tool from this stage prompt. At each work_fanout boundary, stop at the Work-graph boundary; the shared controller supplies the selected adapter's native tool, exact parameters, child task, and wait contract. It launches only entries listed in graph.ready and uses at most the qualified capacity. Every registered CODE Work runs in a fresh child Session, including a serial dependency chain. The coordinator launches those children only through the controller-supplied native delegation instruction; it must never invoke a Work start_command itself. Each child receives its complete packet from dd-flow and runs its own exact start_command. After a Work finishes, the controller uses the graph returned by work finish to launch newly ready Work. To refresh the parent graph yourself use the exact command: " + `${flowCommand(context)} work ls --run ${input.run.id} --ready --project-root ${JSON.stringify(input.projectRoot)} --json`,
503
- "A quiet child is still running until the harness reports its turn completed, failed, cancelled or explicitly needs attention. An elapsed nominal wait, silence, or no new artifact is not an unresponsive-worker failure. Never interrupt, replace, relaunch, or stage-block a still-running child for that reason, even if an external controller asks. Long work finish and stage finish commands emit check progress on stderr. After you issue the exact CODE stage finish command, wait for that same command to return once: a completed command with a non-zero exit and structured `code_gate_failed` output is its terminal result, not a reason to keep waiting. Its repair_command only creates the repair Work; use it with the required origin Work IDs, then end the Turn. Do not invoke that new Work's start_command from the coordinator Session, inspect its PID, start a second finish command, or infer failure from quiet output. Close a disposable child only after its Work is accepted or explicitly failed/cancelled and the harness reports the turn settled.",
504
- `A repairable engine, harness, or environment failure is not a user question. Write the evidence summary to @run/works/${input.rootWork.work_id}/block-summary.md, then record it with a separate command: ${flowCommand(context)} stage block ${input.run.id} --stage code --work ${input.rootWork.work_id} --kind <engine|harness|environment> --code <stable-code> --summary-file @run/works/${input.rootWork.work_id}/block-summary.md --retryable --project-root ${JSON.stringify(input.projectRoot)} --json. Repair it externally, then run the exact unblock_command returned by dd-flow and continue this same stage.`,
521
+ "Do not choose a provider delegation tool from this stage prompt. At each work_fanout boundary, stop at the Work-graph boundary; the shared controller supplies the selected adapter's native tool, exact parameters, child task, and wait contract. It launches only entries listed in graph.ready and uses at most the qualified capacity. Every registered CODE Work runs in a fresh child Session, including a serial dependency chain. The coordinator launches those children only through the controller-supplied native delegation instruction; it must never invoke a Work start_command itself. Each child receives its complete packet from dd-flow and runs its own exact start_command. After a Work finishes, the controller uses the graph returned by work finish to launch newly ready Work. To refresh the parent graph yourself use the exact command: " + runtimeCommand(context, ["work", "ls", "--run", input.run.id, "--ready", "--project-root", input.projectRoot, "--json"]),
522
+ "A quiet child is still running until the harness reports its turn completed, failed, cancelled or explicitly needs attention. An elapsed nominal wait, silence, or no new artifact is not an unresponsive-worker failure. Never interrupt, replace, relaunch, or stage-block a still-running child for that reason, even if an external controller asks. Long work finish and stage finish commands emit check progress on stderr. After you issue the exact CODE stage finish command, wait for that same command to return once: a completed command with a non-zero exit and structured `code_gate_failed` output is its terminal result, not a reason to keep waiting. A repair_required continuation already registered the repair: end this Turn. Do not invoke that Work's start_command from the coordinator Session, inspect its PID, start a second finish command, or infer failure from quiet output. Close a disposable child only after its Work is accepted or explicitly failed/cancelled and the harness reports the turn settled.",
523
+ `A repairable engine, harness, or environment failure is not a user question. Write the evidence summary to ${path.join(requireHome(input.run), "works", input.rootWork.work_id, "block-summary.md")}, then record it with a separate command: ${runtimeCommand(context, ["stage", "block", input.run.id, "--stage", "code", "--work", input.rootWork.work_id, "--kind", "<engine|harness|environment>", "--code", "<stable-code>", "--summary-file", `@run/works/${input.rootWork.work_id}/block-summary.md`, "--retryable", "--project-root", input.projectRoot, "--json"])}. Choose kind and stable-code from evidence; this example is not an executable continuation. Repair it externally, then run the exact unblock_command returned by dd-flow and continue this same stage.`,
505
524
  `When every CODE and repair Work is completed, write ${path.join(input.root, "code-verification.json")} using the exact contract below. Mark passed only when all accepted requirements and current-gate acceptance criteria are implemented or explicitly evidenced; list every remaining issue in unresolved. Every evidence_refs item must already exist as a relative workspace path or run://${input.run.id}/ path. Do not claim a browser or other check receipt that was not retained. Then finish: ${finishCommand(context, input.run.id, input.projectRoot, path.join(input.root, "code-verification.json"))}`,
506
- "If the aggregate gate fails, do not stop after `code_gate_failed`: that rejected finish does not create a repair Work. In the same coordinator Turn, write the concise repair objective to repair_task_file, then use the returned standalone repair_command with the relevant completed origin Work IDs and stop so the runner can dispatch the newly declared repair. Do not edit invisibly in the root orchestrator.",
525
+ "A code_gate_failed response with continuation.kind=repair_required has already registered one repair Work with all failed receipts. End this Turn so the controller can dispatch it in a fresh child. Do not recreate the repair, copy origin IDs, write a repair task file or execute the child start command yourself. After repair, update semantic verification and finish again.",
507
526
  "</execution_commands>",
508
527
  "",
509
528
  "<verification_contract>",
@@ -689,8 +708,7 @@ function requireHome(run) {
689
708
  return run.run_root;
690
709
  }
691
710
  export function finishCommand(context, runId, projectRoot, verificationFile, retryCheckId) {
692
- const retry = retryCheckId ? ` --retry-check ${retryCheckId} --reason "<environment recovery evidence>"` : "";
693
- return managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${runId} --stage code --verification-file ${JSON.stringify(verificationFile)} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl${retry}`);
711
+ return managedLifecycleCommand(context, runtimeCommand(context, ["stage", "finish", runId, "--stage", "code", "--verification-file", verificationFile, "--project-root", projectRoot, "--json", "--progress-jsonl", ...checkRetryArgs(context, projectRoot, runId, retryCheckId)]));
694
712
  }
695
713
  function readVerification(context, input) {
696
714
  return prepareJsonSchema(input.file, "CODE verification", {
@@ -9,6 +9,7 @@ import { listWorks, failWork, cancelWork } from "./work-registry.js";
9
9
  import { storageSessionId } from "./session-identity.js";
10
10
  import { reconcileUnfinishedChecks } from "./code-checks.js";
11
11
  import { vnextStageDirectory } from "../domain/stage-catalog.js";
12
+ import { stageWorkIds, stageGraphScope } from "./stage-work-graph.js";
12
13
  export const subagentCapacityKey = "runtime.subagent.available_slots";
13
14
  /** External concurrency is policy, never evidence of native child qualification. */
14
15
  export function getStageFanoutCapacity(context, input) {
@@ -40,7 +41,7 @@ export function fanoutState(input) {
40
41
  }
41
42
  export function fanoutFile(stageRoot) { return path.join(stageRoot, "fanout.json"); }
42
43
  export function writeFanoutDescriptor(stageRoot, descriptor) {
43
- const value = { schema_id: "dd-flow/work-fanout@1", kind: "work_fanout", ...descriptor };
44
+ const value = { schema_id: descriptor.scope ? "dd-flow/work-fanout@2" : "dd-flow/work-fanout@1", kind: "work_fanout", ...descriptor };
44
45
  fs.writeFileSync(fanoutFile(stageRoot), `${JSON.stringify(value, null, 2)}\n`);
45
46
  return value;
46
47
  }
@@ -49,7 +50,7 @@ export function readFanoutDescriptor(stageRoot) {
49
50
  if (!fs.existsSync(file))
50
51
  return null;
51
52
  const value = JSON.parse(fs.readFileSync(file, "utf8"));
52
- if (value.schema_id !== "dd-flow/work-fanout@1" || value.kind !== "work_fanout" || typeof value.stage !== "string" || typeof value.parent_work_id !== "string" || !["none", "plan-review"].includes(String(value.dispatch)) || typeof value.capacity_required !== "boolean") {
53
+ if (!["dd-flow/work-fanout@1", "dd-flow/work-fanout@2"].includes(String(value.schema_id)) || value.kind !== "work_fanout" || typeof value.stage !== "string" || typeof value.parent_work_id !== "string" || !["none", "plan-review"].includes(String(value.dispatch)) || typeof value.capacity_required !== "boolean" || value.schema_id === "dd-flow/work-fanout@2" && (!value.scope || [value.scope.project_id, value.scope.run_id, value.scope.attempt, value.scope.cycle].some(item => typeof item !== "string"))) {
53
54
  throw new AppError("runtime_missing", "Stage fan-out descriptor is invalid", 1, { file });
54
55
  }
55
56
  return value;
@@ -64,14 +65,30 @@ export function getVnextFanoutStatus(context, input) {
64
65
  if (!run.run_root)
65
66
  throw new AppError("runtime_artifact_missing", "RUN artifact root is unavailable", 1, { run_id: input.runId });
66
67
  const stageRoot = path.join(run.run_root, vnextStageDirectory(input.stage));
67
- const descriptor = readFanoutDescriptor(stageRoot);
68
+ let descriptor = readFanoutDescriptor(stageRoot);
69
+ // Registration is authoritative even if projection publication was interrupted,
70
+ // or an otherwise review-off stage acquired an aggregate repair graph.
71
+ if (!descriptor && context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'repair_intents'")) {
72
+ const members = stageWorkIds(context, project.id, input.runId, input.stage);
73
+ const parents = context.db.all("SELECT w.work_id, w.parent_work_id FROM repair_intents i JOIN works w ON w.work_id = i.work_id WHERE i.project_id = ? AND i.run_id = ? AND i.stage = ?", [project.id, input.runId, input.stage]).filter(work => members.has(work.work_id));
74
+ const roots = [...new Set(parents.map(work => work.parent_work_id))];
75
+ if (roots.length > 1)
76
+ throw new AppError("stage_graph_conflict", "Registered repairs disagree on their stage coordinator", 1);
77
+ if (roots.length === 1)
78
+ descriptor = { schema_id: "dd-flow/work-fanout@2", scope: stageGraphScope(context, project.id, input.runId, input.stage), kind: "work_fanout", stage: input.stage, parent_work_id: roots[0], dispatch: "none", capacity_required: true };
79
+ }
68
80
  if (!descriptor)
69
81
  return { ok: true, run_id: input.runId, stage: input.stage, orchestration: null };
70
82
  if (descriptor.stage !== input.stage)
71
83
  throw new AppError("runtime_missing", "Stage fan-out descriptor belongs to another stage", 1, { requested_stage: input.stage, descriptor_stage: descriptor.stage });
72
84
  const capacity = getStageFanoutCapacity(context, { projectRoot, runId: input.runId, stage: input.stage });
73
- const all = listWorks(context, { parentWorkId: descriptor.parent_work_id, includeResults: false });
74
- const ready = listWorks(context, { parentWorkId: descriptor.parent_work_id, ready: true, includeResults: false });
85
+ const members = stageWorkIds(context, project.id, input.runId, input.stage);
86
+ const currentScope = stageGraphScope(context, project.id, input.runId, input.stage);
87
+ const compatible = descriptor.scope ? Object.entries(currentScope).every(([key, value]) => descriptor.scope[key] === value) : members.size > 0;
88
+ if (!compatible)
89
+ return { ok: true, run_id: input.runId, stage: input.stage, orchestration: { ...descriptor, state: "compatibility_required", compatibility: { code: "stage_graph_compatibility_required", message: "Legacy or stale stage graph has no authoritative current membership; use the pinned engine", expected_scope: currentScope } } };
90
+ const all = listWorks(context, { parentWorkId: descriptor.parent_work_id, includeResults: false, workIds: members });
91
+ const ready = { works: all.works.filter(work => work.ready) };
75
92
  const counts = Object.fromEntries(["created", "running", "completed", "failed", "cancelled"].map((status) => [status, all.works.filter((work) => work.status === status).length]));
76
93
  const hasWork = all.works.length > 0;
77
94
  const capacityKnown = capacity.available_slots !== null;
@@ -86,6 +103,7 @@ export function getVnextFanoutStatus(context, input) {
86
103
  // authoritative prompt. An empty graph is therefore not a success.
87
104
  state: fanoutState({ dispatch: descriptor.dispatch, hasWork, capacityRequired: descriptor.capacity_required, capacityKnown, created: counts.created ?? 0, running: counts.running ?? 0 }),
88
105
  capacity,
106
+ graph_revision: all.works.map(work => ({ work_id: work.work_id, status: work.status, depends_on: work.depends_on })),
89
107
  works: { ...counts, ready: ready.works.map((work) => ({ work_id: work.work_id, task: work.task, launch_policy: work.launch_policy, start_command: work.start_command })) }
90
108
  }
91
109
  };
@@ -99,7 +117,10 @@ export async function reconcileVnextFanout(context, input) {
99
117
  const status = getVnextFanoutStatus(context, input);
100
118
  if (!status.orchestration)
101
119
  return { ok: true, issues: [], reconciled: [] };
120
+ if (status.orchestration.state === "compatibility_required")
121
+ throw new AppError("stage_graph_compatibility_required", "Cannot reconcile a legacy or stale graph", 1);
102
122
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
123
+ const members = stageWorkIds(context, project.id, input.runId, input.stage);
103
124
  const parent = storageSessionId({ harness_id: value.harness_id, session_id: value.parent_session_id });
104
125
  const issues = [], reconciled = [];
105
126
  for (const child of value.children) {
@@ -107,7 +128,7 @@ export async function reconcileVnextFanout(context, input) {
107
128
  continue;
108
129
  const sessionId = storageSessionId({ harness_id: value.harness_id, session_id: String(child.session_id) });
109
130
  const links = context.db.all(`SELECT ws.id, ws.work_id, ws.status, w.status AS work_status, w.parent_work_id FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id JOIN sessions s ON s.project_id = w.project_id AND s.session_id = ws.session_id WHERE w.project_id = ? AND w.run_id = ? AND ws.session_id = ? AND s.parent_session_id = ? ORDER BY ws.created_at`, [project.id, input.runId, sessionId, parent]);
110
- const active = links.filter(link => link.status === "running" && link.work_status === "running" && link.parent_work_id === status.orchestration.parent_work_id);
131
+ const active = links.filter(link => link.status === "running" && link.work_status === "running" && link.parent_work_id === status.orchestration.parent_work_id && members.has(link.work_id));
111
132
  if (!active.length) {
112
133
  if (!links.length)
113
134
  issues.push({ code: "child_launch_association_missing", session_id: child.session_id, outcome: child.status });
@@ -11,7 +11,8 @@ import { requireProjectByRoot } from "./projects.js";
11
11
  import { nextMergeRequestId } from "./ids.js";
12
12
  import { readProjectConfig } from "./config.js";
13
13
  import { appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRun, completeFlowRunStage, gitFacts, prepareFlowRunStageAttachment, prepareVnextMergeSourceRepairAttempt } from "./runs.js";
14
- import { flowCommand, stagePauseCommand, stagePauseCommandTemplate } from "./stage-pause.js";
14
+ import { stagePauseCommand, stagePauseCommandTemplate } from "./stage-pause.js";
15
+ import { runtimeCommand, checkRetryArgs, recoveryEvidenceInput } from "./runtime-command.js";
15
16
  import { writeStageReport } from "./stage-report-renderer.js";
16
17
  import { bindStageCoordinatorWork, createChildWork, failWork, finishFanInWork, finishWork, refreshRunWorkProjection, startStageCoordinatorWork } from "./work-registry.js";
17
18
  import { addVnextCodeRepair } from "./vnext-code.js";
@@ -102,7 +103,7 @@ export function prepareVnextMergeRequest(context, input) {
102
103
  const raced = context.db.get("SELECT * FROM merge_requests WHERE project_id = ? AND run_id = ? AND status NOT IN ('completed','failed','cancelled','superseded') ORDER BY created_at DESC LIMIT 1", [project.id, run.id]);
103
104
  if (raced)
104
105
  return requestView(context, raced);
105
- const child = createChildWork(context, { parentWorkId: root.work_id, slug: "merge", task: `Integrate frozen source ${sourceCommit} into ${targetBranch}, resolve material conflicts, and preserve accepted behavior.`, launchPolicy: route === "server" ? "fresh_agent_required" : "reuse_allowed", resultSchema: "dd-flow/merge-result@1", payload: { kind: "merge", checks: semantic, protocol_ids: protocols } });
106
+ const child = createChildWork(context, { parentWorkId: root.work_id, stage: "merge", slug: "merge", task: `Integrate frozen source ${sourceCommit} into ${targetBranch}, resolve material conflicts, and preserve accepted behavior.`, launchPolicy: route === "server" ? "fresh_agent_required" : "reuse_allowed", resultSchema: "dd-flow/merge-result@1", payload: { kind: "merge", checks: semantic, protocol_ids: protocols } });
106
107
  const requestId = nextMergeRequestId(context);
107
108
  context.db.run("INSERT INTO merge_requests (merge_request_id, project_id, run_id, executor_work_id, protocol_ids_json, source_workspace, source_branch, source_commit, target_workspace, target_branch, enqueue_target_head, execution_target_head, integration_commit, execution_route, status, dispatch_owner, dispatch_lease_token, dispatch_lease_expires_at, lock_acquired_at, checkpoint, profile_hash, replacement_of_merge_request_id, adapter_receipt_json, result_json, last_error_json, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, 'queued', NULL, NULL, NULL, NULL, 'queued', ?, ?, NULL, NULL, NULL, ?, ?, NULL)", [requestId, project.id, run.id, child.work_id, JSON.stringify(protocols), run.workspace_root, sourceBranch, sourceCommit, targetWorkspace, targetBranch, enqueueTarget, route, readCodeCheckProfile(targetWorkspace).hash, replacementOf(run), now, now]);
108
109
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "merge_request_queued", merge_request_id: requestId, work_id: child.work_id, route, source_commit: sourceCommit, target_branch: targetBranch });
@@ -290,7 +291,7 @@ export async function finishVnextMerge(context, input, prepared = prepareVnextMe
290
291
  const failed = receipts.filter((receipt) => receipt.status !== "passed");
291
292
  if (failed.length) {
292
293
  context.db.run("UPDATE merge_requests SET status = 'action_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "merge_gate_failed", failures: failed }), context.now(), request.merge_request_id]);
293
- throw new AppError("merge_gate_failed", "Integrated target checks failed. Classify retained evidence: source defects use source repair; restored environment uses retry.", 2, { failures: failed, repair_command: repairCommand(context, request, projectRoot), retry_command: finishCommand(context, run.id, request, projectRoot, failed[0].id) });
294
+ throw new AppError("merge_gate_failed", "Integrated target checks failed. Classify retained evidence: source defects use source repair; restored environment uses retry.", 2, { failures: failed, repair_command: repairCommand(context, request, projectRoot), continuation: { ...recoveryEvidenceInput(run.run_root, failed[0].id), command_after_input: finishCommand(context, run.id, request, projectRoot, failed[0].id) } });
294
295
  }
295
296
  const passedRefs = new Set(receipts.flatMap((receipt) => receipt.check_refs));
296
297
  const missingRefs = frozenGateRefs(gate).filter((ref) => !passedRefs.has(ref));
@@ -403,7 +404,7 @@ export async function repairVnextMerge(context, input) {
403
404
  if (request.project_id === project.id && request.status === "superseded" && errorCode(request) === "merge_source_repair") {
404
405
  const saved = JSON.parse(request.last_error_json);
405
406
  releaseMergeLane(context, projectRoot, request, `MERGE ${request.merge_request_id} moved to source repair`);
406
- return { ok: true, run_id: request.run_id, merge_request_id: request.merge_request_id, status: "superseded", source_repair: { repair_work_id: saved.repair_work_id }, next: { kind: "start_stage", stage: "code", command: managedLifecycleCommand(context, `${flowCommand(context)} stage start ${request.run_id} --stage code --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`) } };
407
+ return { ok: true, run_id: request.run_id, merge_request_id: request.merge_request_id, status: "superseded", source_repair: { repair_work_id: saved.repair_work_id }, next: { kind: "start_stage", stage: "code", command: managedLifecycleCommand(context, runtimeCommand(context, ["stage", "start", request.run_id, "--stage", "code", "--project-root", projectRoot, "--json", "--progress-jsonl"])) } };
407
408
  }
408
409
  if (request.project_id !== project.id || request.status !== "action_required" || errorCode(request) !== "merge_gate_failed") {
409
410
  throw new AppError("invalid_merge_repair_state", "MERGE source repair requires an action-required failed integration gate", 2, { merge_request_id: input.requestId, status: request.status, code: errorCode(request) });
@@ -422,14 +423,14 @@ export async function repairVnextMerge(context, input) {
422
423
  await failWork(context, request.executor_work_id, `Superseded by source repair after failed integration gate ${failed[0]}.`);
423
424
  assertRunMutationAllowed(context, project.id, request.run_id, generation);
424
425
  const prepared = prepareVnextMergeSourceRepairAttempt(context, { projectRoot, runId: request.run_id, mergeRequestId: request.merge_request_id });
425
- const repair = addVnextCodeRepair(context, { projectRoot, runId: request.run_id, checkReceiptId: failed[0], originWorkIds, reviewCycle: `MRR-${request.merge_request_id}`, objective: `Repair source behavior evidenced by failed integration check ${failed[0]}.` });
426
+ const repair = addVnextCodeRepair(context, { projectRoot, runId: request.run_id, stage: "code", checkReceiptId: failed[0], originWorkIds, reviewCycle: `MRR-${request.merge_request_id}`, objective: `Repair source behavior evidenced by failed integration check ${failed[0]}.` });
426
427
  const now = context.now();
427
428
  context.db.run("UPDATE merge_requests SET status = 'superseded', checkpoint = 'source_repair_required', last_error_json = ?, completed_at = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "merge_source_repair", replacement_pending: true, failed_receipt_ids: failed, repair_work_id: repair.repair_work_id }), now, now, request.merge_request_id]);
428
429
  releaseMergeLane(context, projectRoot, request, `MERGE ${request.merge_request_id} moved to source repair`);
429
430
  appendFlowRunTimelineEvent(context, project.id, request.run_id, { type: "merge_source_repair_created", merge_request_id: request.merge_request_id, repair_work_id: repair.repair_work_id, cycle: prepared.cycle, failed_receipt_ids: failed });
430
- return { ok: true, run_id: request.run_id, merge_request_id: request.merge_request_id, status: "superseded", source_repair: repair, next: { kind: "start_stage", stage: "code", command: managedLifecycleCommand(context, `${flowCommand(context)} stage start ${request.run_id} --stage code --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`) } };
431
+ return { ok: true, run_id: request.run_id, merge_request_id: request.merge_request_id, status: "superseded", source_repair: repair, next: { kind: "start_stage", stage: "code", command: managedLifecycleCommand(context, runtimeCommand(context, ["stage", "start", request.run_id, "--stage", "code", "--project-root", projectRoot, "--json", "--progress-jsonl"])) } };
431
432
  }
432
- function mergePrompt(context, input, checks, settings) { const pause = stagePauseCommand(context, { runId: input.run.id, stage: "merge", workId: input.request.executor_work_id, projectRoot: input.projectRoot }); const repair = repairCommand(context, input.request, input.projectRoot); return ["<stage_identity>", `- RUN: ${input.run.id}`, `- MERGE request: ${input.request.merge_request_id}`, `- Work: ${input.request.executor_work_id}`, "- stage: merge", "</stage_identity>", "", "<trusted_runtime_context>", `- integration workspace: ${input.request.target_workspace}`, `- source workspace: ${input.request.source_workspace}`, `- frozen source commit: ${input.request.source_commit}`, `- target branch: ${input.request.target_branch}`, `- execution target baseline: ${input.request.execution_target_head}`, `- queue route: ${input.request.execution_route}`, `- delivery: ${JSON.stringify(settings.merge_delivery)}`, `- cleanup: ${JSON.stringify(settings.merge_cleanup)}`, "These facts and the acquired project integration lane were established by dd-flow. Do not repeat discovery and do not run git merge/rebase/squash yourself.", "</trusted_runtime_context>", "", "<effective_merge_gate>", ...checks.map((check) => `- ${check.canonical_ref ?? check.id}: ${check.command} — ${check.purpose}`), "</effective_merge_gate>", "", "<execution_contract>", `1. Run this exact standalone command first: ${applyCommand(context, input.request, input.projectRoot)}`, "2. If it reports conflicts, resolve only the actual unmerged paths in the integration workspace. Do not repeat merge apply and do not edit product code, tests, documentation or configuration merely to make a gate pass.", `3. Write the compact semantic result to ${input.resultPath}:`, "```json", JSON.stringify({ schema_id: "dd-flow/merge-result@1", outcome: "completed", summary: "What was integrated.", conflict_resolution: "How material conflicts were resolved, or empty when none.", verification_summary: "Why the integrated result is ready for deterministic checks.", residual_risks: [] }, null, 2), "```", `4. Finish with this exact standalone command and wait for all progress: ${finishCommand(context, input.run.id, input.request, input.projectRoot)}`, `If a gate fails, read its receipt and logs and determine the cause. For a product defect use ${repair}; it restores the target baseline and opens CODE → independent CODE-REVIEW → replacement MRG. For an environment failure restore the declared environment in this MERGE and use the returned finish command with --retry-check <receipt-id> --reason "<what was restored>". The CLI reruns the real gate and retains the old receipt. Never edit product code in the integration target to make a gate pass.`, "If a material conflict has no reasonable answer in accepted evidence, pause this same Work by writing the question file and then running the separate standalone command below, ask the returned user_message, then use the exact resume command returned by CLI:", "```sh", stagePauseCommandTemplate(pause), "```", "</execution_contract>", ""].join("\n"); }
433
+ function mergePrompt(context, input, checks, settings) { const pause = stagePauseCommand(context, { runId: input.run.id, stage: "merge", workId: input.request.executor_work_id, projectRoot: input.projectRoot }); const repair = repairCommand(context, input.request, input.projectRoot); return ["<stage_identity>", `- RUN: ${input.run.id}`, `- MERGE request: ${input.request.merge_request_id}`, `- Work: ${input.request.executor_work_id}`, "- stage: merge", "</stage_identity>", "", "<trusted_runtime_context>", `- integration workspace: ${input.request.target_workspace}`, `- source workspace: ${input.request.source_workspace}`, `- frozen source commit: ${input.request.source_commit}`, `- target branch: ${input.request.target_branch}`, `- execution target baseline: ${input.request.execution_target_head}`, `- queue route: ${input.request.execution_route}`, `- delivery: ${JSON.stringify(settings.merge_delivery)}`, `- cleanup: ${JSON.stringify(settings.merge_cleanup)}`, "These facts and the acquired project integration lane were established by dd-flow. Do not repeat discovery and do not run git merge/rebase/squash yourself.", "</trusted_runtime_context>", "", "<effective_merge_gate>", ...checks.map((check) => `- ${check.canonical_ref ?? check.id}: ${check.command} — ${check.purpose}`), "</effective_merge_gate>", "", "<execution_contract>", `1. Run this exact standalone command first: ${applyCommand(context, input.request, input.projectRoot)}`, "2. If it reports conflicts, resolve only the actual unmerged paths in the integration workspace. Do not repeat merge apply and do not edit product code, tests, documentation or configuration merely to make a gate pass.", `3. Write the compact semantic result to ${input.resultPath}:`, "```json", JSON.stringify({ schema_id: "dd-flow/merge-result@1", outcome: "completed", summary: "What was integrated.", conflict_resolution: "How material conflicts were resolved, or empty when none.", verification_summary: "Why the integrated result is ready for deterministic checks.", residual_risks: [] }, null, 2), "```", `4. Finish with this exact standalone command and wait for all progress: ${finishCommand(context, input.run.id, input.request, input.projectRoot)}`, `If a gate fails, read its receipt and logs and determine the cause. For a product defect use ${repair}; it restores the target baseline and opens CODE → independent CODE-REVIEW → replacement MRG. For an environment failure restore the declared environment in this MERGE and write the returned recovery JSON file and use continuation.command_after_input. The CLI reruns the real gate and retains the old receipt. Never edit product code in the integration target to make a gate pass.`, "If a material conflict has no reasonable answer in accepted evidence, pause this same Work by writing the question file and then running the separate standalone command below, ask the returned user_message, then use the exact resume command returned by CLI:", "```sh", stagePauseCommandTemplate(pause), "```", "</execution_contract>", ""].join("\n"); }
433
434
  function mergeReport(context, run, request, semantic, receipts) { const now = context.now(); const cleanup = cleanupReceiptPath(run); return { schema_id: "dd-flow/stage-report@2", run_id: run.id, stage, generated_at: now, verdict: "done", summary: semantic.summary, semantic: { result: semantic.summary, acceptance: ["source_commit_frozen", "integration_commit_created", "merge_gate_passed", "delivery_confirmed"], changed_files: [], checks: receipts.map((item) => item.command), evidence: [applyReceiptPath(context, request), ...receipts.map((item) => item.receipt_path), ...(fs.existsSync(cleanup) ? [cleanup] : [])], next_action: "merge_completed", merge: { merge_request_id: request.merge_request_id, work_id: request.executor_work_id, protocols: JSON.parse(request.protocol_ids_json), source_commit: request.source_commit, execution_target_head: request.execution_target_head, accepted_tree: request.accepted_tree, integration_commit: request.integration_commit, route: request.execution_route, delivery: executionSettings(run).merge_delivery, cleanup: executionSettings(run).merge_cleanup, verification_summary: semantic.verification_summary, residual_risks: semantic.residual_risks } }, mechanical: { started_at: request.lock_acquired_at, finished_at: now, git: gitFacts(request.target_workspace), queue: queueStatus(context, request) }, artifacts: { json: "stage-report.json", markdown: "stage-report.md", html: "stage-report.html" }, validation: { status: "passed" } }; }
434
435
  function effectiveMergeChecks(run, request) { return readFrozenMergeGate(path.join(requireHome(run), stageDir, "merge-gate.json"), request.merge_request_id).checks; }
435
436
  function planChecks(workspace, protocols) { return protocols.flatMap((protocol) => { const file = path.join(workspace, ".memory-bank", "protocol", protocol, "plan.json"); if (!fs.existsSync(file))
@@ -588,6 +589,8 @@ function findRootWork(context, projectId, runId) { const work = context.db.get("
588
589
  throw new AppError("runtime_missing", "vNext RUN has no root Work", 1); return work; }
589
590
  function requireRootWork(context, projectId, runId) { const work = findRootWork(context, projectId, runId); if (work.status !== "running")
590
591
  throw new AppError("runtime_missing", "vNext RUN has no running root Work", 1); return work; }
591
- function applyCommand(context, request, projectRoot) { return managedLifecycleCommand(context, `${flowCommand(context)} merge apply ${request.merge_request_id} --work ${request.executor_work_id} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`); }
592
- function repairCommand(context, request, projectRoot) { return managedLifecycleCommand(context, `${flowCommand(context)} merge repair ${request.merge_request_id} --project-root ${JSON.stringify(projectRoot)} --json`); }
593
- export function finishCommand(context, runId, request, projectRoot, retryCheckId) { const retry = retryCheckId ? ` --retry-check ${retryCheckId} --reason "<environment recovery evidence>"` : ""; return managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${runId} --stage merge --request ${request.merge_request_id} --work ${request.executor_work_id} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl${retry}`); }
592
+ function applyCommand(context, request, projectRoot) { return managedLifecycleCommand(context, runtimeCommand(context, ["merge", "apply", request.merge_request_id, "--work", request.executor_work_id, "--project-root", projectRoot, "--json", "--progress-jsonl"])); }
593
+ function repairCommand(context, request, projectRoot) { return managedLifecycleCommand(context, runtimeCommand(context, ["merge", "repair", request.merge_request_id, "--project-root", projectRoot, "--json"])); }
594
+ export function finishCommand(context, runId, request, projectRoot, retryCheckId) {
595
+ return managedLifecycleCommand(context, runtimeCommand(context, ["stage", "finish", runId, "--stage", "merge", "--request", request.merge_request_id, "--work", request.executor_work_id, "--project-root", projectRoot, "--json", "--progress-jsonl", ...checkRetryArgs(context, projectRoot, runId, retryCheckId)]));
596
+ }
@@ -20,6 +20,7 @@ import { assertPortableArtifactRef } from "./portable-refs.js";
20
20
  import { applyExternalStageContext, prepareExternalStageContext } from "./stage-context.js";
21
21
  import { prepareJsonSchema, prepareOutputFile } from "../cli/input-preparation.js";
22
22
  import { getStageFanoutCapacity, readFanoutDescriptor, subagentCapacityKey, writeFanoutDescriptor } from "./vnext-fanout.js";
23
+ import { stageGraphScope } from "./stage-work-graph.js";
23
24
  const stage = "plan-review";
24
25
  const stageDir = vnextStageDirectory(stage);
25
26
  const reviewTask = "Independently review the accepted PLAN and decide whether CODE may open.";
@@ -115,7 +116,7 @@ export function startVnextPlanReview(context, input, prepared = prepareVnextPlan
115
116
  bindStageCoordinatorWork(context, { workId: rootWork, hookEventId: input.hookEventId, stage, promptPath, resultPath: path.join(root, "stage-report.json"), ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
116
117
  attachFlowRunStage(context, { projectRoot, runId: run.id, stage, dir: stageDir, status: "running", dataSchemaId: "dd-flow/plan-review-decision@3" });
117
118
  return withStageSettlement(context, () => {
118
- const registered = addWorkBatch(context, { parentWorkId: rootWork, prepared: prepared.codeItems });
119
+ const registered = addWorkBatch(context, { parentWorkId: rootWork, prepared: prepared.codeItems, stage: "code" });
119
120
  const report = reportFor(context, { run, mode: effective, requested, outcome: "off", groups: [], batchChecksum, planChecksum, code: registered, now, projectRoot, flow: flowCommand(context) });
120
121
  writeReport(root, report);
121
122
  completeFlowRunStage(context, { projectRoot, runId: run.id, stage, status: "done", data: "stage-report.json", dataSchemaId: "dd-flow/stage-report@1", report: "stage-report.md", stageReport: "stage-report.html" });
@@ -144,7 +145,7 @@ export function startVnextPlanReview(context, input, prepared = prepareVnextPlan
144
145
  markReviewPending(home, groups);
145
146
  const workSessionId = String(binding.work_session_id);
146
147
  const sessionId = String(binding.session_id);
147
- const orchestration = writeFanoutDescriptor(root, { stage, parent_work_id: workId, dispatch: "plan-review", capacity_required: true });
148
+ const orchestration = writeFanoutDescriptor(root, { stage, parent_work_id: workId, dispatch: "plan-review", capacity_required: true, scope: stageGraphScope(context, project.id, run.id, stage) });
148
149
  writeJson(path.join(root, "work-context.json"), { schema_id: "dd-flow/plan-review-context@1", read_only_input: { parent_work_id: workId }, system: { run_id: run.id, work_id: workId, stage, requested_mode: requested, effective_mode: effective, plan_revision: prepared.revision, plan_checksum: planChecksum, batch_checksum: batchChecksum }, groups, workspace: { project_root: projectRoot, workspace_root: run.workspace_root, stage_root: root, plan_root: planRoot } });
149
150
  refreshRunWorkProjection(context, project.id, run.id);
150
151
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "work_session_started", stage, work_id: workId, id: workSessionId, session_id: sessionId });
@@ -176,7 +177,7 @@ export function dispatchVnextPlanReview(context, input) {
176
177
  const file = path.join(root, ".dispatch.json");
177
178
  writeJson(file, { works: pending.map((group) => ({ key: group.key, task: group.task, depends_on: group.depends_on, launch_policy: "fresh_agent_required", result_schema: "dd-flow/plan-review-result@1", payload: { kind: "plan-review", read_only: true, group: { key: group.key, aspect_ids: group.aspect_ids } } })) });
178
179
  try {
179
- addWorkBatch(context, { parentWorkId: parent.work_id, file });
180
+ addWorkBatch(context, { parentWorkId: parent.work_id, file, stage });
180
181
  }
181
182
  finally {
182
183
  fs.rmSync(file, { force: true });
@@ -306,7 +307,7 @@ function finishPlanReviewOwned(context, input) {
306
307
  function preparedExisting(context, input) {
307
308
  const promptPath = path.join(input.root, "stage-prompt.md");
308
309
  const binding = bindStageCoordinatorWork(context, { workId: input.workId, hookEventId: input.hookEventId, stage, promptPath, resultPath: path.join(input.root, "stage-report.json"), ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
309
- const orchestration = input.orchestration ?? writeFanoutDescriptor(input.root, { stage, parent_work_id: input.workId, dispatch: "plan-review", capacity_required: true });
310
+ const orchestration = input.orchestration ?? writeFanoutDescriptor(input.root, { stage, parent_work_id: input.workId, dispatch: "plan-review", capacity_required: true, scope: stageGraphScope(context, input.run.project_id, input.run.id, stage) });
310
311
  return { ok: true, resumed: true, run_id: input.run.id, stage, outcome: "review_required", work_id: input.workId, id: binding.work_session_id, prompt_path: promptPath, worker_prompt_markdown: fs.readFileSync(promptPath, "utf8"), orchestration, plan_review: { requested_mode: input.requested, effective_mode: input.effective, policy_source: policySource(input.run), groups: input.groups }, next: { dispatch_command: dispatchCommand(context, input.run.id, input.projectRoot), finish_command: finishCommand(context, input.run.id, input.projectRoot, path.join(input.root, "decision.json")) } };
311
312
  }
312
313
  function orchestratorPrompt(context, input) {
@@ -432,7 +433,7 @@ catch {
432
433
  } }
433
434
  function rootWorkId(context, projectId, runId) { const root = context.db.get("SELECT work_id FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NULL ORDER BY created_at LIMIT 1", [projectId, runId]); if (!root)
434
435
  throw new AppError("runtime_missing", "vNext RUN has no root Work", 1); return root.work_id; }
435
- function registerCode(context, projectId, runId, batch) { return addWorkBatch(context, { parentWorkId: rootWorkId(context, projectId, runId), file: batch }); }
436
+ function registerCode(context, projectId, runId, batch) { return addWorkBatch(context, { parentWorkId: rootWorkId(context, projectId, runId), file: batch, stage: "code" }); }
436
437
  function readDecision(context, projectRoot, runId, runRoot, file) { return prepareJsonSchema(file, "PLAN-REVIEW decision", { schemaName: "plan-review-decision", projectRoot, runId, runRoot, ddFlowHome: context.ddFlowHome }).value; }
437
438
  function preserveDecisionReceipt(root, decision, preparedText) {
438
439
  const receipt = path.join(root, "decision.receipt.json");
@@ -1,8 +1,9 @@
1
1
  import { managedLifecycleCommand } from "./lifecycle-invocations.js";
2
+ import { bindStageWork, currentStageWorkIds, stageWorkIds } from "./stage-work-graph.js";
3
+ import { runtimeCommand } from "./runtime-command.js";
2
4
  import crypto from "node:crypto";
3
5
  import fs from "node:fs";
4
6
  import path from "node:path";
5
- import { quote } from "shell-quote";
6
7
  import { AppError } from "../shared/errors.js";
7
8
  import { entityReferenceVariants, shortEntityReference } from "../shared/entity-references.js";
8
9
  import { resolveWorkReference } from "../storage/work-references.js";
@@ -104,6 +105,8 @@ export function createChildWork(context, input) {
104
105
  const id = nextWorkId(context, parent.project_id, input.slug);
105
106
  const now = context.now();
106
107
  context.db.run(`INSERT INTO works (${workColumns}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]', 'created', NULL, ?, NULL, ?, NULL)`, [id, parent.project_id, parent.run_id, parent.work_id, input.task, input.launchPolicy, input.resultSchema ?? null, input.payload ? JSON.stringify(input.payload) : null, now, now]);
108
+ if (input.stage)
109
+ bindStageWork(context, { projectId: parent.project_id, runId: parent.run_id, workId: id, stage: input.stage });
107
110
  refreshRunWorkProjection(context, parent.project_id, parent.run_id);
108
111
  return requireWork(context, id);
109
112
  });
@@ -181,8 +184,14 @@ export function addWorkBatch(context, input) {
181
184
  for (const item of proposed)
182
185
  context.db.run(`INSERT INTO works (work_id, project_id, run_id, parent_work_id, task, launch_policy, result_schema, payload_json, depends_on_json, status, result, started_at, created_at, updated_at, completed_at)
183
186
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'created', NULL, NULL, ?, ?, NULL)`, [item.id, parent.project_id, parent.run_id, item.parentId, item.task, item.launch_policy ?? "reuse_allowed", item.result_schema ?? null, item.payload ? JSON.stringify(item.payload) : null, JSON.stringify(item.dependencies), now, now]);
184
- for (const item of proposed)
187
+ const run = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [parent.project_id, parent.run_id]);
188
+ const stages = run ? (JSON.parse(run.index_json).stage_runs ?? []) : [];
189
+ const ownerStage = input.stage ?? [...stages].reverse().find(stage => stage.status === "running")?.stage;
190
+ for (const item of proposed) {
191
+ if (ownerStage)
192
+ bindStageWork(context, { projectId: parent.project_id, runId: parent.run_id, workId: item.id, stage: ownerStage, ...(input.cycle ? { cycle: input.cycle } : {}) });
185
193
  appendFlowRunTimelineEvent(context, parent.project_id, parent.run_id, { type: "work_materialized", work_id: item.id, parent_work_id: item.parentId, depends_on: item.dependencies, launch_policy: item.launch_policy ?? "reuse_allowed" });
194
+ }
186
195
  refreshRunWorkProjection(context, parent.project_id, parent.run_id);
187
196
  return { ok: true, work_ids: Object.fromEntries(ids) };
188
197
  });
@@ -205,8 +214,8 @@ export function listWorks(context, input) {
205
214
  where.push("status = ?");
206
215
  params.push(input.status);
207
216
  }
208
- const rows = context.db.all(`SELECT ${workColumns} FROM works WHERE ${where.join(" AND ")} ORDER BY created_at, work_id`, params);
209
- const ready = (work) => isReady(context, work);
217
+ const rows = context.db.all(`SELECT ${workColumns} FROM works WHERE ${where.join(" AND ")} ORDER BY created_at, work_id`, params).filter(work => !input.workIds || input.workIds.has(work.work_id));
218
+ const ready = (work) => (input.workIds || currentStageWorkIds(context, work.project_id, work.run_id))?.has(work.work_id) !== false && isReady(context, work);
210
219
  const filtered = input.ready ? rows.filter(ready) : rows;
211
220
  return { works: filtered.slice(0, input.limit ?? filtered.length).map((work) => {
212
221
  const isReadyNow = ready(work);
@@ -382,7 +391,7 @@ export function shortWorkId(id) { return shortEntityReference(id); }
382
391
  * PATH is not inherited; using the shared lifecycle command keeps them on the
383
392
  * same captured runtime as their parent stage.
384
393
  */
385
- export function workStartCommand(context, work) { const run = requireRun(context, work.project_id, work.run_id); const recovery = recoveryGuard(context, work.project_id, work.run_id); return managedLifecycleCommand(context, `${flowCommand(context)} work start ${shortWorkId(work.work_id)} --project-root ${JSON.stringify(run.project_root)}${recovery ? ` --recovery-id ${JSON.stringify(recovery.recovery_id)}` : ""} --json`); }
394
+ export function workStartCommand(context, work) { const run = requireRun(context, work.project_id, work.run_id); const recovery = recoveryGuard(context, work.project_id, work.run_id); return managedLifecycleCommand(context, runtimeCommand(context, ["work", "start", shortWorkId(work.work_id), "--project-root", run.project_root, ...(recovery ? ["--recovery-id", recovery.recovery_id] : []), "--json"])); }
386
395
  export function assertWorkLaunchReady(context, id) {
387
396
  ensureWorkRegistry(context);
388
397
  const work = requireWork(context, id);
@@ -391,7 +400,7 @@ export function assertWorkLaunchReady(context, id) {
391
400
  throw new AppError("invalid_work_state", "Work is not created", 2, { work_id: id, status: work.status });
392
401
  if (!isReady(context, work)) {
393
402
  const blockers = readinessBlockers(context, work);
394
- const ready = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'created' ORDER BY created_at, work_id`, [work.project_id, work.run_id]).filter((candidate) => isReady(context, candidate)).map((candidate) => ({ work_id: candidate.work_id, start_command: workStartCommand(context, candidate) }));
403
+ const ready = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'created' ORDER BY created_at, work_id`, [work.project_id, work.run_id]).filter((candidate) => currentStageWorkIds(context, candidate.project_id, candidate.run_id)?.has(candidate.work_id) !== false && isReady(context, candidate)).map((candidate) => ({ work_id: candidate.work_id, start_command: workStartCommand(context, candidate) }));
395
404
  throw new AppError("work_not_ready", "Work cannot start until dependencies complete and overlapping planned coordination areas are free", 2, { work_id: id, blockers, ready });
396
405
  }
397
406
  return work;
@@ -976,8 +985,8 @@ function renderWorkerPrompt(context, work, run, dependencies) {
976
985
  const command = flowCommand(context);
977
986
  const packet = codePacket(work);
978
987
  const resultInputPath = path.join(requireRunHome(run), "works", work.work_id, "result-input.json");
979
- const finishWorkCommand = managedLifecycleCommand(context, `${command} work finish ${work.work_id} --result-file ${quote([resultInputPath])} --project-root ${quote([run.project_root])} --json --progress-jsonl`);
980
- const failWorkCommand = managedLifecycleCommand(context, `${command} work fail ${shortWorkId(work.work_id)} --reason "receipt path + exact external or semantic blocker" --project-root ${JSON.stringify(run.project_root)} --json`);
988
+ const finishWorkCommand = managedLifecycleCommand(context, runtimeCommand(context, ["work", "finish", work.work_id, "--result-file", resultInputPath, "--project-root", run.project_root, "--json", "--progress-jsonl"]));
989
+ const failWorkCommand = managedLifecycleCommand(context, runtimeCommand(context, ["work", "fail", shortWorkId(work.work_id), "--reason", "receipt path + exact external or semantic blocker", "--project-root", run.project_root, "--json"]));
981
990
  const mergeWork = parsePayload(work)?.kind === "merge";
982
991
  const writeBoundary = mergeWork
983
992
  ? ["MERGE does not own product-code repair. Use only the stage packet's merge apply command; resolve only paths that Git reports as unmerged. Do not edit integration code, tests, documentation or configuration to make a gate pass.", "RUN artifacts are read-only evidence: refer to them with run:// URIs and let dd-flow persist your submitted result."]
@@ -1163,7 +1172,7 @@ export function refreshRunWorkProjection(context, projectId, runId) { refreshRun
1163
1172
  if (fs.existsSync(obsolete))
1164
1173
  fs.rmSync(obsolete);
1165
1174
  } }
1166
- export function codeWorkGraph(context, projectId, runId) { const works = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? ORDER BY created_at, work_id`, [projectId, runId]).filter((work) => Boolean(codePacket(work))); const ready = works.filter((work) => isReady(context, work)); return { total: works.length, created: works.filter((work) => work.status === "created").length, running: works.filter((work) => work.status === "running").length, completed: works.filter((work) => work.status === "completed").length, failed: works.filter((work) => work.status === "failed").length, ready: ready.map((work) => ({ work_id: work.work_id, task: work.task, launch_policy: work.launch_policy, start_command: workStartCommand(context, work) })), blocked: works.filter((work) => work.status === "created" && !ready.includes(work)).map((work) => ({ work_id: work.work_id, depends_on: parseDependencies(work) })) }; }
1175
+ export function codeWorkGraph(context, projectId, runId) { const index = JSON.parse(context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId])?.index_json ?? "{}"); const active = index.stage_runs?.some(item => item.stage === "code-review" && item.status === "running") ? "code-review" : "code"; const members = index.stage_runs?.length ? stageWorkIds(context, projectId, runId, active) : null; const works = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? ORDER BY created_at, work_id`, [projectId, runId]).filter((work) => Boolean(codePacket(work)) && (!members || members.has(work.work_id))); const ready = works.filter((work) => isReady(context, work)); return { total: works.length, created: works.filter((work) => work.status === "created").length, running: works.filter((work) => work.status === "running").length, completed: works.filter((work) => work.status === "completed").length, failed: works.filter((work) => work.status === "failed").length, ready: ready.map((work) => ({ work_id: work.work_id, task: work.task, launch_policy: work.launch_policy, start_command: workStartCommand(context, work) })), blocked: works.filter((work) => work.status === "created" && !ready.includes(work)).map((work) => ({ work_id: work.work_id, depends_on: parseDependencies(work) })) }; }
1167
1176
  function parsePayload(work) { if (!work.payload_json)
1168
1177
  return null; try {
1169
1178
  const value = JSON.parse(work.payload_json);
@@ -5,6 +5,18 @@ import { createRequire } from "node:module";
5
5
  import { ensureDir } from "./paths.js";
6
6
  import { AppError } from "../shared/errors.js";
7
7
  import { executeStoreSchema, readStoreWriterContract, registerStoreWriter } from "./writer-contract.js";
8
+ const workContinuationStorageSql = `
9
+ CREATE TABLE IF NOT EXISTS stage_work_memberships (
10
+ project_id TEXT NOT NULL, run_id TEXT NOT NULL, work_id TEXT NOT NULL REFERENCES works(work_id),
11
+ stage TEXT NOT NULL, attempt TEXT NOT NULL, cycle TEXT NOT NULL DEFAULT '',
12
+ PRIMARY KEY(project_id, run_id, work_id, stage, attempt, cycle)
13
+ );
14
+ CREATE TABLE IF NOT EXISTS repair_intents (
15
+ identity TEXT PRIMARY KEY, project_id TEXT NOT NULL, run_id TEXT NOT NULL,
16
+ stage TEXT NOT NULL, attempt TEXT NOT NULL, payload_hash TEXT NOT NULL,
17
+ payload_json TEXT NOT NULL, work_id TEXT NOT NULL REFERENCES works(work_id), created_at TEXT NOT NULL
18
+ );
19
+ `;
8
20
  const require = createRequire(import.meta.url);
9
21
  const { DatabaseSync } = require("node:sqlite");
10
22
  const resourceDatabases = new Map();
@@ -210,6 +222,7 @@ export function ensureVnextWorkStorage(db) {
210
222
  FOREIGN KEY(work_id) REFERENCES works(work_id)
211
223
  );
212
224
  CREATE INDEX IF NOT EXISTS idx_check_receipts_run ON check_receipts(project_id, run_id, started_at);
225
+ ${workContinuationStorageSql}
213
226
  `);
214
227
  // A committed Work start is authoritative even if publishing its prompt
215
228
  // files is interrupted after commit. Keep the exact bytes for repair.
@@ -949,6 +962,8 @@ function migrate(db) {
949
962
  CREATE INDEX IF NOT EXISTS idx_check_receipts_run
950
963
  ON check_receipts(project_id, run_id, started_at);
951
964
 
965
+ ${workContinuationStorageSql}
966
+
952
967
  CREATE TABLE IF NOT EXISTS merge_requests (
953
968
  merge_request_id TEXT PRIMARY KEY,
954
969
  project_id TEXT NOT NULL,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.88",
3
+ "version": "0.9.0-beta.89",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {