From 4ced0a64cc43457ba00b272a12be9c7e61e200d1 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 27 Jun 2026 08:43:47 -0400 Subject: [PATCH 1/2] feat(#1561): assumption-delta advisory checkpoint (#1767) * feat(#1561): assumption-delta advisory checkpoint * chore(#1561): backfill changeset PR number (#1767) --------- Co-authored-by: review-bot --- .changeset/tidy-mice-cheer.md | 5 + capabilities/assumption-delta/capability.json | 45 +++ .../assumption-delta/fragments/plan-pre.md | 53 ++++ docs/CONFIGURATION.md | 1 + docs/INVENTORY-MANIFEST.json | 1 + docs/reference/capability-matrix.md | 3 +- eslint.config.mjs | 1 + gsd-core/bin/gsd-tools.cjs | 41 ++- gsd-core/bin/lib/assumption-delta.cjs | 231 ++++++++++++++ gsd-core/bin/lib/capability-registry.cjs | 69 ++++ src/assumption-delta.cts | 258 +++++++++++++++ .../assumption-delta-checkpoint-e2e.test.cjs | 245 +++++++++++++++ tests/assumption-delta.test.cjs | 297 ++++++++++++++++++ .../golden-install-parity/antigravity.json | 2 +- .../golden-install-parity/augment.json | 2 +- .../golden-install-parity/claude.json | 2 +- .../fixtures/golden-install-parity/cline.json | 2 +- .../golden-install-parity/codebuddy.json | 2 +- .../fixtures/golden-install-parity/codex.json | 2 +- .../golden-install-parity/copilot.json | 2 +- .../golden-install-parity/cursor.json | 2 +- .../golden-install-parity/gemini.json | 2 +- .../golden-install-parity/hermes.json | 2 +- .../fixtures/golden-install-parity/kilo.json | 2 +- .../fixtures/golden-install-parity/kimi.json | 2 +- .../golden-install-parity/opencode.json | 2 +- .../fixtures/golden-install-parity/qwen.json | 2 +- .../fixtures/golden-install-parity/trae.json | 2 +- .../golden-install-parity/windsurf.json | 2 +- tests/plan-pre-hook-e2e.test.cjs | 1 + 30 files changed, 1265 insertions(+), 18 deletions(-) create mode 100644 .changeset/tidy-mice-cheer.md create mode 100644 capabilities/assumption-delta/capability.json create mode 100644 capabilities/assumption-delta/fragments/plan-pre.md create mode 100644 gsd-core/bin/lib/assumption-delta.cjs create mode 100644 src/assumption-delta.cts create mode 100644 tests/assumption-delta-checkpoint-e2e.test.cjs create mode 100644 tests/assumption-delta.test.cjs diff --git a/.changeset/tidy-mice-cheer.md b/.changeset/tidy-mice-cheer.md new file mode 100644 index 000000000..7bb88d225 --- /dev/null +++ b/.changeset/tidy-mice-cheer.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 1767 +--- +**Plural/optional/chosen assumption-delta checkpoint during planning** — when a phase makes something plural, optional, or chosen that used to be singular, required, or derived, the planner is now prompted to re-ask whether the primary key / identity model still names the right thing, preventing silent architectural drift from accumulating into a later user-facing bug. Advisory (non-blocking); fires only on a detected signal. Toggle with workflow.assumption_delta. (#1561) diff --git a/capabilities/assumption-delta/capability.json b/capabilities/assumption-delta/capability.json new file mode 100644 index 000000000..889e01e53 --- /dev/null +++ b/capabilities/assumption-delta/capability.json @@ -0,0 +1,45 @@ +{ + "id": "assumption-delta", + "role": "feature", + "version": "1.6.0", + "title": "Assumption-delta architecture checkpoint", + "description": "Rarely-firing advisory checkpoint that triggers when a phase makes something plural, optional, or chosen that used to be singular, required, or derived. Surfaces one identity-model question (promote the new general representation to primary, or add it alongside?) so a silent primary-key drift does not accumulate into a later user-facing bug. Non-blocking; fires only on a detected signal.", + "tier": "full", + "requires": [], + "engines": { + "gsd": ">=1.6.0" + }, + "runtimeCompat": { + "supported": [ + "*" + ], + "unsupported": [] + }, + "skills": [], + "agents": [], + "hooks": [], + "config": { + "workflow.assumption_delta": { + "type": "boolean", + "default": true, + "description": "Enable the assumption-delta architecture checkpoint during planning. When a pluralization/optional/chosen signal is detected in the phase scope, the planner is prompted to re-ask whether the primary key / identity model still names the right thing. Advisory (non-blocking)." + } + }, + "steps": [], + "contributions": [ + { + "point": "plan:pre", + "into": "planner", + "fragment": { + "path": "fragments/plan-pre.md" + }, + "produces": [], + "consumes": [ + "CONTEXT.md" + ], + "when": "workflow.assumption_delta", + "onError": "skip" + } + ], + "gates": [] +} diff --git a/capabilities/assumption-delta/fragments/plan-pre.md b/capabilities/assumption-delta/fragments/plan-pre.md new file mode 100644 index 000000000..2864d5c2a --- /dev/null +++ b/capabilities/assumption-delta/fragments/plan-pre.md @@ -0,0 +1,53 @@ +# Assumption-Delta Architecture Checkpoint + +> Advisory, non-blocking. Fires **only** when the phase scope shows a singular→plural / required→optional / derived→chosen transition. When it fires, it surfaces ONE identity-model question before the plan is finalized. Most phases will not fire it — that is the point. + +## Why this exists + +Most quietly-imported architectural debt does not come from a missing upfront design phase. It comes at the *seam*: a later phase introduces a second case (a second platform, auth method, tenant, region, source of truth) and nobody re-asks whether the original abstraction still names the right thing. The phase that adds the second case is exactly the 20-minute conversation that prevents an afternoon of later cleanup. + +## Run the detector + +The detector is a deterministic scan over the phase scope text. It strips fenced code blocks first, so a trigger word that appears only inside a code snippet does not fire. It returns a typed result: `{ detected, signals[], terms }`. Resolve it through the `assumption-delta scan` query (same phase-section resolver as `roadmap.get-phase`): + +```bash +ASSUMPTION_DELTA_JSON=$(gsd_run query assumption-delta scan "${PHASE}" --json 2>/dev/null || echo '{"detected":false,"signals":[],"terms":{}}') +``` + +> If the phase section cannot be resolved (no `ROADMAP.md` / unknown phase), the query emits `{ "detected": false, ... }` — the checkpoint does not fire. Do not block on it. +> +> Optional tuning — pass `--terms ` to replace the curated pluralization cues for this project (the `optional`/`chosen` cues keep their defaults): `gsd_run query assumption-delta scan "${PHASE}" --json --terms second,alternative,fallback`. + +## Decision branch + +Read `ASSUMPTION_DELTA_JSON`. Act on `detected` only — do **not** pattern-match the human prose. + +**If `detected` is `false`:** this phase does not change a core assumption. Skip the checkpoint entirely and continue planning. Do not raise it with the user. + +**If `detected` is `true`:** a core assumption may have lost its monopoly. The `signals[]` array tells you which family fired: + +| `kind` | What changed | The question to answer | +|---|---|---| +| `pluralization` | A second X was introduced where there was one (second platform / auth method / tenant / region / source of truth) | Does the current primary key / identity model still name the right noun? | +| `optional` | A required / `only` field became optional | Is the field still the right anchor, or has the anchor moved? | +| `chosen` | A derived value became chosen, or a constant became a parameter | Has a configuration decision become a modeling decision? | + +Before finalizing the plan, answer this for the user and record the decision explicitly: + +> **Promote vs. add-alongside.** The usual correct move when a generalization occurs is to **promote** the new general representation to the primary and **demote** the old specific one to a detail of one variant — *not* to add the new one alongside the still-required old one. Adding alongside silently contradicts the generalized intent (a later variant that does not fit the old primary can be stored but never confirmed as a default). + +Record the outcome in the PLAN.md front matter / a `` block: + +- The **noun** that is now primary (the generalized identity). +- The **decision**: `promote` | `add-alongside` | `no-change`, with a one-line rationale. +- If `add-alongside`: call it out as accepted debt and note what would force a later promote. + +## Optional companion: an invariant test + +When `detected` is `true`, suggest (do not require) a contract/invariant test that encodes the now-generalized intent — e.g. *"every confirmed default round-trips through the primary use-path, for every supported variant."* That test goes red the instant a future phase reintroduces the singular assumption, so the regression cannot land silently. If the user accepts, add the test as a task in the plan. + +## Tuning the vocabulary (optional) + +The trigger vocabulary is a curated, additive-only set in `gsd-core/bin/lib/assumption-delta.cjs` (`DEFAULT_ASSUMPTION_DELTA_TERMS`). Bare "or" is intentionally excluded — it is too common in prose and would make the gate fire constantly. To widen or narrow the cues for a project, override at the call site with `--terms ` (replaces the pluralization cues; `optional`/`chosen` keep defaults). The whole checkpoint is toggleable via `workflow.assumption_delta` in `.planning/config.json`. + +This checkpoint is advisory: it informs and records; it never blocks the phase. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f2c56d050..b3c8d066e 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -286,6 +286,7 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin | `workflow.nyquist_validation` | boolean | `true` | Test coverage mapping during plan-phase research | | `workflow.ui_phase` | boolean | `true` | Generate UI design contracts for frontend phases | | `workflow.ui_safety_gate` | boolean | `true` | Prompt to run /gsd-ui-phase for frontend phases during plan-phase | +| `workflow.assumption_delta` | boolean | `true` | Advisory architecture checkpoint during planning. When a phase makes something **plural, optional, or chosen** that used to be **singular, required, or derived** (e.g. a second auth method, a required field becoming optional, a constant becoming a parameter), the planner is prompted to re-ask whether the primary key / identity model still names the right thing (promote the new general representation vs. add it alongside). Non-blocking; fires only on a detected signal. Bare "or" is intentionally excluded (prose false-positives). Inspect a phase with `gsd query assumption-delta scan `. Added in #1561 | | `workflow.ui_review` | boolean | `true` | Run visual quality audit (`/gsd-ui-review`) after phase execution in autonomous mode. When `false`, the UI audit step is skipped. | | `workflow.node_repair` | boolean | `true` | Autonomous task repair on verification failure | | `workflow.node_repair_budget` | number | `2` | Max repair attempts per failed task | diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 113b5ad54..68c20baf9 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -283,6 +283,7 @@ "agent-command-router.cjs", "agent-install-check.cjs", "artifacts.cjs", + "assumption-delta.cjs", "audit-command-router.cjs", "audit.cjs", "capability-activation.cjs", diff --git a/docs/reference/capability-matrix.md b/docs/reference/capability-matrix.md index 802ed6a2e..8e050085b 100644 --- a/docs/reference/capability-matrix.md +++ b/docs/reference/capability-matrix.md @@ -44,7 +44,7 @@ Core package and are stamped with the package version at release (per ADR-1244 D6). They are not subject to the consent or integrity-pin flow applied to third-party capabilities. -### Feature capabilities (role: feature) — 16 +### Feature capabilities (role: feature) — 17 Feature capabilities extend what the loop does — contributing research, planning, execution, verification, or ship artefacts at the loop extension @@ -53,6 +53,7 @@ points. | id | role | tier | engines.gsd | extension points | hook kinds | source | |---|---|---|---|---|---|---| | `ai-integration` | feature | full | `>=1.6.0` | `plan:pre` | step | first-party | +| `assumption-delta` | feature | full | `>=1.6.0` | `plan:pre` | contribution | first-party | | `audit` | feature | full | `>=1.6.0` | — | — | first-party | | `code-review` | feature | full | `>=1.6.0` | `execute:post` | step | first-party | | `drift` | feature | full | `>=1.6.0` | `plan:pre`, `execute:wave:post` | gate | first-party | diff --git a/eslint.config.mjs b/eslint.config.mjs index f0b10f905..0beede7f5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -75,6 +75,7 @@ export default tseslint.config( 'gsd-core/bin/lib/code-review-flags.cjs', 'gsd-core/bin/lib/context-utilization.cjs', 'gsd-core/bin/lib/artifacts.cjs', + 'gsd-core/bin/lib/assumption-delta.cjs', 'gsd-core/bin/lib/command-arg-projection.cjs', 'gsd-core/bin/lib/clock.cjs', 'gsd-core/bin/lib/ui-safety-gate.cjs', diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index d3428860e..b55356820 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -227,6 +227,8 @@ const { resolveActiveWorkstream, applyResolvedWorkstreamEnv } = require('./lib/a const state = require('./lib/state.cjs'); const phase = require('./lib/phase.cjs'); const roadmap = require('./lib/roadmap.cjs'); +// #1561 — assumption-delta advisory checkpoint detector (pure function). +const { detectAssumptionDelta } = require('./lib/assumption-delta.cjs'); const verify = require('./lib/verify.cjs'); const config = require('./lib/config.cjs'); const template = require('./lib/template.cjs'); @@ -657,7 +659,7 @@ async function main() { // discovery; previously it was a partial subset that didn't include // phase / roadmap / milestone / progress / etc. const TOP_LEVEL_USAGE = 'Usage: gsd-tools [args] [--raw] [--pick ] [--cwd ] [--ws ] [--json-errors]\n' + - 'Commands: agent, agent-skills, audit-open, audit-uat, check, check-commit, commit, commit-to-subrepo, pr-subrepo, ' + + 'Commands: agent, agent-skills, assumption-delta, audit-open, audit-uat, check, check-commit, commit, commit-to-subrepo, pr-subrepo, ' + 'config-ensure-section, config-get, config-new-project, config-path, config-set, migrate-config, ' + 'current-timestamp, detect-custom-files, docs-init, drift-guard, effort, extract-messages, find-phase, ' + 'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' + @@ -1382,6 +1384,43 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand break; } + case 'assumption-delta': { + // #1561 — advisory architecture checkpoint. `scan ` reads the + // phase section via the same resolver as roadmap.get-phase and runs the + // deterministic detectAssumptionDelta, emitting the typed IR as JSON. + const sub = args[1]; + if (sub === 'scan') { + const phaseNum = args[2]; + // Reject missing or flag-shaped phase values (QA matrix: values that + // look like flags). `scan --json` must not treat "--json" as a phase. + if (!phaseNum || phaseNum.startsWith('-')) { + error('Usage: assumption-delta scan [--terms ]', ERROR_REASON.SDK_UNKNOWN_COMMAND); + break; + } + // Optional --terms override (replaces the pluralization cues; + // optional/chosen keep defaults). An EMPTY value ("") or a flag-shaped + // value restores the curated defaults (does NOT disable pluralization). + // Terms are normalized (deduped, alphanumeric-only, capped) by + // detectAssumptionDelta's resolveTerms. + let termsOverride; + const termsIdx = args.indexOf('--terms'); + const termsVal = termsIdx !== -1 ? args[termsIdx + 1] : undefined; + if (typeof termsVal === 'string' && !termsVal.startsWith('-')) { + const list = termsVal + .split(',') + .map((t) => t.trim().toLowerCase()) + .filter((t) => t.length > 0); + termsOverride = list.length > 0 ? { pluralization: list } : undefined; + } + const section = roadmap.getRoadmapPhaseWithFallback(cwd, phaseNum); + const result = detectAssumptionDelta(section ?? '', termsOverride); + output(result, raw); + break; + } + error(`Unknown assumption-delta subcommand: ${sub}. Available: scan`, ERROR_REASON.SDK_UNKNOWN_COMMAND); + break; + } + case 'requirements': { const subcommand = args[1]; if (subcommand === 'mark-complete') { diff --git a/gsd-core/bin/lib/assumption-delta.cjs b/gsd-core/bin/lib/assumption-delta.cjs new file mode 100644 index 000000000..66f42b54d --- /dev/null +++ b/gsd-core/bin/lib/assumption-delta.cjs @@ -0,0 +1,231 @@ +"use strict"; +/** + * Assumption-Delta detector (#1561). + * + * A rarely-firing, advisory architecture checkpoint. When a phase makes + * something PLURAL / OPTIONAL / CHOSEN that used to be SINGULAR / REQUIRED / + * DERIVED, the primary key / identity model may silently stop matching the + * generalized intent. This detector scans phase-scope prose for the linguistic + * signals of that transition so the plan:pre capability hook (see + * capabilities/assumption-delta/) can surface ONE identity-model question. + * + * Design notes (rubber-duck'd): + * - DETERMINISTIC + TYPED IR. The "does it fire?" decision is a pure function + * returning { detected, signals, terms }, not an LLM judgment — so the + * low-false-positive guarantee (acceptance criterion #2) is testable. + * - BARE "or" IS INTENTIONALLY EXCLUDED from the default pluralization cues. + * The issue lists "or" as a tell, but bare "or" is extremely common in + * English prose and would make the gate fire on nearly every phase + * description. Pluralization requires a stronger second-case cue + * (second / alternative / fallback / additional / ...). The vocabulary is + * tunable (config + the `terms` parameter) so teams can widen it. + * - FENCED CODE BLOCKS ARE STRIPPED first (via the markdown-sectionizer seam) + * so a trigger term that appears only inside a code snippet does not fire. + * - Mirrors ui-safety-gate.cts: a pure function + a STDIN-reading CLI whose + * exit codes mirror grep (0 = signal found, 1 = none, 2 = usage error). + * + * Public API: + * detectAssumptionDelta(text, terms?) -> { detected, signals, terms } + * DEFAULT_ASSUMPTION_DELTA_TERMS + * + * CLI: + * echo "$PHASE_SECTION" | node gsd-core/bin/lib/assumption-delta.cjs [--json] + * exit 0 = signal detected, 1 = none, 2 = startup error + * --json additionally prints the typed IR on stdout + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.DEFAULT_ASSUMPTION_DELTA_TERMS = void 0; +exports.detectAssumptionDelta = detectAssumptionDelta; +const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs"); +/** + * Curated default trigger vocabulary. Each kind lists cue terms that signal a + * core-assumption monopoly has been lost. ADDITIVE-ONLY (Hyrum's Law: once + * shipped, this set is a depended-upon interface). Tunable via the `terms` + * parameter or the capability's config slice. + */ +exports.DEFAULT_ASSUMPTION_DELTA_TERMS = { + // Primary trigger — a second X where there was one. + // Bare "or" excluded (prose-frequency false positives). + pluralization: [ + 'second', + 'alternative', + 'alternate', + 'fallback', + 'also', + 'additional', + 'another', + 'supplementary', + 'alongside', + 'multiple', + 'plural', + '2nd', + ], + // required / `only` -> optional + optional: ['optional', 'optionally'], + // derived -> chosen / constant -> parameter + chosen: [ + 'chosen', + 'choose', + 'selectable', + 'configurable', + 'parameterized', + 'parameterised', + 'parameterize', + 'parameterise', + 'custom', + ], +}; +/** Hardening caps for the tunable term vocabulary (Codex review finding). */ +const MAX_TERMS_PER_KIND = 200; +const MAX_TERM_LEN = 32; +/** + * Normalize a caller-provided term list: trim, lowercase, reject empties and + * punctuation-only terms (e.g. "-"), dedupe (preserve order), and cap the + * count/length so a huge or hostile `--terms` value cannot build a giant + * alternation regex or echo a massive payload. Defaults are already clean, so + * this is a no-op on them. + */ +function normalizeTerms(list) { + if (!Array.isArray(list)) + return []; + const seen = new Set(); + const out = []; + for (const raw of list) { + if (typeof raw !== 'string') + continue; + const t = raw.trim().toLowerCase().slice(0, MAX_TERM_LEN); + // Require at least one alphanumeric char so punctuation-only terms like + // "-" cannot match prose punctuation as a "signal". + if (!t || !/[a-z0-9]/.test(t)) + continue; + if (seen.has(t)) + continue; + seen.add(t); + out.push(t); + if (out.length >= MAX_TERMS_PER_KIND) + break; + } + return out; +} +/** + * Resolve the effective term set: per-kind override. An explicitly-provided + * non-empty array for a kind REPLACES that kind's defaults (then normalized); + * an absent kind KEEPS its defaults. An explicitly-empty array disables that + * kind (override present, normalized to []). This lets a caller narrow one axis + * without re-declaring the others. + */ +function resolveTerms(terms) { + const merge = (key) => { + const t = terms && terms[key]; + return Array.isArray(t) ? normalizeTerms(t) : [...exports.DEFAULT_ASSUMPTION_DELTA_TERMS[key]]; + }; + return { + pluralization: merge('pluralization'), + optional: merge('optional'), + chosen: merge('chosen'), + }; +} +/** Trim + collapse + truncate a context window around a match for the snippet. */ +function makeSnippet(line, term) { + const cleaned = line.replace(/\s+/g, ' ').trim(); + if (cleaned.length <= 120) + return cleaned; + // Centre the window on the matched term when the line is long. + const idx = cleaned.toLowerCase().indexOf(term); + if (idx < 0) + return cleaned.slice(0, 120); + const start = Math.max(0, idx - 50); + const end = Math.min(cleaned.length, idx + term.length + 50); + const prefix = start > 0 ? '…' : ''; + const suffix = end < cleaned.length ? '…' : ''; + return `${prefix}${cleaned.slice(start, end)}${suffix}`; +} +/** + * Detect assumption-delta signals in phase-scope prose. + * + * @param text - Roadmap phase section / scope prose. Non-string inputs degrade + * to `{ detected: false }` without throwing. + * @param terms - Optional per-kind override (see resolveTerms). + * @returns typed IR: { detected, signals[], terms }. `terms` is the effective + * (merged) set actually used, so callers/tests can audit what fired. + */ +function detectAssumptionDelta(text, terms) { + if (typeof text !== 'string') { + return { detected: false, signals: [], terms: resolveTerms(terms) }; + } + const effective = resolveTerms(terms); + // Strip fenced code blocks so trigger terms inside code snippets do not fire. + // stripFencedCode is CommonMark-correct and CRLF-safe. + const stripped = (0, markdown_sectionizer_cjs_1.stripFencedCode)(text.replace(/\r\n/g, '\n')).text; + if (stripped.trim().length === 0) { + return { detected: false, signals: [], terms: effective }; + } + const signals = []; + const kinds = ['pluralization', 'optional', 'chosen']; + for (const kind of kinds) { + const cueTerms = effective[kind]; + if (cueTerms.length === 0) + continue; + // Word-boundary anchored, case-insensitive — same shape as ui-safety-gate. + // (^|[^a-zA-Z0-9])(TERM)([^a-zA-Z0-9]|$) prevents interior-substring matches. + const escaped = cueTerms.map(escapeRegex).join('|'); + const pattern = new RegExp('(^|[^a-zA-Z0-9])(' + escaped + ')([^a-zA-Z0-9]|$)', 'gi'); + const seen = new Set(); + for (const line of stripped.split('\n')) { + pattern.lastIndex = 0; + for (const m of line.matchAll(pattern)) { + const raw = m[2]; + if (!raw) + continue; + const matched = raw.toLowerCase(); + const key = `${kind}:${matched}`; + if (seen.has(key)) + continue; + seen.add(key); + signals.push({ kind, term: matched, snippet: makeSnippet(line, matched) }); + } + } + } + return { detected: signals.length > 0, signals, terms: effective }; +} +function escapeRegex(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} +// ── CLI entry point ────────────────────────────────────────────────────────── +// Reads phase-section text from STDIN (not argv) to avoid OS ARG_MAX limits. +// Invoked by workflow bash as: echo "$PHASE_SECTION" | node .../assumption-delta.cjs [--json] +// Exit 0 = signal detected, 1 = none, 2 = startup error. Mirrors ui-safety-gate. +if (require.main === module) { + const argv = process.argv.slice(2); + const wantJson = argv.includes('--json'); + // --terms : config-tunable vocabulary override. Replaces the + // pluralization cues (the primary trigger); optional/chosen keep defaults. + // An EMPTY value ("") or a flag-shaped value restores the curated defaults + // (does NOT disable pluralization). Terms are normalized (deduped, etc.) by + // detectAssumptionDelta's resolveTerms. + let termsOverride; + const termsIdx = argv.indexOf('--terms'); + const termsVal = termsIdx !== -1 ? argv[termsIdx + 1] : undefined; + if (typeof termsVal === 'string' && !termsVal.startsWith('-')) { + const list = termsVal + .split(',') + .map((t) => t.trim().toLowerCase()) + .filter((t) => t.length > 0); + termsOverride = list.length > 0 ? { pluralization: list } : undefined; + } + const chunks = []; + process.stdin.setEncoding('utf-8'); + process.stdin.on('data', (chunk) => chunks.push(chunk)); + process.stdin.on('end', () => { + const input = chunks.join(''); + const result = detectAssumptionDelta(input, termsOverride); + if (wantJson) { + process.stdout.write(JSON.stringify(result) + '\n'); + } + process.exit(result.detected ? 0 : 1); + }); + process.stdin.on('error', (err) => { + process.stderr.write(`ERROR: assumption-delta.cjs stdin read failed: ${err.message}\n`); + process.exit(2); + }); +} diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 4d1b0c64f..6bd774ffa 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -138,6 +138,52 @@ const capabilities = { } } }, + "assumption-delta": { + "id": "assumption-delta", + "role": "feature", + "version": "1.6.0", + "title": "Assumption-delta architecture checkpoint", + "description": "Rarely-firing advisory checkpoint that triggers when a phase makes something plural, optional, or chosen that used to be singular, required, or derived. Surfaces one identity-model question (promote the new general representation to primary, or add it alongside?) so a silent primary-key drift does not accumulate into a later user-facing bug. Non-blocking; fires only on a detected signal.", + "tier": "full", + "requires": [], + "engines": { + "gsd": ">=1.6.0" + }, + "runtimeCompat": { + "supported": [ + "*" + ], + "unsupported": [] + }, + "skills": [], + "agents": [], + "hooks": [], + "config": { + "workflow.assumption_delta": { + "type": "boolean", + "default": true, + "description": "Enable the assumption-delta architecture checkpoint during planning. When a pluralization/optional/chosen signal is detected in the phase scope, the planner is prompted to re-ask whether the primary key / identity model still names the right thing. Advisory (non-blocking)." + } + }, + "steps": [], + "contributions": [ + { + "point": "plan:pre", + "into": "planner", + "fragment": { + "path": "fragments/plan-pre.md", + "inline": "# Assumption-Delta Architecture Checkpoint\n\n> Advisory, non-blocking. Fires **only** when the phase scope shows a singular→plural / required→optional / derived→chosen transition. When it fires, it surfaces ONE identity-model question before the plan is finalized. Most phases will not fire it — that is the point.\n\n## Why this exists\n\nMost quietly-imported architectural debt does not come from a missing upfront design phase. It comes at the *seam*: a later phase introduces a second case (a second platform, auth method, tenant, region, source of truth) and nobody re-asks whether the original abstraction still names the right thing. The phase that adds the second case is exactly the 20-minute conversation that prevents an afternoon of later cleanup.\n\n## Run the detector\n\nThe detector is a deterministic scan over the phase scope text. It strips fenced code blocks first, so a trigger word that appears only inside a code snippet does not fire. It returns a typed result: `{ detected, signals[], terms }`. Resolve it through the `assumption-delta scan` query (same phase-section resolver as `roadmap.get-phase`):\n\n```bash\nASSUMPTION_DELTA_JSON=$(gsd_run query assumption-delta scan \"${PHASE}\" --json 2>/dev/null || echo '{\"detected\":false,\"signals\":[],\"terms\":{}}')\n```\n\n> If the phase section cannot be resolved (no `ROADMAP.md` / unknown phase), the query emits `{ \"detected\": false, ... }` — the checkpoint does not fire. Do not block on it.\n>\n> Optional tuning — pass `--terms ` to replace the curated pluralization cues for this project (the `optional`/`chosen` cues keep their defaults): `gsd_run query assumption-delta scan \"${PHASE}\" --json --terms second,alternative,fallback`.\n\n## Decision branch\n\nRead `ASSUMPTION_DELTA_JSON`. Act on `detected` only — do **not** pattern-match the human prose.\n\n**If `detected` is `false`:** this phase does not change a core assumption. Skip the checkpoint entirely and continue planning. Do not raise it with the user.\n\n**If `detected` is `true`:** a core assumption may have lost its monopoly. The `signals[]` array tells you which family fired:\n\n| `kind` | What changed | The question to answer |\n|---|---|---|\n| `pluralization` | A second X was introduced where there was one (second platform / auth method / tenant / region / source of truth) | Does the current primary key / identity model still name the right noun? |\n| `optional` | A required / `only` field became optional | Is the field still the right anchor, or has the anchor moved? |\n| `chosen` | A derived value became chosen, or a constant became a parameter | Has a configuration decision become a modeling decision? |\n\nBefore finalizing the plan, answer this for the user and record the decision explicitly:\n\n> **Promote vs. add-alongside.** The usual correct move when a generalization occurs is to **promote** the new general representation to the primary and **demote** the old specific one to a detail of one variant — *not* to add the new one alongside the still-required old one. Adding alongside silently contradicts the generalized intent (a later variant that does not fit the old primary can be stored but never confirmed as a default).\n\nRecord the outcome in the PLAN.md front matter / a `` block:\n\n- The **noun** that is now primary (the generalized identity).\n- The **decision**: `promote` | `add-alongside` | `no-change`, with a one-line rationale.\n- If `add-alongside`: call it out as accepted debt and note what would force a later promote.\n\n## Optional companion: an invariant test\n\nWhen `detected` is `true`, suggest (do not require) a contract/invariant test that encodes the now-generalized intent — e.g. *\"every confirmed default round-trips through the primary use-path, for every supported variant.\"* That test goes red the instant a future phase reintroduces the singular assumption, so the regression cannot land silently. If the user accepts, add the test as a task in the plan.\n\n## Tuning the vocabulary (optional)\n\nThe trigger vocabulary is a curated, additive-only set in `gsd-core/bin/lib/assumption-delta.cjs` (`DEFAULT_ASSUMPTION_DELTA_TERMS`). Bare \"or\" is intentionally excluded — it is too common in prose and would make the gate fire constantly. To widen or narrow the cues for a project, override at the call site with `--terms ` (replaces the pluralization cues; `optional`/`chosen` keep defaults). The whole checkpoint is toggleable via `workflow.assumption_delta` in `.planning/config.json`.\n\nThis checkpoint is advisory: it informs and records; it never blocks the phase.\n" + }, + "produces": [], + "consumes": [ + "CONTEXT.md" + ], + "when": "workflow.assumption_delta", + "onError": "skip" + } + ], + "gates": [] + }, "audit": { "id": "audit", "role": "feature", @@ -2556,6 +2602,21 @@ const byLoopPoint = { } ], "contributions": [ + { + "capId": "assumption-delta", + "point": "plan:pre", + "into": "planner", + "fragment": { + "path": "fragments/plan-pre.md", + "inline": "# Assumption-Delta Architecture Checkpoint\n\n> Advisory, non-blocking. Fires **only** when the phase scope shows a singular→plural / required→optional / derived→chosen transition. When it fires, it surfaces ONE identity-model question before the plan is finalized. Most phases will not fire it — that is the point.\n\n## Why this exists\n\nMost quietly-imported architectural debt does not come from a missing upfront design phase. It comes at the *seam*: a later phase introduces a second case (a second platform, auth method, tenant, region, source of truth) and nobody re-asks whether the original abstraction still names the right thing. The phase that adds the second case is exactly the 20-minute conversation that prevents an afternoon of later cleanup.\n\n## Run the detector\n\nThe detector is a deterministic scan over the phase scope text. It strips fenced code blocks first, so a trigger word that appears only inside a code snippet does not fire. It returns a typed result: `{ detected, signals[], terms }`. Resolve it through the `assumption-delta scan` query (same phase-section resolver as `roadmap.get-phase`):\n\n```bash\nASSUMPTION_DELTA_JSON=$(gsd_run query assumption-delta scan \"${PHASE}\" --json 2>/dev/null || echo '{\"detected\":false,\"signals\":[],\"terms\":{}}')\n```\n\n> If the phase section cannot be resolved (no `ROADMAP.md` / unknown phase), the query emits `{ \"detected\": false, ... }` — the checkpoint does not fire. Do not block on it.\n>\n> Optional tuning — pass `--terms ` to replace the curated pluralization cues for this project (the `optional`/`chosen` cues keep their defaults): `gsd_run query assumption-delta scan \"${PHASE}\" --json --terms second,alternative,fallback`.\n\n## Decision branch\n\nRead `ASSUMPTION_DELTA_JSON`. Act on `detected` only — do **not** pattern-match the human prose.\n\n**If `detected` is `false`:** this phase does not change a core assumption. Skip the checkpoint entirely and continue planning. Do not raise it with the user.\n\n**If `detected` is `true`:** a core assumption may have lost its monopoly. The `signals[]` array tells you which family fired:\n\n| `kind` | What changed | The question to answer |\n|---|---|---|\n| `pluralization` | A second X was introduced where there was one (second platform / auth method / tenant / region / source of truth) | Does the current primary key / identity model still name the right noun? |\n| `optional` | A required / `only` field became optional | Is the field still the right anchor, or has the anchor moved? |\n| `chosen` | A derived value became chosen, or a constant became a parameter | Has a configuration decision become a modeling decision? |\n\nBefore finalizing the plan, answer this for the user and record the decision explicitly:\n\n> **Promote vs. add-alongside.** The usual correct move when a generalization occurs is to **promote** the new general representation to the primary and **demote** the old specific one to a detail of one variant — *not* to add the new one alongside the still-required old one. Adding alongside silently contradicts the generalized intent (a later variant that does not fit the old primary can be stored but never confirmed as a default).\n\nRecord the outcome in the PLAN.md front matter / a `` block:\n\n- The **noun** that is now primary (the generalized identity).\n- The **decision**: `promote` | `add-alongside` | `no-change`, with a one-line rationale.\n- If `add-alongside`: call it out as accepted debt and note what would force a later promote.\n\n## Optional companion: an invariant test\n\nWhen `detected` is `true`, suggest (do not require) a contract/invariant test that encodes the now-generalized intent — e.g. *\"every confirmed default round-trips through the primary use-path, for every supported variant.\"* That test goes red the instant a future phase reintroduces the singular assumption, so the regression cannot land silently. If the user accepts, add the test as a task in the plan.\n\n## Tuning the vocabulary (optional)\n\nThe trigger vocabulary is a curated, additive-only set in `gsd-core/bin/lib/assumption-delta.cjs` (`DEFAULT_ASSUMPTION_DELTA_TERMS`). Bare \"or\" is intentionally excluded — it is too common in prose and would make the gate fire constantly. To widen or narrow the cues for a project, override at the call site with `--terms ` (replaces the pluralization cues; `optional`/`chosen` keep defaults). The whole checkpoint is toggleable via `workflow.assumption_delta` in `.planning/config.json`.\n\nThis checkpoint is advisory: it informs and records; it never blocks the phase.\n" + }, + "produces": [], + "consumes": [ + "CONTEXT.md" + ], + "when": "workflow.assumption_delta", + "onError": "skip" + }, { "capId": "schema-gate", "point": "plan:pre", @@ -2859,6 +2920,7 @@ const byLoopPoint = { const configKeys = { "workflow.ai_integration_phase": "ai-integration", + "workflow.assumption_delta": "assumption-delta", "workflow.code_review": "code-review", "workflow.code_review_depth": "code-review", "workflow.drift_threshold": "drift", @@ -2899,6 +2961,12 @@ const configSchema = { "default": true, "description": "Prompt for an AI-SPEC design contract before planning phases that involve AI systems." }, + "workflow.assumption_delta": { + "owner": "assumption-delta", + "type": "boolean", + "default": true, + "description": "Enable the assumption-delta architecture checkpoint during planning. When a pluralization/optional/chosen signal is detected in the phase scope, the planner is prompted to re-ask whether the primary key / identity model still names the right thing. Advisory (non-blocking)." + }, "workflow.code_review": { "owner": "code-review", "type": "boolean", @@ -4572,6 +4640,7 @@ const profileMembership = { const _requiresGraph = { "ai-integration": [], "antigravity": [], + "assumption-delta": [], "audit": [], "augment": [], "claude": [], diff --git a/src/assumption-delta.cts b/src/assumption-delta.cts new file mode 100644 index 000000000..8cae79677 --- /dev/null +++ b/src/assumption-delta.cts @@ -0,0 +1,258 @@ +/** + * Assumption-Delta detector (#1561). + * + * A rarely-firing, advisory architecture checkpoint. When a phase makes + * something PLURAL / OPTIONAL / CHOSEN that used to be SINGULAR / REQUIRED / + * DERIVED, the primary key / identity model may silently stop matching the + * generalized intent. This detector scans phase-scope prose for the linguistic + * signals of that transition so the plan:pre capability hook (see + * capabilities/assumption-delta/) can surface ONE identity-model question. + * + * Design notes (rubber-duck'd): + * - DETERMINISTIC + TYPED IR. The "does it fire?" decision is a pure function + * returning { detected, signals, terms }, not an LLM judgment — so the + * low-false-positive guarantee (acceptance criterion #2) is testable. + * - BARE "or" IS INTENTIONALLY EXCLUDED from the default pluralization cues. + * The issue lists "or" as a tell, but bare "or" is extremely common in + * English prose and would make the gate fire on nearly every phase + * description. Pluralization requires a stronger second-case cue + * (second / alternative / fallback / additional / ...). The vocabulary is + * tunable (config + the `terms` parameter) so teams can widen it. + * - FENCED CODE BLOCKS ARE STRIPPED first (via the markdown-sectionizer seam) + * so a trigger term that appears only inside a code snippet does not fire. + * - Mirrors ui-safety-gate.cts: a pure function + a STDIN-reading CLI whose + * exit codes mirror grep (0 = signal found, 1 = none, 2 = usage error). + * + * Public API: + * detectAssumptionDelta(text, terms?) -> { detected, signals, terms } + * DEFAULT_ASSUMPTION_DELTA_TERMS + * + * CLI: + * echo "$PHASE_SECTION" | node gsd-core/bin/lib/assumption-delta.cjs [--json] + * exit 0 = signal detected, 1 = none, 2 = startup error + * --json additionally prints the typed IR on stdout + */ + +import { stripFencedCode } from './markdown-sectionizer.cjs'; + +export type AssumptionDeltaKind = 'pluralization' | 'optional' | 'chosen'; + +export interface AssumptionDeltaSignal { + kind: AssumptionDeltaKind; + term: string; + snippet: string; +} + +export interface AssumptionDeltaTermSet { + pluralization: string[]; + optional: string[]; + chosen: string[]; +} + +export interface AssumptionDeltaResult { + detected: boolean; + signals: AssumptionDeltaSignal[]; + terms: AssumptionDeltaTermSet; +} + +/** + * Curated default trigger vocabulary. Each kind lists cue terms that signal a + * core-assumption monopoly has been lost. ADDITIVE-ONLY (Hyrum's Law: once + * shipped, this set is a depended-upon interface). Tunable via the `terms` + * parameter or the capability's config slice. + */ +export const DEFAULT_ASSUMPTION_DELTA_TERMS: Readonly = { + // Primary trigger — a second X where there was one. + // Bare "or" excluded (prose-frequency false positives). + pluralization: [ + 'second', + 'alternative', + 'alternate', + 'fallback', + 'also', + 'additional', + 'another', + 'supplementary', + 'alongside', + 'multiple', + 'plural', + '2nd', + ], + // required / `only` -> optional + optional: ['optional', 'optionally'], + // derived -> chosen / constant -> parameter + chosen: [ + 'chosen', + 'choose', + 'selectable', + 'configurable', + 'parameterized', + 'parameterised', + 'parameterize', + 'parameterise', + 'custom', + ], +}; + +/** Hardening caps for the tunable term vocabulary (Codex review finding). */ +const MAX_TERMS_PER_KIND = 200; +const MAX_TERM_LEN = 32; + +/** + * Normalize a caller-provided term list: trim, lowercase, reject empties and + * punctuation-only terms (e.g. "-"), dedupe (preserve order), and cap the + * count/length so a huge or hostile `--terms` value cannot build a giant + * alternation regex or echo a massive payload. Defaults are already clean, so + * this is a no-op on them. + */ +function normalizeTerms(list: unknown): string[] { + if (!Array.isArray(list)) return []; + const seen = new Set(); + const out: string[] = []; + for (const raw of list) { + if (typeof raw !== 'string') continue; + const t = raw.trim().toLowerCase().slice(0, MAX_TERM_LEN); + // Require at least one alphanumeric char so punctuation-only terms like + // "-" cannot match prose punctuation as a "signal". + if (!t || !/[a-z0-9]/.test(t)) continue; + if (seen.has(t)) continue; + seen.add(t); + out.push(t); + if (out.length >= MAX_TERMS_PER_KIND) break; + } + return out; +} + +/** + * Resolve the effective term set: per-kind override. An explicitly-provided + * non-empty array for a kind REPLACES that kind's defaults (then normalized); + * an absent kind KEEPS its defaults. An explicitly-empty array disables that + * kind (override present, normalized to []). This lets a caller narrow one axis + * without re-declaring the others. + */ +function resolveTerms(terms?: Partial): AssumptionDeltaTermSet { + const merge = (key: AssumptionDeltaKind): string[] => { + const t = terms && terms[key]; + return Array.isArray(t) ? normalizeTerms(t) : [...DEFAULT_ASSUMPTION_DELTA_TERMS[key]]; + }; + return { + pluralization: merge('pluralization'), + optional: merge('optional'), + chosen: merge('chosen'), + }; +} + +/** Trim + collapse + truncate a context window around a match for the snippet. */ +function makeSnippet(line: string, term: string): string { + const cleaned = line.replace(/\s+/g, ' ').trim(); + if (cleaned.length <= 120) return cleaned; + // Centre the window on the matched term when the line is long. + const idx = cleaned.toLowerCase().indexOf(term); + if (idx < 0) return cleaned.slice(0, 120); + const start = Math.max(0, idx - 50); + const end = Math.min(cleaned.length, idx + term.length + 50); + const prefix = start > 0 ? '…' : ''; + const suffix = end < cleaned.length ? '…' : ''; + return `${prefix}${cleaned.slice(start, end)}${suffix}`; +} + +/** + * Detect assumption-delta signals in phase-scope prose. + * + * @param text - Roadmap phase section / scope prose. Non-string inputs degrade + * to `{ detected: false }` without throwing. + * @param terms - Optional per-kind override (see resolveTerms). + * @returns typed IR: { detected, signals[], terms }. `terms` is the effective + * (merged) set actually used, so callers/tests can audit what fired. + */ +export function detectAssumptionDelta( + text: unknown, + terms?: Partial, +): AssumptionDeltaResult { + if (typeof text !== 'string') { + return { detected: false, signals: [], terms: resolveTerms(terms) }; + } + + const effective = resolveTerms(terms); + + // Strip fenced code blocks so trigger terms inside code snippets do not fire. + // stripFencedCode is CommonMark-correct and CRLF-safe. + const stripped = stripFencedCode(text.replace(/\r\n/g, '\n')).text; + if (stripped.trim().length === 0) { + return { detected: false, signals: [], terms: effective }; + } + + const signals: AssumptionDeltaSignal[] = []; + const kinds: AssumptionDeltaKind[] = ['pluralization', 'optional', 'chosen']; + + for (const kind of kinds) { + const cueTerms = effective[kind]; + if (cueTerms.length === 0) continue; + // Word-boundary anchored, case-insensitive — same shape as ui-safety-gate. + // (^|[^a-zA-Z0-9])(TERM)([^a-zA-Z0-9]|$) prevents interior-substring matches. + const escaped = cueTerms.map(escapeRegex).join('|'); + const pattern = new RegExp('(^|[^a-zA-Z0-9])(' + escaped + ')([^a-zA-Z0-9]|$)', 'gi'); + const seen = new Set(); + for (const line of stripped.split('\n')) { + pattern.lastIndex = 0; + for (const m of line.matchAll(pattern)) { + const raw = m[2]; + if (!raw) continue; + const matched = raw.toLowerCase(); + const key = `${kind}:${matched}`; + if (seen.has(key)) continue; + seen.add(key); + signals.push({ kind, term: matched, snippet: makeSnippet(line, matched) }); + } + } + } + + return { detected: signals.length > 0, signals, terms: effective }; +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// ── CLI entry point ────────────────────────────────────────────────────────── +// Reads phase-section text from STDIN (not argv) to avoid OS ARG_MAX limits. +// Invoked by workflow bash as: echo "$PHASE_SECTION" | node .../assumption-delta.cjs [--json] +// Exit 0 = signal detected, 1 = none, 2 = startup error. Mirrors ui-safety-gate. + +if (require.main === module) { + const argv = process.argv.slice(2); + const wantJson = argv.includes('--json'); + // --terms : config-tunable vocabulary override. Replaces the + // pluralization cues (the primary trigger); optional/chosen keep defaults. + // An EMPTY value ("") or a flag-shaped value restores the curated defaults + // (does NOT disable pluralization). Terms are normalized (deduped, etc.) by + // detectAssumptionDelta's resolveTerms. + let termsOverride: Partial | undefined; + const termsIdx = argv.indexOf('--terms'); + const termsVal = termsIdx !== -1 ? argv[termsIdx + 1] : undefined; + if (typeof termsVal === 'string' && !termsVal.startsWith('-')) { + const list = termsVal + .split(',') + .map((t) => t.trim().toLowerCase()) + .filter((t) => t.length > 0); + termsOverride = list.length > 0 ? { pluralization: list } : undefined; + } + const chunks: string[] = []; + process.stdin.setEncoding('utf-8'); + + process.stdin.on('data', (chunk: string) => chunks.push(chunk)); + + process.stdin.on('end', () => { + const input = chunks.join(''); + const result = detectAssumptionDelta(input, termsOverride); + if (wantJson) { + process.stdout.write(JSON.stringify(result) + '\n'); + } + process.exit(result.detected ? 0 : 1); + }); + + process.stdin.on('error', (err: Error) => { + process.stderr.write(`ERROR: assumption-delta.cjs stdin read failed: ${err.message}\n`); + process.exit(2); + }); +} diff --git a/tests/assumption-delta-checkpoint-e2e.test.cjs b/tests/assumption-delta-checkpoint-e2e.test.cjs new file mode 100644 index 000000000..7ae030fb4 --- /dev/null +++ b/tests/assumption-delta-checkpoint-e2e.test.cjs @@ -0,0 +1,245 @@ +'use strict'; + +/** + * E2E capability-wiring tests for the assumption-delta checkpoint (#1561). + * + * Drives the real `loop render-hooks plan:pre` CLI subprocess against temp + * projects with different config values and asserts on the typed envelope's + * activeHooks — proving the capability contribution activates/deactivates by + * config (acceptance criteria #4 non-blocking advisory + #6 capability hook). + * + * CONTENT/E2E only: every test drives a real CLI subprocess. No readFileSync + * source-grep. Genuine assertions: each case asserts the SPECIFIC differing + * value (capId presence/absence), not a count (plan:pre carries other + * default-on contributions owned by other capabilities). + */ + +const { describe, test, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const { cleanup } = require('./helpers.cjs'); + +const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); + +const TEST_ENV_BASE = { + GSD_SESSION_KEY: '', + CODEX_THREAD_ID: '', + CLAUDE_SESSION_ID: '', + CLAUDE_CODE_SSE_PORT: '', + OPENCODE_SESSION_ID: '', + GEMINI_SESSION_ID: '', + CURSOR_SESSION_ID: '', + WINDSURF_SESSION_ID: '', + TERM_SESSION_ID: '', + WT_SESSION: '', + TMUX_PANE: '', + ZELLIJ_SESSION_NAME: '', + TTY: '', + SSH_TTY: '', +}; + +function runTools(args, cwd) { + const argv = Array.isArray(args) + ? args + : (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []) + .map((t) => t.replace(/"([^"]*)"/g, '$1').replace(/'([^']*)'/g, '$1')); + + try { + const stdout = execFileSync(process.execPath, [TOOLS_PATH, ...argv], { + cwd, + encoding: 'utf-8', + env: { ...process.env, ...TEST_ENV_BASE }, + timeout: 60000, + }); + return { success: true, output: stdout.trim(), exitCode: 0, error: '' }; + } catch (err) { + return { + success: false, + output: err.stdout?.toString().trim() || '', + error: err.stderr?.toString().trim() || err.message, + exitCode: err.status ?? 1, + }; + } +} + +function makeProject(config) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-adelta-')); + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify(config), + 'utf8' + ); + return tmpDir; +} + +function planPreHooks(cwd) { + const result = runTools('loop render-hooks plan:pre --raw', cwd); + assert.ok(result.success, `render-hooks plan:pre should succeed. stderr: ${result.error}`); + const envelope = JSON.parse(result.output); + assert.strictEqual(envelope.point, 'plan:pre', 'point field must be plan:pre'); + assert.ok(Array.isArray(envelope.activeHooks), 'activeHooks must be an array'); + return envelope; +} + +function findCap(envelope, capId) { + return envelope.activeHooks.find((h) => h.capId === capId) || null; +} + +// ─── ROADMAP fixture for the scan query ────────────────────────────────────── +const ROADMAP = [ + '# Roadmap', + '', + '## v1.0.0', + '', + '### Phase 1: Add a second auth method alongside passwords', + '**Goal:** Users can authenticate via SSO in addition to passwords', + '**Success Criteria**:', + '1. SSO login works', + '2. Password login still works', + '', + '### Phase 2: Refactor the parser for readability', + '**Goal:** Smaller functions, no behavior change', + '**Success Criteria**:', + '1. All existing tests still pass', + '', +].join('\n'); + +function makeRoadmapProject() { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-adelta-rm-')); + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), ROADMAP, 'utf8'); + return tmpDir; +} + +function scanQuery(cwd, phase, extra) { + const argv = ['query', 'assumption-delta', 'scan', String(phase)]; + if (extra) argv.push(...extra); + return runTools(argv, cwd); +} + +describe('assumption-delta scan query — phase-section detection (#1561)', () => { + let tmpDir; + afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } }); + + test('phase with a pluralization signal → detected:true', () => { + tmpDir = makeRoadmapProject(); + const r = scanQuery(tmpDir, 1, ['--json']); + assert.ok(r.success, `scan should succeed. stderr: ${r.error}`); + const parsed = JSON.parse(r.output); + assert.strictEqual(parsed.detected, true); + assert.ok(parsed.signals.some((s) => s.kind === 'pluralization'), 'phase 1 must trip pluralization'); + }); + + test('phase with no signal → detected:false (low false-positive)', () => { + tmpDir = makeRoadmapProject(); + const r = scanQuery(tmpDir, 2, ['--json']); + assert.ok(r.success, `scan should succeed. stderr: ${r.error}`); + const parsed = JSON.parse(r.output); + assert.strictEqual(parsed.detected, false); + assert.deepStrictEqual(parsed.signals, []); + }); + + test('unknown phase → detected:false, no throw (graceful)', () => { + tmpDir = makeRoadmapProject(); + const r = scanQuery(tmpDir, 999, ['--json']); + assert.ok(r.success, `scan should succeed on unknown phase. stderr: ${r.error}`); + assert.strictEqual(JSON.parse(r.output).detected, false); + }); + + test('--terms override narrows the vocabulary (custom cue fires, default cue does not)', () => { + tmpDir = makeRoadmapProject(); + // phase 1 trips "second" by default; override to "xyzzy" → must NOT fire + const r = scanQuery(tmpDir, 1, ['--json', '--terms', 'xyzzy']); + assert.ok(r.success, `scan should succeed. stderr: ${r.error}`); + assert.strictEqual(JSON.parse(r.output).detected, false); + }); + + test('missing phase arg → non-zero exit (usage error)', () => { + tmpDir = makeRoadmapProject(); + const r = runTools(['query', 'assumption-delta', 'scan'], tmpDir); + assert.notStrictEqual(r.exitCode, 0, 'scan with no phase must exit non-zero'); + }); + + // ── Hardening (Codex Step-4 review): malformed args ────────────────────── + test('flag-shaped phase (scan --json) → non-zero exit, not treated as phase', () => { + tmpDir = makeRoadmapProject(); + const r = runTools(['query', 'assumption-delta', 'scan', '--json'], tmpDir); + assert.notStrictEqual(r.exitCode, 0, '"--json" must not be accepted as a phase number'); + }); + + test('empty --terms restores curated defaults (detected, not disabled)', () => { + tmpDir = makeRoadmapProject(); + const r = scanQuery(tmpDir, 1, ['--terms', '', '--json']); + assert.ok(r.success, `scan should succeed. stderr: ${r.error}`); + const parsed = JSON.parse(r.output); + assert.strictEqual(parsed.detected, true, 'phase 1 must still trip defaults under empty --terms'); + assert.ok(parsed.terms.pluralization.includes('second')); + }); + + test('flag-shaped --terms value (--terms --json) falls back to defaults, not treated as a term', () => { + tmpDir = makeRoadmapProject(); + const r = scanQuery(tmpDir, 1, ['--terms', '--json']); + assert.ok(r.success, `scan should succeed. stderr: ${r.error}`); + const parsed = JSON.parse(r.output); + // 'json' must NOT have been consumed as a pluralization cue; defaults apply. + assert.ok(!parsed.terms.pluralization.includes('json'), '"--json" must not become a trigger term'); + assert.ok(parsed.terms.pluralization.includes('second'), 'defaults restored'); + }); +}); + +describe('assumption-delta capability — plan:pre render-hooks wiring (#1561)', () => { + let tmpDir; + afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } }); + + test('default config (no toggle): assumption-delta contribution is ACTIVE', () => { + tmpDir = makeProject({}); + const env = planPreHooks(tmpDir); + const hook = findCap(env, 'assumption-delta'); + assert.ok(hook, 'assumption-delta contribution must be active under default config'); + assert.strictEqual(hook.kind, 'contribution', 'must be a non-blocking contribution, not a gate'); + assert.strictEqual(hook.into, 'planner', 'contribution injects into the planner role'); + assert.strictEqual(hook.when, 'workflow.assumption_delta', 'when must gate on workflow.assumption_delta'); + assert.strictEqual(hook.onError, 'skip', 'onError must be skip (advisory, never halts)'); + // The fragment body must be inlined so the planner receives concrete prose. + const frag = hook.fragment && hook.fragment.inline ? hook.fragment.inline : hook.fragment; + assert.ok(typeof frag === 'string' && frag.length > 0, 'fragment must be inlined as non-empty text'); + assert.ok( + /Assumption-Delta Architecture Checkpoint/i.test(frag), + 'inlined fragment must carry the checkpoint heading' + ); + }); + + test('workflow.assumption_delta=true: contribution is ACTIVE', () => { + tmpDir = makeProject({ workflow: { assumption_delta: true } }); + const env = planPreHooks(tmpDir); + assert.ok(findCap(env, 'assumption-delta'), 'must be active when explicitly true'); + }); + + test('workflow.assumption_delta=false: contribution is INACTIVE (absent from activeHooks)', () => { + tmpDir = makeProject({ workflow: { assumption_delta: false } }); + const env = planPreHooks(tmpDir); + assert.strictEqual( + findCap(env, 'assumption-delta'), + null, + 'must NOT appear in activeHooks when disabled — other default-on plan:pre hooks may still be present' + ); + }); + + test('non-blocking guarantee: no assumption-delta entry is ever a blocking gate', () => { + // Advisory contract (acceptance #4): the checkpoint informs; it never blocks. + // Across both on/off states the capability must never surface as kind=gate + // with blocking=true. + tmpDir = makeProject({ workflow: { assumption_delta: true } }); + const env = planPreHooks(tmpDir); + const gates = env.activeHooks.filter((h) => h.kind === 'gate'); + assert.ok( + gates.every((g) => g.capId !== 'assumption-delta'), + 'assumption-delta must never register a blocking gate at plan:pre' + ); + }); +}); diff --git a/tests/assumption-delta.test.cjs b/tests/assumption-delta.test.cjs new file mode 100644 index 000000000..ccbce009a --- /dev/null +++ b/tests/assumption-delta.test.cjs @@ -0,0 +1,297 @@ +/** + * Tests for the assumption-delta detector (#1561). + * + * The detector is a pure function over phase-scope text that returns a typed + * IR ({ detected, signals, terms }). Tests assert on the IR — never on + * rendered prose — per RULESET.TESTS (no raw text matching on outputs). + * + * The detector mirrors ui-safety-gate.cts: a pure function plus a STDIN-reading + * CLI (exit 0 = signal detected, 1 = none, 2 = usage error). + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const path = require('node:path'); + +const MODULE_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'assumption-delta.cjs'); + +describe('detectAssumptionDelta — pure detector (#1561)', () => { + let mod; + try { + mod = require(MODULE_PATH); + } catch (err) { + // Surface a clear failure if build:lib has not run yet. + throw new Error( + `Could not require ${MODULE_PATH}. Run "npm run build:lib" first. Underlying: ${err.message}` + ); + } + + const { detectAssumptionDelta, DEFAULT_ASSUMPTION_DELTA_TERMS } = mod; + + test('result shape — always carries detected, signals[], terms', () => { + const r = detectAssumptionDelta('refactor the login function'); + assert.strictEqual(r.detected, false); + assert(Array.isArray(r.signals)); + assert.strictEqual(r.signals.length, 0); + assert.ok(r.terms && Array.isArray(r.terms.pluralization)); + assert.ok(Array.isArray(r.terms.optional)); + assert.ok(Array.isArray(r.terms.chosen)); + }); + + test('terms echo is the effective term set actually used', () => { + const r = detectAssumptionDelta('nothing here'); + assert.deepStrictEqual(r.terms.pluralization, [...DEFAULT_ASSUMPTION_DELTA_TERMS.pluralization]); + assert.deepStrictEqual(r.terms.optional, [...DEFAULT_ASSUMPTION_DELTA_TERMS.optional]); + assert.deepStrictEqual(r.terms.chosen, [...DEFAULT_ASSUMPTION_DELTA_TERMS.chosen]); + }); + + // ── Primary trigger: pluralization ─────────────────────────────────────── + for (const cue of ['second auth method', 'alternative platform', 'fallback provider', 'also support a second region', 'an additional source of truth']) { + test(`pluralization fires on: "${cue}"`, () => { + const r = detectAssumptionDelta(cue); + assert.strictEqual(r.detected, true, `expected detection for: ${cue}`); + assert.ok(r.signals.some((s) => s.kind === 'pluralization'), `expected a pluralization signal for: ${cue}`); + }); + } + + // ── Secondary trigger: required → optional ─────────────────────────────── + for (const cue of ['the field becomes optional', 'optionally omitted', 'may be optional now']) { + test(`optional fires on: "${cue}"`, () => { + const r = detectAssumptionDelta(cue); + assert.strictEqual(r.detected, true, `expected detection for: ${cue}`); + assert.ok(r.signals.some((s) => s.kind === 'optional'), `expected an optional signal for: ${cue}`); + }); + } + + // ── Secondary trigger: derived → chosen / constant → parameter ─────────── + for (const cue of ['value is chosen by the caller', 'now configurable per tenant', 'parameterized at runtime', 'selectable in settings']) { + test(`chosen fires on: "${cue}"`, () => { + const r = detectAssumptionDelta(cue); + assert.strictEqual(r.detected, true, `expected detection for: ${cue}`); + assert.ok(r.signals.some((s) => s.kind === 'chosen'), `expected a chosen signal for: ${cue}`); + }); + } + + // ── No-signal phases do NOT fire (acceptance criterion #2 — low FP) ─────── + for (const clean of ['refactor the login function', 'add a unit test for the parser', 'fix the off-by-one in the loop', 'update the README install steps']) { + test(`no-signal phase does NOT fire: "${clean}"`, () => { + const r = detectAssumptionDelta(clean); + assert.strictEqual(r.detected, false, `false positive on: ${clean}`); + assert.strictEqual(r.signals.length, 0); + }); + } + + // ── FALSE-POSITIVE GUARD: bare "or" in prose must NOT fire ──────────────── + // The issue lists "or" as a tell, but bare "or" is extremely common in + // English prose and would make the gate fire constantly. The default term + // set intentionally excludes bare "or"; pluralization requires a stronger + // second-case cue (second/alternative/fallback/also/additional/...). + test('FALSE-POSITIVE GUARD: bare "or" in normal prose does NOT fire', () => { + const r = detectAssumptionDelta('refactor or rewrite the module to be cleaner'); + assert.strictEqual(r.detected, false, 'bare "or" must not fire — it would make every English sentence trip the gate'); + }); + + // ── FALSE-POSITIVE GUARD: trigger term inside a fenced code block ───────── + // A code snippet mentioning "fallback" is not a pluralization of an + // architectural concept. Fenced blocks are stripped before scanning. + test('FALSE-POSITIVE GUARD: trigger term inside a fenced code block does NOT fire', () => { + const scope = [ + 'Add a retry helper to the client.', + '', + '```js', + 'const fallback = () => retry(); // internal var name', + '```', + '', + 'No architectural change here.', + ].join('\n'); + const r = detectAssumptionDelta(scope); + assert.strictEqual(r.detected, false, 'a trigger term appearing only inside a fenced code block must not fire'); + }); + + // ── A real signal in prose still fires even when a code block is present ── + test('signal in prose fires even when an unrelated fenced block is present', () => { + const scope = [ + 'This phase adds a second platform alongside the existing one.', + '', + '```js', + 'const x = 1;', + '```', + ].join('\n'); + const r = detectAssumptionDelta(scope); + assert.strictEqual(r.detected, true); + assert.ok(r.signals.some((s) => s.kind === 'pluralization')); + }); + + // ── signal carries a usable context snippet ────────────────────────────── + test('each signal carries a non-empty snippet with context', () => { + const r = detectAssumptionDelta('This phase introduces a second authentication method.'); + assert.strictEqual(r.detected, true); + const sig = r.signals[0]; + assert.ok(typeof sig.snippet === 'string' && sig.snippet.length > 0); + assert.ok(sig.snippet.toLowerCase().includes(sig.term), 'snippet should contain the matched term'); + }); + + // ── CRLF resilience ─────────────────────────────────────────────────────── + test('CRLF line endings are handled identically to LF', () => { + const lf = detectAssumptionDelta('adds a second region\r\nalso configurable'); + const crlf = detectAssumptionDelta('adds a second region\nalso configurable'); + assert.strictEqual(lf.detected, true); + assert.strictEqual(crlf.detected, true); + assert.strictEqual(lf.signals.length, crlf.signals.length); + }); + + // ── empty / whitespace / non-string inputs degrade to detected:false ────── + test('empty string → detected:false', () => { + assert.strictEqual(detectAssumptionDelta('').detected, false); + }); + test('whitespace-only → detected:false', () => { + assert.strictEqual(detectAssumptionDelta(' \n\t ').detected, false); + }); + test('non-string (null/undefined/number) → detected:false, no throw', () => { + assert.strictEqual(detectAssumptionDelta(null).detected, false); + assert.strictEqual(detectAssumptionDelta(undefined).detected, false); + assert.strictEqual(detectAssumptionDelta(42).detected, false); + }); + + // ── custom term set overrides defaults (config-tunable vocabulary) ──────── + test('custom term set overrides defaults', () => { + const custom = { pluralization: ['xyzzy'], optional: [], chosen: [] }; + const r = detectAssumptionDelta('this phase adds a second platform', custom); + assert.strictEqual(r.detected, false, 'default cue "second" must not fire when defaults are overridden'); + assert.deepStrictEqual(r.terms.pluralization, ['xyzzy']); + const r2 = detectAssumptionDelta('introduces an xyzzy adapter', custom); + assert.strictEqual(r2.detected, true); + assert.ok(r2.signals.some((s) => s.term === 'xyzzy')); + }); + + test('partial custom term set merges over defaults per-kind (absent kinds keep defaults)', () => { + const partial = { pluralization: ['second'] }; + const r = detectAssumptionDelta('now optional', partial); + assert.strictEqual(r.detected, true, 'optional defaults still apply when only pluralization was overridden'); + assert.ok(r.signals.some((s) => s.kind === 'optional')); + }); +}); + +describe('assumption-delta CLI — STDIN exit codes (mirrors ui-safety-gate)', () => { + // Exit code contract: 0 = signal detected, 1 = none, 2 = usage/startup error. + function runCli(stdin) { + const res = spawnSync(process.execPath, [MODULE_PATH], { + input: stdin, + encoding: 'utf-8', + timeout: 15000, + }); + return { status: res.status, stdout: res.stdout ?? '', stderr: res.stderr ?? '' }; + } + + test('exit 0 when a pluralization signal is present', () => { + const r = runCli('This phase adds a second platform alongside the existing one.'); + assert.strictEqual(r.status, 0); + }); + + test('exit 1 when no signal is present', () => { + const r = runCli('Refactor the login function to be smaller.'); + assert.strictEqual(r.status, 1); + }); + + test('exit 1 on empty stdin (no signal)', () => { + const r = runCli(''); + assert.strictEqual(r.status, 1); + }); + + test('--json emits typed IR with detected field on stdout (exit 0)', () => { + const res = spawnSync(process.execPath, [MODULE_PATH, '--json'], { + input: 'introduces a configurable retry policy', + encoding: 'utf-8', + timeout: 15000, + }); + assert.strictEqual(res.status, 0); + const parsed = JSON.parse(res.stdout); + assert.strictEqual(parsed.detected, true); + assert.ok(Array.isArray(parsed.signals)); + assert.ok(parsed.signals.some((s) => s.kind === 'chosen')); + }); + + test('--json emits detected:false on stdout (exit 1) for no-signal input', () => { + const res = spawnSync(process.execPath, [MODULE_PATH, '--json'], { + input: 'just a routine refactor', + encoding: 'utf-8', + timeout: 15000, + }); + assert.strictEqual(res.status, 1); + const parsed = JSON.parse(res.stdout); + assert.strictEqual(parsed.detected, false); + assert.deepStrictEqual(parsed.signals, []); + }); + + // ── --terms config override (config-tunable vocabulary) ─────────────────── + test('--terms overrides the pluralization cues (custom term fires, default cue does not)', () => { + // default cue "second" present, but overridden to "xyzzy" → must NOT fire + const noFire = spawnSync(process.execPath, [MODULE_PATH, '--terms', 'xyzzy', '--json'], { + input: 'adds a second platform', + encoding: 'utf-8', + timeout: 15000, + }); + assert.strictEqual(noFire.status, 1); + assert.strictEqual(JSON.parse(noFire.stdout).detected, false); + + const fire = spawnSync(process.execPath, [MODULE_PATH, '--terms', 'xyzzy', '--json'], { + input: 'introduces an xyzzy adapter', + encoding: 'utf-8', + timeout: 15000, + }); + assert.strictEqual(fire.status, 0); + const parsed = JSON.parse(fire.stdout); + assert.strictEqual(parsed.detected, true); + assert.ok(parsed.signals.some((s) => s.term === 'xyzzy' && s.kind === 'pluralization')); + // optional/chosen defaults are retained by the partial override + assert.ok(parsed.terms.optional.length > 0, 'optional defaults retained under --terms override'); + }); +}); + +// ─── Hardening (Codex Step-4 review fixes) ──────────────────────────────────── +describe('assumption-delta hardening (Codex review)', () => { + const { detectAssumptionDelta } = require(MODULE_PATH); + + test('normalizeTerms: punctuation-only / empty / dupe terms filtered; lowercased', () => { + const r = detectAssumptionDelta('adds a second platform', { + pluralization: ['second', 'second', '-', '', 'XYZZY'], + optional: [], + chosen: [], + }); + // '-' (punct-only) and '' dropped; dupe 'second' collapsed; 'XYZZY'→'xyzzy' + assert.deepStrictEqual(r.terms.pluralization, ['second', 'xyzzy']); + // 'second' survived → detected + assert.strictEqual(r.detected, true); + }); + + test('normalizeTerms: cap guards a huge/hostile term list (no giant regex / echo)', () => { + const huge = Array.from({ length: 250 }, (_, i) => `cue${i}`); + const r = detectAssumptionDelta('routine refactor', { pluralization: huge, optional: [], chosen: [] }); + assert.ok(r.terms.pluralization.length <= 200, `capped to <=200, got ${r.terms.pluralization.length}`); + assert.strictEqual(r.detected, false); + }); + + test('punctuation-only term does NOT match prose punctuation as a signal', () => { + // '-' as a term must not fire on "a - b" prose + const r = detectAssumptionDelta('refactor the parser - keep behavior', { + pluralization: ['-'], + optional: [], + chosen: [], + }); + assert.strictEqual(r.detected, false, 'punctuation-only term must not produce a signal'); + assert.deepStrictEqual(r.terms.pluralization, []); + }); + + test('CLI --terms "" (empty) restores curated defaults (does NOT disable pluralization)', () => { + const res = spawnSync(process.execPath, [MODULE_PATH, '--terms', '', '--json'], { + input: 'adds a second platform', + encoding: 'utf-8', + timeout: 15000, + }); + assert.strictEqual(res.status, 0, 'empty --terms must fall back to defaults → detected'); + const parsed = JSON.parse(res.stdout); + assert.strictEqual(parsed.detected, true); + assert.ok(parsed.terms.pluralization.includes('second'), 'default pluralization cues restored'); + }); +}); diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json index 39d203a1e..8b3fbd085 100644 --- a/tests/fixtures/golden-install-parity/antigravity.json +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -38,7 +38,7 @@ "gsd-core/CHANGELOG.md": "e141e3fb369ff712", "gsd-core/VERSION": "562368b20a64be95", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", - "gsd-core/bin/gsd-tools.cjs": "46deb2174be356dd", + "gsd-core/bin/gsd-tools.cjs": "5cf26d5f9e588cf8", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2", diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json index 9602d3631..38291f89d 100644 --- a/tests/fixtures/golden-install-parity/augment.json +++ b/tests/fixtures/golden-install-parity/augment.json @@ -107,7 +107,7 @@ "gsd-core/CHANGELOG.md": "e141e3fb369ff712", "gsd-core/VERSION": "562368b20a64be95", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", - "gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249", + "gsd-core/bin/gsd-tools.cjs": "74594e8bf36e5580", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2", diff --git a/tests/fixtures/golden-install-parity/claude.json b/tests/fixtures/golden-install-parity/claude.json index 372cf19ad..f2a888378 100644 --- a/tests/fixtures/golden-install-parity/claude.json +++ b/tests/fixtures/golden-install-parity/claude.json @@ -37,7 +37,7 @@ "gsd-core/CHANGELOG.md": "e141e3fb369ff712", "gsd-core/VERSION": "562368b20a64be95", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", - "gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249", + "gsd-core/bin/gsd-tools.cjs": "74594e8bf36e5580", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2", diff --git a/tests/fixtures/golden-install-parity/cline.json b/tests/fixtures/golden-install-parity/cline.json index 3f92dc7d3..ccba55dd2 100644 --- a/tests/fixtures/golden-install-parity/cline.json +++ b/tests/fixtures/golden-install-parity/cline.json @@ -41,7 +41,7 @@ "gsd-core/CHANGELOG.md": "e141e3fb369ff712", "gsd-core/VERSION": "562368b20a64be95", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", - "gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249", + "gsd-core/bin/gsd-tools.cjs": "74594e8bf36e5580", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2", diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json index 91be3f4a1..4ea935a5b 100644 --- a/tests/fixtures/golden-install-parity/codebuddy.json +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -107,7 +107,7 @@ "gsd-core/CHANGELOG.md": "e141e3fb369ff712", "gsd-core/VERSION": "562368b20a64be95", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", - "gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249", + "gsd-core/bin/gsd-tools.cjs": "74594e8bf36e5580", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2", diff --git a/tests/fixtures/golden-install-parity/codex.json b/tests/fixtures/golden-install-parity/codex.json index 10d4f6e37..44c7fae7d 100644 --- a/tests/fixtures/golden-install-parity/codex.json +++ b/tests/fixtures/golden-install-parity/codex.json @@ -73,7 +73,7 @@ "gsd-core/CHANGELOG.md": "e141e3fb369ff712", "gsd-core/VERSION": "562368b20a64be95", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", - "gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249", + "gsd-core/bin/gsd-tools.cjs": "74594e8bf36e5580", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2", diff --git a/tests/fixtures/golden-install-parity/copilot.json b/tests/fixtures/golden-install-parity/copilot.json index cdcc2c120..cae745c94 100644 --- a/tests/fixtures/golden-install-parity/copilot.json +++ b/tests/fixtures/golden-install-parity/copilot.json @@ -39,7 +39,7 @@ "gsd-core/CHANGELOG.md": "e141e3fb369ff712", "gsd-core/VERSION": "562368b20a64be95", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", - "gsd-core/bin/gsd-tools.cjs": "46deb2174be356dd", + "gsd-core/bin/gsd-tools.cjs": "5cf26d5f9e588cf8", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2", diff --git a/tests/fixtures/golden-install-parity/cursor.json b/tests/fixtures/golden-install-parity/cursor.json index f4e4d47c5..2c83e4abe 100644 --- a/tests/fixtures/golden-install-parity/cursor.json +++ b/tests/fixtures/golden-install-parity/cursor.json @@ -107,7 +107,7 @@ "gsd-core/CHANGELOG.md": "e141e3fb369ff712", "gsd-core/VERSION": "562368b20a64be95", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", - "gsd-core/bin/gsd-tools.cjs": "46deb2174be356dd", + "gsd-core/bin/gsd-tools.cjs": "5cf26d5f9e588cf8", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2", diff --git a/tests/fixtures/golden-install-parity/gemini.json b/tests/fixtures/golden-install-parity/gemini.json index 001063ace..426c81ae4 100644 --- a/tests/fixtures/golden-install-parity/gemini.json +++ b/tests/fixtures/golden-install-parity/gemini.json @@ -107,7 +107,7 @@ "gsd-core/CHANGELOG.md": "e141e3fb369ff712", "gsd-core/VERSION": "562368b20a64be95", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", - "gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249", + "gsd-core/bin/gsd-tools.cjs": "74594e8bf36e5580", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index b3ea4da48..c5e386558 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -38,7 +38,7 @@ "gsd-core/CHANGELOG.md": "e141e3fb369ff712", "gsd-core/VERSION": "562368b20a64be95", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", - "gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249", + "gsd-core/bin/gsd-tools.cjs": "74594e8bf36e5580", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2", diff --git a/tests/fixtures/golden-install-parity/kilo.json b/tests/fixtures/golden-install-parity/kilo.json index a2eacbaba..e5cfdf6a7 100644 --- a/tests/fixtures/golden-install-parity/kilo.json +++ b/tests/fixtures/golden-install-parity/kilo.json @@ -107,7 +107,7 @@ "gsd-core/CHANGELOG.md": "e141e3fb369ff712", "gsd-core/VERSION": "562368b20a64be95", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", - "gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249", + "gsd-core/bin/gsd-tools.cjs": "74594e8bf36e5580", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2", diff --git a/tests/fixtures/golden-install-parity/kimi.json b/tests/fixtures/golden-install-parity/kimi.json index 21e06d8a2..e150c8f97 100644 --- a/tests/fixtures/golden-install-parity/kimi.json +++ b/tests/fixtures/golden-install-parity/kimi.json @@ -74,7 +74,7 @@ "gsd-core/CHANGELOG.md": "e141e3fb369ff712", "gsd-core/VERSION": "562368b20a64be95", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", - "gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249", + "gsd-core/bin/gsd-tools.cjs": "74594e8bf36e5580", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index e09fa4f65..10966dba8 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -107,7 +107,7 @@ "gsd-core/CHANGELOG.md": "e141e3fb369ff712", "gsd-core/VERSION": "562368b20a64be95", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", - "gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249", + "gsd-core/bin/gsd-tools.cjs": "74594e8bf36e5580", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index 97c73a585..faeb29fc5 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -38,7 +38,7 @@ "gsd-core/CHANGELOG.md": "e141e3fb369ff712", "gsd-core/VERSION": "562368b20a64be95", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", - "gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249", + "gsd-core/bin/gsd-tools.cjs": "74594e8bf36e5580", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2", diff --git a/tests/fixtures/golden-install-parity/trae.json b/tests/fixtures/golden-install-parity/trae.json index 660f180b7..2bcc9e85b 100644 --- a/tests/fixtures/golden-install-parity/trae.json +++ b/tests/fixtures/golden-install-parity/trae.json @@ -38,7 +38,7 @@ "gsd-core/CHANGELOG.md": "e141e3fb369ff712", "gsd-core/VERSION": "562368b20a64be95", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", - "gsd-core/bin/gsd-tools.cjs": "46deb2174be356dd", + "gsd-core/bin/gsd-tools.cjs": "5cf26d5f9e588cf8", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2", diff --git a/tests/fixtures/golden-install-parity/windsurf.json b/tests/fixtures/golden-install-parity/windsurf.json index 7480efea7..f12c4b968 100644 --- a/tests/fixtures/golden-install-parity/windsurf.json +++ b/tests/fixtures/golden-install-parity/windsurf.json @@ -38,7 +38,7 @@ "gsd-core/CHANGELOG.md": "e141e3fb369ff712", "gsd-core/VERSION": "562368b20a64be95", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", - "gsd-core/bin/gsd-tools.cjs": "46deb2174be356dd", + "gsd-core/bin/gsd-tools.cjs": "5cf26d5f9e588cf8", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2", diff --git a/tests/plan-pre-hook-e2e.test.cjs b/tests/plan-pre-hook-e2e.test.cjs index 6f92b8b75..41b02833f 100644 --- a/tests/plan-pre-hook-e2e.test.cjs +++ b/tests/plan-pre-hook-e2e.test.cjs @@ -254,6 +254,7 @@ describe('plan:pre all-off — empty resolution', () => { pattern_mapper: false, schema_push_detection: false, plan_drift_precheck: false, + assumption_delta: false, }, intel: { enabled: false }, }); From 4732c704f74bb3da635d9a6d159e4827fdad0fcf Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 27 Jun 2026 09:46:07 -0400 Subject: [PATCH 2/2] =?UTF-8?q?docs(#1769):=20ADR-1769=20STATE.md=20Transi?= =?UTF-8?q?tion=20Module=20=E2=80=94=20Phase=200=20(#1770)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce ADR-1769 establishing the STATE.md Transition Module design: intent-based transitions over scattered RMW callbacks. Seven design decisions resolved via /improve-codebase-architecture + /grilling + /domain-modeling: 1. Module shape: Transition Module owns the full transaction 2. Method set: 10 transitions (lifecycle + maintenance + milestoneComplete) 3. I/O shape: pure core (content, intent, deps) -> newContent 4. Policy model: field-classification table 5. External writers: 2 migrate (milestone, phase), 1 stays (verify) 6. Core scope: writes only; body sections as constants 7. Migration: substrate first, transition-by-transition Updates CONTEXT.md with the new Module entry; updates docs/adr/README.md index. No behavior change. Implementation lands in Phases 1-7 per the ADR's migration sequence. Closes #1769 Co-authored-by: review-bot --- CONTEXT.md | 3 + docs/adr/1769-state-md-transition-module.md | 217 ++++++++++++++++++++ docs/adr/README.md | 1 + 3 files changed, 221 insertions(+) create mode 100644 docs/adr/1769-state-md-transition-module.md diff --git a/CONTEXT.md b/CONTEXT.md index 0eb8a720e..c3917daab 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -52,6 +52,9 @@ Module owning projection from dispatch results/errors to CLI `{ exitCode, stdout ### STATE.md Document Module Module owning STATE.md parse, field extraction, field replacement, status normalization, and frontmatter reconstruction. It does not scan `.planning/phases` and does not own persistence or locking; phase/plan/summary counts arrive from inventory/progress Modules as inputs, and read-modify-write paths remain Adapters. Source of truth: `gsd-core/bin/lib/state-document.cjs`. +### STATE.md Transition Module [Planned] +Module owning STATE.md lifecycle/maintenance transitions as intent-based methods (`beginPhase`, `advancePlan`, `completePhase`, `plannedPhase`, `milestoneSwitch`, `milestoneComplete`, `patch`, `sync`, `prune`, `update`). Pure core `(content, intent, deps) → newContent` with injected I/O (file read/write, lock, disk scan); consults a field-classification table that names each STATE.md field's class (`derived-from-body` | `derived-from-disk` | `derived-from-external` | `curated` | `free`) and its preservation policy. Supersedes the 14 scattered RMW callbacks in `state.cts` and the direct `writeStateMd` callers in `milestone.cts:352` and `phase.cts:1770`; verify's `regenerateState` factory-reset primitive stays as a direct `writeStateMd` call. Absorbs `syncStateFrontmatter` + `readModifyWriteStateMd`'s post-sync preservation block; Encoding 3 (`cmdStateBuildFrontmatter`) stays separate — read path concern. Sibling/super-module of the STATE.md Document Module; consumes its `stateReplaceField`/`stateExtractField` primitives. Body section structure (`## Current Position`, `## Session`, etc.) lives as a constants block inside the Module. Append-only transitions (`addDecision`, `addBlocker`, etc.) stay on today's RMW seam for now. Targets the #1760/#1761/#1743/#1695/#1264/#1255/#1257/#3242 bug cluster. Migration per ADR-1372 §T6 sequenced as substrate + `beginPhase` first (PR1), then transition-by-transition with characterization tests first per transition. Source of truth: `gsd-core/bin/lib/state-transition.cjs` (planned, generated from `src/state-transition.cts`). + ### Query Execution Policy Module Module owning query transport routing policy projection (`preferNative`, fallback policy, workstream subprocess forcing) at execution seam. diff --git a/docs/adr/1769-state-md-transition-module.md b/docs/adr/1769-state-md-transition-module.md new file mode 100644 index 000000000..1fa4a4b53 --- /dev/null +++ b/docs/adr/1769-state-md-transition-module.md @@ -0,0 +1,217 @@ +# ADR-1769: STATE.md Transition Module — intent-based transitions over scattered RMW callbacks + +- **Status:** Proposed (Phase 0); **Accepted** at Phase 7 closeout +- **Date:** 2026-06-27 (Phase 0) +- **Issue:** [#1769](https://github.com/open-gsd/gsd-core/issues/1769) — epic +- **Supersedes:** the policy portions of `syncStateFrontmatter` (`src/state.cts:1667–1743`), + `readModifyWriteStateMd`'s post-sync preservation block (`src/state.cts:2008–2119`), and + the format-detection duplication across 14 RMW callbacks in `state.cts` plus direct + `writeStateMd` callers in `phase.cts:1770` and `milestone.cts:352`. Does NOT supersede + `state-document.cjs`'s parse/extract/replace primitives — those are deep and stay. + +## Context + +`CONTEXT.md`'s "STATE.md Document Module" entry describes a deep leaf: parse, extract, +replace primitives, no persistence or locking. The friction lives in the **adapter layer** +above it. + +STATE.md writes happen via three mechanisms today: + +1. **`readModifyWriteStateMd(statePath, transformFn, cwd, options, clock)`** — the RMW seam. + Called from 14 sites in `state.cts`, each passing a closure that re-encodes format + detection, field preservation, and section mutation. +2. **Direct `writeStateMd` / `syncStateFrontmatter` calls** in `milestone.cts:352` + (milestone complete), `verify.cts:1899` (regenerateState recovery), and + `phase.cts:1770` (phase complete state-half, inside `writePlanningFileSet`). +3. **`cmdStateBuildFrontmatter`** — Encoding 3 of the preservation policy, used by the + read path (`cmdStateJson`). + +The **preservation policy** ("which fields win when frontmatter and body disagree?") is +encoded in **three places that drift independently**: + +- `syncStateFrontmatter` (`state.cts:1667–1743`) — preserves status when derived='unknown', + preserves milestone_name vs. placeholder, preserves stopped_at/paused_at/current_phase/ + current_phase_name/current_plan/progress when derived is empty. +- `readModifyWriteStateMd` post-sync block (`state.cts:2069–2113`) — restores progress + block when `resync=false`, preserves status/stopped_at when body source field unchanged + (#1230 delta heuristic). +- `cmdStateBuildFrontmatter` (`state.cts:1469+`) — own copies of the status/progress + preservation rules. + +The same field has different rules in different encodings. `progress` has two rules. +`status` has three. `stopped_at` has two. + +**Bug cluster this produces:** #1760 (prune no-ops), #1761 (sync writes wrong progress), +#1743 (patch clobbers curated field), #1695 (patch clobbers current_phase_name), #1264 +(resync=false restore), #1255/#1257 (format mismatch), #3242 (curated-progress ratchet). +Every fix is per-call-site and doesn't touch the other 13. + +The friction shape: ADR-857/1372/1508/3660 correctly identified the **leaf modules** and +gave them depth. The remaining friction is in the **adapter layer** that didn't finish +thinning. The recurring pattern is *"seam adopted for locating, hand-rolled code retained +for mutating"* — `tokenizeHeadings` was adopted, `replaceSection` was not. + +## Decision + +Introduce a **STATE.md Transition Module** as a sibling/super-module of the STATE.md Document +Module. Seven design decisions, resolved via `/grilling`: + +### 1. Module shape: Transition Module owns the full transaction + +The Module owns lock → read → apply transition → preserve policy → write. Interface is +intent-based: `beginPhase(statePath, phaseNum)`, `advancePlan(statePath, planId)`, etc. +The 14 RMW callbacks collapse to 14 one-line transition calls. + +*Rejected:* (B) Field Policy Module (locks stay outside) — leaves lock/scan bugs tangled +with policy bugs. (C) Widen state-document — shallow; format-detection + I/O concerns +leak in. + +### 2. Method set: 10 transitions (lifecycle + maintenance + milestoneComplete) + +`beginPhase`, `advancePlan`, `completePhase`, `plannedPhase`, `milestoneSwitch`, +`milestoneComplete`, `patch`, `sync`, `prune`, `update`. + +*Rejected:* (i) Lifecycle only (5) — leaves maintenance bugs #1760/#1743 alive. +(iii) All 16 writers — interface balloons, hurting depth. + +### 3. I/O shape: pure core + injected deps + +`transitionCore(content, intent, deps) → newContent` where +`deps = { progressProvider, writer, locker, clock }`. A thin adapter wires the real I/O. +Tests pass stubs. + +*Rejected:* (a) Absorb all four I/O concerns — too much fs surface in the core. +(b) Own transaction + lock; delegate disk scan — awkward coupling. + +This shape also enables `phase.cts:1770`'s use case: the transition core is called inside +`writePlanningFileSet(writes)` (multi-file transaction), not just inside `writeStateMd`. +Pure function = leverage across multiple orchestration shapes. Concretely: in Phase 3, +`completePhase` runs inside the multi-file ROADMAP+REQUIREMENTS+STATE transaction without +the transition core knowing it is inside a transaction. The core only sees +`(content, intent, deps) → newContent`; the orchestrator handles lock acquisition, +multi-file atomicity, and write ordering. This isolates the atomicity risk to the +orchestration layer, where the existing `writePlanningFileSet` already owns it. + +### 4. Policy model: field-classification table + +Each STATE.md field has a row: `{ source: body|disk|external|curated|free, preservation: +derive|preserve-when-unchanged|preserve-always|clear }`. Transitions declare which body +fields they touch; the core consults the table. + +*Rejected:* (α) Per-transition hardcoded policy — duplicates the rule × 10 transitions = +today's pain. (γ) Per-transition DSL — adds learning cost, still drifts. + +The table kills the bug class by construction: `patch` consulting the table sees +`current_phase_name` is `curated` and refuses to overwrite unless explicitly named. #1743 +impossible. + +### 5. External writers: 2 migrate, 1 stays + +- `milestone.cts:352` (milestone complete) → migrates to `milestoneComplete` transition. +- `phase.cts:1770` (phase complete state-half) → migrates to `completePhase` transition, + called as pure function inside the existing multi-file transaction. +- `verify.cts:1899` (`regenerateState` recovery) → **stays as direct `writeStateMd`**. + Factory-reset primitive, not a transition; nothing to preserve. + +### 6. Core scope: writes only + +Core owns the table + 10 transitions + format detection + preservation + frontmatter/body +invariant. Body section structure (`## Current Position`, `## Session`, etc.) lives as a +constants block at the top of the core. + +*Outside the core:* + +- Append-only transitions (`addDecision`, `addBlocker`, etc.) — stay on today's RMW. They + don't touch curated fields; routing them through the core adds interface width without + buying depth. +- Read path (`cmdStateBuildFrontmatter`, Encoding 3) — different concern (interpretation, + not preservation). Stays separate. +- `verify.cts:1899` regenerateState — factory reset, not a transition. + +*Rejected:* (B) Widen to reads — grows the table past preservation into interpretation. +(C) Absorb append-only — interface balloons to ~17 methods. + +### 7. Migration: substrate first, then transition-by-transition + +Per ADR-1372 §T6's "high risk, load-bearing, surgical, last" rating, big-bang is the wrong +shape. The migration sequence: + +- **Phase 0:** ADR + CONTEXT.md update (this PR). +- **Phase 1:** Substrate — transition core skeleton + table + section constants + + `beginPhase` migration + characterization tests. +- **Phase 2:** `advancePlan`. +- **Phase 3:** `completePhase` + `phase.cts:1770` migration (proves pure-core-inside- + multi-file-transaction). +- **Phase 4:** `plannedPhase`, `milestoneSwitch`. +- **Phase 5:** `milestoneComplete` + `milestone.cts:352` migration. +- **Phase 6:** `patch` (covers #1743, #1695). +- **Phase 7:** `sync`, `prune`, `update` (covers #1760, #1761). + +Per-transition discipline: characterization tests first (capture current behavior including +the bug-preservation we want to keep, e.g. #1230's delta heuristic), then migrate, then +verify old tests still pass, then add bug-fix tests for what the migration fixes. + +*Rejected:* (A) Big-bang — half-finished migration is what we're fixing. (C) Substrate +alongside, leave callbacks — parallel worlds don't converge (ADR-857's failure mode). + +## Consequences + +**Positive:** + +- Bug cluster killed structurally: #1760/#1761/#1743/#1695/#1264/#1255/#1257/#3242 each + become impossible-by-construction or one-row table fixes. +- ADR-1372 §T6 completed: `replaceSection` from the markdown sectionizer becomes the + body-mutation primitive inside the transition core. +- Test surface improves: tests pass `deps` stubs, no `.planning/phases/*` fixtures required + for policy tests. +- `phase.cts:1770` and `milestone.cts:352` stop reimplementing format detection and + section regexes (the `// allow-adhoc-markdown: pre-seam section write-modify` lint + suppressions in `milestone.cts` are deleted). + +**Negative:** + +- 10 transitions × characterization tests = substantial test volume in Phase 1 substrate. +- The field-classification table is a new shared artifact — adding a new STATE.md field + means one table row, but a new field *class* means updating every transition that + declares which classes it touches. (Single-enum: 5 classes; expected to be stable.) +- Migration is sequenced (8 phases, 8 PRs) — the codebase carries both old and new shapes + between Phase 1 and Phase 7. Each PR is independently shippable; the old shape is fully + retired only at Phase 7. + +**Neutral:** + +- `readModifyWriteStateMd` and `writeStateMd` stay exported during the migration; they're + used by the 7 append-only callbacks that aren't migrating. +- `cmdStateBuildFrontmatter` (Encoding 3) stays as the read path; future work may converge + it with the table, but that's out of scope here. + +## Alternatives considered + +1. **Widen STATE.md Document Module to absorb the policy.** Rejected — Document Module owns + pure parse/extract/replace; widening pulls format detection and preservation policy into + the leaf, hurting its depth. +2. **Per-transition hardcoded policy (option α in design).** Rejected — duplicates the rule + per transition; same field has different rules in different transitions = today's pain. +3. **All 16 writers in core (option iii in design).** Rejected — interface balloons to ~17 + methods, hurting depth. Append-only transitions don't touch curated fields. +4. **Big-bang migration (option A in design).** Rejected — ADR-1372 §T6 rates this surface + "high risk, surgical." Half-finished migration is what we're fixing. +5. **Substrate alongside, leave callbacks (option C in design).** Rejected — parallel worlds + don't converge. ADR-857's "decompose Core" produced today's half-finished state precisely + because new code moved and old code stayed. +6. **Defer until ADR-1372 §T6 finishes independently.** Rejected — §T6 has been deferred for + over a year precisely because there's no consumer for `replaceSection` in the state path. + The Transition Module is the consumer; the two land together. + +## Phases + +| Phase | Scope | Closes issue | Bug coverage | +|---|---|---|---| +| 0 | ADR + CONTEXT.md update | #1769 | — | +| 1 | Substrate + `beginPhase` | TBD | #1255, #1257, #3242 | +| 2 | `advancePlan` | TBD | — | +| 3 | `completePhase` + `phase.cts:1770` | TBD | — | +| 4 | `plannedPhase` + `milestoneSwitch` | TBD | — | +| 5 | `milestoneComplete` + `milestone.cts:352` | TBD | — | +| 6 | `patch` | TBD | #1743, #1695 | +| 7 | `sync`, `prune`, `update` | TBD | #1760, #1761 | diff --git a/docs/adr/README.md b/docs/adr/README.md index 5ecdce327..a1a9ee823 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -61,6 +61,7 @@ See **[CONTRIBUTING.md — "Proposing an ADR or PRD"](../../CONTRIBUTING.md#prop | [1411-resolution-provenance.md](1411-resolution-provenance.md) | Resolution must report provenance, not fall open silently | Accepted | | [1508-runtime-artifact-conversion-module.md](1508-runtime-artifact-conversion-module.md) | Runtime Artifact Conversion Module owns per-runtime content rewriting | Accepted | | [1593-skill-mapping-converter-methodology.md](1593-skill-mapping-converter-methodology.md) | Skill mapping & converter methodology across runtimes | Accepted | +| [1769-state-md-transition-module.md](1769-state-md-transition-module.md) | STATE.md Transition Module — intent-based transitions over scattered RMW callbacks | Proposed | ## Seam map