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 +11 -11
- package/bin/plan-governance-cli.mjs +53 -16
- package/package.json +2 -1
- package/resources/manifest.json +13 -0
- package/resources/migration-skill/SKILL.md +14 -0
- package/resources/migration-skill/agents/openai.yaml +4 -0
- package/resources/skill/SKILL.md +1 -1
- package/resources/skill/assets/PLAN_MAP.template.md +1 -1
- package/resources/skill/references/cli.md +2 -2
- package/resources/skill/references/planning.md +3 -1
- package/scripts/check_plan_governance.py +62 -17
- package/scripts/init_plan_governance.py +26 -11
package/README.md
CHANGED
|
@@ -55,12 +55,12 @@ docs/
|
|
|
55
55
|
| 文档 | 权威内容 |
|
|
56
56
|
|---|---|
|
|
57
57
|
| `docs/PLAN_MAP.md` | 计划索引、状态、依赖、替代/合并/废弃关系、推荐顺序、阻塞项、证据链接 |
|
|
58
|
-
| `docs/plans/*.md` |
|
|
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
|
|
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`
|
|
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`
|
|
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`
|
|
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
|
|
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(
|
|
131
|
-
|
|
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
|
|
156
|
+
let skills;
|
|
149
157
|
try {
|
|
150
158
|
manifest = loadManifest();
|
|
151
|
-
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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.
|
|
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",
|
package/resources/manifest.json
CHANGED
|
@@ -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 检查符合项目规则。任一检查失败时停止,并依据映射恢复文件和引用;报告实际恢复范围及仍未解决的项目。只处理用户授权的仓库与计划范围,不安装依赖、不发布、不触碰其他项目。
|
package/resources/skill/SKILL.md
CHANGED
|
@@ -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 在用户明确要求时执行,日常治理不会自动搬迁计划。
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
## 分发与资源
|
|
20
20
|
|
|
21
|
-
|
|
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(
|
|
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"\]\(([
|
|
855
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
118
|
-
|
|
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`
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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)
|