plan-governance-cli 2.0.2 → 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.
@@ -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
- function error(message) {
64
+ class UsageError extends Error { code = 2; }
65
+
66
+ function error(message, status = 1) {
65
67
  console.error(`graph: ERROR: ${message}`);
66
- return 1;
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 parseValidateArgs(args) {
411
- const options = { layer: "functional", root: process.cwd() };
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
- for (let index = 0; index < args.length; index += 1) {
414
- const arg = args[index];
415
- if (arg === "--layer") options.layer = args[++index];
416
- else if (arg === "-h" || arg === "--help") return null;
417
- else positional.push(arg);
418
- }
419
- if (!["functional", "architecture"].includes(options.layer)) {
420
- throw new Error("validate --layer 只能是 functional 或 architecture");
421
- }
422
- if (positional.length > 1) throw new Error("validate 只能接收一个 root 路径");
423
- if (positional.length === 1) options.root = resolve(positional[0]);
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 parseImpactArgs(args) {
428
- const options = { from: null, depth: 2, format: "text", root: process.cwd() };
429
- const positional = [];
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
- const options = { input: null, format: "text", root: process.cwd() };
447
- const positional = [];
448
- for (let index = 0; index < args.length; index += 1) {
449
- const arg = args[index];
450
- if (arg === "--input") options.input = args[++index];
451
- else if (arg === "--format") options.format = args[++index];
452
- else if (arg === "-h" || arg === "--help") return null;
453
- else positional.push(arg);
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 && existsSync(resolve(root, 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", root: process.cwd() };
543
- const positional = [];
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", root: process.cwd() };
592
- const positional = [];
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?.error) {
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 : 1;
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
 
@@ -61,6 +70,7 @@ def agent_rules_body():
61
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
- shutil.copy2(source, target)
219
- target.chmod(0o755)
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
- args = parse_args(argv or sys.argv[1:])
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, plan_map_content(plan_slug, title, args.status, args.phase, created_on), args.force)
440
+ write_file(plan_map, map_text, args.force)
375
441
  created.append(plan_map)
376
- write_file(plan_file, plan_content(plan_slug, title, args.status, args.phase, args.goal), args.force)
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 (FileExistsError, FileNotFoundError, ValueError) as exc:
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
- print_gate_hints(root, {plan["name"] for plan in active})
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
- print_gate_hints(root, {plan["name"] for plan in matches})
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(checker), "."],
219
- cwd=root,
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 = (result.stdout or result.stderr).strip()
225
- if output:
226
- print(output)
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
- return parser.parse_args(argv)
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
- raise SystemExit(main())
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)