* docs(#3812): say that Current Position is single-valued, and pin the behavior that makes it true #3812 shipped CLOSED with half its acceptance unmet. #3873 delivered cardinality for FRONTMATTER keys - current_phase/current_plan render as optional at docs/reference/state-md.md:89,91, covered by tests/gen-state-md-docs.test.cjs:374. The issue's actual ask was the ## Current Position BODY section, and that never landed. Surfaced by an /adr-phase-coverage audit of epic #3473; the issue was reopened rather than noted. The section now states three things: every field is single-valued, the section is overwritten rather than appended to, and a duplicate resolves to the FIRST occurrence with no warning - so a line appended in good faith is silently ignored rather than winning. Progress history belongs in ## Performance Metrics, two headings down, and the text now points there. The third claim is a behavioral promise about the reader, so it was VERIFIED BY EXECUTION before being written rather than inferred from the issue title: stateExtractField(<"Phase: 1 of 5 (First)" ... "Phase: 9 of 9 (Appended later)">, "Phase") -> "1 of 5 (First)" The mechanism is state-document.cjs:405 - the plain-line pattern ^<field>:[ \t]*(.+) carries flags im with NO g, so String.match returns the first hit. Writing "first wins" without running it would have repeated the exact error I had to retract twice in this epic already. A test pins the reader, not the prose. Three rows in tests/state.test.cjs: T1 (load-bearing) asserts the duplicated case resolves first; T2 asserts the ordinary single-field case still works, so a fix that only functions when duplicated cannot pass; T3 puts a Plan: line BETWEEN the two Phase: lines and asserts it resolves independently - negative space, because a reader returning the first line of the SECTION rather than the first matching FIELD would satisfy T1 alone. Proven to discriminate: a last-match variant returns "9 of 9 (Appended later)" and T1 reds. No assertion checks that the document contains a sentence. That is what local/no-source-grep exists to stop, and it would pin wording that is allowed to improve. The point of the test is that if that regex ever gains g and a last-match walk, the test fails - instead of the documentation quietly becoming a lie with nothing to notice. Prose only, no new heading. docs-state-md-locale-parity compares heading-level sequences by LCS rather than text, so added paragraphs cannot fail it while an added HEADING would fail all four locales. The constraint is structural, not stylistic - confirmed by running that comparison after the edit. The four locale copies are translated rather than left stale. They are not gate-enforced for prose, so "nothing fails" was available and is not the same as correct: leaving four documents asserting something the English one now contradicts is a correctness problem. Code spans and the anchor link stay untranslated - they name real tokens. The whole approach rests on one fact, checked first: ## Current Position at :196-208 sits OUTSIDE every generated marker region (:81-104, :138-151), so a hand edit survives --write. Re-confirmed after all five edits - gen-state-md-docs --check reports all 6 targets up to date. Had that been false the fix would have belonged in the generator, and a hand edit would have been silently reverted. One real gate failure fixed inline rather than reported: the new test's comments referenced docs/reference/state-md.md, which was not in that file's registered exempt-docs paths, and lint-docs-guard-registration failed lint:ci correctly. Registered. Known limit, named rather than folded in: gsd-tools validate/health still do NOT warn on a duplicated Phase:. #3812 records that as a "consider", not a requirement, and confirms none of the nine rules in src/health-diagnostic-rules/{state-consistency,phase-structure}.cts counts occurrences. Documenting the silent first-match is the delivered scope; making it loud is new scope and stays unclaimed. Closes #3812 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#3812): the rule I documented was false — replace it with the measured one An isolated review returned two blockers. Both mine, and the first is the worse kind: I wrote a falsifiable rule into a reference page and got it wrong. 1. "A duplicate resolves to the FIRST occurrence" is FALSE. stateExtractField (src/state-document.cts:401-419) tries BOLD `**F:**` across the whole input, THEN plain `^F:`, THEN a pipe-table row. Form precedence beats document order. Measured against the built reader, all intra-section: Phase: A (plain) / **Phase:** B (bold, later) -> B LATER WINS Phase: A (indented) / Phase: B (plain, later) -> B LATER WINS Phase: A (plain) / | Phase | T (table) | -> A first wins My original verification tested plain-versus-plain, saw first-wins, and generalized to all forms. Measuring one case and claiming the general rule is the same error I have had to retract twice already in this epic. It is also worse than silence. The sentence told authors an appended line is safely ignored; a bold line appended "for emphasis" silently overrides the original. Someone trusting the doc would have corrupted their own state file. And #3812 never asked for a resolution rule - it asked for single-valued, overwrite-not-append, and where history goes. The rule was my unrequested addition. Replaced with the measured truth: resolution is by FORM (bold anywhere, then plain at line-start, then table row), and only WITHIN the winning form does the first occurrence win. Both consequences stated plainly - a higher-ranked form wins regardless of position, and an indented `Phase:` is invisible to the plain form. All five claims in the new paragraph verified by execution before being written, including the two I had wrong. 2. The tests tested the wrong case and passed for the wrong reason. T1/T3 put the second `Phase:` under `## Somewhere else` - the INTER-section case, which #2956 already fixed by scoping. #3812 says verbatim that #2956 "fixed the inter-section case and never addressed intra-section duplication", so the case the new prose describes was untested, and the fixtures passed because of section scoping rather than field resolution. They also called bare stateExtractField rather than the production chain, T2 could not discriminate first from last at all, and no fixture mixed forms - which is precisely why the false claim survived to review. Rewritten as four rows, all intra-section, all through the real stateCurrentPositionSlice -> stateExtractField path: plain-then-plain (first wins within a form), plain-then-bold (the bold LATER value wins - the row whose absence let the false claim ship), indented-then-plain (indented invisible), and sibling-field independence. Each proven to fail against a reader that disagrees. 3. Two dead anchors. pt-BR and zh-CN linked `#performance-metrics` while their own headings are `### Métricas de Desempenho` and `### 性能指标`. Both fixed to the anchor their own heading generates. ja-JP/ko-KR kept the English heading, so theirs already resolved. 4. A ja/ko sentence inverted its own meaning. Both rendered "which is the section designed to grow" with a bare demonstrative whose nearest referent read as Current Position - saying the opposite of the point. Rewritten so the clause attaches unambiguously to `## Performance Metrics`. 5. Cross-locale drift, flagged by the implementing agent rather than by me: after fixing EN, the four locales still stated the OLD false rule. Four documents asserting something measured to be wrong is worse than four saying nothing. All four now carry a faithful translation of the corrected paragraph, with code spans, each file's own anchor, and the ja/ko referent fix preserved. Verified: all five claims executed against the built reader; every rewritten test row proven to discriminate; gen-state-md-docs --check reports all 6 targets up to date, so the edits stay outside the generated marker regions; locale heading parity unaffected (prose only, no headings added); build:lib, lint and lint:ci all exit 0. Refs #3812 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#3812): second false rule on the same page — scope the ranking to the section A second isolated review found a second false falsifiable claim, and the failure mode is the same one twice in a row: attempt 1: verified plain-vs-plain, wrote a claim about ALL FORMS attempt 2: verified bare stateExtractField, wrote a claim about THE DOCUMENT Both times the claim covered a wider surface than what was actually executed. The fix each time was not a better sentence, it was executing the surface the sentence describes. BLOCKER — "bold `**Phase:**` anywhere in the DOCUMENT wins" is false. ## Current Position / Phase: 1 of 5 + ## Archive / **Phase:** 88 bare stateExtractField(whole doc) -> "88 (other section)" PRODUCTION (slice then extract) -> "1 of 5 (in section)" #2956's section slice means production never hands another section to the matcher; a bold line in `## Archive`, or in the YAML frontmatter, is simply not seen. The ranking is real but scoped: it applies WITHIN `## Current Position`. I verified against the bare function and wrote a claim about the system. Every existing test placed its bold line inside the section, which is exactly why nothing contradicted the claim. T5 now puts a bold `**Phase:**` in `## Archive` and asserts production returns the in-section plain value, with the unscoped reader asserted to DISAGREE so the row proves the scoping rather than assuming it. BLOCKER — the changeset still shipped the ORIGINAL retracted claim. I corrected the page and left the release note saying "resolves to the first occurrence ... a second entry added in good faith is silently ignored". The note contradicted the page it announces, and the release note is what most people actually read. Rewritten to the corrected rule. MEDIUM — the concession was inverted. It read "wins even if it comes FIRST in the file", which is the vacuous direction; the surprising case, and the one the very next clause illustrates with an APPENDED bold line, is "even if it comes LAST". All four locales reproduced the inversion faithfully, so it was an EN-source defect rather than translation drift. Two sharp edges now named, both measured: a bold `**Phase:**` followed only by trailing spaces resolves to an EMPTY STRING and does not fall through to a valid plain line below (T6 pins it); and `| **Phase:** | 3 of 4 |` short-circuits to the bold form and returns the literal `"| 3 of 4 |"`. A page that teaches form ranking has to say where the ranking bites. Also fixed: all five files labelled the link `## Performance Metrics` while the heading is `### Performance Metrics`. Anchors resolved correctly everywhere; only the label's level was wrong. Every clause in the final paragraph re-verified through the PRODUCTION chain (stateCurrentPositionSlice -> stateExtractField), clause by clause, before being written: bold in another section does not win; bold in frontmatter does not win; bold appended last does win; first wins within one form; trailing-space bold yields empty. All four locales carry the same corrected rule. gen-state-md-docs --check reports all 6 targets up to date; heading counts unchanged at 20/20 across all five files, so locale heading-parity is untouched; build:lib, lint and lint:ci all exit 0. Refs #3812 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3812): backfill changeset pr number Refs #3812 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
19 KiB
STATE.md スキーマリファレンス
STATE.md は GSD Core のプロジェクト記憶ファイルです — プロジェクトの現在地、直近の作業内容、次に実行すべきコマンドを記録する単一の Markdown ドキュメントです。このページではそのスキーマを説明します。ドキュメントインデックス も参照してください。
概要
GSD Core で管理するプロジェクトはすべて .planning/STATE.md に STATE.md を1つ保持します。このファイルはすべてのワークフロー開始時に読み込まれ、重要なアクションのたびに書き込まれます。ファイルは以下を組み合わせた構成です:
- YAML フロントマター — ステータスラインフック(
parseStateMd)およびgsd-tools stateコマンドが読み取る機械可読フィールド。 - Markdown 本文 — 現在の進捗、蓄積されたコンテキスト、セッション継続性、パフォーマンス指標を記述する人間可読なセクション。
ファイルは意図的にコンパクトに保たれています(目標: 100行以内)。プロジェクト状態のダイジェストであり、アーカイブではありません。
YAML フロントマター
フロントマターはファイルの先頭にある --- デリミタの間に記述します。gsd_state_version と status 以外のフィールドはすべてオプションで、データが未取得の場合は省略できます。
注釈付きサンプル
---
gsd_state_version: '1.0'
milestone: v2.0
milestone_name: Code Quality
status: executing
# フェーズライフサイクルフィールド — すべてオプション (v1.40.0, issue #2833 で追加)
active_phase: "4.5"
next_action: execute-phase
next_phases: ["4.5"]
progress:
total_phases: 17
completed_phases: 10
total_plans: 84
completed_plans: 47
percent: 59
# syncStateFrontmatter が書き込む追加フィールド
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
---
フィールドリファレンス
| フィールド | 型 | 設定タイミング | 用途 |
|---|---|---|---|
gsd_state_version |
string ('1.0') |
常時 | スキーマバージョン。syncStateFrontmatter が最初の state.* 呼び出し時に書き込む。 |
milestone |
string (例: v2.0) |
マイルストーン設定時 | 現在のマイルストーンバージョン。プロジェクト設定から読み取る。 |
milestone_name |
string | マイルストーン設定時 | 人間可読なマイルストーンラベル (例: Code Quality)。 |
status |
string | 常時 | 現在のライフサイクルステージ。normalizeStateStatus() によって正規化される — ステータス値 参照。 |
active_phase |
string (例: "4.5") |
このフェーズでオーケストレーターコマンドが実行中の場合 | 現在処理中のフェーズ番号。フェーズ間は null に設定。 |
next_action |
string | アイドル中で推奨コマンドがある場合 | 次に実行するスラッシュコマンド: discuss-phase、plan-phase、execute-phase、verify-phase。オーケストレーターが実行中または推奨なしの場合は null に設定。 |
next_phases |
YAML フロー配列 (例: ["4.5"]) |
next_action と対応して |
next_action が適用されるフェーズ ID(通常1〜2件)。next_action と同条件で null に設定。 |
progress.total_phases |
integer | フェーズデータ取得済みの場合 | 現在のマイルストーンにおける総フェーズ数。ROADMAP.md とフェーズディレクトリから算出。 |
progress.completed_phases |
integer | フェーズデータ取得済みの場合 | すべてのプランサマリーがディスク上に存在するフェーズ数(すなわちすべてのプランが完了したもの)。 |
progress.total_plans |
integer | プランファイルが存在する場合 | 現在のマイルストーンの全フェーズにわたるプランファイルの合計数。 |
progress.completed_plans |
integer | サマリーファイルが存在する場合 | 完了したプランサマリーの合計数(実行済みプランごとに1つの SUMMARY.md)。 |
progress.percent |
integer 0–100 | 進捗データ取得済みの場合 | フェーズ次元 でのマイルストーン進捗(min(completed_plans/total_plans, completed_phases/total_phases))。このフィールドが存在するときのみステータスラインの進捗バーが描画されます — 不在の場合はバーが非表示になります。 |
current_phase |
string | フェーズ実行中 | 本文の Current Phase: フィールドから抽出したフェーズ番号。 |
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: フィールドから抽出した最終活動日。 |
last_activity_desc |
string | 本文に設定されている場合 | 本文の Last Activity Description: フィールドから抽出した最終活動の説明。 |
stopped_at |
string | 停止ポイントが記録された場合 | 最後に完了したアクションの説明。アーカイブの文章とのマッチを避けるため ## Session 本文セクションにスコープを限定。 |
paused_at |
string | プロジェクトが一時停止中の場合 | 一時停止ポイントの自由形式の説明。一時停止していない場合は省略または null。 |
フィールドの多重度
| フィールド | 多重度 |
|---|---|
gsd_state_version |
one |
milestone |
optional |
milestone_name |
optional |
current_phase |
optional |
current_phase_name |
optional |
current_plan |
optional |
status |
one |
stopped_at |
optional |
paused_at |
optional |
last_updated |
one |
last_activity |
optional |
last_activity_desc |
optional |
state_head |
optional |
progress.total_phases |
optional |
progress.completed_phases |
optional |
progress.total_plans |
optional |
progress.completed_plans |
optional |
progress.percent |
optional |
ステータス値
gsd-core/bin/lib/state-document.cjs の normalizeStateStatus() が本文の生テキストを以下の正規値にマッピングします:
| 正規値 | マッチするテキスト(大文字小文字不問) |
|---|---|
discussing |
discussing を含む |
planning |
planning または ready to plan を含む |
executing |
executing、in progress、または ready to execute を含む |
verifying |
verif を含む |
completed |
complete または done を含む |
paused |
paused または stopped を含む、または paused_at が存在する |
unknown |
上記のいずれにも該当しない |
オーケストレーターコマンドが実行中の場合、慣例(issue #2833)として status にライフサイクルステージを直接書き込みます:
| コマンド | 実行中の status |
|---|---|
/gsd-discuss-phase |
discussing |
/gsd-plan-phase |
planning |
/gsd-execute-phase |
executing |
/gsd-verify-work |
verifying |
ステータスライフサイクル (ADR-2207)
The Status field follows a strict lifecycle across phase and milestone boundaries:
| 値 | 書き込み元 | 意味 |
|---|---|---|
Ready to plan |
completePhaseCore (non-last phase) |
Next phase is ready for planning |
All phases complete |
completePhaseCore (last phase) |
All phases done; milestone awaiting formal close |
<version> milestone complete |
milestoneCompleteCore |
Milestone formally closed and archived |
Awaiting next milestone |
milestoneCompleteCore |
Terminal/archived state |
Phase-completion verbs never write Milestone complete (the overloaded bare value was removed in #2204 per ADR-2207 to decouple phase-level writes from milestone termination).
ステータスライン描画シーン
hooks/gsd-statusline.js の formatGsdState() がパース済みフロントマターを読み取り、最初にマッチしたシーン を出力します。新しいライフサイクルフィールドが適用されない場合は、v1.38.x から一切変更なくオリジナルのフォーマットにフォールスルーします。
| シーン | トリガー | 表示例 |
|---|---|---|
| 1. フェーズアクティブ | active_phase が設定されている |
v2.0 [██░░░░░░░░] 20% · Phase 4.5 executing |
| 2. アイドル・次のアクション推奨 | active_phase が null かつ next_action と next_phases の両方が設定されている |
v2.0 [██░░░░░░░░] 20% · next execute-phase 4.5 |
| 3. マイルストーン完了 | percent が 100 または completed_phases == total_phases |
v2.0 [██████████] 100% · milestone complete |
| 4. デフォルトフォールバック | 上記のいずれにも該当しない | v1.9 Code Quality · executing · ph 1/5(既存フォーマット) |
シーン優先度: active_phase と next_action が両方設定されている場合、シーン1が優先されます — オーケストレーターが実行中であるため「次の推奨」は誤解を招くためです。この優先度は formatGsdState() のチェック順序によって強制され、tests/gsd-statusline.test.cjs の "scene priority" スイートでカバーされています。
進捗バー([██░░░░░░░░] 20%)はフロントマターに progress.percent が存在する場合のみマイルストーンセグメントに追加されます。不在の場合はバーは表示されません。
フロントマターパースの制約
ステータスラインフックは正規表現ベースのパース(完全な YAML ライブラリを使用しない)を使用するため、以下の制約が適用されます。これらは tests/gsd-statusline.test.cjs でテストされています。
-
フロントマターはファイルの先頭文字から始まる必要があります。 コメントを含む何かが開始
---の前にあると、マッチが無効になります。開始---行は末尾のスペースなしで正確にそれだけである必要があります。 -
ネストされたブロック内のコメントはサポートされていません。
progress:ブロックパーサーは次の行が[ \t]+\w+:であることを要求します。progress:と最初のキーの間に# commentを挿入するとマッチが壊れてバーが消えます。ドキュメントはフロントマターブロック内ではなくSTATE.md本文に記載してください。 -
next_phasesの主形式は単一行フローです。 パーサーは最初にnext_phases: ["4.5", "4.6"]を試みます。ブロックシーケンス(- 4.5\n- 4.6)もパースされますが、ステータスライン描画の信頼性は低下します。正規表現ベースのパーサーを予測可能に保つため、next_phasesには単一行フローを使用してください。多数の候補フェーズをドキュメント目的で記録する必要がある場合はSTATE.md本文に格納してください。
将来的に正規表現パーサーを完全な YAML ライブラリに置き換えた場合は、これらの制約を緩和しテストを更新できます。
Markdown 本文セクション
本文(末尾の --- 以降のすべて)は gsd-core/templates/state.md のテンプレートに従います。標準セクションは以下の通りです:
Project Reference
.planning/PROJECT.md へのポインタです。以下を含みます:
- Core value —
PROJECT.mdの Core Value セクションの一行説明。 - Current focus — アクティブなフェーズ。
Current Position
プロジェクトの現在の状況:
| フィールド | フォーマット |
|---|---|
Phase: |
X of Y (Phase name) |
Plan: |
A of B in current phase |
Status: |
自由テキスト。例: Ready to execute、Executing Phase 4、Phase complete — ready for verification |
Last activity: |
ハンドラー書き込み時は ISO 日付(YYYY-MM-DD); エグゼキューター作成時はナラティブ文章 |
Progress: |
ビジュアルバー。例: [████░░░░░░] 40% |
このセクションのすべてのフィールドは単一値であり、このセクションは追記されるのではなく上書きされます。 2つ目の Phase: 行は2つ目の位置ではなく、履歴でもありません — それは不正な入力です。リーダーは重複を文書内の出現順序では解決しません。フォームによって解決します。判定順序は各フォームが ## Current Position セクション内のどこに現れるかに関係なく、このセクション内のどこにあっても太字の **Phase:** value、次に行頭の平文 Phase: value、最後にパイプテーブルの | Phase | value | 行の順です — そして勝者となったフォーム内でのみ、最初の出現が勝ちます。このセクションの外にある太字または平文の行 — 以前の ## Archive エントリや YAML フロントマター内の太字行 — は一切参照されません。実装は常にまず ## Current Position セクションへスコープを絞ってから(#2956)フォームの優先順位を適用します。実務上の帰結は2つあります。上位のフォームで書かれた重複は、セクション内で後に現れていても勝ちます。つまり「強調のために」追記された太字行は、無視されるのではなく、先行する平文行を黙って上書きします。また、インデントされた Phase: 行は平文フォーム(行頭を基準とする)からは見えず、次に一致するフォームへとフォールスルーします。「上位フォームが勝つ」というルールには2つの鋭い落とし穴が伴います。値が末尾の空白だけの太字行は、下にある有効な平文行にフォールスルーせず、空文字列として解決されます。また、ラベルのセル自体が太字になっているパイプテーブル行(| **Phase:** | value |)は太字パターンに先に捕捉され、パイプを含むセルの文字列がそのまま返されます。このセクションを書き込む際は、行を追加するのではなく、置き換えてください。
進捗の履歴はここには属しません。それは、増えていくように設計されたセクションである以下の ### Performance Metrics に蓄積されます。
このセクションの Status: および Last activity: フィールドは、既存の値が既知のテンプレートデフォルト値の場合に GSD ハンドラーによって更新されます(クヌース不変式: エグゼキューター作成値は保存されます)。既知のハンドラーデフォルト値の完全なリストは gsd-core/bin/lib/state-document.cjs の KNOWN_TEMPLATE_DEFAULTS に記載されています。
Performance Metrics
実行速度の追跡:
- 完了した総プラン数、プランあたりの平均所要時間。
- フェーズごとの内訳テーブル(
Phase | Plans | Total | Avg/Plan)。 - 最近のトレンド: Improving / Stable / Degrading。
各プラン完了後に更新されます。
Accumulated Context
Decisions — 現在の作業に影響する最近の意思決定のサマリー(完全なログは PROJECT.md に保存)。gsd-tools state add-decision で追加。
Pending Todos — 件数と .planning/todos/pending/ への参照。/gsd-capture で取得。
Blockers/Concerns — 今後の作業に影響する課題。起点フェーズのプレフィックス付き。gsd-tools state add-blocker で追加し、gsd-tools state resolve-blocker で解決。
Session Continuity
即座のセッション再開を可能にします:
Last session:— 最後のセッションの ISO-8601 タイムスタンプ。Stopped at:— 最後に完了したアクションの説明。Resume file:—.continue-here*.mdファイルが存在する場合はそのパス、存在しない場合はNone。
後方互換性
フェーズライフサイクルフィールド(active_phase、next_action、next_phases、バー用の progress.percent)は 追加式でプロジェクトごとにオプトイン です:
- ライフサイクルフィールドが一切設定されていない
STATE.mdは v1.38.x 以前と バイト単位で同一 に描画されます。 - ライフサイクルフィールドの追加はオプトインです — フィールドが不在の場合レンダラーはグレースフルに縮退します。
- 進捗バーは
progressブロックが存在する場合でもオプトインです: バーをトリガーするのはprogress.percentのみで、total_phasesとcompleted_phasesだけではトリガーされません。
tests/gsd-statusline.test.cjs の formatGsdState #2833 backward compatibility テストスイートがこの保証を固定しています。レガシー STATE.md 描画を壊す変更があればスイートが失敗します。