@deksden-com/dd-flow-cli 0.8.0-beta.135 → 0.9.0-beta.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.
- package/CHANGELOG.md +52 -0
- package/README.md +31 -4
- package/dist/build-info.json +10 -10
- package/dist/cli/help.js +15 -63
- package/dist/cli/run-cli.js +55 -26
- package/dist/domain/stage-catalog.js +2 -2
- package/dist/domain/validation.js +1 -1
- package/dist/schemas/agent-profile.schema.json +17 -0
- package/dist/schemas/code-review-decision.schema.json +5 -3
- package/dist/schemas/code-work-batch.schema.json +3 -3
- package/dist/schemas/compatibility.schema.json +32 -0
- package/dist/schemas/flow-contract.schema.json +3 -2
- package/dist/schemas/merge-result.schema.json +15 -0
- package/dist/schemas/protocol-plan.schema.json +1 -1
- package/dist/schemas/stage-start-response.schema.json +4 -2
- package/dist/schemas/status-report.schema.json +76 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +4 -4
- package/dist/services/cli-operation-classifier.js +1 -1
- package/dist/services/code-checks.js +125 -208
- package/dist/services/eval-snapshots.js +10 -1
- package/dist/services/harness-adapter.js +59 -0
- package/dist/services/hooks.js +87 -237
- package/dist/services/ids.js +18 -1
- package/dist/services/lifecycle-command.js +288 -0
- package/dist/services/merge-server.js +124 -0
- package/dist/services/prompts.js +16 -10
- package/dist/services/runs.js +7 -2
- package/dist/services/sessions.js +18 -27
- package/dist/services/stage-pause.js +13 -0
- package/dist/services/vnext-code-review.js +71 -20
- package/dist/services/vnext-code.js +131 -34
- package/dist/services/vnext-execution-profile.js +6 -3
- package/dist/services/vnext-merge.js +330 -0
- package/dist/services/vnext-plan-review.js +2 -2
- package/dist/services/vnext-plan.js +22 -38
- package/dist/services/vnext-specify.js +5 -2
- package/dist/services/vnext-workspace-policy.js +8 -2
- package/dist/services/work-registry.js +85 -34
- package/dist/storage/database.js +66 -0
- package/package.json +2 -1
|
@@ -5,7 +5,7 @@ import { execFileSync } from "node:child_process";
|
|
|
5
5
|
import { AppError } from "../shared/errors.js";
|
|
6
6
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
7
7
|
import { requireProjectByRoot } from "./projects.js";
|
|
8
|
-
import { appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRun, completeFlowRunStage, gitFacts } from "./runs.js";
|
|
8
|
+
import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRun, completeFlowRunStage, gitFacts } from "./runs.js";
|
|
9
9
|
import { validateSchema } from "./schema-validation.js";
|
|
10
10
|
import { flowCommand } from "./stage-pause.js";
|
|
11
11
|
import { assertStageStartHookEvent } from "./hooks.js";
|
|
@@ -17,6 +17,7 @@ import { vnextStageDirectory } from "../domain/stage-catalog.js";
|
|
|
17
17
|
import { writeStageReport } from "./stage-report-renderer.js";
|
|
18
18
|
import { applyExternalStageContext } from "./stage-context.js";
|
|
19
19
|
import { readFanoutDescriptor, writeFanoutDescriptor } from "./vnext-fanout.js";
|
|
20
|
+
import { ensureVnextMergeRequest } from "./vnext-merge.js";
|
|
20
21
|
const stage = "code-review";
|
|
21
22
|
const stageDir = vnextStageDirectory(stage);
|
|
22
23
|
const baselineAspects = ["goal_traceability", "coding_standards_design_review", "verification_evidence_review"];
|
|
@@ -84,13 +85,25 @@ export function addVnextCodeReviewRepair(context, input) {
|
|
|
84
85
|
const origins = codeWorks(context, project.id, run.id).map((work) => work.work_id);
|
|
85
86
|
if (!origins.length)
|
|
86
87
|
throw new AppError("runtime_missing", "CODE-REVIEW repair requires completed CODE Work", 1);
|
|
88
|
+
const checks = new Map(codeWorks(context, project.id, run.id).flatMap((work) => {
|
|
89
|
+
const payload = readJsonString(work.payload_json);
|
|
90
|
+
return Array.isArray(payload.checks) ? payload.checks : [];
|
|
91
|
+
}).map((check) => [check.id, check]));
|
|
92
|
+
const requestedCheckRefs = [...new Set(selected.flatMap(({ finding_ref }) => input.checkRefsByFinding[finding_ref] ?? []))];
|
|
93
|
+
if (!requestedCheckRefs.length)
|
|
94
|
+
throw new AppError("review_repair_check_required", "Each accepted CODE-REVIEW repair needs an explicit causal CHK-* check reference", 2, { finding_ids: input.findingIds });
|
|
95
|
+
const unknown = requestedCheckRefs.filter((ref) => !checks.has(ref));
|
|
96
|
+
if (unknown.length)
|
|
97
|
+
throw new AppError("review_check_reference_unknown", "CODE-REVIEW finding cites a check absent from the accepted CODE handoff", 2, { check_refs: unknown });
|
|
98
|
+
const reviewChecks = requestedCheckRefs.map((ref) => checks.get(ref)).filter((check) => check.run_at !== "external");
|
|
87
99
|
return addVnextCodeRepair(context, {
|
|
88
100
|
projectRoot,
|
|
89
101
|
runId: run.id,
|
|
90
102
|
reviewFindingIds: selected.map((finding) => finding.finding_ref),
|
|
91
103
|
reviewEvidenceRefs: selected.flatMap(({ finding }) => finding.evidence_refs),
|
|
104
|
+
reviewChecks,
|
|
105
|
+
reviewCheckRefs: requestedCheckRefs,
|
|
92
106
|
originWorkIds: origins,
|
|
93
|
-
writeScope: input.writeScope,
|
|
94
107
|
objective: input.objective
|
|
95
108
|
});
|
|
96
109
|
}
|
|
@@ -102,15 +115,17 @@ export async function finishVnextCodeReview(context, input) {
|
|
|
102
115
|
const root = path.join(home, stageDir);
|
|
103
116
|
const mode = effectiveMode(run, changedPaths(run.workspace_root));
|
|
104
117
|
const reportPath = path.join(root, "stage-report.json");
|
|
105
|
-
if (stageStatus(run, stage) === "done" && fs.existsSync(reportPath))
|
|
106
|
-
|
|
118
|
+
if (stageStatus(run, stage) === "done" && fs.existsSync(reportPath)) {
|
|
119
|
+
const report = readJson(reportPath);
|
|
120
|
+
return { ok: true, resumed: true, run_id: run.id, stage, outcome: "accepted", report_path: reportPath, next_action: report.semantic?.next_action ?? "code_review_completed" };
|
|
121
|
+
}
|
|
107
122
|
const rootWork = requireRootWork(context, project.id, run.id);
|
|
108
123
|
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]);
|
|
109
124
|
if (!coordinator || path.resolve(coordinator.result_path ?? "") !== path.resolve(reportPath))
|
|
110
125
|
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 });
|
|
111
126
|
const decisionFile = input.decisionFile ?? path.join(root, "decision.json");
|
|
112
127
|
if (!fs.existsSync(decisionFile) && mode === "off")
|
|
113
|
-
fs.writeFileSync(decisionFile, `${JSON.stringify({ schema_id: "dd-flow/code-review-decision@
|
|
128
|
+
fs.writeFileSync(decisionFile, `${JSON.stringify({ schema_id: "dd-flow/code-review-decision@3", summary: "CODE-REVIEW is disabled by RUN configuration.", findings: [] }, null, 2)}\n`);
|
|
114
129
|
validateSchema({ schemaName: "code-review-decision", file: decisionFile, projectRoot: run.workspace_root, ddFlowHome: context.ddFlowHome, runId: run.id });
|
|
115
130
|
const decision = readJson(decisionFile);
|
|
116
131
|
const reviewers = reviewerWorks(context, project.id, run.id);
|
|
@@ -121,6 +136,7 @@ export async function finishVnextCodeReview(context, input) {
|
|
|
121
136
|
for (const work of reviewers)
|
|
122
137
|
validateReviewerResult(context, work, run);
|
|
123
138
|
const canonical = reviewDecisionForRepair(decision, reviewers);
|
|
139
|
+
const checkRefsByFinding = validateRepairChecks(context, { projectId: project.id, runId: run.id, decision: canonical.decision });
|
|
124
140
|
let repairs = reviewRepairWorks(context, project.id, run.id, root);
|
|
125
141
|
const decisionSha = sha256File(decisionFile);
|
|
126
142
|
const frozenShaFile = path.join(root, ".decision-sha256");
|
|
@@ -128,9 +144,8 @@ export async function finishVnextCodeReview(context, input) {
|
|
|
128
144
|
throw new AppError("review_decision_changed", "The accepted CODE-REVIEW decision cannot change during repair", 2, { decision_file: decisionFile });
|
|
129
145
|
const fixIds = canonical.fix_ids;
|
|
130
146
|
if (fixIds.length && repairs.length === 0) {
|
|
147
|
+
const repair = addVnextCodeReviewRepair(context, { projectRoot, runId: run.id, findingIds: fixIds, checkRefsByFinding, objective: `Resolve accepted CODE-REVIEW findings: ${fixIds.join(", ")}` });
|
|
131
148
|
fs.writeFileSync(frozenShaFile, `${decisionSha}\n`);
|
|
132
|
-
const writeScope = uniqueStrings(codeWorks(context, project.id, run.id).flatMap((work) => { const payload = readJsonString(work.payload_json); return Array.isArray(payload.write_scope) ? payload.write_scope.filter((value) => typeof value === "string") : []; }));
|
|
133
|
-
const repair = addVnextCodeReviewRepair(context, { projectRoot, runId: run.id, findingIds: fixIds, writeScope, objective: `Resolve accepted CODE-REVIEW findings: ${fixIds.join(", ")}` });
|
|
134
149
|
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) } };
|
|
135
150
|
}
|
|
136
151
|
repairs = reviewRepairWorks(context, project.id, run.id, root);
|
|
@@ -146,20 +161,33 @@ export async function finishVnextCodeReview(context, input) {
|
|
|
146
161
|
if (unchangedFailures.length)
|
|
147
162
|
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 });
|
|
148
163
|
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 } : {}) }) : [];
|
|
149
|
-
const failed = receipts.filter((receipt) => receipt.status
|
|
164
|
+
const failed = receipts.filter((receipt) => receipt.status !== "passed");
|
|
150
165
|
if (failed.length)
|
|
151
|
-
throw new AppError("code_review_gate_failed", "CODE-REVIEW repair changed the project but the aggregate gate failed", 2, {
|
|
166
|
+
throw new AppError("code_review_gate_failed", "CODE-REVIEW repair changed the project but the aggregate gate failed", 2, {
|
|
167
|
+
failures: failed,
|
|
168
|
+
outcome: "repair_required",
|
|
169
|
+
retry_after_workspace_change: true,
|
|
170
|
+
workspace_fingerprint: failed[0].workspace_fingerprint,
|
|
171
|
+
repair_command: `${flowCommand(context)} work repair add --run ${run.id} --from-check ${failed[0].id} --origin-work <WORK-ID> --task-stdin --project-root ${JSON.stringify(projectRoot)} --json`
|
|
172
|
+
});
|
|
173
|
+
const stopTarget = executionStopTarget(run);
|
|
174
|
+
const nextAction = stopTarget === "merge_completed" ? "start_merge" : "code_review_completed";
|
|
152
175
|
const now = context.now();
|
|
153
176
|
const startedAt = stageStartedAt(run, now);
|
|
154
|
-
writeProtocolFlowStatus(run.workspace_root, home, run.id, "CODE-REVIEW complete; this RUN reached its configured terminal boundary.");
|
|
155
|
-
const report = { schema_id: "dd-flow/stage-report@1", run_id: run.id, stage, generated_at: now, verdict: "done", semantic: { result:
|
|
177
|
+
writeProtocolFlowStatus(run.workspace_root, home, run.id, nextAction === "start_merge" ? "CODE-REVIEW complete; MERGE is queued." : "CODE-REVIEW complete; this RUN reached its configured terminal boundary.");
|
|
178
|
+
const report = { schema_id: "dd-flow/stage-report@1", run_id: run.id, stage, generated_at: now, verdict: "done", semantic: { result: `Independent CODE review completed: ${reviewers.length} reviewer Work(s), ${repairs.length} repair Work(s).`, 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: nextAction, 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" } };
|
|
156
179
|
writeReport(root, report);
|
|
157
180
|
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" });
|
|
158
|
-
await finishWork(context, rootWork.work_id, JSON.stringify(report, null, 2));
|
|
159
181
|
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "code_review_completed", work_id: rootWork.work_id, outcome });
|
|
160
|
-
|
|
182
|
+
if (nextAction === "start_merge")
|
|
183
|
+
advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "code_review_completed", nextAction });
|
|
184
|
+
else {
|
|
185
|
+
await finishWork(context, rootWork.work_id, JSON.stringify(report, null, 2));
|
|
186
|
+
completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: "code_review_completed", nextAction: undefined });
|
|
187
|
+
}
|
|
161
188
|
refreshRunWorkProjection(context, project.id, run.id);
|
|
162
|
-
|
|
189
|
+
const mergeRequest = nextAction === "start_merge" ? ensureVnextMergeRequest(context, { projectRoot, runId: run.id }) : null;
|
|
190
|
+
return { ok: true, run_id: run.id, stage, outcome, report_path: path.join(root, "stage-report.json"), next_action: nextAction, ...(mergeRequest ? { merge_request: mergeRequest, next: { kind: "start_stage", stage: "merge", command: `${flowCommand(context)} stage start ${run.id} --stage merge --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl` } } : {}) };
|
|
163
191
|
}
|
|
164
192
|
function reviewGroups(run, home, mode) {
|
|
165
193
|
const aspectMapFiles = findFiles(path.join(home, "03-plan"), "aspect-map.json");
|
|
@@ -175,7 +203,7 @@ function reviewGroups(run, home, mode) {
|
|
|
175
203
|
return Array.from({ length: Math.ceil(all.length / chunk) }, (_, index) => ({ key: `group-${index + 1}`, aspect_ids: all.slice(index * chunk, (index + 1) * chunk) }));
|
|
176
204
|
}
|
|
177
205
|
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.`; }
|
|
178
|
-
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@
|
|
206
|
+
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"); const checks = acceptedCodeChecks(context, input.run.project_id, input.run.id).map(({ id, purpose }) => ({ id, purpose })); 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. For every disposition fix, check_refs is mandatory: choose the one or more causal checks from the accepted list below that the repair must rerun. Do not guess a CHK id or copy every check. The first successful Finish freezes this decision and creates one repair Work when needed. Run it, then call the same Finish command again. Review is not repeated.", "If the post-repair aggregate gate fails, do not stop after `code_review_gate_failed`: that rejected finish does not create a repair Work. In the same coordinator Turn, use the returned repair command with the failed receipt, a relevant completed origin Work ID, and a concise repair objective; then stop so the runner can dispatch the new repair. Do not edit invisibly in the root orchestrator.", `Accepted repair checks: ${JSON.stringify(checks)}`, "```json", JSON.stringify({ schema_id: "dd-flow/code-review-decision@3", summary: "Evidence-backed conclusion.", findings: [{ finding_ref: "WRK-001-review/FIND-001", disposition: "fix | defer | reject | duplicate", reason: "Why this classification is correct.", check_refs: ["CHK-CAUSAL-CHECK only when disposition is fix"], 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"); }
|
|
179
207
|
function canonicalReviewEvidence(decision, reviewers) {
|
|
180
208
|
const known = new Set(canonicalCodeFindings(reviewers).map((item) => item.finding_ref));
|
|
181
209
|
const items = new Map(decision.findings.map((item) => [item.finding_ref, item]));
|
|
@@ -212,6 +240,8 @@ export function reviewDecisionForRepair(decision, reviewers) {
|
|
|
212
240
|
if (["p0", "p1"].includes(finding.priority) && item.disposition !== "fix") {
|
|
213
241
|
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 });
|
|
214
242
|
}
|
|
243
|
+
if (item.disposition === "fix" && !item.check_refs?.length)
|
|
244
|
+
throw new AppError("review_repair_check_required", "Each accepted CODE-REVIEW repair needs an explicit causal CHK-* check reference", 2, { finding_ref: findingRef });
|
|
215
245
|
}
|
|
216
246
|
return { ...canonical, fix_ids: canonical.decision.findings.filter((item) => item.disposition === "fix").map((item) => item.finding_ref) };
|
|
217
247
|
}
|
|
@@ -219,15 +249,24 @@ function validateDecision(context, input) {
|
|
|
219
249
|
const findings = canonicalCodeFindings(input.reviewers);
|
|
220
250
|
const known = new Map(findings.map((item) => [item.finding_ref, item.finding]));
|
|
221
251
|
const decisions = new Map(input.decision.findings.map((item) => [item.finding_ref, item]));
|
|
222
|
-
const
|
|
252
|
+
const repairedBy = new Map();
|
|
253
|
+
for (const work of input.repairs) {
|
|
254
|
+
const result = readJsonString(work.result);
|
|
255
|
+
for (const ref of Array.isArray(result.resolved_finding_refs) ? result.resolved_finding_refs.filter((value) => typeof value === "string") : [])
|
|
256
|
+
repairedBy.set(ref, [...(repairedBy.get(ref) ?? []), work]);
|
|
257
|
+
}
|
|
223
258
|
for (const { finding_ref: findingRef, finding } of findings) {
|
|
224
259
|
const item = decisions.get(findingRef);
|
|
225
260
|
if (!item)
|
|
226
261
|
throw new AppError("review_evidence_invalid", "Every material CODE review finding needs one decision", 2, { finding_ref: findingRef });
|
|
227
|
-
|
|
262
|
+
const repaired = repairedBy.get(findingRef) ?? [];
|
|
263
|
+
if (["p0", "p1"].includes(finding.priority) && !(item.disposition === "fix" && repaired.length))
|
|
228
264
|
throw new AppError("review_unresolved_material", "P0/P1 findings must be explicitly resolved by a completed clean repair Work", 2, { finding_ref: findingRef });
|
|
229
|
-
if (item.disposition === "fix" && !repaired.
|
|
265
|
+
if (item.disposition === "fix" && !repaired.length)
|
|
230
266
|
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 });
|
|
267
|
+
const requiredChecks = item.check_refs ?? [];
|
|
268
|
+
if (item.disposition === "fix" && !repaired.some((work) => requiredChecks.every((ref) => reviewCheckRefs(work).includes(ref))))
|
|
269
|
+
throw new AppError("review_repair_check_missing", "A fixed CODE-REVIEW finding needs a repair Work that retains every selected causal check", 2, { finding_ref: findingRef, check_refs: requiredChecks });
|
|
231
270
|
if (finding.priority === "p2" && item.disposition === "defer") {
|
|
232
271
|
if (!item.def_id || !fs.existsSync(path.join(input.workspaceRoot, ".memory-bank", "defs", `${item.def_id}.md`)))
|
|
233
272
|
throw new AppError("deferral_invalid", "A deferred P2 requires a named durable DEF", 2, { finding_ref: findingRef, def_id: item.def_id ?? null });
|
|
@@ -242,7 +281,17 @@ function validateDecision(context, input) {
|
|
|
242
281
|
function validateReviewerResult(context, work, run) { if (!work.result)
|
|
243
282
|
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 }); }
|
|
244
283
|
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"); }); }
|
|
245
|
-
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@
|
|
284
|
+
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); }
|
|
285
|
+
function acceptedCodeChecks(context, projectId, runId) { return codeWorks(context, projectId, runId).flatMap((work) => { const payload = readJsonString(work.payload_json); return Array.isArray(payload.checks) ? payload.checks : []; }); }
|
|
286
|
+
function validateRepairChecks(context, input) { const known = new Set(acceptedCodeChecks(context, input.projectId, input.runId).map((check) => check.id)); const selected = {}; for (const item of input.decision.findings.filter((item) => item.disposition === "fix")) {
|
|
287
|
+
const refs = [...new Set(item.check_refs ?? [])];
|
|
288
|
+
if (!refs.length)
|
|
289
|
+
throw new AppError("review_repair_check_required", "Each accepted CODE-REVIEW repair needs an explicit causal CHK-* check reference", 2, { finding_ref: item.finding_ref });
|
|
290
|
+
const unknown = refs.filter((ref) => !known.has(ref));
|
|
291
|
+
if (unknown.length)
|
|
292
|
+
throw new AppError("review_check_reference_unknown", "CODE-REVIEW repair selects a check absent from the accepted CODE handoff", 2, { finding_ref: item.finding_ref, check_refs: unknown });
|
|
293
|
+
selected[item.finding_ref] = refs;
|
|
294
|
+
} return selected; }
|
|
246
295
|
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)); }
|
|
247
296
|
export function isCodeReviewStageRepair(work, root) {
|
|
248
297
|
if (reviewFindingIds(work).length)
|
|
@@ -258,8 +307,11 @@ export function isCodeReviewStageRepair(work, root) {
|
|
|
258
307
|
}
|
|
259
308
|
function reviewFindingIds(work) { const repair = readJsonString(work.payload_json).repair; if (!repair || typeof repair !== "object" || Array.isArray(repair))
|
|
260
309
|
return []; const ids = repair.review_finding_ids; return Array.isArray(ids) ? ids.filter((value) => typeof value === "string") : []; }
|
|
310
|
+
function reviewCheckRefs(work) { const repair = readJsonString(work.payload_json).repair; if (!repair || typeof repair !== "object" || Array.isArray(repair))
|
|
311
|
+
return []; const refs = repair.review_check_refs; return Array.isArray(refs) ? refs.filter((value) => typeof value === "string") : []; }
|
|
261
312
|
function canonicalCodeFindings(works) { return works.flatMap((work) => readJsonString(work.result).findings.map((finding) => ({ finding_ref: `${work.work_id}/${finding.finding_id}`, finding }))); }
|
|
262
313
|
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; }
|
|
314
|
+
function executionStopTarget(run) { return JSON.parse(run.index_json).execution_profile?.settings?.stop_target ?? "code_review_completed"; }
|
|
263
315
|
function changedPaths(workspaceRoot) { try {
|
|
264
316
|
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(/^.* -> /, ""));
|
|
265
317
|
}
|
|
@@ -267,7 +319,6 @@ catch {
|
|
|
267
319
|
return [];
|
|
268
320
|
} }
|
|
269
321
|
function sha256File(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
|
|
270
|
-
function uniqueStrings(values) { return [...new Set(values)]; }
|
|
271
322
|
function stageStatus(run, name) { try {
|
|
272
323
|
return JSON.parse(run.index_json).stage_runs?.find((item) => item.stage === name)?.status ?? null;
|
|
273
324
|
}
|
|
@@ -4,7 +4,7 @@ import fs from "node:fs";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { AppError } from "../shared/errors.js";
|
|
6
6
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
7
|
-
import { aggregateCheckDeclarations, checkReceipts, codeExecutionEnvironment, finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures } from "./code-checks.js";
|
|
7
|
+
import { aggregateCheckDeclarations, checkReceipts, codeExecutionEnvironment, finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures, workspaceFingerprint } from "./code-checks.js";
|
|
8
8
|
import { requireProjectByRoot } from "./projects.js";
|
|
9
9
|
import { appendFlowRunTimelineEvent, advanceFlowRun, attachFlowRunStage, completeFlowRunStage, completeFlowRun, getFlowRunVariables, gitFacts } from "./runs.js";
|
|
10
10
|
import { validateSchema } from "./schema-validation.js";
|
|
@@ -15,6 +15,7 @@ import { readVnextSpecifyResult } from "./vnext-specify.js";
|
|
|
15
15
|
import { addWorkBatch, bindStageCoordinatorWork, codeWorkGraph, finishWork, refreshRunWorkProjection, validateWorkBatchFile, workStartCommand } from "./work-registry.js";
|
|
16
16
|
import { vnextStageDirectory } from "../domain/stage-catalog.js";
|
|
17
17
|
import { writeStageReport } from "./stage-report-renderer.js";
|
|
18
|
+
import { ensureVnextMergeRequest } from "./vnext-merge.js";
|
|
18
19
|
import { assertStageStartHookEvent } from "./hooks.js";
|
|
19
20
|
import { applyExternalStageContext } from "./stage-context.js";
|
|
20
21
|
import { readFanoutDescriptor, writeFanoutDescriptor } from "./vnext-fanout.js";
|
|
@@ -141,16 +142,27 @@ export async function finishVnextCode(context, input) {
|
|
|
141
142
|
const unchangedFailures = unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, declarations: checks });
|
|
142
143
|
if (unchangedFailures.length)
|
|
143
144
|
throw new AppError("code_gate_repair_required", "CODE 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 });
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
145
|
+
let receipts = reusableAggregateGateReceipts(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, checks });
|
|
146
|
+
if (!receipts) {
|
|
147
|
+
const release = acquireAggregateGateLock(root);
|
|
148
|
+
try {
|
|
149
|
+
// A prior caller may have completed the slow gate while this caller was
|
|
150
|
+
// waiting to acquire the lock. Reuse that immutable evidence.
|
|
151
|
+
receipts = reusableAggregateGateReceipts(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, checks }) ?? await runCodeChecks(context, {
|
|
152
|
+
projectId: project.id,
|
|
153
|
+
runId: run.id,
|
|
154
|
+
runHome: home,
|
|
155
|
+
workspaceRoot: run.workspace_root,
|
|
156
|
+
scope: "aggregate",
|
|
157
|
+
checks,
|
|
158
|
+
...(input.progress ? { progress: input.progress } : {})
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
release();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const failed = receipts.filter((receipt) => receipt.status !== "passed");
|
|
154
166
|
if (failed.length) {
|
|
155
167
|
throw new AppError("code_gate_failed", "CODE remains running because the aggregate project gate failed", 2, {
|
|
156
168
|
failures: failed,
|
|
@@ -166,7 +178,7 @@ export async function finishVnextCode(context, input) {
|
|
|
166
178
|
const timing = stageTiming(home, stage, now);
|
|
167
179
|
const changedPaths = [...new Set(works.flatMap((work) => resultPaths(work.result)))];
|
|
168
180
|
const next = nextAction(run, changedPaths);
|
|
169
|
-
writeProtocolFlowStatus(run.workspace_root, home, run.id, next === "start_code_review" ? "CODE complete; CODE-REVIEW is next." : "CODE complete; this RUN reached its configured terminal boundary.");
|
|
181
|
+
writeProtocolFlowStatus(run.workspace_root, home, run.id, next === "start_code_review" ? "CODE complete; CODE-REVIEW is next." : next === "start_merge" ? "CODE complete; MERGE is queued." : "CODE complete; this RUN reached its configured terminal boundary.");
|
|
170
182
|
const report = {
|
|
171
183
|
schema_id: "dd-flow/stage-report@1",
|
|
172
184
|
run_id: run.id,
|
|
@@ -223,8 +235,9 @@ export async function finishVnextCode(context, input) {
|
|
|
223
235
|
completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: "code_completed", nextAction: undefined });
|
|
224
236
|
}
|
|
225
237
|
else {
|
|
226
|
-
advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "code_completed", nextAction:
|
|
238
|
+
advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "code_completed", nextAction: next });
|
|
227
239
|
}
|
|
240
|
+
const mergeRequest = next === "start_merge" ? ensureVnextMergeRequest(context, { projectRoot, runId: run.id }) : null;
|
|
228
241
|
return {
|
|
229
242
|
ok: true,
|
|
230
243
|
run_id: run.id,
|
|
@@ -232,7 +245,7 @@ export async function finishVnextCode(context, input) {
|
|
|
232
245
|
outcome: "accepted",
|
|
233
246
|
report_path: path.join(root, "stage-report.json"),
|
|
234
247
|
next_action: next,
|
|
235
|
-
...(next === "start_code_review" ? { next: { kind: "start_stage", stage: "code-review", command: `${flowCommand(context)} stage start ${run.id} --stage code-review --project-root ${JSON.stringify(projectRoot)} --json` } } : {})
|
|
248
|
+
...(next === "start_code_review" ? { next: { kind: "start_stage", stage: "code-review", command: `${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: `${flowCommand(context)} stage start ${run.id} --stage merge --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl` } } : {})
|
|
236
249
|
};
|
|
237
250
|
}
|
|
238
251
|
export function addVnextCodeRepair(context, input) {
|
|
@@ -271,14 +284,14 @@ export function addVnextCodeRepair(context, input) {
|
|
|
271
284
|
const key = input.reviewFindingIds?.length ? "code-review-repair" : "code-gate-repair";
|
|
272
285
|
const receiptWriteScope = receipt ? receiptRepairPaths(run.workspace_root, receipt) : [];
|
|
273
286
|
const repair = {
|
|
274
|
-
schema_id: "dd-flow/code-work-packet@
|
|
287
|
+
schema_id: "dd-flow/code-work-packet@5",
|
|
275
288
|
key,
|
|
276
289
|
launch_policy: "fresh_agent_required",
|
|
277
290
|
source: first.source,
|
|
278
291
|
repair: {
|
|
279
292
|
origin_work_ids: origins.map((work) => work.work_id),
|
|
280
293
|
...(receipt ? { check_receipt_id: receipt.id, failure_receipt_path: receipt.receipt_path } : {}),
|
|
281
|
-
...(input.reviewFindingIds?.length ? { review_finding_ids: unique(input.reviewFindingIds), review_evidence_refs: unique(input.reviewEvidenceRefs ?? []) } : {})
|
|
294
|
+
...(input.reviewFindingIds?.length ? { review_finding_ids: unique(input.reviewFindingIds), review_evidence_refs: unique(input.reviewEvidenceRefs ?? []), review_check_refs: unique(input.reviewCheckRefs ?? (input.reviewChecks ?? []).map((check) => check.id)) } : {})
|
|
282
295
|
},
|
|
283
296
|
task: input.objective,
|
|
284
297
|
semantic_spine: {
|
|
@@ -290,22 +303,22 @@ export function addVnextCodeRepair(context, input) {
|
|
|
290
303
|
},
|
|
291
304
|
requirements: uniqueBy(invariantPackets.flatMap((value) => value.requirements), (value) => value.id),
|
|
292
305
|
acceptance: uniqueBy(invariantPackets.flatMap((value) => value.acceptance), (value) => JSON.stringify(value)),
|
|
293
|
-
|
|
306
|
+
// A CODE-REVIEW repair changes delivered code/evidence, never the
|
|
307
|
+
// already accepted PLAN or its ownership declaration.
|
|
308
|
+
document_updates: [],
|
|
294
309
|
required_read: unique([...(receipt ? [receipt.receipt_path] : input.reviewEvidenceRefs ?? []), ...packets.flatMap((value) => value.required_read)]),
|
|
295
310
|
discovery_boundary: unique(packets.flatMap((value) => value.discovery_boundary)),
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
|
|
299
|
-
// never infer a directory or accept a path outside the workspace.
|
|
300
|
-
write_scope: unique([...packets.flatMap((value) => value.write_scope), ...input.writeScope, ...receiptWriteScope]),
|
|
311
|
+
// These are collision-avoidance hints. Receipt paths enrich the coordinator
|
|
312
|
+
// picture but never restrict the repair's project-local edits.
|
|
313
|
+
planned_write_areas: unique([...packets.flatMap((value) => value.planned_write_areas), ...receiptWriteScope]),
|
|
301
314
|
// Receipts record the resolved shell command. A repair must retain the
|
|
302
315
|
// accepted declaration (including its immutable @check alias) and only
|
|
303
316
|
// change when it runs, so work finish can validate it again.
|
|
304
|
-
checks:
|
|
317
|
+
checks: selectRepairChecks({ ...(failedCheck ? { failedCheck } : {}), ...(input.reviewChecks ? { reviewChecks: input.reviewChecks } : {}) }),
|
|
305
318
|
provides_checks: [],
|
|
306
319
|
stop_conditions: unique([
|
|
307
320
|
...invariantPackets.flatMap((value) => value.stop_conditions),
|
|
308
|
-
...(receipt ? ["Stop and report the blocker if the
|
|
321
|
+
...(receipt ? ["Stop and report the blocker if the proposed repair contradicts an accepted requirement or non-goal."] : ["Do not modify accepted PLAN artifacts, code-work-batch.json, or review decisions. Stop and report if the finding cannot be repaired within accepted scope."])
|
|
309
322
|
]),
|
|
310
323
|
depends_on: origins.map((work) => work.work_id),
|
|
311
324
|
result_schema: "dd-flow/code-work-result@2"
|
|
@@ -331,6 +344,13 @@ export function addVnextCodeRepair(context, input) {
|
|
|
331
344
|
refreshRunWorkProjection(context, project.id, run.id);
|
|
332
345
|
return { ok: true, run_id: run.id, repair_work_id: id, start_command: workStartCommand(context, work) };
|
|
333
346
|
}
|
|
347
|
+
/** The repair Work runs only the check that motivated it; the stage retains the aggregate gate. */
|
|
348
|
+
export function selectRepairChecks(input) {
|
|
349
|
+
const checks = input.failedCheck
|
|
350
|
+
? [{ ...input.failedCheck, purpose: "Re-run the failed accepted check.", run_at: "work" }]
|
|
351
|
+
: (input.reviewChecks ?? []).map((check) => ({ ...check, purpose: `Prove the CODE-REVIEW repair: ${check.purpose}`, run_at: "work" }));
|
|
352
|
+
return uniqueBy(checks, (check) => check.id);
|
|
353
|
+
}
|
|
334
354
|
function receiptRepairPaths(workspaceRoot, receipt) {
|
|
335
355
|
const root = path.resolve(workspaceRoot);
|
|
336
356
|
const text = [receipt.stdout_path, receipt.stderr_path]
|
|
@@ -382,10 +402,10 @@ function coordinatorPrompt(context, input) {
|
|
|
382
402
|
"",
|
|
383
403
|
"<execution_commands>",
|
|
384
404
|
"Launch only entries listed in graph.ready. Use at most the known available slot count. Every registered CODE Work runs in a fresh child Session, including a serial dependency chain; the coordinator owns dispatch and the stage conclusion, not implementation Work. Every child starts with its exact start_command and receives its complete packet from dd-flow. After a Work finishes, use 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`,
|
|
385
|
-
"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. Close a disposable child only after its Work is accepted or explicitly failed/cancelled and the harness reports the turn settled.",
|
|
405
|
+
"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: do not 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.",
|
|
386
406
|
`A repairable engine, harness, or environment failure is not a user question. Record it without finishing CODE: ${flowCommand(context)} stage block ${input.run.id} --stage code --work ${input.rootWork.work_id} --kind <engine|harness|environment> --code <stable-code> --summary-stdin --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.`,
|
|
387
407
|
`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"))}`,
|
|
388
|
-
"If the aggregate gate fails,
|
|
408
|
+
"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, use its returned repair command with the relevant completed origin Work IDs and a concise repair objective, then stop so the runner can dispatch the newly declared repair. Do not edit invisibly in the root orchestrator.",
|
|
389
409
|
"</execution_commands>",
|
|
390
410
|
"",
|
|
391
411
|
"<verification_contract>",
|
|
@@ -407,13 +427,77 @@ export function profileCommands(workspaceRoot) {
|
|
|
407
427
|
function latestReceiptsByCommand(receipts) {
|
|
408
428
|
const latest = new Map();
|
|
409
429
|
for (const receipt of receipts)
|
|
410
|
-
|
|
411
|
-
|
|
430
|
+
for (const ref of receipt.check_refs)
|
|
431
|
+
latest.set(ref, receipt);
|
|
432
|
+
return [...new Set(latest.values())];
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* A CODE finish may be retried after a client loses the long command's output.
|
|
436
|
+
* Reusing complete, same-workspace receipts is correct; rerunning them is both
|
|
437
|
+
* expensive and can race the still-settling original invocation.
|
|
438
|
+
*/
|
|
439
|
+
export function reusableAggregateGateReceipts(context, input) {
|
|
440
|
+
const expected = new Set(input.checks.map((check) => check.id));
|
|
441
|
+
const current = workspaceFingerprint(input.workspaceRoot);
|
|
442
|
+
const latest = new Map();
|
|
443
|
+
for (const receipt of checkReceipts(context, { projectId: input.projectId, runId: input.runId })) {
|
|
444
|
+
if (receipt.scope === "aggregate")
|
|
445
|
+
for (const ref of receipt.check_refs)
|
|
446
|
+
if (expected.has(ref))
|
|
447
|
+
latest.set(ref, receipt);
|
|
448
|
+
}
|
|
449
|
+
if (latest.size !== expected.size)
|
|
450
|
+
return null;
|
|
451
|
+
const receipts = input.checks.map((check) => latest.get(check.id));
|
|
452
|
+
return receipts.every((receipt) => receipt.status === "passed" && receipt.workspace_fingerprint === current) ? receipts : null;
|
|
453
|
+
}
|
|
454
|
+
/** One aggregate gate per RUN; a second invocation must wait, never duplicate it. */
|
|
455
|
+
function acquireAggregateGateLock(root) {
|
|
456
|
+
const directory = path.join(root, "checks");
|
|
457
|
+
const lock = path.join(directory, ".aggregate-gate.lock");
|
|
458
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
459
|
+
try {
|
|
460
|
+
const descriptor = fs.openSync(lock, "wx");
|
|
461
|
+
fs.writeFileSync(descriptor, JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }));
|
|
462
|
+
fs.closeSync(descriptor);
|
|
463
|
+
}
|
|
464
|
+
catch (error) {
|
|
465
|
+
if (!isExistingLock(error))
|
|
466
|
+
throw error;
|
|
467
|
+
const activePid = lockPid(lock);
|
|
468
|
+
if (activePid && processAlive(activePid)) {
|
|
469
|
+
throw new AppError("code_gate_in_progress", "CODE aggregate gate is already running; wait for that exact stage finish command to return", 1, { pid: activePid, lock });
|
|
470
|
+
}
|
|
471
|
+
fs.rmSync(lock, { force: true });
|
|
472
|
+
return acquireAggregateGateLock(root);
|
|
473
|
+
}
|
|
474
|
+
return () => fs.rmSync(lock, { force: true });
|
|
475
|
+
}
|
|
476
|
+
function isExistingLock(error) {
|
|
477
|
+
return Boolean(error && typeof error === "object" && error.code === "EEXIST");
|
|
478
|
+
}
|
|
479
|
+
function lockPid(lock) {
|
|
480
|
+
try {
|
|
481
|
+
const parsed = JSON.parse(fs.readFileSync(lock, "utf8"));
|
|
482
|
+
return Number.isInteger(parsed.pid) && Number(parsed.pid) > 0 ? Number(parsed.pid) : null;
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
function processAlive(pid) {
|
|
489
|
+
try {
|
|
490
|
+
process.kill(pid, 0);
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
412
496
|
}
|
|
413
|
-
function verificationProjection(works, receipts) {
|
|
497
|
+
export function verificationProjection(works, receipts) {
|
|
414
498
|
const packets = works.map((work) => packet(work)).filter((value) => value !== null);
|
|
415
499
|
const declarations = uniqueBy(packets.flatMap((value) => value.checks), (value) => value.id);
|
|
416
|
-
const receiptByDeclaration = new Map(receipts.
|
|
500
|
+
const receiptByDeclaration = new Map(receipts.flatMap((receipt) => receipt.check_refs.map((ref) => [ref, receipt])));
|
|
417
501
|
const evidence = new Map();
|
|
418
502
|
for (const work of works) {
|
|
419
503
|
const result = readJsonResult(work.result);
|
|
@@ -422,9 +506,19 @@ function verificationProjection(works, receipts) {
|
|
|
422
506
|
evidence.set(item.criterion_id, unique([...(evidence.get(item.criterion_id) ?? []), ...(item.refs ?? [])]));
|
|
423
507
|
}
|
|
424
508
|
const acceptance = uniqueBy(packets.flatMap((value) => value.acceptance), (value) => value.criterion_id);
|
|
509
|
+
const hasEvidence = (criterionId) => (evidence.get(criterionId)?.length ?? 0) > 0;
|
|
510
|
+
const isPassed = (checkId, criterionId) => receiptByDeclaration.get(checkId)?.status === "passed" || (declarations.find((check) => check.id === checkId)?.run_at === "external" && hasEvidence(criterionId));
|
|
425
511
|
return {
|
|
426
|
-
checks: declarations.map((check) => {
|
|
427
|
-
|
|
512
|
+
checks: declarations.map((check) => {
|
|
513
|
+
const receipt = receiptByDeclaration.get(check.id);
|
|
514
|
+
const externallyEvidenced = check.run_at === "external" && acceptance.some((item) => item.check_refs.includes(check.id) && hasEvidence(item.criterion_id));
|
|
515
|
+
return { id: check.id, run_at: check.run_at, status: receipt?.status ?? (externallyEvidenced ? "evidence_recorded" : ["work", "code", "readiness"].includes(check.run_at) ? "failed" : "not_due"), receipt_ref: receipt?.receipt_path ?? null };
|
|
516
|
+
}),
|
|
517
|
+
acceptance: acceptance.map((item) => {
|
|
518
|
+
const external = item.check_refs.some((id) => declarations.find((check) => check.id === id)?.run_at === "external");
|
|
519
|
+
const confirmed = item.check_refs.every((id) => isPassed(id, item.criterion_id)) && hasEvidence(item.criterion_id);
|
|
520
|
+
return { criterion_id: item.criterion_id, gate: item.gate, status: confirmed ? (external ? "confirmed_with_external_evidence" : "confirmed") : ["work", "code", "readiness"].includes(item.gate) ? "unresolved" : "not_due", check_refs: item.check_refs, evidence_refs: evidence.get(item.criterion_id) ?? [] };
|
|
521
|
+
})
|
|
428
522
|
};
|
|
429
523
|
}
|
|
430
524
|
function readJsonResult(value) { try {
|
|
@@ -454,7 +548,7 @@ function packet(work) {
|
|
|
454
548
|
return null;
|
|
455
549
|
try {
|
|
456
550
|
const value = JSON.parse(work.payload_json);
|
|
457
|
-
return value.schema_id === "dd-flow/code-work-packet@
|
|
551
|
+
return value.schema_id === "dd-flow/code-work-packet@5" ? value : null;
|
|
458
552
|
}
|
|
459
553
|
catch {
|
|
460
554
|
return null;
|
|
@@ -570,7 +664,10 @@ function nextAction(run, changedPaths) {
|
|
|
570
664
|
const index = JSON.parse(run.index_json);
|
|
571
665
|
const requested = index.settings?.code_review?.mode ?? "auto";
|
|
572
666
|
const enabled = requested === "off" ? false : requested === "auto" ? changedPaths.length > 0 : true;
|
|
573
|
-
|
|
667
|
+
const stopTarget = index.execution_profile?.settings?.stop_target;
|
|
668
|
+
if (enabled && (stopTarget === "code_review_completed" || stopTarget === "merge_completed"))
|
|
669
|
+
return "start_code_review";
|
|
670
|
+
return stopTarget === "merge_completed" ? "start_merge" : "code_completed";
|
|
574
671
|
}
|
|
575
672
|
function stageStatus(run, name) {
|
|
576
673
|
try {
|
|
@@ -13,14 +13,17 @@ export function loadVnextExecutionProfile(projectRoot) {
|
|
|
13
13
|
throw new AppError("execution_profile_missing", "vNext flow requires .memory-bank/dd-flow/project-execution.json", 1, { file, cause: String(error) });
|
|
14
14
|
}
|
|
15
15
|
const profile = value;
|
|
16
|
-
if (profile.schema_id !== "dd-flow/project-execution@
|
|
16
|
+
if (profile.schema_id !== "dd-flow/project-execution@2"
|
|
17
17
|
|| (profile.stage_session_mode !== undefined && profile.stage_session_mode !== "same_session" && profile.stage_session_mode !== "new_session")
|
|
18
18
|
|| !["auto", "off", "standard", "deep"].includes(String(profile.plan_review_mode))
|
|
19
19
|
|| (profile.code_review_mode !== undefined && !["auto", "off", "standard", "deep"].includes(String(profile.code_review_mode)))
|
|
20
|
-
|| !["
|
|
20
|
+
|| !["same_session", "server"].includes(String(profile.merge_mode))
|
|
21
|
+
|| profile.merge_delivery?.strategy !== "local"
|
|
22
|
+
|| !["retain", "delete_after_success"].includes(String(profile.merge_cleanup?.source))
|
|
23
|
+
|| !["code_completed", "code_review_completed", "merge_completed"].includes(String(profile.stop_target))
|
|
21
24
|
|| !profile.code_bootstrap || typeof profile.code_bootstrap.command !== "string" || !profile.code_bootstrap.command.trim()
|
|
22
25
|
|| typeof profile.code_bootstrap.policy_ref !== "string" || !profile.code_bootstrap.policy_ref.trim()) {
|
|
23
|
-
throw new AppError("execution_profile_invalid", "Project execution profile does not match dd-flow/project-execution@
|
|
26
|
+
throw new AppError("execution_profile_invalid", "Project execution profile does not match dd-flow/project-execution@2", 1, { file });
|
|
24
27
|
}
|
|
25
28
|
return { ...profile, stage_session_mode: profile.stage_session_mode ?? "same_session", code_review_mode: profile.code_review_mode ?? "off" };
|
|
26
29
|
}
|