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

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

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

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

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

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

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

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 2622
---
**STATE.md now records the commit it was written against** — a new `state_head` frontmatter stamp lets `/gsd-health` and smart-entry report how far the codebase has moved since STATE.md was last written, so a long-stale STATE.md can be discounted rather than read at face value. Health adds advisory `W024` once the gap reaches 20 commits. This is a freshness proxy, not a drift measurement: the count includes commits that never touched anything STATE.md describes, and the stamp refreshes on any state write — so it is always worded as approximate and never gates anything. The stamp is omitted entirely when the commit cannot be resolved to the project's *own* repository — a project nested inside an unrelated checkout reports unknown rather than borrowing that repo's freshness. (#2573)

View File

@@ -56,10 +56,10 @@ Adapter Module that satisfies native query dispatch at the Dispatch Policy seam,
Module owning projection from dispatch results/errors to CLI `{ exitCode, stdoutChunks, stderrLines }` output contract. Module owning projection from dispatch results/errors to CLI `{ exitCode, stdoutChunks, stderrLines }` output contract.
### STATE.md Document Module ### STATE.md Document Module
Module owning STATE.md parse, field extraction, field replacement, status normalization, frontmatter reconstruction, and `## Current Position` section scoping (`stateCurrentPositionSlice`, #1956 — the one owner of that scope for the read path; `state.cts`'s `matchCurrentPositionSection` is a thin alias over it, and the `drift-guard phase-status` seam consumes it, so the #2956 archive-shadowing fix cannot be re-derived into a second copy — the byte-exact mutation path served by `state-transition.cts`'s `locateCurrentPosition`/`sliceCurrentPositionSection` is a deliberately separate, un-consolidated locator, #3187). `stateFieldValue` (#3187) is the single owner of the #1760 frontmatter-then-body field fallback chain, consolidating the 14 re-derivations of that ladder onto one scope-carrying (`complete`/`truncated`/`unscoped`/`unreadable`) primitive. It does not scan `.planning/phases` and does not own persistence or locking; phase/plan/summary counts arrive from inventory/progress Modules as inputs, and read-modify-write paths remain Adapters. Source of truth: `gsd-core/bin/lib/state-document.cjs`. Module owning STATE.md parse, field extraction, field replacement, status normalization, frontmatter reconstruction, and `## Current Position` section scoping (`stateCurrentPositionSlice`, #1956 — the one owner of that scope for the read path; `state.cts`'s `matchCurrentPositionSection` is a thin alias over it, and the `drift-guard phase-status` seam consumes it, so the #2956 archive-shadowing fix cannot be re-derived into a second copy — the byte-exact mutation path served by `state-transition.cts`'s `locateCurrentPosition`/`sliceCurrentPositionSection` is a deliberately separate, un-consolidated locator, #3187). `stateFieldValue` (#3187) is the single owner of the #1760 frontmatter-then-body field fallback chain, consolidating the 14 re-derivations of that ladder onto one scope-carrying (`complete`/`truncated`/`unscoped`/`unreadable`) primitive. It does not scan `.planning/phases` and does not own persistence or locking; phase/plan/summary counts arrive from inventory/progress Modules as inputs, and read-modify-write paths remain Adapters. Source of truth: `gsd-core/bin/lib/state-document.cjs`. **Commit provenance (#2573):** `state_head` records the full sha STATE.md was written against, stamped by `syncStateFrontmatter` and omitted entirely outside a git repo. `readStateHeadFreshness(cwd, stateHead)` (`src/state.cts`) is the single derivation consumed by both `validate.health` (W024) and smart-entry — it returns `{ state_head, current_commit, commits_behind, commit_stale }` with **tri-state** `commit_stale`: `null` = unknown (no stamp, no git, or a stamp that is not an ancestor of HEAD after a history rewrite), `false` = known fresh, `true` = the codebase has moved. Mirrors the graphify commit-staleness contract deliberately. It is a freshness PROXY, never a drift measurement: `rev-list` counts unrelated commits and the stamp restamps on every state write, so a low count means STATE.md was written recently, not that its contents are accurate — it must never gate.
### STATE.md Transition Module ### STATE.md Transition Module
Module owning STATE.md lifecycle/maintenance transitions as intent-based methods (`beginPhase`, `advancePlan`, `completePhase`, `plannedPhase`, `milestoneSwitch`, `milestoneComplete`, `patch`, `sync`, `prune`, `update`, `rebuild`). Pure core `(content, intent, deps) → newContent` with injected I/O (file read/write, lock, disk scan); consults a field-classification table that names each STATE.md field's class (`derived-from-body` | `derived-from-disk` | `derived-from-external` | `curated` | `free`) and its preservation policy. Supersedes the 14 scattered RMW callbacks in `state.cts` and the direct `writeStateMd` caller in `milestone.cts:552` (phase.cts's former direct caller has since been migrated away); verify's `regenerateState` factory-reset primitive stays as a direct `writeStateMd` call (`verify.cts:1925`). Absorbs `syncStateFrontmatter` + `readModifyWriteStateMd`'s post-sync preservation block; Encoding 3 (`cmdStateBuildFrontmatter`) stays separate — read path concern. Sibling/super-module of the STATE.md Document Module; consumes its `stateReplaceField`/`stateExtractField` primitives. Body section structure (`## Current Position`, `## Session`, etc.) lives as a constants block inside the Module. Append-only transitions (`addDecision`, `addBlocker`, etc.) stay on today's RMW seam for now. Targets the #1760/#1761/#1743/#1695/#1264/#1255/#1257/#3242 bug cluster. Migration per ADR-1372 §T6 sequenced as substrate + `beginPhase` first (PR1), then transition-by-transition with characterization tests first per transition. **ADR-1817 adds `rebuild` as the capstone 11th transition — the body-structure derivability contract.** Re-derives `## Current Position` prose from frontmatter and `## By-Phase Progress` table from phase dirs on disk; preserves `## Session` / `## Decisions` / unknown sections verbatim; de-duplicates `## Session Continuity Archive` (keep most-recent N, default 3); appends a structured audit entry to `## Rebuild Log` (`timestamp`, `kind`, `section`, `before`, `after`, `reason`) for every mutation. Hard idempotency guarantee: a no-mutation rebuild appends no log entry, so two successive invocations on a clean file are byte-identical. Non-overlapping with `sync` (3 lightweight frontmatter fields, auto-triggered) and orthogonal to `auto_prune_state` (age-based removal) — `rebuild` reconciles with current canonical sources, `prune` removes by retention policy, the two compose (rebuild first, then prune). Section ordering is invariant: rebuild rewrites content in place, never reorders. Targets the #1776/#1761/#1591 body-drift cluster that survived ADR-1769's per-field transitions. Phased per ADR-1817: Phase 0 = this ADR + predicates (closes #1817), Phase 1 = `rebuildCore` body + `rebuild` dispatch case + drift-class unit tests (#1827), Phase 2 = `cmdStateRebuild` CLI + `--dry-run`/`--verbose` + integration tests + docs + changeset (#1826). Source of truth: `gsd-core/bin/lib/state-transition.cjs` (generated from `src/state-transition.cts`). Module owning STATE.md lifecycle/maintenance transitions as intent-based methods (`beginPhase`, `advancePlan`, `completePhase`, `plannedPhase`, `milestoneSwitch`, `milestoneComplete`, `patch`, `sync`, `prune`, `update`, `rebuild`). Pure core `(content, intent, deps) → newContent` with injected I/O (file read/write, lock, disk scan); consults a field-classification table that names each STATE.md field's class (`derived-from-body` | `derived-from-disk` | `derived-from-external` | `curated` | `free`) and its preservation policy. Supersedes the 14 scattered RMW callbacks in `state.cts` and the direct `writeStateMd` caller in `milestone.cts:552` (phase.cts's former direct caller has since been migrated away); verify's `regenerateState` factory-reset primitive stays as a direct `writeStateMd` call (`verify.cts:1925`). Absorbs `syncStateFrontmatter` + `readModifyWriteStateMd`'s post-sync preservation block; Encoding 3 (`cmdStateBuildFrontmatter`) stays separate — read path concern. Sibling/super-module of the STATE.md Document Module; consumes its `stateReplaceField`/`stateExtractField` primitives. Body section structure (`## Current Position`, `## Session`, etc.) lives as a constants block inside the Module. Append-only transitions (`addDecision`, `addBlocker`, etc.) stay on today's RMW seam for now. Targets the #1760/#1761/#1743/#1695/#1264/#1255/#1257/#3242 bug cluster. Migration per ADR-1372 §T6 sequenced as substrate + `beginPhase` first (PR1), then transition-by-transition with characterization tests first per transition. **ADR-1817 adds `rebuild` as the capstone 11th transition — the body-structure derivability contract.** Re-derives `## Current Position` prose from frontmatter and `## By-Phase Progress` table from phase dirs on disk; preserves `## Session` / `## Decisions` / unknown sections verbatim; de-duplicates `## Session Continuity Archive` (keep most-recent N, default 3); appends a structured audit entry to `## Rebuild Log` (`timestamp`, `kind`, `section`, `before`, `after`, `reason`) for every mutation. Hard idempotency guarantee: a no-mutation rebuild appends no log entry, so two successive invocations on a clean file are byte-identical. Non-overlapping with `sync` (3 lightweight frontmatter fields, auto-triggered) and orthogonal to `auto_prune_state` (age-based removal) — `rebuild` reconciles with current canonical sources, `prune` removes by retention policy, the two compose (rebuild first, then prune). Section ordering is invariant: rebuild rewrites content in place, never reorders. Targets the #1776/#1761/#1591 body-drift cluster that survived ADR-1769's per-field transitions. Phased per ADR-1817: Phase 0 = this ADR + predicates (closes #1817), Phase 1 = `rebuildCore` body + `rebuild` dispatch case + drift-class unit tests (#1827), Phase 2 = `cmdStateRebuild` CLI + `--dry-run`/`--verbose` + integration tests + docs + changeset (#1826). Source of truth: `gsd-core/bin/lib/state-transition.cjs` (generated from `src/state-transition.cts`). `state_head` (#2573) is classified `{ source: 'free', preservation: 'derive' }` — an ambient git read recomputed on every write, like `last_updated`; never preserved, because a stale stamp would claim STATE.md was written against a commit it wasn't.
### STATE.md Status Lifecycle (ADR-2207) ### STATE.md Status Lifecycle (ADR-2207)
The `Status` field in STATE.md follows a strict lifecycle: `Ready to plan` → `All phases complete` (all phases done, milestone awaiting formal close) → `<version> milestone complete` (terminal, written only by the milestone-close verb `milestoneCompleteCore`) → `Awaiting next milestone` (archived). Phase-completion verbs write `All phases complete` on the last phase — never `Milestone complete` (the overloaded bare value was removed in #2204 per ADR-2207 to decouple phase-level writes from milestone termination). `normalizeStateStatus` maps any status containing "complete" → `completed`, so consumers using the normalized projection (workstream inventory's `status` field, statusline) recognize `All phases complete` without code changes. Note: `isCompletedInventory` (workstream-inventory-builder.cts) intentionally checks only for the terminal `\bmilestone\s+complete\b` / `\barchived\b` — `All phases complete` returns `false` (intermediate, not terminal). The `Status` field in STATE.md follows a strict lifecycle: `Ready to plan` → `All phases complete` (all phases done, milestone awaiting formal close) → `<version> milestone complete` (terminal, written only by the milestone-close verb `milestoneCompleteCore`) → `Awaiting next milestone` (archived). Phase-completion verbs write `All phases complete` on the last phase — never `Milestone complete` (the overloaded bare value was removed in #2204 per ADR-2207 to decouple phase-level writes from milestone termination). `normalizeStateStatus` maps any status containing "complete" → `completed`, so consumers using the normalized projection (workstream inventory's `status` field, statusline) recognize `All phases complete` without code changes. Note: `isCompletedInventory` (workstream-inventory-builder.cts) intentionally checks only for the terminal `\bmilestone\s+complete\b` / `\barchived\b` — `All phases complete` returns `false` (intermediate, not terminal).

View File

@@ -1000,6 +1000,18 @@ v1.40.0, [#2792](https://github.com/open-gsd/gsd-core/issues/2792)).
/gsd-health --context # Context-utilization triage /gsd-health --context # Context-utilization triage
``` ```
**STATE.md freshness (`W024`).** STATE.md records the commit it was last written
against (`state_head` in its frontmatter). When the codebase has moved a long way
since — 20 commits or more — health adds an advisory noting that STATE.md's
contents should be treated as approximate.
This is a *freshness proxy, not a drift measurement*: the count includes commits
that never touched anything STATE.md describes, and the stamp is refreshed by any
command that writes STATE.md, so a low count means STATE.md was written recently
rather than that its contents are correct. The advisory never changes health's
pass/fail status, and stays silent when the stamp is absent or the project isn't
a git repo — "unknown" is reported as unknown, not as fresh.
### `/gsd-cleanup` ### `/gsd-cleanup`
Archive accumulated phase directories from completed milestones and prune local branches whose upstream has been deleted. Archive accumulated phase directories from completed milestones and prune local branches whose upstream has been deleted.

View File

@@ -45,6 +45,7 @@ current_phase: "4"
current_phase_name: Observability current_phase_name: Observability
current_plan: "3" current_plan: "3"
last_updated: "2026-06-01T12:34:56.789Z" last_updated: "2026-06-01T12:34:56.789Z"
state_head: 4f3c2b1a9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b
last_activity: "2026-06-01" last_activity: "2026-06-01"
stopped_at: "Phase 4 P3 execution complete" stopped_at: "Phase 4 P3 execution complete"
paused_at: null paused_at: null
@@ -71,6 +72,7 @@ paused_at: null
| `current_phase_name` | string | フェーズに名前がある場合 | 本文の `Current Phase Name:` フィールドから抽出したフェーズ名。 | | `current_phase_name` | string | フェーズに名前がある場合 | 本文の `Current Phase Name:` フィールドから抽出したフェーズ名。 |
| `current_plan` | string | プランが進行中の場合 | 本文の `Current Plan:` フィールドから抽出したプラン番号。 | | `current_plan` | string | プランが進行中の場合 | 本文の `Current Plan:` フィールドから抽出したプラン番号。 |
| `last_updated` | ISO-8601 タイムスタンプ | 書き込み時に常時 | 最後の `syncStateFrontmatter` 呼び出しのタイムスタンプ。`realClock.nowIso()` によって書き込まれる。 | | `last_updated` | ISO-8601 タイムスタンプ | 書き込み時に常時 | 最後の `syncStateFrontmatter` 呼び出しのタイムスタンプ。`realClock.nowIso()` によって書き込まれる。 |
| `state_head` | string (40-char sha) | On write, when the project's own git repo resolves | Full commit sha STATE.md was written against (#2573). Omitted entirely outside a git repo, or when the resolved repo is not the project's own — an unverifiable stamp degrades to absent rather than asserting provenance the file does not have. Recomputed on every write and never carried forward. |
| `last_activity` | string | 本文に設定されている場合 | 本文の `Last Activity:` フィールドから抽出した最終活動日。 | | `last_activity` | string | 本文に設定されている場合 | 本文の `Last Activity:` フィールドから抽出した最終活動日。 |
| `stopped_at` | string | 停止ポイントが記録された場合 | 最後に完了したアクションの説明。アーカイブの文章とのマッチを避けるため `## Session` 本文セクションにスコープを限定。 | | `stopped_at` | string | 停止ポイントが記録された場合 | 最後に完了したアクションの説明。アーカイブの文章とのマッチを避けるため `## Session` 本文セクションにスコープを限定。 |
| `paused_at` | string | プロジェクトが一時停止中の場合 | 一時停止ポイントの自由形式の説明。一時停止していない場合は省略または `null`。 | | `paused_at` | string | プロジェクトが一時停止中の場合 | 一時停止ポイントの自由形式の説明。一時停止していない場合は省略または `null`。 |

View File

@@ -45,6 +45,7 @@ current_phase: "4"
current_phase_name: Observability current_phase_name: Observability
current_plan: "3" current_plan: "3"
last_updated: "2026-06-01T12:34:56.789Z" last_updated: "2026-06-01T12:34:56.789Z"
state_head: 4f3c2b1a9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b
last_activity: "2026-06-01" last_activity: "2026-06-01"
stopped_at: "Phase 4 P3 execution complete" stopped_at: "Phase 4 P3 execution complete"
paused_at: null paused_at: null
@@ -71,6 +72,7 @@ paused_at: null
| `current_phase_name` | string | 페이즈에 이름이 있는 경우 | 본문 `Current Phase Name:` 필드에서 추출된 페이즈 이름. | | `current_phase_name` | string | 페이즈에 이름이 있는 경우 | 본문 `Current Phase Name:` 필드에서 추출된 페이즈 이름. |
| `current_plan` | string | 플랜이 진행 중인 경우 | 본문 `Current Plan:` 필드에서 추출된 플랜 번호. | | `current_plan` | string | 플랜이 진행 중인 경우 | 본문 `Current Plan:` 필드에서 추출된 플랜 번호. |
| `last_updated` | ISO-8601 타임스탬프 | 항상 (쓰기 시) | 마지막 `syncStateFrontmatter` 호출의 타임스탬프. `realClock.nowIso()`에 의해 기록됩니다. | | `last_updated` | ISO-8601 타임스탬프 | 항상 (쓰기 시) | 마지막 `syncStateFrontmatter` 호출의 타임스탬프. `realClock.nowIso()`에 의해 기록됩니다. |
| `state_head` | string (40-char sha) | On write, when the project's own git repo resolves | Full commit sha STATE.md was written against (#2573). Omitted entirely outside a git repo, or when the resolved repo is not the project's own — an unverifiable stamp degrades to absent rather than asserting provenance the file does not have. Recomputed on every write and never carried forward. |
| `last_activity` | string | 본문에 설정된 경우 | 본문 `Last Activity:` 필드에서 추출된 마지막 활동 날짜. | | `last_activity` | string | 본문에 설정된 경우 | 본문 `Last Activity:` 필드에서 추출된 마지막 활동 날짜. |
| `stopped_at` | string | 중단점이 기록된 경우 | 마지막으로 완료된 작업의 설명. 아카이브 산문과의 매칭을 피하기 위해 `## Session` 본문 섹션으로 범위가 제한됩니다. | | `stopped_at` | string | 중단점이 기록된 경우 | 마지막으로 완료된 작업의 설명. 아카이브 산문과의 매칭을 피하기 위해 `## Session` 본문 섹션으로 범위가 제한됩니다. |
| `paused_at` | string | 프로젝트가 일시 정지된 경우 | 일시 정지 지점에 대한 자유형 설명. 일시 정지 상태가 아닐 때는 없거나 `null`. | | `paused_at` | string | 프로젝트가 일시 정지된 경우 | 일시 정지 지점에 대한 자유형 설명. 일시 정지 상태가 아닐 때는 없거나 `null`. |

View File

@@ -45,6 +45,7 @@ current_phase: "4"
current_phase_name: Observability current_phase_name: Observability
current_plan: "3" current_plan: "3"
last_updated: "2026-06-01T12:34:56.789Z" last_updated: "2026-06-01T12:34:56.789Z"
state_head: 4f3c2b1a9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b
last_activity: "2026-06-01" last_activity: "2026-06-01"
stopped_at: "Phase 4 P3 execution complete" stopped_at: "Phase 4 P3 execution complete"
paused_at: null paused_at: null
@@ -71,6 +72,7 @@ paused_at: null
| `current_phase_name` | string | Quando uma fase tem nome | Nome da fase extraído do campo `Current Phase Name:` do corpo. | | `current_phase_name` | string | Quando uma fase tem nome | Nome da fase extraído do campo `Current Phase Name:` do corpo. |
| `current_plan` | string | Quando um plano está em andamento | Número do plano extraído do campo `Current Plan:` do corpo. | | `current_plan` | string | Quando um plano está em andamento | Número do plano extraído do campo `Current Plan:` do corpo. |
| `last_updated` | timestamp ISO-8601 | Sempre (na escrita) | Timestamp da última chamada a `syncStateFrontmatter`; escrito por `realClock.nowIso()`. | | `last_updated` | timestamp ISO-8601 | Sempre (na escrita) | Timestamp da última chamada a `syncStateFrontmatter`; escrito por `realClock.nowIso()`. |
| `state_head` | string (40-char sha) | On write, when the project's own git repo resolves | Full commit sha STATE.md was written against (#2573). Omitted entirely outside a git repo, or when the resolved repo is not the project's own — an unverifiable stamp degrades to absent rather than asserting provenance the file does not have. Recomputed on every write and never carried forward. |
| `last_activity` | string | Quando definido no corpo | Data da última atividade, extraída do campo `Last Activity:` do corpo. | | `last_activity` | string | Quando definido no corpo | Data da última atividade, extraída do campo `Last Activity:` do corpo. |
| `stopped_at` | string | Quando um ponto de parada foi registrado | Descrição da última ação concluída; limitada à seção `## Session` do corpo para evitar correspondência com prosa de arquivo. | | `stopped_at` | string | Quando um ponto de parada foi registrado | Descrição da última ação concluída; limitada à seção `## Session` do corpo para evitar correspondência com prosa de arquivo. |
| `paused_at` | string | Quando o projeto está pausado | Descrição de forma livre do ponto de pausa; ausente ou `null` quando não pausado. | | `paused_at` | string | Quando o projeto está pausado | Descrição de forma livre do ponto de pausa; ausente ou `null` quando não pausado. |

View File

@@ -45,6 +45,7 @@ current_phase: "4"
current_phase_name: Observability current_phase_name: Observability
current_plan: "3" current_plan: "3"
last_updated: "2026-06-01T12:34:56.789Z" last_updated: "2026-06-01T12:34:56.789Z"
state_head: 4f3c2b1a9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b
last_activity: "2026-06-01" last_activity: "2026-06-01"
stopped_at: "Phase 4 P3 execution complete" stopped_at: "Phase 4 P3 execution complete"
paused_at: null paused_at: null
@@ -71,10 +72,20 @@ paused_at: null
| `current_phase_name` | string | When a phase has a name | Phase name extracted from the body `Current Phase Name:` field. | | `current_phase_name` | string | When a phase has a name | Phase name extracted from the body `Current Phase Name:` field. |
| `current_plan` | string | When a plan is in progress | Plan number extracted from the body `Current Plan:` field. | | `current_plan` | string | When a plan is in progress | Plan number extracted from the body `Current Plan:` field. |
| `last_updated` | ISO-8601 timestamp | Always (on write) | Timestamp of the last `syncStateFrontmatter` call; written by `realClock.nowIso()`. | | `last_updated` | ISO-8601 timestamp | Always (on write) | Timestamp of the last `syncStateFrontmatter` call; written by `realClock.nowIso()`. |
| `state_head` | string (40-char sha) | On write, when the project's own git repo resolves | Full commit sha STATE.md was written against (#2573). Omitted entirely outside a git repo, when the resolved repo is not the project's own, or in a `planning.sub_repos` workspace — an unverifiable stamp degrades to absent rather than asserting provenance the file does not have. Recomputed on every write and never carried forward. |
| `last_activity` | string | When set in body | Date of the last activity, extracted from the body `Last Activity:` field. | | `last_activity` | string | When set in body | Date of the last activity, extracted from the body `Last Activity:` field. |
| `stopped_at` | string | When a stop point was recorded | Description of the last completed action; scoped to the `## Session` body section to avoid matching archive prose. | | `stopped_at` | string | When a stop point was recorded | Description of the last completed action; scoped to the `## Session` body section to avoid matching archive prose. |
| `paused_at` | string | When the project is paused | Freeform description of the pause point; absent or `null` when not paused. | | `paused_at` | string | When the project is paused | Freeform description of the pause point; absent or `null` when not paused. |
> **Known limitation — multi-repo workspaces.** In a workspace configured with
> [`planning.sub_repos`](../CONFIGURATION.md#planning), the freshness hint reports *unknown*
> rather than a commit age, and `state_head` is omitted. The outer workspace can own both
> `.planning/` and its own git repo while every code commit lands in a nested child repo, so the
> outer `HEAD` would not advance when the code does — measuring against it would report
> "known fresh" for a STATE.md that is arbitrarily far behind. Reporting unknown is deliberate:
> a wrong answer here is worse than no answer. Aggregating freshness across several child
> histories needs a defined semantics and is not part of this feature.
### Status values ### Status values
`normalizeStateStatus()` in `gsd-core/bin/lib/state-document.cjs` maps raw body text to these canonical values: `normalizeStateStatus()` in `gsd-core/bin/lib/state-document.cjs` maps raw body text to these canonical values:

View File

@@ -45,6 +45,7 @@ current_phase: "4"
current_phase_name: Observability current_phase_name: Observability
current_plan: "3" current_plan: "3"
last_updated: "2026-06-01T12:34:56.789Z" last_updated: "2026-06-01T12:34:56.789Z"
state_head: 4f3c2b1a9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b
last_activity: "2026-06-01" last_activity: "2026-06-01"
stopped_at: "Phase 4 P3 execution complete" stopped_at: "Phase 4 P3 execution complete"
paused_at: null paused_at: null
@@ -71,6 +72,7 @@ paused_at: null
| `current_phase_name` | 字符串 | 阶段有名称时 | 从正文 `Current Phase Name:` 字段提取的阶段名称。 | | `current_phase_name` | 字符串 | 阶段有名称时 | 从正文 `Current Phase Name:` 字段提取的阶段名称。 |
| `current_plan` | 字符串 | 计划进行中时 | 从正文 `Current Plan:` 字段提取的计划编号。 | | `current_plan` | 字符串 | 计划进行中时 | 从正文 `Current Plan:` 字段提取的计划编号。 |
| `last_updated` | ISO-8601 时间戳 | 始终(写入时) | 最后一次 `syncStateFrontmatter` 调用的时间戳;由 `realClock.nowIso()` 写入。 | | `last_updated` | ISO-8601 时间戳 | 始终(写入时) | 最后一次 `syncStateFrontmatter` 调用的时间戳;由 `realClock.nowIso()` 写入。 |
| `state_head` | string (40-char sha) | On write, when the project's own git repo resolves | Full commit sha STATE.md was written against (#2573). Omitted entirely outside a git repo, or when the resolved repo is not the project's own — an unverifiable stamp degrades to absent rather than asserting provenance the file does not have. Recomputed on every write and never carried forward. |
| `last_activity` | 字符串 | 正文中设置时 | 最后活动日期,从正文 `Last Activity:` 字段提取。 | | `last_activity` | 字符串 | 正文中设置时 | 最后活动日期,从正文 `Last Activity:` 字段提取。 |
| `stopped_at` | 字符串 | 记录了停止点时 | 最后完成操作的描述;限定在 `## Session` 正文章节内,以避免匹配存档文本。 | | `stopped_at` | 字符串 | 记录了停止点时 | 最后完成操作的描述;限定在 `## Session` 正文章节内,以避免匹配存档文本。 |
| `paused_at` | 字符串 | 项目已暂停时 | 暂停点的自由描述;未暂停时缺失或为 `null`。 | | `paused_at` | 字符串 | 项目已暂停时 | 暂停点的自由描述;未暂停时缺失或为 `null`。 |

View File

@@ -186,6 +186,7 @@ Report final status.
| W009 | warning | Phase has Validation Architecture in RESEARCH.md but no VALIDATION.md | No | | W009 | warning | Phase has Validation Architecture in RESEARCH.md but no VALIDATION.md | No |
| W018 | warning | MILESTONES.md missing entry for archived milestone snapshot | Yes (`--backfill`) | | W018 | warning | MILESTONES.md missing entry for archived milestone snapshot | Yes (`--backfill`) |
| W019 | warning | Unrecognized .planning/ root file — not a canonical GSD artifact | No | | W019 | warning | Unrecognized .planning/ root file — not a canonical GSD artifact | No |
| W024 | warning | STATE.md was written many commits ago — treat its contents as approximate | No |
| I001 | info | Plan without SUMMARY (may be in progress) | No | | I001 | info | Plan without SUMMARY (may be in progress) | No |
</error_codes> </error_codes>

View File

@@ -129,6 +129,14 @@ ALLOWLIST=(
# asserts nothing: it is the payload the guard is required to catch, carried # asserts nothing: it is the payload the guard is required to catch, carried
# as test DATA. Same class as the read-injection-scanner suites above. # as test DATA. Same class as the read-injection-scanner suites above.
'tests/kimi-payload-field-shadowing.security.test.cjs' 'tests/kimi-payload-field-shadowing.security.test.cjs'
# Phase-ID grammar regression tests exercise `RegExp.prototype.exec` via
# `re.exec('<phase-id>')` against fixtures like 'MANIFOLD-64-auth' / 'CK-64-auth'.
# The scanner's `exec('` code-execution pattern matches that benign method call,
# not an attack vector — same DEFECT.PROMPT-INJECTION-SCAN-COLLISION class as the
# test fixtures above. Pre-existing content (16 such calls on `next`); it surfaces
# here only because #2573's W024 `state_head` assertions make the file appear in
# the changed-file set the diff-mode scan walks.
'tests/health-validation.test.cjs'
) )
is_allowlisted() { is_allowlisted() {

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
{
"version": 1,
"paths": {
"health.md": "#2573 registers W024 (STATE.md written many commits ago — treat its contents as approximate) in the health workflow's <error_codes> table, so the advisory health now emits is documented where every other W-code is listed. The growth is that single table row written inline, not relocated into an eagerly @-imported reference (ADR-1610 Decision 4). 12246 -> 12348 bytes (+102), DEFAULT tier, cap 40960."
}
}

View File

@@ -1816,3 +1816,149 @@ describe('validate consistency — checklist-style roadmap phases must not emit
}); });
}); });
} }
// ── W024 (#2573): STATE.md commit-age freshness advisory ─────────────────────
//
// Advisory ONLY. It appends to warnings[] and must never change `status` or any
// existing count — promoting it to a gate is a separate, disclosed change.
//
// Goodhart guard (why the threshold is coarse and the wording is a proxy):
// `state_head` restamps on EVERY state write, so a low commits-behind count
// means "something wrote STATE recently", not "STATE's content is accurate".
// The number is a freshness proxy; the message must never assert drift.
describe('W024 — STATE.md commit-age freshness advisory (#2573)', () => {
const { after } = require('node:test');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { runGsdTools, cleanup } = require('./helpers.cjs');
const { runGit } = require('./helpers/process-seam.cjs');
const {
STATE_HEAD_ADVISORY_COMMITS,
} = require('../gsd-core/bin/lib/verify.cjs');
const dirs = [];
const track = (d) => { dirs.push(d); return d; };
after(() => { while (dirs.length) cleanup(dirs.pop()); });
function project({ commitsAhead, stateHead = 'BASE' }) {
const base = track(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2573-h-')));
const planningDir = path.join(base, '.planning');
fs.mkdirSync(path.join(planningDir, 'phases'), { recursive: true });
fs.writeFileSync(
path.join(planningDir, 'PROJECT.md'),
'# Project\n\n## What This Is\nTest.\n\n## Core Value\nTest.\n\n## Requirements\nTest.\n',
);
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ model_profile: 'balanced' }));
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
'# Roadmap\n\n## Milestone v1.0\n\n### Phase 1: One\n**Goal:** g\n',
);
runGit(['init', '-q'], { cwd: base });
runGit(['config', 'user.email', 't@t.com'], { cwd: base });
runGit(['config', 'user.name', 'T'], { cwd: base });
runGit(['config', 'commit.gpgsign', 'false'], { cwd: base });
runGit(['add', '-A'], { cwd: base });
runGit(['commit', '-q', '-m', 'seed'], { cwd: base });
const head = runGit(['rev-parse', 'HEAD'], { cwd: base }).stdout.trim();
fs.writeFileSync(
path.join(planningDir, 'STATE.md'),
[
'---',
'status: executing',
...(stateHead === null ? [] : [`state_head: ${stateHead === 'BASE' ? head : stateHead}`]),
'---',
'',
'# State',
'',
'**Current Phase:** 1',
'**Status:** In progress',
'',
].join('\n'),
);
for (let i = 0; i < commitsAhead; i++) {
fs.writeFileSync(path.join(base, `f${i}.txt`), `${i}\n`);
runGit(['add', '-A'], { cwd: base });
runGit(['commit', '-q', '-m', `c${i}`], { cwd: base });
}
return base;
}
function health(dir) {
const r = runGsdTools(['validate', 'health', '--json'], dir);
assert.strictEqual(r.success, true, `unexpected failure: ${r.error}`);
return JSON.parse(r.output);
}
const w024 = (d) => (d.warnings ?? []).filter((w) => w.code === 'W024');
test('exports a named threshold constant rather than a bare magic number', () => {
assert.strictEqual(typeof STATE_HEAD_ADVISORY_COMMITS, 'number');
assert.ok(STATE_HEAD_ADVISORY_COMMITS > 0);
});
test('does NOT fire when STATE.md was written at HEAD', () => {
const data = health(project({ commitsAhead: 0 }));
const w024 = (data.warnings ?? []).filter((w) => w.code === 'W024');
assert.strictEqual(w024.length, 0, `expected no W024, got ${JSON.stringify(w024)}`);
});
test('boundary: silent at threshold-1, fires at threshold+1', () => {
// The 0/1/20 cases alone leave the actual boundary untested — 1 is a
// trivial-fit, not an edge. These two pin the comparison operator.
const below = health(project({ commitsAhead: STATE_HEAD_ADVISORY_COMMITS - 1 }));
assert.strictEqual((below.warnings ?? []).filter((w) => w.code === 'W024').length, 0,
`must stay silent at ${STATE_HEAD_ADVISORY_COMMITS - 1} commits`);
const above = health(project({ commitsAhead: STATE_HEAD_ADVISORY_COMMITS + 1 }));
assert.strictEqual((above.warnings ?? []).filter((w) => w.code === 'W024').length, 1,
`must fire at ${STATE_HEAD_ADVISORY_COMMITS + 1} commits`);
});
test('does NOT fire below the threshold (a healthy project stays quiet)', () => {
// Hyrum guard: firing on every ordinary project would change health's
// observable "clean" state and make anything gating on clean-health noisy.
const data = health(project({ commitsAhead: 1 }));
const w024 = (data.warnings ?? []).filter((w) => w.code === 'W024');
assert.strictEqual(w024.length, 0, `expected no W024 at 1 commit, got ${JSON.stringify(w024)}`);
});
test('fires at/above the threshold, states the count, and stays a proxy (never asserts drift)', () => {
const data = health(project({ commitsAhead: STATE_HEAD_ADVISORY_COMMITS }));
const w024 = (data.warnings ?? []).filter((w) => w.code === 'W024');
assert.strictEqual(w024.length, 1, `expected exactly one W024, got ${JSON.stringify(data.warnings)}`);
const msg = String(w024[0].message);
assert.ok(msg.includes(String(STATE_HEAD_ADVISORY_COMMITS)),
`W024 must state the commit count, got: ${msg}`);
assert.ok(/approximate/i.test(msg),
`W024 must frame the signal as approximate (freshness proxy), got: ${msg}`);
assert.ok(!/\bdrift(ed)?\b|\bis wrong\b|\bstale content\b/i.test(msg),
`W024 must NOT assert drift — it is a proxy, not a measurement. Got: ${msg}`);
});
test('is advisory only — lands in warnings[], never errors[] or the repair set', () => {
// NOT "stale.status === fresh.status": these fixtures already carry W006
// ("Phase in ROADMAP but no directory"), so both sides are `degraded`
// regardless of W024 and that assertion can never fail. Health derives
// status from warnings by design (warnings -> degraded), so the real
// invariant is that W024 is a WARNING and never escalates.
const data = health(project({ commitsAhead: STATE_HEAD_ADVISORY_COMMITS }));
assert.strictEqual(w024(data).length, 1, 'precondition: W024 fired');
assert.ok(
!(data.errors ?? []).some((e) => e.code === 'W024'),
`W024 must never appear in errors[]: ${JSON.stringify(data.errors)}`,
);
assert.notStrictEqual(data.status, 'broken',
'an advisory must never break health');
assert.ok(!w024(data)[0].repairable,
'W024 is diagnostic, not auto-repairable — it must not enter the repair set');
});
test('absent state_head → no W024 (unknown is not a finding)', () => {
const data = health(project({ commitsAhead: 5, stateHead: null }));
const w024 = (data.warnings ?? []).filter((w) => w.code === 'W024');
assert.strictEqual(w024.length, 0, `unknown must stay silent, got ${JSON.stringify(w024)}`);
});
});

View File

@@ -551,6 +551,149 @@ describe('#2427 — roadmap-grounded completion + tightened status regex', () =>
}); });
}); });
// ─── #2573: STATE.md commit-age freshness signal ─────────────────────────────
describe('detectSignals — state_head commit-age freshness (#2573)', () => {
const { runGit } = require('./helpers/process-seam.cjs');
const dirs = [];
const track = (d) => { dirs.push(d); return d; };
afterEach(() => { while (dirs.length) cleanup(dirs.pop()); });
function gitProject(stateHead) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2573-se-'));
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
runGit(['init', '-q'], { cwd: dir });
runGit(['config', 'user.email', 't@t.com'], { cwd: dir });
runGit(['config', 'user.name', 'T'], { cwd: dir });
runGit(['config', 'commit.gpgsign', 'false'], { cwd: dir });
fs.writeFileSync(path.join(dir, 'seed.txt'), 'seed\n');
runGit(['add', '-A'], { cwd: dir });
runGit(['commit', '-q', '-m', 'seed'], { cwd: dir });
const base = runGit(['rev-parse', 'HEAD'], { cwd: dir }).stdout.trim();
fs.writeFileSync(
path.join(dir, '.planning', 'STATE.md'),
[
'---',
'status: executing',
...(stateHead === null ? [] : [`state_head: ${stateHead === 'BASE' ? base : stateHead}`]),
'---',
'',
'# Project State',
'',
'Phase: 1',
'',
].join('\n'),
);
return { dir: track(dir), base };
}
function advance(dir, n) {
for (let i = 0; i < n; i++) {
fs.writeFileSync(path.join(dir, `c${i}.txt`), `${i}\n`);
runGit(['add', '-A'], { cwd: dir });
runGit(['commit', '-q', '-m', `c${i}`], { cwd: dir });
}
}
test('state_head at HEAD → commits_behind 0, commit_stale false (known fresh)', () => {
const { dir } = gitProject('BASE');
const s = detectSignals(dir);
assert.strictEqual(s.state_commits_behind, 0);
assert.strictEqual(s.state_commit_stale, false);
});
test('state_head N commits back → commits_behind N', () => {
const { dir } = gitProject('BASE');
advance(dir, 3);
const s = detectSignals(dir);
assert.strictEqual(s.state_commits_behind, 3,
'commits_behind must count commits between state_head and HEAD');
assert.strictEqual(s.state_commit_stale, true);
});
test('missing state_head → tri-state null ("we don\'t know"), NOT false', () => {
// Mirrors graphify's shipped commit_stale contract (src/graphify.cts:446):
// null = unknown, distinct from false = known fresh. Collapsing unknown to
// false would assert freshness the engine cannot actually vouch for.
const { dir } = gitProject(null);
const s = detectSignals(dir);
assert.strictEqual(s.state_commits_behind, null);
assert.strictEqual(s.state_commit_stale, null);
});
test('malformed / unreachable state_head → null, never throws', () => {
for (const bad of ['not-a-sha', 'zzzz', 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef']) {
const { dir } = gitProject(bad);
let s;
assert.doesNotThrow(() => { s = detectSignals(dir); }, `${bad} must not throw`);
assert.strictEqual(s.state_commits_behind, null, `${bad} → null`);
assert.strictEqual(s.state_commit_stale, null, `${bad} → null`);
}
});
test('non-git project → null (no signal), never throws', () => {
const dir = track(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2573-nogit-')));
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(dir, '.planning', 'STATE.md'),
['---', 'status: executing', 'state_head: abc1234', '---', '', '# Project State', ''].join('\n'),
);
let s;
assert.doesNotThrow(() => { s = detectSignals(dir); });
assert.strictEqual(s.state_commit_stale, null);
});
// #2573 × #3099 composition: the commit-age freshness signal reads `state_head`
// while the LAST_ACTIVITY_UNPARSEABLE diagnostic reads `last_activity` — two
// DIFFERENT fields. A STATE.md carrying both an unparseable last_activity AND a
// valid state_head must resolve each independently: the diagnostic fires for
// last_activity, and the freshness signal still reads state_head. Neither
// shadows the other (they are not "two staleness signals on one field").
test('unparseable last_activity + valid state_head compose: diagnostic fires AND freshness reads independently (#3099)', () => {
const {
_resetUnusableInputWarningsForTests,
_unusableInputEmissionCountForTests,
} = require('../gsd-core/bin/lib/unusable-input.cjs');
_resetUnusableInputWarningsForTests();
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2573-compose-'));
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
runGit(['init', '-q'], { cwd: dir });
runGit(['config', 'user.email', 't@t.com'], { cwd: dir });
runGit(['config', 'user.name', 'T'], { cwd: dir });
runGit(['config', 'commit.gpgsign', 'false'], { cwd: dir });
fs.writeFileSync(path.join(dir, 'seed.txt'), 'seed\n');
runGit(['add', '-A'], { cwd: dir });
runGit(['commit', '-q', '-m', 'seed'], { cwd: dir });
const base = runGit(['rev-parse', 'HEAD'], { cwd: dir }).stdout.trim();
track(dir);
fs.writeFileSync(
path.join(dir, '.planning', 'STATE.md'),
[
'---',
'status: executing',
'last_activity: not-a-real-date at all',
`state_head: ${base}`,
'---',
'',
'# Project State',
'',
'Phase: 1',
'',
].join('\n'),
);
const s = detectSignals(dir);
// last_activity is unusable → its diagnostic fires (its own field), exactly once.
assert.strictEqual(_unusableInputEmissionCountForTests(), 1,
'unparseable last_activity must emit exactly one LAST_ACTIVITY_UNPARSEABLE — the freshness path adds none');
// state_head still resolves independently → fresh (0 commits behind HEAD).
assert.strictEqual(s.state_commits_behind, 0,
'state_head freshness must resolve independently of the last_activity diagnostic');
assert.strictEqual(s.state_commit_stale, false);
});
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// #3099: unusable last_activity emits a diagnostic (ADR-1411 amendment: // #3099: unusable last_activity emits a diagnostic (ADR-1411 amendment:
// corrupt is not absent — the fallback stays, the silence is the defect) // corrupt is not absent — the fallback stays, the silence is the defect)

View File

@@ -62,6 +62,18 @@ describe('ADR-1769 substrate: field-classification table', () => {
assert.strictEqual(cls && cls.preservation, 'preserve-always'); assert.strictEqual(cls && cls.preservation, 'preserve-always');
}); });
test('state_head is free / derive (ADR-1769 §4 — ambient git read, refreshed every write; #2573)', () => {
// `state_head` records the commit STATE.md was written against. It is not
// body-derived, disk-derived, or curated — it is an ambient external read
// recomputed on every write, exactly like `last_updated` (realClock.nowIso()).
// ADR-1769 §4: "Each STATE.md field has a row." The per-transition guard in
// transitionCore only checks the keys a transition declares, so an
// unregistered field would slip through silently — this test is the check.
const cls = getFieldClassification('state_head');
assert.strictEqual(cls && cls.source, 'free');
assert.strictEqual(cls && cls.preservation, 'derive');
});
test('table covers every frontmatter key emitted by buildStateFrontmatter (codex Phase 1 review)', () => { test('table covers every frontmatter key emitted by buildStateFrontmatter (codex Phase 1 review)', () => {
// Verified against src/state.cts:1633-1653 (buildStateFrontmatter emit block). // Verified against src/state.cts:1633-1653 (buildStateFrontmatter emit block).
const requiredFields = [ const requiredFields = [
@@ -77,6 +89,7 @@ describe('ADR-1769 substrate: field-classification table', () => {
'last_updated', 'last_updated',
'last_activity', 'last_activity',
'last_activity_desc', 'last_activity_desc',
'state_head',
'progress', 'progress',
'progress.total_phases', 'progress.total_phases',
'progress.completed_phases', 'progress.completed_phases',

View File

@@ -12075,3 +12075,381 @@ describe('bug #2440 — shouldPreserveExistingProgress does not ratchet total_pl
}); });
}); });
} }
// ─── #2573: state_head commit provenance on the write seam ───────────────────
describe('syncStateFrontmatter — state_head commit provenance (#2573)', () => {
const { runGit } = require('./helpers/process-seam.cjs');
const { syncStateFrontmatter } = require('../gsd-core/bin/lib/state.cjs');
const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs');
const { createTempGitProject: mkGit } = require('./helpers.cjs');
const dirs = [];
const track = (d) => { dirs.push(d); return d; };
afterEach(() => { while (dirs.length) cleanup(dirs.pop()); });
const MINIMAL_STATE = [
'---',
'status: executing',
'---',
'',
'# Session State',
'',
'Status: executing',
'',
].join('\n');
test('stamps state_head with the full HEAD sha of the project repo', () => {
const dir = track(mkGit('gsd-2573-'));
const head = runGit(['rev-parse', 'HEAD'], { cwd: dir }).stdout.trim();
const synced = syncStateFrontmatter(MINIMAL_STATE, dir);
const fm = extractFrontmatter(synced);
assert.strictEqual(fm.state_head, head,
'state_head must record the commit STATE.md was written against');
});
test('omits state_head entirely when the project is not a git repo (degrade, never throw)', () => {
// trek-e's approval condition 3: degrade to no-signal rather than throwing
// when the commit is unresolvable. A non-repo is the canonical case.
const dir = track(createTempProject('gsd-2573-nogit-'));
let synced;
assert.doesNotThrow(() => { synced = syncStateFrontmatter(MINIMAL_STATE, dir); },
'a non-git project must not throw');
const fm = extractFrontmatter(synced);
assert.ok(!('state_head' in fm),
`state_head must be absent outside a git repo, got ${JSON.stringify(fm.state_head)}`);
});
test('drops a PRE-EXISTING state_head when the commit becomes unresolvable (never carried forward)', () => {
// The omission test above feeds MINIMAL_STATE, which has no pre-existing
// state_head — so it never reaches the #2202 carry-forward loop, which
// copies any key absent from derivedFm straight back from the old file.
// This fixture DOES carry a stamp, so it exercises that branch.
//
// state-transition.cts classifies state_head as { preservation: 'derive' }:
// "Never preserved: a stale stamp would claim STATE.md was written against
// a commit it wasn't." A carried-forward value contradicts that contract and
// asserts provenance the file no longer has.
const STAMPED_STATE = [
'---',
'status: executing',
'state_head: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
'---',
'',
'# Session State',
'',
'Status: executing',
'',
].join('\n');
const dir = track(createTempProject('gsd-2573-stale-stamp-'));
let synced;
assert.doesNotThrow(() => { synced = syncStateFrontmatter(STAMPED_STATE, dir); },
'a non-git project must not throw even with a pre-existing stamp');
const fm = extractFrontmatter(synced);
assert.ok(!('state_head' in fm),
`a stale state_head must be DROPPED, not carried forward, when the commit is unresolvable — got ${JSON.stringify(fm.state_head)}`);
});
test('restamps state_head to the new HEAD after a commit (freshness proxy resets on write)', () => {
// Goodhart guard, asserted rather than assumed: the counter resets as a
// side effect of ANY state write, so state_head means "written at this
// commit", never "STATE's content is accurate". Pinning it here so nobody
// later builds a gate on the derived commit distance.
const dir = track(mkGit('gsd-2573-restamp-'));
const first = extractFrontmatter(syncStateFrontmatter(MINIMAL_STATE, dir)).state_head;
fs.writeFileSync(path.join(dir, 'unrelated.txt'), 'change\n');
runGit(['add', '-A'], { cwd: dir });
runGit(['commit', '-m', 'unrelated'], { cwd: dir });
const second = extractFrontmatter(syncStateFrontmatter(MINIMAL_STATE, dir)).state_head;
const head = runGit(['rev-parse', 'HEAD'], { cwd: dir }).stdout.trim();
assert.notStrictEqual(second, first, 'a new commit must produce a new state_head');
assert.strictEqual(second, head, 'state_head must track the current HEAD');
});
test('carries a body-absent last_activity forward instead of dropping it (#2622 B1)', () => {
// #2622 B1: the #2202 carry-forward loop skips `source: 'free'` fields
// (state_head) so an unresolvable stamp is never re-asserted — but it must
// NOT skip `last_activity` ({source:'body', preservation:'derive'}). When the
// body carries no "Last activity:" line, buildStateFrontmatter omits the
// field, and the existing frontmatter value has to survive: dropping it is
// silent frontmatter data loss and would defeat #2570's staleness signal
// downstream. A non-git project keeps this on the carry-forward path
// (state_head is simply absent) and needs no subprocess.
const STATE_WITH_ACTIVITY = [
'---',
'status: executing',
'last_activity: 2026-01-15',
'---',
'',
'# Session State',
'',
'Status: executing',
'',
].join('\n');
const dir = track(createTempProject('gsd-2622-b1-'));
const fm = extractFrontmatter(syncStateFrontmatter(STATE_WITH_ACTIVITY, dir));
assert.strictEqual(fm.last_activity, '2026-01-15',
'a body-absent last_activity must carry forward, not be dropped by the state_head narrowing');
});
});
// ─── #2573: property invariants for the state_head fence ─────────────────────
//
// `state_head` is read from disk and then passed to git AS AN ARGUMENT, which
// makes this a parser with a security-relevant fence — the class the repo's
// testing standards require fast-check coverage for. Example-based tests pin
// the shapes we thought of; these pin the invariant for the ones we didn't.
describe('readStateHeadFreshness — property invariants (#2573)', () => {
const fc = require('./helpers/fast-check-setup.cjs');
const { runGit } = require('./helpers/process-seam.cjs');
const { after } = require('node:test');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { cleanup } = require('./helpers.cjs');
const { readStateHeadFreshness } = require('../gsd-core/bin/lib/state.cjs');
const propDirs = [];
after(() => { while (propDirs.length) cleanup(propDirs.pop()); });
function gitRepo() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2573-prop-'));
propDirs.push(dir);
runGit(['init', '-q'], { cwd: dir });
runGit(['config', 'user.email', 't@t.com'], { cwd: dir });
runGit(['config', 'user.name', 'T'], { cwd: dir });
runGit(['config', 'commit.gpgsign', 'false'], { cwd: dir });
fs.writeFileSync(path.join(dir, 'a.txt'), 'a\n');
runGit(['add', '-A'], { cwd: dir });
runGit(['commit', '-q', '-m', 'seed'], { cwd: dir });
return dir;
}
const HEX_RE = /^[0-9a-f]{4,40}$/i;
const repo = gitRepo();
test('(a) total function — never throws for arbitrary input', () => {
fc.assert(
fc.property(fc.anything(), (value) => {
readStateHeadFreshness(repo, value);
return true;
}),
);
});
test('(b) fence — non-hex input never yields a stamp', () => {
fc.assert(
fc.property(fc.string(), (s) => {
const r = readStateHeadFreshness(repo, s);
if (HEX_RE.test(s.trim())) return true; // valid shape: out of scope here
return r.state_head === null && r.commits_behind === null && r.commit_stale === null;
}),
);
});
test('(c) tri-state integrity — unknown never reads as known-fresh', () => {
fc.assert(
fc.property(fc.string(), (s) => {
const r = readStateHeadFreshness(repo, s);
const validTri = r.commit_stale === null || r.commit_stale === true || r.commit_stale === false;
const unknownIsNull = r.commits_behind === null ? r.commit_stale === null : true;
const agreement = typeof r.commits_behind === 'number'
? r.commit_stale === (r.commits_behind > 0)
: true;
return validTri && unknownIsNull && agreement;
}),
);
});
test('(d) no git-argument injection — dash-led values are rejected by the fence', () => {
fc.assert(
fc.property(
fc.constantFrom('--all', '-n', '--not', '--output=/tmp/pwn', '--help', '-- --all'),
fc.string(),
(flag, tail) => {
const r = readStateHeadFreshness(repo, `${flag}${tail}`);
return r.state_head === null && r.commits_behind === null && r.commit_stale === null;
},
),
);
});
test('(f) a NON-ANCESTOR stamp resolves to unknown, never to "known fresh"', () => {
// `rev-list --count A..B` exits 0 with "0" when A is unreachable from B, so
// reset --hard / rebase / squash / force-push past the stamp used to render
// as commit_stale:false — "known fresh" for a codebase that was rewound.
// That collapses the exact unknown-vs-fresh distinction the tri-state exists
// to preserve, so a non-ancestor stamp must come back null.
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2573-nonanc-'));
propDirs.push(d);
const g = (argv) => runGit(argv, { cwd: d }).stdout;
g(['init', '-q']); g(['config', 'user.email', 't@t.com']); g(['config', 'user.name', 'T']);
g(['config', 'commit.gpgsign', 'false']);
fs.writeFileSync(path.join(d, 'a.txt'), 'a\n');
g(['add', '-A']); g(['commit', '-q', '-m', 'base']);
const base = g(['rev-parse', 'HEAD']).trim();
fs.writeFileSync(path.join(d, 'b.txt'), 'b\n');
g(['add', '-A']); g(['commit', '-q', '-m', 'c1']);
const tip = g(['rev-parse', 'HEAD']).trim();
g(['reset', '--hard', '-q', base]);
const r = readStateHeadFreshness(d, tip);
assert.strictEqual(r.commits_behind, null, 'a non-ancestor stamp has no meaningful distance');
assert.strictEqual(r.commit_stale, null, 'unknown must NOT report as false ("known fresh")');
});
test('(e) a real HEAD sha always resolves to zero commits behind', () => {
const head = runGit(['rev-parse', 'HEAD'], { cwd: repo }).stdout.trim();
const r = readStateHeadFreshness(repo, head);
assert.strictEqual(r.commits_behind, 0);
assert.strictEqual(r.commit_stale, false);
assert.strictEqual(r.state_head, head.slice(0, 7));
});
test('(g) a project whose nearest .git is an ANCESTOR repo resolves to unknown, never "known fresh"', () => {
// #2573 degrade path D5. `git rev-parse HEAD` walks UP from cwd to the
// nearest enclosing .git — nothing pins that repo to the project. A GSD
// project living under an unrelated repo (a dotfiles/notes checkout, or the
// outer workspace of a planning.sub_repos layout) measures its freshness
// against a repo it has no relationship to.
//
// The stamp below IS that ancestor repo's HEAD, so pre-fix the ancestry
// check passes, rev-list returns 0, and the tri-state reports
// commit_stale:false — "known fresh" for a directory that is not in that
// repo at all. Same invariant violation as (f), reached by another route.
const outer = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2573-ancestor-'));
propDirs.push(outer);
const g = (argv) => runGit(argv, { cwd: outer }).stdout;
g(['init', '-q']); g(['config', 'user.email', 't@t.com']); g(['config', 'user.name', 'T']);
g(['config', 'commit.gpgsign', 'false']);
fs.writeFileSync(path.join(outer, 'unrelated.txt'), 'x\n');
g(['add', '-A']); g(['commit', '-q', '-m', 'outer']);
const outerHead = g(['rev-parse', 'HEAD']).trim();
// The project itself is NOT a git repo — it merely sits inside one.
const project = path.join(outer, 'nested-project');
fs.mkdirSync(path.join(project, '.planning'), { recursive: true });
const r = readStateHeadFreshness(project, outerHead);
assert.strictEqual(r.commit_stale, null,
'a stamp resolved against an ancestor repo is UNKNOWN — it must not report false ("known fresh")');
assert.strictEqual(r.commits_behind, null,
'distance measured against an unrelated repo is not a meaningful count');
});
test('(h) a SYMLINKED project path still resolves — repo pinning compares identity, not spelling', () => {
// Guard against over-tightening (g). `git rev-parse --show-toplevel` reports
// the REAL path while the project root arrives as the caller spelled it, and
// those differ routinely: macOS temp dirs (/var/folders → /private/var/folders),
// any symlinked checkout, Windows casing. A raw string compare would report a
// perfectly normal project as unknown — the inverse of the bug (g) fixes, and
// exactly what broke the macOS and Windows CI shards.
const realDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2573-symreal-'));
propDirs.push(realDir);
const g = (argv) => runGit(argv, { cwd: realDir }).stdout;
g(['init', '-q']); g(['config', 'user.email', 't@t.com']); g(['config', 'user.name', 'T']);
g(['config', 'commit.gpgsign', 'false']);
fs.mkdirSync(path.join(realDir, '.planning'), { recursive: true });
fs.writeFileSync(path.join(realDir, 'a.txt'), 'a\n');
g(['add', '-A']); g(['commit', '-q', '-m', 'base']);
const head = g(['rev-parse', 'HEAD']).trim();
const linkDir = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2573-symlink-')), 'proj');
propDirs.push(path.dirname(linkDir));
try {
fs.symlinkSync(realDir, linkDir, 'dir');
} catch {
return; // symlink creation unavailable (e.g. unprivileged Windows) — nothing to assert
}
const r = readStateHeadFreshness(linkDir, head);
assert.strictEqual(r.commit_stale, false,
'a symlinked project path is the SAME repo — it must resolve, not degrade to unknown');
assert.strictEqual(r.commits_behind, 0);
});
test('(i) a sub_repos workspace resolves to unknown even though it owns its own repo', () => {
// #2573 D5, sub_repos flavor. (g) covers the case where the project owns NO
// .git. This is the harder one: the outer workspace owns BOTH .planning/ and
// its own repo, so projectOwnsItsRepo passes — yet every code commit lands in
// a nested child repo and the outer HEAD never advances.
//
// Pre-fix that stamps the outer HEAD, --is-ancestor passes trivially,
// rev-list counts 0, and the tri-state reports commit_stale:false — "known
// fresh" — no matter how far the children have moved. That is a WRONG answer,
// not a missing one: the same invariant (g) protects, reached by a third
// route. docs/CONFIGURATION.md describes sub_repos as scoping work per
// sub-repo "instead of treating the outer repo as a monorepo", so an outer
// wrapper that is itself a repo is a supported layout, not a contrived one.
const outer = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2573-subrepos-'));
propDirs.push(outer);
const g = (argv) => runGit(argv, { cwd: outer }).stdout;
g(['init', '-q']); g(['config', 'user.email', 't@t.com']); g(['config', 'user.name', 'T']);
g(['config', 'commit.gpgsign', 'false']);
fs.mkdirSync(path.join(outer, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(outer, '.planning', 'config.json'),
JSON.stringify({ planning: { sub_repos: ['frontend'] } }, null, 2),
);
fs.writeFileSync(path.join(outer, 'wrapper.txt'), 'x\n');
g(['add', '-A']); g(['commit', '-q', '-m', 'outer']);
const outerHead = g(['rev-parse', 'HEAD']).trim();
// A separately tracked child repo — where the real work happens. The outer
// repo is deliberately NOT advanced past `outerHead` afterwards, which is
// precisely the topology that makes the stale reading look fresh.
const child = path.join(outer, 'frontend');
fs.mkdirSync(child, { recursive: true });
const gc = (argv) => runGit(argv, { cwd: child }).stdout;
gc(['init', '-q']); gc(['config', 'user.email', 't@t.com']); gc(['config', 'user.name', 'T']);
gc(['config', 'commit.gpgsign', 'false']);
fs.writeFileSync(path.join(child, 'app.js'), 'let a = 1;\n');
gc(['add', '-A']); gc(['commit', '-q', '-m', 'child']);
const r = readStateHeadFreshness(outer, outerHead);
assert.strictEqual(r.commit_stale, null,
'a sub_repos workspace cannot substantiate a freshness claim from the outer ' +
'HEAD — it must report unknown, never false ("known fresh")');
assert.strictEqual(r.commits_behind, null,
'a distance measured against the wrapper repo is not a meaningful count');
});
test('(j) a plain single-repo project is NOT degraded by the sub_repos check', () => {
// Over-tightening guard for (i), mirroring what (h) does for (g). An empty or
// absent sub_repos must leave the normal path untouched — a check that
// degraded every project to unknown would "pass" (i) while destroying the
// feature, which is the failure mode this pins.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2573-plain-'));
propDirs.push(dir);
const g = (argv) => runGit(argv, { cwd: dir }).stdout;
g(['init', '-q']); g(['config', 'user.email', 't@t.com']); g(['config', 'user.name', 'T']);
g(['config', 'commit.gpgsign', 'false']);
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(dir, '.planning', 'config.json'),
JSON.stringify({ planning: { sub_repos: [] } }, null, 2),
);
fs.writeFileSync(path.join(dir, 'a.txt'), 'a\n');
g(['add', '-A']); g(['commit', '-q', '-m', 'base']);
const head = g(['rev-parse', 'HEAD']).trim();
const r = readStateHeadFreshness(dir, head);
assert.strictEqual(r.commit_stale, false,
'an empty sub_repos list is a normal single-repo project — it must resolve');
assert.strictEqual(r.commits_behind, 0);
});
});