* 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>
15 KiB
STATE.md 架构参考
STATE.md 是 GSD Core 的动态项目记忆文件——一个记录项目当前状态、最近发生的事情以及下一步操作的单一 Markdown 文档。本页面记录其结构。参见文档索引。
概述
由 GSD Core 管理的每个项目在 .planning/STATE.md 处保存一个 STATE.md。该文件在每次工作流开始时被读取,并在每次重要操作后被写入。该文件包含:
- 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
# Phase-lifecycle fields — all optional (added in 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
# Additional fields written by 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 |
字符串('1.0') |
始终 | 架构版本;在第一次 state.* 调用时由 syncStateFrontmatter 写入。 |
milestone |
字符串(如 v2.0) |
配置了里程碑时 | 当前里程碑版本,从项目配置中读取。 |
milestone_name |
字符串 | 配置了里程碑时 | 里程碑的人类可读标签(如 Code Quality)。 |
status |
字符串 | 始终 | 当前生命周期阶段。由 normalizeStateStatus() 规范化——参见状态值。 |
active_phase |
字符串(如 "4.5") |
编排器命令正在处理该阶段时 | 当前正在处理的阶段编号。阶段之间时设为 null。 |
next_action |
字符串 | 空闲且有推荐命令时 | 下一步要运行的斜线命令: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 |
整数 | 阶段数据可用时 | 当前里程碑中的阶段总数,从 ROADMAP.md 和阶段目录派生。 |
progress.completed_phases |
整数 | 阶段数据可用时 | 磁盘上所有计划摘要均已存在的阶段数量(即每个计划均已完成)。 |
progress.total_plans |
整数 | 计划文件存在时 | 当前里程碑中所有阶段的计划文件总数。 |
progress.completed_plans |
整数 | 摘要文件存在时 | 已完成的计划摘要总数(每个已执行计划一个 SUMMARY.md)。 |
progress.percent |
整数 0–100 | 进度数据可用时 | 里程碑在阶段维度的进度(min(completed_plans/total_plans, completed_phases/total_phases))。状态行进度条仅在该字段存在时渲染——缺失时进度条不显示。 |
current_phase |
字符串 | 阶段正在执行时 | 从正文 Current Phase: 字段提取的阶段编号。 |
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: 字段提取。 |
last_activity_desc |
字符串 | 正文中设置时 | 最后活动描述,从正文 Last Activity Description: 字段提取。 |
stopped_at |
字符串 | 记录了停止点时 | 最后完成操作的描述;限定在 ## Session 正文章节内,以避免匹配存档文本。 |
paused_at |
字符串 | 项目已暂停时 | 暂停点的自由描述;未暂停时缺失或为 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 中的模板。标准章节为:
项目参考
指向 .planning/PROJECT.md。包含:
- 核心价值 — 来自
PROJECT.md核心价值章节的一句话说明。 - 当前焦点 — 哪个阶段处于活跃状态。
当前位置
项目当前所处的状态:
| 字段 | 格式 |
|---|---|
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% |
本节中的每个字段都是单值的,本节会被覆盖而不是追加。 第二个 Phase: 行并不是第二个位置,也不是历史记录——它是格式错误的输入。读取器并不按文档中的出现顺序来解析重复项,而是按形式来解析:判定顺序与各形式在 ## Current Position 本节内的位置无关,依次是本节内任意位置的粗体 **Phase:** value,其次是位于行首的普通文本 Phase: value,最后是表格行 | Phase | value |——而且只有在胜出的形式内部,才由第一个出现的值取胜。本节之外的粗体或普通文本行——例如更早的 ## Archive 条目,或 YAML frontmatter 中的粗体行——绝不会被读取;生产实现总是先将范围限定到 ## Current Position 本节(#2956),然后才应用形式优先级。这带来两个实际后果:以更高优先级形式写入的重复项,即使在本节中出现得更晚也会获胜,因此为"强调"而追加的粗体行会静默覆盖在它之前的普通文本行,而不是被忽略;此外,缩进的 Phase: 行对普通文本形式(以行首为锚点)是不可见的,会被跳过并落到下一个匹配的形式上。"更高形式获胜"这条规则还带来两个需要留意的边界情况:如果粗体行的值只是尾随空白,则会解析为空字符串,而不会回退到下方有效的普通文本行;如果表格行的标签单元格本身是粗体写法(| **Phase:** | value |),会被粗体模式优先捕获,返回包含竖线在内的原始单元格文本。写入本节时应替换它,而不是新增一行。
进度历史不属于这里。它会累积到下方的 ### Performance Metrics 中,那才是设计用于增长的部分。
当现有值为已知模板默认值时,该章节中的 Status: 和 Last activity: 字段由 GSD 处理器更新(Knuth 不变式:执行器编写的值被保留)。已知处理器默认值的完整列表位于 gsd-core/bin/lib/state-document.cjs 中的 KNOWN_TEMPLATE_DEFAULTS。
性能指标
执行速度跟踪:
- 已完成计划总数,每个计划的平均耗时。
- 每阶段明细表(
Phase | Plans | Total | Avg/Plan)。 - 近期趋势:改善中 / 稳定 / 下降中。
每次计划完成后更新。
累积的上下文
决策 — 影响当前工作的近期决策摘要(完整日志在 PROJECT.md 中)。通过 gsd-tools state add-decision 添加。
待处理的待办事项 — 数量及对 .planning/todos/pending/ 的引用。通过 /gsd-capture 捕获。
阻碍/关切 — 影响未来工作的问题,以发起阶段为前缀。通过 gsd-tools state add-blocker 添加;通过 gsd-tools state resolve-blocker 解决。
会话连续性
实现即时会话恢复:
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 渲染的变更都将导致该套件失败。