enhance(#2573): stamp STATE.md with its commit and surface a freshness hint (#2622)

* enhance(#2573): stamp STATE.md with its commit and surface a commit-age freshness hint

Adds a `state_head` stamp to STATE.md and derives a tri-state commit-age
freshness proxy (state_commits_behind / state_commit_stale) through
state.cjs's readStateHeadFreshness, surfaced on smart-entry signals and as
health W024. The proxy is advisory: classify() deliberately does NOT consume
it (ADR-1787 locks the classification/routing boundary — a signal, not a route).

Composes with #3099 and #1882 (both merged to next after this branch): the
commit-age proxy reads `state_head` while the LAST_ACTIVITY_UNPARSEABLE
diagnostic reads `last_activity` — two different fields, not "two staleness
signals on one field." A new regression test asserts a STATE.md carrying both
an unparseable last_activity AND a valid state_head resolves each independently
(diagnostic fires once; freshness reads state_head, commits_behind 0).

Rebased onto next (flattened): resolved the add/add conflicts in
src/smart-entry.cts (kept both the #2573 freshness import/derivation and the
#3099 diagnostic import/call) and tests/smart-entry.unit.test.cjs (kept both
describe blocks). Drift-ack for health.md's W024 row is unchanged (12348 B).
Tests: smart-entry 62, state/state-transition/health/verify 639, all pass.

* chore(#2573): allowlist health-validation test in the prompt-injection scan

The scanner's `exec('` code-execution pattern matches the benign
`re.exec('<phase-id>')` RegExp method calls in the phase-ID grammar tests
(pre-existing: 16 such calls on next, this PR adds none). The file entered the
diff-mode scan's changed-file set only because #2573's W024 state_head
assertions touch it. Allowlist it alongside the other test files that carry
pattern-matching content as data (same DEFECT.PROMPT-INJECTION-SCAN-COLLISION
class). Scanner self-test 38/0; diff scan 14 files, 0 findings.
This commit is contained in:
Rezolv
2026-08-11 17:10:23 -04:00
committed by GitHub
parent 9341d8b8d3
commit e87fb409ee
19 changed files with 1014 additions and 7 deletions

View File

@@ -52,6 +52,9 @@ const { stateFieldValue } = stateDocument;
import phaseId = require('./phase-id.cjs');
const { comparePhaseNum, extractPhaseToken, normalizePhaseName, phaseTokenMatches } = phaseId;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import stateMod = require('./state.cjs');
const { readStateHeadFreshness } = stateMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import unusableInput = require('./unusable-input.cjs');
const { warnUnusableInput, UNUSABLE_REASON } = unusableInput;
@@ -104,6 +107,18 @@ export interface SmartEntrySignals {
*/
roadmap_total_phases: number | null;
roadmap_completed_phases: number | null;
/**
* Commits between STATE.md's recorded `state_head` and HEAD (#2573). Null
* when unknown — no stamp, no git, or an unresolvable commit.
*/
state_commits_behind: number | null;
/**
* Tri-state freshness proxy: null = unknown, false = written at HEAD,
* true = the codebase has moved since STATE.md was written. Advisory only —
* classify() deliberately does NOT consume this (ADR-1787 locks the
* classification/routing boundary; this is a signal, not a route).
*/
state_commit_stale: boolean | null;
}
export interface SmartEntryResult {
@@ -304,6 +319,9 @@ export function detectSignals(cwd: string, now: () => number = Date.now): SmartE
stale_activity: false,
roadmap_total_phases: null,
roadmap_completed_phases: null,
// No STATE.md (or unreadable) → no stamp to compare. Unknown, not fresh.
state_commits_behind: null,
state_commit_stale: null,
};
if (!hasPlanning) return empty;
@@ -395,6 +413,12 @@ export function detectSignals(cwd: string, now: () => number = Date.now): SmartE
}
}
// #2573: commit-age freshness proxy. Derived through state.cjs's
// readStateHeadFreshness so the tri-state and the hash fence stay identical
// to validate.health's W024 — one derivation, two surfaces.
const stateHeadRaw = stateFieldValue(fm, body, 'state_head', 'State Head').value;
const freshness = readStateHeadFreshness(cwd, stateHeadRaw);
return {
current_phase: parseIntOrNull(currentPhaseRaw),
total_phases: parseIntOrNull(totalPhasesRaw),
@@ -411,6 +435,8 @@ export function detectSignals(cwd: string, now: () => number = Date.now): SmartE
stale_activity: staleActivity,
roadmap_total_phases: roadmapTotalPhases,
roadmap_completed_phases: roadmapCompletedPhases,
state_commits_behind: freshness.commits_behind,
state_commit_stale: freshness.commit_stale,
};
}

View File

@@ -98,6 +98,11 @@ export const FIELD_CLASSIFICATION: Readonly<Record<string, FieldClassification>>
last_activity: { source: 'body', preservation: 'derive' } as FieldClassification, // always refresh on transition
last_activity_desc: { source: 'body', preservation: 'preserve-when-unchanged' } as FieldClassification,
// Commit provenance (#2573) — ambient git read, recomputed on every write,
// exactly like last_updated. Never preserved: a stale stamp would claim
// STATE.md was written against a commit it wasn't.
state_head: { source: 'free', preservation: 'derive' } as FieldClassification, // #2573
// Progress block (disk-derived, except the curated progress ratchet)
progress: { source: 'curated', preservation: 'preserve-always' } as FieldClassification, // #3242, #1446
'progress.total_phases': { source: 'disk', preservation: 'derive' } as FieldClassification,

View File

@@ -20,7 +20,7 @@ const { escapeRegex, parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, phaseKeyFro
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapParserMod = require('./roadmap-parser.cjs');
const { getMilestoneInfo, extractCurrentMilestone, isMilestoneBoundedInRoadmap, hasMilestoneSectioning } = roadmapParserMod;
import { platformWriteSync, platformReadSync, platformEnsureDir, retryRenameSync, toPosixPath } from './shell-command-projection.cjs';
import { platformWriteSync, platformReadSync, platformEnsureDir, retryRenameSync, toPosixPath, execGit } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
const { planningDir, planningPaths } = planningWorkspace;
@@ -42,6 +42,10 @@ import phaseLocatorMod = require('./phase-locator.cjs');
const { listMilestonePhaseDirs } = phaseLocatorMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import stateTransitionMod = require('./state-transition.cjs');
// #2573 D5: used to pin `git rev-parse` to the project's own repo. Imports only
// node builtins, so it introduces no cycle on this path.
import { findProjectRoot } from './project-root.cjs';
const { transitionCore, applyStatePreservation, sliceCurrentPositionSection } = stateTransitionMod;
type StateTransitionIntent = stateTransitionMod.StateTransitionIntent;
type StateTransitionDeps = stateTransitionMod.StateTransitionDeps;
@@ -1971,6 +1975,12 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto
fm['last_updated'] = realClock.nowIso();
if (lastActivity) fm['last_activity'] = lastActivity;
if (lastActivityDesc) fm['last_activity_desc'] = lastActivityDesc;
// #2573: stamp the commit this STATE.md was written against, so consumers can
// report how far the codebase has moved since. Omitted entirely outside a git
// repo — an absent field reads as "unknown", which is the honest answer and
// keeps every consumer's tri-state intact (see readStateHeadFreshness).
const stateHead = readGitHeadSha(cwd);
if (stateHead) fm['state_head'] = stateHead;
const progress: Record<string, unknown> = {};
if (totalPhases !== null) progress['total_phases'] = totalPhases;
@@ -1983,6 +1993,186 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto
return fm;
}
// ─── state_head commit provenance (#2573) ────────────────────────────────────
//
// STATE.md records the commit it was written against (`state_head`); consumers
// derive how many commits the codebase has moved since. This mirrors the shipped
// graphify commit-staleness contract (src/graphify.cts, #3170) rather than
// inventing a second vocabulary: `commits_behind` is a count, and `commit_stale`
// is TRI-STATE — null means "we don't know" (no git, no stamp, unresolvable
// commit), which is deliberately distinct from false ("known fresh").
//
// IMPORTANT — this is a freshness PROXY, never a drift measurement.
// `rev-list state_head..HEAD` counts every commit in between, including ones
// that never touched anything STATE.md describes. And because `state_head`
// restamps on EVERY state write, a low count means "something wrote STATE
// recently", NOT "STATE's content is accurate". Consumers must word it as
// approximate and must never gate on it.
/** Strict hash fence before any value from disk reaches a git argument. */
const STATE_HEAD_HASH_RE = /^[0-9a-f]{4,40}$/i;
/**
* Resolve the project's current HEAD sha, or null when unavailable.
* Bounded + non-interactive via execGit (10s timeout, GIT_TERMINAL_PROMPT=0);
* a non-repo, missing git, or timeout degrades to null rather than throwing.
*/
/**
* Does the project root carry its own git repository?
*
* #2573 D5. `git rev-parse HEAD` walks UP from cwd and stops at the FIRST
* enclosing `.git`. So the repo that answered is the project's own exactly when
* the project root itself carries a `.git` entry — a directory for a normal
* clone, a file for a worktree or submodule, both of which `existsSync` accepts.
* If it does not, the answer necessarily came from an ancestor repo and the
* stamp would assert provenance the project cannot claim.
*
* Deliberately a filesystem-identity check rather than comparing
* `--show-toplevel` against the project root as strings. That comparison is
* unreliable across platforms — macOS resolves temp dirs through
* `/private/var/…`, Windows adds 8.3 short names and separator/case variance —
* and an over-strict compare degrades healthy projects to "unknown", which is
* the very failure this check exists to prevent, inverted. No path spelling is
* involved here at all.
*/
function projectOwnsItsRepo(projectRoot: string): boolean {
try {
return fs.existsSync(path.join(projectRoot, '.git'));
} catch {
return false;
}
}
function readGitHeadSha(cwd: string | undefined): string | null {
if (!cwd) return null;
// #2573 degrade path D5. `git rev-parse HEAD` walks UP from cwd to the nearest
// enclosing `.git`, and nothing pins that repo to the project. A GSD project
// living inside an unrelated checkout — a dotfiles/notes repo, or the outer
// workspace of a `planning.sub_repos` layout where all code commits land in
// the sub-repos — would otherwise measure freshness against a repo it has no
// relationship to, and report `commit_stale: false` ("known fresh") while
// doing it. Unverified provenance must degrade to unknown, never to fresh.
//
// TWO independent conditions must hold before a stamp is trustworthy, and both
// are checked below because either alone is insufficient:
// 1. the project root owns a `.git` (else an ancestor repo answered), and
// 2. the project is not a `sub_repos` workspace (else the repo that answers
// is the outer wrapper, whose HEAD does not move when the code does).
// KNOWN LIMITATION, by design: in a `sub_repos` workspace this feature reports
// unknown rather than measuring the children. Per-child freshness needs a
// defined aggregate across N histories and is out of scope for this increment.
//
// `--show-toplevel HEAD` answers both in ONE spawn, so pinning costs no extra
// subprocess on this path (the caller holds the STATE lock).
let projectRoot: string;
try {
projectRoot = findProjectRoot(cwd);
} catch {
return null; // cannot prove which repo would answer → unknown
}
if (!projectOwnsItsRepo(projectRoot)) return null;
// #2573 D5, sub_repos flavor. Owning a `.git` is necessary but NOT sufficient.
// In a `planning.sub_repos` workspace the outer directory can legitimately own
// BOTH `.planning/` and its own repo while every code commit lands in a nested
// child repo — `docs/CONFIGURATION.md` describes sub_repos as scoping work per
// sub-repo "instead of treating the outer repo as a monorepo". The outer HEAD
// then never advances, so `merge-base --is-ancestor` passes trivially and
// `rev-list` counts 0: the stamp would report `commit_stale: false`, i.e.
// "known fresh", while the code it describes has moved arbitrarily far.
//
// That is a WRONG answer, not a missing one, and it is the same invariant the
// ancestor-repo check above exists to protect: a freshness claim the project
// cannot substantiate must degrade to unknown, never to fresh. Measuring the
// children instead would mean picking one HEAD out of N unrelated histories
// (or inventing an aggregate), which is a design question beyond this
// increment — so this scopes to the honest tri-state and declines to answer.
// Deliberately keyed on the DECLARED config rather than probing the filesystem
// for nested `.git` entries: the declaration is what the workspace asserts
// about itself, and a probe would spuriously fire on a vendored dependency.
try {
const subRepos = (loadConfig(projectRoot) as { sub_repos?: unknown }).sub_repos;
if (Array.isArray(subRepos) && subRepos.length > 0) return null;
} catch {
return null; // cannot read the layout → cannot claim provenance → unknown
}
const r = execGit(['rev-parse', 'HEAD'], { cwd });
if (r.exitCode !== 0) return null;
const sha = r.stdout.trim();
return STATE_HEAD_HASH_RE.test(sha) ? sha : null;
}
interface StateHeadFreshness {
/** The recorded stamp, short form, or null when absent/malformed. */
state_head: string | null;
/** Current HEAD, short form, or null outside a resolvable repo. */
current_commit: string | null;
/** Commits between the stamp and HEAD; null when either end is unknown. */
commits_behind: number | null;
/** Tri-state: null = unknown, false = known fresh, true = moved since. */
commit_stale: boolean | null;
}
/**
* Derive the commit-age freshness signal from a recorded `state_head`.
*
* Single source of truth for the derivation — `validate.health` (W024) and
* smart-entry both consume this rather than re-deriving it, so the tri-state
* and the hash fence cannot drift apart between surfaces.
*
* Never throws: every unresolvable input degrades to nulls.
*/
function readStateHeadFreshness(
cwd: string | undefined,
stateHead: unknown,
): StateHeadFreshness {
const raw = (typeof stateHead === 'string' ? stateHead : '').trim();
const stamp = STATE_HEAD_HASH_RE.test(raw) ? raw : null;
const head = readGitHeadSha(cwd);
let commitsBehind: number | null = null;
let commitStale: boolean | null = null;
if (stamp && head && cwd) {
// The stamp must be an ANCESTOR of HEAD before a distance means anything.
// `rev-list --count A..B` exits 0 with "0" when A is not reachable from B —
// which is what a `reset --hard` to an earlier commit, a rebase or squash
// that drops the stamped commit, or a force-push rewriting history all
// produce. Without this guard those cases report `commit_stale: false`,
// i.e. "known fresh", for a codebase that was actually rewound past the
// stamp — collapsing the exact unknown-vs-fresh distinction this tri-state
// exists to preserve. A non-ancestor stamp is UNKNOWN, so it stays null.
const ancestry = execGit(['merge-base', '--is-ancestor', stamp, head], { cwd });
if (ancestry.exitCode === 0) {
const r = execGit(['rev-list', '--count', `${stamp}..${head}`], { cwd });
if (r.exitCode === 0) {
const n = parseInt(r.stdout.trim(), 10);
if (Number.isFinite(n)) {
commitsBehind = n;
// #2573 D4 — deliberately RAW, not thresholded. `commit_stale` means
// exactly what its contract says: the codebase has moved since the
// stamp. Applying an advisory threshold here would make the field lie
// at n < threshold, and W024 needs the true count to threshold on.
// Alarm-fatigue is handled at the ALARMING surface, not the
// derivation: W024 (the only user-visible consumer) fires at
// STATE_HEAD_ADVISORY_COMMITS, which absorbs the `commit_docs: true`
// off-by-one. Smart-entry re-exports the raw tri-state as advisory
// JSON and is not consumed by classify().
commitStale = n > 0;
}
}
}
}
return {
state_head: stamp ? stamp.slice(0, 7) : null,
current_commit: head ? head.slice(0, 7) : null,
commits_behind: commitsBehind,
commit_stale: commitStale,
};
}
function syncStateFrontmatter(content: string, cwd: string | undefined, authoritativeFm?: Record<string, unknown>): string {
// Read existing frontmatter BEFORE stripping — it may contain values
// that the body no longer has (e.g., Status field removed by an agent).
@@ -2081,9 +2271,28 @@ function syncStateFrontmatter(content: string, cwd: string | undefined, authorit
// Schema-owned keys (already in derivedFm from buildStateFrontmatter + the
// preserve guards above) still win.
for (const key of Object.keys(existingFm)) {
if (!(key in derivedFm) && existingFm[key] !== undefined) {
derivedFm[key] = existingFm[key];
}
if (key in derivedFm || existingFm[key] === undefined) continue;
// #2573: a `source: 'free'` field is the writer's word on every write and
// carries no preservation (see the FieldSource doc). When buildStateFrontmatter
// omits it — `state_head` outside a git repo, per its `if (stateHead)` guard —
// carrying the old value forward would re-assert provenance the file no longer
// has: a stale state_head would claim STATE.md was written against a commit it
// wasn't, contradicting its own ADR-1769 row.
//
// Narrow the skip to `source: 'free'`, NOT every `derive` row. `last_activity`
// ({source:'body'}) and the `progress.*` rows ({source:'disk'}) are also
// `derive`, but they are body/disk-sourced and MUST still carry forward when
// the writer omits them this pass — dropping `last_activity` here is silent
// frontmatter data loss and would defeat #2570's staleness fix downstream.
// `last_updated` and `gsd_state_version` are the only other `free` rows and are
// both produced unconditionally by buildStateFrontmatter, so this loop never
// reaches them; `state_head` is the sole field the skip governs. Consult the
// table rather than naming fields, so the policy stays single-sourced.
const classification = stateTransitionMod.getFieldClassification(key);
if (classification && classification.source === 'free') continue;
derivedFm[key] = existingFm[key];
}
// #2567: guard the information-losing direction — a stale archive
@@ -3734,6 +3943,7 @@ export = {
writeStateMd,
readModifyWriteStateMd,
syncStateFrontmatter,
readStateHeadFreshness,
withStateLock,
updatePerformanceMetricsSection,
cmdStateLoad,

View File

@@ -62,7 +62,18 @@ const { determinePhaseStatus } = commandsMod;
const { planningDir, planningRoot } = planningWorkspace;
const { extractFrontmatter, parseMustHavesBlock } = frontmatterMod;
const { writeStateMd } = stateMod;
const { writeStateMd, readStateHeadFreshness } = stateMod;
/**
* W024 (#2573) threshold — how many commits STATE.md may lag HEAD before
* `validate.health` mentions it.
*
* Deliberately coarse. `state_head` restamps on every state write, so a small
* count is normal for any active project; firing near zero would make health
* noisy for healthy projects without telling anyone anything. This is a
* freshness proxy, not a drift measurement — see readStateHeadFreshness.
*/
const STATE_HEAD_ADVISORY_COMMITS = 20;
const { MODEL_PROFILES } = modelProfilesMod;
// Unused but imported for structural parity
@@ -1642,6 +1653,29 @@ function cmdValidateHealth(
repairs.push('regenerateState');
} else {
const stateContent = fs.readFileSync(statePath, 'utf-8');
// W024 (#2573): STATE.md commit-age freshness. Advisory ONLY — it appends
// to warnings[] and never touches `status`, the repair set, or any existing
// count. Silent when the stamp is absent or unresolvable: "unknown" is not
// a finding. The threshold is deliberately coarse so an ordinary project
// stays quiet — firing on every project would change health's observable
// "clean" state for anything gating on it.
{
const fm = extractFrontmatter(stateContent) as Record<string, unknown>;
const freshness = readStateHeadFreshness(cwd, fm['state_head']);
if (
freshness.commits_behind !== null &&
freshness.commits_behind >= STATE_HEAD_ADVISORY_COMMITS
) {
addIssue(
'warning',
'W024',
`STATE.md was written ${freshness.commits_behind} commits ago (at ${freshness.state_head}) — treat its contents as approximate`,
'Re-read the current phase artifacts before relying on STATE.md, or run a GSD command that refreshes it',
);
}
}
const phaseRefs = [
...stateContent.matchAll(new RegExp(`[Pp]hase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`, 'g')),
].map(
@@ -2739,6 +2773,7 @@ export = {
cmdValidateAgents,
cmdVerifySchemaDrift,
cmdVerifyCodebaseDrift,
STATE_HEAD_ADVISORY_COMMITS,
// Test seam (#1883): listMilestoneArchiveDirs is private and exercised through
// the validate command, which runs in a subprocess — an fs monkeypatch in the
// test process cannot reach it. Exposed under a leading underscore so the