diff --git a/.changeset/daring-voles-snooze.md b/.changeset/daring-voles-snooze.md new file mode 100644 index 000000000..868ce43d2 --- /dev/null +++ b/.changeset/daring-voles-snooze.md @@ -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) diff --git a/CONTEXT.md b/CONTEXT.md index c2f791865..78b7b4bf4 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -189,7 +189,7 @@ Pure, no-write Module owning the **detection rung** of runtime identity — ADR- Pure, additive, no-I/O Module owning the versioned, negotiated contract over the six host-integration interface points (command, dispatch, model, hooks, state, artifact) — ADR-1239 Phase A. Extends the ADR-1016 runtime descriptor with nine closed-vocabulary axes carried under `capability.json` `runtime.hostIntegration`: `embeddingMode` (`imperative|declarative`), `commandSurface` (`slash-file|slash-programmatic|slash-toml|palette|prose-only`), `dispatch` (`{namedDispatch,nested,maxDepth,background,backgroundDispatch,subagentToolkit,isolation}`), `modelMode` (`active|passive`), `hookBus` (`host|engine|none`), `stateIO` (`filesystem|sandboxed-storage|session-log-append`), `transport` (`mcp|native-extension`), `runtime` (`node|bun|sandboxed-web|python|go|rust|electron|other`), `effortSurface` (`argv|none` — how reasoning effort reaches the host; ADR-1239 amendment #2481, the first axis whose consumer is an invocation-time argument rather than an install-time artifact). `dispatch.isolation` (`harness-worktree|orchestrator-worktree|none` — how a host isolates concurrent same-wave executors; ADR-1239 Codex-binding amendment #2584; consumed by the phase scheduler since #2584 Phase 3 and, since #2652, by every single-agent dispatch site — `quick.md`, `diagnose-issues.md`, `execute-plan.md` — which resolve it through the canonical `gsd-core/references/dispatch-isolation-gate.md` rather than branching on a runtime id; and, since #2486, by the runtime-neutral diagnostics — `/gsd:settings` gates its Worktrees recommendation and `/gsd:health` raises W025 from this axis, read through the sentinel-free `inspect-dispatch-isolation` verb). `resolveOrchestratorExec(orchestratorExec, cwd) → { ok:true, command, args, cwd } | { ok:false, reason }` (#2584 Phase 2, pure, no I/O — resolves the `runtime.orchestratorExec` descriptor field, a sibling of `runtime.hostBehaviors` in `capability.json` carrying `{command, args?, cwdFlag?}`, into the concrete argv/cwd a process-spawn primitive would use for a `dispatch.isolation: orchestrator-worktree` host; appends `[cwdFlag, cwd]` to `args` when `cwdFlag` is a non-empty string, e.g. codex `exec --cd `, opencode `run --dir `, kimi `--work-dir `; when `cwdFlag` is `null`/absent — kimi-code's process-cwd case — no flag is appended and `cwd` alone is returned for the caller to bind via the subprocess's own working-directory option; fail-closed `missing_command`/`invalid_cwd`/`invalid_args`/`invalid_cwd_flag`; CONSUMED since #2584 Phase 3 — `routeDispatchIsolation` resolves it into the `exec` field of `gsd_run query dispatch-isolation --json`, and `gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md` process-spawns that `command`/`args`/`cwd`; #2652 adds a second consumer, `_negotiatedDispatchIsolation`, which probes it at install time against a placeholder target to decide whether an `orchestrator-worktree` declaration actually resolves). Interface: `negotiateHostCapabilities(host, engine?) → { protocolVersion, effective, points, warnings }` enforcing the trust-boundary invariant `effective ⊆ host-declared ∩ engine-known` (never augment with an undeclared or unknown/future-`protocolVersion` value — fail-closed via the most-restrictive-known `SAFE_DEFAULTS`); `degradationFor(point, axes) → { level, fallback }` (a pure Full/Degraded/Absent ladder table, never throws); `profileOf(axes) → 'programmatic-cli'|'declarative-cli'|'ide'|null`; plus `PROTOCOL_VERSION` (integer, starts at 1 — distinct from the package `version`/`engines.gsd` semver), `HOST_INTEGRATION_AXES` (the frozen closed vocabulary, single source of truth), `PROFILE_BASELINES`, and `shouldFlattenDispatch(dispatch) → boolean` (ADR-1239 Phase B / #1708 — graduates the #853 rule: returns `true` = run the orchestrator inline UNLESS the host is documented to background a nesting-capable orchestrator (`background === true && backgroundDispatch === true`); fail-closed to inline; exposed to the plan/execute workflows via the `gsd_run query dispatch-should-flatten --raw` CLI, which replaced the former scattered `RUNTIME === 'codex'` prose check). The runtime-descriptor validator (`gsd-core/bin/lib/capability-validator.cjs` `validateRuntimeBody`) mirrors the closed vocabulary inline (exported as `_HOST_INTEGRATION_VOCAB`) and is kept in lock-step by the parity guard `tests/host-integration-validator-parity.test.cjs`. Orthogonal axes (resolved explicitly per ADR-1239 Phase A): `commandStyle` (GSD emission style, retained) vs `commandSurface` (host surface type); `hookEvents` dialect vs `hookBus` ownership (a host with `hooksSurface:none` may still be `hookBus:host` — e.g. opencode); `runtimeCompat` (feature→host) vs these negotiated runtime→engine axes. Phase A defined the interface; Phase B (#1679) wires it incrementally — `destSubpath` write-confinement (#1704) and the typed documentation-sourced #853 dispatch-flatten (#1708, the first consumer of a negotiated `dispatch` axis); adapters/MCP/host-bindings remain Phases C–E. Source of truth: `gsd-core/bin/lib/host-integration.cjs` (generated from `src/host-integration.cts`). See ADR-1239 and ADR-1016. ### Statusline -Host-integration hook (`hooks/gsd-statusline.js`) that renders the session status line: model name, context-window meter, workspace directory, and the GSD-state segment (`formatGsdState()` projecting `.planning/` STATE.md). `readGsdState()` is workstream-aware (#2850): when the walk-up finds no flat `.planning/STATE.md` but lands on a `.planning/workstreams/` directory, it resolves the active workstream via `resolveActiveWorkstream` (`active-workstream-store.cts`), called with an empty args array — only its env>store precedence applies for this caller, since the CLI leg is inert without argv — and `planningPaths`/`listAvailableWorkstreams` (`planning-workspace.cts`) for path/mode resolution, the same seams every other workstream-aware command uses, and reads that workstream's `STATE.md` instead. The store tier is `peekActiveWorkstream`, a read-only sibling of `getActiveWorkstream` that never deletes a stale/invalid pointer file — a renderer invoked on every prompt must never mutate persistent state as a side effect of drawing a screen (`getActiveWorkstream`'s self-heal is correct for a command, not a render). When workstream mode is detected but nothing resolves, it returns a `{noActiveWorkstream:true}` sentinel that `formatGsdState`/`formatGsdStateCompact` render as `"no active workstream"` — observable, never silent emptiness. Opt-in segments are gated by `.planning/config.json` keys (`statusline.show_last_command`, `statusline.context_position`, plus the approved `statusline.show_context_tokens` and `statusline.state_format`), each registered across `gsd-core/bin/shared/config-schema.manifest.json` + `src/config.cts` + the `loadConfig` whitelist + `docs/CONFIGURATION.md`. The compact GSD-state format consumes the canonical status vocabulary from `normalizeStateStatus()` (STATE.md Document Module) rather than a parallel keyword list. **Data-source boundary (ADR-2164):** the statusline sources only local, read-only data — it refines the stdin payload Claude Code already sends and may add a new *local* source (e.g. `git`), but does not read credentials or call external/network APIs for data; account/usage/platform-level state is out of scope. +Host-integration hook (`hooks/gsd-statusline.js`) that renders the session status line: model name, context-window meter, workspace directory, and the GSD-state segment (`formatGsdState()` projecting `.planning/` STATE.md). `readGsdState()` is workstream-aware (#2850): when the walk-up finds no flat `.planning/STATE.md` but lands on a `.planning/workstreams/` directory, it resolves the active workstream via `resolveActiveWorkstream` (`active-workstream-store.cts`), called with an empty args array — only its env>store precedence applies for this caller, since the CLI leg is inert without argv — and `planningPaths`/`listAvailableWorkstreams` (`planning-workspace.cts`) for path/mode resolution, the same seams every other workstream-aware command uses, and reads that workstream's `STATE.md` instead. The store tier is `peekActiveWorkstream`, a read-only sibling of `getActiveWorkstream` that never deletes a stale/invalid pointer file — a renderer invoked on every prompt must never mutate persistent state as a side effect of drawing a screen (`getActiveWorkstream`'s self-heal is correct for a command, not a render). When workstream mode is detected but nothing resolves, it returns a `{noActiveWorkstream:true}` sentinel that `formatGsdState`/`formatGsdStateCompact` render as `"no active workstream"` — observable, never silent emptiness. Opt-in segments are gated by `.planning/config.json` keys (`statusline.show_last_command`, `statusline.context_position`, plus the approved `statusline.show_context_tokens`, `statusline.state_format`, `statusline.show_git` and `statusline.show_state_freshness`), each registered across `gsd-core/bin/shared/config-schema.manifest.json` + `src/config.cts` + the `loadConfig` whitelist + `docs/CONFIGURATION.md`. The compact GSD-state format consumes the canonical status vocabulary from `normalizeStateStatus()` (STATE.md Document Module) rather than a parallel keyword list. Config resolution for every `statusline.*` key is centralized in `resolveStatuslineOptions(cfg)` — the two entry points (`runStatusline()`, the stdin path, and `renderStatusline(data)`, the test-facing renderer) previously duplicated it byte-for-byte, and a single resolver is what keeps a newly-added key from reaching only one of them (#2734). The opt-in **STATE.md freshness marker** (`statusline.show_state_freshness`, #2734) renders `state ~N commits back` in both renderers when STATE.md's `state_head` stamp is at least `STATE_HEAD_ADVISORY_COMMITS` (20) commits behind HEAD — the same threshold `validate.health`'s W024 uses, not `> 0`, because `commit_docs: true` advances HEAD by one on every STATE sync and a `> 0` threshold would alarm permanently on a fresh project. It follows the git segment's impure-reader → pure-IR → pure-formatter shape (`readStateHeadCommits` → `deriveStateFreshness` → `formatStateFreshness`), spends exactly one bounded `git rev-list --left-right --count` per render (ancestry and distance in one spawn) and none when disabled, and mirrors `src/state.cts`'s hash fence and `projectOwnsItsRepo`/`sub_repos` degradation guards hook-side rather than requiring `state.cjs` on the per-render path; `tests/gsd-statusline.test.cjs` binds the two copies by **behavioral** differential parity against `readStateHeadFreshness`, not a source comparison. Every degradation yields the tri-state *unknown* (marker absent), never a "fresh" claim the project cannot substantiate. **Data-source boundary (ADR-2164):** the statusline sources only local, read-only data — it refines the stdin payload Claude Code already sends and may add a new *local* source (e.g. `git`), but does not read credentials or call external/network APIs for data; account/usage/platform-level state is out of scope. ### Install Engine Module Module owning the layout-driven runtime-artifact install pipeline — `installRuntimeArtifacts`, `uninstallRuntimeArtifacts`, `installOpencodeFamilySkills`, and their cluster helpers (`_copyStaged`, `_snapshotDir`/`_restoreDir`, legacy-migration + GSD-entry pruning, user-artifact preserve/restore). Extracted from the 12k-line `bin/install.js` (ADR-1239 Phase B, #1679) so adapters import the engine instead of reaching into the installer. Commit-attribution resolution stays in `bin/install.js` and is injected via a `resolveAttribution` parameter (the engine takes no config I/O). Source: `src/install-engine.cts` -> `gsd-core/bin/lib/install-engine.cjs`. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 557100417..614153037 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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.state_format` | string | `"full"` | Format of the GSD-state segment. `"full"` (default) is the existing rendering with milestone name and progress bar. `"compact"` renders ` · P/ · ` (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_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. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 5df58b8d3..55f86439a 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -185,6 +185,8 @@ - [Broken-Windows Ledger](#158-broken-windows-ledger) - [Complexity-Triggered Refactor](#159-complexity-triggered-refactor) - [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. 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) diff --git a/docs/README.md b/docs/README.md index c5a03fb6a..922a859da 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 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 +- [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) - [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 diff --git a/docs/how-to/read-the-statusline-freshness-marker.md b/docs/how-to/read-the-statusline-freshness-marker.md new file mode 100644 index 000000000..6fb361f0f --- /dev/null +++ b/docs/how-to/read-the-statusline-freshness-marker.md @@ -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 HEAD; echo $?` prints `1` | No — reported as unknown on purpose | +| **The stamp is not a commit in this repo** | `git cat-file -e ` 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 diff --git a/gsd-core/bin/shared/config-schema.manifest.json b/gsd-core/bin/shared/config-schema.manifest.json index 2e3a8749d..e2939aae1 100644 --- a/gsd-core/bin/shared/config-schema.manifest.json +++ b/gsd-core/bin/shared/config-schema.manifest.json @@ -76,6 +76,7 @@ "statusline.show_context_tokens", "statusline.state_format", "statusline.show_git", + "statusline.show_state_freshness", "workflow.max_discuss_passes", "features.thinking_partner", "context", diff --git a/hooks/gsd-statusline.js b/hooks/gsd-statusline.js index 16e785bcd..c8067f5b8 100755 --- a/hooks/gsd-statusline.js +++ b/hooks/gsd-statusline.js @@ -169,13 +169,27 @@ function readStateFileOrNull(statePath) { * - 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 * (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(); let current = dir; for (let i = 0; i < 10; i++) { 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) { let resolvedWs = null; @@ -187,7 +201,11 @@ function readGsdState(dir) { 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); @@ -238,6 +256,10 @@ function parseStateMd(content) { 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) 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. 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(' · '); } @@ -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 '); } @@ -572,6 +602,146 @@ function buildGitSegment(info) { 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 ...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 + * ("\t"). 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 ------------------------------------------------------------------ function runStatusline() { @@ -717,31 +887,34 @@ function runStatusline() { // Last-slash-command suffix and context_position config (#2538, #2937). // Reads the active session transcript for the most recent tag. // 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 position = 'end'; - let stateFormat = 'full'; let gitSuffix = ''; + const options = resolveStatuslineOptions(cfg); try { - if (getConfigValue(cfg, 'statusline.show_last_command') === true) { + if (options.showLastCommand) { const transcriptPath = data.transcript_path; const lastCmd = readLastSlashCommand(transcriptPath); if (lastCmd) { lastCmdSuffix = ` │ \x1b[2mlast: /${lastCmd}\x1b[0m`; } } - const cfgPos = getConfigValue(cfg, 'statusline.context_position'); - if (cfgPos != null) position = cfgPos; - if (getConfigValue(cfg, 'statusline.state_format') === 'compact') stateFormat = 'compact'; - if (getConfigValue(cfg, 'statusline.show_git') === true) { + if (options.showGit) { gitSuffix = buildGitSegment(parseGitStatus(readGitStatus(dir))); } } catch (e) { // 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) { - const state = readGsdState(dir) || {}; - gsdStateStr = stateFormat === 'compact' ? formatGsdStateCompact(state) : formatGsdState(state); + const state = readGsdState(dir, { stateFreshness: options.showStateFreshness }) || {}; + gsdStateStr = options.stateFormat === 'compact' ? formatGsdStateCompact(state) : formatGsdState(state); } // Output @@ -752,7 +925,7 @@ function runStatusline() { ? `\x1b[2m${gsdStateStr}\x1b[0m` : 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) { // Silent fail - don't break statusline on parse errors } @@ -846,6 +1019,9 @@ module.exports = { shortGsdStatus, formatGsdStateCompact, compactModelName, 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 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 position = 'end'; - let stateFormat = 'full'; let gitSuffix = ''; + let options = { showLastCommand: false, position: 'end', stateFormat: 'full', showGit: false, showStateFreshness: false }; try { const cfg = readGsdConfig(dir); - if (getConfigValue(cfg, 'statusline.show_last_command') === true) { + options = resolveStatuslineOptions(cfg); + if (options.showLastCommand) { const lastCmd = readLastSlashCommand(data.transcript_path); if (lastCmd) { lastCmdSuffix = ` │ \x1b[2mlast: /${lastCmd}\x1b[0m`; } } - const cfgPos = getConfigValue(cfg, 'statusline.context_position'); - if (cfgPos != null) position = cfgPos; - if (getConfigValue(cfg, 'statusline.state_format') === 'compact') stateFormat = 'compact'; - if (getConfigValue(cfg, 'statusline.show_git') === true) { + if (options.showGit) { gitSuffix = buildGitSegment(parseGitStatus(readGitStatus(dir))); } } catch (e) { /* swallow */ } - const state = readGsdState(dir) || {}; - const gsdStateStr = stateFormat === 'compact' ? formatGsdStateCompact(state) : formatGsdState(state); + const state = readGsdState(dir, { stateFreshness: options.showStateFreshness }) || {}; + const gsdStateStr = options.stateFormat === 'compact' ? formatGsdStateCompact(state) : formatGsdState(state); 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; diff --git a/tests/gsd-statusline-state.property.test.cjs b/tests/gsd-statusline-state.property.test.cjs index e5289070f..dd7af316a 100644 --- a/tests/gsd-statusline-state.property.test.cjs +++ b/tests/gsd-statusline-state.property.test.cjs @@ -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); + }, + ), + ); + }); +}); diff --git a/tests/gsd-statusline.test.cjs b/tests/gsd-statusline.test.cjs index f4a8cd723..bae8ad0bb 100644 --- a/tests/gsd-statusline.test.cjs +++ b/tests/gsd-statusline.test.cjs @@ -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: ` 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); + }); + }); + }); +}