feat(#1956): flag cross-artifact fact drift in the plan drift guard (#3259)

* test(#1956): failing-first contract for cross-artifact fact-drift pass

* feat(#1956): flag cross-artifact fact drift in the plan drift guard

* fix(#1956): correct config-key assertion and bidirectional lifecycle-lag exemption

* docs(#1956): document the cross-artifact axis in the architecture reference

* feat(#1956): decide the phase-status drift axis deterministically

* fix(#1956): scope the progress-table lookup, abstain without a position section, rank deferred

* docs(#1956): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-09 14:23:35 -04:00
committed by GitHub
parent 9f57fa43ed
commit b7431a9259
14 changed files with 1116 additions and 11 deletions

View File

@@ -80,6 +80,7 @@
* drift-guard severity --status <S> Classify a symbol verdict into { severity, hardBlock }
* [--authority <A>] Status: VERIFIED|MISSING|AMBIGUOUS|UNCHECKABLE
* Authority: grep|intel|treesitter|lsp|scip (default: config-resolved)
* drift-guard phase-status [--phase N] Compare STATE.md vs ROADMAP.md phase status
*
* Validation:
* validate consistency Check phase numbering, disk/roadmap sync
@@ -305,7 +306,7 @@ const { routeCheckCommand } = require('./lib/check-command-router.cjs');
const { routeTaskCommand } = require('./lib/task-command-router.cjs');
const { parseNamedArgs, parseMultiwordArg } = require('./lib/command-arg-projection.cjs');
const { cmdGitBaseBranch } = require('./lib/git-base-branch.cjs');
const { getEffectiveAuthority, classifyDriftSeverity } = require('./lib/plan-drift-guard.cjs');
const { getEffectiveAuthority, classifyDriftSeverity, comparePhaseStatus } = require('./lib/plan-drift-guard.cjs');
// ─── Bridge collapsed (Phase 4) ────────────────────────────────────────────────
// Non-family commands now run through their CJS handlers directly. Keep the
@@ -3283,8 +3284,146 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
return;
}
if (subcommand === 'phase-status') {
// #1956: deterministic STATE.md-vs-ROADMAP.md phase-status drift.
const { planningDir } = require('./lib/planning-workspace.cjs');
const { stateExtractField, stateCurrentPositionSlice } = require('./lib/state-document.cjs');
const { findRoadmapProgressTable } = require('./lib/roadmap-parser.cjs');
const { phaseKeyFromProse } = require('./lib/phase-id.cjs');
// STATE.md's YAML frontmatter carries its own lowercase `status:`
// scalar ahead of the body's `## Current Position` prose "Status:"
// line; stateExtractField's non-scoped regex would otherwise match
// that frontmatter line first (it comes first in the file) and
// silently report the wrong value. Strip frontmatter so extraction
// is scoped to the body.
const { stripFrontmatter } = require('./lib/frontmatter.cjs');
const phaseIdx = args.indexOf('--phase');
const phaseArg = (phaseIdx !== -1 && args[phaseIdx + 1] && !args[phaseIdx + 1].startsWith('--'))
? args[phaseIdx + 1]
: undefined;
const dir = planningDir(cwd);
const statePath = path.join(dir, 'STATE.md');
const roadmapPath = path.join(dir, 'ROADMAP.md');
let stateContent = null;
try {
stateContent = fs.readFileSync(statePath, 'utf-8');
} catch {
// missing_state below
}
if (stateContent === null) {
const phase = phaseArg !== undefined ? phaseKeyFromProse(phaseArg) : null;
output({
verdict: 'uncheckable',
reason: 'missing_state',
phase,
stateStatus: null,
roadmapStatus: null,
authority: 'STATE.md',
}, raw);
return;
}
let roadmapContent = null;
try {
roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
} catch {
// missing_roadmap below
}
const stateBody = stripFrontmatter(stateContent);
// #1956 fix: scope extraction to `## Current Position` (or `###`
// in the bootstrap template) so a historical `Phase:` / `Status:`
// line in an archive section (e.g. `## Session Continuity
// Archive`) can't shadow the real one — same #2956 scope state.cts
// uses for current_phase, via the shared owner in
// state-document.cjs.
//
// Deliberately NO whole-body fallback here. `state.cts`'s WRITE
// path falls back to the whole body when no Current Position
// heading is found (legacy behavior it must preserve for
// backward-compatible writes) — but that fallback is wrong for a
// READ that feeds a drift finding: a STATE.md with no Current
// Position heading is exactly the shape that let a stray historical
// `Status:` line elsewhere in the body shadow the real value and
// fabricate a 'drifted' verdict. A guess is worse than an
// abstention for a drift detector, so an absent Current Position
// section reports 'uncheckable' instead of guessing from the whole
// document.
const currentPositionBody = stateCurrentPositionSlice(stateBody);
if (currentPositionBody === null) {
const phase = phaseArg !== undefined ? phaseKeyFromProse(phaseArg) : null;
output({
verdict: 'uncheckable',
reason: 'no_current_position',
phase,
stateStatus: null,
roadmapStatus: null,
authority: 'STATE.md',
}, raw);
return;
}
// Resolve the target phase: --phase if given, else whatever
// STATE.md's Current Position reports as current.
const phase = phaseArg !== undefined
? phaseKeyFromProse(phaseArg)
: phaseKeyFromProse(stateExtractField(currentPositionBody, 'Phase'));
if (roadmapContent === null) {
output({
verdict: 'uncheckable',
reason: 'missing_roadmap',
phase,
stateStatus: null,
roadmapStatus: null,
authority: 'STATE.md',
}, raw);
return;
}
const stateStatus = stateExtractField(currentPositionBody, 'Status');
// #1956/#2012: scoped to `## Progress` first (decoy-avoidance) —
// see findRoadmapProgressTable's doc comment (roadmap-parser.cts).
const table = findRoadmapProgressTable(roadmapContent);
const matchedRow = table
? table.rows.find((row) => phaseKeyFromProse(row.Phase) === phase && phase !== null)
: undefined;
if (!matchedRow) {
const result = comparePhaseStatus({ stateStatus, roadmapStatus: null });
output({
verdict: 'uncheckable',
reason: 'phase_not_in_roadmap',
phase,
stateStatus,
roadmapStatus: null,
stateRank: result.stateRank,
roadmapRank: result.roadmapRank,
authority: 'STATE.md',
}, raw);
return;
}
const roadmapStatus = matchedRow.Status;
const result = comparePhaseStatus({ stateStatus, roadmapStatus });
output({
verdict: result.verdict,
phase,
stateStatus,
roadmapStatus,
stateRank: result.stateRank,
roadmapRank: result.roadmapRank,
authority: 'STATE.md',
}, raw);
return;
}
error(
`Unknown drift-guard subcommand: ${subcommand || '(none)'}. Available: authority, severity`,
`Unknown drift-guard subcommand: ${subcommand || '(none)'}. Available: authority, severity, phase-status`,
ERROR_REASON.SDK_UNKNOWN_COMMAND,
);
}

View File

@@ -254,6 +254,51 @@ Run this pass unless `plan_review.source_grounding` is `false`. It verifies ever
- Signature mismatches cannot be asserted under `grep`/`intel`; report the signature as UNCHECKABLE.
5. **Coverage block.** Append a "Verification coverage" section to `REVIEWS.md` listing every UNCHECKABLE/skipped symbol and why — a clean review must never silently mean "nothing was checked."
### Cross-artifact fact-drift pass (same gate: `plan_review.source_grounding`)
Run this pass whenever the source-grounding pass ran — it is the second axis of the same drift guard, gated by the same `plan_review.source_grounding` key and adding no config surface of its own. Where source-grounding asks *"does this symbol exist in the source?"*, this asks *"does the project state the same fact in two planning artifacts, and do the two disagree?"* Because each phase runs in a fresh context, an agent typically reads only one artifact and trusts it, so a stale duplicate silently steers it wrong.
**Key on knowledge, not on similar text.** DRY is about a single authoritative representation of a piece of *knowledge*. Two passages that merely read alike, or that restate one fact at different levels of detail, are NOT drift. Only a contradiction is.
1. **Phase status — decided by the seam, not by judgment.** Do not eyeball this axis:
```bash
DRIFT=$(gsd_run drift-guard phase-status --phase "${PHASE}")
# $DRIFT is JSON: {"verdict":"consistent|lag|drifted|uncheckable","stateStatus":…,"roadmapStatus":…}
```
- `drifted` — STATE.md and ROADMAP.md contradict each other. Report it; the authority is STATE.md.
- `lag` — one lifecycle step apart between non-terminal statuses. NOT a finding.
- `consistent` — nothing to report.
- `uncheckable` — a document was absent or carried a status outside both vocabularies. Record it in the coverage block; never read it as consistent.
Completeness is terminal: when exactly one side says the phase is complete, the verdict is `drifted` and never `lag`, however few steps apart the two words look.
2. **Pair up the remaining facts by judgment.** The authority column names the source of truth, so a finding can say which side to keep:
| Fact class | Artifact pair | Authority | Decided by |
|---|---|---|---|
| Success criteria / must-have truths | ROADMAP.md Success Criteria ↔ PLAN.md `must_haves.truths` | ROADMAP.md | judgment |
| Requirement IDs | ROADMAP.md `**Requirements:**` ↔ PLAN.md task requirement refs | ROADMAP.md | judgment |
| Phase status | STATE.md status ↔ ROADMAP.md phase state | STATE.md | step 1 (deterministic) |
| Glossary / domain term | CONTEXT.md `Decisions` ↔ PLAN.md usage of the term | CONTEXT.md | judgment |
3. **Judge each judgment pair.** FLAG only when ALL THREE hold:
1. both sides name the *same* fact — same requirement ID, same success criterion, or the same defined term; and
2. the two representations *contradict*, one asserting what the other denies, rather than differing in wording or in level of detail; and
3. the pair is one of the judgment pairs above.
4. **Record.** Emit each finding into `REVIEWS.md` beside the source-grounding coverage block, quoting both locations and naming the divergence and the authority, so the author can collapse the two copies to a single source of truth.
**Do NOT flag:** a wording-only difference that asserts the same thing; a fact that appears in one artifact only — single-source is the target state, not a finding; a PLAN that ADDS a truth beyond the roadmap Success Criteria, which is sanctioned (plans may add, never subtract); a `lag` verdict from step 1 — two non-terminal statuses a single lifecycle step apart, in either direction, since STATE.md is written at planning time independently of ROADMAP.md and can lead as readily as trail (a disagreement about *completion* is never lag, and step 1 already reports it as `drifted`); anything under CONTEXT.md's `Claude's Discretion` or `Deferred Ideas`, which are non-authoritative by design.
**Report once, not twice — these belong to `gsd-plan-checker`:** a PLAN that omits a roadmap Success Criterion is scope reduction (Dimension 7b); a requirement ID the ROADMAP never defines is requirement coverage (Dimension 1); two PLAN.md files in one phase disagreeing is cross-plan data contracts (Dimension 9).
**Severity: advisory, never a blocker.** This pass never sets `hardBlock`, and its findings contribute to neither `HIGH_COUNT` nor `ACTIONABLE_COUNT` — a project carrying pre-existing drift must still be able to converge, or an advisory check becomes an endless replan loop.
**Coverage, never silence.** If STATE.md or CONTEXT.md is absent, that axis is skipped and the skip is recorded in the same "Verification coverage" block. A clean pass must never mean "nothing was compared."
After agent returns, verify REVIEWS.md exists:
```bash
REVIEWS_FILE=$(ls ${phase_dir}/${padded_phase}-REVIEWS.md 2>/dev/null)