From af1401e4eabc847e1f611e783b84e38cbce928da Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Fri, 15 May 2026 18:32:59 +0200 Subject: [PATCH] fix(solver): enforce PDD purpose gate --- .../extensions/sf/autonomous-solver.js | 37 +++++++++++++++++++ .../sf/tests/autonomous-solver.test.mjs | 31 ++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/src/resources/extensions/sf/autonomous-solver.js b/src/resources/extensions/sf/autonomous-solver.js index 665c9bdca..2b84666ae 100644 --- a/src/resources/extensions/sf/autonomous-solver.js +++ b/src/resources/extensions/sf/autonomous-solver.js @@ -165,6 +165,32 @@ function renderPdd(pdd = {}) { ].join("\n"); } +const REQUIRED_PDD_FIELDS = [ + ["purpose", "Purpose"], + ["consumer", "Consumer"], + ["contract", "Contract"], + ["failureBoundary", "Failure boundary"], + ["evidence", "Evidence"], + ["nonGoals", "Non-goals"], + ["invariants", "Invariants"], + ["assumptions", "Assumptions"], +]; + +/** + * Return missing PDD field labels for a checkpoint payload. + * + * Purpose: make ADR-0000's purpose gate executable instead of leaving the + * eight-field contract as prompt-only guidance. + * + * Consumer: assessAutonomousSolverTurn before accepting continue/complete + * checkpoint outcomes. + */ +export function missingPddFieldLabels(pdd = {}) { + return REQUIRED_PDD_FIELDS.filter( + ([key]) => !String(pdd?.[key] ?? "").trim(), + ).map(([, label]) => label); +} + function renderProjection(state) { const checkpoint = state.latestCheckpoint ?? {}; return [ @@ -1080,6 +1106,17 @@ export function assessAutonomousSolverTurn( maxCheckpointCount: MAX_CHECKPOINTS_PER_ITERATION, }; } + const missingPdd = missingPddFieldLabels(checkpoint.pdd); + if (checkpoint.outcome !== "blocked" && missingPdd.length > 0) { + return { + action: "pause", + reason: "solver-purpose-gate", + state, + checkpoint, + missingPddFields: missingPdd, + message: `BLOCKED: purpose unclear - ${missingPdd.join(", ")}`, + }; + } if ( (checkpoint.outcome === "continue" || checkpoint.outcome === "decide") && (checkpoint.remainingItems?.length ?? 0) === 0 diff --git a/src/resources/extensions/sf/tests/autonomous-solver.test.mjs b/src/resources/extensions/sf/tests/autonomous-solver.test.mjs index eac7bdd54..a20b4e81b 100644 --- a/src/resources/extensions/sf/tests/autonomous-solver.test.mjs +++ b/src/resources/extensions/sf/tests/autonomous-solver.test.mjs @@ -19,6 +19,7 @@ import { getSolverPhase, isNoOpExecutorTranscript, MAX_EXECUTOR_REFUSAL_ESCALATIONS, + missingPddFieldLabels, readAutonomousSolverState, readLatestAutonomousSolverCheckpoint, recordAutonomousSolverMissingCheckpointRetry, @@ -1124,6 +1125,36 @@ describe("appendAutonomousSolverCheckpoint sticky identity", () => { expect(assessment.action).toBe("complete"); }); + test("assessAutonomousSolverTurn_missing_pdd_fields_pauses_purpose_gate", () => { + const project = makeProject(); + beginAutonomousSolverIteration(project, "execute-task", "M001/S04/T03"); + appendAutonomousSolverCheckpoint(project, { + unitType: "execute-task", + unitId: "M001/S04/T03", + outcome: "complete", + summary: "Done.", + completedItems: ["work"], + remainingItems: [], + verificationEvidence: ["npm test"], + pdd: pdd({ purpose: "", evidence: "" }), + }); + + const assessment = assessAutonomousSolverTurn( + project, + "execute-task", + "M001/S04/T03", + ); + + expect(assessment.action).toBe("pause"); + expect(assessment.reason).toBe("solver-purpose-gate"); + expect(assessment.missingPddFields).toEqual(["Purpose", "Evidence"]); + expect(assessment.message).toContain("BLOCKED: purpose unclear"); + }); + + test("missingPddFieldLabels_when_all_fields_present_returns_empty", () => { + expect(missingPddFieldLabels(pdd())).toEqual([]); + }); + test("matching unitId does not flag mismatch", () => { const project = makeProject(); beginAutonomousSolverIteration(project, "execute-task", "M001/S04/T02");