feat(#2734): surface STATE.md commit-age on the statusline (#3700)

* test(#2734): failing-first suite for the statusline STATE.md freshness marker

Binds the contract before any hook change exists: a `state ~N commits back`
segment gated on the state_head stamp landed by #2622, firing at the same
advisory threshold /gsd-health's W024 uses rather than at > 0.

Covers all five acceptance criteria — threshold parity (19/20/21 boundaries),
both renderers including formatGsdStateCompact, an exact spawn-count assertion,
repo-pinning and sub_repos degradation, and behavioral parity against
readStateHeadFreshness rather than a source-grep of the two fence copies.

52 example-based tests plus 5 seeded fast-check properties. Red now by design.

* feat(#2734): surface STATE.md commit-age on the statusline

Adds an opt-in `state ~N commits back` marker to the GSD-state segment,
consuming the `state_head` stamp and freshness contract landed by #2622.
A solo developer returning to a project reads "Phase 4, executing" in
STATE.md and acts on it, without noticing the codebase moved 40 commits
since that line was written. /gsd-health reports it as W024, but only if
you think to run it; the statusline is the surface you see without asking.

Fires at STATE_HEAD_ADVISORY_COMMITS (20), the same threshold W024 uses,
not at > 0: with commit_docs:true the commit carrying a STATE.md sync
advances HEAD by one, so > 0 would alarm permanently on a fresh project.

Costs exactly one bounded git subprocess per render and none when
disabled. `rev-list --left-right --count` answers ancestry and distance
together, and repo pinning is a filesystem check mirroring
projectOwnsItsRepo rather than a --show-toplevel compare, which is
unreliable on macOS /private/var and Windows 8.3 paths.

Every unresolvable input degrades to the tri-state unknown -- the marker
is absent, never a "fresh" claim the project cannot substantiate: a
malformed stamp, a root that does not own its .git, a sub_repos
workspace, history rewound past the stamp, or git being unavailable.

Also collapses statusline config resolution onto one resolveStatuslineOptions()
seam. runStatusline() and renderStatusline() duplicated it byte-for-byte;
one copy is what keeps a newly-added key from reaching only one of them.

* test(#2734): route the e2e spawn through the process seam and fix fixture leaks

Review findings from the two orthogonal passes:

- `bothEntryPointsResolveOptionsIdentically` spawned a child and substring-matched
  its stdout to test a pure function. It now calls resolveStatuslineOptions()
  directly — no subprocess, no text matching.
- `skipsFreshnessWorkWhenTodoTaskActive` genuinely needs a child (the !task gate
  lives in runStatusline, which reads stdin), so it now spawns through
  tests/helpers/process-seam.cjs and proves the negative with a filesystem fact:
  the git shim appends to a marker file on every invocation, and the assertion is
  that the marker never appears. Stronger than asserting text is missing, and it
  drops the last stdout substring match in the block.
- Every fixture-creating test now registers `t.after(() => cleanup(dir))` instead
  of a trailing cleanup(dir), which leaked the temp repo on assertion failure.
  derivationAgreesWithStateModule reassigns `dir` across five fixtures, so it
  binds each directory at scheduling time rather than cleaning only the last.

Also corrects markerCoexistsWithMilestoneComplete, which asserted the wrong
expectation rather than finding a code defect: `percent` drives the progress bar
too, so the milestone segment reads "v1.9 [##########] 100%". The marker appends
after it, which is what the test exists to prove.

CONTEXT.md's opt-in statusline key list was missing statusline.show_git as well
as the new key; both are now enumerated.

* docs(#2734): backfill changeset PR number (#3700)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-20 00:35:01 -04:00
committed by GitHub
parent 2fca0e17e4
commit adb46cdd85
10 changed files with 1158 additions and 24 deletions

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 3700
---
**Statusline can now warn that STATE.md has fallen behind the code** — enable `statusline.show_state_freshness` and the GSD-state segment renders `state ~N commits back` once HEAD is 20+ commits past the commit STATE.md was written against, the same advisory threshold `/gsd-health`'s W024 uses. Off by default; costs one bounded git call per render only while enabled, and stays silent rather than guessing when freshness cannot be established. (#2734)

File diff suppressed because one or more lines are too long

View File

@@ -613,6 +613,7 @@ for a worked example.
| `statusline.show_context_tokens` | boolean | `false` | Append the absolute token count (e.g. `(156k)`) after the context meter's percentage. Sums input, cache-creation, cache-read, and output tokens from the hook payload — a broader basis than the meter's percentage (which excludes output tokens), so the two figures can diverge slightly. Opt-in; the meter is unchanged when the flag is absent | | `statusline.show_context_tokens` | boolean | `false` | Append the absolute token count (e.g. `(156k)`) after the context meter's percentage. Sums input, cache-creation, cache-read, and output tokens from the hook payload — a broader basis than the meter's percentage (which excludes output tokens), so the two figures can diverge slightly. Opt-in; the meter is unchanged when the flag is absent |
| `statusline.state_format` | string | `"full"` | Format of the GSD-state segment. `"full"` (default) is the existing rendering with milestone name and progress bar. `"compact"` renders `<version> · P<phase>/<total> · <status>` (e.g. `v1.12 · P7/12 · executing`) — drops the milestone name and bar, and collapses narrative statuses to the canonical keyword set from `normalizeStateStatus()` (`paused` — the canonical stuck state — renders uppercase as `PAUSED`) | | `statusline.state_format` | string | `"full"` | Format of the GSD-state segment. `"full"` (default) is the existing rendering with milestone name and progress bar. `"compact"` renders `<version> · P<phase>/<total> · <status>` (e.g. `v1.12 · P7/12 · executing`) — drops the milestone name and bar, and collapses narrative statuses to the canonical keyword set from `normalizeStateStatus()` (`paused` — the canonical stuck state — renders uppercase as `PAUSED`) |
| `statusline.show_git` | boolean | `false` | Append a git segment after the directory: current branch plus compact work-state markers (`+staged` `~unstaged` `?untracked` `↑ahead` `↓behind`, or `✓` when clean and in sync). One `git status --porcelain=v2` call per render; the segment is absent outside a git repo or when git is unavailable | | `statusline.show_git` | boolean | `false` | Append a git segment after the directory: current branch plus compact work-state markers (`+staged` `~unstaged` `?untracked` `↑ahead` `↓behind`, or `✓` when clean and in sync). One `git status --porcelain=v2` call per render; the segment is absent outside a git repo or when git is unavailable |
| `statusline.show_state_freshness` | boolean | `false` | Append `state ~N commits back` to the GSD-state segment when STATE.md carries a `state_head` stamp and the codebase has moved at least 20 commits past it (the same advisory threshold `/gsd-health`'s W024 uses). Exactly one `git rev-list` call per render, and only when enabled and a stamp is present; the marker is absent below the threshold, outside a git repo, when the project root does not own its `.git`, and in `planning.sub_repos` workspaces |
The prompt injection guard hook (`gsd-prompt-guard.js`) is always active and cannot be disabled — it's a security feature, not a workflow toggle. The prompt injection guard hook (`gsd-prompt-guard.js`) is always active and cannot be disabled — it's a security feature, not a workflow toggle.

View File

@@ -185,6 +185,8 @@
- [Broken-Windows Ledger](#158-broken-windows-ledger) - [Broken-Windows Ledger](#158-broken-windows-ledger)
- [Complexity-Triggered Refactor](#159-complexity-triggered-refactor) - [Complexity-Triggered Refactor](#159-complexity-triggered-refactor)
- [Archive Quick Tasks at Milestone Close](#160-archive-quick-tasks-at-milestone-close) - [Archive Quick Tasks at Milestone Close](#160-archive-quick-tasks-at-milestone-close)
- [Verify-Command Path Grounding](#161-verify-command-path-grounding)
- [Statusline STATE.md Freshness Marker](#162-statusline-statemd-freshness-marker)
--- ---
@@ -3449,3 +3451,23 @@ See [Archiving quick tasks](how-to/handle-quick-and-fast-tasks.md#archiving-quic
- `script_missing` is advisory only — this phase may be adding the script — so a genuinely mistyped npm script still reaches the executor. - `script_missing` is advisory only — this phase may be adding the script — so a genuinely mistyped npm script still reaches the executor.
See [Resolve verify-command path findings](how-to/resolve-verify-command-path-findings.md) and [`gsd-tools check verify-command-paths`](COMMANDS.md#gsd-tools-check-verify-command-paths). See [Resolve verify-command path findings](how-to/resolve-verify-command-path-findings.md) and [`gsd-tools check verify-command-paths`](COMMANDS.md#gsd-tools-check-verify-command-paths).
---
### 162. Statusline STATE.md Freshness Marker
**Config key:** `statusline.show_state_freshness` (default `false`)
**Purpose:** A solo developer returning to a project after time away reads "Phase 4, executing" in `STATE.md` and acts on it — without noticing the codebase has moved 40 commits since that line was written. `/gsd-health` reports this as `W024`, but only if the user thinks to run it. The statusline is the one surface seen continuously without asking (#2734).
**Behavior:** Renders `state ~N commits back` inside the GSD-state segment when `STATE.md` carries a `state_head` stamp (#2573) and `HEAD` is at least `STATE_HEAD_ADVISORY_COMMITS` (20) commits past it. Both statusline formats carry it — the default renderer and the compact `statusline.state_format` one.
**The threshold is 20, deliberately not 1.** With `commit_docs: true` (the default) the commit carrying a `STATE.md` sync advances `HEAD` by one, so a `> 0` threshold would render `state ~1 commits back` permanently on a project that is by construction fresh — alarm fatigue on the one always-visible surface.
**It degrades to silence rather than to a wrong answer.** The marker is absent — never "fresh" — when the stamp is malformed, when the project root does not own its `.git` (an enclosing unrelated repo would otherwise answer), in a `planning.sub_repos` workspace (the outer `HEAD` never advances when code lands in children), when history was rewound past the stamp, and when git is unavailable or slow. A freshness claim the project cannot substantiate degrades to *unknown*.
**Cost:** exactly one bounded `git rev-list` call per render, and only when enabled *and* a stamp is present — `rev-list --left-right --count` answers ancestry and distance together, and repo pinning is a filesystem check rather than a subprocess. Disabled (the default) it adds none.
**A proxy, never a drift measurement.** The count includes commits that touched nothing `STATE.md` describes, and the stamp restamps on every state write — so a low count means "something wrote STATE recently", not "STATE is accurate". Rendered with a `~`; never gate on it.
**Reference:** [Configuration](CONFIGURATION.md) · [Read the statusline freshness marker](how-to/read-the-statusline-freshness-marker.md) · [ADR-2164](adr/2164-statusline-scope-boundary.md)

View File

@@ -28,6 +28,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md)
- [Resolve a contract-drift finding](how-to/resolve-contract-drift-findings.md) — bring an agent's completion contract, read-tag gate, or deleted-file test reference back into agreement with the registry - [Resolve a contract-drift finding](how-to/resolve-contract-drift-findings.md) — bring an agent's completion contract, read-tag gate, or deleted-file test reference back into agreement with the registry
- [Resolve unreachable-guard findings](how-to/resolve-unreachable-guard-findings.md) — fix shell guards whose fallback arm cannot run, and tell "nothing to report" apart from "could not look" - [Resolve unreachable-guard findings](how-to/resolve-unreachable-guard-findings.md) — fix shell guards whose fallback arm cannot run, and tell "nothing to report" apart from "could not look"
- [Resolve an ESLint glob-coverage finding](how-to/resolve-eslint-coverage-findings.md) — bring a source file that matches no lint rule under coverage, or record a reasoned exemption - [Resolve an ESLint glob-coverage finding](how-to/resolve-eslint-coverage-findings.md) — bring a source file that matches no lint rule under coverage, or record a reasoned exemption
- [Read the statusline freshness marker](how-to/read-the-statusline-freshness-marker.md) — turn on `state ~N commits back`, and tell "STATE.md is fresh" apart from "freshness could not be established"
- [Keep planning docs out of a shared repo](how-to/keep-planning-docs-private.md) — make `.planning/` local-only, including untracking files git already tracks (the step `.gitignore` alone cannot do) - [Keep planning docs out of a shared repo](how-to/keep-planning-docs-private.md) — make `.planning/` local-only, including untracking files git already tracks (the step `.gitignore` alone cannot do)
- [Plan a phase](how-to/plan-a-phase.md) — run research, decompose work, and verify plan quality - [Plan a phase](how-to/plan-a-phase.md) — run research, decompose work, and verify plan quality
- [Execute a phase](how-to/execute-a-phase.md) — run plans in parallel waves with fresh-context subagents - [Execute a phase](how-to/execute-a-phase.md) — run plans in parallel waves with fresh-context subagents

View File

@@ -0,0 +1,111 @@
# Read the statusline STATE.md freshness marker
The statusline can tell you, at a glance, that `STATE.md` is describing a codebase that has
moved on without it:
```
Claude │ v2.0 Auth Rework · executing · state ~34 commits back │ my-project
```
`state ~34 commits back` means `STATE.md` was last written against a commit that is now 34
commits behind `HEAD`. You come back to a project after two weeks, the state file still says
"Phase 4, executing", and this is the thing that tells you that sentence is stale before you
act on it.
The marker is **advisory and approximate**. It never blocks anything.
## Turn it on
```bash
gsd-tools config-set statusline.show_state_freshness true
```
It is off by default. That is the only step — the `state_head` stamp it reads is written
automatically every time GSD syncs `STATE.md`, so an active project already has one.
To turn it off again:
```bash
gsd-tools config-set statusline.show_state_freshness false
```
The marker also appears in the compact statusline format
(`statusline.state_format: "compact"`), rendered identically.
## When it appears
Only when **all** of these hold:
1. `statusline.show_state_freshness` is `true`.
2. `.planning/STATE.md` carries a `state_head:` stamp.
3. The project root owns its own `.git`.
4. The stamp is an ancestor of the current `HEAD`.
5. `HEAD` is at least **20 commits** past the stamp.
Twenty is the same advisory threshold `/gsd-health` uses for its `W024` warning, and it is
deliberately not `1`. With `commit_docs: true` (the default) the commit that carries a
`STATE.md` sync advances `HEAD` by one, so a threshold of `> 0` would show
`state ~1 commits back` permanently on a project that is, by construction, perfectly fresh.
## I turned it on and see nothing
That is usually correct behavior rather than a fault, but "nothing to report" and "could not
look" render identically — both are simply absent. Work down this table to tell them apart.
| Reason | How to confirm | Is it a problem? |
|---|---|---|
| **Fewer than 20 commits behind** | `git rev-list --count $(grep '^state_head:' .planning/STATE.md \| cut -d' ' -f2)..HEAD` | No — this is the healthy case |
| **No `state_head` stamp** | `grep '^state_head:' .planning/STATE.md` returns nothing | No — the stamp appears on the next state write |
| **Project root does not own its `.git`** | `ls -d .git` at the directory holding `.planning/` | No — deliberate. See below |
| **`planning.sub_repos` is set** | `gsd-tools config-get planning.sub_repos` | No — deliberate. See below |
| **History was rewound past the stamp** | `git merge-base --is-ancestor <stamp> HEAD; echo $?` prints `1` | No — reported as unknown on purpose |
| **The stamp is not a commit in this repo** | `git cat-file -e <stamp>` fails | Possibly — a hand-edited `STATE.md` |
| **`git` unavailable, or the repo is enormous** | `git --version`; the read is abandoned after 1.5s | Rarely — the marker yields rather than stall your prompt |
| **A todo task is showing instead** | The middle segment shows a task, not GSD state | No — the whole GSD-state segment is replaced |
Run `/gsd-health` for the same signal in a form that always explains itself — it reports `W024`
with the count, and it is not subject to the statusline's silence.
### Why it stays quiet instead of guessing
Two cases deserve spelling out, because in both the marker *could* print a number and that
number would be a confident lie:
- **A GSD project nested inside an unrelated checkout.** `git` resolves `HEAD` from the nearest
enclosing `.git`, which might belong to a dotfiles or notes repo that has nothing to do with
your project. Rather than report that repo's history as your project's freshness, GSD checks
that the directory holding `.planning/` owns its own `.git` and otherwise says nothing.
- **A `planning.sub_repos` workspace.** The outer directory legitimately owns both `.planning/`
and its own repo, while every code commit lands in a nested child. The outer `HEAD` never
advances, so the marker would read "fresh" forever while the code moved arbitrarily far.
Per-child freshness would require choosing one `HEAD` out of several unrelated histories, so
GSD declines to answer instead.
The rule in both: a freshness claim the project cannot substantiate degrades to *unknown*,
never to *fresh*.
## What the number does and does not mean
`~34` counts **every** commit between the stamp and `HEAD`, including commits that touched
nothing `STATE.md` describes. And because `state_head` is restamped on every state write, a
*low* count means "something wrote `STATE.md` recently" — not "`STATE.md` is accurate."
So read it as a prompt to look, not as a measurement of drift:
- **A high count** is a reliable signal that the state file is worth re-reading.
- **A low count** is not evidence that the state file is correct.
Do not build automation on it. It is a proxy, deliberately rendered with a `~`.
## Cost
One `git rev-list` call per statusline render, and only when the marker is enabled *and* a
`state_head` stamp is present. With the feature off — the default — it adds no subprocess and
no measurable work. The call is abandoned after 1.5 seconds, so a slow or huge repository
costs you a missing marker rather than a stalled prompt.
## Related
- [Configuration reference](../CONFIGURATION.md) — `statusline.show_state_freshness` and the
other `statusline.*` keys
- [`/gsd-health`](../COMMANDS.md) — the `W024` warning that thresholds on the same constant

View File

@@ -76,6 +76,7 @@
"statusline.show_context_tokens", "statusline.show_context_tokens",
"statusline.state_format", "statusline.state_format",
"statusline.show_git", "statusline.show_git",
"statusline.show_state_freshness",
"workflow.max_discuss_passes", "workflow.max_discuss_passes",
"features.thinking_partner", "features.thinking_partner",
"context", "context",

View File

@@ -169,13 +169,27 @@ function readStateFileOrNull(statePath) {
* - null when no .planning marker is found at all (GSD not present), or * - null when no .planning marker is found at all (GSD not present), or
* when a workstream DOES resolve but its STATE.md doesn't exist yet * when a workstream DOES resolve but its STATE.md doesn't exist yet
* (negative space: mirrors flat-mode's own silent pre-STATE.md window) * (negative space: mirrors flat-mode's own silent pre-STATE.md window)
*
* @param {string} dir
* @param {{ stateFreshness?: boolean }} [opts] — #2734, additive/default-off.
* When true and the resolved state carries a truthy `.stateHead`, attaches
* `state.freshness` (deriveStateFreshness) before returning — `current` at
* that point is the project root the walk resolved, which is what AC-4's
* repo-pinning check needs. Never derived when false or the stamp is
* absent, so existing callers (default opts) spend zero extra spawns.
*/ */
function readGsdState(dir) { function readGsdState(dir, opts = {}) {
const { stateFreshness = false } = opts;
const home = os.homedir(); const home = os.homedir();
let current = dir; let current = dir;
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
const flatState = readStateFileOrNull(path.join(current, '.planning', 'STATE.md')); const flatState = readStateFileOrNull(path.join(current, '.planning', 'STATE.md'));
if (flatState !== null) return flatState; if (flatState !== null) {
if (stateFreshness && flatState.stateHead) {
flatState.freshness = deriveStateFreshness(current, flatState.stateHead);
}
return flatState;
}
if (listAvailableWorkstreams(current).length > 0) { if (listAvailableWorkstreams(current).length > 0) {
let resolvedWs = null; let resolvedWs = null;
@@ -187,7 +201,11 @@ function readGsdState(dir) {
if (!resolvedWs) return { noActiveWorkstream: true }; if (!resolvedWs) return { noActiveWorkstream: true };
return readStateFileOrNull(planningPaths(current, resolvedWs).state); const wsState = readStateFileOrNull(planningPaths(current, resolvedWs).state);
if (wsState !== null && stateFreshness && wsState.stateHead) {
wsState.freshness = deriveStateFreshness(current, wsState.stateHead);
}
return wsState;
} }
const parent = path.dirname(current); const parent = path.dirname(current);
@@ -238,6 +256,10 @@ function parseStateMd(content) {
if (key === 'active_phase') state.activePhase = (v === 'null' || v === '') ? null : v; if (key === 'active_phase') state.activePhase = (v === 'null' || v === '') ? null : v;
// next_action: recommended command when idle (discuss-phase / plan-phase / execute-phase / verify-phase) // next_action: recommended command when idle (discuss-phase / plan-phase / execute-phase / verify-phase)
if (key === 'next_action') state.nextAction = (v === 'null' || v === '') ? null : v; if (key === 'next_action') state.nextAction = (v === 'null' || v === '') ? null : v;
// #2734: state_head — the commit STATE.md was written against, consumed
// by deriveStateFreshness() below. Mirrors active_phase/next_action's
// null/empty handling exactly.
if (key === 'state_head') state.stateHead = (v === 'null' || v === '') ? null : v;
} }
// next_phases supports both flow array and block-list YAML forms. // next_phases supports both flow array and block-list YAML forms.
const npFlowMatch = fm.match(/^next_phases:\s*\[([^\]]*)\]/m); const npFlowMatch = fm.match(/^next_phases:\s*\[([^\]]*)\]/m);
@@ -371,6 +393,10 @@ function formatGsdState(s) {
} }
} }
// #2734: STATE.md freshness marker — opt-in, appended last.
const fresh = formatStateFreshness(s.freshness);
if (fresh) parts.push(fresh);
return parts.join(' · '); return parts.join(' · ');
} }
@@ -476,6 +502,10 @@ function formatGsdStateCompact(s) {
} }
} }
// #2734: STATE.md freshness marker \u2014 opt-in, appended last.
const fresh = formatStateFreshness(s.freshness);
if (fresh) parts.push(fresh);
return parts.join(' \u00b7 '); return parts.join(' \u00b7 ');
} }
@@ -572,6 +602,146 @@ function buildGitSegment(info) {
return ` │ \x1b[2m${info.branch}\x1b[0m${state}`; return ` │ \x1b[2m${info.branch}\x1b[0m${state}`;
} }
// --- STATE.md freshness marker (opt-in, #2734) --------------------------------
//
// Opt-in via `statusline.show_state_freshness: true`. Renders `state ~N
// commits back` inside the GSD-state segment when STATE.md's `state_head`
// stamp (#2573) is at least STATE_HEAD_ADVISORY_COMMITS commits behind HEAD.
// Same impure-reader -> pure-IR -> pure-formatter shape as the git segment
// above. See .gsd/phase/feat-2734-statusline-state-freshness/40-design.md.
// Deliberate mirror of the fence in src/state.cts (STATE_HEAD_HASH_RE) — kept
// hook-side rather than requiring state.cjs on the per-render path (measured
// ~20ms; see design doc "Laws that apply"). tests/gsd-statusline.test.cjs
// asserts behavioral parity against readStateHeadFreshness rather than
// comparing source (local/no-source-grep forbids the latter anyway).
const STATE_HEAD_HASH_RE = /^[0-9a-f]{4,40}$/i;
// Mirror of the constant verify.cts's W024 health check thresholds on
// (STATE_HEAD_ADVISORY_COMMITS). A test asserts equality with verify.cjs's
// export so the two copies can't drift.
const STATE_HEAD_ADVISORY_COMMITS = 20;
// Same bound class as GIT_STATUS_TIMEOUT_MS above.
const STATE_FRESHNESS_GIT_TIMEOUT_MS = 1500;
/**
* Pure function: does raw pass the state_head hash fence? Must run BEFORE any
* value from STATE.md reaches a git argv slot.
*/
function isValidStateHeadStamp(raw) {
return typeof raw === 'string' && STATE_HEAD_HASH_RE.test(raw.trim());
}
/**
* Run `git rev-list --left-right --count <stamp>...HEAD` in root. Returns raw
* stdout, or null when git is missing, root isn't a repo, the stamp is
* unknown, or the call times out. Never throws. Only call with a stamp that
* already passed isValidStateHeadStamp/the hash fence above.
*/
function readStateHeadCommits(root, stamp) {
try {
return childProcess.execFileSync('git',
['-C', root, 'rev-list', '--left-right', '--count', `${stamp}...HEAD`],
{ encoding: 'utf8', timeout: STATE_FRESHNESS_GIT_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
} catch (e) {
return null;
}
}
/**
* Pure function: parse `git rev-list --left-right --count A...B` output
* ("<left>\t<right>"). Returns { left, right } as non-negative integers, or
* null when text isn't a matching string (covers null, '', 'garbage', '1',
* 'a\tb', '\t', and any other unparseable shape).
*/
function parseRevListCounts(text) {
if (typeof text !== 'string') return null;
const m = text.match(/^(\d+)\s+(\d+)\s*$/);
if (!m) return null;
return { left: parseInt(m[1], 10), right: parseInt(m[2], 10) };
}
/**
* Impure -> pure IR: derive the freshness signal for a recorded state_head
* stamp. Returns { state_head, commits_behind, commit_stale } — never throws,
* every unresolvable input degrades to the all-null-but-state_head shape.
*
* Order (each failure returns immediately, no further work):
* a. hash fence — malformed/absent stamp never reaches a spawn
* b. repo pinning — root must own its own .git (mirrors projectOwnsItsRepo
* in src/state.cts: a filesystem-identity check, not a --show-toplevel
* string compare, which is unreliable on macOS /private/var and Windows
* 8.3 paths). Costs no subprocess.
* c. sub_repos guard — a planning.sub_repos workspace's outer HEAD never
* advances when code lands in nested children, so a "fresh" answer here
* would be a confident lie. Costs no subprocess.
* d. one bounded git spawn: rev-list --left-right --count answers ancestry
* and distance together. left > 0 means the stamp is not an ancestor of
* HEAD (reset/rebase/force-push) -> unknown, never "fresh".
*/
function deriveStateFreshness(root, stamp, deps = {}) {
const { existsSync = fs.existsSync, readConfig = readGsdConfig, readCounts = readStateHeadCommits } = deps;
const raw = typeof stamp === 'string' ? stamp.trim() : '';
const valid = STATE_HEAD_HASH_RE.test(raw);
const state_head = valid ? raw.slice(0, 7) : null;
const nullResult = { state_head, commits_behind: null, commit_stale: null };
if (!valid || !root) return nullResult;
try {
if (!existsSync(path.join(root, '.git'))) return nullResult;
} catch (e) {
return nullResult;
}
try {
const cfg = readConfig(root);
const sub = getConfigValue(cfg, 'planning.sub_repos') ?? getConfigValue(cfg, 'sub_repos');
if (Array.isArray(sub) && sub.length > 0) return nullResult;
} catch (e) {
return nullResult;
}
const counts = parseRevListCounts(readCounts(root, raw));
if (!counts || counts.left > 0) return nullResult;
return { state_head, commits_behind: counts.right, commit_stale: counts.right > 0 };
}
/**
* Pure function: format the freshness IR into the marker text, or '' below
* STATE_HEAD_ADVISORY_COMMITS (including when commits_behind is absent/null —
* the unknown case must never render, never mind alarm on it).
*/
function formatStateFreshness(fresh) {
if (!fresh || typeof fresh.commits_behind !== 'number' || fresh.commits_behind < STATE_HEAD_ADVISORY_COMMITS) return '';
return `state ~${fresh.commits_behind} commits back`;
}
/**
* Pure function: single source of truth for statusline config resolution.
* `runStatusline()` and `renderStatusline()` previously read this config
* independently, which had drifted into a live divergence between the two
* entry points — this collapses both onto one resolver.
*
* @param {object} cfg — parsed .planning/config.json (readGsdConfig())
* @returns {{ showLastCommand: boolean, position: 'end'|'front', stateFormat: 'full'|'compact', showGit: boolean, showStateFreshness: boolean }}
*/
function resolveStatuslineOptions(cfg) {
const showLastCommand = getConfigValue(cfg, 'statusline.show_last_command') === true;
const cfgPos = getConfigValue(cfg, 'statusline.context_position');
// Clamp any non-'front' value (including absent/null) to 'end' — the single
// source of truth for this default; composeStatusline's own coercion stays
// as belt-and-suspenders defense for direct callers.
const position = cfgPos === 'front' ? 'front' : 'end';
const stateFormat = getConfigValue(cfg, 'statusline.state_format') === 'compact' ? 'compact' : 'full';
const showGit = getConfigValue(cfg, 'statusline.show_git') === true;
const showStateFreshness = getConfigValue(cfg, 'statusline.show_state_freshness') === true;
return { showLastCommand, position, stateFormat, showGit, showStateFreshness };
}
// --- stdin ------------------------------------------------------------------ // --- stdin ------------------------------------------------------------------
function runStatusline() { function runStatusline() {
@@ -717,31 +887,34 @@ function runStatusline() {
// Last-slash-command suffix and context_position config (#2538, #2937). // Last-slash-command suffix and context_position config (#2538, #2937).
// Reads the active session transcript for the most recent <command-name> tag. // Reads the active session transcript for the most recent <command-name> tag.
// Failure here must never break the statusline — wrap the entire lookup. // Failure here must never break the statusline — wrap the entire lookup.
// #2734: config resolution moved to resolveStatuslineOptions() — the single
// source of truth shared with renderStatusline() below. The two entry
// points duplicated this resolution byte-for-byte; one copy is what keeps
// a new key from reaching only one of them.
let lastCmdSuffix = ''; let lastCmdSuffix = '';
let position = 'end';
let stateFormat = 'full';
let gitSuffix = ''; let gitSuffix = '';
const options = resolveStatuslineOptions(cfg);
try { try {
if (getConfigValue(cfg, 'statusline.show_last_command') === true) { if (options.showLastCommand) {
const transcriptPath = data.transcript_path; const transcriptPath = data.transcript_path;
const lastCmd = readLastSlashCommand(transcriptPath); const lastCmd = readLastSlashCommand(transcriptPath);
if (lastCmd) { if (lastCmd) {
lastCmdSuffix = ` │ \x1b[2mlast: /${lastCmd}\x1b[0m`; lastCmdSuffix = ` │ \x1b[2mlast: /${lastCmd}\x1b[0m`;
} }
} }
const cfgPos = getConfigValue(cfg, 'statusline.context_position'); if (options.showGit) {
if (cfgPos != null) position = cfgPos;
if (getConfigValue(cfg, 'statusline.state_format') === 'compact') stateFormat = 'compact';
if (getConfigValue(cfg, 'statusline.show_git') === true) {
gitSuffix = buildGitSegment(parseGitStatus(readGitStatus(dir))); gitSuffix = buildGitSegment(parseGitStatus(readGitStatus(dir)));
} }
} catch (e) { } catch (e) {
// Never break the statusline on config/transcript/git errors // Never break the statusline on config/transcript/git errors
} }
// #2734: readGsdState is inside `if (!task)` deliberately — when a todo
// task is in flight the GSD-state segment is not rendered, so spending a
// freshness git spawn here would spend a subprocess on discarded output.
if (!task) { if (!task) {
const state = readGsdState(dir) || {}; const state = readGsdState(dir, { stateFreshness: options.showStateFreshness }) || {};
gsdStateStr = stateFormat === 'compact' ? formatGsdStateCompact(state) : formatGsdState(state); gsdStateStr = options.stateFormat === 'compact' ? formatGsdStateCompact(state) : formatGsdState(state);
} }
// Output // Output
@@ -752,7 +925,7 @@ function runStatusline() {
? `\x1b[2m${gsdStateStr}\x1b[0m` ? `\x1b[2m${gsdStateStr}\x1b[0m`
: null; : null;
process.stdout.write(composeStatusline({ gsdUpdate, model, ctx, middle, dirname, lastCmdSuffix, gitSuffix, position })); process.stdout.write(composeStatusline({ gsdUpdate, model, ctx, middle, dirname, lastCmdSuffix, gitSuffix, position: options.position }));
} catch (e) { } catch (e) {
// Silent fail - don't break statusline on parse errors // Silent fail - don't break statusline on parse errors
} }
@@ -846,6 +1019,9 @@ module.exports = {
shortGsdStatus, formatGsdStateCompact, shortGsdStatus, formatGsdStateCompact,
compactModelName, compactModelName,
readGitStatus, parseGitStatus, buildGitSegment, readGitStatus, parseGitStatus, buildGitSegment,
STATE_HEAD_ADVISORY_COMMITS, isValidStateHeadStamp,
readStateHeadCommits, parseRevListCounts, deriveStateFreshness,
formatStateFreshness, resolveStatuslineOptions,
}; };
/** /**
@@ -857,30 +1033,31 @@ function renderStatusline(data) {
const dir = data.workspace?.current_dir || process.cwd(); const dir = data.workspace?.current_dir || process.cwd();
const dirname = path.basename(dir); const dirname = path.basename(dir);
// #2734: config resolution moved to resolveStatuslineOptions() — the single
// source of truth shared with runStatusline() above. The two entry points
// duplicated this resolution byte-for-byte; one copy is what keeps a new
// key from reaching only one of them.
let lastCmdSuffix = ''; let lastCmdSuffix = '';
let position = 'end';
let stateFormat = 'full';
let gitSuffix = ''; let gitSuffix = '';
let options = { showLastCommand: false, position: 'end', stateFormat: 'full', showGit: false, showStateFreshness: false };
try { try {
const cfg = readGsdConfig(dir); const cfg = readGsdConfig(dir);
if (getConfigValue(cfg, 'statusline.show_last_command') === true) { options = resolveStatuslineOptions(cfg);
if (options.showLastCommand) {
const lastCmd = readLastSlashCommand(data.transcript_path); const lastCmd = readLastSlashCommand(data.transcript_path);
if (lastCmd) { if (lastCmd) {
lastCmdSuffix = ` │ \x1b[2mlast: /${lastCmd}\x1b[0m`; lastCmdSuffix = ` │ \x1b[2mlast: /${lastCmd}\x1b[0m`;
} }
} }
const cfgPos = getConfigValue(cfg, 'statusline.context_position'); if (options.showGit) {
if (cfgPos != null) position = cfgPos;
if (getConfigValue(cfg, 'statusline.state_format') === 'compact') stateFormat = 'compact';
if (getConfigValue(cfg, 'statusline.show_git') === true) {
gitSuffix = buildGitSegment(parseGitStatus(readGitStatus(dir))); gitSuffix = buildGitSegment(parseGitStatus(readGitStatus(dir)));
} }
} catch (e) { /* swallow */ } } catch (e) { /* swallow */ }
const state = readGsdState(dir) || {}; const state = readGsdState(dir, { stateFreshness: options.showStateFreshness }) || {};
const gsdStateStr = stateFormat === 'compact' ? formatGsdStateCompact(state) : formatGsdState(state); const gsdStateStr = options.stateFormat === 'compact' ? formatGsdStateCompact(state) : formatGsdState(state);
const middle = gsdStateStr ? `\x1b[2m${gsdStateStr}\x1b[0m` : null; const middle = gsdStateStr ? `\x1b[2m${gsdStateStr}\x1b[0m` : null;
return composeStatusline({ model, ctx: '', middle, dirname, lastCmdSuffix, gitSuffix, position }); return composeStatusline({ model, ctx: '', middle, dirname, lastCmdSuffix, gitSuffix, position: options.position });
} }
module.exports.renderStatusline = renderStatusline; module.exports.renderStatusline = renderStatusline;

View File

@@ -64,3 +64,108 @@ describe('shortGsdStatus properties (#2162)', () => {
); );
}); });
}); });
/**
* Property tests for STATE.md freshness marker derivation (#2734).
*
* These properties are asserted against `deriveStateFreshness`,
* `isValidStateHeadStamp`, `formatStateFreshness`, `formatGsdState`, and
* `formatGsdStateCompact` per `.gsd/phase/feat-2734-statusline-state-freshness/50-test-matrix.md`
* rows P1-P5. None of these symbols exist on `hooks/gsd-statusline.js` yet —
* this block is deliberately failing-first TDD.
*/
const {
STATE_HEAD_ADVISORY_COMMITS,
isValidStateHeadStamp,
deriveStateFreshness,
formatStateFreshness,
formatGsdState: formatGsdStateFull,
formatGsdStateCompact: formatGsdStateCompactFn,
} = require('../hooks/gsd-statusline.js');
describe('state-head freshness properties (#2734)', () => {
test('derivationIsTotalOverArbitraryStamps', () => {
const root = require('node:os').tmpdir();
fc.assert(
fc.property(fc.string(), (stamp) => {
const ir = deriveStateFreshness(root, stamp);
if (!ir || typeof ir !== 'object') return false;
const keys = Object.keys(ir).sort().join(',');
if (keys !== ['commit_stale', 'commits_behind', 'state_head'].sort().join(',')) return false;
const cb = ir.commits_behind;
return cb === null || (Number.isInteger(cb) && cb >= 0);
}),
);
});
test('fenceAcceptsOnlyHexInRange', () => {
fc.assert(
fc.property(fc.string(), (s) => {
const expected = /^[0-9a-f]{4,40}$/i.test(String(s).trim());
return isValidStateHeadStamp(s) === expected;
}),
);
});
test('markerVisibilityIsMonotonicInCount', () => {
fc.assert(
fc.property(fc.nat({ max: 1000000 }), (n) => {
const out = formatStateFreshness({ commits_behind: n });
const shouldShow = n >= STATE_HEAD_ADVISORY_COMMITS;
return shouldShow ? out !== '' : out === '';
}),
);
});
test('unknownNeverDegradesToFresh', () => {
const root = require('node:os').tmpdir();
fc.assert(
fc.property(fc.string(), (stamp) => {
const ir = deriveStateFreshness(root, stamp);
return ir.commit_stale === null;
}),
);
});
test('renderersAreTotalOverArbitraryIr', () => {
const optionalString = fc.oneof(fc.constant(undefined), fc.string());
const optionalCount = fc.oneof(fc.constant(undefined), fc.nat({ max: 999 }));
const hasBadSeparator = (str) => {
const trimmed = str.trim();
if (trimmed.startsWith('·') || trimmed.endsWith('·')) return true;
return str.includes('··') || str.includes('· ·');
};
fc.assert(
fc.property(
fc.record({
status: optionalString,
phaseNum: optionalCount,
phaseTotal: optionalCount,
phaseName: optionalString,
milestone: optionalString,
milestoneName: optionalString,
percent: fc.oneof(fc.constant(undefined), fc.nat({ max: 100 })),
activePhase: optionalString,
nextAction: optionalString,
nextPhases: fc.oneof(fc.constant(undefined), fc.array(fc.string(), { maxLength: 3 })),
completedPhases: optionalCount,
totalPhases: optionalCount,
noActiveWorkstream: fc.boolean(),
freshness: fc.record({
state_head: fc.oneof(fc.constant(null), fc.string()),
commits_behind: fc.oneof(fc.constant(null), fc.nat({ max: 1000000 })),
commit_stale: fc.oneof(fc.constant(null), fc.boolean()),
}),
}),
(s) => {
const full = formatGsdStateFull(s);
const compact = formatGsdStateCompactFn(s);
if (typeof full !== 'string' || typeof compact !== 'string') return false;
return !hasBadSeparator(full) && !hasBadSeparator(compact);
},
),
);
});
});

View File

@@ -2461,3 +2461,714 @@ describe('evaluateUpdateCache lineage guard', () => {
}); });
}); });
} }
// ─── #2734: STATE.md freshness marker (failing-first — API does not exist yet) ─
//
// Test matrix: .gsd/phase/feat-2734-statusline-state-freshness/50-test-matrix.md
// Design: .gsd/phase/feat-2734-statusline-state-freshness/40-design.md
//
// This block binds the new hook contract (STATE_HEAD_ADVISORY_COMMITS,
// isValidStateHeadStamp, parseRevListCounts, deriveStateFreshness,
// formatStateFreshness, resolveStatuslineOptions, readGsdState's opts arg,
// parseStateMd's stateHead field, and the renderers' freshness suffix) —
// none of it is implemented yet, so every test below is expected to fail
// (or error at call time) until the hook change lands.
{
const {
STATE_HEAD_ADVISORY_COMMITS, isValidStateHeadStamp, parseRevListCounts,
deriveStateFreshness, formatStateFreshness, resolveStatuslineOptions,
} = require('../hooks/gsd-statusline.js');
const { createTempGitProject, createTempProject } = require('./helpers.cjs');
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
const { runHook: runHookSeam, OUTCOME } = require('./helpers/process-seam.cjs');
const childProcess = require('node:child_process');
// Deterministic IO-failure / fake-response injection (repo convention —
// never chmod 0o000, which root bypasses). Lives in a module-level helper,
// never inline in a test body, per the repo's no-try/finally-in-tests rule.
function withSpawnSpy(impl, body) {
const original = childProcess.execFileSync;
const calls = [];
childProcess.execFileSync = (...args) => {
calls.push(args);
return impl(...args);
};
try {
body(calls);
} finally {
childProcess.execFileSync = original;
}
}
// Returns HEAD's sha BEFORE writing n filler commits, so the returned sha
// is exactly n commits behind the new HEAD. Unique filenames per call so
// multiple commitN() invocations against the same repo (e.g. two branches,
// or a re-stamp mid-test) never collide.
function commitN(dir, n) {
const sha = gitOrThrow(['rev-parse', 'HEAD'], { cwd: dir }).trim();
for (let i = 0; i < n; i++) {
const marker = `freshness-filler-${Date.now()}-${Math.random().toString(36).slice(2)}-${i}.txt`;
fs.writeFileSync(path.join(dir, marker), String(i));
gitOrThrow(['add', '-A'], { cwd: dir });
gitOrThrow(['commit', '-m', `filler ${i}`], { cwd: dir });
}
return sha;
}
function writeConfig(dir, cfg) {
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify(cfg));
}
// Writes a STATE.md carrying `state_head: <stateHeadValue>` and returns the
// exact content string written, so callers can feed the same content to
// parseStateMd() directly without a redundant readFileSync of a fixture file.
function writeStateHead(dir, stateHeadValue, extraLines = []) {
const content = [
'---',
'status: executing',
...extraLines,
`state_head: ${stateHeadValue}`,
'---',
'',
'# State',
].join('\n');
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
fs.writeFileSync(path.join(dir, '.planning', 'STATE.md'), content);
return content;
}
describe('gsd-statusline.js: #2734 STATE.md freshness marker', () => {
// ─── rows 1-7: deriveStateFreshness threshold boundary ─────────────────
describe('deriveStateFreshness: advisory threshold boundary', () => {
test('rendersMarkerAtAdvisoryThreshold', (t) => {
const dir = createTempGitProject('gsd-freshness-at-threshold-');
t.after(() => cleanup(dir));
const stamp = commitN(dir, STATE_HEAD_ADVISORY_COMMITS);
const ir = deriveStateFreshness(dir, stamp);
assert.equal(ir.commits_behind, STATE_HEAD_ADVISORY_COMMITS);
assert.equal(ir.commit_stale, true);
assert.equal(formatStateFreshness(ir), `state ~${STATE_HEAD_ADVISORY_COMMITS} commits back`);
});
test('omitsMarkerJustBelowThreshold', (t) => {
const dir = createTempGitProject('gsd-freshness-below-threshold-');
t.after(() => cleanup(dir));
const n = STATE_HEAD_ADVISORY_COMMITS - 1;
const stamp = commitN(dir, n);
const ir = deriveStateFreshness(dir, stamp);
assert.equal(ir.commits_behind, n);
assert.equal(formatStateFreshness(ir), '');
});
test('rendersMarkerExactlyAtThreshold', (t) => {
const dir = createTempGitProject('gsd-freshness-exact-threshold-');
t.after(() => cleanup(dir));
const stamp = commitN(dir, STATE_HEAD_ADVISORY_COMMITS);
const ir = deriveStateFreshness(dir, stamp);
assert.equal(ir.state_head, stamp.slice(0, 7));
assert.equal(ir.commits_behind, STATE_HEAD_ADVISORY_COMMITS);
assert.notEqual(formatStateFreshness(ir), '');
});
test('rendersMarkerJustAboveThreshold', (t) => {
const dir = createTempGitProject('gsd-freshness-above-threshold-');
t.after(() => cleanup(dir));
const n = STATE_HEAD_ADVISORY_COMMITS + 1;
const stamp = commitN(dir, n);
const ir = deriveStateFreshness(dir, stamp);
assert.equal(ir.commits_behind, n);
assert.equal(formatStateFreshness(ir), `state ~${n} commits back`);
});
test('omitsMarkerWhenStampIsHead', (t) => {
const dir = createTempGitProject('gsd-freshness-stamp-is-head-');
t.after(() => cleanup(dir));
const stamp = gitOrThrow(['rev-parse', 'HEAD'], { cwd: dir }).trim();
const ir = deriveStateFreshness(dir, stamp);
assert.equal(ir.commits_behind, 0);
assert.equal(ir.commit_stale, false);
assert.equal(formatStateFreshness(ir), '');
});
test('omitsMarkerForCommitDocsOffByOne', (t) => {
const dir = createTempGitProject('gsd-freshness-off-by-one-');
t.after(() => cleanup(dir));
const stamp = commitN(dir, 1);
const ir = deriveStateFreshness(dir, stamp);
assert.equal(ir.commits_behind, 1);
assert.equal(ir.commit_stale, true);
assert.equal(formatStateFreshness(ir), '', 'a single commit_docs restamp commit must not alarm');
});
test('rendersLargeCountUncapped', (t) => {
const dir = createTempGitProject('gsd-freshness-large-count-');
t.after(() => cleanup(dir));
const stamp = gitOrThrow(['rev-parse', 'HEAD'], { cwd: dir }).trim();
withSpawnSpy(() => '0\t99999\n', () => {
const ir = deriveStateFreshness(dir, stamp);
assert.equal(ir.commits_behind, 99999);
assert.equal(formatStateFreshness(ir), 'state ~99999 commits back');
});
});
});
// ─── rows 8-10: resolveStatuslineOptions flag gating ───────────────────
describe('resolveStatuslineOptions: show_state_freshness gating', () => {
test('omitsMarkerAndSpawnsNothingWhenFlagOff', (t) => {
const dir = createTempGitProject('gsd-freshness-flag-off-');
t.after(() => cleanup(dir));
const stamp = gitOrThrow(['rev-parse', 'HEAD'], { cwd: dir }).trim();
writeStateHead(dir, stamp);
assert.equal(resolveStatuslineOptions({}).showStateFreshness, false);
withSpawnSpy(() => '0\t20\n', (calls) => {
const state = readGsdState(dir);
assert.equal('freshness' in state, false);
assert.equal(calls.length, 0);
});
});
test('defaultsToDisabledWhenKeyAbsent', () => {
assert.equal(resolveStatuslineOptions({}).showStateFreshness, false);
assert.equal(resolveStatuslineOptions({ statusline: {} }).showStateFreshness, false);
assert.equal(resolveStatuslineOptions(undefined).showStateFreshness, false);
});
test('requiresStrictTrueToEnable', () => {
assert.equal(resolveStatuslineOptions({ statusline: { show_state_freshness: 'yes' } }).showStateFreshness, false);
assert.equal(resolveStatuslineOptions({ statusline: { show_state_freshness: 1 } }).showStateFreshness, false);
assert.equal(resolveStatuslineOptions({ statusline: { show_state_freshness: true } }).showStateFreshness, true);
});
});
// ─── rows 11-14: stamp-presence guards ──────────────────────────────────
describe('deriveStateFreshness wiring: stamp-absence guards', () => {
test('omitsMarkerWhenStampAbsent', (t) => {
const dir = createTempGitProject('gsd-freshness-no-stamp-');
t.after(() => cleanup(dir));
const content = ['---', 'status: executing', '---', '', '# State'].join('\n');
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
fs.writeFileSync(path.join(dir, '.planning', 'STATE.md'), content);
assert.equal(parseStateMd(content).stateHead, undefined);
withSpawnSpy(() => '0\t20\n', (calls) => {
const state = readGsdState(dir, { stateFreshness: true });
assert.equal(state.freshness, undefined);
assert.equal(calls.length, 0);
});
});
test('treatsLiteralNullStampAsAbsent', (t) => {
const dir = createTempGitProject('gsd-freshness-null-stamp-');
t.after(() => cleanup(dir));
const content = writeStateHead(dir, 'null');
assert.equal(parseStateMd(content).stateHead, null);
withSpawnSpy(() => '0\t20\n', (calls) => {
const state = readGsdState(dir, { stateFreshness: true });
assert.equal(state.freshness, undefined);
assert.equal(calls.length, 0);
});
});
test('treatsEmptyStampAsAbsent', (t) => {
const dir = createTempGitProject('gsd-freshness-empty-stamp-');
t.after(() => cleanup(dir));
const content = writeStateHead(dir, '""');
assert.equal(parseStateMd(content).stateHead, null);
withSpawnSpy(() => '0\t20\n', (calls) => {
const state = readGsdState(dir, { stateFreshness: true });
assert.equal(state.freshness, undefined);
assert.equal(calls.length, 0);
});
});
test('treatsWhitespaceStampAsAbsent', () => {
assert.equal(isValidStateHeadStamp(' '), false);
assert.equal(isValidStateHeadStamp('\t\t'), false);
});
});
// ─── rows 15-19: hash-fence boundaries ──────────────────────────────────
describe('isValidStateHeadStamp: hash-fence boundaries', () => {
test('rejectsStampBelowFenceMinimum', () => {
assert.equal(isValidStateHeadStamp('abc'), false);
});
test('acceptsStampAtFenceMinimum', () => {
assert.equal(isValidStateHeadStamp('abcd'), true);
});
test('acceptsStampAtFenceMaximum', () => {
assert.equal(isValidStateHeadStamp('a'.repeat(40)), true);
});
test('rejectsStampAboveFenceMaximum', () => {
assert.equal(isValidStateHeadStamp('a'.repeat(41)), false);
});
test('rejectsNonHexStamp', () => {
assert.equal(isValidStateHeadStamp('zzzz'), false);
assert.equal(isValidStateHeadStamp('g1b2'), false);
});
});
// ─── rows 20-22: hostile stamps — negative proof (git never invoked) ───
describe('deriveStateFreshness: hostile stamps never reach git', () => {
test('rejectsFlagLookalikeStampBeforeSpawn', (t) => {
const dir = createTempGitProject('gsd-freshness-hostile-flag-');
t.after(() => cleanup(dir));
withSpawnSpy(() => '0\t20\n', (calls) => {
const ir = deriveStateFreshness(dir, '--upload-pack=/bin/sh');
assert.equal(ir.state_head, null);
assert.equal(ir.commits_behind, null);
assert.equal(calls.length, 0, 'git must never be invoked for a flag-lookalike stamp');
});
});
test('rejectsRevisionSyntaxStamp', (t) => {
const dir = createTempGitProject('gsd-freshness-hostile-revsyntax-');
t.after(() => cleanup(dir));
const hostileStamps = ['HEAD', '..', '@{u}', '-'];
withSpawnSpy(() => '0\t20\n', (calls) => {
for (const stamp of hostileStamps) {
const ir = deriveStateFreshness(dir, stamp);
assert.equal(ir.state_head, null, `expected state_head null for ${JSON.stringify(stamp)}`);
assert.equal(ir.commits_behind, null, `expected commits_behind null for ${JSON.stringify(stamp)}`);
}
assert.equal(calls.length, 0, 'git must never be invoked for revision-syntax stamps');
});
});
test('rejectsShellMetacharacterStamp', (t) => {
const dir = createTempGitProject('gsd-freshness-hostile-shellmeta-');
t.after(() => cleanup(dir));
const hostileStamps = ['abcd1234\nrm -rf /', 'abcd1234;rm -rf /', '`touch /tmp/pwned`', '$(touch /tmp/pwned)'];
withSpawnSpy(() => '0\t20\n', (calls) => {
for (const stamp of hostileStamps) {
const ir = deriveStateFreshness(dir, stamp);
assert.equal(ir.state_head, null, `expected state_head null for ${JSON.stringify(stamp)}`);
assert.equal(ir.commits_behind, null, `expected commits_behind null for ${JSON.stringify(stamp)}`);
}
assert.equal(calls.length, 0, 'git must never be invoked for shell-metacharacter stamps');
});
});
});
// ─── rows 23-30: ancestry + provenance degradation guards ──────────────
describe('deriveStateFreshness: ancestry and provenance guards', () => {
test('omitsMarkerForUnknownStamp', (t) => {
const dir = createTempGitProject('gsd-freshness-unknown-stamp-');
t.after(() => cleanup(dir));
const ir = deriveStateFreshness(dir, 'deadbeef');
assert.equal(ir.state_head, 'deadbee');
assert.equal(ir.commits_behind, null);
assert.equal(ir.commit_stale, null);
});
test('omitsMarkerWhenStampIsNotAncestor', (t) => {
const dir = createTempGitProject('gsd-freshness-rewind-');
t.after(() => cleanup(dir));
const preSha = commitN(dir, 3);
const advancedSha = gitOrThrow(['rev-parse', 'HEAD'], { cwd: dir }).trim();
gitOrThrow(['reset', '--hard', preSha], { cwd: dir });
const ir = deriveStateFreshness(dir, advancedSha);
assert.equal(ir.state_head, advancedSha.slice(0, 7));
assert.equal(ir.commits_behind, null);
assert.equal(ir.commit_stale, null);
});
test('omitsMarkerForDivergedHistory', (t) => {
const dir = createTempGitProject('gsd-freshness-diverge-');
t.after(() => cleanup(dir));
const originalBranch = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir }).trim();
gitOrThrow(['checkout', '-b', 'gsd-freshness-side'], { cwd: dir });
commitN(dir, 2);
const divergedStamp = gitOrThrow(['rev-parse', 'HEAD'], { cwd: dir }).trim();
gitOrThrow(['checkout', originalBranch], { cwd: dir });
commitN(dir, 2);
const ir = deriveStateFreshness(dir, divergedStamp);
assert.equal(ir.commits_behind, null);
assert.equal(ir.commit_stale, null);
});
test('omitsMarkerWhenProjectDoesNotOwnRepo', (t) => {
const outerDir = createTempGitProject('gsd-freshness-outer-');
t.after(() => cleanup(outerDir));
const nestedDir = path.join(outerDir, 'nested-project');
fs.mkdirSync(path.join(nestedDir, '.planning'), { recursive: true });
withSpawnSpy(() => '0\t20\n', (calls) => {
const ir = deriveStateFreshness(nestedDir, 'abcd1234');
assert.equal(ir.commits_behind, null);
assert.equal(ir.commit_stale, null);
assert.equal(calls.length, 0);
});
});
test('omitsMarkerInSubReposWorkspace', (t) => {
const dir = createTempGitProject('gsd-freshness-subrepos-');
t.after(() => cleanup(dir));
writeConfig(dir, { planning: { sub_repos: ['child-a', 'child-b'] } });
withSpawnSpy(() => '0\t20\n', (calls) => {
const ir = deriveStateFreshness(dir, 'abcd1234');
assert.equal(ir.commits_behind, null);
assert.equal(ir.commit_stale, null);
assert.equal(calls.length, 0);
});
});
test('omitsMarkerForFlatSubReposKey', (t) => {
const dir = createTempGitProject('gsd-freshness-subrepos-flat-');
t.after(() => cleanup(dir));
writeConfig(dir, { 'planning.sub_repos': ['child-a'] });
withSpawnSpy(() => '0\t20\n', (calls) => {
const ir = deriveStateFreshness(dir, 'abcd1234');
assert.equal(ir.commits_behind, null);
assert.equal(calls.length, 0);
});
});
test('allowsMarkerWhenSubReposEmpty', (t) => {
const dir = createTempGitProject('gsd-freshness-subrepos-empty-');
t.after(() => cleanup(dir));
writeConfig(dir, { planning: { sub_repos: [] } });
const stamp = commitN(dir, STATE_HEAD_ADVISORY_COMMITS);
const ir = deriveStateFreshness(dir, stamp);
assert.equal(ir.commits_behind, STATE_HEAD_ADVISORY_COMMITS);
assert.equal(formatStateFreshness(ir), `state ~${STATE_HEAD_ADVISORY_COMMITS} commits back`);
});
test('ignoresNonArraySubRepos', (t) => {
const dir = createTempGitProject('gsd-freshness-subrepos-scalar-');
t.after(() => cleanup(dir));
writeConfig(dir, { planning: { sub_repos: 'child-a' } });
const stamp = commitN(dir, STATE_HEAD_ADVISORY_COMMITS);
const ir = deriveStateFreshness(dir, stamp);
assert.equal(ir.commits_behind, STATE_HEAD_ADVISORY_COMMITS);
});
});
// ─── rows 31-34: IO fault injection ─────────────────────────────────────
describe('deriveStateFreshness: never throws on git faults', () => {
test('degradesWhenGitMissing', (t) => {
const dir = createTempGitProject('gsd-freshness-enoent-');
t.after(() => cleanup(dir));
withSpawnSpy(() => {
const err = new Error('spawnSync git ENOENT');
err.code = 'ENOENT';
throw err;
}, () => {
let ir;
assert.doesNotThrow(() => { ir = deriveStateFreshness(dir, 'abcd1234'); });
assert.equal(ir.commits_behind, null);
assert.equal(ir.commit_stale, null);
});
});
test('degradesOnGitTimeout', (t) => {
const dir = createTempGitProject('gsd-freshness-timeout-');
t.after(() => cleanup(dir));
withSpawnSpy(() => {
const err = new Error('spawnSync git ETIMEDOUT');
err.code = 'ETIMEDOUT';
err.errno = -110;
throw err;
}, () => {
let ir;
assert.doesNotThrow(() => { ir = deriveStateFreshness(dir, 'abcd1234'); });
assert.equal(ir.commits_behind, null);
assert.equal(ir.commit_stale, null);
});
});
test('degradesOnUnparseableRevListOutput', (t) => {
const dir = createTempGitProject('gsd-freshness-bad-stdout-');
t.after(() => cleanup(dir));
assert.equal(parseRevListCounts(null), null);
assert.equal(parseRevListCounts(''), null);
assert.equal(parseRevListCounts('garbage'), null);
assert.equal(parseRevListCounts('1'), null);
assert.equal(parseRevListCounts('a\tb'), null);
assert.equal(parseRevListCounts('\t'), null);
assert.equal(parseRevListCounts('not-a-count\n'), null);
withSpawnSpy(() => 'not-a-count\n', () => {
const ir = deriveStateFreshness(dir, 'abcd1234');
assert.equal(ir.commits_behind, null);
assert.equal(ir.commit_stale, null);
});
});
test('neverThrowsFromDerivation', (t) => {
const dir = createTempGitProject('gsd-freshness-arbitrary-throw-');
t.after(() => cleanup(dir));
withSpawnSpy(() => { throw new TypeError('arbitrary failure'); }, () => {
assert.doesNotThrow(() => deriveStateFreshness(dir, 'abcd1234'));
});
});
});
// ─── rows 35-39: renderer composition ───────────────────────────────────
describe('formatGsdState / formatGsdStateCompact: freshness suffix', () => {
test('fullRendererShowsMarker', () => {
const freshness = { state_head: 'abcd123', commits_behind: 20, commit_stale: true };
const s = { status: 'executing', phaseNum: '1', phaseTotal: '5', freshness };
const expected = ['executing', 'ph 1/5', formatStateFreshness(freshness)].join(' · ');
assert.equal(formatGsdState(s), expected);
});
test('compactRendererShowsMarker', () => {
const freshness = { state_head: 'abcd123', commits_behind: 20, commit_stale: true };
const s = { milestone: 'v1.9', status: 'executing', freshness };
const expected = ['v1.9', 'executing', formatStateFreshness(freshness)].join(' · ');
assert.equal(formatGsdStateCompact(s), expected);
});
test('compactRendererOmitsBelowThreshold', () => {
const freshness = { state_head: 'abcd123', commits_behind: 5, commit_stale: true };
const s = { milestone: 'v1.9', status: 'executing', freshness };
const expected = ['v1.9', 'executing'].join(' · ');
assert.equal(formatGsdStateCompact(s), expected);
});
test('workstreamSentinelSuppressesMarker', () => {
const freshness = { state_head: 'abcd123', commits_behind: 20, commit_stale: true };
const s = { noActiveWorkstream: true, freshness };
assert.equal(formatGsdState(s), 'no active workstream');
assert.equal(formatGsdStateCompact(s), 'no active workstream');
});
test('markerCoexistsWithMilestoneComplete', () => {
const freshness = { state_head: 'abcd123', commits_behind: 25, commit_stale: true };
const sFull = { milestone: 'v1.9', percent: '100', freshness };
const expectedFull = ['v1.9 [██████████] 100%', 'milestone complete', formatStateFreshness(freshness)].join(' · ');
assert.equal(formatGsdState(sFull), expectedFull);
const sCompact = { milestone: 'v1.9', percent: '100', freshness };
const expectedCompact = ['v1.9', 'complete', formatStateFreshness(freshness)].join(' · ');
assert.equal(formatGsdStateCompact(sCompact), expectedCompact);
});
});
// ─── row 40: todo-task gate (no wasted spawn while a task is active) ───
describe('runStatusline wiring: todo-task gate', () => {
test('skipsFreshnessWorkWhenTodoTaskActive', { skip: process.platform === 'win32' ? 'POSIX-only git shim' : false }, (t) => {
const dir = createTempGitProject('gsd-freshness-todo-gate-');
t.after(() => cleanup(dir));
writeConfig(dir, { statusline: { show_state_freshness: true } });
const stamp = commitN(dir, STATE_HEAD_ADVISORY_COMMITS);
writeStateHead(dir, stamp);
// A `git` shim on PATH that appends a line to a marker file on
// EVERY invocation and always fails — proves the ONLY way to
// detect a spawn across a real subprocess boundary (an in-process
// execFileSync monkeypatch can't reach a child node process's own
// module cache). Asserting the marker file never exists is a
// filesystem fact, not a text match against rendered output.
const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-freshness-shim-'));
t.after(() => cleanup(shimDir));
const marker = path.join(shimDir, 'git-was-invoked');
fs.writeFileSync(path.join(shimDir, 'git'), ['#!/bin/sh', `echo invoked >> "${marker}"`, 'exit 1', ''].join('\n'));
fs.chmodSync(path.join(shimDir, 'git'), 0o755);
const claudeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-freshness-todo-claude-'));
t.after(() => cleanup(claudeDir));
const todosDir = path.join(claudeDir, 'todos');
fs.mkdirSync(todosDir, { recursive: true });
const session = `sess-2734-${Date.now()}-${Math.random().toString(36).slice(2)}`;
fs.writeFileSync(path.join(todosDir, `${session}-agent-A.json`), JSON.stringify([
{ content: 'task', status: 'in_progress', activeForm: 'ACTIVE TASK 2734' },
]));
const hookPath = path.join(__dirname, '..', 'hooks', 'gsd-statusline.js');
const payload = JSON.stringify({
model: { display_name: 'Claude' },
workspace: { current_dir: dir },
session_id: session,
context_window: { remaining_percentage: 80, total_tokens: 1_000_000 },
});
const r = runHookSeam(hookPath, [], {
input: payload,
env: { ...process.env, PATH: `${shimDir}${path.delimiter}${process.env.PATH}`, CLAUDE_CONFIG_DIR: claudeDir },
timeoutMs: 5000,
});
assert.equal(r.outcome, OUTCOME.EXITED, `expected clean exit, got outcome=${r.outcome}`);
assert.equal(r.exitCode, 0);
assert.equal(fs.existsSync(marker), false, 'git must never be invoked while a todo task is active');
});
});
// ─── rows 41-45: parseStateMd state_head extraction edge cases ─────────
describe('parseStateMd: state_head extraction', () => {
test('parsesStampFromCrlfStateMd', () => {
const lf = ['---', 'status: executing', 'state_head: abcd1234', '---', '', '# State'].join('\n');
const crlf = lf.replace(/\n/g, '\r\n');
assert.equal(parseStateMd(crlf).stateHead, parseStateMd(lf).stateHead);
assert.equal(parseStateMd(crlf).stateHead, 'abcd1234');
});
test('omitsMarkerWithoutFrontmatter', () => {
const content = ['# State', 'Status: executing'].join('\n');
assert.equal(parseStateMd(content).stateHead, undefined);
});
test('handlesEmptyStateFile', () => {
assert.doesNotThrow(() => parseStateMd(''));
assert.equal(parseStateMd('').stateHead, undefined);
});
test('handlesDuplicateStampKey', () => {
const content = ['---', 'state_head: aaaa1111', 'state_head: bbbb2222', '---'].join('\n');
assert.equal(parseStateMd(content).stateHead, 'bbbb2222');
});
test('stripsQuotesFromStamp', () => {
const content = ['---', 'state_head: "abc1234"', '---'].join('\n');
assert.equal(parseStateMd(content).stateHead, 'abc1234');
});
});
// ─── rows 46-52: independence + parity ──────────────────────────────────
describe('independence + parity', () => {
test('defaultCallShapeIsUnchanged', (t) => {
const dir = createTempGitProject('gsd-freshness-default-shape-');
t.after(() => cleanup(dir));
const stamp = gitOrThrow(['rev-parse', 'HEAD'], { cwd: dir }).trim();
writeStateHead(dir, stamp);
withSpawnSpy(() => '0\t20\n', (calls) => {
const state = readGsdState(dir);
assert.equal('freshness' in state, false);
assert.equal(calls.length, 0);
});
});
test('spendsExactlyOneSpawnPerRender', (t) => {
const dir = createTempGitProject('gsd-freshness-spawn-count-');
t.after(() => cleanup(dir));
const stamp = gitOrThrow(['rev-parse', 'HEAD'], { cwd: dir }).trim();
writeStateHead(dir, stamp);
withSpawnSpy(() => '0\t20\n', (calls) => {
const state = readGsdState(dir, { stateFreshness: true });
assert.equal(calls.length, 1, `expected exactly one git spawn, got ${calls.length}`);
assert.equal(state.freshness.commits_behind, 20);
});
});
test('thresholdMatchesHealthConstant', () => {
const { STATE_HEAD_ADVISORY_COMMITS: healthConstant } = require('../gsd-core/bin/lib/verify.cjs');
assert.equal(STATE_HEAD_ADVISORY_COMMITS, healthConstant);
});
test('fenceAgreesWithStateModule', (t) => {
const { readStateHeadFreshness } = require('../gsd-core/bin/lib/state.cjs');
const dir = createTempProject('gsd-freshness-fence-parity-');
t.after(() => cleanup(dir));
const candidates = [
'abcd', 'abcd1234', 'a'.repeat(40), 'a'.repeat(41), 'abc', 'zzzz', 'g1b2',
'', ' ', 'null', '--upload-pack=x', 'HEAD', '..', '@{u}', '-',
'abcd1234\nrm -rf /', 'abcd1234;rm -rf /', '`abcd1234`', '$(abcd1234)', '"abcd1234"',
];
for (const candidate of candidates) {
const hookAccepts = isValidStateHeadStamp(candidate);
const moduleAccepts = readStateHeadFreshness(dir, candidate).state_head !== null;
assert.equal(hookAccepts, moduleAccepts, `fence mismatch for candidate ${JSON.stringify(candidate)}`);
}
});
test('derivationAgreesWithStateModule', (t) => {
const { readStateHeadFreshness } = require('../gsd-core/bin/lib/state.cjs');
// Registers cleanup for THIS fixture's directory at scheduling time
// (captured as a function parameter, not a reused outer `dir`
// binding) so a throw partway through the fixture list still tears
// down every directory created up to that point.
function registerCleanup(fixtureDir) {
t.after(() => cleanup(fixtureDir));
}
function assertAgree(dir, stamp) {
const hookIr = deriveStateFreshness(dir, stamp);
const moduleIr = readStateHeadFreshness(dir, stamp);
assert.equal(hookIr.state_head, moduleIr.state_head, 'state_head mismatch');
assert.equal(hookIr.commits_behind, moduleIr.commits_behind, 'commits_behind mismatch');
assert.equal(hookIr.commit_stale, moduleIr.commit_stale, 'commit_stale mismatch');
}
// Ancestor stamp, 5 commits behind.
let dir = createTempGitProject('gsd-freshness-parity-ancestor-');
registerCleanup(dir);
let stamp = commitN(dir, 5);
assertAgree(dir, stamp);
// Rewound (non-ancestor) stamp.
dir = createTempGitProject('gsd-freshness-parity-rewound-');
registerCleanup(dir);
const preSha = commitN(dir, 3);
const advancedSha = gitOrThrow(['rev-parse', 'HEAD'], { cwd: dir }).trim();
gitOrThrow(['reset', '--hard', preSha], { cwd: dir });
assertAgree(dir, advancedSha);
// Invalid (non-hex) stamp.
dir = createTempGitProject('gsd-freshness-parity-invalid-');
registerCleanup(dir);
assertAgree(dir, 'zzzznothex');
// .git-less root.
dir = createTempProject('gsd-freshness-parity-nogit-');
registerCleanup(dir);
assertAgree(dir, 'abcd1234');
// sub_repos workspace.
dir = createTempGitProject('gsd-freshness-parity-subrepos-');
registerCleanup(dir);
writeConfig(dir, { planning: { sub_repos: ['child-a'] } });
assertAgree(dir, 'abcd1234');
});
test('bothEntryPointsResolveOptionsIdentically', () => {
const cfgs = [
{},
{ statusline: { show_state_freshness: true } },
{ statusline: { state_format: 'compact', show_git: true, show_state_freshness: true } },
{ 'statusline.show_state_freshness': true, 'statusline.context_position': 'front' },
];
for (const cfg of cfgs) {
const o = resolveStatuslineOptions(cfg);
assert.equal(typeof o.showStateFreshness, 'boolean', `showStateFreshness type for ${JSON.stringify(cfg)}`);
assert.equal(typeof o.showGit, 'boolean', `showGit type for ${JSON.stringify(cfg)}`);
assert.ok(o.stateFormat === 'full' || o.stateFormat === 'compact', `unexpected stateFormat for ${JSON.stringify(cfg)}: ${o.stateFormat}`);
assert.ok(o.position === 'end' || o.position === 'front', `unexpected position for ${JSON.stringify(cfg)}: ${o.position}`);
}
const flat = resolveStatuslineOptions({ 'statusline.show_state_freshness': true, 'statusline.state_format': 'compact' });
const nested = resolveStatuslineOptions({ statusline: { show_state_freshness: true, state_format: 'compact' } });
assert.deepEqual(flat, nested, 'flat dotted-key and nested config forms must resolve identically');
});
test('derivationIsNotMemoizedAcrossRenders', (t) => {
const dir = createTempGitProject('gsd-freshness-no-memo-');
t.after(() => cleanup(dir));
const stampA = commitN(dir, 5);
writeStateHead(dir, stampA);
const first = readGsdState(dir, { stateFreshness: true });
assert.equal(first.freshness.commits_behind, 5);
const stampB = commitN(dir, 10);
writeStateHead(dir, stampB);
const second = readGsdState(dir, { stateFreshness: true });
assert.equal(second.freshness.commits_behind, 10);
assert.notEqual(first.freshness.commits_behind, second.freshness.commits_behind);
});
});
});
}