plan-governance-cli 1.1.2 → 1.1.3

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
@@ -55,12 +55,12 @@ docs/
55
55
  | 文档 | 权威内容 |
56
56
  |---|---|
57
57
  | `docs/PLAN_MAP.md` | 计划索引、状态、依赖、替代/合并/废弃关系、推荐顺序、阻塞项、证据链接 |
58
- | `docs/plans/*.md` | 本次目标、范围、行为差异、阶段、Step 0、验证与完成条件;链接现行契约 |
58
+ | `docs/plans/YYYYMMDD/*.md` | 新计划的目标、范围、行为差异、阶段、Step 0、验证与完成条件;旧式 `docs/plans/*.md` 继续兼容 |
59
59
  | 已有 Schema/OpenAPI 或按需的 `docs/specs/*.md` | 跨迭代的现行契约;优先复用已有来源,文字 spec 补充行为/失败语义,不重复字段定义 |
60
60
  | `docs/adr/*.md` | 关键架构决策、备选方案和后果 |
61
61
  | `docs/migrations/*.md` | 兼容策略、迁移步骤、回滚方式和旧行为保留窗口 |
62
62
 
63
- `docs/plans/*.md` 保持平铺。目录按真实内容增加:`docs/reviews/` 放独立复核,`docs/fixtures/` 放人工样本/回放说明,`tests/fixtures/` 放可执行输入,`docs/attestations/` 放按需快照。init 只创建最小结构;可选 [spec 模板](resources/skill/assets/spec.template.md) 随 skill 分发,不自动创建目标项目 spec 或空目录。
63
+ 新计划按创建时的本地日期存放在 `docs/plans/YYYYMMDD/<plan>.md`;修改时不改变日期目录。根目录旧计划继续可读,工具升级不会自动迁移。用户明确要求迁移存量计划时,单独调用 `plan-governance-migration` skill。其他目录按真实内容增加:`docs/reviews/` 放独立复核,`docs/fixtures/` 放人工样本/回放说明,`tests/fixtures/` 放可执行输入,`docs/attestations/` 放按需快照。init 只创建最小结构;可选 [spec 模板](resources/skill/assets/spec.template.md) 随 skill 分发,不自动创建目标项目 spec 或空目录。
64
64
 
65
65
  ## 任务分流与验收
66
66
 
@@ -85,7 +85,7 @@ docs/
85
85
 
86
86
  启用治理后,已有草案、历史设计、归档计划、临时分析文档等默认只作为背景材料,不再作为规范事实源。
87
87
 
88
- - 新发生的目标、范围、公共契约、字段、Schema、状态语义、阶段、验证方式、完成条件、风险和回滚,应写入 `docs/plans/*.md`、ADR、migration 或正式 spec。
88
+ - 新发生的目标、范围、公共契约、字段、Schema、状态语义、阶段、验证方式、完成条件、风险和回滚,应写入 `docs/plans/YYYYMMDD/*.md`、ADR、migration 或正式 spec。
89
89
  - 计划状态、依赖、替代/合并/废弃关系、推荐顺序、阻塞项和证据链接,应写入或同步 `docs/PLAN_MAP.md`。
90
90
  - 不要为了“保持一致”而修改草案或历史文档;除非用户明确点名该文件,否则草案只在治理文档中作为背景材料引用。
91
91
  - 验收时应搜索并修正“草案为准”“以草案为事实源”“详见草案”等表达。
@@ -110,7 +110,7 @@ plan-governance-cli init \
110
110
  ```text
111
111
  .git/
112
112
  docs/PLAN_MAP.md
113
- docs/plans/api-compat-migration.md
113
+ docs/plans/20260926/api-compat-migration.md
114
114
  AGENTS.md
115
115
  ```
116
116
 
@@ -118,7 +118,7 @@ AGENTS.md
118
118
 
119
119
  如果目标目录还不是 Git 仓库,初始化流程会先执行 `git init`;已有 `.git/` 时会跳过,不重复初始化。
120
120
 
121
- `--update-agent-rules` 会创建或更新 `AGENTS.md` 中带标记的计划治理章节,只写稳定执行规则,不写具体计划内容。具体计划仍以 `docs/PLAN_MAP.md` 和 `docs/plans/*.md` 为准。
121
+ `--update-agent-rules` 会创建或更新 `AGENTS.md` 中带标记的计划治理章节,只写稳定执行规则,不写具体计划内容。具体计划仍以 `docs/PLAN_MAP.md` 中的真实链接为准,新计划默认位于 `docs/plans/YYYYMMDD/`。
122
122
 
123
123
  也可以使用含义更直接的别名:
124
124
 
@@ -134,7 +134,7 @@ AGENTS.md
134
134
 
135
135
  ## 更新已有项目
136
136
 
137
- 如果项目已经有 `docs/PLAN_MAP.md` 和 `docs/plans/*.md`,不要重新初始化计划文档。只更新代理执行规则时运行:
137
+ 如果项目已经有 `docs/PLAN_MAP.md` 和计划文件,不要重新初始化计划文档。只更新代理执行规则时运行:
138
138
 
139
139
  ```bash
140
140
  plan-governance-cli init \
@@ -156,7 +156,7 @@ plan-governance-cli init \
156
156
 
157
157
  - 按兼容模式覆盖更新项目中的 `scripts/check_plan_governance.py`
158
158
  - 创建或更新 `AGENTS.md` 中带标记的计划治理章节,既有其他代理入口保持原样
159
- - 保留已有 `docs/PLAN_MAP.md` 和 `docs/plans/*.md`
159
+ - 保留已有 `docs/PLAN_MAP.md` 和所有现存计划文件;兼容读取平铺和日期目录计划
160
160
  - 提示缺失的治理文档
161
161
 
162
162
  新项目和日常升级不需要使用该兼容模式;优先使用全局 npm CLI。
@@ -260,7 +260,7 @@ plan-governance-cli setup --target codex --dry-run
260
260
  plan-governance-cli setup --target codex --force
261
261
  ```
262
262
 
263
- `setup` 只向 Codex 同步 npm 包 manifest 指定的 skill、代理元数据和模板文件,必须恰好指定一次 `--target codex`。旧版或重复 target、混合 help、缺失 target 都会在写入前失败;目标之外的用户目录不会被读取、修改或删除。默认先用 `--dry-run` 查看差异,目标文件有本地修改时不会静默覆盖。检查器、初始化器和 hook runtime 由 npm 包内部调用,不复制到项目或 skill 目录;项目初始化器只维护 `AGENTS.md`,当前实现不自动安装 hook 配置。
263
+ `setup` 只向 Codex 同步 npm 包 manifest 指定的主治理 skill、独立迁移 skill、代理元数据和模板文件,必须恰好指定一次 `--target codex`。旧版或重复 target、混合 help、缺失 target 都会在写入前失败;目标之外的用户目录不会被读取、修改或删除。默认先用 `--dry-run` 查看差异,目标文件有本地修改时不会静默覆盖。使用 `--destination DIR` 指定主 skill 临时目标时,迁移 skill 会写到同一 skills 目录下的 `plan-governance-migration`。检查器、初始化器和 hook runtime 由 npm 包内部调用,不复制到项目或 skill 目录;项目初始化器只维护 `AGENTS.md`,当前实现不自动安装 hook 配置。
264
264
 
265
265
  初始化项目时使用包内初始化器:
266
266
 
@@ -299,7 +299,7 @@ plan-governance-cli init --root . --migrate-plan-map-last-updated --last-updated
299
299
  当前检查项包括:
300
300
 
301
301
  - `PLAN_MAP.md` 引用的计划文件是否存在。
302
- - `docs/plans/*.md` 是否存在未登记到 `PLAN_MAP.md` 的孤立计划;孤立计划以 `WARNING` 提示,不阻断检查。
302
+ - 根目录平铺计划及 `docs/plans/YYYYMMDD/*.md` 是否存在未登记到 `PLAN_MAP.md` 的孤立计划;孤立计划以 `WARNING` 提示,不阻断检查。
303
303
  - 计划状态是否合法。
304
304
  - 计划依赖是否存在环。
305
305
  - 活跃计划正文中的计划引用是否与 `PLAN_MAP.md` 依赖列一致;不一致以 `WARNING` 提示。
@@ -336,7 +336,7 @@ plan-governance-cli check . --check-attestations --strict-readiness
336
336
 
337
337
  这是可选模式:快照绑定显式文件、目标与必要上游计划的实际内容,以及相关地图行。无关计划日期、地图注释或 HEAD 变化不触发复核;相关内容变化、删除或绑定无效会显示 `needs_review`,默认警告,显式严格检查失败。旧快照继续按原有全文 hash 规则检查,不自动回填;CI/发布不会自动开启证据检查。
338
338
 
339
- 文件须为仓库内普通文件,拒绝重复、目录、glob、越界或 symlink。它记录工作树字节,HEAD 只作定位;未列出的新文件不会自动被发现,hash 不代表独立验收。完成新的复核后,用带绑定的新快照和 `--supersedes <旧快照相对路径>` 替代;旧格式后继不能解除绑定快照的复核责任。具体投影、兼容和失败边界见[范围绑定契约](docs/plans/iterative-governance-reliability.md#阶段-4-行为契约)。
339
+ 文件须为仓库内普通文件,拒绝重复、目录、glob、越界或 symlink。它记录工作树字节,HEAD 只作定位;未列出的新文件不会自动被发现,hash 不代表独立验收。完成新的复核后,用带绑定的新快照和 `--supersedes <旧快照相对路径>` 替代;旧格式后继不能解除绑定快照的复核责任。具体投影、兼容和失败边界见[范围绑定契约](docs/plans/20260906/iterative-governance-reliability.md#阶段-4-行为契约)。
340
340
 
341
341
  ## 测试覆盖率
342
342
 
@@ -396,7 +396,7 @@ $plan-governance 为这个项目初始化计划治理,计划名是 api-compat-
396
396
  或继续推进已有计划:
397
397
 
398
398
  ```text
399
- $plan-governance 继续推进 docs/plans/api-compat-migration.md 的当前阶段,完成后记录验证证据。
399
+ $plan-governance 继续推进 docs/plans/20260926/api-compat-migration.md 的当前阶段,完成后记录验证证据。
400
400
  ```
401
401
 
402
402
  skill 只负责让 Codex 按流程工作;真实计划状态仍然保存在项目仓库的 `docs/` 中。
@@ -10,7 +10,7 @@ import {
10
10
  writeFileSync,
11
11
  } from "node:fs";
12
12
  import { homedir, tmpdir } from "node:os";
13
- import { dirname, join, relative, resolve } from "node:path";
13
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
14
14
  import { spawnSync } from "node:child_process";
15
15
  import { fileURLToPath } from "node:url";
16
16
 
@@ -80,7 +80,7 @@ function loadManifest() {
80
80
 
81
81
  function parseSetupArgs(args) {
82
82
  if (args.length === 1 && ["-h", "--help"].includes(args[0])) {
83
- console.log("用法:plan-governance-cli setup --target codex [--destination DIR] [--dry-run] [--force]");
83
+ console.log("用法:plan-governance-cli setup --target codex [--destination DIR] [--dry-run] [--force]\n同步主治理 skill 和独立计划迁移 skill;自定义 destination 时,其他 skill 写入同一 skills 目录。");
84
84
  return null;
85
85
  }
86
86
 
@@ -127,9 +127,8 @@ function targetRoot(manifest, options) {
127
127
  };
128
128
  }
129
129
 
130
- function resourceFiles(manifest) {
131
- const skillPrefix = "resources/skill/";
132
- return manifest.skill.files.map((source) => {
130
+ function resourceFiles(skill, skillPrefix) {
131
+ return skill.files.map((source) => {
133
132
  if (!source.startsWith(skillPrefix)) {
134
133
  throw new Error(`skill 资源不在受支持的目录中:${source}`);
135
134
  }
@@ -140,31 +139,69 @@ function resourceFiles(manifest) {
140
139
  });
141
140
  }
142
141
 
142
+ function pathsOverlap(left, right) {
143
+ const contains = (parent, child) => {
144
+ const childRelative = relative(parent, child);
145
+ return childRelative === ""
146
+ || (childRelative !== ".." && !childRelative.startsWith(`..${sep}`) && !isAbsolute(childRelative));
147
+ };
148
+ return contains(left, right) || contains(right, left);
149
+ }
150
+
143
151
  function setup(args) {
144
152
  const options = parseSetupArgs(args);
145
153
  if (options === null) return 0;
146
154
 
147
155
  let manifest;
148
- let files;
156
+ let skills;
149
157
  try {
150
158
  manifest = loadManifest();
151
- files = resourceFiles(manifest);
159
+ skills = [
160
+ { name: "plan-governance", definition: manifest.skill, prefix: "resources/skill/" },
161
+ ...(manifest.additionalSkills ?? []).map((definition) => ({
162
+ name: definition.name,
163
+ definition,
164
+ prefix: definition.prefix,
165
+ })),
166
+ ];
152
167
  } catch (error) {
153
168
  return fail(error.message);
154
169
  }
155
170
 
156
171
  const plans = [];
157
172
  try {
158
- const target = targetRoot(manifest, options);
159
- for (const file of files) {
160
- accessSync(file.source, constants.R_OK);
161
- const destination = resolve(target.root, file.relative);
162
- const existing = existsSync(destination) ? readFileSync(destination, "utf8") : null;
163
- const sourceContent = readFileSync(file.source, "utf8");
164
- if (existing !== null && existing !== sourceContent && !options.force) {
165
- throw new Error(`目标文件存在本地差异,未覆盖:${destination}(如确认覆盖请加 --force)`);
173
+ const primaryTarget = targetRoot(manifest, options);
174
+ const targets = skills.map((skill) => {
175
+ const configuredTarget = skill.definition.targets.codex;
176
+ return skill.name === "plan-governance"
177
+ ? primaryTarget
178
+ : {
179
+ name: skill.name,
180
+ root: resolve(options.destination
181
+ ? join(dirname(primaryTarget.root), basename(expandTarget(configuredTarget)))
182
+ : expandTarget(configuredTarget)),
183
+ };
184
+ });
185
+ for (let index = 0; index < targets.length; index += 1) {
186
+ for (let other = index + 1; other < targets.length; other += 1) {
187
+ if (pathsOverlap(targets[index].root, targets[other].root)) {
188
+ throw new Error(`setup 的 skill 目标目录重叠:${targets[index].root} 与 ${targets[other].root}`);
189
+ }
190
+ }
191
+ }
192
+ for (let index = 0; index < skills.length; index += 1) {
193
+ const skill = skills[index];
194
+ const target = targets[index];
195
+ for (const file of resourceFiles(skill.definition, skill.prefix)) {
196
+ accessSync(file.source, constants.R_OK);
197
+ const destination = resolve(target.root, file.relative);
198
+ const existing = existsSync(destination) ? readFileSync(destination, "utf8") : null;
199
+ const sourceContent = readFileSync(file.source, "utf8");
200
+ if (existing !== null && existing !== sourceContent && !options.force) {
201
+ throw new Error(`目标文件存在本地差异,未覆盖:${destination}(如确认覆盖请加 --force)`);
202
+ }
203
+ plans.push({ target: target.name, destination, existing, sourceContent });
166
204
  }
167
- plans.push({ target: target.name, destination, existing, sourceContent });
168
205
  }
169
206
  } catch (error) {
170
207
  return fail(error.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plan-governance-cli",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
4
4
  "description": "统一入口的计划治理检查 CLI",
5
5
  "private": false,
6
6
  "type": "module",
@@ -11,6 +11,7 @@
11
11
  "bin/plan-governance-cli.mjs",
12
12
  "resources/manifest.json",
13
13
  "resources/skill",
14
+ "resources/migration-skill",
14
15
  "scripts/check_plan_governance.py",
15
16
  "scripts/init_plan_governance.py",
16
17
  "scripts/plan_governance_hook.py",
@@ -17,6 +17,19 @@
17
17
  "resources/skill/references/cli.md"
18
18
  ]
19
19
  },
20
+ "additionalSkills": [
21
+ {
22
+ "name": "plan-governance-migration",
23
+ "targets": {
24
+ "codex": "~/.agents/skills/plan-governance-migration"
25
+ },
26
+ "prefix": "resources/migration-skill/",
27
+ "files": [
28
+ "resources/migration-skill/SKILL.md",
29
+ "resources/migration-skill/agents/openai.yaml"
30
+ ]
31
+ }
32
+ ],
20
33
  "runtime": [
21
34
  "scripts/check_plan_governance.py",
22
35
  "scripts/init_plan_governance.py",
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: plan-governance-migration
3
+ description: Migrate existing flat docs/plans/*.md files into date directories when the user explicitly requests migration, preserving plan history and repairing references.
4
+ ---
5
+
6
+ # 计划日期目录迁移
7
+
8
+ 仅在用户明确要求迁移存量计划时使用。计划目录存在或主治理 skill 已升级,都不构成迁移授权。日常治理只读取索引路径,不主动搬动计划。
9
+
10
+ 迁移前读取项目 `AGENTS.md`、`docs/PLAN_MAP.md`、受影响计划和适用的 plan-governance verification 规则。先检查工作区差异、地图登记、所有平铺计划、目标路径占用、Markdown 引用和 attestation;建立源路径、目标路径、创建日期及引用影响清单,并先报告无法确定的日期或歧义。日期优先取 Git 首次添加记录;仅当正文明确记录创建日期时才可回退使用该日期。不得用修改时间或最后更新日期猜测。路径冲突、未登记文件、无法解析的日期或会丢失的本地修改未解决前,不移动文件。
11
+
12
+ 迁移时保留计划内容、文件名、身份、状态、阶段和历史证据。按核对后的清单移动到 `docs/plans/YYYYMMDD/<slug>.md`,同步 `PLAN_MAP.md`、指向这些计划的文档链接,以及因源文件层级变化而失效的相对 Markdown 链接。保留锚点;计划链接按原目标重新计算相对路径。代码块中的示例路径不视为引用。不要改写旧 attestation;检查其计划路径、地图 hash 和替代关系。若不能用项目 CLI 建立可信后继快照,保留旧快照并报告 `needs_review`,不要手改 JSON 来制造通过状态。
13
+
14
+ 每批迁移都先保存可逆的源/目标映射。完成后确认源路径已清空、目标文件各出现一次、索引路径准确、所有受影响本地链接可达,且 checker、workset 和 attestation 检查符合项目规则。任一检查失败时停止,并依据映射恢复文件和引用;报告实际恢复范围及仍未解决的项目。只处理用户授权的仓库与计划范围,不安装依赖、不发布、不触碰其他项目。
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Plan Governance Migration"
3
+ short_description: "按创建日期迁移存量计划并修复文档引用"
4
+ default_prompt: "使用 $plan-governance-migration 将这些存量计划迁入日期目录,先核对迁移清单和日期来源,再按授权执行并验证引用。"
@@ -23,4 +23,4 @@ description: 维护已有项目计划,或为需要跨阶段、跨会话协调
23
23
  | 阶段准入、风险判断、验证或完成 | [verification](references/verification.md) |
24
24
  | 查询工作集、运行检查、初始化或分发 | [cli](references/cli.md) |
25
25
 
26
- 开发或审查本 skill 时使用仓库源及对应 CLI;其他项目使用实际发现的安装版,参考按所选 skill 目录解析。仅 CLI 可用时用 `guide` 和对应主题读取。来源一次选定后复用,只有内容变化或能力不符时重新核对;不要混读副本。缺少所需能力时说明具体缺口,继续不依赖它的工作,安装与升级按已有授权处理。
26
+ 开发或审查本 skill 时使用仓库源及对应 CLI;其他项目使用实际发现的安装版,参考按所选 skill 目录解析。仅 CLI 可用时用 `guide` 和对应主题读取。来源一次选定后复用,只有内容变化或能力不符时重新核对;不要混读副本。缺少所需能力时说明具体缺口,继续不依赖它的工作,安装与升级按已有授权处理。已有计划批量迁移由独立 `plan-governance-migration` skill 在用户明确要求时执行,日常治理不会自动搬迁计划。
@@ -20,7 +20,7 @@
20
20
 
21
21
  | 计划 | 状态 | 当前阶段 | 最后更新 | 依赖 | 证据 |
22
22
  |---|---|---|---|---|---|
23
- | [example-plan](plans/example-plan.md) | 候选 | 阶段 0 | <YYYY-MM-DD> | - | - |
23
+ | [example-plan](plans/YYYYMMDD/example-plan.md) | 候选 | 阶段 0 | <YYYY-MM-DD> | - | - |
24
24
 
25
25
  ### 已完成
26
26
 
@@ -18,7 +18,7 @@
18
18
 
19
19
  ## 分发与资源
20
20
 
21
- SKILL、三个 references、agents 元数据与 assets 由同一 npm 包的 manifest 分发。`plan-governance-cli setup --target codex --dry-run` 查看 Codex 同步差异;实际覆盖需既有用户授权并按 setup 冲突提示处理,不自动清理目标之外的用户目录。setup 只支持 Codex 单目标同步;项目初始化器只维护 `AGENTS.md`。项目升级不迁移旧计划,受管块外内容必须保持。
21
+ 主治理 skill 和独立 migration skill 由同一 npm 包的 manifest 分别分发。`plan-governance-cli setup --target codex --dry-run` 查看两个 skill 的同步差异;`--destination DIR` 将主 skill 放入指定目录,并将迁移 skill 放在其同级目录。实际覆盖需既有用户授权并按 setup 冲突提示处理,不自动清理目标之外的用户目录。setup 只支持 Codex 单目标同步;项目初始化器只维护 `AGENTS.md`。项目升级不迁移旧计划,受管块外内容必须保持。
22
22
 
23
23
  仅更新计划格式不等于更新项目入口。已有项目在任务交接、没有进行中的实施或复核时更新受管入口;升级不批量回写历史,旧策略名和六列格式按 verification 解释为统一单次行为。安装副本与仓库开发规则有差异时说明本次选用来源,不能因版本号相同就混读正文。
24
24
 
@@ -28,7 +28,7 @@ SKILL、三个 references、agents 元数据与 assets 由同一 npm 包的 mani
28
28
  - `../assets/plan.template.md`:计划文档模板。
29
29
  - `../assets/adr.template.md`:可选 ADR 模板。
30
30
  - `../assets/migration.template.md`:可选迁移模板。
31
- - `scripts/init_plan_governance.py`:初始化 Git 仓库、`docs/PLAN_MAP.md` 和首个 `docs/plans/<plan>.md`;也可升级已有项目的代理规则和检查脚本。
31
+ - `scripts/init_plan_governance.py`:初始化 Git 仓库、`docs/PLAN_MAP.md` 和首个 `docs/plans/YYYYMMDD/<plan>.md`;检查器仍读取旧式根目录计划。也可升级已有项目的代理规则和检查脚本。
32
32
  - `scripts/check_plan_governance.py`:检查状态、最后更新、计划链接、依赖、阻塞项、完成证据、测试覆盖率证据,并提供可选漂移、pre-commit、停滞和 attestation 生命周期检查。
33
33
  - `scripts/plan_governance_hook.py`:只读 hook runtime,可由项目级 Agent hooks 手动调用;只输出短提示和检查结果,不修改治理文档、不更新状态、不安装 hooks。
34
34
 
@@ -15,11 +15,13 @@
15
15
  | 文档 | 职责 |
16
16
  |---|---|
17
17
  | `docs/PLAN_MAP.md` | 计划状态、阶段、关系、阻塞及证据入口 |
18
- | `docs/plans/*.md` | 本次目标、范围、决策、差异与验证证据,计划文件平铺 |
18
+ | `docs/plans/YYYYMMDD/*.md` | 新计划的目标、范围、决策、差异与验证证据;旧式 `docs/plans/*.md` 计划继续兼容读取 |
19
19
  | Schema/OpenAPI 或 `docs/specs/` | 现行公共契约,优先复用已有来源 |
20
20
  | `docs/adr/`、`docs/migrations/` | 确有需要时记录长期架构决策或迁移步骤 |
21
21
  | `docs/reviews/`、`docs/fixtures/` | 必要复核及可复验样本,代码 fixture 放 `tests/fixtures/` |
22
22
 
23
+ 新建计划使用 `docs/plans/YYYYMMDD/<plan>.md`,日期是创建时的本地日期,之后更新不改变目录日期。`PLAN_MAP.md` 链接真实路径。根目录中的旧式 `docs/plans/<plan>.md` 仍受支持;工具升级不会自动迁移它们。已有计划要批量迁移时,只有用户明确要求才调用独立 `plan-governance-migration` skill。
24
+
23
25
  ## 多文档同步规则
24
26
 
25
27
  同一事实保留一个权威来源,其他文档引用。决策或结果改变后,同步地图及实际受影响的索引/引用;同一组已收敛变化可合并同步,不为每次保存增加流程。用 `rg` 定向检查相关计划名、关键字段和链接,不重扫无关历史。
@@ -793,6 +793,27 @@ def extract_plan_link(cell):
793
793
  return None
794
794
 
795
795
 
796
+ def plan_link_issue(link):
797
+ """Validate the canonical YYYYMMDD bucket while preserving legacy/custom paths."""
798
+ parts = Path(link).parts
799
+ if len(parts) < 2 or parts[0] != "plans":
800
+ return f"计划路径必须位于 docs/plans 下:{link}"
801
+ bucket = parts[1]
802
+ if len(parts) == 2 and bucket.endswith(".md"):
803
+ return None
804
+ if not bucket[:1].isdigit():
805
+ return None
806
+ if not re.fullmatch(r"\d{8}", bucket):
807
+ return f"计划日期目录必须为 YYYYMMDD:{link}"
808
+ try:
809
+ datetime.strptime(bucket, "%Y%m%d")
810
+ except ValueError:
811
+ return f"计划日期目录不是有效日期:{link}"
812
+ if len(parts) != 3 or not parts[2].endswith(".md"):
813
+ return f"计划日期目录必须直接包含 Markdown 文件:{link}"
814
+ return None
815
+
816
+
796
817
  def extract_declared_dependencies(depends_cell):
797
818
  return [d.strip("` ") for d in re.split(r",|<br>|、", depends_cell) if d.strip("` -")]
798
819
 
@@ -846,13 +867,19 @@ def extract_plan_references(plan_text, known_plans, current_name):
846
867
  if name in known_plans and name != current_name:
847
868
  references.add(name)
848
869
 
849
- for match in re.finditer(r"(?:docs/)?plans/([A-Za-z0-9\u4e00-\u9fff._-]+)\.md", plan_text):
870
+ for match in re.finditer(
871
+ r"(?:docs/)?plans/(?:\d{8}/)?([A-Za-z0-9\u4e00-\u9fff._-]+)\.md",
872
+ plan_text,
873
+ ):
850
874
  name = Path(match.group(1).strip()).stem
851
875
  if name in known_plans and name != current_name:
852
876
  references.add(name)
853
877
 
854
- for match in re.finditer(r"\]\(([A-Za-z0-9\u4e00-\u9fff._-]+)\.md(?:#[^)]+)?\)", plan_text):
855
- name = Path(match.group(1).strip()).stem
878
+ for match in re.finditer(r"\]\(([^)#]+\.md)(?:#[^)]+)?\)", plan_text):
879
+ target = match.group(1).strip()
880
+ if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", target):
881
+ continue
882
+ name = Path(target).stem
856
883
  if name in known_plans and name != current_name:
857
884
  references.add(name)
858
885
 
@@ -918,9 +945,16 @@ def find_orphan_plans(docs, plans):
918
945
  if not plans_dir.exists():
919
946
  return []
920
947
  indexed_paths = {data["path"].resolve() for data in plans.values()}
948
+ candidates = set(plans_dir.glob("*.md"))
949
+ for bucket in plans_dir.iterdir():
950
+ if not bucket.is_dir() or not bucket.name[:1].isdigit():
951
+ continue
952
+ # Numeric buckets are reserved for dated plans. Include malformed buckets
953
+ # and deeper files so they cannot disappear from orphan diagnostics.
954
+ candidates.update(bucket.rglob("*.md"))
921
955
  return sorted(
922
956
  plan_file
923
- for plan_file in plans_dir.glob("*.md")
957
+ for plan_file in candidates
924
958
  if plan_file.resolve() not in indexed_paths
925
959
  )
926
960
 
@@ -1630,32 +1664,33 @@ def warn_attestation_drift(warnings, root, plans, errors=None, strict=False, rep
1630
1664
  plan_map_path = root / plan_map_path_rel
1631
1665
  if scoped_checks:
1632
1666
  try:
1633
- binding_file(root, plan_path_rel)
1667
+ binding_file(root, plan_path_rel, require_file=plan_path.exists())
1634
1668
  binding_file(root, plan_map_path_rel)
1635
1669
  except (ValueError, OSError) as exc:
1636
1670
  attestation_issue(warnings, errors, strict, f"{path}: 混合快照路径读取预检失败:{exc}")
1637
1671
  continue
1638
- if not plan_path.exists():
1639
- warn(warnings, f"{path}: 快照引用的计划文件不存在:{plan_path}")
1640
- continue
1641
1672
  if not plan_map_path.exists():
1642
1673
  warn(warnings, f"{path}: 快照引用的 PLAN_MAP.md 不存在:{plan_map_path}")
1643
1674
  continue
1644
1675
 
1645
- drifted = False
1676
+ # A moved plan leaves the historical snapshot's recorded path behind.
1677
+ # Keep that legacy record in the supersedes graph; an explicit valid
1678
+ # successor can retire it, while an unretired record remains needs_review.
1679
+ missing_plan = not plan_path.exists()
1680
+ drift_reasons = []
1681
+ if missing_plan:
1682
+ drift_reasons.append(f"快照引用的计划文件不存在:{plan_path}")
1646
1683
  try:
1647
- if sha256_file(plan_path) != attestation.get("plan_sha256"):
1648
- warn(warnings, f"{path}: {plan_name} 计划文件 hash 已变化,需要人工复核")
1649
- drifted = True
1684
+ if not missing_plan and sha256_file(plan_path) != attestation.get("plan_sha256"):
1685
+ drift_reasons.append(f"{plan_name} 计划文件 hash 已变化,需要人工复核")
1650
1686
  if sha256_file(plan_map_path) != attestation.get("plan_map_sha256"):
1651
- warn(warnings, f"{path}: PLAN_MAP.md hash 已变化,需要人工复核")
1652
- drifted = True
1687
+ drift_reasons.append("PLAN_MAP.md hash 已变化,需要人工复核")
1653
1688
  except OSError as exc:
1654
1689
  if not scoped_checks:
1655
1690
  raise
1656
1691
  # 内容不可读不使已存在的替代目标消失,也不阻止后续绑定报告。
1657
1692
  attestation_issue(warnings, errors, strict, f"{path}: 混合快照内容读取失败:{exc}")
1658
- drifted = True
1693
+ drift_reasons.append(f"快照内容读取失败:{exc}")
1659
1694
  records.append(
1660
1695
  {
1661
1696
  "path": path,
@@ -1664,10 +1699,12 @@ def warn_attestation_drift(warnings, root, plans, errors=None, strict=False, rep
1664
1699
  "purpose": purpose,
1665
1700
  "review_status": review_status,
1666
1701
  "supersedes": normalized_supersedes,
1667
- "drifted": drifted,
1702
+ "drifted": bool(drift_reasons),
1668
1703
  "legacy": legacy,
1669
1704
  "invalid_structure": invalid_structure,
1670
1705
  "bound": False,
1706
+ "plan_path": plan_path,
1707
+ "drift_reasons": drift_reasons,
1671
1708
  }
1672
1709
  )
1673
1710
 
@@ -1739,6 +1776,9 @@ def warn_attestation_drift(warnings, root, plans, errors=None, strict=False, rep
1739
1776
  key = (record["plan"], record["purpose"])
1740
1777
  current_by_key.setdefault(key, []).append(record["relative_path"])
1741
1778
  record["effective_status"] = effective
1779
+ if effective != "superseded":
1780
+ for reason in record.get("drift_reasons", []):
1781
+ warn(warnings, f"{record['path']}: {reason}")
1742
1782
 
1743
1783
  for key, paths in current_by_key.items():
1744
1784
  if len(paths) > 1:
@@ -1835,6 +1875,9 @@ def plan_index_issues(plan_map_text):
1835
1875
  if not link:
1836
1876
  issues.append(f"计划索引第 {row_index} 行缺少计划链接,无法安全派生工作集")
1837
1877
  continue
1878
+ path_issue = plan_link_issue(link)
1879
+ if path_issue:
1880
+ issues.append(f"计划索引第 {row_index} 行路径无效:{path_issue}")
1838
1881
  name = Path(link).stem
1839
1882
  if name in seen:
1840
1883
  issues.append(f"计划索引存在重复计划 ID:{name}")
@@ -2128,7 +2171,9 @@ def validate_relation_evidence(evidence, plans, row_label):
2128
2171
  """仅校验 Markdown 链接形式的相对证据路径;纯文本运行记录保持兼容。"""
2129
2172
  if not evidence or not plans:
2130
2173
  return []
2131
- docs_root = next(iter(plans.values()))["path"].parent.parent
2174
+ first_path = next(iter(plans.values()))["path"]
2175
+ plans_dir = next((parent for parent in first_path.parents if parent.name == "plans"), None)
2176
+ docs_root = plans_dir.parent if plans_dir else first_path.parent.parent
2132
2177
  errors = []
2133
2178
  for match in re.finditer(r"\]\(([^)#]+)(?:#[^)]+)?\)", evidence):
2134
2179
  target = match.group(1).strip()
@@ -4,7 +4,7 @@ import re
4
4
  import shutil
5
5
  import subprocess
6
6
  import sys
7
- from datetime import date
7
+ from datetime import date, datetime
8
8
  from pathlib import Path
9
9
 
10
10
 
@@ -60,7 +60,7 @@ def agent_rules_body():
60
60
 
61
61
  - 普通一次性任务直接处理;已有计划按共享 verification 验证。新旧计划均为普通改动自验,高风险或高影响同范围独立复核一次,修复后自验。
62
62
  - 只实施已授权、已准入的当前阶段。每个阶段有自身 Step 0、验证/完成和失败边界;未解决问题不能记为完成,不可用或超时不能冒充已检查。已有授权不重复索取,新增高影响外部动作按实际授权处理。
63
- - 首次读取 `docs/PLAN_MAP.md`、当前相关 `docs/plans/*.md` 及适用契约/ADR/migration;恢复先核对当前工作集、最近证据和实际 diff,缺失或冲突再展开。
63
+ - 首次读取 `docs/PLAN_MAP.md`、索引登记的相关 `docs/plans/` 计划及适用契约/ADR/migration;恢复先核对当前工作集、最近证据和实际 diff,缺失或冲突再展开。
64
64
  - 地图维护状态、阶段、关系、阻塞与证据入口;计划记录本次差异及验证,现行契约优先复用 Schema/OpenAPI。新事实不写回历史草案,变化同步地图及相关引用。
65
65
  - 完成时按共享规范记录实际验证与适用用户验收,运行 `plan-governance-cli check .`;准入/CI/发布显式使用 `--strict-readiness`,机械通过不等于业务验收。
66
66
  """
@@ -113,9 +113,11 @@ def update_agent_rules(root):
113
113
  return [update_agents_md(root)]
114
114
 
115
115
 
116
- def plan_map_content(plan_slug, title, status, phase):
117
- today = date.today().isoformat()
118
- row = f"| [{title}](plans/{plan_slug}.md) | {status} | {phase} | {today} | - | - |"
116
+ def plan_map_content(plan_slug, title, status, phase, created_on=None):
117
+ created_on = created_on or date.today()
118
+ today = created_on.isoformat()
119
+ plan_relative = f"plans/{created_on:%Y%m%d}/{plan_slug}.md"
120
+ row = f"| [{title}]({plan_relative}) | {status} | {phase} | {today} | - | - |"
119
121
  unfinished_row = row if status in {"候选", "设计中", "待实施", "实施中"} else ""
120
122
  completed_row = row if status == "已完成" else ""
121
123
  deprecated_row = row if status in {"已废弃", "已替代", "已合并"} else ""
@@ -128,13 +130,13 @@ def plan_map_content(plan_slug, title, status, phase):
128
130
  ## 文档权责
129
131
 
130
132
  - `docs/PLAN_MAP.md` 是状态、依赖、替代/合并/废弃关系、推荐顺序、阻塞项和证据链接的事实源。
131
- - `docs/plans/*.md` 记录本次行为差异、阶段、Step 0 和验收;现行契约优先引用已有 Schema/OpenAPI,无合适来源且需长期维护时才按需建立 `docs/specs/*.md`。同一事实不重复维护。
133
+ - `docs/plans/YYYYMMDD/*.md` 默认记录新计划的行为差异、阶段、Step 0 和验收;旧式 `docs/plans/*.md` 计划继续兼容读取。现行契约优先引用已有 Schema/OpenAPI,无合适来源且需长期维护时才按需建立 `docs/specs/*.md`。同一事实不重复维护。
132
134
  - 总路线图、优先级计划和索引只记录顺序、状态摘要和专项计划链接,不复制字段级方案、枚举、Step 0 细节或完成定义。
133
135
  - 当专项计划变化时,必须同步所有引用该计划的路线图、优先级计划或索引。
134
136
  - 如果同一事实在多个文档中重复,保留一个事实源,其他文档改为链接引用。
135
137
  - `PLAN_MAP.md` 的 `状态` 是计划级生命周期,`当前阶段` 是阶段身份指针;阶段 N 完成后,阶段 N+1 默认保持 `设计中`。
136
138
  - 阶段准入摘要、样本矩阵和独立复核记录只写入专项计划,不复制到本索引。
137
- - 启用治理后,已有草案、历史设计、归档计划和临时分析文档默认只作为背景材料,不再作为规范事实源;后续新规范默认进入 `docs/plans/*.md`、ADR、migration、正式 spec 或 `docs/PLAN_MAP.md`。
139
+ - 启用治理后,已有草案、历史设计、归档计划和临时分析文档默认只作为背景材料,不再作为规范事实源;后续新规范默认进入 `docs/plans/YYYYMMDD/*.md`、ADR、migration、正式 spec 或 `docs/PLAN_MAP.md`。
138
140
  - 计划索引固定分为 `未完成`、`已完成`、`已废弃` 三张表;`已替代`、`已合并`等不再推进的终态归入 `已废弃` 表,但保留真实状态值。
139
141
 
140
142
  ## 计划索引
@@ -223,8 +225,20 @@ def docs_warnings(root):
223
225
  if not (root / "docs" / "PLAN_MAP.md").exists():
224
226
  warnings.append("缺少 docs/PLAN_MAP.md")
225
227
  plans_dir = root / "docs" / "plans"
226
- if not plans_dir.exists() or not any(plans_dir.glob("*.md")):
227
- warnings.append("缺少 docs/plans/*.md")
228
+ dated_plans = False
229
+ if plans_dir.exists():
230
+ for child in plans_dir.iterdir():
231
+ if not (child.is_dir() and re.fullmatch(r"\d{8}", child.name)):
232
+ continue
233
+ try:
234
+ datetime.strptime(child.name, "%Y%m%d")
235
+ except ValueError:
236
+ continue
237
+ if any(child.glob("*.md")):
238
+ dated_plans = True
239
+ break
240
+ if not plans_dir.exists() or (not any(plans_dir.glob("*.md")) and not dated_plans):
241
+ warnings.append("缺少 docs/plans 下的平铺或 YYYYMMDD 日期目录计划")
228
242
  return warnings
229
243
 
230
244
 
@@ -349,14 +363,15 @@ def main(argv=None):
349
363
 
350
364
  docs = root / "docs"
351
365
  plan_map = docs / "PLAN_MAP.md"
352
- plan_file = docs / "plans" / f"{plan_slug}.md"
366
+ created_on = date.today()
367
+ plan_file = docs / "plans" / created_on.strftime("%Y%m%d") / f"{plan_slug}.md"
353
368
 
354
369
  created = []
355
370
  git_dir = init_git(root)
356
371
  if git_dir is not None:
357
372
  created.append(git_dir)
358
373
 
359
- write_file(plan_map, plan_map_content(plan_slug, title, args.status, args.phase), args.force)
374
+ write_file(plan_map, plan_map_content(plan_slug, title, args.status, args.phase, created_on), args.force)
360
375
  created.append(plan_map)
361
376
  write_file(plan_file, plan_content(plan_slug, title, args.status, args.phase, args.goal), args.force)
362
377
  created.append(plan_file)