@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
@@ -0,0 +1,305 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { execFileSync } from "node:child_process";
5
+ import { AppError } from "../shared/errors.js";
6
+ import { resolveProjectRoot } from "../storage/paths.js";
7
+ import { requireProjectByRoot } from "./projects.js";
8
+ import { appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRun, completeFlowRunStage, gitFacts } from "./runs.js";
9
+ import { validateSchema } from "./schema-validation.js";
10
+ import { flowCommand } from "./stage-pause.js";
11
+ import { assertStageStartHookEvent } from "./hooks.js";
12
+ import { requireVnextWorkspaceRoute } from "./vnext-workspace-policy.js";
13
+ import { addVnextCodeRepair, writeProtocolFlowStatus } from "./vnext-code.js";
14
+ import { finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures } from "./code-checks.js";
15
+ import { addWorkBatch, bindStageCoordinatorWork, finishWork, refreshRunWorkProjection } from "./work-registry.js";
16
+ import { vnextStageDirectory } from "../domain/stage-catalog.js";
17
+ import { writeStageReport } from "./stage-report-renderer.js";
18
+ import { applyExternalStageContext } from "./stage-context.js";
19
+ import { readFanoutDescriptor, writeFanoutDescriptor } from "./vnext-fanout.js";
20
+ const stage = "code-review";
21
+ const stageDir = vnextStageDirectory(stage);
22
+ const baselineAspects = ["goal_traceability", "coding_standards_design_review", "verification_evidence_review"];
23
+ export function isVnextCodeReviewRun(context, input) {
24
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
25
+ return Boolean(context.db.get("SELECT id FROM runs WHERE project_id = ? AND id = ? AND flow_kind = 'vnext_protocolize'", [project.id, input.runId]));
26
+ }
27
+ export function startVnextCodeReview(context, input) {
28
+ const projectRoot = resolveProjectRoot(input.projectRoot);
29
+ const project = requireProjectByRoot(context, projectRoot);
30
+ const run = requireRun(context, project.id, input.runId);
31
+ const home = requireHome(run);
32
+ assertStageStartHookEvent(context, { projectId: project.id, eventKey: input.hookEventId, runId: run.id, stage, projectRoot, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
33
+ requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: home, workspaceRoot: run.workspace_root, stage });
34
+ if (!fs.existsSync(path.join(home, "05-code", "stage-report.json")))
35
+ throw new AppError("not_found", "CODE-REVIEW requires an accepted CODE report", 1);
36
+ const root = path.join(home, stageDir);
37
+ fs.mkdirSync(root, { recursive: true });
38
+ const rootWork = requireRootWork(context, project.id, run.id);
39
+ const prior = stageStatus(run, stage);
40
+ const existingPrompt = path.join(root, "stage-prompt.md");
41
+ if (prior && fs.existsSync(existingPrompt)) {
42
+ const externalContext = applyExternalStageContext({ stageRoot: root, promptPath: existingPrompt, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
43
+ const binding = bindStageCoordinatorWork(context, { workId: rootWork.work_id, hookEventId: input.hookEventId, stage, promptPath: existingPrompt, resultPath: path.join(root, "stage-report.json"), ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
44
+ const prompt = fs.readFileSync(existingPrompt, "utf8");
45
+ const orchestration = readFanoutDescriptor(root);
46
+ return { ok: true, resumed: true, run_id: run.id, stage, stage_status: prior, id: binding.work_session_id, prompt_path: existingPrompt, worker_prompt_markdown: prompt, ...(externalContext ? { external_context: externalContext } : {}), ...(orchestration ? { orchestration } : {}), next: { finish_command: finishCommand(context, run.id, projectRoot, path.join(root, "decision.json")) } };
47
+ }
48
+ const mode = effectiveMode(run, changedPaths(run.workspace_root));
49
+ const promptPath = path.join(root, "stage-prompt.md");
50
+ if (mode === "off") {
51
+ const prompt = `<stage_identity>\n- RUN: ${run.id}\n- stage: code-review\n- mode: off\n</stage_identity>\n\nCODE-REVIEW is disabled by the frozen RUN configuration. Finish with: ${finishCommand(context, run.id, projectRoot, path.join(root, "decision.json"))}\n`;
52
+ fs.writeFileSync(promptPath, prompt);
53
+ const externalContext = applyExternalStageContext({ stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
54
+ const binding = bindStageCoordinatorWork(context, { workId: rootWork.work_id, hookEventId: input.hookEventId, stage, promptPath, resultPath: path.join(root, "stage-report.json"), ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
55
+ attachFlowRunStage(context, { projectRoot, runId: run.id, stage, dir: stageDir, status: "running", dataSchemaId: "dd-flow/stage-report@1" });
56
+ return { ok: true, run_id: run.id, stage, mode, id: binding.work_session_id, prompt_path: promptPath, worker_prompt_markdown: fs.readFileSync(promptPath, "utf8"), ...(externalContext ? { external_context: externalContext } : {}), next: { finish_command: finishCommand(context, run.id, projectRoot, path.join(root, "decision.json")) } };
57
+ }
58
+ const groups = reviewGroups(run, home, mode);
59
+ const batch = { works: groups.map((group, index) => ({ key: `code-review-${index + 1}`, task: reviewerTask(home, group), launch_policy: "fresh_agent_required", result_schema: "dd-flow/code-review-result@1", payload: { kind: "code-review", group, read_only: true } })) };
60
+ const batchFile = path.join(root, "review-work-batch.json");
61
+ fs.writeFileSync(batchFile, `${JSON.stringify(batch, null, 2)}\n`);
62
+ const prompt = orchestratorPrompt(context, { projectRoot, run, root, rootWork, mode, groups });
63
+ fs.writeFileSync(promptPath, prompt);
64
+ const externalContext = applyExternalStageContext({ stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
65
+ const binding = bindStageCoordinatorWork(context, { workId: rootWork.work_id, hookEventId: input.hookEventId, stage, promptPath, resultPath: path.join(root, "stage-report.json"), ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
66
+ const registered = addWorkBatch(context, { parentWorkId: rootWork.work_id, file: batchFile });
67
+ const orchestration = writeFanoutDescriptor(root, { stage, parent_work_id: rootWork.work_id, dispatch: "none", capacity_required: true });
68
+ attachFlowRunStage(context, { projectRoot, runId: run.id, stage, dir: stageDir, status: "running", dataSchemaId: "dd-flow/stage-report@1" });
69
+ appendFlowRunTimelineEvent(context, project.id, run.id, { type: "code_review_started", work_id: rootWork.work_id, mode, groups, registered });
70
+ return { ok: true, run_id: run.id, stage, mode, id: binding.work_session_id, prompt_path: promptPath, worker_prompt_markdown: fs.readFileSync(promptPath, "utf8"), ...(externalContext ? { external_context: externalContext } : {}), orchestration, review: { groups, works: registered }, next: { finish_command: finishCommand(context, run.id, projectRoot, path.join(root, "decision.json")) } };
71
+ }
72
+ /** Create a narrow CODE repair from accepted independent-review evidence. */
73
+ export function addVnextCodeReviewRepair(context, input) {
74
+ const projectRoot = resolveProjectRoot(input.projectRoot);
75
+ const project = requireProjectByRoot(context, projectRoot);
76
+ const run = requireRun(context, project.id, input.runId);
77
+ const reviewers = reviewerWorks(context, project.id, run.id);
78
+ const findings = canonicalCodeFindings(reviewers);
79
+ const selected = findings.filter((finding) => input.findingIds.includes(finding.finding_ref));
80
+ if (selected.length !== new Set(input.findingIds).size)
81
+ throw new AppError("not_found", "CODE-REVIEW repair references an unknown or ambiguous finding", 2, { finding_ids: input.findingIds });
82
+ if (selected.some(({ finding }) => finding.priority === "p3"))
83
+ throw new AppError("validation", "P3 observations are not CODE repair inputs", 2, { finding_ids: input.findingIds });
84
+ const origins = codeWorks(context, project.id, run.id).map((work) => work.work_id);
85
+ if (!origins.length)
86
+ throw new AppError("runtime_missing", "CODE-REVIEW repair requires completed CODE Work", 1);
87
+ return addVnextCodeRepair(context, {
88
+ projectRoot,
89
+ runId: run.id,
90
+ reviewFindingIds: selected.map((finding) => finding.finding_ref),
91
+ reviewEvidenceRefs: selected.flatMap(({ finding }) => finding.evidence_refs),
92
+ originWorkIds: origins,
93
+ objective: input.objective
94
+ });
95
+ }
96
+ export async function finishVnextCodeReview(context, input) {
97
+ const projectRoot = resolveProjectRoot(input.projectRoot);
98
+ const project = requireProjectByRoot(context, projectRoot);
99
+ const run = requireRun(context, project.id, input.runId);
100
+ const home = requireHome(run);
101
+ const root = path.join(home, stageDir);
102
+ const mode = effectiveMode(run, changedPaths(run.workspace_root));
103
+ const reportPath = path.join(root, "stage-report.json");
104
+ if (stageStatus(run, stage) === "done" && fs.existsSync(reportPath))
105
+ return { ok: true, resumed: true, run_id: run.id, stage, outcome: "accepted", report_path: reportPath, next_action: "code_review_completed" };
106
+ const rootWork = requireRootWork(context, project.id, run.id);
107
+ const coordinator = context.db.get("SELECT session_id, result_path FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [rootWork.work_id]);
108
+ if (!coordinator || path.resolve(coordinator.result_path ?? "") !== path.resolve(reportPath))
109
+ throw new AppError("code_review_session_binding_conflict", "CODE-REVIEW can finish only after its successful stage start bound the coordinator Session and report path", 1, { run_id: run.id, expected_result_path: reportPath, actual_result_path: coordinator?.result_path ?? null });
110
+ const decisionFile = input.decisionFile ?? path.join(root, "decision.json");
111
+ if (!fs.existsSync(decisionFile) && mode === "off")
112
+ fs.writeFileSync(decisionFile, `${JSON.stringify({ schema_id: "dd-flow/code-review-decision@2", summary: "CODE-REVIEW is disabled by RUN configuration.", findings: [] }, null, 2)}\n`);
113
+ validateSchema({ schemaName: "code-review-decision", file: decisionFile, projectRoot: run.workspace_root, ddFlowHome: context.ddFlowHome, runId: run.id });
114
+ const decision = readJson(decisionFile);
115
+ const reviewers = reviewerWorks(context, project.id, run.id);
116
+ if (mode !== "off") {
117
+ const incomplete = reviewers.filter((work) => work.status !== "completed");
118
+ if (incomplete.length)
119
+ throw new AppError("reviewer_jobs_incomplete", "CODE-REVIEW cannot finish while reviewer Work is unsettled", 2, { works: incomplete.map((work) => ({ work_id: work.work_id, status: work.status })) });
120
+ for (const work of reviewers)
121
+ validateReviewerResult(context, work, run);
122
+ const canonical = reviewDecisionForRepair(decision, reviewers);
123
+ let repairs = reviewRepairWorks(context, project.id, run.id, root);
124
+ const decisionSha = sha256File(decisionFile);
125
+ const frozenShaFile = path.join(root, ".decision-sha256");
126
+ if (fs.existsSync(frozenShaFile) && fs.readFileSync(frozenShaFile, "utf8").trim() !== decisionSha)
127
+ throw new AppError("review_decision_changed", "The accepted CODE-REVIEW decision cannot change during repair", 2, { decision_file: decisionFile });
128
+ const fixIds = canonical.fix_ids;
129
+ if (fixIds.length && repairs.length === 0) {
130
+ fs.writeFileSync(frozenShaFile, `${decisionSha}\n`);
131
+ const repair = addVnextCodeReviewRepair(context, { projectRoot, runId: run.id, findingIds: fixIds, objective: `Resolve accepted CODE-REVIEW findings: ${fixIds.join(", ")}` });
132
+ return { ok: true, run_id: run.id, stage, outcome: "repair_required", decision_sha256: decisionSha, repair, instruction: "Run the returned repair Work in one fresh child session. When it completes, invoke the same stage finish command again with the unchanged decision file.", next: { finish_command: finishCommand(context, run.id, projectRoot, decisionFile) } };
133
+ }
134
+ repairs = reviewRepairWorks(context, project.id, run.id, root);
135
+ const incompleteRepairs = repairs.filter((work) => work.status !== "completed");
136
+ if (incompleteRepairs.length)
137
+ throw new AppError("review_repair_incomplete", "CODE-REVIEW cannot finish while a repair Work is unsettled", 2, { works: incompleteRepairs.map((work) => ({ work_id: work.work_id, status: work.status })) });
138
+ validateDecision(context, { decision: canonical.decision, reviewers: canonical.reviewers, repairs, workspaceRoot: run.workspace_root });
139
+ }
140
+ const outcome = decision.findings.some((item) => item.disposition === "defer") ? "accepted_with_DEF" : "accepted";
141
+ const repairs = reviewRepairWorks(context, project.id, run.id, root);
142
+ const finalChecks = finalCodeCheckDeclarations(run.workspace_root, codeWorks(context, project.id, run.id).flatMap((work) => { const payload = readJsonString(work.payload_json); return Array.isArray(payload.checks) ? payload.checks : []; }));
143
+ const unchangedFailures = repairs.length ? unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, declarations: finalChecks }) : [];
144
+ if (unchangedFailures.length)
145
+ throw new AppError("code_review_gate_repair_required", "CODE-REVIEW final gate already failed for the unchanged workspace; repair the evidenced failure before retrying", 2, { outcome: "repair_required", retry_after_workspace_change: true, workspace_fingerprint: unchangedFailures[0].workspace_fingerprint, failures: unchangedFailures });
146
+ const receipts = repairs.length ? await runCodeChecks(context, { projectId: project.id, runId: run.id, runHome: home, workspaceRoot: run.workspace_root, artifactDir: stageDir, scope: "aggregate", checks: finalChecks, ...(input.progress ? { progress: input.progress } : {}) }) : [];
147
+ const failed = receipts.filter((receipt) => receipt.status === "failed");
148
+ if (failed.length)
149
+ throw new AppError("code_review_gate_failed", "CODE-REVIEW repair changed the project but the aggregate gate failed", 2, { failures: failed });
150
+ const now = context.now();
151
+ const startedAt = stageStartedAt(run, now);
152
+ writeProtocolFlowStatus(run.workspace_root, home, run.id, "CODE-REVIEW complete; this RUN reached its configured terminal boundary.");
153
+ const report = { schema_id: "dd-flow/stage-report@1", run_id: run.id, stage, generated_at: now, verdict: "done", semantic: { result: decision.summary, acceptance: ["all_selected_review_groups_completed", "all_material_findings_classified", ...(repairs.length ? ["all_accepted_repairs_completed", "post_repair_final_gate_passed"] : [])], run_changed_files: changedPaths(run.workspace_root), checks: receipts.map((receipt) => receipt.command), evidence: [...reviewers.flatMap((work) => readJsonString(work.result).findings.flatMap((finding) => finding.evidence_refs)), ...receipts.map((receipt) => runRef(run.id, home, receipt.receipt_path))], next_action: "code_review_completed", code_review: { mode, reviewer_work_ids: reviewers.map((work) => work.work_id), repair_work_ids: repairs.map((work) => work.work_id), decision } }, mechanical: { started_at: startedAt, finished_at: now, wall_clock_ms: Math.max(0, Date.parse(now) - Date.parse(startedAt)), git: gitFacts(run.workspace_root), session_stats_command: `${flowCommand(context)} stat run sessions ls --run ${run.id} --project-root ${JSON.stringify(projectRoot)} --json`, usage_stats_command: `${flowCommand(context)} stat usage --run ${run.id} --project-root ${JSON.stringify(projectRoot)} --json` }, artifacts: { json: "stage-report.json", markdown: "stage-report.md", html: "stage-report.html" }, validation: { status: "passed" } };
154
+ writeReport(root, report);
155
+ 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" });
156
+ await finishWork(context, rootWork.work_id, JSON.stringify(report, null, 2));
157
+ appendFlowRunTimelineEvent(context, project.id, run.id, { type: "code_review_completed", work_id: rootWork.work_id, outcome });
158
+ completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: "code_review_completed", nextAction: undefined });
159
+ refreshRunWorkProjection(context, project.id, run.id);
160
+ return { ok: true, run_id: run.id, stage, outcome, report_path: path.join(root, "stage-report.json"), next_action: "code_review_completed" };
161
+ }
162
+ function reviewGroups(run, home, mode) {
163
+ const aspectMapFiles = findFiles(path.join(home, "03-plan"), "aspect-map.json");
164
+ const ids = new Set(baselineAspects);
165
+ for (const file of aspectMapFiles) {
166
+ const map = readJson(file);
167
+ for (const aspect of map.aspects ?? [])
168
+ if (aspect.applicability === "applicable" && aspect.aspect_id)
169
+ ids.add(aspect.aspect_id);
170
+ }
171
+ const all = [...ids];
172
+ const chunk = mode === "deep" ? 1 : 3;
173
+ return Array.from({ length: Math.ceil(all.length / chunk) }, (_, index) => ({ key: `group-${index + 1}`, aspect_ids: all.slice(index * chunk, (index + 1) * chunk) }));
174
+ }
175
+ function reviewerTask(home, group) { return `Read-only independent CODE review for ${group.key}. Assess every assigned aspect exactly once: ${group.aspect_ids.join(", ")}. Read the accepted PLAN, ${path.join(home, "05-code", "stage-report.json")}, and any files needed under the bounded CODE evidence root ${path.join(home, "05-code")}. Report only material, evidenced defects: a violated obligation or rule, direct evidence, impact, and minimum required outcome. Do not report taste, cosmetics, or untargeted refactoring. Finding ids must use the unique prefix FIND-${group.key}-. Return dd-flow/code-review-result@1.`; }
176
+ function orchestratorPrompt(context, input) { const template = read(path.join(input.run.workspace_root, ".memory-bank", "dd-flow", "vnext", "code-review.md")); const decision = path.join(input.root, "decision.json"); return ["<stage_identity>", `- RUN: ${input.run.id}`, `- root Work: ${input.rootWork.work_id}`, `- stage: ${stage}`, `- mode: ${input.mode}`, "</stage_identity>", "", "<trusted_runtime_context>", `- project root: ${input.projectRoot}`, `- immutable write workspace: ${input.run.workspace_root}`, `- stage workspace: ${input.root}`, `- bounded CODE evidence root: ${path.join(input.run.run_home_path, "05-code")}`, "CODE is already semantically verified and all declared checks passed. Do not redo CODE verification; conduct independent quality review.", "</trusted_runtime_context>", "", "<review_groups>", ...input.groups.map((group) => `- ${group.key}: ${group.aspect_ids.join(", ")}`), "</review_groups>", "", "<execution_commands>", "Each reviewer Work must run in one fresh child session. The coordinator must never claim a reviewer Work itself. Start each ready Work with its exact start_command from the work graph; it receives the task and result schema. Reviewers are read-only and must not create subagents. Do not interrupt a quiet worker.", `Ready reviewer Works: ${flowCommand(context)} work ls --run ${input.run.id} --ready --project-root ${JSON.stringify(input.projectRoot)} --json`, `Finish only after every reviewer Work settles and you have written ${decision}: ${finishCommand(context, input.run.id, input.projectRoot, decision)}`, "Reviewer results use local FIND-NNN ids. Classify them by the canonical WRK-.../FIND-NNN finding_ref returned by dd-flow. Fix P0/P1. Fix bounded safe P2 by default; defer only a legitimate P2 with a named DEF. P3 is an observation or a reasoned rejection, not automatic repair/DEF. The first Finish freezes this decision and creates one repair Work when needed. Run it, then call the same Finish command again. Review is not repeated.", "```json", JSON.stringify({ schema_id: "dd-flow/code-review-decision@2", summary: "Evidence-backed conclusion.", findings: [{ finding_ref: "WRK-001-review/FIND-001", disposition: "fix | defer | reject | duplicate", reason: "Why this classification is correct.", def_id: "DEF-0001 only for an allowed P2 deferral", duplicate_of: "canonical finding_ref only for duplicate" }] }, null, 2), "```", "</execution_commands>", "", "<stage_instructions>", template, "</stage_instructions>", ""].join("\n"); }
177
+ function canonicalReviewEvidence(decision, reviewers) {
178
+ const known = new Set(canonicalCodeFindings(reviewers).map((item) => item.finding_ref));
179
+ const items = new Map(decision.findings.map((item) => [item.finding_ref, item]));
180
+ const duplicates = new Set();
181
+ for (const item of decision.findings.filter((entry) => entry.disposition === "duplicate")) {
182
+ if (!item.duplicate_of || !known.has(item.duplicate_of) || item.duplicate_of === item.finding_ref)
183
+ throw new AppError("review_evidence_invalid", "A duplicate finding must name a different known canonical finding", 2, { finding_ref: item.finding_ref, duplicate_of: item.duplicate_of ?? null });
184
+ let cursor = item;
185
+ const seen = new Set();
186
+ while (cursor?.disposition === "duplicate") {
187
+ if (seen.has(cursor.finding_ref))
188
+ throw new AppError("review_evidence_invalid", "Duplicate finding links must be acyclic", 2, { finding_ref: item.finding_ref });
189
+ seen.add(cursor.finding_ref);
190
+ cursor = cursor.duplicate_of ? items.get(cursor.duplicate_of) : undefined;
191
+ }
192
+ if (!cursor)
193
+ throw new AppError("review_evidence_invalid", "Duplicate finding chain must end at a decided canonical finding", 2, { finding_ref: item.finding_ref });
194
+ duplicates.add(item.finding_ref);
195
+ }
196
+ return {
197
+ decision: { ...decision, findings: decision.findings.filter((item) => !duplicates.has(item.finding_ref)) },
198
+ reviewers: reviewers.map((work) => { const result = readJsonString(work.result); return { ...work, result: JSON.stringify({ ...result, findings: result.findings.filter((finding) => !duplicates.has(`${work.work_id}/${finding.finding_id}`)) }) }; })
199
+ };
200
+ }
201
+ /** Freeze the complete, valid repair set before creating any repair Work. */
202
+ export function reviewDecisionForRepair(decision, reviewers) {
203
+ const canonical = canonicalReviewEvidence(decision, reviewers);
204
+ const findings = canonicalCodeFindings(canonical.reviewers);
205
+ const decisions = new Map(canonical.decision.findings.map((item) => [item.finding_ref, item]));
206
+ for (const { finding_ref: findingRef, finding } of findings) {
207
+ const item = decisions.get(findingRef);
208
+ if (!item)
209
+ throw new AppError("review_evidence_invalid", "Every material CODE review finding needs one decision", 2, { finding_ref: findingRef });
210
+ if (["p0", "p1"].includes(finding.priority) && item.disposition !== "fix") {
211
+ throw new AppError("review_material_disposition_invalid", "P0/P1 CODE-REVIEW findings must be fixed, not rejected or deferred", 2, { finding_ref: findingRef, disposition: item.disposition });
212
+ }
213
+ }
214
+ return { ...canonical, fix_ids: canonical.decision.findings.filter((item) => item.disposition === "fix").map((item) => item.finding_ref) };
215
+ }
216
+ function validateDecision(context, input) {
217
+ const findings = canonicalCodeFindings(input.reviewers);
218
+ const known = new Map(findings.map((item) => [item.finding_ref, item.finding]));
219
+ const decisions = new Map(input.decision.findings.map((item) => [item.finding_ref, item]));
220
+ const repaired = new Set(input.repairs.flatMap((work) => { const result = readJsonString(work.result); return Array.isArray(result.resolved_finding_refs) ? result.resolved_finding_refs.filter((value) => typeof value === "string") : []; }));
221
+ for (const { finding_ref: findingRef, finding } of findings) {
222
+ const item = decisions.get(findingRef);
223
+ if (!item)
224
+ throw new AppError("review_evidence_invalid", "Every material CODE review finding needs one decision", 2, { finding_ref: findingRef });
225
+ if (["p0", "p1"].includes(finding.priority) && !(item.disposition === "fix" && repaired.has(findingRef)))
226
+ throw new AppError("review_unresolved_material", "P0/P1 findings must be explicitly resolved by a completed clean repair Work", 2, { finding_ref: findingRef });
227
+ if (item.disposition === "fix" && !repaired.has(findingRef))
228
+ throw new AppError("review_repair_missing", "A fixed CODE-REVIEW finding needs a completed clean repair Work that explicitly resolves it", 2, { finding_ref: findingRef });
229
+ if (finding.priority === "p2" && item.disposition === "defer") {
230
+ if (!item.def_id || !fs.existsSync(path.join(input.workspaceRoot, ".memory-bank", "defs", `${item.def_id}.md`)))
231
+ throw new AppError("deferral_invalid", "A deferred P2 requires a named durable DEF", 2, { finding_ref: findingRef, def_id: item.def_id ?? null });
232
+ }
233
+ if (finding.priority === "p3" && item.disposition === "defer")
234
+ throw new AppError("deferral_invalid", "P3 observations are not deferred as DEF", 2, { finding_ref: findingRef });
235
+ }
236
+ for (const item of input.decision.findings)
237
+ if (!known.has(item.finding_ref))
238
+ throw new AppError("review_evidence_invalid", "Decision references an unknown reviewer finding", 2, { finding_ref: item.finding_ref });
239
+ }
240
+ function validateReviewerResult(context, work, run) { if (!work.result)
241
+ throw new AppError("review_evidence_invalid", "Reviewer Work has no result", 2, { work_id: work.work_id }); const file = path.join(requireHome(run), stageDir, `${work.work_id}.result.json`); fs.writeFileSync(file, work.result); validateSchema({ schemaName: "code-review-result", file, projectRoot: run.workspace_root, ddFlowHome: context.ddFlowHome, runId: run.id }); }
242
+ function reviewerWorks(context, projectId, runId) { return context.db.all("SELECT * FROM works WHERE project_id = ? AND run_id = ? ORDER BY created_at, work_id", [projectId, runId]).filter((work) => { const p = readJsonString(work.payload_json); return Boolean(p && p.kind === "code-review"); }); }
243
+ function codeWorks(context, projectId, runId) { return context.db.all("SELECT * FROM works WHERE project_id = ? AND run_id = ? AND status = 'completed' ORDER BY created_at, work_id", [projectId, runId]).filter((work) => readJsonString(work.payload_json).schema_id === "dd-flow/code-work-packet@5" && !reviewFindingIds(work).length); }
244
+ function reviewRepairWorks(context, projectId, runId, root) { return context.db.all("SELECT * FROM works WHERE project_id = ? AND run_id = ? ORDER BY created_at, work_id", [projectId, runId]).filter((work) => isCodeReviewStageRepair(work, root)); }
245
+ export function isCodeReviewStageRepair(work, root) {
246
+ if (reviewFindingIds(work).length)
247
+ return true;
248
+ const repair = readJsonString(work.payload_json).repair;
249
+ if (!repair || typeof repair !== "object" || Array.isArray(repair))
250
+ return false;
251
+ const receipt = repair.failure_receipt_path;
252
+ if (typeof receipt !== "string" || !receipt)
253
+ return false;
254
+ const relative = path.relative(path.resolve(root), path.resolve(receipt));
255
+ return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
256
+ }
257
+ function reviewFindingIds(work) { const repair = readJsonString(work.payload_json).repair; if (!repair || typeof repair !== "object" || Array.isArray(repair))
258
+ return []; const ids = repair.review_finding_ids; return Array.isArray(ids) ? ids.filter((value) => typeof value === "string") : []; }
259
+ function canonicalCodeFindings(works) { return works.flatMap((work) => readJsonString(work.result).findings.map((finding) => ({ finding_ref: `${work.work_id}/${finding.finding_id}`, finding }))); }
260
+ function effectiveMode(run, paths) { const index = JSON.parse(run.index_json); const requested = index.settings?.code_review?.mode ?? "auto"; return requested === "auto" ? (paths.length ? "standard" : "off") : requested; }
261
+ function changedPaths(workspaceRoot) { try {
262
+ return execFileSync("git", ["status", "--porcelain=v1", "--untracked-files=all"], { cwd: workspaceRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).split(/\r?\n/).filter(Boolean).map((line) => line.slice(3).replace(/^.* -> /, ""));
263
+ }
264
+ catch {
265
+ return [];
266
+ } }
267
+ function sha256File(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
268
+ function stageStatus(run, name) { try {
269
+ return JSON.parse(run.index_json).stage_runs?.find((item) => item.stage === name)?.status ?? null;
270
+ }
271
+ catch {
272
+ return null;
273
+ } }
274
+ function stageStartedAt(run, fallback) { try {
275
+ return JSON.parse(run.index_json).stage_runs?.find((item) => item.stage === stage)?.started_at ?? fallback;
276
+ }
277
+ catch {
278
+ return fallback;
279
+ } }
280
+ function runRef(runId, home, file) { const relative = path.relative(home, file).split(path.sep).join("/"); return relative && !relative.startsWith("../") ? `run://${runId}/${relative}` : file; }
281
+ function requireRootWork(context, projectId, runId) { const work = context.db.get("SELECT * FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NULL ORDER BY created_at LIMIT 1", [projectId, runId]); if (!work || work.status !== "running")
282
+ throw new AppError("runtime_missing", "vNext RUN has no running root Work", 1); return work; }
283
+ function requireRun(context, projectId, runId) { const run = context.db.get("SELECT id, project_id, workspace_root, run_home_path, index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]); if (!run)
284
+ throw new AppError("not_found", "RUN is not registered", 1); return run; }
285
+ function requireHome(run) { if (!run.run_home_path)
286
+ throw new AppError("runtime_missing", "RUN workspace is unavailable", 1); return run.run_home_path; }
287
+ function finishCommand(context, runId, projectRoot, decision) { return `${flowCommand(context)} stage finish ${runId} --stage code-review --decision-file ${JSON.stringify(decision)} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`; }
288
+ function findFiles(root, name) { if (!fs.existsSync(root))
289
+ return []; const out = []; for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
290
+ const file = path.join(root, entry.name);
291
+ if (entry.isDirectory())
292
+ out.push(...findFiles(file, name));
293
+ else if (entry.name === name)
294
+ out.push(file);
295
+ } return out; }
296
+ function read(file) { return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "Apply independent code review only; preserve accepted scope and report material evidence."; }
297
+ function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); }
298
+ function readJsonString(value) { try {
299
+ const valueJson = JSON.parse(value ?? "");
300
+ return valueJson && typeof valueJson === "object" && !Array.isArray(valueJson) ? valueJson : {};
301
+ }
302
+ catch {
303
+ return {};
304
+ } }
305
+ function writeReport(root, report) { writeStageReport(root, report); }