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

@@ -9,6 +9,8 @@
import { splitTableRow } from './markdown-table.cjs';
import { clampPercentFromFraction } from './phase-lifecycle.cjs';
import { collectSection } from './markdown-sectionizer.cjs';
import type { HeadingToken } from './markdown-sectionizer.cjs';
// Internal helpers
function escapeRegex(str: string): string {
@@ -232,6 +234,38 @@ export function stateExtractField(content: string, fieldName: string): string |
return null;
}
/**
* Match the "Current Position" section body from a STATE.md body. #2956: this
* is the Phase analogue of state.cts's matchSessionSection. `Phase` canonically
* lives under `## Current Position` (gsd-core/templates/state.md), so — like
* Stopped At / Paused At under `## Session` — it must be extracted from THAT
* section, not from the first `Phase:` / `**Phase:**` line anywhere in the
* body. Without the scope, a historical `Phase:` line in an archive section
* silently shadows the real one on every read/write, and because callers use
* this for routing (state.cts's current_phase) and for drift detection
* (gsd-tools.cjs's `drift-guard phase-status` CLI seam), a stale match either
* routes work to the wrong phase or fabricates a drift finding.
*
* Level-flexible: the canonical template uses an h2 `## Current Position`, the
* bootstrap template an h3 `### Current Position` (templates/state.md). Both
* must match — mirroring how matchSessionSection recognises `## Session` and
* `## Session Continuity`. Exact 'current position' text match (case-
* insensitive) excludes unrelated headings. Built on the `collectSection`
* seam, so it inherits that seam's CRLF tolerance (#2444 fix).
*
* This is the single owner of the scope — state.cts's private
* `matchCurrentPositionSection` delegates here rather than duplicating the
* logic, so the two consumers cannot drift apart.
*
* Returns the section body, or null (caller falls back to full-body search).
*/
export function stateCurrentPositionSlice(body: string): string | null {
const isCurrentPosition = (h: HeadingToken): boolean =>
(h.level === 2 || h.level === 3) && h.text.trim().toLowerCase() === 'current position';
const section = collectSection(body, isCurrentPosition, { levelBounded: true });
return section ? section.body : null;
}
export function stateReplaceField(content: string, fieldName: string, newValue: string): string | null {
const escaped = escapeRegex(fieldName);
// Bold inline format: **FieldName:** value