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

@@ -148,3 +148,146 @@ export function classifyDriftSeverity({
return { severity: 'INFO', hardBlock: false };
}
}
// ─── #1956 cross-artifact phase-status drift ────────────────────────────────
/** Verdict from comparing one phase's status across STATE.md and ROADMAP.md. */
export type PhaseStatusVerdict = 'consistent' | 'lag' | 'drifted' | 'uncheckable';
/** Result of comparePhaseStatus. */
export interface PhaseStatusResult {
verdict: PhaseStatusVerdict;
stateRank: number | null;
roadmapRank: number | null;
}
/**
* Frozen map from lowercased phase-status text to a shared ordinal rank,
* covering the union of the STATE.md "Current Position" vocabulary
* (gsd-core/templates/state.md) and the FULL ROADMAP.md "## Progress" table
* Status column vocabulary declared by gsd-core/templates/roadmap.md:133 —
* `Not started | In progress | Complete | Deferred`.
*
* The two vocabularies overlap on 'in progress', which is rank 1 in both —
* no conflict. 'deferred' is rank 0 (no work done) — see comparePhaseStatus's
* doc comment for how a deferred/non-rank-0 mismatch is classified; it is NOT
* simply numeric distance from rank 0 like an ordinary lag.
*/
const PHASE_STATUS_RANKS: Readonly<Record<string, number>> = Object.freeze({
// STATE.md "Current Position" vocabulary
'ready to plan': 0,
'planning': 0,
'ready to execute': 1,
'in progress': 1,
'phase complete': 2,
// ROADMAP.md "## Progress" table Status column vocabulary
'not started': 0,
'complete': 2,
'deferred': 0,
} as const);
/** Rank at which a status asserts work is DONE (terminal, not comparative). */
const TERMINAL_RANK = 2;
/**
* Normalize a raw phase-status string for lookup/comparison: trims
* surrounding whitespace and lowercases. Returns null for missing/empty
* values. Single owner of this normalization so `resolvePhaseStatusRank` and
* the 'deferred' declared-intent check in `comparePhaseStatus` cannot drift
* apart on what counts as "empty".
*/
function normalizePhaseStatusText(value: string | null | undefined): string | null {
if (value === null || value === undefined) return null;
const normalized = value.trim().toLowerCase();
return normalized === '' ? null : normalized;
}
/**
* Resolve a raw phase-status string to its shared ordinal rank, or null when
* the value is missing/empty/unrecognized. Case-insensitive, trims
* surrounding whitespace.
*
* @param value - raw status text from STATE.md or ROADMAP.md
* @returns the resolved rank, or null if unresolvable
*/
function resolvePhaseStatusRank(value: string | null | undefined): number | null {
const normalized = normalizePhaseStatusText(value);
if (normalized === null) return null;
if (!Object.prototype.hasOwnProperty.call(PHASE_STATUS_RANKS, normalized)) return null;
return PHASE_STATUS_RANKS[normalized];
}
/**
* Compare a phase's status as reported by STATE.md against the same phase's
* status as reported by ROADMAP.md's "## Progress" table, and classify the
* result.
*
* Unlike classifyDriftSeverity, this never throws for an unrecognized
* status: the inputs are user document text (prose a human or agent typed
* into STATE.md/ROADMAP.md), not config, so an unrecognized value is data —
* surfaced as 'uncheckable' — not a programming error.
*
* Rank 2 ('phase complete' / 'Complete') is TERMINAL: it asserts the work is
* DONE. If exactly one side reports rank 2 and the other does not, that is
* always 'drifted', regardless of numeric distance — a document claiming
* "done" while another claims "still going" is a direct contradiction, not
* mere lag. This is the issue's canonical example: complete in STATE.md but
* in progress in ROADMAP.md.
*
* 'Deferred' is a second declared-intent rank-0 status (gsd-core/templates/
* roadmap.md:133's full vocabulary: `Not started | In progress | Complete |
* Deferred`) that is NOT ordinary lag from rank 0: it is an explicit
* decision to STOP work, not merely "hasn't started yet". If exactly one
* side declares 'deferred' and the other side's rank is >= 1 (work is
* reported as in progress or complete), that is always 'drifted' — a phase
* declared deferred while the other document says work is happening is a
* direct contradiction, checked here (like the terminal-completeness rule
* above) BEFORE the numeric distance comparison. 'Deferred' against a
* rank-0 status on the other side (e.g. 'Not started') stays 'consistent' —
* both agree no work has happened.
*
* Otherwise, ranks are compared numerically: equal → 'consistent';
* off-by-one → 'lag'; off-by-two-or-more → 'drifted'.
*
* @param opts.stateStatus - raw Status value from STATE.md's Current Position
* @param opts.roadmapStatus - raw Status cell from ROADMAP.md's Progress table
* @returns { verdict, stateRank, roadmapRank }
*/
export function comparePhaseStatus({
stateStatus,
roadmapStatus,
}: {
stateStatus: string | null | undefined;
roadmapStatus: string | null | undefined;
}): PhaseStatusResult {
const stateRank = resolvePhaseStatusRank(stateStatus);
const roadmapRank = resolvePhaseStatusRank(roadmapStatus);
if (stateRank === null || roadmapRank === null) {
return { verdict: 'uncheckable', stateRank, roadmapRank };
}
const stateIsTerminal = stateRank === TERMINAL_RANK;
const roadmapIsTerminal = roadmapRank === TERMINAL_RANK;
if (stateIsTerminal !== roadmapIsTerminal) {
return { verdict: 'drifted', stateRank, roadmapRank };
}
const stateIsDeferred = normalizePhaseStatusText(stateStatus) === 'deferred';
const roadmapIsDeferred = normalizePhaseStatusText(roadmapStatus) === 'deferred';
if (stateIsDeferred !== roadmapIsDeferred) {
const otherRank = stateIsDeferred ? roadmapRank : stateRank;
if (otherRank >= 1) {
return { verdict: 'drifted', stateRank, roadmapRank };
}
}
const diff = Math.abs(stateRank - roadmapRank);
if (diff === 0) {
return { verdict: 'consistent', stateRank, roadmapRank };
}
if (diff === 1) {
return { verdict: 'lag', stateRank, roadmapRank };
}
return { verdict: 'drifted', stateRank, roadmapRank };
}