You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Read CONTRIBUTING.md; will wait for approved-enhancement before coding
Can describe a concrete benefit, not just "nicer"
What existing feature or behavior does this improve?
The STATE.md freshness signalling consumed by validate.health (src/verify.cts), smart-entry (src/smart-entry.cts), and the statusline (hooks/gsd-statusline.js) — and the syncStateFrontmatter write path (src/state.cts).
Current behavior
GSD already ships a clean freshness-provenance pattern — but only for the artifacts it derives, not the ones it authors:
drift.cts stamps last_mapped_commit / last_mapped_at into .planning/codebase/*.md frontmatter (src/drift.cts:383-415) and compares against HEAD to detect codebase-map drift.
graphify stamps built_at_commit / head_at_build and surfaces graphifyStatus().stale to every consumer (references/planner-graphify-auto-update.md).
The layer users actually read — STATE.md, ROADMAP.md — carries no commit anchor. There is no signal anywhere that says "STATE.md was last written 50 commits ago." smart-entry has only a time-based stale_activity (and #2570 just restored even that from a fail-open bug). The statusline reads git behind — but that's the branch vs its upstream, not STATE.md vs HEAD.
references/planner-graphify-auto-update.md states the principle explicitly for the graph: "consumed automatically but produced manually … the producer-consumer gap silently widens with every commit." That sentence is true, word for word, of STATE.md — and nobody has applied the fix to it.
Reproducible on this repo today:.planning/STATE.mdlast_activity: 2026-06-08; HEAD is 45+ days and ~50 commits later; validate.health reports 0 errors and smart-entry reports no staleness. Every surface says the project is fine.
Proposed behavior
Stamp provenance.syncStateFrontmatter writes state_head: <sha> (the commit HEAD was at when STATE.md was last synced), exactly mirroring last_mapped_commit.
Surface it where users already look, as a freshness hint, not a drift assertion (see the framing note below):
validate.health: a new advisory W0xx: STATE.md was last synced N commits ago (treat as approximate).
smart-entry: a commit-age signal alongside the (time-based) stale_activity.
statusline: a single "stale" marker past a small threshold.
Framing (load-bearing — this is what the adversarial pass sharpened).rev-list state_head..HEAD counts all commits since the last sync, including ones irrelevant to the plan — 50 commits of dependency bumps do not make STATE wrong, only old. So the signal is a proxy for freshness, not a claim of drift, exactly like graphify's mtime-based stale: true. It must be phrased as "STATE was written N commits ago — treat as approximate" (graphify's own consumer language), never "STATE has drifted / is wrong." Overclaiming here would be a Goodhart trap (a proxy dressed as the thing) and would train users to ignore it. The value is narrow and real: catching the case where a human/agent believes STATE is current when the code has moved out from under it.
No auto-repair — surface the age, let a human/workflow decide (git tells you the artifact is old; it cannot tell you whether its content is still true, nor your intent).
Behavior when .planning/ is not committed (commit_docs: false)
This is the case that makes or breaks the design, so it is specified up front. commit_docs defaults to true, but false is fully supported (OSS/client/private work — gsd-core itself uses it), and it is auto-forced to false whenever .planning/ is gitignored (references/planning-config.md). So a large fraction of real projects — including every OSS contributor whose .planning/ is private — never commit STATE.md.
The design works in that case by construction, because it measures the codebase's movement, not STATE.md's own git history:
state_head is written into the STATE.md file on disk (working tree) by syncStateFrontmatter — it persists whether or not STATE.md is ever committed.
git rev-list --count state_head..HEAD counts commits on the code branch between the recorded sha and current HEAD. HEAD advances because of code commits; it does not require STATE.md to be tracked. So "N commits behind" is exactly "the code moved N commits since STATE was last synced" — orthogonal to commit_docs.
In other words this is most useful precisely when .planning/ is uncommitted, which is the audit/OSS scenario where drift is worst.
Required degrade paths (all silent, never blocking):
state_head absent (legacy STATE.md, or first sync) → no signal this run; stamp it going forward.
state_head not resolvable / not an ancestor of HEAD (rebased, squashed, shallow clone, branch switch) → rev-list fails or is meaningless; degrade to time-based rather than report a garbage/negative count.
commit_docs: true off-by-the-doc-commit — when STATE.md is committed, a sync stamps state_head = HEAD_before_commit, then the commit that includes STATE.md advances HEAD by one, so a freshly-committed project would spuriously read "1 behind". Handle by either stamping state_headafter the doc commit, or by only flagging when the count exceeds a small threshold (e.g. > 1). This wrinkle exists only under commit_docs: true; under false there is no doc commit to discount.
Code in a sub-repo (planning.sub_repos) — if planning sits at the outer repo but code advances in a sub-repo, "HEAD" may not reflect code movement; scope the rev-list to the repo the code lives in, or note the limitation.
Reason and benefit
This is the direct answer to the recurring user report that "the roadmap / project / plans go out of date with the real project." The mechanism causing it is that all state reconciliation is write-triggered — if a workflow doesn't run, nothing reconciles, and the agent whose omission caused the drift is the same one that would have fixed it. A commit-based staleness signal is the cheapest possible intervention: it costs one git rev-list, needs no new command, and no user has to remember to run anything. An agent reading STATE.md annotated "50 commits behind" goes and checks git; an agent reading an unannotated STATE.md trusts it.
Scope of changes
src/state.cts — add state_head to the set of first-class keys syncStateFrontmatter writes and preserves.
src/verify.cts — new advisory health check computing rev-list --count state_head..HEAD (degrade to silent when no git / no state_head).
src/smart-entry.cts — a commit-drift signal in SmartEntrySignals.
hooks/gsd-statusline.js + its parseStateMd — tolerate and optionally render the new field.
tests/ — regressions folded into owning module test files, asserting structured signals (no source-grep).
Changeset; docs update for the STATE.md frontmatter contract.
Breaking changes
Adding a frontmatter key touches a de-facto public contract: the statusline, smart-entry, gsd-statusline tests, and third-party capabilities (e.g. projects-sync) parse STATE.md frontmatter. #2202 (closed — "syncStateFrontmatter drops unknown STATE.md frontmatter keys on every mutating verb") is the precedent: state_head must be added as a known key, and every parser must tolerate its absence on legacy files. With that handled, no behavior change to existing fields.
Auto-repair STATE from git. Rejected: git cannot recover intent, only detect staleness.
A new /gsd-reconcile command. Rejected: ADR-1787 + #3054 record that gsd-core already removed a duplicate front door; extend health/smart-entry, don't add a tenth.
Area affected
STATE tracking / workflow situational awareness (area: workflow).
Additional context
Mirrors drift.cts (last_mapped_commit) and graphify (built_at_commit/head_at_build) — this is applying a shipped, tested pattern to a new artifact.
Pre-file review (adversarial + laws-of-software). Refutation attempts: "commits-behind conflates code churn with plan drift" → valid, and it reshaped the proposal — the signal is now framed as a freshness hint ("written N commits ago, treat as approximate"), never a drift/wrongness claim (see Framing above). "always-on alarm → fatigue" → mitigated by a threshold + advisory-only + informational phrasing. "duplicates statusline behind" → refuted (that is branch-vs-upstream, not STATE-vs-HEAD). Laws that fire: Goodhart (the proxy-as-target trap — the entire framing note is the guard), Postel (liberal-but-visible; degrade to unknown/time-based, never fail silent — the bug(smart-entry): stale_activity never fires when last_activity carries the template's " — description" suffix (Date.parse → NaN → fails open) #2570 lineage), Hyrum (frontmatter-key contract, syncStateFrontmatter drops unknown STATE.md frontmatter keys on every mutating verb #2202), Gall (mirrors drift.cts, no new subsystem — supports), Leaky-abstractions (STATE.md presents as "project state" but is unreconciled markdown; R2 documents that leak at the boundary rather than pretending to plug it).
GSD Version
1.8.0 (next @ a5180d9)
Pre-submission checklist
status: passed#2348 (see Additional context)approved-enhancementbefore codingWhat existing feature or behavior does this improve?
The STATE.md freshness signalling consumed by
validate.health(src/verify.cts),smart-entry(src/smart-entry.cts), and the statusline (hooks/gsd-statusline.js) — and thesyncStateFrontmatterwrite path (src/state.cts).Current behavior
GSD already ships a clean freshness-provenance pattern — but only for the artifacts it derives, not the ones it authors:
drift.ctsstampslast_mapped_commit/last_mapped_atinto.planning/codebase/*.mdfrontmatter (src/drift.cts:383-415) and compares against HEAD to detect codebase-map drift.graphifystampsbuilt_at_commit/head_at_buildand surfacesgraphifyStatus().staleto every consumer (references/planner-graphify-auto-update.md).The layer users actually read —
STATE.md,ROADMAP.md— carries no commit anchor. There is no signal anywhere that says "STATE.md was last written 50 commits ago."smart-entryhas only a time-basedstale_activity(and #2570 just restored even that from a fail-open bug). The statusline reads gitbehind— but that's the branch vs its upstream, not STATE.md vs HEAD.references/planner-graphify-auto-update.mdstates the principle explicitly for the graph: "consumed automatically but produced manually … the producer-consumer gap silently widens with every commit." That sentence is true, word for word, of STATE.md — and nobody has applied the fix to it.Reproducible on this repo today:
.planning/STATE.mdlast_activity: 2026-06-08; HEAD is 45+ days and ~50 commits later;validate.healthreports 0 errors andsmart-entryreports no staleness. Every surface says the project is fine.Proposed behavior
syncStateFrontmatterwritesstate_head: <sha>(the commit HEAD was at when STATE.md was last synced), exactly mirroringlast_mapped_commit.stale_commits = git rev-list --count state_head..HEAD— deterministic, exogenous, one git call.validate.health: a new advisoryW0xx: STATE.md was last synced N commits ago (treat as approximate).smart-entry: a commit-age signal alongside the (time-based)stale_activity.Framing (load-bearing — this is what the adversarial pass sharpened).
rev-list state_head..HEADcounts all commits since the last sync, including ones irrelevant to the plan — 50 commits of dependency bumps do not make STATE wrong, only old. So the signal is a proxy for freshness, not a claim of drift, exactly like graphify's mtime-basedstale: true. It must be phrased as "STATE was written N commits ago — treat as approximate" (graphify's own consumer language), never "STATE has drifted / is wrong." Overclaiming here would be a Goodhart trap (a proxy dressed as the thing) and would train users to ignore it. The value is narrow and real: catching the case where a human/agent believes STATE is current when the code has moved out from under it.No auto-repair — surface the age, let a human/workflow decide (git tells you the artifact is old; it cannot tell you whether its content is still true, nor your intent).
Behavior when
.planning/is not committed (commit_docs: false)This is the case that makes or breaks the design, so it is specified up front.
commit_docsdefaults totrue, butfalseis fully supported (OSS/client/private work — gsd-core itself uses it), and it is auto-forced tofalsewhenever.planning/is gitignored (references/planning-config.md). So a large fraction of real projects — including every OSS contributor whose.planning/is private — never commit STATE.md.The design works in that case by construction, because it measures the codebase's movement, not STATE.md's own git history:
state_headis written into the STATE.md file on disk (working tree) bysyncStateFrontmatter— it persists whether or not STATE.md is ever committed.git rev-list --count state_head..HEADcounts commits on the code branch between the recorded sha and current HEAD. HEAD advances because of code commits; it does not require STATE.md to be tracked. So "N commits behind" is exactly "the code moved N commits since STATE was last synced" — orthogonal tocommit_docs.In other words this is most useful precisely when
.planning/is uncommitted, which is the audit/OSS scenario where drift is worst.Required degrade paths (all silent, never blocking):
stale_activity(bug(smart-entry): stale_activity never fires when last_activity carries the template's " — description" suffix (Date.parse → NaN → fails open) #2570).state_headabsent (legacy STATE.md, or first sync) → no signal this run; stamp it going forward.state_headnot resolvable / not an ancestor of HEAD (rebased, squashed, shallow clone, branch switch) →rev-listfails or is meaningless; degrade to time-based rather than report a garbage/negative count.commit_docs: trueoff-by-the-doc-commit — when STATE.md is committed, a sync stampsstate_head = HEAD_before_commit, then the commit that includes STATE.md advances HEAD by one, so a freshly-committed project would spuriously read "1 behind". Handle by either stampingstate_headafter the doc commit, or by only flagging when the count exceeds a small threshold (e.g.> 1). This wrinkle exists only undercommit_docs: true; underfalsethere is no doc commit to discount.planning.sub_repos) — if planning sits at the outer repo but code advances in a sub-repo, "HEAD" may not reflect code movement; scope therev-listto the repo the code lives in, or note the limitation.Reason and benefit
This is the direct answer to the recurring user report that "the roadmap / project / plans go out of date with the real project." The mechanism causing it is that all state reconciliation is write-triggered — if a workflow doesn't run, nothing reconciles, and the agent whose omission caused the drift is the same one that would have fixed it. A commit-based staleness signal is the cheapest possible intervention: it costs one
git rev-list, needs no new command, and no user has to remember to run anything. An agent readingSTATE.mdannotated "50 commits behind" goes and checks git; an agent reading an unannotated STATE.md trusts it.Scope of changes
src/state.cts— addstate_headto the set of first-class keyssyncStateFrontmatterwrites and preserves.src/verify.cts— new advisory health check computingrev-list --count state_head..HEAD(degrade to silent when no git / nostate_head).src/smart-entry.cts— a commit-drift signal inSmartEntrySignals.hooks/gsd-statusline.js+ itsparseStateMd— tolerate and optionally render the new field.tests/— regressions folded into owning module test files, asserting structured signals (no source-grep).Breaking changes
Adding a frontmatter key touches a de-facto public contract: the statusline,
smart-entry,gsd-statuslinetests, and third-party capabilities (e.g. projects-sync) parse STATE.md frontmatter. #2202 (closed — "syncStateFrontmatterdrops unknown STATE.md frontmatter keys on every mutating verb") is the precedent:state_headmust be added as a known key, and every parser must tolerate its absence on legacy files. With that handled, no behavior change to existing fields.Alternatives considered
status: passed#2348 (closed) established that mtime is a bad freshness oracle (a 49-second skew silently overrode apassedreport). Use a commit sha./gsd-reconcilecommand. Rejected: ADR-1787 + #3054 record that gsd-core already removed a duplicate front door; extendhealth/smart-entry, don't add a tenth.Area affected
STATE tracking / workflow situational awareness (
area: workflow).Additional context
drift.cts(last_mapped_commit) andgraphify(built_at_commit/head_at_build) — this is applying a shipped, tested pattern to a new artifact.stale_activityfail-open fix): bug(smart-entry): stale_activity never fires when last_activity carries the template's " — description" suffix (Date.parse → NaN → fails open) #2570 restored the time detector; this adds the commit detector, which is the honest oracle.state_headfor STATE.md — this is net-new. Adjacent-but-distinct: workstream progress reports "milestone complete / 100%" while roadmap phases are unstarted (status inferred, not derived from artifacts) #2562 (confirmed-bug — status inferred not derived; the display lies, whereas R2 adds a freshness signal) and gsd-debug-session-manager never commits session docs — commit_docs: true silently ignored on the manager-driven path #2568 (confirmed-bug —commit_docs: truesilently ignored on the debug-manager path, docs stranded uncommitted; confirmscommit_docsis live territory but is about committing docs, not staleness of docs). Neither overlaps R2's mechanism.behind" → refuted (that is branch-vs-upstream, not STATE-vs-HEAD). Laws that fire: Goodhart (the proxy-as-target trap — the entire framing note is the guard), Postel (liberal-but-visible; degrade tounknown/time-based, never fail silent — the bug(smart-entry): stale_activity never fires when last_activity carries the template's " — description" suffix (Date.parse → NaN → fails open) #2570 lineage), Hyrum (frontmatter-key contract,syncStateFrontmatterdrops unknown STATE.md frontmatter keys on every mutating verb #2202), Gall (mirrorsdrift.cts, no new subsystem — supports), Leaky-abstractions (STATE.md presents as "project state" but is unreconciled markdown; R2 documents that leak at the boundary rather than pretending to plug it).