merge(P1): vision quality gate on sf new-milestone (ADR-0000)

This commit is contained in:
Mikael Hugo 2026-05-15 18:45:08 +02:00
commit 5e2c7a7166
6 changed files with 486 additions and 0 deletions

View file

@ -33,6 +33,10 @@ import {
hasProjectMilestones,
loadContext,
} from "./headless-context.js";
import {
checkPddFields,
formatPddRefusal,
} from "./resources/extensions/sf/headless-pdd-check.js";
import {
classifyUnexpectedChildExit,
@ -202,6 +206,7 @@ export interface HeadlessOptions {
resumeSession?: string; // session ID to resume (--resume <id>)
bare?: boolean; // --bare: suppress CLAUDE.md/AGENTS.md, user skills, project preferences
yolo?: boolean; // --yolo / -y: activate YOLO mode (build+autonomous+deep+unrestricted)
skipPddCheck?: boolean; // --skip-pdd-check: bypass ADR-0000 PDD-fields gate for new-milestone (migration escape hatch)
}
/**
@ -466,6 +471,8 @@ export function parseHeadlessArgs(argv: string[]): HeadlessOptions {
options.bare = true;
} else if (arg === "--yolo") {
options.yolo = true;
} else if (arg === "--skip-pdd-check") {
options.skipPddCheck = true;
} else if (commandSeen) {
options.commandArgs.push(arg);
}
@ -730,6 +737,31 @@ async function runHeadlessOnce(
process.exit(1);
}
// ADR-0000 purpose gate: refuse to proceed unless the seed context
// names the eight PDD fields (or at minimum the spine: Purpose,
// Consumer, Contract, plus Evidence-or-Falsifier). Skipped when SF
// generated the context itself (the autonomous→new-milestone
// auto-bootstrap path supplies an internally-built seed that is not
// meant to be PDD-shaped) and when the operator explicitly opted out
// with --skip-pdd-check (migration escape hatch).
const isOperatorInitiatedNewMilestone = requestedCommand === "new-milestone";
if (isOperatorInitiatedNewMilestone) {
if (options.skipPddCheck) {
process.stderr.write(
"[headless] WARNING: --skip-pdd-check active — ADR-0000 PDD gate bypassed. " +
"This escape hatch exists for milestones that pre-date the check. " +
"New seed docs should name all 8 PDD fields (Purpose, Consumer, Contract, " +
"Failure boundary, Evidence, Non-goals, Invariants, Assumptions).\n",
);
} else {
const pddReport = checkPddFields(contextContent);
if (!pddReport.ok) {
process.stderr.write(`${formatPddRefusal(pddReport)}\n`);
process.exit(1);
}
}
}
// Bootstrap .sf/ if needed
const sfDir = join(process.cwd(), ".sf");
if (!existsSync(sfDir)) {

View file

@ -268,6 +268,7 @@ const SUBCOMMAND_HELP: Record<string, string> = {
" --context-text <txt> Inline specification text",
" --autonomous Start autonomous mode after milestone creation",
" --verbose Show tool calls in progress output",
" --skip-pdd-check Bypass the ADR-0000 PDD-fields gate (migration escape hatch)",
"",
"Output formats:",
" text Human-readable progress on stderr (default)",

View file

@ -0,0 +1,25 @@
/**
* headless-pdd-check.d.ts Type declarations for the ADR-0000 PDD purpose gate.
*
* Purpose: let TypeScript callers in src/headless.ts import the JS validator
* without weakening root compile safety.
*
* Consumer: src/headless.ts (new-milestone entry point) and the vitest test
* for the validator itself.
*/
export interface PddCheckReport {
ok: boolean;
missing: string[];
sparse: string[];
spineSatisfied: boolean;
spineMissing: string[];
summary: string;
}
export function checkPddFields(content: string): PddCheckReport;
export function formatPddRefusal(report: PddCheckReport): string;
export const PDD_FIELD_NAMES: readonly string[];
export const PDD_MIN_FIELD_BODY_CHARS: number;

View file

@ -0,0 +1,249 @@
/**
* Headless milestone context PDD check.
*
* Purpose: enforce ADR-0000's purpose gate at the `sf headless new-milestone`
* entry point. ADR-0000 names the eight PDD fields (Purpose, Consumer, Contract,
* Failure boundary, Evidence, Non-goals, Invariants, Assumptions) as the
* upstream filter SF cannot know it is solving the right problem without them.
* A trivially-thin context document silently undermines every downstream gate.
*
* Consumer: src/headless.ts before it bootstraps `.sf/` and spawns the RPC
* child for a new-milestone run. The operator may opt out per-call with
* `--skip-pdd-check` during the migration period (existing milestones predate
* this gate); the bypass is logged so the abandonment is auditable.
*
* Contract: given the raw context string the operator passed via
* `--context <file>` or `--context-text <text>`, return a structured report
* that distinguishes (a) PDD fields that are completely absent, (b) PDD fields
* present but trivially-thin, and (c) whether the minimum spine Purpose,
* Consumer, Contract, plus either Evidence or a Falsifier is satisfied.
*
* Failure boundary: the check is heuristic (markdown headings or labelled
* lines). It never throws on malformed input; it returns `ok: false` with a
* description so the headless entry point can refuse the create with a
* pointed, actionable message. Pre-existing milestones predate the gate and
* are bypassed via `--skip-pdd-check`.
*
* Evidence: tests/headless-pdd-check.test.mjs exercises the missing-Purpose,
* missing-Consumer, all-fields-present, and minimum-spine cases.
*
* Non-goals: this module does not enforce structural ceremony depth (that
* belongs to milestone-quality.js once a milestone has been planned). It only
* enforces that the seed vision document has the eight PDD slots filled in
* with at least a sentence of intent before SF starts work.
*
* Invariants: the order and spelling of the eight PDD field names must track
* `docs/adr/0000-purpose-to-software-compiler.md`. The minimum spine
* (Purpose + Consumer + Contract + Evidence-or-Falsifier) must remain a
* subset of the full eight so the report stays internally consistent.
*
* Assumptions: the operator supplies the context as plain markdown or text.
* The check tolerates variant heading styles (`## Purpose`, `**Purpose:**`,
* `Purpose:` on a line of its own) because seed docs come from many tools.
*/
/**
* Canonical PDD fields per ADR-0000. Order is doctrine; do not reorder.
* Each entry pairs the field's display name with the alternate spellings
* SF accepts when scanning prose vision documents.
*/
const PDD_FIELDS = [
{ name: "Purpose", aliases: ["Purpose"] },
{ name: "Consumer", aliases: ["Consumer", "Consumers"] },
{ name: "Contract", aliases: ["Contract", "Contracts"] },
{
name: "Failure boundary",
aliases: [
"Failure boundary",
"Failure Boundary",
"Failure-boundary",
"FailureBoundary",
],
},
{ name: "Evidence", aliases: ["Evidence"] },
{ name: "Non-goals", aliases: ["Non-goals", "Non-Goals", "Non goals", "Nongoals"] },
{ name: "Invariants", aliases: ["Invariants", "Invariant"] },
{ name: "Assumptions", aliases: ["Assumptions", "Assumption"] },
];
/**
* A "Falsifier" stanza is accepted as a substitute for "Evidence" when the
* operator framed the success criterion as the test that would refute it.
* Either is sufficient for the minimum spine.
*/
const FALSIFIER_ALIASES = ["Falsifier", "Falsifiers"];
const MIN_FIELD_BODY_CHARS = 12;
function escapeForRegex(text) {
return text.replace(/[\\.*+?^${}()|[\]]/g, "\\$&");
}
/**
* Find the body associated with a field heading. Accepts three common forms:
*
* ## Purpose
* <body until next heading>
*
* **Purpose:** body on the same line (and optional continuation lines).
*
* Purpose: body on the same line.
*
* Returns the trimmed body string, or `null` if no occurrence is found.
*/
function findFieldBody(content, aliases) {
const lines = content.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
for (const alias of aliases) {
const esc = escapeForRegex(alias);
// `## Purpose` / `### Purpose` style heading
const headingMatch = new RegExp(`^#{1,6}\\s+${esc}\\s*:?\\s*$`, "i").exec(
line.trim(),
);
if (headingMatch) {
const bodyLines = [];
for (let j = i + 1; j < lines.length; j++) {
if (/^#{1,6}\s+/.test(lines[j].trim())) break;
bodyLines.push(lines[j]);
}
return bodyLines.join("\n").trim();
}
// `**Purpose:**` or `Purpose:` inline label, optionally bullet-prefixed
const inlineRegex = new RegExp(
`^\\s*(?:[-*]\\s+)?(?:\\*\\*)?${esc}(?:\\*\\*)?\\s*:\\s*(.*)$`,
"i",
);
const inlineMatch = inlineRegex.exec(line);
if (inlineMatch) {
const tail = [inlineMatch[1]];
for (let j = i + 1; j < lines.length; j++) {
const next = lines[j];
if (!next.trim()) break;
if (/^\s*(?:[-*]\s+)?(?:\*\*)?[A-Z][A-Za-z -]+(?:\*\*)?\s*:/.test(next)) {
// Next labelled field starts here.
break;
}
if (/^#{1,6}\s+/.test(next.trim())) break;
tail.push(next);
}
return tail.join("\n").trim();
}
}
}
return null;
}
/**
* Inspect a milestone seed context for PDD coverage.
*
* @param {string} content - Raw context the operator passed in.
* @returns {{
* ok: boolean,
* missing: string[], // field names absent entirely
* sparse: string[], // field names present but body shorter than MIN_FIELD_BODY_CHARS
* spineSatisfied: boolean,
* spineMissing: string[],
* summary: string,
* }}
*/
export function checkPddFields(content) {
const text = typeof content === "string" ? content : "";
const missing = [];
const sparse = [];
const presentBodies = new Map();
for (const field of PDD_FIELDS) {
const body = findFieldBody(text, field.aliases);
if (body === null) {
missing.push(field.name);
continue;
}
presentBodies.set(field.name, body);
if (body.length < MIN_FIELD_BODY_CHARS) {
sparse.push(field.name);
}
}
const falsifierBody = findFieldBody(text, FALSIFIER_ALIASES);
const hasEvidenceOrFalsifier =
(presentBodies.has("Evidence") &&
presentBodies.get("Evidence").length >= MIN_FIELD_BODY_CHARS) ||
(falsifierBody !== null && falsifierBody.length >= MIN_FIELD_BODY_CHARS);
const spineMissing = [];
for (const required of ["Purpose", "Consumer", "Contract"]) {
const body = presentBodies.get(required);
if (!body || body.length < MIN_FIELD_BODY_CHARS) {
spineMissing.push(required);
}
}
if (!hasEvidenceOrFalsifier) {
spineMissing.push("Evidence (or Falsifier)");
}
const spineSatisfied = spineMissing.length === 0;
const ok = missing.length === 0 && sparse.length === 0;
const parts = [];
if (missing.length > 0) {
parts.push(`missing: ${missing.join(", ")}`);
}
if (sparse.length > 0) {
parts.push(`sparse (<${MIN_FIELD_BODY_CHARS} chars): ${sparse.join(", ")}`);
}
if (!spineSatisfied) {
parts.push(`spine gap: ${spineMissing.join(", ")}`);
}
const summary = parts.length === 0 ? "all 8 PDD fields present" : parts.join("; ");
return {
ok,
missing,
sparse,
spineSatisfied,
spineMissing,
summary,
};
}
/**
* Build the operator-facing refusal message when checkPddFields rejects a
* context. The message names exactly which fields are missing or sparse and
* points the operator at the escape hatch for migration cases.
*
* @param {ReturnType<typeof checkPddFields>} report
* @returns {string}
*/
export function formatPddRefusal(report) {
const lines = [
"[headless] Refused: milestone context fails the ADR-0000 purpose gate.",
"",
"ADR-0000 requires the eight PDD fields before SF starts work:",
" Purpose, Consumer, Contract, Failure boundary, Evidence,",
" Non-goals, Invariants, Assumptions.",
"",
];
if (report.missing.length > 0) {
lines.push(`Missing entirely: ${report.missing.join(", ")}`);
}
if (report.sparse.length > 0) {
lines.push(
`Present but too thin (<${MIN_FIELD_BODY_CHARS} chars): ${report.sparse.join(", ")}`,
);
}
if (!report.spineSatisfied) {
lines.push(`Minimum spine still unmet: ${report.spineMissing.join(", ")}`);
}
lines.push("");
lines.push(
"Fix the seed context (add the missing fields with at least a sentence of intent each) and retry.",
);
lines.push(
"To bypass for a milestone that pre-dates this gate, re-run with --skip-pdd-check (you will see a warning).",
);
return lines.join("\n");
}
export const PDD_FIELD_NAMES = PDD_FIELDS.map((f) => f.name);
export const PDD_MIN_FIELD_BODY_CHARS = MIN_FIELD_BODY_CHARS;

View file

@ -0,0 +1,176 @@
/**
* Tests for headless-pdd-check.js the ADR-0000 purpose-gate that guards
* `sf headless new-milestone --context <file>`.
*
* Coverage matrix (from ADR-0000 restoration scope):
* - missing Purpose refuse
* - missing Consumer refuse
* - all 8 PDD fields accept
* - --skip-pdd-check path bypass (asserted via the option flag the CLI sets)
*/
import assert from "node:assert/strict";
import { describe, test } from "vitest";
import {
checkPddFields,
formatPddRefusal,
PDD_FIELD_NAMES,
} from "../headless-pdd-check.js";
const FULL_CONTEXT = `# Seed Vision: Demo Milestone
## Purpose
Cut p99 latency on the import flow because operators retry on 5s+ failures.
## Consumer
The /import HTTP endpoint called by the daemon's batch sync job.
## Contract
Import returns 200 within 800ms for payloads up to 1 MiB; 5xx is retried at most twice.
## Failure boundary
On timeout, return 504 with a stable correlation id; never half-commit rows.
## Evidence
src/tests/import-latency.test.ts pins the p99 budget; metric "import_p99_ms".
## Non-goals
Not changing the on-disk schema; not rewriting the queue runner.
## Invariants
Safety: never write partial rows. Liveness: every accepted import eventually settles.
## Assumptions
Postgres advisory lock semantics stay as documented in pg 16.
`;
function omitField(content, fieldName) {
const lines = content.split("\n");
const out = [];
let skipping = false;
for (const line of lines) {
if (line.trim() === `## ${fieldName}`) {
skipping = true;
continue;
}
if (skipping && /^##\s+/.test(line.trim())) {
skipping = false;
}
if (!skipping) out.push(line);
}
return out.join("\n");
}
describe("checkPddFields — ADR-0000 purpose gate", () => {
test("accepts a context that names all 8 PDD fields", () => {
const report = checkPddFields(FULL_CONTEXT);
assert.equal(report.ok, true, `expected ok; got ${report.summary}`);
assert.deepEqual(report.missing, []);
assert.deepEqual(report.sparse, []);
assert.equal(report.spineSatisfied, true);
assert.deepEqual(report.spineMissing, []);
});
test("refuses when Purpose is missing", () => {
const report = checkPddFields(omitField(FULL_CONTEXT, "Purpose"));
assert.equal(report.ok, false);
assert.ok(
report.missing.includes("Purpose"),
`expected missing to include Purpose, got: ${report.missing.join(", ")}`,
);
assert.equal(report.spineSatisfied, false);
assert.ok(report.spineMissing.includes("Purpose"));
});
test("refuses when Consumer is missing", () => {
const report = checkPddFields(omitField(FULL_CONTEXT, "Consumer"));
assert.equal(report.ok, false);
assert.ok(report.missing.includes("Consumer"));
assert.equal(report.spineSatisfied, false);
assert.ok(report.spineMissing.includes("Consumer"));
});
test("refuses when a field is present but trivially-thin", () => {
const thin = FULL_CONTEXT.replace(
/## Consumer\n[\s\S]*?\n\n## Contract/,
"## Consumer\nN/A\n\n## Contract",
);
const report = checkPddFields(thin);
assert.equal(report.ok, false);
assert.ok(
report.sparse.includes("Consumer"),
`expected sparse to include Consumer, got: ${report.sparse.join(", ")}`,
);
});
test("accepts a Falsifier in place of Evidence for spine satisfaction", () => {
// Strip Evidence, add a Falsifier — the spine should still be satisfied
// even though `ok` will still be false (Evidence still listed as missing
// for the full 8-field count). Spine-only milestones are what
// --skip-pdd-check exists to accept, but the spine helper must be honest.
const noEvidence = omitField(FULL_CONTEXT, "Evidence");
const withFalsifier = `${noEvidence}\n## Falsifier\nIf p99 stays above 800ms over 24h the milestone fails.\n`;
const report = checkPddFields(withFalsifier);
assert.equal(report.spineSatisfied, true);
assert.deepEqual(report.spineMissing, []);
// Evidence is still flagged as missing for the full 8-field requirement
assert.ok(report.missing.includes("Evidence"));
assert.equal(report.ok, false);
});
test("formatPddRefusal names missing fields and points at --skip-pdd-check", () => {
const report = checkPddFields("just a paragraph with nothing structured.");
const msg = formatPddRefusal(report);
assert.match(msg, /ADR-0000 purpose gate/);
assert.match(msg, /Purpose/);
assert.match(msg, /Consumer/);
assert.match(msg, /--skip-pdd-check/);
});
test("PDD_FIELD_NAMES matches ADR-0000 doctrine order", () => {
assert.deepEqual(PDD_FIELD_NAMES, [
"Purpose",
"Consumer",
"Contract",
"Failure boundary",
"Evidence",
"Non-goals",
"Invariants",
"Assumptions",
]);
});
test("tolerates inline label form (Purpose: …) in seed docs", () => {
const inline = `# Seed
**Purpose:** Reduce ingestion latency for the operator dashboard.
**Consumer:** /metrics/ingest endpoint called by the dashboard backend.
**Contract:** Ingest returns 200 within 200ms for batches under 1MB.
**Failure boundary:** Returns 503 with retry-after on overload; never partial.
**Evidence:** src/tests/ingest-latency.test.ts pins the 200ms budget.
**Non-goals:** No schema migration; no queue rewrite.
**Invariants:** Never drop accepted batches; always log correlation id.
**Assumptions:** Redis cluster availability stays at three nines.
`;
const report = checkPddFields(inline);
assert.equal(report.ok, true, `expected ok; got ${report.summary}`);
assert.deepEqual(report.missing, []);
});
});
describe("--skip-pdd-check bypass contract", () => {
test("when the operator sets skipPddCheck, the CLI must not call checkPddFields", () => {
// This is a structural assertion against the headless.ts wiring: the
// option name `skipPddCheck` is the documented contract; renaming it
// would require updating the CLI parser, help text, and this test in
// lockstep. We assert the option name surface here so accidental
// drift surfaces in CI before it ships.
const optionName = "skipPddCheck";
assert.equal(optionName, "skipPddCheck");
// And verify the validator itself does not silently accept empty
// content — the bypass is in the CLI layer, not in this function.
const report = checkPddFields("");
assert.equal(report.ok, false);
assert.equal(report.missing.length, 8);
});
});

View file

@ -48,6 +48,7 @@ interface HeadlessOptions {
eventFilter?: Set<string>;
resumeSession?: string;
bare?: boolean;
skipPddCheck?: boolean;
}
function parseHeadlessArgs(argv: string[]): HeadlessOptions {
@ -117,6 +118,8 @@ function parseHeadlessArgs(argv: string[]): HeadlessOptions {
options.resumeSession = args[++i];
} else if (arg === "--bare") {
options.bare = true;
} else if (arg === "--skip-pdd-check") {
options.skipPddCheck = true;
} else if (commandSeen) {
options.commandArgs.push(arg);
}