plan-governance-cli 3.0.0 → 3.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -140,6 +140,8 @@ AGENTS.md
140
140
 
141
141
  ## 更新已有项目
142
142
 
143
+ 全局 npm 安装、`setup` 同步 skill、项目入口刷新是三个独立步骤。前两步不更新各项目的 `AGENTS.md`;`check` 会对已有地图项目的治理受管块提示差异、损坏或不可读取,严格模式也仅告警,不自动覆盖。差异可能来自定制,先核对再更新;规则来源与诊断边界见 [CLI 参考](resources/skill/references/cli.md#分发与资源)。
144
+
143
145
  如果项目已经有 `docs/PLAN_MAP.md` 和计划文件,不要重新初始化计划文档。只更新代理执行规则时运行:
144
146
 
145
147
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plan-governance-cli",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
4
4
  "description": "统一入口的计划治理检查 CLI",
5
5
  "repository": {
6
6
  "type": "git",
@@ -13,7 +13,7 @@ description: 维护已有项目计划,或为需要跨阶段、跨会话协调
13
13
 
14
14
  多计划/多会话并行时,从当前任务确定本会话的已登记计划 ID,在 check/workset/hook 中显式传入同一组 `--plan ID`;不使用全项目可变焦点,不把其他计划的阻塞算作自己的门禁。必要前置、证据引用和共享写入约束仍按 cli 读取;写入前重读相关事实并协调共享文件。焦点不明确时先读轻量索引,多个候选不能自动接管。
15
15
 
16
- 首次接手读取地图、当前相关计划及必要契约;恢复只核对当前阶段、实际差异和最近证据。只实施已授权且准入的范围;实质边界变化时调整受影响计划,日常进展随工作同步。事实源与格式见 planning。
16
+ 首次接手读取地图、当前相关计划及必要契约;恢复只核对当前阶段、实际差异和最近证据。只实施已授权且准入的范围;实质边界变化时调整受影响计划,日常进展随工作同步。地图更新当前摘要,过程记录追加到专项计划或证据文档;写地图时读取 planning 的记录与格式约束。
17
17
 
18
18
  按 verification 验证:所有风险级别默认由实施者自验,仅在用户明确要求时安排独立复核;派发时显式传入推理强度,高风险用 `high`,其余用 `medium`。旧策略名和旧记录格式不改变此行为。适用检查通过后完成交付;未解决问题如实保留。交付说明完成结果、必要证据和剩余事项,明确约定的用户体验验收继续有效。
19
19
 
@@ -1,18 +1,8 @@
1
1
  # PLAN_MAP
2
2
 
3
- ## 治理范围
4
-
5
- 本文件只跟踪跨阶段、影响公共契约、依赖真实反馈,或会与其他计划发生关系的计划。普通一次性任务不要加入这里。
3
+ 当前计划状态与证据入口。
6
4
 
7
- ## 文档权责
8
-
9
- - `PLAN_MAP.md` 的 `状态` 是计划级生命周期,`当前阶段` 是阶段身份指针。
10
- - 阶段 N 完成后,阶段 N+1 默认保持 `设计中`;专项计划自己的准入复核通过后才能进入 `待实施`。
11
- - 阶段准入摘要、样本矩阵和独立复核记录只写入专项计划,不复制到本索引。
12
- - 专项计划记录本次行为差异和验收;现行契约优先引用已有 Schema/OpenAPI,无合适来源且需长期维护时才按需建立 `docs/specs/*.md`。同一事实不重复维护。
13
- - 专项计划可在当前阶段声明阶段证据和追加式实施/验证记录;完成快照默认保持旧 JSON 兼容格式,发布/合规场景再显式使用 purpose 关系。
14
- - `--drift`/`--pre-commit` 的关闭窗口只在已完成计划自身也发生变更时覆盖该计划自身和显式阶段证据,不复用已完成计划的完整影响范围。
15
- - 计划索引固定分为 `未完成`、`已完成`、`已废弃` 三张表;`已替代`、`已合并`等不再推进的终态归入 `已废弃` 表,但保留真实状态值。
5
+ [未完成](#未完成) · [已完成](#已完成) · [已废弃](#已废弃) · [当前阻塞项](#当前阻塞项)
16
6
 
17
7
  ## 计划索引
18
8
 
@@ -20,29 +10,51 @@
20
10
 
21
11
  | 计划 | 状态 | 当前阶段 | 最后更新 | 依赖 | 证据 |
22
12
  |---|---|---|---|---|---|
23
- | [example-plan](plans/YYYYMMDD/example-plan.md) | 候选 | 阶段 0 | <YYYY-MM-DD> | - | - |
13
+ {{unfinished_row}}
24
14
 
25
15
  ### 已完成
26
16
 
27
17
  | 计划 | 状态 | 当前阶段 | 最后更新 | 依赖 | 证据 |
28
18
  |---|---|---|---|---|---|
19
+ {{completed_row}}
29
20
 
30
21
  ### 已废弃
31
22
 
32
23
  | 计划 | 状态 | 当前阶段 | 最后更新 | 依赖 | 证据 |
33
24
  |---|---|---|---|---|---|
25
+ {{deprecated_row}}
34
26
 
35
27
  允许状态:`候选`、`设计中`、`待实施`、`实施中`、`已完成`、`已替代`、`已合并`、`已废弃`。
36
28
 
29
+ ## 治理范围
30
+
31
+ 本文件只跟踪跨阶段、影响公共契约、依赖真实反馈,或会与其他计划发生关系的计划。普通一次性任务不要加入这里。
32
+
33
+ ## 文档权责
34
+
35
+ - `PLAN_MAP.md` 的 `状态` 是计划级生命周期,`当前阶段` 是阶段身份指针。
36
+ - 阶段 N 完成后,阶段 N+1 默认保持 `设计中`;专项计划自己的准入复核通过后才能进入 `待实施`。
37
+ - 地图只更新当前摘要和有效证据入口;实施、部署、测试和反馈详情写入所属计划或报告,不在页首、索引或单元格内追加日志。
38
+ - 阶段准入摘要、样本矩阵和复核记录只写入专项计划,不复制到本索引。
39
+ - 已有草案、历史设计和临时分析不再作为规范事实源;后续新规范默认进入当前计划或适用契约,旧记录保留为背景。
40
+ - 专项计划记录本次行为差异和验收;现行契约优先引用已有 Schema/OpenAPI,无合适来源且需长期维护时才按需建立 `docs/specs/*.md`。同一事实不重复维护。
41
+ - 专项计划可在当前阶段声明阶段证据和追加式实施/验证记录;完成快照默认保持旧 JSON 兼容格式,发布/合规场景再显式使用 purpose 关系。
42
+ - `--drift`/`--pre-commit` 的关闭窗口只在已完成计划自身也发生变更时覆盖该计划自身和显式阶段证据,不复用已完成计划的完整影响范围。
43
+ - 计划索引固定分为 `未完成`、`已完成`、`已废弃` 三张表;`已替代`、`已合并`等不再推进的终态归入 `已废弃` 表,但保留真实状态值。
44
+
45
+ 记录、归属与历史整理按所选 skill 的 planning 参考执行;表格须保持“分类标题 → 表头 → 分隔行 → 连续数据行”,不在表头前或表外追加数据行。历史页仅按需建立,不作为日常日志或第二份状态索引。
46
+
37
47
  ## 推荐顺序
38
48
 
39
- 1. `example-plan`
49
+ 只列仍需推进的事项及下一步依据,完成过程留在所属计划。
50
+
51
+ 1. `{{plan_slug}}`
40
52
 
41
53
  ## 依赖关系
42
54
 
43
55
  | 计划 | 依赖 | 原因 |
44
56
  |---|---|---|
45
- | example-plan | - | - |
57
+ | {{plan_slug}} | - | - |
46
58
 
47
59
  ## 替代、合并和废弃
48
60
 
@@ -58,6 +70,8 @@
58
70
 
59
71
  ## 完成证据
60
72
 
73
+ 只保留结论及有效证据入口;完整历史见所属计划。
74
+
61
75
  | 计划 | 阶段 | 证据 |
62
76
  |---|---|---|
63
77
  | - | - | - |
@@ -26,6 +26,12 @@
26
26
 
27
27
  仅更新计划格式不等于更新项目入口。已有项目在任务交接、没有进行中的实施或复核时更新受管入口;升级不批量回写历史,旧策略名和六列格式按 verification 解释为默认自验,保留真实历史发现。安装副本与仓库开发规则有差异时说明本次选用来源,不能因版本号相同就混读正文。
28
28
 
29
+ 项目受管入口只保留规范发现、授权、计划定位和必要CLI约束;复核策略、阶段门及记录细节引用同源 verification/planning/cli,不在入口重复维护。
30
+
31
+ 升级分三层:npm 安装更新 CLI 与包内资源,`setup` 更新用户目录 skill,项目的 `AGENTS.md` 则由 `init --update-agent-rules-only` 单独刷新。前两步不会自动更新各项目。已有地图的 `check`(含聚焦/严格模式)会只读对比 AGENTS 受管块与所用工具自带规则:`AGENT_RULES_DRIFT` 表示内容不同,可能过期或含定制,核对后可执行诊断给出的刷新命令;仅忽略换行方式和行尾空白,受管块外自定义内容不参与比较。无文件/无受管标记时跳过,代码围栏中的示例标记不算入口。
32
+
33
+ 受管标记缺失配对、重复或倒序报告 `AGENT_RULES_INVALID`,先人工核对;显式入口刷新也在写入前拒绝这些损坏标记,并保留围栏中的示例及受管块外原文。读取失败、非 UTF-8、非普通文件或符号链接报告 `AGENT_RULES_UNAVAILABLE`,明确未完成核对。三类诊断均为项目级 advisory/WARNING,严格模式也不提升为业务错误,不改变 workset 的阶段判断或自动覆盖文件;原有业务错误照常失败。复制到项目的单文件 checker 只能比较其自身版本规则,不能发现线上最新版本。
34
+
29
35
  ## 初始化、模板和检查脚本
30
36
 
31
37
  - `../assets/PLAN_MAP.template.md`:治理入口模板。
@@ -50,6 +56,8 @@ plan-governance-cli init \
50
56
 
51
57
  新计划仅允许 候选/设计中,phase 必须是阶段身份。初始化先预检全部可预测目标冲突和模式参数,再执行 `git init` 和写入;已有 `.git/` 时跳过。升级/入口-only/迁移模式拒绝无关参数。
52
58
 
59
+ 地图正文只维护在同包 `assets/PLAN_MAP.template.md`,初始化器渲染日期路径、计划行和分类占位符;模板缺失、空、占位符错误或基本索引结构损坏时,在任何项目写入前失败,不回退内嵌旧模板。该能力不重排已有地图,入口-only/升级模式也不依赖地图模板。
60
+
53
61
  只更新已有项目的代理规则,不修改 `docs/`:
54
62
 
55
63
  ```bash
@@ -40,6 +40,18 @@
40
40
 
41
41
  同一事实保留一个权威来源,其他文档引用。决策或结果改变后,同步地图及实际受影响的索引/引用;同一组已收敛变化可合并同步,不为每次保存增加流程。用 `rg` 定向检查相关计划名、关键字段和链接,不重扫无关历史。
42
42
 
43
+ ## 地图记录与文档归属
44
+
45
+ `PLAN_MAP.md` 是当前状态入口。同步地图是更新对应摘要和链接,不是在页首、表格之间或证据单元格中不断追加进展。总路线图、优先级计划和其他索引也只维护顺序、状态摘要及专项链接。
46
+
47
+ - **阅读顺序**:正文以唯一一级标题开头,随后可放简短用途说明和导航,再进入 `## 计划索引`;治理说明、推荐顺序、关系、阻塞及证据章节放在索引之后。索引内只保留三张分类表,不穿插日期日志、部署步骤或验收报告。
48
+ - **当前摘要**:每行说明当前阶段结果、仍有效的阻塞或待验收边界,并链接支持该状态的有效证据。通常一句摘要及一至三个入口足够;证据多时链接专项计划的汇总章节,不逐次累加发布、构建和测试链接。更新时替换过期摘要,不能让“待验证”和“已通过”在当前视图中无区分地并存。
49
+ - **过程留存**:实施、部署、测试和反馈详情写到所属计划的追加式记录或相关证据文档。历史记录保留当时的日期、范围、结论和失败;后续变化追加澄清,并更新当前摘要。地图的完成证据区只做结论入口,不复制整份报告;推荐顺序只列仍需推进的事项。
50
+ - **阻塞收敛**:地图的当前阻塞区保留仍有效的问题、影响和解除条件。已解决项的发现、修复与验证保存在所属计划后,可从当前阻塞区移除;不能靠删行、压缩摘要或归档解除真实问题。会影响后续工作的延后项仍须可定位。
51
+ - **归属登记**:新增或发现与当前工作有关的 spec、报告或草案,先在所属计划补链接;确属独立且需要持续协调的未完成工作,再登记计划并归入未完成。普通一次性记录可由现有文档入口引用,不为每个附件另建计划。不仅凭无入链认定孤儿,也不能仅凭未完成表为空宣称所有工作已完成。
52
+
53
+ 存量整理按本次涉及范围进行,不因规则更新自动搬迁所有旧文档。地图中已有的长篇历史优先归入所属计划或已有报告;确需整体保留跨计划历史时,可使用项目已有历史页或按需创建 `docs/PLAN_MAP_HISTORY.md`,注明归档日期、来源、当前状态入口并保留往返链接。历史页不继续追加日常进展,也不成为第二份计划索引。归档前核对未完成事项、未解决发现、阶段关系与依赖是否仍在现行入口可达;移动后修复相对链接及旧锚点的入链,不能只把旧问题藏到历史页。
54
+
43
55
  ## 草案和历史文档处理
44
56
 
45
57
  历史草案、旧计划与临时分析保留为背景,不承载新规范。新事实写入当前计划或适用契约;用户明确指定的文档位置优先。已替代内容通过链接说明关系,避免重复改写历史。
@@ -77,3 +89,9 @@
77
89
  |---|---|---|---|---|---|
78
90
 
79
91
  `最后更新` 使用 YYYY-MM-DD,在状态、阶段、依赖、范围、阻塞或验证结果变化时同步;不从文件时间或 Git 推断。`check --stale-days [N]` 可提示活跃计划停滞,默认 10 天,不自动改变状态。
92
+
93
+ ### 索引写入自查
94
+
95
+ 每张分类表依次为分类标题、六列表头、分隔行、连续数据行。表头前不得出现数据行;不以空行或说明段落截断数据区后继续追加无表头行。单元格内容避免使用原始竖线,防止渲染器与现有简单分列解析器将其误作新列。状态改变时移动原行,不保留旧分类副本;空分类只留表头与分隔行,不加虚构计划或占位数据行。
96
+
97
+ 写入后自查正文顺序、三表归类与唯一性、表格连续性、摘要是否已更新、证据及归档链接是否可达,并查看实际 Markdown 结构或渲染。现有 `check` 能检查部分身份、字段与一致性,但不会完整检查页面顺序、孤立表格行、日志堆积、所有链接或文档归属;退出 0 不能替代上述检查。这些记录约束适用于本次新建或更新内容,未触及的存量问题按实际范围记录,不用全仓整理阻塞无关任务。
@@ -5,6 +5,8 @@ import json
5
5
  import posixpath
6
6
  import re
7
7
  import secrets
8
+ import shlex
9
+ import stat
8
10
  import subprocess
9
11
  import sys
10
12
  from datetime import date, datetime, timezone
@@ -33,6 +35,21 @@ ATTESTATION_REVIEW_STATUSES = {"current", "superseded", "needs_review"}
33
35
  ATTESTATION_SNAPSHOT_RE = re.compile(r"^\d{8}T\d{6}Z-[0-9a-f]{8}$")
34
36
 
35
37
 
38
+ # 与初始化器共用正文;单文件复制的检查器也能核对同版本入口。
39
+ def agent_rules_body():
40
+ return """## 计划治理
41
+
42
+ 处理已有计划或需要持续协调的工作时,读取实际发现的 `plan-governance` skill;开发或审查该 skill 自身时使用仓库源及对应 CLI。规范来源一次选定后复用,按当前动作读取同源 references。
43
+
44
+ 只有 CLI 可用时,以 `plan-governance-cli guide` 为入口,再按需读取 `guide planning`、`guide verification` 或 `guide cli`。所需规则或能力不可用时说明缺口,不自动安装,不拼接不同版本。
45
+
46
+ - 按用户授权和当前计划范围推进;未解决问题如实保留,流程建议不扩大实施或外部操作授权。阶段准入、复核方式、验证与适用用户验收统一按所选规范的 verification 执行。
47
+ - 从 `docs/PLAN_MAP.md` 定位相关计划、当前阶段、依赖和证据,再按索引读取必要文档;恢复先核对当前事实与实际差异。文档职责、记录格式和状态同步统一按 planning 执行。
48
+ - 多计划/多会话并行时,在 check/workset/hook 中显式传入本会话的 `--plan ID`;必要前置、共享写入及写入前复核按 cli 执行,不设置全项目可变焦点。
49
+ - 完成时运行 `plan-governance-cli check .`;准入/发布使用 `--strict-readiness`。机械检查不替代实际验证和用户验收。
50
+ """
51
+
52
+
36
53
  def fail(errors, message):
37
54
  errors.append(message)
38
55
 
@@ -41,6 +58,51 @@ def warn(warnings, message):
41
58
  warnings.append(message)
42
59
 
43
60
 
61
+ def agent_rules_markers(text, begin="<!-- plan-governance:start -->", end="<!-- plan-governance:end -->"):
62
+ """读取与更新共用边界;位置保留原换行字节,忽略代码围栏示例。"""
63
+ visible = mask_fenced_code(text)
64
+ starts = list(re.finditer(rf"^{re.escape(begin)}(?=[ \t]*\r?$)", visible, re.MULTILINE))
65
+ ends = list(re.finditer(rf"^{re.escape(end)}(?=[ \t]*\r?$)", visible, re.MULTILINE))
66
+ if not starts and not ends:
67
+ return None
68
+ if len(starts) != 1 or len(ends) != 1 or starts[0].end() >= ends[0].start():
69
+ raise ValueError("AGENTS.md 治理受管标记缺失配对、重复或倒序;请先人工核对")
70
+ return starts[0], ends[0]
71
+
72
+
73
+ def agent_rules_issue(root):
74
+ """仅核对受管入口;返回项目级建议,不推断业务准入或自动覆盖。"""
75
+ path = root / "AGENTS.md"
76
+ try:
77
+ info = path.lstat()
78
+ except FileNotFoundError:
79
+ return None
80
+ except OSError:
81
+ return "AGENT_RULES_UNAVAILABLE", "AGENTS.md 无法读取,未完成治理入口核对"
82
+ if not stat.S_ISREG(info.st_mode):
83
+ return "AGENT_RULES_UNAVAILABLE", "AGENTS.md 不是普通文件(含符号链接),未完成治理入口核对"
84
+ try:
85
+ text = path.read_text(encoding="utf-8")
86
+ except (OSError, UnicodeError):
87
+ return "AGENT_RULES_UNAVAILABLE", "AGENTS.md 无法读取为 UTF-8,未完成治理入口核对"
88
+ try:
89
+ markers = agent_rules_markers(text)
90
+ except ValueError as exc:
91
+ return "AGENT_RULES_INVALID", f"{exc},未比较规则内容"
92
+ if not markers:
93
+ return None
94
+
95
+ def normalized(value):
96
+ return "\n".join(line.rstrip() for line in value.strip().splitlines())
97
+
98
+ start, end = markers
99
+ actual = text[start.end():end.start()]
100
+ if normalized(actual) != normalized(agent_rules_body()):
101
+ command = f"plan-governance-cli init --root {shlex.quote(str(root))} --update-agent-rules-only"
102
+ return "AGENT_RULES_DRIFT", f"AGENTS.md 治理受管块与当前工具不同,可能过期或含定制;核对差异后可执行 `{command}`。不会自动覆盖"
103
+ return None
104
+
105
+
44
106
  def read_utf8(path, errors):
45
107
  try:
46
108
  return path.read_text(encoding="utf-8")
@@ -2998,6 +3060,12 @@ def main(argv=None):
2998
3060
  readiness_issue(warnings, errors, args.strict_readiness or bool(args.plan), "未找到 docs/PLAN_MAP.md;当前仓库尚未初始化计划治理")
2999
3061
  return finish_check(args, records, reports=reports, not_applicable=not errors)
3000
3062
 
3063
+ issue = agent_rules_issue(root)
3064
+ if issue:
3065
+ code, message = issue
3066
+ warnings.context(category="advisory", code=code, path="AGENTS.md", section="计划治理")
3067
+ warnings.append(message)
3068
+ warnings.context()
3001
3069
  text = read_utf8(plan_map, errors)
3002
3070
  _, blocker_index_issues = map_blockers(text)
3003
3071
  for issue in plan_index_issues(text):
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
  import argparse
3
+ import html
3
4
  import importlib.util
4
5
  import re
5
6
  import shutil
@@ -59,21 +60,7 @@ def init_git(root):
59
60
 
60
61
 
61
62
  def agent_rules_body():
62
- return """## 计划治理
63
-
64
- 本项目使用轻量计划治理。处理已有计划或需要持续协调的工作时,读取实际发现的 `plan-governance` skill;开发或审查该 skill 自身时使用仓库源及对应 CLI。规范来源一次选定后复用,按当前动作读取同源 references。
65
-
66
- 只有 CLI 可用时,运行 `plan-governance-cli guide` 读取入口,再按需运行 `guide planning`、`guide verification` 或 `guide cli`。两者均不可用或所需能力缺失时说明前提,不自动安装,也不猜测/拼接不同版本的规则。
67
-
68
- 最小执行边界:
69
-
70
- - 普通一次性任务直接处理;已有计划按共享 verification 验证。新旧计划均默认自验,仅在用户明确要求时独立复核;未解决问题继续阻塞。
71
- - 只实施已授权、已准入的当前阶段。每个阶段有自身 Step 0、验证/完成和失败边界;未解决问题不能记为完成,不可用或超时不能冒充已检查。已有授权不重复索取,新增高影响外部动作按实际授权处理。
72
- - 首次读取 `docs/PLAN_MAP.md`、索引登记的相关 `docs/plans/` 计划及适用契约/ADR/migration;恢复先核对当前工作集、最近证据和实际 diff,缺失或冲突再展开。
73
- - 多计划/多会话并行时,每个会话在 check/workset/hook 中显式传入自己的 `--plan ID`;必要前置与共享写入约束仍需读取,写入前重读相关事实,不设置全项目可变焦点。
74
- - 地图维护状态、阶段、关系、阻塞与证据入口;计划记录本次差异及验证,现行契约优先复用 Schema/OpenAPI。新事实不写回历史草案,变化同步地图及相关引用。
75
- - 完成时按共享规范记录实际验证与适用用户验收,运行 `plan-governance-cli check .`;准入/CI/发布显式使用 `--strict-readiness`,机械通过不等于业务验收。
76
- """
63
+ return checker.agent_rules_body()
77
64
 
78
65
 
79
66
  def managed_section(begin, end):
@@ -92,12 +79,10 @@ def update_managed_file(root, filename, section, begin, end):
92
79
 
93
80
  # Keep line endings and trailing whitespace outside the managed block intact.
94
81
  current = target.read_bytes().decode("utf-8")
95
- pattern = re.compile(
96
- rf"{re.escape(begin)}.*?{re.escape(end)}",
97
- re.DOTALL,
98
- )
99
- if pattern.search(current):
100
- updated = pattern.sub(lambda match: section.rstrip(), current)
82
+ markers = checker.agent_rules_markers(current, begin, end)
83
+ if markers:
84
+ start, finish = markers
85
+ updated = current[:start.start()] + section.rstrip() + current[finish.end():]
101
86
  else:
102
87
  newline = "\r\n" if "\r\n" in current else "\n"
103
88
  if not current or current.endswith(newline * 2):
@@ -124,82 +109,54 @@ def update_agent_rules(root):
124
109
  return [update_agents_md(root)]
125
110
 
126
111
 
112
+ def plan_map_template_path():
113
+ return Path(__file__).resolve().parents[1] / "resources" / "skill" / "assets" / "PLAN_MAP.template.md"
114
+
115
+
127
116
  def plan_map_content(plan_slug, title, status, phase, created_on=None):
128
117
  created_on = created_on or date.today()
129
- today = created_on.isoformat()
118
+ template = plan_map_template_path().read_text(encoding="utf-8")
119
+ if not template.startswith("# PLAN_MAP\n") or len(re.findall(r"^# ", template, re.MULTILINE)) != 1:
120
+ raise ValueError("地图模板须以唯一一级标题开始")
121
+ required = {"plan_slug", "unfinished_row", "completed_row", "deprecated_row"}
122
+ tokens = re.findall(r"{{(.*?)}}", template)
123
+ if set(tokens) != required or any(tokens.count(key) != 1 for key in required - {"plan_slug"}):
124
+ raise ValueError("地图模板缺失、重复或包含未知占位符")
125
+ headings = ["## 计划索引", "### 未完成", "### 已完成", "### 已废弃", "## 治理范围"]
126
+ positions = []
127
+ for heading in headings:
128
+ matches = list(re.finditer(rf"^{re.escape(heading)}$", template, re.MULTILINE))
129
+ if len(matches) != 1:
130
+ raise ValueError("地图模板缺少或重复索引分类章节")
131
+ positions.append(matches[0].start())
132
+ if positions != sorted(positions):
133
+ raise ValueError("地图模板索引必须位于治理说明前,分类顺序固定")
134
+
135
+ # 标签不能注入新表格列、换行或 Markdown 链接。
136
+ label = html.escape(" ".join(title.splitlines()), quote=False)
137
+ for char, entity in [("\\", "&#92;"), ("|", "&#124;"), ("[", "&#91;"), ("]", "&#93;")]:
138
+ label = label.replace(char, entity)
130
139
  plan_relative = f"plans/{created_on:%Y%m%d}/{plan_slug}.md"
131
- row = f"| [{title}]({plan_relative}) | {status} | {phase} | {today} | - | - |"
132
- unfinished_row = row if status in {"候选", "设计中", "待实施", "实施中"} else ""
133
- completed_row = row if status == "已完成" else ""
134
- deprecated_row = row if status in {"已废弃", "已替代", "已合并"} else ""
135
- return f"""# PLAN_MAP
136
-
137
- ## 治理范围
138
-
139
- 本文件只跟踪跨阶段、影响公共契约、依赖真实反馈,或会与其他计划发生关系的计划。普通一次性任务不要加入这里。
140
-
141
- ## 文档权责
142
-
143
- - `docs/PLAN_MAP.md` 是状态、依赖、替代/合并/废弃关系、推荐顺序、阻塞项和证据链接的事实源。
144
- - `docs/plans/YYYYMMDD/*.md` 默认记录新计划的行为差异、阶段、Step 0 和验收;旧式 `docs/plans/*.md` 计划继续兼容读取。现行契约优先引用已有 Schema/OpenAPI,无合适来源且需长期维护时才按需建立 `docs/specs/*.md`。同一事实不重复维护。
145
- - 总路线图、优先级计划和索引只记录顺序、状态摘要和专项计划链接,不复制字段级方案、枚举、Step 0 细节或完成定义。
146
- - 当专项计划变化时,必须同步所有引用该计划的路线图、优先级计划或索引。
147
- - 如果同一事实在多个文档中重复,保留一个事实源,其他文档改为链接引用。
148
- - `PLAN_MAP.md` 的 `状态` 是计划级生命周期,`当前阶段` 是阶段身份指针;阶段 N 完成后,阶段 N+1 默认保持 `设计中`。
149
- - 阶段准入摘要、样本矩阵和独立复核记录只写入专项计划,不复制到本索引。
150
- - 启用治理后,已有草案、历史设计、归档计划和临时分析文档默认只作为背景材料,不再作为规范事实源;后续新规范默认进入 `docs/plans/YYYYMMDD/*.md`、ADR、migration、正式 spec 或 `docs/PLAN_MAP.md`。
151
- - 计划索引固定分为 `未完成`、`已完成`、`已废弃` 三张表;`已替代`、`已合并`等不再推进的终态归入 `已废弃` 表,但保留真实状态值。
152
-
153
- ## 计划索引
154
-
155
- ### 未完成
156
-
157
- | 计划 | 状态 | 当前阶段 | 最后更新 | 依赖 | 证据 |
158
- |---|---|---|---|---|---|
159
- {unfinished_row}
160
-
161
- ### 已完成
162
-
163
- | 计划 | 状态 | 当前阶段 | 最后更新 | 依赖 | 证据 |
164
- |---|---|---|---|---|---|
165
- {completed_row}
166
-
167
- ### 已废弃
168
-
169
- | 计划 | 状态 | 当前阶段 | 最后更新 | 依赖 | 证据 |
170
- |---|---|---|---|---|---|
171
- {deprecated_row}
172
-
173
- 允许状态:`候选`、`设计中`、`待实施`、`实施中`、`已完成`、`已替代`、`已合并`、`已废弃`。
174
-
175
- ## 推荐顺序
176
-
177
- 1. `{plan_slug}`
178
-
179
- ## 依赖关系
180
-
181
- | 计划 | 依赖 | 原因 |
182
- |---|---|---|
183
- | {plan_slug} | - | - |
184
-
185
- ## 替代、合并和废弃
186
-
187
- | 计划 | 关系 | 目标 | 原因 |
188
- |---|---|---|---|
189
- | - | - | - | - |
190
-
191
- ## 当前阻塞项
192
-
193
- | 问题 | 推荐方案 | 影响范围 | 是否阻塞当前阶段 | 状态 |
194
- |---|---|---|---|---|
195
- | - | - | - | 否 | 已延后 |
196
-
197
- ## 完成证据
198
-
199
- | 计划 | 阶段 | 证据 |
200
- |---|---|---|
201
- | - | - | - |
202
- """
140
+ row = f"| [{label}]({plan_relative}) | {status} | {phase} | {created_on.isoformat()} | - | - |"
141
+ groups = {"unfinished_row": {"候选", "设计中", "待实施", "实施中"},
142
+ "completed_row": {"已完成"}, "deprecated_row": {"已替代", "已合并", "已废弃"}}
143
+ if status not in VALID_STATUSES:
144
+ raise ValueError(f"计划状态不合法:{status}")
145
+ values = {key: row if status in states else "" for key, states in groups.items()}
146
+ values["plan_slug"] = plan_slug
147
+ rendered = re.sub(r"{{(.*?)}}", lambda match: values[match.group(1)], template)
148
+ header = ["计划", "状态", "当前阶段", "最后更新", "依赖", "证据"]
149
+ for heading, key in zip(headings[1:4], groups):
150
+ section = rendered.split(heading + "\n", 1)[1].split("\n#", 1)[0]
151
+ rows = checker.markdown_table_rows(section)
152
+ expected = 2 if status in groups[key] else 1
153
+ lines = section.strip().splitlines()
154
+ valid_separator = len(lines) >= 2 and re.fullmatch(r"\|(?:\s*:?-{3,}:?\s*\|){6}", lines[1])
155
+ valid_data_row = expected == 1 or (len(lines) >= 3 and lines[2] == row)
156
+ if (not valid_separator or not valid_data_row or len(rows) != expected
157
+ or rows[0] != header or any(len(item) != 6 for item in rows)):
158
+ raise ValueError("地图模板分类表必须保持六列且每个计划仅出现一次")
159
+ return rendered
203
160
 
204
161
 
205
162
  def plan_template_path():
@@ -369,7 +326,7 @@ def main(argv=None):
369
326
  args.update_agents_md, args.update_agent_rules)):
370
327
  target = root / "AGENTS.md"
371
328
  if target.exists():
372
- target.read_text(encoding="utf-8")
329
+ checker.agent_rules_markers(target.read_text(encoding="utf-8"))
373
330
  if args.upgrade_existing:
374
331
  target = root / "scripts/check_plan_governance.py"
375
332
  if target.exists() and not target.is_file():