Skip to content

enh(state): stamp STATE.md with the commit it was written against + surface a commit-age freshness hint #2573

Description

@davesienkowski

GSD Version

1.8.0 (next @ a5180d9)

Pre-submission checklist

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.md last_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

  1. Stamp provenance. syncStateFrontmatter writes state_head: <sha> (the commit HEAD was at when STATE.md was last synced), exactly mirroring last_mapped_commit.
  2. Compute drift cheaply. stale_commits = git rev-list --count state_head..HEAD — deterministic, exogenous, one git call.
  3. 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):

  1. Not a git repo / no HEAD → no commit signal; fall back to the time-based stale_activity (bug(smart-entry): stale_activity never fires when last_activity carries the template's " — description" suffix (Date.parse → NaN → fails open) #2570).
  2. state_head absent (legacy STATE.md, or first sync) → no signal this run; stamp it going forward.
  3. 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.
  4. 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_head after 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.
  5. 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.

Alternatives considered

Area affected

STATE tracking / workflow situational awareness (area: workflow).

Additional context

Metadata

Metadata

Assignees

No one assigned

    Labels

    approved-enhancementEnhancement approved — contributor may begin codingarea: workflowPhase execution, ROADMAP, STATE.mdenhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions