diff --git a/.changeset/2573-state-head-freshness.md b/.changeset/2573-state-head-freshness.md new file mode 100644 index 000000000..c399f1db1 --- /dev/null +++ b/.changeset/2573-state-head-freshness.md @@ -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) diff --git a/CONTEXT.md b/CONTEXT.md index 6c2d35e42..d3ac030df 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. ### 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 -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) The `Status` field in STATE.md follows a strict lifecycle: `Ready to plan` → `All phases complete` (all phases done, milestone awaiting formal close) → ` 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). diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 1f14ee347..3c5822509 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -1000,6 +1000,18 @@ v1.40.0, [#2792](https://github.com/open-gsd/gsd-core/issues/2792)). /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` Archive accumulated phase directories from completed milestones and prune local branches whose upstream has been deleted. diff --git a/docs/ja-JP/reference/state-md.md b/docs/ja-JP/reference/state-md.md index 343dcd78b..883c72e55 100644 --- a/docs/ja-JP/reference/state-md.md +++ b/docs/ja-JP/reference/state-md.md @@ -45,6 +45,7 @@ current_phase: "4" current_phase_name: Observability current_plan: "3" last_updated: "2026-06-01T12:34:56.789Z" +state_head: 4f3c2b1a9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b last_activity: "2026-06-01" stopped_at: "Phase 4 P3 execution complete" paused_at: null @@ -71,6 +72,7 @@ paused_at: null | `current_phase_name` | string | フェーズに名前がある場合 | 本文の `Current Phase Name:` フィールドから抽出したフェーズ名。 | | `current_plan` | string | プランが進行中の場合 | 本文の `Current Plan:` フィールドから抽出したプラン番号。 | | `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:` フィールドから抽出した最終活動日。 | | `stopped_at` | string | 停止ポイントが記録された場合 | 最後に完了したアクションの説明。アーカイブの文章とのマッチを避けるため `## Session` 本文セクションにスコープを限定。 | | `paused_at` | string | プロジェクトが一時停止中の場合 | 一時停止ポイントの自由形式の説明。一時停止していない場合は省略または `null`。 | diff --git a/docs/ko-KR/reference/state-md.md b/docs/ko-KR/reference/state-md.md index 141925431..6ee956496 100644 --- a/docs/ko-KR/reference/state-md.md +++ b/docs/ko-KR/reference/state-md.md @@ -45,6 +45,7 @@ current_phase: "4" current_phase_name: Observability current_plan: "3" last_updated: "2026-06-01T12:34:56.789Z" +state_head: 4f3c2b1a9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b last_activity: "2026-06-01" stopped_at: "Phase 4 P3 execution complete" paused_at: null @@ -71,6 +72,7 @@ paused_at: null | `current_phase_name` | string | 페이즈에 이름이 있는 경우 | 본문 `Current Phase Name:` 필드에서 추출된 페이즈 이름. | | `current_plan` | string | 플랜이 진행 중인 경우 | 본문 `Current Plan:` 필드에서 추출된 플랜 번호. | | `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:` 필드에서 추출된 마지막 활동 날짜. | | `stopped_at` | string | 중단점이 기록된 경우 | 마지막으로 완료된 작업의 설명. 아카이브 산문과의 매칭을 피하기 위해 `## Session` 본문 섹션으로 범위가 제한됩니다. | | `paused_at` | string | 프로젝트가 일시 정지된 경우 | 일시 정지 지점에 대한 자유형 설명. 일시 정지 상태가 아닐 때는 없거나 `null`. | diff --git a/docs/pt-BR/reference/state-md.md b/docs/pt-BR/reference/state-md.md index 7cb7f9492..ef7d83699 100644 --- a/docs/pt-BR/reference/state-md.md +++ b/docs/pt-BR/reference/state-md.md @@ -45,6 +45,7 @@ current_phase: "4" current_phase_name: Observability current_plan: "3" last_updated: "2026-06-01T12:34:56.789Z" +state_head: 4f3c2b1a9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b last_activity: "2026-06-01" stopped_at: "Phase 4 P3 execution complete" 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_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()`. | +| `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. | | `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. | diff --git a/docs/reference/state-md.md b/docs/reference/state-md.md index f7cc85d8e..074ee78e2 100644 --- a/docs/reference/state-md.md +++ b/docs/reference/state-md.md @@ -45,6 +45,7 @@ current_phase: "4" current_phase_name: Observability current_plan: "3" last_updated: "2026-06-01T12:34:56.789Z" +state_head: 4f3c2b1a9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b last_activity: "2026-06-01" stopped_at: "Phase 4 P3 execution complete" 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_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()`. | +| `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. | | `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. | +> **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 `normalizeStateStatus()` in `gsd-core/bin/lib/state-document.cjs` maps raw body text to these canonical values: diff --git a/docs/zh-CN/reference/state-md.md b/docs/zh-CN/reference/state-md.md index 18562a16b..3bddf6026 100644 --- a/docs/zh-CN/reference/state-md.md +++ b/docs/zh-CN/reference/state-md.md @@ -45,6 +45,7 @@ current_phase: "4" current_phase_name: Observability current_plan: "3" last_updated: "2026-06-01T12:34:56.789Z" +state_head: 4f3c2b1a9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b last_activity: "2026-06-01" stopped_at: "Phase 4 P3 execution complete" paused_at: null @@ -71,6 +72,7 @@ paused_at: null | `current_phase_name` | 字符串 | 阶段有名称时 | 从正文 `Current Phase Name:` 字段提取的阶段名称。 | | `current_plan` | 字符串 | 计划进行中时 | 从正文 `Current Plan:` 字段提取的计划编号。 | | `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:` 字段提取。 | | `stopped_at` | 字符串 | 记录了停止点时 | 最后完成操作的描述;限定在 `## Session` 正文章节内,以避免匹配存档文本。 | | `paused_at` | 字符串 | 项目已暂停时 | 暂停点的自由描述;未暂停时缺失或为 `null`。 | diff --git a/gsd-core/workflows/health.md b/gsd-core/workflows/health.md index 828946934..707641c6d 100644 --- a/gsd-core/workflows/health.md +++ b/gsd-core/workflows/health.md @@ -186,6 +186,7 @@ Report final status. | 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`) | | 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 | diff --git a/scripts/prompt-injection-scan.sh b/scripts/prompt-injection-scan.sh index c2f19ca90..ff2385144 100755 --- a/scripts/prompt-injection-scan.sh +++ b/scripts/prompt-injection-scan.sh @@ -129,6 +129,14 @@ ALLOWLIST=( # 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. 'tests/kimi-payload-field-shadowing.security.test.cjs' + # Phase-ID grammar regression tests exercise `RegExp.prototype.exec` via + # `re.exec('')` 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() { diff --git a/src/smart-entry.cts b/src/smart-entry.cts index b4b19c2dc..95d8b8c41 100644 --- a/src/smart-entry.cts +++ b/src/smart-entry.cts @@ -52,6 +52,9 @@ const { stateFieldValue } = stateDocument; import phaseId = require('./phase-id.cjs'); const { comparePhaseNum, extractPhaseToken, normalizePhaseName, phaseTokenMatches } = phaseId; // 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'); const { warnUnusableInput, UNUSABLE_REASON } = unusableInput; @@ -104,6 +107,18 @@ export interface SmartEntrySignals { */ roadmap_total_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 { @@ -304,6 +319,9 @@ export function detectSignals(cwd: string, now: () => number = Date.now): SmartE stale_activity: false, roadmap_total_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; @@ -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 { current_phase: parseIntOrNull(currentPhaseRaw), total_phases: parseIntOrNull(totalPhasesRaw), @@ -411,6 +435,8 @@ export function detectSignals(cwd: string, now: () => number = Date.now): SmartE stale_activity: staleActivity, roadmap_total_phases: roadmapTotalPhases, roadmap_completed_phases: roadmapCompletedPhases, + state_commits_behind: freshness.commits_behind, + state_commit_stale: freshness.commit_stale, }; } diff --git a/src/state-transition.cts b/src/state-transition.cts index 8b7c2fbdf..859c64182 100644 --- a/src/state-transition.cts +++ b/src/state-transition.cts @@ -98,6 +98,11 @@ export const FIELD_CLASSIFICATION: Readonly> last_activity: { source: 'body', preservation: 'derive' } as FieldClassification, // always refresh on transition 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: { source: 'curated', preservation: 'preserve-always' } as FieldClassification, // #3242, #1446 'progress.total_phases': { source: 'disk', preservation: 'derive' } as FieldClassification, diff --git a/src/state.cts b/src/state.cts index c9e4b8fd9..e41f7058f 100644 --- a/src/state.cts +++ b/src/state.cts @@ -20,7 +20,7 @@ const { escapeRegex, parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, phaseKeyFro // eslint-disable-next-line @typescript-eslint/no-require-imports import roadmapParserMod = require('./roadmap-parser.cjs'); 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 import planningWorkspace = require('./planning-workspace.cjs'); const { planningDir, planningPaths } = planningWorkspace; @@ -42,6 +42,10 @@ import phaseLocatorMod = require('./phase-locator.cjs'); const { listMilestonePhaseDirs } = phaseLocatorMod; // eslint-disable-next-line @typescript-eslint/no-require-imports 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; type StateTransitionIntent = stateTransitionMod.StateTransitionIntent; type StateTransitionDeps = stateTransitionMod.StateTransitionDeps; @@ -1971,6 +1975,12 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto fm['last_updated'] = realClock.nowIso(); if (lastActivity) fm['last_activity'] = lastActivity; 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 = {}; if (totalPhases !== null) progress['total_phases'] = totalPhases; @@ -1983,6 +1993,186 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto 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 { // Read existing frontmatter BEFORE stripping — it may contain values // 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 // preserve guards above) still win. for (const key of Object.keys(existingFm)) { - if (!(key in derivedFm) && existingFm[key] !== undefined) { - derivedFm[key] = existingFm[key]; - } + if (key in derivedFm || existingFm[key] === undefined) continue; + + // #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 @@ -3734,6 +3943,7 @@ export = { writeStateMd, readModifyWriteStateMd, syncStateFrontmatter, + readStateHeadFreshness, withStateLock, updatePerformanceMetricsSection, cmdStateLoad, diff --git a/src/verify.cts b/src/verify.cts index fc8cc3e08..011b9fcf3 100644 --- a/src/verify.cts +++ b/src/verify.cts @@ -62,7 +62,18 @@ const { determinePhaseStatus } = commandsMod; const { planningDir, planningRoot } = planningWorkspace; 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; // Unused but imported for structural parity @@ -1642,6 +1653,29 @@ function cmdValidateHealth( repairs.push('regenerateState'); } else { 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; + 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 = [ ...stateContent.matchAll(new RegExp(`[Pp]hase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`, 'g')), ].map( @@ -2739,6 +2773,7 @@ export = { cmdValidateAgents, cmdVerifySchemaDrift, cmdVerifyCodebaseDrift, + STATE_HEAD_ADVISORY_COMMITS, // Test seam (#1883): listMilestoneArchiveDirs is private and exercised through // 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 diff --git a/tests/emitted-drift-acks/2573-state-head-freshness.json b/tests/emitted-drift-acks/2573-state-head-freshness.json new file mode 100644 index 000000000..ecb372b44 --- /dev/null +++ b/tests/emitted-drift-acks/2573-state-head-freshness.json @@ -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 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." + } +} diff --git a/tests/health-validation.test.cjs b/tests/health-validation.test.cjs index d2c10ab78..b60ee18da 100644 --- a/tests/health-validation.test.cjs +++ b/tests/health-validation.test.cjs @@ -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)}`); + }); +}); diff --git a/tests/smart-entry.unit.test.cjs b/tests/smart-entry.unit.test.cjs index 1f864a7d6..ad6488dec 100644 --- a/tests/smart-entry.unit.test.cjs +++ b/tests/smart-entry.unit.test.cjs @@ -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: // corrupt is not absent — the fallback stays, the silence is the defect) diff --git a/tests/state-transition.test.cjs b/tests/state-transition.test.cjs index 4d78531c0..d69e263f2 100644 --- a/tests/state-transition.test.cjs +++ b/tests/state-transition.test.cjs @@ -62,6 +62,18 @@ describe('ADR-1769 substrate: field-classification table', () => { 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)', () => { // Verified against src/state.cts:1633-1653 (buildStateFrontmatter emit block). const requiredFields = [ @@ -77,6 +89,7 @@ describe('ADR-1769 substrate: field-classification table', () => { 'last_updated', 'last_activity', 'last_activity_desc', + 'state_head', 'progress', 'progress.total_phases', 'progress.completed_phases', diff --git a/tests/state.test.cjs b/tests/state.test.cjs index 208e9463d..65d90d2a0 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -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); + }); +});