plan-governance-cli 2.0.1 → 3.0.0
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 +20 -8
- package/bin/plan-governance-cli.mjs +58 -20
- package/package.json +1 -1
- package/resources/skill/SKILL.md +3 -1
- package/resources/skill/assets/plan.template.md +4 -2
- package/resources/skill/references/cli.md +25 -10
- package/resources/skill/references/planning.md +3 -1
- package/resources/skill/references/verification.md +14 -12
- package/scripts/check_plan_governance.py +539 -108
- package/scripts/graph_governance.mjs +67 -95
- package/scripts/init_plan_governance.py +81 -14
- package/scripts/plan_governance_hook.py +72 -27
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
-
import { isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
4
|
+
import { isAbsolute, resolve, relative } from "node:path";
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
6
|
import { parse as parseYaml } from "yaml";
|
|
7
7
|
|
|
@@ -61,9 +61,11 @@ function usage() {
|
|
|
61
61
|
架构图谱入口:<root>/docs/graph/architecture/index.yaml`);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
class UsageError extends Error { code = 2; }
|
|
65
|
+
|
|
66
|
+
function error(message, status = 1) {
|
|
65
67
|
console.error(`graph: ERROR: ${message}`);
|
|
66
|
-
return
|
|
68
|
+
return status;
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
function graphPath(root) {
|
|
@@ -407,57 +409,58 @@ function validate(root, layer = "functional") {
|
|
|
407
409
|
return 0;
|
|
408
410
|
}
|
|
409
411
|
|
|
410
|
-
function
|
|
411
|
-
|
|
412
|
+
function parseOptions(args, name, defaults, required = []) {
|
|
413
|
+
if (args.length === 1 && ["-h", "--help"].includes(args[0])) return null;
|
|
414
|
+
const options = { ...defaults, root: process.cwd() };
|
|
415
|
+
const seen = new Set();
|
|
412
416
|
const positional = [];
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
417
|
+
let terminated = false;
|
|
418
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
419
|
+
const arg = args[i];
|
|
420
|
+
if (arg === "--" && !terminated) { terminated = true; continue; }
|
|
421
|
+
if (!terminated && arg.startsWith("-")) {
|
|
422
|
+
const key = arg.slice(2);
|
|
423
|
+
if (!arg.startsWith("--") || !Object.hasOwn(defaults, key)) throw new UsageError(`${name} 不支持参数:${arg}`);
|
|
424
|
+
if (seen.has(key)) throw new UsageError(`${arg} 只能指定一次`);
|
|
425
|
+
seen.add(key);
|
|
426
|
+
const value = args[++i];
|
|
427
|
+
if (!value?.trim() || value.startsWith("-")) throw new UsageError(`${arg} 缺少值`);
|
|
428
|
+
options[key] = value;
|
|
429
|
+
} else positional.push(arg);
|
|
430
|
+
}
|
|
431
|
+
if (positional.length > 1) throw new UsageError(`${name} 只能接收一个 root 路径`);
|
|
432
|
+
if (positional.some((value) => !value.trim())) throw new UsageError("root 不能为空");
|
|
433
|
+
if (positional.length) options.root = resolve(positional[0]);
|
|
434
|
+
for (const key of required) if (!options[key]) throw new UsageError(`${name} 必须指定 --${key} <value>`);
|
|
435
|
+
if ("format" in options && !["text", "json"].includes(options.format)) throw new UsageError("--format 只能是 text 或 json");
|
|
436
|
+
if ("depth" in options) {
|
|
437
|
+
if (!/^[0-9]+$/.test(String(options.depth)) || Number(options.depth) < 1 || Number(options.depth) > 10) throw new UsageError("--depth 必须是 1 到 10 的整数");
|
|
438
|
+
options.depth = Number(options.depth);
|
|
439
|
+
}
|
|
440
|
+
if (!existsSync(options.root) || !statSync(options.root).isDirectory()) throw new Error(`root 不存在或不是目录:${options.root}`);
|
|
424
441
|
return options;
|
|
425
442
|
}
|
|
426
443
|
|
|
427
|
-
function
|
|
428
|
-
const options =
|
|
429
|
-
|
|
430
|
-
for (let index = 0; index < args.length; index += 1) {
|
|
431
|
-
const arg = args[index];
|
|
432
|
-
if (arg === "--from") options.from = args[++index];
|
|
433
|
-
else if (arg === "--depth") options.depth = Number(args[++index]);
|
|
434
|
-
else if (arg === "--format") options.format = args[++index];
|
|
435
|
-
else if (arg === "-h" || arg === "--help") return null;
|
|
436
|
-
else positional.push(arg);
|
|
437
|
-
}
|
|
438
|
-
if (positional.length > 0) options.root = resolve(positional.at(-1));
|
|
439
|
-
if (!options.from) throw new Error("impact 必须指定 --from <graph-node-id>");
|
|
440
|
-
if (!Number.isInteger(options.depth) || options.depth < 1 || options.depth > 10) throw new Error("--depth 必须是 1 到 10 的整数");
|
|
441
|
-
if (!["text", "json"].includes(options.format)) throw new Error("--format 只能是 text 或 json");
|
|
444
|
+
function parseValidateArgs(args) {
|
|
445
|
+
const options = parseOptions(args, "validate", { layer: "functional" });
|
|
446
|
+
if (options && !["functional", "architecture"].includes(options.layer)) throw new UsageError("validate --layer 只能是 functional 或 architecture");
|
|
442
447
|
return options;
|
|
443
448
|
}
|
|
444
449
|
|
|
450
|
+
function parseImpactArgs(args) {
|
|
451
|
+
return parseOptions(args, "impact", { from: null, depth: 2, format: "text" }, ["from"]);
|
|
452
|
+
}
|
|
453
|
+
|
|
445
454
|
function parsePlanImpactArgs(args) {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
if (positional.length > 1) throw new Error("plan impact 只能接收一个 root 路径");
|
|
456
|
-
if (positional.length === 1) options.root = resolve(positional[0]);
|
|
457
|
-
if (!options.input) throw new Error("plan impact 必须指定 --input <plan-impact-request.json>");
|
|
458
|
-
if (!options.format || !["text", "json"].includes(options.format)) throw new Error("--format 只能是 text 或 json");
|
|
459
|
-
if (!existsSync(options.root)) throw new Error(`root 不存在:${options.root}`);
|
|
460
|
-
return options;
|
|
455
|
+
return parseOptions(args, "plan impact", { input: null, format: "text" }, ["input"]);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function candidateFile(root, file) {
|
|
459
|
+
const path = resolve(root, file);
|
|
460
|
+
const inside = (candidate) => { const rel = relative(realpathSync(root), candidate); return rel !== ".." && !rel.startsWith("../") && !isAbsolute(rel); };
|
|
461
|
+
if (!existsSync(path) || !statSync(path).isFile()) throw new Error(`指定代码文件不存在或不是普通文件:${file}`);
|
|
462
|
+
if (!inside(realpathSync(path))) throw new Error(`代码文件必须位于 root 内:${file}`);
|
|
463
|
+
return relative(root, path);
|
|
461
464
|
}
|
|
462
465
|
|
|
463
466
|
function loadPlanImpactRequest(root, input) {
|
|
@@ -481,8 +484,9 @@ function loadPlanImpactRequest(root, input) {
|
|
|
481
484
|
const anchor = request.code_anchor;
|
|
482
485
|
if (!anchor || typeof anchor !== "object") throw new Error("code_locator_requested=true 时必须提供 code_anchor");
|
|
483
486
|
for (const field of ["file", "symbol", "kind"]) {
|
|
484
|
-
if (typeof anchor[field] !== "string" || anchor[field].length === 0) throw new Error(`code_anchor.${field} 缺失`);
|
|
487
|
+
if (typeof anchor[field] !== "string" || anchor[field].trim().length === 0) throw new Error(`code_anchor.${field} 缺失`);
|
|
485
488
|
}
|
|
489
|
+
if (!["class", "function"].includes(anchor.kind)) throw new Error("code_anchor.kind 只能是 class 或 function");
|
|
486
490
|
} else if (request.code_anchor !== undefined) {
|
|
487
491
|
throw new Error("未请求代码定位时不得提供 code_anchor");
|
|
488
492
|
}
|
|
@@ -515,8 +519,8 @@ function listCodeFiles(root) {
|
|
|
515
519
|
|
|
516
520
|
function runCodeCandidateSearch(root, options) {
|
|
517
521
|
let files;
|
|
518
|
-
if (options.file
|
|
519
|
-
files = [options.file];
|
|
522
|
+
if (options.file) {
|
|
523
|
+
files = [candidateFile(root, options.file)];
|
|
520
524
|
} else {
|
|
521
525
|
files = listCodeFiles(root);
|
|
522
526
|
}
|
|
@@ -539,28 +543,13 @@ function runCodeCandidateSearch(root, options) {
|
|
|
539
543
|
}
|
|
540
544
|
|
|
541
545
|
function parseCodeCandidatesArgs(args) {
|
|
542
|
-
const options = { file: null, symbol: null, kind: null, format: "text",
|
|
543
|
-
|
|
544
|
-
for (let index = 0; index < args.length; index += 1) {
|
|
545
|
-
const arg = args[index];
|
|
546
|
-
if (arg === "--file") options.file = args[++index];
|
|
547
|
-
else if (arg === "--symbol") options.symbol = args[++index];
|
|
548
|
-
else if (arg === "--kind") options.kind = args[++index];
|
|
549
|
-
else if (arg === "--format") options.format = args[++index];
|
|
550
|
-
else if (arg === "-h" || arg === "--help") return null;
|
|
551
|
-
else positional.push(arg);
|
|
552
|
-
}
|
|
553
|
-
if (positional.length > 1) throw new Error("code candidates 只能接收一个 root 路径");
|
|
554
|
-
if (positional.length === 1) options.root = resolve(positional[0]);
|
|
555
|
-
if (!options.symbol) throw new Error("code candidates 必须指定 --symbol <symbol>");
|
|
556
|
-
if (!options.kind) throw new Error("code candidates 必须指定 --kind <kind>");
|
|
557
|
-
if (!options.file && options.file !== null) throw new Error("--file 必须指定路径");
|
|
558
|
-
if (!options.format || !["text", "json"].includes(options.format)) throw new Error("--format 只能是 text 或 json");
|
|
559
|
-
if (!existsSync(options.root)) throw new Error(`root 不存在:${options.root}`);
|
|
546
|
+
const options = parseOptions(args, "code candidates", { file: null, symbol: null, kind: null, format: "text" }, ["symbol", "kind"]);
|
|
547
|
+
if (options && !["class", "function"].includes(options.kind)) throw new UsageError("--kind 只能是 class 或 function");
|
|
560
548
|
return options;
|
|
561
549
|
}
|
|
562
550
|
|
|
563
551
|
function codeCandidates(root, options) {
|
|
552
|
+
if (!["class", "function"].includes(options.kind) || !options.symbol?.trim()) throw new Error("代码定位 kind/symbol 无效");
|
|
564
553
|
const candidates = runCodeCandidateSearch(root, options);
|
|
565
554
|
const resolution = candidates.length === 1
|
|
566
555
|
? "unique_candidate"
|
|
@@ -588,27 +577,8 @@ function printCodeCandidatesText(result) {
|
|
|
588
577
|
}
|
|
589
578
|
|
|
590
579
|
function parseCodeImpactArgs(args) {
|
|
591
|
-
const options = { repo: null, file: null, symbol: null, kind: null, depth: 3, format: "text",
|
|
592
|
-
|
|
593
|
-
for (let index = 0; index < args.length; index += 1) {
|
|
594
|
-
const arg = args[index];
|
|
595
|
-
if (arg === "--repo") options.repo = args[++index];
|
|
596
|
-
else if (arg === "--file") options.file = args[++index];
|
|
597
|
-
else if (arg === "--symbol") options.symbol = args[++index];
|
|
598
|
-
else if (arg === "--kind") options.kind = args[++index];
|
|
599
|
-
else if (arg === "--depth") options.depth = Number(args[++index]);
|
|
600
|
-
else if (arg === "--format") options.format = args[++index];
|
|
601
|
-
else if (arg === "-h" || arg === "--help") return null;
|
|
602
|
-
else positional.push(arg);
|
|
603
|
-
}
|
|
604
|
-
if (positional.length > 1) throw new Error("code impact 只能接收一个 root 路径");
|
|
605
|
-
if (positional.length === 1) options.root = resolve(positional[0]);
|
|
606
|
-
["repo", "file", "symbol", "kind"].forEach((field) => {
|
|
607
|
-
if (!options[field]) throw new Error(`code impact 必须指定 --${field} <value>`);
|
|
608
|
-
});
|
|
609
|
-
if (!Number.isInteger(options.depth) || options.depth < 1 || options.depth > 10) throw new Error("--depth 必须是 1 到 10 的整数");
|
|
610
|
-
if (!options.format || !["text", "json"].includes(options.format)) throw new Error("--format 只能是 text 或 json");
|
|
611
|
-
if (!existsSync(options.root)) throw new Error(`root 不存在:${options.root}`);
|
|
580
|
+
const options = parseOptions(args, "code impact", { repo: null, file: null, symbol: null, kind: null, depth: 3, format: "text" }, ["repo", "file", "symbol", "kind"]);
|
|
581
|
+
if (options && !["class", "function", "method"].includes(options.kind)) throw new UsageError("--kind 只能是 class、function 或 method");
|
|
612
582
|
return options;
|
|
613
583
|
}
|
|
614
584
|
|
|
@@ -637,9 +607,11 @@ function codeImpact(root, options) {
|
|
|
637
607
|
} catch (cause) {
|
|
638
608
|
throw new Error(`GitNexus 代码级影响查询返回了无法解析的结果:${cause.message}`);
|
|
639
609
|
}
|
|
640
|
-
if (gitnexusResult
|
|
610
|
+
if (!gitnexusResult || typeof gitnexusResult !== "object" || Array.isArray(gitnexusResult)) throw new Error("GitNexus 响应必须是非空对象");
|
|
611
|
+
if (gitnexusResult.error) {
|
|
641
612
|
throw new Error(`GitNexus 代码级影响查询失败:${String(gitnexusResult.error)}`);
|
|
642
613
|
}
|
|
614
|
+
if (!Number.isInteger(gitnexusResult.impactedCount) || gitnexusResult.impactedCount < 0 || typeof gitnexusResult.risk !== "string") throw new Error("GitNexus 响应缺少有效 risk/impactedCount");
|
|
643
615
|
return {
|
|
644
616
|
schema_version: 1,
|
|
645
617
|
query: {
|
|
@@ -920,7 +892,7 @@ function main(args) {
|
|
|
920
892
|
const command = args[0];
|
|
921
893
|
if (!command || command === "--help" || command === "-h") {
|
|
922
894
|
usage();
|
|
923
|
-
return command ? 0 :
|
|
895
|
+
return command ? 0 : 2;
|
|
924
896
|
}
|
|
925
897
|
try {
|
|
926
898
|
if (command === "validate") {
|
|
@@ -943,7 +915,7 @@ function main(args) {
|
|
|
943
915
|
return 0;
|
|
944
916
|
}
|
|
945
917
|
if (command === "plan") {
|
|
946
|
-
if (args[1] !== "impact") return error(`plan 不支持子命令:${args[1] ?? "<missing>"}
|
|
918
|
+
if (args[1] !== "impact") return error(`plan 不支持子命令:${args[1] ?? "<missing>"}`, 2);
|
|
947
919
|
const options = parsePlanImpactArgs(args.slice(2));
|
|
948
920
|
if (options === null) {
|
|
949
921
|
usage();
|
|
@@ -977,11 +949,11 @@ function main(args) {
|
|
|
977
949
|
else printCodeImpactText(result);
|
|
978
950
|
return 0;
|
|
979
951
|
}
|
|
980
|
-
return error(`code 不支持子命令:${args[1] ?? "<missing>"}
|
|
952
|
+
return error(`code 不支持子命令:${args[1] ?? "<missing>"}`, 2);
|
|
981
953
|
}
|
|
982
|
-
return error(`不支持子命令:${command}
|
|
954
|
+
return error(`不支持子命令:${command}`, 2);
|
|
983
955
|
} catch (cause) {
|
|
984
|
-
return error(cause.message);
|
|
956
|
+
return error(cause.message, cause.code === 2 ? 2 : 1);
|
|
985
957
|
}
|
|
986
958
|
}
|
|
987
959
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
import argparse
|
|
3
|
+
import importlib.util
|
|
3
4
|
import re
|
|
4
5
|
import shutil
|
|
5
6
|
import subprocess
|
|
@@ -8,6 +9,11 @@ from datetime import date, datetime
|
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
|
|
10
11
|
|
|
12
|
+
_checker_spec = importlib.util.spec_from_file_location("initializer_checker", Path(__file__).with_name("check_plan_governance.py"))
|
|
13
|
+
checker = importlib.util.module_from_spec(_checker_spec)
|
|
14
|
+
_checker_spec.loader.exec_module(checker)
|
|
15
|
+
|
|
16
|
+
|
|
11
17
|
VALID_STATUSES = {
|
|
12
18
|
"候选",
|
|
13
19
|
"设计中",
|
|
@@ -21,6 +27,7 @@ VALID_STATUSES = {
|
|
|
21
27
|
|
|
22
28
|
AGENTS_SECTION_BEGIN = "<!-- plan-governance:start -->"
|
|
23
29
|
AGENTS_SECTION_END = "<!-- plan-governance:end -->"
|
|
30
|
+
WRITTEN_FILES = []
|
|
24
31
|
|
|
25
32
|
|
|
26
33
|
def slugify(value):
|
|
@@ -38,6 +45,7 @@ def write_file(path, content, force):
|
|
|
38
45
|
raise FileExistsError(f"{path} 已存在;如需覆盖请加 --force")
|
|
39
46
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
40
47
|
path.write_text(content, encoding="utf-8")
|
|
48
|
+
WRITTEN_FILES.append(path)
|
|
41
49
|
|
|
42
50
|
|
|
43
51
|
def init_git(root):
|
|
@@ -46,6 +54,7 @@ def init_git(root):
|
|
|
46
54
|
|
|
47
55
|
root.mkdir(parents=True, exist_ok=True)
|
|
48
56
|
subprocess.run(["git", "init"], cwd=root, check=True)
|
|
57
|
+
WRITTEN_FILES.append(root / ".git")
|
|
49
58
|
return root / ".git"
|
|
50
59
|
|
|
51
60
|
|
|
@@ -58,9 +67,10 @@ def agent_rules_body():
|
|
|
58
67
|
|
|
59
68
|
最小执行边界:
|
|
60
69
|
|
|
61
|
-
- 普通一次性任务直接处理;已有计划按共享 verification
|
|
70
|
+
- 普通一次性任务直接处理;已有计划按共享 verification 验证。新旧计划均默认自验,仅在用户明确要求时独立复核;未解决问题继续阻塞。
|
|
62
71
|
- 只实施已授权、已准入的当前阶段。每个阶段有自身 Step 0、验证/完成和失败边界;未解决问题不能记为完成,不可用或超时不能冒充已检查。已有授权不重复索取,新增高影响外部动作按实际授权处理。
|
|
63
72
|
- 首次读取 `docs/PLAN_MAP.md`、索引登记的相关 `docs/plans/` 计划及适用契约/ADR/migration;恢复先核对当前工作集、最近证据和实际 diff,缺失或冲突再展开。
|
|
73
|
+
- 多计划/多会话并行时,每个会话在 check/workset/hook 中显式传入自己的 `--plan ID`;必要前置与共享写入约束仍需读取,写入前重读相关事实,不设置全项目可变焦点。
|
|
64
74
|
- 地图维护状态、阶段、关系、阻塞与证据入口;计划记录本次差异及验证,现行契约优先复用 Schema/OpenAPI。新事实不写回历史草案,变化同步地图及相关引用。
|
|
65
75
|
- 完成时按共享规范记录实际验证与适用用户验收,运行 `plan-governance-cli check .`;准入/CI/发布显式使用 `--strict-readiness`,机械通过不等于业务验收。
|
|
66
76
|
"""
|
|
@@ -96,6 +106,7 @@ def update_managed_file(root, filename, section, begin, end):
|
|
|
96
106
|
separator = newline if current.endswith(("\n", "\r")) else newline * 2
|
|
97
107
|
updated = f"{current}{separator}{section}"
|
|
98
108
|
target.write_bytes(updated.encode("utf-8"))
|
|
109
|
+
WRITTEN_FILES.append(target)
|
|
99
110
|
return target
|
|
100
111
|
|
|
101
112
|
|
|
@@ -210,13 +221,15 @@ def copy_checker(root, force):
|
|
|
210
221
|
source = Path(__file__).with_name("check_plan_governance.py")
|
|
211
222
|
target = root / "scripts" / "check_plan_governance.py"
|
|
212
223
|
if source.resolve() == target.resolve():
|
|
213
|
-
target.chmod(0o755)
|
|
214
224
|
return target
|
|
215
225
|
if target.exists() and not force:
|
|
216
226
|
raise FileExistsError(f"{target} 已存在;如需覆盖请加 --force")
|
|
217
227
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
218
|
-
|
|
219
|
-
|
|
228
|
+
if target.exists():
|
|
229
|
+
target.write_bytes(source.read_bytes())
|
|
230
|
+
else:
|
|
231
|
+
shutil.copy2(source, target)
|
|
232
|
+
WRITTEN_FILES.append(target)
|
|
220
233
|
return target
|
|
221
234
|
|
|
222
235
|
|
|
@@ -281,6 +294,7 @@ def migrate_plan_map_last_updated(root, last_updated):
|
|
|
281
294
|
|
|
282
295
|
if changed:
|
|
283
296
|
plan_map.write_text("\n".join(migrated) + ("\n" if text.endswith("\n") else ""), encoding="utf-8")
|
|
297
|
+
WRITTEN_FILES.append(plan_map)
|
|
284
298
|
return plan_map, changed
|
|
285
299
|
|
|
286
300
|
|
|
@@ -293,13 +307,13 @@ def upgrade_existing(root):
|
|
|
293
307
|
|
|
294
308
|
|
|
295
309
|
def parse_args(argv):
|
|
296
|
-
parser = argparse.ArgumentParser(description="初始化中文 plan-governance 文档。")
|
|
297
|
-
parser.add_argument("--root", default=".", help="目标仓库根目录,默认当前目录。")
|
|
298
|
-
parser.add_argument("--plan", help="计划文件名或计划标识,例如 api-compat-migration。")
|
|
299
|
-
parser.add_argument("--title", help="计划显示名称,默认使用 --plan。")
|
|
300
|
-
parser.add_argument("--goal", help="计划目标,会写入计划文档。")
|
|
310
|
+
parser = argparse.ArgumentParser(description="初始化中文 plan-governance 文档。", allow_abbrev=False)
|
|
311
|
+
parser.add_argument("--root", type=checker.nonempty, default=".", help="目标仓库根目录,默认当前目录。")
|
|
312
|
+
parser.add_argument("--plan", type=checker.nonempty, help="计划文件名或计划标识,例如 api-compat-migration。")
|
|
313
|
+
parser.add_argument("--title", type=checker.nonempty, help="计划显示名称,默认使用 --plan。")
|
|
314
|
+
parser.add_argument("--goal", type=checker.nonempty, help="计划目标,会写入计划文档。")
|
|
301
315
|
parser.add_argument("--status", default="设计中", choices=sorted(VALID_STATUSES), help="初始状态。")
|
|
302
|
-
parser.add_argument("--phase", default="阶段 0", help="当前阶段名称。")
|
|
316
|
+
parser.add_argument("--phase", type=checker.nonempty, default="阶段 0", help="当前阶段名称。")
|
|
303
317
|
parser.add_argument("--copy-checker", action="store_true", help="复制检查脚本到目标仓库 scripts/。")
|
|
304
318
|
parser.add_argument("--update-agents-md", action="store_true", help="创建或更新目标仓库 AGENTS.md 中的计划治理规则。")
|
|
305
319
|
parser.add_argument("--update-agent-rules", action="store_true", help="创建或更新目标仓库 AGENTS.md 中的计划治理规则。")
|
|
@@ -309,6 +323,7 @@ def parse_args(argv):
|
|
|
309
323
|
parser.add_argument("--migrate-plan-map-last-updated", action="store_true", help="将旧五列表 PLAN_MAP.md 迁移为包含最后更新的六列表。")
|
|
310
324
|
parser.add_argument("--last-updated-date", default=date.today().isoformat(), help="迁移 PLAN_MAP.md 时填入的最后更新日期,默认今天。")
|
|
311
325
|
parser.add_argument("--force", action="store_true", help="允许覆盖已存在的治理文件。")
|
|
326
|
+
checker.validate_options(parser, argv)
|
|
312
327
|
args = parser.parse_args(argv)
|
|
313
328
|
only_modes = [
|
|
314
329
|
args.update_agents_md_only,
|
|
@@ -320,12 +335,47 @@ def parse_args(argv):
|
|
|
320
335
|
parser.error("--update-*-only、--upgrade-existing 和 --migrate-plan-map-last-updated 不能同时使用")
|
|
321
336
|
if not any(only_modes) and not args.plan:
|
|
322
337
|
parser.error("正常初始化必须提供 --plan;已有项目可使用 --update-*-only、--upgrade-existing 或 --migrate-plan-map-last-updated")
|
|
338
|
+
supplied = {token.split("=", 1)[0] for token in argv if token.startswith("--")}
|
|
339
|
+
if any(only_modes):
|
|
340
|
+
allowed = {"--root", "--update-agents-md-only", "--update-agent-rules-only", "--upgrade-existing", "--migrate-plan-map-last-updated"}
|
|
341
|
+
if args.migrate_plan_map_last_updated:
|
|
342
|
+
allowed.add("--last-updated-date")
|
|
343
|
+
if supplied - allowed:
|
|
344
|
+
parser.error("当前模式不接受:" + "、".join(sorted(supplied - allowed)))
|
|
345
|
+
else:
|
|
346
|
+
if "--last-updated-date" in supplied:
|
|
347
|
+
parser.error("--last-updated-date 仅用于迁移")
|
|
348
|
+
if args.status not in {"候选", "设计中"}:
|
|
349
|
+
parser.error("新计划只能以 候选/设计中 初始化;准入后再同步状态")
|
|
350
|
+
if not checker.valid_phase_name(args.phase):
|
|
351
|
+
parser.error("--phase 必须为阶段身份,例如 阶段 0")
|
|
352
|
+
if args.update_agents_md and args.update_agent_rules:
|
|
353
|
+
parser.error("代理规则更新别名不能同时指定")
|
|
354
|
+
if args.migrate_plan_map_last_updated:
|
|
355
|
+
try:
|
|
356
|
+
date.fromisoformat(args.last_updated_date)
|
|
357
|
+
except ValueError:
|
|
358
|
+
parser.error("--last-updated-date 必须是有效 ISO 日期")
|
|
323
359
|
return args
|
|
324
360
|
|
|
325
361
|
|
|
326
362
|
def main(argv=None):
|
|
327
|
-
|
|
363
|
+
WRITTEN_FILES.clear()
|
|
364
|
+
args = parse_args(sys.argv[1:] if argv is None else argv)
|
|
328
365
|
root = Path(args.root).expanduser().resolve()
|
|
366
|
+
if root.exists() and not root.is_dir():
|
|
367
|
+
raise NotADirectoryError(f"root 不是目录:{root}")
|
|
368
|
+
if any((args.update_agents_md_only, args.update_agent_rules_only, args.upgrade_existing,
|
|
369
|
+
args.update_agents_md, args.update_agent_rules)):
|
|
370
|
+
target = root / "AGENTS.md"
|
|
371
|
+
if target.exists():
|
|
372
|
+
target.read_text(encoding="utf-8")
|
|
373
|
+
if args.upgrade_existing:
|
|
374
|
+
target = root / "scripts/check_plan_governance.py"
|
|
375
|
+
if target.exists() and not target.is_file():
|
|
376
|
+
raise IsADirectoryError(f"checker 目标不是普通文件:{target}")
|
|
377
|
+
if target.parent.exists() and not target.parent.is_dir():
|
|
378
|
+
raise NotADirectoryError(f"checker 父路径不是目录:{target.parent}")
|
|
329
379
|
|
|
330
380
|
if args.update_agents_md_only:
|
|
331
381
|
target = update_agents_md(root)
|
|
@@ -366,14 +416,30 @@ def main(argv=None):
|
|
|
366
416
|
created_on = date.today()
|
|
367
417
|
plan_file = docs / "plans" / created_on.strftime("%Y%m%d") / f"{plan_slug}.md"
|
|
368
418
|
|
|
419
|
+
targets = [plan_map, plan_file]
|
|
420
|
+
if args.copy_checker:
|
|
421
|
+
targets.append(root / "scripts/check_plan_governance.py")
|
|
422
|
+
for target in targets:
|
|
423
|
+
if target.exists() and (not args.force or not target.is_file()):
|
|
424
|
+
raise FileExistsError(f"{target} 已存在;未写入任何治理文件")
|
|
425
|
+
for parent in target.parents:
|
|
426
|
+
if parent.exists() and not parent.is_dir():
|
|
427
|
+
raise NotADirectoryError(f"目标父路径不是目录:{parent}")
|
|
428
|
+
# 首次写入前读取全部必要来源。
|
|
429
|
+
map_text = plan_map_content(plan_slug, title, args.status, args.phase, created_on)
|
|
430
|
+
body = plan_content(plan_slug, title, args.status, args.phase, args.goal)
|
|
431
|
+
if args.update_agents_md or args.update_agent_rules:
|
|
432
|
+
target = root / "AGENTS.md"
|
|
433
|
+
if target.exists():
|
|
434
|
+
target.read_text(encoding="utf-8")
|
|
369
435
|
created = []
|
|
370
436
|
git_dir = init_git(root)
|
|
371
437
|
if git_dir is not None:
|
|
372
438
|
created.append(git_dir)
|
|
373
439
|
|
|
374
|
-
write_file(plan_map,
|
|
440
|
+
write_file(plan_map, map_text, args.force)
|
|
375
441
|
created.append(plan_map)
|
|
376
|
-
write_file(plan_file,
|
|
442
|
+
write_file(plan_file, body, args.force)
|
|
377
443
|
created.append(plan_file)
|
|
378
444
|
|
|
379
445
|
if args.copy_checker:
|
|
@@ -392,6 +458,7 @@ def main(argv=None):
|
|
|
392
458
|
if __name__ == "__main__":
|
|
393
459
|
try:
|
|
394
460
|
raise SystemExit(main())
|
|
395
|
-
except (
|
|
461
|
+
except (OSError, ValueError, subprocess.CalledProcessError) as exc:
|
|
396
462
|
print(f"ERROR: {exc}", file=sys.stderr)
|
|
463
|
+
print("初始化未完成;已写入:" + ("、".join(str(p) for p in WRITTEN_FILES) or "无") + ";请核对出错目标后恢复或重试。", file=sys.stderr)
|
|
397
464
|
raise SystemExit(1)
|
|
@@ -136,7 +136,10 @@ def matching_plans(root, paths):
|
|
|
136
136
|
return matches
|
|
137
137
|
|
|
138
138
|
|
|
139
|
-
def print_session_start(root):
|
|
139
|
+
def print_session_start(root, names=None):
|
|
140
|
+
if names:
|
|
141
|
+
print("[plan-governance] 会话聚焦计划:" + "、".join(names))
|
|
142
|
+
return print_gate_hints(root, names)
|
|
140
143
|
active, _ = active_plans(root)
|
|
141
144
|
if not active:
|
|
142
145
|
print("[plan-governance] 未发现活跃计划。")
|
|
@@ -148,12 +151,16 @@ def print_session_start(root):
|
|
|
148
151
|
f"- {plan['name']}: {plan['status']},{plan['phase']},"
|
|
149
152
|
f"最后更新 {plan['last_updated']},证据 {plan['evidence']}"
|
|
150
153
|
)
|
|
151
|
-
|
|
154
|
+
print("[plan-governance] 尚未设置会话焦点;选择计划后显式传入 --plan ID。")
|
|
152
155
|
return 0
|
|
153
156
|
|
|
154
157
|
|
|
155
158
|
def print_gate_hints(root, names):
|
|
156
|
-
payload, _ = checker.workset_payload(root)
|
|
159
|
+
payload, _ = checker.workset_payload(root, requested=list(names))
|
|
160
|
+
for constraint in payload.get("scope", {}).get("shared_writes", []):
|
|
161
|
+
print(f" 共享写入约束:{constraint}")
|
|
162
|
+
for dependency in payload.get("scope", {}).get("dependencies", []):
|
|
163
|
+
print(f" 必要前置:{dependency['plan']}@{dependency['phase']},通过={dependency['phase_passed']}")
|
|
157
164
|
has_blockers = False
|
|
158
165
|
for plan in payload["plans"]:
|
|
159
166
|
if plan["plan"] not in names:
|
|
@@ -168,8 +175,19 @@ def print_gate_hints(root, names):
|
|
|
168
175
|
print("[plan-governance] 当前阻塞项:无法确认,请核对诊断。" if payload["warnings"]
|
|
169
176
|
else "[plan-governance] 当前阻塞项:无。")
|
|
170
177
|
|
|
178
|
+
return 0
|
|
179
|
+
|
|
171
180
|
|
|
172
|
-
def print_pre_write(root, paths):
|
|
181
|
+
def print_pre_write(root, paths, names=None):
|
|
182
|
+
if names:
|
|
183
|
+
print("[plan-governance] 写入前检查聚焦范围:" + "、".join(names))
|
|
184
|
+
plans, _ = load_plans(root)
|
|
185
|
+
targets = [target for plan in plans if plan["name"] in names for target in plan["targets"]]
|
|
186
|
+
targets += [plan["path"].relative_to(root).as_posix() for plan in plans if plan["name"] in names and plan["path"]]
|
|
187
|
+
for path in paths:
|
|
188
|
+
if path != "docs/PLAN_MAP.md" and not any(target_matches_path(target, path) for target in targets):
|
|
189
|
+
print(f" WARNING: 写入路径超出聚焦计划声明范围:{path};请先核对范围与授权。")
|
|
190
|
+
return print_gate_hints(root, names)
|
|
173
191
|
matches = matching_plans(root, paths)
|
|
174
192
|
if not matches:
|
|
175
193
|
print("[plan-governance] 未匹配到相关活跃计划。")
|
|
@@ -179,7 +197,7 @@ def print_pre_write(root, paths):
|
|
|
179
197
|
for plan in matches:
|
|
180
198
|
print(f"- {plan['name']}: {plan['status']},{plan['phase']}")
|
|
181
199
|
print(" 当前阶段门禁:确认 Step 0 证据、验证方式、完成条件和公共契约约束。")
|
|
182
|
-
|
|
200
|
+
print("[plan-governance] 路径匹配仅为候选;请显式选择 --plan,不能据此授权或接管计划。")
|
|
183
201
|
return 0
|
|
184
202
|
|
|
185
203
|
|
|
@@ -208,29 +226,25 @@ def print_post_write(paths):
|
|
|
208
226
|
return 0
|
|
209
227
|
|
|
210
228
|
|
|
211
|
-
def print_stop(root):
|
|
212
|
-
checker = root / "scripts" / "check_plan_governance.py"
|
|
213
|
-
if not checker.exists():
|
|
214
|
-
print("[plan-governance] 未找到 scripts/check_plan_governance.py,跳过停止前检查。")
|
|
215
|
-
return 0
|
|
216
|
-
|
|
229
|
+
def print_stop(root, names=None):
|
|
217
230
|
result = subprocess.run(
|
|
218
|
-
[sys.executable, str(
|
|
219
|
-
|
|
220
|
-
check=False,
|
|
221
|
-
capture_output=True,
|
|
222
|
-
text=True,
|
|
231
|
+
[sys.executable, str(Path(__file__).with_name("check_plan_governance.py")),
|
|
232
|
+
str(root), *[token for name in names or [] for token in ("--plan", name)]],
|
|
233
|
+
check=False, capture_output=True, text=True,
|
|
223
234
|
)
|
|
224
|
-
output
|
|
225
|
-
|
|
226
|
-
|
|
235
|
+
for output in (result.stdout, result.stderr):
|
|
236
|
+
if output.strip():
|
|
237
|
+
print(output.strip())
|
|
238
|
+
if result.returncode not in {0, 1}:
|
|
239
|
+
print("[plan-governance] 停止前检查未完成:检查器执行失败。", file=sys.stderr)
|
|
240
|
+
return 1
|
|
227
241
|
print("[plan-governance] Stop 检查为非阻塞提示;请人工复核 WARNING/ERROR。")
|
|
228
242
|
return 0
|
|
229
243
|
|
|
230
244
|
|
|
231
245
|
def parse_args(argv):
|
|
232
|
-
parser = argparse.ArgumentParser(description="计划治理 hook runtime。")
|
|
233
|
-
parser.add_argument("--root", default=".", help="仓库根目录,默认当前目录。")
|
|
246
|
+
parser = argparse.ArgumentParser(description="计划治理 hook runtime。", allow_abbrev=False)
|
|
247
|
+
parser.add_argument("--root", default=".", type=checker.nonempty, help="仓库根目录,默认当前目录。")
|
|
234
248
|
parser.add_argument(
|
|
235
249
|
"--event",
|
|
236
250
|
required=True,
|
|
@@ -238,23 +252,54 @@ def parse_args(argv):
|
|
|
238
252
|
help="hook 事件类型。",
|
|
239
253
|
)
|
|
240
254
|
parser.add_argument("--paths", nargs="*", default=[], help="事件相关路径。")
|
|
241
|
-
|
|
255
|
+
parser.add_argument("--plan", action="append", type=checker.nonempty, default=[], help="会话聚焦计划 ID,可重复。")
|
|
256
|
+
checker.validate_options(parser, argv, {"plan"})
|
|
257
|
+
args = parser.parse_args(argv)
|
|
258
|
+
if len(set(args.plan)) != len(args.plan):
|
|
259
|
+
parser.error("--plan 不接受重复 ID")
|
|
260
|
+
if "--paths" in argv and args.event not in {"pre-write", "post-write"}:
|
|
261
|
+
parser.error("--paths 仅用于 pre-write/post-write")
|
|
262
|
+
return args
|
|
242
263
|
|
|
243
264
|
|
|
244
265
|
def main(argv=None):
|
|
245
266
|
args = parse_args(sys.argv[1:] if argv is None else argv)
|
|
246
|
-
root = Path(args.root)
|
|
267
|
+
root = Path(args.root).expanduser().resolve()
|
|
268
|
+
if not root.is_dir():
|
|
269
|
+
print(f"ERROR: root 不存在或不是目录:{root}", file=sys.stderr)
|
|
270
|
+
return 1
|
|
271
|
+
if args.plan:
|
|
272
|
+
errors = []
|
|
273
|
+
text, _, plans = checker.load_plan_records(root, errors)
|
|
274
|
+
try:
|
|
275
|
+
checker.build_scope(root, args.plan, text, plans)
|
|
276
|
+
except checker.UsageError as exc:
|
|
277
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
278
|
+
return 2
|
|
279
|
+
if errors:
|
|
280
|
+
print("ERROR: 未完成检查:" + ";".join(errors), file=sys.stderr)
|
|
281
|
+
return 1
|
|
282
|
+
for path in args.paths:
|
|
283
|
+
candidate = (root / path).resolve()
|
|
284
|
+
if not path.strip() or not candidate.is_relative_to(root):
|
|
285
|
+
print(f"ERROR: paths 必须是 root 内的非空路径:{path}", file=sys.stderr)
|
|
286
|
+
return 2
|
|
287
|
+
args.paths = [(root / path).resolve().relative_to(root).as_posix() for path in args.paths]
|
|
247
288
|
|
|
248
289
|
if args.event == "session-start":
|
|
249
|
-
return print_session_start(root)
|
|
290
|
+
return print_session_start(root, args.plan)
|
|
250
291
|
if args.event == "pre-write":
|
|
251
|
-
return print_pre_write(root, args.paths)
|
|
292
|
+
return print_pre_write(root, args.paths, args.plan)
|
|
252
293
|
if args.event == "post-write":
|
|
253
294
|
return print_post_write(args.paths)
|
|
254
295
|
if args.event == "stop":
|
|
255
|
-
return print_stop(root)
|
|
296
|
+
return print_stop(root, args.plan)
|
|
256
297
|
return 0
|
|
257
298
|
|
|
258
299
|
|
|
259
300
|
if __name__ == "__main__":
|
|
260
|
-
|
|
301
|
+
try:
|
|
302
|
+
raise SystemExit(main())
|
|
303
|
+
except (OSError, ValueError) as exc:
|
|
304
|
+
print(f"ERROR: hook 未完成检查:{exc}", file=sys.stderr)
|
|
305
|
+
raise SystemExit(1)
|