@deksden-com/dd-flow-cli 0.9.0-beta.64 → 0.9.0-beta.74

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 (52) hide show
  1. package/dist/build-info.json +3 -3
  2. package/dist/cli/help.js +2 -0
  3. package/dist/cli/run-cli.js +15 -0
  4. package/dist/harness-runtime/bin/dd-agy.mjs +4 -2
  5. package/dist/harness-runtime/bin/dd-codex.mjs +4 -2
  6. package/dist/harness-runtime/bin/dd-droid.mjs +4 -2
  7. package/dist/harness-runtime/bin/dd-grok.mjs +4 -2
  8. package/dist/harness-runtime/bin/dd-opencode.mjs +4 -2
  9. package/dist/harness-runtime/bin/dd-zcode.mjs +15 -7
  10. package/dist/harness-runtime/lib/daemon-operations.mjs +108 -10
  11. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +5 -4
  12. package/dist/harness-runtime/lib/dd-codex.mjs +38 -4
  13. package/dist/harness-runtime/lib/dd-droid.mjs +55 -20
  14. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +3 -2
  15. package/dist/harness-runtime/lib/dd-grok.mjs +14 -8
  16. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +43 -8
  17. package/dist/harness-runtime/lib/dd-opencode.mjs +5 -4
  18. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +182 -16
  19. package/dist/harness-runtime/lib/dd-zcode.mjs +162 -63
  20. package/dist/harness-runtime/lib/driver-recovery.mjs +2 -2
  21. package/dist/harness-runtime/lib/managed-daemon.mjs +27 -5
  22. package/dist/harness-runtime/lib/operation-errors.mjs +1 -1
  23. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  24. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  25. package/dist/services/code-checks.js +120 -19
  26. package/dist/services/controller-fanout.js +11 -0
  27. package/dist/services/engines.js +40 -9
  28. package/dist/services/eval-snapshots.js +30 -47
  29. package/dist/services/harness-adapter.js +12 -6
  30. package/dist/services/lifecycle-invocations.js +12 -8
  31. package/dist/services/native-session-control.js +6 -0
  32. package/dist/services/projects.js +6 -2
  33. package/dist/services/recovery-observation-budget.js +9 -1
  34. package/dist/services/run-control-worker.js +19 -0
  35. package/dist/services/run-control.js +87 -13
  36. package/dist/services/run-controller-adapter.js +134 -7
  37. package/dist/services/run-controller.js +11 -3
  38. package/dist/services/run-engine-bindings.js +1 -1
  39. package/dist/services/run-fork.js +124 -0
  40. package/dist/services/run-observations.js +112 -0
  41. package/dist/services/runs.js +1 -11
  42. package/dist/services/schema-validation.js +1 -1
  43. package/dist/services/stage-lifecycle.js +14 -2
  44. package/dist/services/usage.js +20 -87
  45. package/dist/services/vnext-code-review.js +35 -27
  46. package/dist/services/vnext-code.js +62 -26
  47. package/dist/services/vnext-merge.js +8 -8
  48. package/dist/services/vnext-plan-review.js +16 -16
  49. package/dist/services/vnext-plan.js +51 -11
  50. package/dist/services/work-registry.js +48 -27
  51. package/dist/shared/errors.js +12 -0
  52. package/package.json +1 -1
@@ -2,7 +2,6 @@ import { managedLifecycleCommand } from "./lifecycle-invocations.js";
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
- import { execFileSync } from "node:child_process";
6
5
  import { AppError } from "../shared/errors.js";
7
6
  import { resolveProjectRoot } from "../storage/paths.js";
8
7
  import { requireProjectByRoot } from "./projects.js";
@@ -12,9 +11,9 @@ import { flowCommand } from "./stage-pause.js";
12
11
  import { assertStageStartHookEvent } from "./hooks.js";
13
12
  import { requireVnextWorkspaceRoute } from "./vnext-workspace-policy.js";
14
13
  import { addVnextCodeRepair, resumeAcceptedCodeStage } from "./vnext-code.js";
15
- import { finalCodeCheckDeclarations, runCodeChecks, validateFailedCheckRetry, workspaceFingerprint } from "./code-checks.js";
14
+ import { finalCodeCheckDeclarations, runCodeChecks, validateFailedCheckRetry, workspaceChangedPaths } from "./code-checks.js";
16
15
  import { addWorkBatch, bindStageCoordinatorWork, finishStageCoordinatorWork, refreshRunWorkProjection } from "./work-registry.js";
17
- import { withStageSettlement, stageInputGuard } from "./runs.js";
16
+ import { withStageSettlement } from "./runs.js";
18
17
  import { vnextStageDirectory } from "../domain/stage-catalog.js";
19
18
  import { writeStageReport } from "./stage-report-renderer.js";
20
19
  import { applyExternalStageContext } from "./stage-context.js";
@@ -55,7 +54,7 @@ export function startVnextCodeReview(context, input) {
55
54
  freezeFlowRunReviewMode(context, { projectRoot, runId: run.id, review: "code", mode: mode.mode, source: mode.source, reason: mode.reason });
56
55
  const promptPath = path.join(root, "stage-prompt.md");
57
56
  const reviewContextPath = path.join(root, "review-context.json");
58
- fs.writeFileSync(reviewContextPath, `${JSON.stringify({ schema_id: "dd-flow/code-review-context@1", cycle, mode: mode.mode, mode_source: mode.source, mode_reason: mode.reason, workspace_fingerprint: workspaceFingerprint(run.workspace_root), code_report_sha256: sha256File(path.join(home, "05-code", "stage-report.json")) }, null, 2)}\n`);
57
+ fs.writeFileSync(reviewContextPath, `${JSON.stringify({ schema_id: "dd-flow/code-review-context@1", cycle, mode: mode.mode, mode_source: mode.source, mode_reason: mode.reason, ...(mode.mode !== "off" ? { read_only_input: { parent_work_id: rootWork.work_id } } : {}), code_report_sha256: sha256File(path.join(home, "05-code", "stage-report.json")) }, null, 2)}\n`);
59
58
  if (mode.mode === "off") {
60
59
  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`;
61
60
  fs.writeFileSync(promptPath, prompt);
@@ -120,23 +119,23 @@ export async function finishVnextCodeReview(context, input) {
120
119
  const run = requireRun(context, project.id, input.runId);
121
120
  const home = requireHome(run);
122
121
  const root = path.join(home, stageDir);
122
+ const generation = assertRunMutationAllowed(context, project.id, run.id);
123
+ const reportPath = path.join(root, "stage-report.json");
124
+ if (stageStatus(run, stage) === "done" && fs.existsSync(reportPath)) {
125
+ const report = readJson(reportPath);
126
+ const next = report.semantic?.next_action ?? "code_review_completed";
127
+ resumeAcceptedCodeStage(context, run, projectRoot, stage, report, next);
128
+ return { ok: true, resumed: true, run_id: run.id, stage, outcome: "accepted", report_path: reportPath, next_action: next };
129
+ }
123
130
  const reviewContext = readJson(path.join(root, "review-context.json"));
124
131
  const mode = reviewContext.mode;
125
132
  if (!mode)
126
133
  throw new AppError("runtime_missing", "CODE-REVIEW effective mode was not frozen at stage start", 1);
127
- const generation = assertRunMutationAllowed(context, project.id, run.id);
128
134
  if (reviewContext.code_report_sha256 !== sha256File(path.join(home, "05-code", "stage-report.json")))
129
135
  throw new AppError("code_review_input_changed", "Accepted CODE report changed after CODE-REVIEW start", 2);
130
136
  const handoffFailures = validateVnextCodeHandoff(context, { projectRoot, workspaceRoot: run.workspace_root, runId: run.id, home });
131
137
  if (handoffFailures.length)
132
138
  throw new AppError("validation", "CODE-REVIEW handoff no longer matches the accepted PLAN", 2, { errors: handoffFailures });
133
- const reportPath = path.join(root, "stage-report.json");
134
- if (stageStatus(run, stage) === "done" && fs.existsSync(reportPath)) {
135
- const report = readJson(reportPath);
136
- const next = report.semantic?.next_action ?? "code_review_completed";
137
- resumeAcceptedCodeStage(context, run, projectRoot, stage, report, next);
138
- return { ok: true, resumed: true, run_id: run.id, stage, outcome: "accepted", report_path: reportPath, next_action: next };
139
- }
140
139
  const rootWork = requireRootWork(context, project.id, run.id);
141
140
  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]);
142
141
  if (!coordinator || path.resolve(coordinator.result_path ?? "") !== path.resolve(reportPath))
@@ -144,8 +143,8 @@ export async function finishVnextCodeReview(context, input) {
144
143
  const decisionFile = input.decisionFile ?? path.join(root, "decision.json");
145
144
  if (!fs.existsSync(decisionFile) && mode === "off")
146
145
  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`);
147
- validateSchema({ schemaName: "code-review-decision", file: decisionFile, projectRoot: run.workspace_root, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: home });
148
- const decision = readJson(decisionFile);
146
+ const decision = readCodeReviewDecision(decisionFile, reviewContext.accepted_decision, Boolean(input.decisionFile));
147
+ validateSchema({ schemaName: "code-review-decision", file: decisionFile, data: decision, projectRoot: run.workspace_root, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: home });
149
148
  const cycle = reviewContext.cycle ?? "initial";
150
149
  const reviewers = reviewerWorks(context, project.id, run.id, cycle);
151
150
  if (mode !== "off") {
@@ -158,15 +157,18 @@ export async function finishVnextCodeReview(context, input) {
158
157
  const canonical = reviewDecisionForRepair(decision, reviewers);
159
158
  const checkRefsByFinding = validateRepairChecks(context, { projectId: project.id, runId: run.id, decision: canonical.decision });
160
159
  let repairs = reviewRepairWorks(context, project.id, run.id, root, cycle);
161
- const decisionSha = sha256File(decisionFile);
162
160
  const frozenShaFile = path.join(root, ".decision-sha256");
163
- if (fs.existsSync(frozenShaFile) && fs.readFileSync(frozenShaFile, "utf8").trim() !== decisionSha)
161
+ if (!reviewContext.accepted_decision && fs.existsSync(frozenShaFile) && fs.readFileSync(frozenShaFile, "utf8").trim() !== sha256File(decisionFile))
164
162
  throw new AppError("review_decision_changed", "The accepted CODE-REVIEW decision cannot change during repair", 2, { decision_file: decisionFile });
165
163
  const fixIds = canonical.fix_ids;
166
164
  if (fixIds.length && repairs.length === 0) {
165
+ // Accept the value once. Repairs consume this decision, not a mutable
166
+ // operator file. Reuse the existing context artifact and review cycle.
167
+ const contextFile = path.join(root, "review-context.json"), temporary = `${contextFile}.tmp-${crypto.randomUUID()}`;
168
+ fs.writeFileSync(temporary, `${JSON.stringify({ ...reviewContext, accepted_decision: decision }, null, 2)}\n`);
169
+ fs.renameSync(temporary, contextFile);
167
170
  const repair = addVnextCodeReviewRepair(context, { projectRoot, runId: run.id, findingIds: fixIds, checkRefsByFinding, decisionReasonsByFinding: Object.fromEntries(canonical.decision.findings.map((item) => [item.finding_ref, item.reason])), objective: `Resolve accepted CODE-REVIEW findings: ${fixIds.join(", ")}` });
168
- fs.writeFileSync(frozenShaFile, `${decisionSha}\n`);
169
- 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 next.finish_command with the unchanged decision file.", next: { finish_command: finishCommand(context, run.id, projectRoot, decisionFile) } };
171
+ return { ok: true, run_id: run.id, stage, outcome: "repair_required", repair, instruction: "Run the returned repair Work in one fresh child session. When it completes, invoke next.finish_command; this review cycle uses the decision already accepted by the runtime.", next: { finish_command: finishCommand(context, run.id, projectRoot, decisionFile) } };
170
172
  }
171
173
  repairs = reviewRepairWorks(context, project.id, run.id, root, cycle);
172
174
  const incompleteRepairs = repairs.filter((work) => work.status !== "completed");
@@ -175,7 +177,6 @@ export async function finishVnextCodeReview(context, input) {
175
177
  validateDecision(context, { decision: canonical.decision, reviewers: canonical.reviewers, repairs, workspaceRoot: run.workspace_root });
176
178
  }
177
179
  const outcome = decision.findings.some((item) => item.disposition === "defer") ? "accepted_with_DEF" : "accepted";
178
- const assertInputs = stageInputGuard(context, project.id, run.id, () => [workspaceFingerprint(run.workspace_root), sha256File(decisionFile), sha256File(path.join(home, "05-code", "stage-report.json"))]);
179
180
  const repairs = reviewRepairWorks(context, project.id, run.id, root, cycle);
180
181
  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 : []; }));
181
182
  if (input.retryCheckId) {
@@ -202,11 +203,15 @@ export async function finishVnextCodeReview(context, input) {
202
203
  // merge-gate error creates an irrecoverable `done` report with no MRG-*.
203
204
  const acceptedPaths = changedPaths(run.workspace_root);
204
205
  let mergeRequest = null;
205
- assertInputs();
206
+ assertRunMutationAllowed(context, project.id, run.id, generation);
206
207
  const prepareMerge = nextAction === "start_merge" ? prepareVnextMergeRequest(context, { projectRoot, runId: run.id, acceptedPaths }) : null;
207
208
  withStageSettlement(context, () => {
208
209
  assertRunMutationAllowed(context, project.id, run.id, generation);
209
- assertInputs();
210
+ const currentReviewers = reviewerWorks(context, project.id, run.id, cycle);
211
+ const currentRepairs = reviewRepairWorks(context, project.id, run.id, root, cycle);
212
+ if (currentReviewers.length !== reviewers.length || currentRepairs.length !== repairs.length || [...currentReviewers, ...currentRepairs].some(work => work.status !== "completed" || ![...reviewers, ...repairs].some(accepted => accepted.work_id === work.work_id && accepted.result === work.result))) {
213
+ throw new AppError("reviewer_jobs_incomplete", "Review or repair results changed while the final checks were running", 2);
214
+ }
210
215
  mergeRequest = prepareMerge?.() ?? null;
211
216
  writeReport(root, report);
212
217
  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" });
@@ -221,6 +226,14 @@ export async function finishVnextCodeReview(context, input) {
221
226
  });
222
227
  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: managedLifecycleCommand(context, `${flowCommand(context)} stage start ${run.id} --stage merge --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`) } } : {}) };
223
228
  }
229
+ export function readCodeReviewDecision(file, accepted, explicit = false) {
230
+ if (!accepted)
231
+ return readJson(file);
232
+ if (explicit && fs.existsSync(file) && JSON.stringify(readJson(file)) !== JSON.stringify(accepted)) {
233
+ throw new AppError("review_decision_changed", "This review cycle already accepted a different decision", 2, { decision_file: file });
234
+ }
235
+ return accepted;
236
+ }
224
237
  export function reviewGroups(home) {
225
238
  const expected = new Set(baselineAspects);
226
239
  const declared = [];
@@ -390,12 +403,7 @@ function effectiveMode(home, run) { const index = JSON.parse(run.index_json); if
390
403
  return { mode: "standard", source: "project_policy", reason: "A failed integration gate requires independent review of its source repair." }; const configured = index.settings?.code_review; const requested = configured?.mode ?? "auto"; if (requested !== "auto")
391
404
  return { mode: requested, source: configured?.source === "user_instruction" ? "user_instruction" : "project_policy", reason: configured?.reason ?? "Frozen RUN configuration." }; const assessments = findFiles(path.join(home, "03-plan"), "plan.json").map((file) => readJson(file)); const deep = assessments.some((plan) => plan.assessment?.failure_impact?.level === "high" || plan.assessment?.solution_uncertainty?.level === "high"); return { mode: deep ? "deep" : "standard", source: "plan_assessment", reason: deep ? "Accepted PLAN assessment records high impact or uncertainty." : "Accepted PLAN assessment requires the standard independent review." }; }
392
405
  function executionStopTarget(run) { return JSON.parse(run.index_json).execution_profile?.settings?.stop_target ?? "code_review_completed"; }
393
- function changedPaths(workspaceRoot) { try {
394
- 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(/^.* -> /, ""));
395
- }
396
- catch {
397
- return [];
398
- } }
406
+ function changedPaths(workspaceRoot) { return workspaceChangedPaths(workspaceRoot); }
399
407
  function sha256File(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
400
408
  function stageStatus(run, name) { try {
401
409
  return JSON.parse(run.index_json).stage_runs?.find((item) => item.stage === name)?.status ?? null;
@@ -1,7 +1,5 @@
1
1
  import { managedLifecycleCommand } from "./lifecycle-invocations.js";
2
2
  import crypto from "node:crypto";
3
- import { stageInputGuard } from "./runs.js";
4
- import { workspaceFingerprint } from "./code-checks.js";
5
3
  import { runWorkspaceBootstrap } from "./workspace-bootstrap.js";
6
4
  import fs from "node:fs";
7
5
  import path from "node:path";
@@ -25,6 +23,22 @@ import { readFanoutDescriptor, writeFanoutDescriptor } from "./vnext-fanout.js";
25
23
  import { assertRunMutationAllowed } from "./run-recovery.js";
26
24
  const stage = "code";
27
25
  const stageDir = vnextStageDirectory("code");
26
+ /** A controller prepares slow, deterministic project tooling before it asks an
27
+ * agent to enter CODE or a restored CODE_REVIEW. The subsequent lifecycle tool call remains the sole
28
+ * authority for attaching and binding the Stage. */
29
+ export async function prepareVnextCodeWorkspace(context, input) {
30
+ if (context.env.DD_FLOW_CODE_READINESS_OWNER !== input.ownerId) {
31
+ throw new AppError("code_readiness_owner_mismatch", "CODE readiness preparation must use its controller's frozen owner", 1, { owner_id: input.ownerId });
32
+ }
33
+ const projectRoot = resolveProjectRoot(input.projectRoot);
34
+ const project = requireProjectByRoot(context, projectRoot);
35
+ const run = requireRun(context, project.id, input.runId);
36
+ assertRunMutationAllowed(context, project.id, run.id);
37
+ requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: requireHome(run), workspaceRoot: run.workspace_root, stage: input.stage ?? stage });
38
+ const root = path.join(requireHome(run), stageDir);
39
+ fs.mkdirSync(root, { recursive: true });
40
+ return ensureCodeWorkspaceReady(context, project.id, run, root, [], input.progress, { force: input.stage !== "code-review" });
41
+ }
28
42
  export async function startVnextCode(context, input) {
29
43
  const projectRoot = resolveProjectRoot(input.projectRoot);
30
44
  const project = requireProjectByRoot(context, projectRoot);
@@ -145,7 +159,6 @@ export async function finishVnextCode(context, input) {
145
159
  throw new AppError("obligation_coverage_incomplete", "CODE graph does not cover every accepted obligation", 2, { missing });
146
160
  }
147
161
  // Reject a malformed semantic receipt before running the expensive gate.
148
- const assertInputs = stageInputGuard(context, project.id, run.id, () => [workspaceFingerprint(run.workspace_root), crypto.createHash("sha256").update(fs.readFileSync(input.verificationFile)).digest("hex")]);
149
162
  const verification = verificationForFinish(context, { file: input.verificationFile, projectRoot: run.workspace_root, runId: run.id, runRoot: home });
150
163
  if (verification.verdict === "blocked") {
151
164
  return { ok: true, run_id: run.id, stage, outcome: "blocked", verification, instruction: "CODE remains running. Resolve the stated external or user-input blocker in this same coordinator session, update code-verification.json, then invoke this same stage finish command again." };
@@ -192,12 +205,10 @@ export async function finishVnextCode(context, input) {
192
205
  throw new AppError("code_acceptance_unresolved", "CODE cannot finish until every due acceptance criterion has current checks and evidence", 2, { unresolved: unresolvedAcceptance });
193
206
  const now = context.now();
194
207
  const timing = stageTiming(home, stage, now);
195
- const reportedPaths = [...new Set(works.flatMap((work) => resultPaths(work.result)))];
196
- const observedChangedPaths = workspaceChangedPaths(run.workspace_root);
197
- const changedPaths = observedChangedPaths ?? reportedPaths;
198
- const missingReportedPaths = observedChangedPaths === null ? [] : reportedPaths.filter((item) => !observedChangedPaths.includes(item));
199
- if (missingReportedPaths.length)
200
- throw new AppError("changed_path_not_materialized", "CODE Work reported paths that are not changed in the accepted workspace", 2, { paths: missingReportedPaths });
208
+ // Work result paths are provenance, not a mutable filesystem oracle. The
209
+ // stage reports one shared Git observation; ignored machine repairs are
210
+ // rejected before a Work can become completed.
211
+ const changedPaths = workspaceChangedPaths(run.workspace_root);
201
212
  const next = nextAction(run, changedPaths);
202
213
  const report = {
203
214
  schema_id: "dd-flow/stage-report@1",
@@ -237,11 +248,14 @@ export async function finishVnextCode(context, input) {
237
248
  // Do it before materialising a terminal stage report or changing RUN state,
238
249
  // so an invalid merge policy leaves CODE safely running and retryable.
239
250
  let mergeRequest = null;
240
- assertInputs();
251
+ assertRunMutationAllowed(context, project.id, run.id, generation);
241
252
  const prepareMerge = next === "start_merge" ? prepareVnextMergeRequest(context, { projectRoot, runId: run.id, acceptedPaths: changedPaths }) : null;
242
253
  withStageSettlement(context, () => {
243
254
  assertRunMutationAllowed(context, project.id, run.id, generation);
244
- assertInputs();
255
+ const currentWorks = codeWorks(context, project.id, run.id);
256
+ if (currentWorks.length !== works.length || currentWorks.some(work => work.status !== "completed" || !works.some(accepted => accepted.work_id === work.work_id && accepted.result === work.result))) {
257
+ throw new AppError("code_graph_incomplete", "CODE results changed while the final checks were running", 2);
258
+ }
245
259
  mergeRequest = prepareMerge?.() ?? null;
246
260
  writeReport(root, report);
247
261
  completeFlowRunStage(context, {
@@ -581,15 +595,45 @@ function requireRun(context, projectId, runId) {
581
595
  return run;
582
596
  }
583
597
  /** CODE, not PROTOCOLIZE, makes a checkout ready for project tooling. */
584
- async function ensureCodeWorkspaceReady(context, projectId, run, root, readinessChecks, progress) {
598
+ export function isReusablePreparedCodeReadiness(value, expected) {
599
+ if (!value || typeof value !== "object" || Array.isArray(value))
600
+ return false;
601
+ const receipt = value;
602
+ return receipt.schema_id === "dd-flow/workspace-readiness@2"
603
+ && receipt.status === "passed"
604
+ && receipt.prepared_by === expected.ownerId
605
+ && receipt.workspace_root === expected.workspaceRoot
606
+ && receipt.command === expected.command
607
+ && receipt.policy_ref === expected.policyRef;
608
+ }
609
+ function readPreparedReadiness(file) {
610
+ try {
611
+ return JSON.parse(fs.readFileSync(file, "utf8"));
612
+ }
613
+ catch {
614
+ return null;
615
+ }
616
+ }
617
+ async function ensureCodeWorkspaceReady(context, projectId, run, root, readinessChecks, progress, options = {}) {
585
618
  const profile = JSON.parse(run.index_json);
586
619
  const bootstrap = profile.execution_profile?.settings?.code_bootstrap;
587
620
  if (!bootstrap || typeof bootstrap.command !== "string" || typeof bootstrap.policy_ref !== "string") {
588
621
  throw new AppError("execution_profile_invalid", "CODE requires a frozen bootstrap command in the RUN execution profile", 1, { run_id: run.id });
589
622
  }
590
623
  const receiptPath = path.join(root, "workspace-readiness.json");
591
- // Re-run the project bootstrap on stage entry: a prior receipt cannot prove
592
- // that ignored dependencies or local services still exist.
624
+ const ownerId = context.env.DD_FLOW_CODE_READINESS_OWNER;
625
+ if (!options.force && ownerId && isReusablePreparedCodeReadiness(readPreparedReadiness(receiptPath), {
626
+ ownerId,
627
+ workspaceRoot: run.workspace_root,
628
+ command: bootstrap.command,
629
+ policyRef: bootstrap.policy_ref
630
+ })) {
631
+ progress?.("workspace bootstrap already prepared by this controller");
632
+ return readPreparedReadiness(receiptPath);
633
+ }
634
+ // Unowned and stale receipts are never trusted: a direct stage entry still
635
+ // performs its own readiness check. Only the same controller can hand a
636
+ // freshly prepared workspace to its immediately following lifecycle call.
593
637
  const startedAt = new Date().toISOString();
594
638
  progress?.(`workspace bootstrap started: ${bootstrap.command}`);
595
639
  const result = await runWorkspaceBootstrap(context, { projectId, runId: run.id, runHome: requireHome(run), workspaceRoot: run.workspace_root, command: bootstrap.command, artifactDir: "05-code/readiness", progress });
@@ -597,7 +641,7 @@ async function ensureCodeWorkspaceReady(context, projectId, run, root, readiness
597
641
  const checks = bootstrapPassed && readinessChecks.length
598
642
  ? await runCodeChecks(context, { projectId, runId: run.id, runHome: requireHome(run), workspaceRoot: run.workspace_root, scope: "aggregate", artifactDir: "05-code/readiness", checks: readinessChecks, ...(progress ? { progress } : {}) })
599
643
  : [];
600
- const receipt = { schema_id: "dd-flow/workspace-readiness@2", workspace_root: run.workspace_root, command: bootstrap.command, policy_ref: bootstrap.policy_ref, started_at: startedAt, finished_at: new Date().toISOString(), status: bootstrapPassed && checks.every((check) => check.status === "passed") ? "passed" : "failed", process_id: result.process_id, stdout_path: result.stdout_path, stderr_path: result.stderr_path, completion_path: result.completion_path, exit_code: result.exit_code, stdout: result.stdout, stderr: result.stderr, check_declarations: readinessChecks, checks: checks.map((check) => ({ id: check.id, status: check.status, receipt_path: check.receipt_path })), ...(result.error ? { error: result.error } : {}), receipt_path: receiptPath };
644
+ const receipt = { schema_id: "dd-flow/workspace-readiness@2", workspace_root: run.workspace_root, command: bootstrap.command, policy_ref: bootstrap.policy_ref, started_at: startedAt, finished_at: new Date().toISOString(), status: bootstrapPassed && checks.every((check) => check.status === "passed") ? "passed" : "failed", ...(ownerId ? { prepared_by: ownerId } : {}), process_id: result.process_id, stdout_path: result.stdout_path, stderr_path: result.stderr_path, completion_path: result.completion_path, exit_code: result.exit_code, stdout: result.stdout, stderr: result.stderr, check_declarations: readinessChecks, checks: checks.map((check) => ({ id: check.id, status: check.status, receipt_path: check.receipt_path })), ...(result.error ? { error: result.error } : {}), receipt_path: receiptPath };
601
645
  fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
602
646
  if (receipt.status !== "passed")
603
647
  throw new AppError("workspace_readiness_failed", "CODE workspace bootstrap failed", 1, receipt);
@@ -615,8 +659,9 @@ export function finishCommand(context, runId, projectRoot, verificationFile, ret
615
659
  }
616
660
  function readVerification(context, input) {
617
661
  const file = path.resolve(input.file);
618
- validateSchema({ schemaName: "code-verification", file, projectRoot: input.projectRoot, ddFlowHome: context.ddFlowHome, runId: input.runId, runRoot: input.runRoot });
619
- return JSON.parse(fs.readFileSync(file, "utf8"));
662
+ const data = JSON.parse(fs.readFileSync(file, "utf8"));
663
+ validateSchema({ schemaName: "code-verification", file, data, projectRoot: input.projectRoot, ddFlowHome: context.ddFlowHome, runId: input.runId, runRoot: input.runRoot });
664
+ return data;
620
665
  }
621
666
  function verificationForFinish(context, input) {
622
667
  return readVerification(context, input);
@@ -673,15 +718,6 @@ function changedPathsFromReport(file) {
673
718
  return [];
674
719
  }
675
720
  }
676
- function resultPaths(result) {
677
- try {
678
- const value = JSON.parse(result ?? "");
679
- return Array.isArray(value.changed_paths) ? value.changed_paths.filter((item) => typeof item === "string") : [];
680
- }
681
- catch {
682
- return [];
683
- }
684
- }
685
721
  function unique(values) {
686
722
  return [...new Set(values)];
687
723
  }
@@ -16,7 +16,7 @@ import { validateSchema } from "./schema-validation.js";
16
16
  import { writeStageReport } from "./stage-report-renderer.js";
17
17
  import { bindStageCoordinatorWork, createChildWork, failWork, finishFanInWork, finishWork, refreshRunWorkProjection, startStageCoordinatorWork } from "./work-registry.js";
18
18
  import { addVnextCodeRepair } from "./vnext-code.js";
19
- import { checkReceiptEvidenceValid, checkReceipts, effectiveCheckDeclarations, readCodeCheckProfile, runCodeChecks, validateFailedCheckRetry, workspaceFingerprint } from "./code-checks.js";
19
+ import { checkReceiptEvidenceValid, checkReceipts, effectiveCheckDeclarations, readCodeCheckProfile, runCodeChecks, validateFailedCheckRetry, workspaceChangedPaths, workspaceFingerprint } from "./code-checks.js";
20
20
  import { applyExternalStageContext } from "./stage-context.js";
21
21
  import { ensureLaneWorkspace, heartbeatLaneLock, releaseLaneLock, waitAcquireLaneLock } from "./lanes.js";
22
22
  import { assertRunMutationAllowed } from "./run-recovery.js";
@@ -72,11 +72,13 @@ export function prepareVnextMergeRequest(context, input) {
72
72
  const frozen = JSON.parse(fs.readFileSync(freezeFile, "utf8"));
73
73
  sourceCommit = frozen.run_id === run.id && typeof frozen.source_commit === "string" ? frozen.source_commit : "";
74
74
  sourcePaths = Array.isArray(frozen.paths) ? frozen.paths : [];
75
- if (!sourceCommit || gitValue(run.workspace_root, ["rev-parse", "HEAD"]) !== sourceCommit || (accepted && JSON.stringify(accepted) !== JSON.stringify(sourcePaths)))
75
+ // A retry sees a clean tree: the original paths are already in sourceCommit.
76
+ // Reuse that receipt, but never accept new edits merely because names match.
77
+ if (!sourceCommit || frozen.source_workspace !== run.workspace_root || frozen.source_branch !== sourceBranch || gitValue(run.workspace_root, ["rev-parse", "HEAD"]) !== sourceCommit || meaningfulStatus(run.workspace_root, ignored).length)
76
78
  throw new AppError("merge_source_freeze_conflict", "Existing MERGE source freeze does not match the accepted source", 2, { freeze_file: freezeFile });
77
79
  }
78
80
  else {
79
- sourcePaths = [...new Set(meaningfulStatus(run.workspace_root, ignored).map(statusPath))].sort();
81
+ sourcePaths = meaningfulStatus(run.workspace_root, ignored);
80
82
  if (accepted) {
81
83
  const unexpected = sourcePaths.filter((item) => !accepted.includes(item));
82
84
  const absent = accepted.filter((item) => !sourcePaths.includes(item));
@@ -491,15 +493,13 @@ function requireOwnedActiveRequest(context, input) { const project = requireProj
491
493
  function assertExecutionBaseline(request) { const head = gitValue(request.target_workspace, ["rev-parse", "HEAD"], true); if (request.checkpoint === "baseline_locked" && head !== request.execution_target_head)
492
494
  throw new AppError("merge_target_changed", "Integration target changed after the lane baseline was locked", 1, { expected: request.execution_target_head, actual: head }); }
493
495
  function recovery(context, request, code, detail) { context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code, detail }), context.now(), request.merge_request_id]); throw new AppError("merge_recovery_required", "MERGE mutation could not be reconciled automatically", 1, { merge_request_id: request.merge_request_id, code, detail }); }
494
- function commitPendingSource(cwd, runId, ignored) { const paths = meaningfulStatus(cwd, ignored).map(statusPath); if (!paths.length)
496
+ function commitPendingSource(cwd, runId, ignored) { const paths = meaningfulStatus(cwd, ignored); if (!paths.length)
495
497
  return; git(cwd, ["add", "-A", "--", ...paths]); git(cwd, ["commit", "-m", `chore(${runId.toLowerCase()}): freeze accepted source`]); }
496
- function commitIntegration(cwd, runId, ignored) { const mergeHead = fs.existsSync(path.join(gitDirectory(cwd), "MERGE_HEAD")); const paths = meaningfulStatus(cwd, ignored).map(statusPath); if (!mergeHead && !paths.length)
498
+ function commitIntegration(cwd, runId, ignored) { const mergeHead = fs.existsSync(path.join(gitDirectory(cwd), "MERGE_HEAD")); const paths = meaningfulStatus(cwd, ignored); if (!mergeHead && !paths.length)
497
499
  return; if (paths.length)
498
500
  git(cwd, ["add", "-A", "--", ...paths]); git(cwd, ["commit", "-m", `merge(${runId.toLowerCase()}): integrate accepted source`]); }
499
501
  function ignoredGitPaths(context, projectId) { return [readProjectConfig(context, projectId).dashboard.markdown_path.replaceAll("\\", "/")]; }
500
- function meaningfulStatus(cwd, ignored) { const result = spawnSync("git", ["status", "--porcelain=v1", "--untracked-files=all"], { cwd, encoding: "utf8" }); if (result.status !== 0)
501
- return []; return result.stdout.split(/\r?\n/).filter(Boolean).filter((line) => !ignored.includes(statusPath(line))); }
502
- function statusPath(line) { const value = line.slice(3); const renamed = value.includes(" -> ") ? value.slice(value.lastIndexOf(" -> ") + 4) : value; return renamed.replace(/^"|"$/g, ""); }
502
+ function meaningfulStatus(cwd, ignored) { return workspaceChangedPaths(cwd).filter((item) => !ignored.includes(item)); }
503
503
  function unmerged(cwd) { const value = gitValue(cwd, ["diff", "--name-only", "--diff-filter=U"], true); return value.split(/\r?\n/).filter(Boolean); }
504
504
  function gitDirectory(cwd) { const value = gitValue(cwd, ["rev-parse", "--git-dir"]); return path.resolve(cwd, value); }
505
505
  function git(cwd, args) { try {
@@ -1,6 +1,6 @@
1
1
  import { managedLifecycleCommand } from "./lifecycle-invocations.js";
2
2
  import crypto from "node:crypto";
3
- import { withStageSettlement, stageInputGuard } from "./runs.js";
3
+ import { withStageSettlement } from "./runs.js";
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { AppError } from "../shared/errors.js";
@@ -11,7 +11,7 @@ import { addWorkBatch, bindStageCoordinatorWork, ensureWorkRegistry, refreshRunW
11
11
  import { validateSchema } from "./schema-validation.js";
12
12
  import { flowCommand, stagePauseCommand, stagePauseCommandTemplate } from "./stage-pause.js";
13
13
  import { assertStageStartHookEvent } from "./hooks.js";
14
- import { validateVnextPlanArtifacts } from "./vnext-plan.js";
14
+ import { prepareVnextPlanArtifacts, validateVnextPlanArtifacts } from "./vnext-plan.js";
15
15
  import { requireVnextWorkspaceRoute } from "./vnext-workspace-policy.js";
16
16
  import { nextWorkId } from "./ids.js";
17
17
  import { vnextStageDirectory } from "../domain/stage-catalog.js";
@@ -104,7 +104,7 @@ export function startVnextPlanReview(context, input) {
104
104
  const workSessionId = String(binding.work_session_id);
105
105
  const sessionId = String(binding.session_id);
106
106
  const orchestration = writeFanoutDescriptor(root, { stage, parent_work_id: workId, dispatch: "plan-review", capacity_required: true });
107
- writeJson(path.join(root, "work-context.json"), { schema_id: "dd-flow/plan-review-context@1", system: { run_id: run.id, work_id: workId, stage, requested_mode: requested, effective_mode: effective, plan_revision: currentPlanRevision(home, run.workspace_root), plan_checksum: planChecksum, batch_checksum: batchChecksum }, groups, workspace: { project_root: projectRoot, workspace_root: run.workspace_root, stage_root: root, plan_root: planRoot } });
107
+ 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: currentPlanRevision(home, run.workspace_root), plan_checksum: planChecksum, batch_checksum: batchChecksum }, groups, workspace: { project_root: projectRoot, workspace_root: run.workspace_root, stage_root: root, plan_root: planRoot } });
108
108
  refreshRunWorkProjection(context, project.id, run.id);
109
109
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "work_session_started", stage, work_id: workId, id: workSessionId, session_id: sessionId });
110
110
  return { ok: true, run_id: run.id, stage, outcome: "review_required", work_id: workId, id: workSessionId, prompt_path: promptPath, worker_prompt_markdown: fs.readFileSync(promptPath, "utf8"), ...(externalContext ? { external_context: externalContext } : {}), orchestration, plan_review: { requested_mode: requested, effective_mode: effective, policy_source: policySource(run), groups }, next: { dispatch_command: dispatchCommand(context, run.id, projectRoot), finish_command: finishCommand(context, run.id, projectRoot, path.join(root, "decision.json")) } };
@@ -173,6 +173,9 @@ export function recordVnextPlanReviewCapacity(context, input) {
173
173
  return { ok: true, run_id: run.id, capacity: { available_slots: input.availableSlots, source: "harness_qualification" } };
174
174
  }
175
175
  export function finishVnextPlanReview(context, input) {
176
+ return withStageSettlement(context, () => finishPlanReviewOwned(context, input));
177
+ }
178
+ function finishPlanReviewOwned(context, input) {
176
179
  const projectRoot = resolveProjectRoot(input.projectRoot);
177
180
  const project = requireProjectByRoot(context, projectRoot);
178
181
  const run = requireRun(context, projectRoot, input.runId);
@@ -184,7 +187,6 @@ export function finishVnextPlanReview(context, input) {
184
187
  if (!parent || parent.status !== "running")
185
188
  throw new AppError("invalid_work_state", "PLAN-REVIEW has no running parent Work", 2);
186
189
  const decisionPath = path.resolve(input.decisionFile ?? path.join(root, "decision.json"));
187
- const assertInputs = stageInputGuard(context, project.id, run.id, () => [planSetChecksum(home, run.workspace_root), checksum(batch), checksum(decisionPath), checksum(path.join(root, "work-context.json"))]);
188
190
  const decision = readDecision(context, projectRoot, run.id, home, decisionPath);
189
191
  const children = context.db.all("SELECT work_id, task, status, result, created_at FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id = ?", [project.id, run.id, parent.work_id]);
190
192
  const contextFile = readJson(path.join(root, "work-context.json"));
@@ -198,8 +200,7 @@ export function finishVnextPlanReview(context, input) {
198
200
  throw new AppError("stage_pause_required", "A user or decision blocker must pause the current PLAN-REVIEW Work with `dd-flow stage pause`; do not finish the stage as blocked", 2, { run_id: run.id, stage, work_id: parent.work_id });
199
201
  }
200
202
  if (["failed", "cancelled"].includes(decision.outcome)) {
201
- preserveDecisionReceipt(root, decisionPath);
202
- return withStageSettlement(context, () => { assertInputs(); return finishTerminalReview(context, { projectRoot, projectId: project.id, run, root, planRoot, batch, parentWorkId: parent.work_id, children, contextFile, decision }); });
203
+ return withStageSettlement(context, () => { preserveDecisionReceipt(root, decisionPath); return finishTerminalReview(context, { projectRoot, projectId: project.id, run, root, planRoot, batch, parentWorkId: parent.work_id, children, contextFile, decision }); });
203
204
  }
204
205
  if (children.some((child) => child.status === "created" || child.status === "running"))
205
206
  throw new AppError("worker_jobs_incomplete", "PLAN-REVIEW finish requires all reviewer Works to settle", 1, { works: children.map((child) => ({ work_id: child.work_id, status: child.status })) });
@@ -233,21 +234,20 @@ export function finishVnextPlanReview(context, input) {
233
234
  else if (decision.correction.status !== "not_required" || planRevision !== reviewedPlanRevision || currentPlanChecksum !== contextFile.system?.plan_checksum) {
234
235
  throw new AppError("validation", "A clean PLAN review must not claim an unverified correction", 2);
235
236
  }
236
- const planFailures = validateVnextPlanArtifacts(context, { projectRoot, workspaceRoot: run.workspace_root, runId: run.id, home, protocols: protocolIdsForReview(home) });
237
- if (planFailures.length)
238
- throw new AppError("validation", "Corrected PLAN artifacts have validation errors", 2, { errors: planFailures });
237
+ const prepared = prepareVnextPlanArtifacts(context, { projectRoot, workspaceRoot: run.workspace_root, runId: run.id, home, protocols: protocolIdsForReview(home) });
238
+ if (prepared.failures.length)
239
+ throw new AppError("validation", "Corrected PLAN artifacts have validation errors", 2, { errors: prepared.failures });
239
240
  if (needsCorrection) {
240
- const initialBatchChecksum = contextFile.system?.batch_checksum;
241
- if (!initialBatchChecksum || checksum(batch) === initialBatchChecksum)
242
- throw new AppError("stale_code_work_batch", "Applied PLAN correction must regenerate the proposed CODE batch", 2, { batch, initial_batch_checksum: initialBatchChecksum });
241
+ // Regeneration above is authoritative. A valid PLAN correction need not
242
+ // change the derived CODE tasks (for example, a clarified explanation).
243
243
  if (decision.correction.changed_paths.some((item) => item.endsWith("code-work-batch.json")))
244
244
  throw new AppError("validation", "Generated CODE batch must not be listed as an agent-authored correction", 2, { changed_paths: decision.correction.changed_paths });
245
245
  }
246
- preserveDecisionReceipt(root, decisionPath);
247
246
  const finalPlanChecksum = planSetChecksum(home, run.workspace_root);
248
- const finalBatchChecksum = checksum(batch);
247
+ const finalBatchChecksum = prepared.batchChecksum;
249
248
  return withStageSettlement(context, () => {
250
- assertInputs();
249
+ prepared.publish();
250
+ preserveDecisionReceipt(root, decisionPath);
251
251
  const registered = registerCode(context, project.id, run.id, batch);
252
252
  const now = context.now();
253
253
  settleReviewParent(context, { projectRoot, projectId: project.id, runId: run.id, parentWorkId: parent.work_id, status: "completed", result: decision.summary, reason: "plan_review_completed", resultPath: path.join(root, "stage-report.json") });
@@ -393,7 +393,7 @@ catch {
393
393
  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)
394
394
  throw new AppError("runtime_missing", "vNext RUN has no root Work", 1); return root.work_id; }
395
395
  function registerCode(context, projectId, runId, batch) { return addWorkBatch(context, { parentWorkId: rootWorkId(context, projectId, runId), file: batch }); }
396
- function readDecision(context, projectRoot, runId, runRoot, file) { validateSchema({ schemaName: "plan-review-decision", file, projectRoot, runId, runRoot, ddFlowHome: context.ddFlowHome }); return readJson(file); }
396
+ function readDecision(context, projectRoot, runId, runRoot, file) { const data = readJson(file); validateSchema({ schemaName: "plan-review-decision", file, data, projectRoot, runId, runRoot, ddFlowHome: context.ddFlowHome }); return data; }
397
397
  function preserveDecisionReceipt(root, source) {
398
398
  const receipt = path.join(root, "decision.receipt.json");
399
399
  const bytes = fs.readFileSync(source);
@@ -6,7 +6,7 @@ import path from "node:path";
6
6
  import { AppError } from "../shared/errors.js";
7
7
  import { effectiveCheckDeclarations, readCodeCheckProfile, validateCheckDeclaration, validateCheckPlacement, validateCodeCheckCommands } from "./code-checks.js";
8
8
  import { requireProjectByRoot } from "./projects.js";
9
- import { resolveProjectRoot } from "../storage/paths.js";
9
+ import { canonicalPath, resolveProjectRoot } from "../storage/paths.js";
10
10
  import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRunStage, getFlowRunVariables, gitFacts } from "./runs.js";
11
11
  import { validateSchema } from "./schema-validation.js";
12
12
  import { bindRunningWorkSession, bindStageCoordinatorWork, ensureWorkRegistry, refreshRunWorkProjection, validateWorkBatchFile } from "./work-registry.js";
@@ -151,6 +151,13 @@ function finishVnextPlanOwned(context, input) {
151
151
  return { ok: true, run_id: run.id, protocols, next_action: "start_plan_review", next_command: reviewCommand, next: { kind: "start_stage", stage: "plan-review", command: reviewCommand } };
152
152
  }
153
153
  export function validateVnextPlanArtifacts(context, input) {
154
+ const prepared = prepareVnextPlanArtifacts(context, input);
155
+ if (!prepared.failures.length && input.publishBatch !== false)
156
+ prepared.publish();
157
+ return prepared.failures;
158
+ }
159
+ /** Validate in scratch files; publish only after the caller accepts its inputs. */
160
+ export function prepareVnextPlanArtifacts(context, input) {
154
161
  const workspaceRoot = input.workspaceRoot ?? input.projectRoot;
155
162
  const planFiles = input.protocols.map((id) => path.join(workspaceRoot, ".memory-bank", "protocol", id, "plan.json"));
156
163
  const mapFiles = input.protocols.map((id) => path.join(input.home, "03-plan", id, "aspect-map.json"));
@@ -163,6 +170,7 @@ export function validateVnextPlanArtifacts(context, input) {
163
170
  : []) : []) ?? [])
164
171
  : undefined;
165
172
  const failures = [];
173
+ const publications = new Map();
166
174
  const plans = [];
167
175
  for (const [index, file] of planFiles.entries()) {
168
176
  const protocolId = input.protocols[index];
@@ -186,14 +194,22 @@ export function validateVnextPlanArtifacts(context, input) {
186
194
  }
187
195
  }
188
196
  for (const file of mapFiles) {
197
+ const temporary = `${file}.tmp-${crypto.randomUUID()}`;
189
198
  try {
190
- normalizeAspectMapRefs(file, workspaceRoot, input.home, input.runId);
191
- validateSchema({ schemaName: "plan-aspect-map", file, projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome, runId: input.runId, runRoot: input.home });
192
- validateAspectMap(file, input.protocols, workspaceRoot);
199
+ const bytes = normalizedAspectMapRefs(file, workspaceRoot, input.home, input.runId);
200
+ if (input.publishBatch === false && bytes !== fs.readFileSync(file, "utf8"))
201
+ throw new AppError("validation", "Accepted aspect-map requires normalization; regenerate it in PLAN", 2, { file });
202
+ fs.writeFileSync(temporary, bytes);
203
+ validateSchema({ schemaName: "plan-aspect-map", file: temporary, projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome, runId: input.runId, runRoot: input.home });
204
+ validateAspectMap(temporary, input.protocols, workspaceRoot);
205
+ publications.set(file, bytes);
193
206
  }
194
207
  catch (error) {
195
208
  failures.push(validationFailure(file, error));
196
209
  }
210
+ finally {
211
+ fs.rmSync(temporary, { force: true });
212
+ }
197
213
  }
198
214
  if (!failures.length) {
199
215
  try {
@@ -213,7 +229,7 @@ export function validateVnextPlanArtifacts(context, input) {
213
229
  validateSchema({ schemaName: "code-work-batch", file: temporaryBatch, projectRoot: workspaceRoot, ddFlowHome: context.ddFlowHome, runId: input.runId, runRoot: input.home });
214
230
  validateWorkBatchFile(temporaryBatch);
215
231
  if (input.publishBatch !== false) {
216
- fs.renameSync(temporaryBatch, batch);
232
+ publications.set(batch, fs.readFileSync(temporaryBatch, "utf8"));
217
233
  }
218
234
  else if (!fs.existsSync(batch) || checksum(temporaryBatch) !== checksum(batch)) {
219
235
  throw new AppError("stale_code_work_batch", "CODE batch no longer matches the accepted semantic PLAN", 2, { batch });
@@ -226,7 +242,29 @@ export function validateVnextPlanArtifacts(context, input) {
226
242
  fs.rmSync(temporaryBatch, { force: true });
227
243
  }
228
244
  }
229
- return failures;
245
+ return { failures, batchChecksum: publications.has(batch) ? crypto.createHash("sha256").update(publications.get(batch)).digest("hex") : null,
246
+ publish: () => {
247
+ if (failures.length)
248
+ throw new AppError("validation", "Cannot publish invalid PLAN artifacts", 2, { errors: failures });
249
+ const published = [];
250
+ try {
251
+ for (const [file, bytes] of publications) {
252
+ const temporary = `${file}.tmp-${crypto.randomUUID()}`;
253
+ try {
254
+ fs.writeFileSync(temporary, bytes);
255
+ fs.renameSync(temporary, file);
256
+ published.push(file);
257
+ }
258
+ finally {
259
+ fs.rmSync(temporary, { force: true });
260
+ }
261
+ }
262
+ }
263
+ catch (cause) {
264
+ throw new AppError("plan_publication_failed", cause instanceof Error ? cause.message : String(cause), 1, { effect: "unknown", published_files: published, business_commit: false });
265
+ }
266
+ }
267
+ };
230
268
  }
231
269
  /** CODE entry validates the same PLAN closure without changing an accepted batch. */
232
270
  export function validateVnextCodeHandoff(context, input) {
@@ -375,13 +413,16 @@ function assertPlanIdentity(value, identity, file) {
375
413
  if (JSON.stringify(value.source_refs) !== JSON.stringify(identity.source_refs))
376
414
  throw new AppError("validation", "PLAN must preserve CLI-owned source_refs", 2, { file });
377
415
  }
378
- function normalizeAspectMapRefs(file, projectRoot, runHome, runId) {
379
- const map = JSON.parse(fs.readFileSync(file, "utf8"));
416
+ function normalizedAspectMapRefs(file, projectRoot, runHome, runId) {
417
+ projectRoot = canonicalPath(projectRoot);
418
+ runHome = canonicalPath(runHome);
419
+ const bytes = fs.readFileSync(file, "utf8");
420
+ const map = JSON.parse(bytes);
380
421
  let changed = false;
381
422
  const normalize = (value) => {
382
423
  if (!path.isAbsolute(value))
383
424
  return value;
384
- const source = path.resolve(value);
425
+ const source = canonicalPath(value, false);
385
426
  const inProject = source.startsWith(`${projectRoot}${path.sep}`);
386
427
  const inRun = source.startsWith(`${runHome}${path.sep}`);
387
428
  if (!inProject && !inRun)
@@ -394,8 +435,7 @@ function normalizeAspectMapRefs(file, projectRoot, runHome, runId) {
394
435
  for (const aspect of map.aspects ?? [])
395
436
  if (Array.isArray(aspect.planned_artifact_refs))
396
437
  aspect.planned_artifact_refs = aspect.planned_artifact_refs.map(normalize);
397
- if (changed)
398
- fs.writeFileSync(file, `${JSON.stringify(map, null, 2)}\n`);
438
+ return changed ? `${JSON.stringify(map, null, 2)}\n` : bytes;
399
439
  }
400
440
  function projectCodeWorkBatch(input) {
401
441
  const obligationMap = acceptedObligationMap(input.home);