* 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:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user