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.
@@ -600,9 +600,9 @@ def risk_review_state(plan_text, phase):
600
600
  return result
601
601
 
602
602
 
603
- def check_phase_structure(plan_name, data, plan_text, strict, warnings, errors, review_state=None):
603
+ def check_phase_structure(plan_name, data, plan_text, strict, warnings, errors, review_state=None, selected=False):
604
604
  """检查待实施/实施中计划的阶段准入结构,不判断业务证据真实性。"""
605
- if data["status"] not in ACTIVE:
605
+ if data["status"] not in ACTIVE and not selected:
606
606
  return
607
607
 
608
608
  current_phase = data["phase"]
@@ -1452,6 +1452,8 @@ def create_attestation(
1452
1452
  attest_files=None,
1453
1453
  ):
1454
1454
  binding = None
1455
+ if errors:
1456
+ return None
1455
1457
  if attest_files is not None:
1456
1458
  if errors:
1457
1459
  return None
@@ -1567,7 +1569,7 @@ def attestation_issue(warnings, errors, strict, message):
1567
1569
 
1568
1570
 
1569
1571
  def warn_attestation_drift(warnings, root, plans, errors=None, strict=False, reports=None,
1570
- pending=None, enforce_binding_review=True):
1572
+ pending=None, enforce_binding_review=True, selected=None):
1571
1573
  errors = errors if errors is not None else []
1572
1574
  attestations_dir = root / "docs" / "attestations"
1573
1575
  try:
@@ -1599,6 +1601,8 @@ def warn_attestation_drift(warnings, root, plans, errors=None, strict=False, rep
1599
1601
  bound = "binding" in attestation
1600
1602
  invalid_structure = False
1601
1603
  plan_name = str(attestation.get("plan", "")).strip()
1604
+ if selected and plan_name in plans and plan_name not in selected:
1605
+ continue
1602
1606
  if not plan_name or plan_name not in plans:
1603
1607
  message = f"{path}: 快照引用了未登记计划:{plan_name or '<missing>'}"
1604
1608
  if not bound:
@@ -1898,6 +1902,10 @@ def plan_index_issues(plan_map_text):
1898
1902
  status = row[1].strip("` ")
1899
1903
  if status not in VALID_STATUSES:
1900
1904
  issues.append(f"{name}: 计划状态非法:{status}")
1905
+ try:
1906
+ parse_plan_date(row[3].strip("` "))
1907
+ except ValueError:
1908
+ issues.append(f"{name}: 最后更新日期不合法:{row[3]}")
1901
1909
  return issues
1902
1910
 
1903
1911
 
@@ -1992,12 +2000,12 @@ def structural_ambiguities(plan_text, phase):
1992
2000
  return issues
1993
2001
 
1994
2002
 
1995
- def phase_gate(plan_map_text, plan_name, data, plan_text):
2003
+ def phase_gate(plan_map_text, plan_name, data, plan_text, selected=False):
1996
2004
  """共享只读派生;严重级别由 check/workset/hook 的入口契约决定。"""
1997
2005
  issues = structural_ambiguities(plan_text, data["phase"])
1998
2006
  warnings = []
1999
2007
  review_state = risk_review_state(plan_text, data["phase"])
2000
- check_phase_structure(plan_name, data, plan_text, True, warnings, issues, review_state)
2008
+ check_phase_structure(plan_name, data, plan_text, True, warnings, issues, review_state, selected=selected)
2001
2009
  if review_state["enabled"]:
2002
2010
  issues.extend(review_state["issues"] + review_state["blockers"])
2003
2011
  if data["status"] in ACTIVE and is_placeholder(data["phase"]):
@@ -2008,17 +2016,28 @@ def phase_gate(plan_map_text, plan_name, data, plan_text):
2008
2016
  indexed = mapped.get(plan_name, [])
2009
2017
  summary = current_summary(plan_text)
2010
2018
  value = summary.get("当前阻塞项", "").strip()
2011
- declared = [value] if value and not is_placeholder(value) else []
2019
+ declared = [value] if value and not is_placeholder(value) and not re.match(r"^无(?:[;;,,。]|$)", value) else []
2012
2020
  blockers = list(dict.fromkeys(details + indexed + declared))
2021
+ issue_codes = {}
2013
2022
  if blockers:
2014
- issues.append("当前阶段存在未解决阻塞项:" + ";".join(blockers))
2023
+ message = "当前阶段存在未解决阻塞项:" + ";".join(blockers)
2024
+ issues.append(message)
2025
+ issue_codes[message] = "CURRENT_PHASE_BLOCKED"
2015
2026
  if ((details or indexed) and value == "无") or ((details or declared) and not indexed):
2016
2027
  issues.append("当前阻塞来源需同步:PLAN_MAP 索引、未决问题或阶段准入摘要缺失/冲突")
2017
2028
  if review_state["enabled"]:
2018
2029
  blockers.extend(review_state["blockers"])
2019
2030
  else:
2020
2031
  legacy_state = legacy_review_state(plan_text, data["phase"])
2021
- blockers.extend(legacy_state["blockers"])
2032
+ history = [r for r in review_history_rows(plan_text) if len(r) == 6 and r[2] == data["phase"]]
2033
+ completion_positions = [i for i, r in enumerate(history)
2034
+ if "完成" in r[1] and review_outcome(r[3]) == "passed"
2035
+ and valid_review_date(r[0]) is not None
2036
+ and not any(is_placeholder(v) for v in r)]
2037
+ completed_review = (data["status"] in COMPLETED and completion_positions
2038
+ and all(review_outcome(r[3]) == "passed" for r in history[completion_positions[-1]:]))
2039
+ if not completed_review:
2040
+ blockers.extend(legacy_state["blockers"])
2022
2041
  review = key_value_table(fixed_section(plan_text, "最新独立准入复核"))
2023
2042
  conclusion = review.get("结论", "").strip()
2024
2043
  if review.get("阶段") == data["phase"] and conclusion.startswith(("未通过", "不通过", "失败", "不满足", "拒绝")):
@@ -2029,17 +2048,26 @@ def phase_gate(plan_map_text, plan_name, data, plan_text):
2029
2048
  warnings.extend(recent_warnings)
2030
2049
  issues = [message.removeprefix(f"{plan_name}: ") for message in issues]
2031
2050
  return {"blockers": list(dict.fromkeys(blockers)), "issues": list(dict.fromkeys(issues)),
2032
- "warnings": warnings, "review": review_state}
2051
+ "warnings": warnings, "review": review_state, "issue_codes": issue_codes}
2033
2052
 
2034
2053
 
2035
- def check_phase_readiness(plan_map_text, plan_name, data, plan_text, strict, warnings, errors):
2054
+ def check_phase_readiness(plan_map_text, plan_name, data, plan_text, strict, warnings, errors, selected=False):
2055
+ if selected and data["status"] in INACTIVE:
2056
+ readiness_issue(warnings, errors, strict, f"{plan_name}: 已废弃/替代/合并的计划不能准入")
2057
+ return
2036
2058
  if data["status"] not in WARNING_ACTIVE:
2037
2059
  return
2038
- gate = phase_gate(plan_map_text, plan_name, data, plan_text)
2060
+ gate = phase_gate(plan_map_text, plan_name, data, plan_text, selected=selected)
2039
2061
  warnings.extend(f"{plan_name}: {message}" for message in gate["warnings"])
2040
2062
  for message in gate["issues"]:
2063
+ for target in (errors, warnings):
2064
+ if isinstance(target, DiagnosticList):
2065
+ sources = target.metadata["sources"]
2066
+ target.context(plan_name, data["phase"], category="readiness",
2067
+ code=gate["issue_codes"].get(message, "PHASE_NOT_READY"),
2068
+ path=sources[0]["path"] if sources else data["path"], section="当前阶段")
2041
2069
  # 缺失材料的设计计划仍合法;其显式冲突和阻塞保持可见而不强制准入。
2042
- readiness_issue(warnings, errors, strict and data["status"] in ACTIVE, f"{plan_name}: {message}")
2070
+ readiness_issue(warnings, errors, strict and (data["status"] in ACTIVE or selected), f"{plan_name}: {message}")
2043
2071
 
2044
2072
 
2045
2073
  def top_level_section(text, heading):
@@ -2343,10 +2371,244 @@ def recent_evidence(plan_text, phase=None):
2343
2371
  return current_recent_evidence(plan_text, phase)[0]
2344
2372
 
2345
2373
 
2346
- def workset_payload(root, include_history=False, strict=False):
2374
+ class UsageError(ValueError):
2375
+ pass
2376
+
2377
+
2378
+ def validate_options(parser, argv, repeatable=()):
2379
+ """Argparse handles values/types; reject ambiguous duplicate declarations."""
2380
+ seen = set()
2381
+ for token in argv:
2382
+ if token == "--":
2383
+ break
2384
+ option = token.split("=", 1)[0]
2385
+ action = parser._option_string_actions.get(option)
2386
+ if action is None or action.dest in repeatable:
2387
+ continue
2388
+ if action.dest in seen:
2389
+ parser.error(f"{option} 只能指定一次")
2390
+ seen.add(action.dest)
2391
+
2392
+
2393
+ def nonempty(value):
2394
+ if not value.strip():
2395
+ raise argparse.ArgumentTypeError("参数值不能为空")
2396
+ return value
2397
+
2398
+
2399
+ def scoped_table(text, title, keep):
2400
+ section = top_level_section(text, title)
2401
+ if section is None:
2402
+ return text
2403
+ rows = markdown_table_rows(section)
2404
+ selected = [row for i, row in enumerate(rows) if i == 0 or keep(row)]
2405
+ replacement = "\n\n" + "\n".join("| " + " | ".join(row) + " |" for row in selected) + "\n\n"
2406
+ start = re.search(rf"^##\s+{re.escape(title)}\s*$", mask_fenced_code(text), re.MULTILINE).end()
2407
+ return text[:start] + replacement + text[start + len(section):]
2408
+
2409
+
2410
+ def scoped_map(text, names):
2411
+ for title in ("阶段关系", "机器可检查共享写入约束"):
2412
+ text = scoped_table(text, title, lambda row: len(row) < 3 or bool({clean_relation_value(row[0]), clean_relation_value(row[2])} & names))
2413
+ return text
2414
+
2415
+
2416
+ def historical_phase_review(text, phase):
2417
+ """复用历史校验,不使用后续阶段的最新结果替代来源阶段。"""
2418
+ for title, width in (("阶段复核记录", 8), ("独立复核记录", 6)):
2419
+ rows = markdown_table_rows(fixed_section(text, title))
2420
+ expected = (["日期", "类型", "阶段", "方式", "风险", "结论", "证据", "复核者"]
2421
+ if width == 8 else ["日期", "类型", "阶段", "结论", "证据", "复核者"])
2422
+ relevant = [r for r in rows[1:] if len(r) < 3 or r[2] == phase or not valid_phase_name(r[2])]
2423
+ if relevant and (rows[0] != expected or rows.count(expected) > 1
2424
+ or any(len(r) != width or len(r) < 3 or not valid_phase_name(r[2]) for r in relevant)):
2425
+ return {"passed": False, "issues": [f"来源阶段 {phase} 的 {title} 结构无效"], "blockers": []}
2426
+ explicit = [r for r in markdown_table_rows(fixed_section(text, "阶段复核记录")) if len(r) == 8 and r[2] == phase]
2427
+ legacy = [r for r in review_history_rows(text) if len(r) == 6 and r[2] == phase]
2428
+ if explicit:
2429
+ row = explicit[-1]
2430
+ latest = dict(zip(["日期", "阶段", "方式", "风险", "结论", "证据", "复核者"], [row[0], row[2], *row[3:]]))
2431
+ latest["风险依据"] = "来源阶段历史证据"
2432
+ synthetic = "## 当前阶段\n\n### 阶段准入摘要\n\n| 字段 | 内容 |\n|---|---|\n| 复核策略 | 默认自验 |\n| 最新阶段复核 | #最新阶段复核 |\n\n## 最新阶段复核\n\n| 字段 | 内容 |\n|---|---|\n"
2433
+ synthetic += "\n".join(f"| {key} | {value} |" for key, value in latest.items())
2434
+ synthetic += "\n\n## 阶段复核记录\n\n| 日期 | 类型 | 阶段 | 方式 | 风险 | 结论 | 证据 | 复核者 |\n|---|---|---|---|---|---|---|---|\n"
2435
+ synthetic += "\n".join("| " + " | ".join(r) + " |" for r in explicit)
2436
+ return risk_review_state(synthetic, phase)
2437
+ if legacy:
2438
+ row = legacy[-1]
2439
+ synthetic = "## 最新独立准入复核\n\n| 字段 | 内容 |\n|---|---|\n"
2440
+ synthetic += "\n".join(f"| {key} | {value} |" for key, value in zip(["日期", "阶段", "结论", "证据", "复核者"], [row[0], *row[2:]]))
2441
+ synthetic += "\n\n## 独立复核记录\n\n| 日期 | 类型 | 阶段 | 结论 | 证据 | 复核者 |\n|---|---|---|---|---|---|\n"
2442
+ synthetic += "\n".join("| " + " | ".join(r) + " |" for r in legacy)
2443
+ return legacy_review_state(synthetic, phase)
2444
+ return {"passed": False, "issues": [f"来源阶段 {phase} 缺少有效复核历史"], "blockers": []}
2445
+
2446
+
2447
+ def build_scope(root, requested, text, plans):
2448
+ if len(requested) != len(set(requested)):
2449
+ raise UsageError("--plan 不接受重复计划 ID")
2450
+ unknown = set(requested) - set(plans)
2451
+ if unknown:
2452
+ raise UsageError("未登记的 --plan:" + "、".join(sorted(unknown)))
2453
+ scope = {"requested_plans": requested, "focused_phases": [{"plan": n, "phase": plans[n]["phase"]} for n in requested],
2454
+ "evaluated_plans": list(requested), "dependencies": [], "evidence_links": [], "shared_writes": [], "global_diagnostics": []}
2455
+ relation_header, relations = relation_rows(text)
2456
+ shared_header, shared = shared_write_rows(text)
2457
+ relations = [[clean_relation_value(cell) for cell in row] for row in relations]
2458
+ shared = [[clean_relation_value(cell) for cell in row] for row in shared]
2459
+ if relation_header == [] or shared_header == []:
2460
+ scope["global_diagnostics"].append("相关关系表缺少固定表头,无法可信确认作用域")
2461
+ queue = [(name, plans[name]["phase"]) for name in requested]
2462
+ visited = set()
2463
+ edges = {}
2464
+ for name, phase in queue:
2465
+ if (name, phase) in visited:
2466
+ continue
2467
+ visited.add((name, phase))
2468
+ # 已完成前置由完成证据核验,不重新开启其已经关闭的历史依赖链。
2469
+ if plans[name]["status"] in COMPLETED:
2470
+ continue
2471
+ incoming = [r for r in relations if len(r) == 7 and clean_relation_value(r[2]) == name and clean_relation_value(r[3]) == phase and r[4] == "hard_gate"]
2472
+ predecessors = [(clean_relation_value(r[0]), clean_relation_value(r[1]), r[5], r[6]) for r in incoming]
2473
+ explicit = {row[0] for row in predecessors}
2474
+ for dep in extract_declared_dependencies(plans[name]["depends"]) if phase == plans[name]["phase"] else []:
2475
+ if dep not in explicit:
2476
+ predecessors.append((dep, plans.get(dep, {}).get("phase", ""), "计划索引依赖", "PLAN_MAP.md"))
2477
+ for dep, source_phase, unlock, evidence in predecessors:
2478
+ if dep not in plans:
2479
+ scope["global_diagnostics"].append(f"{name}: 必要前置不存在:{dep}")
2480
+ continue
2481
+ edges.setdefault(f"{name}@{phase}", []).append(f"{dep}@{source_phase}")
2482
+ record = {"plan": dep, "phase": source_phase, "required_by": name, "reason": unlock, "evidence": evidence}
2483
+ if record not in scope["dependencies"]:
2484
+ scope["dependencies"].append(record)
2485
+ queue.append((dep, source_phase))
2486
+ for cycle in detect_dependency_cycles(edges):
2487
+ scope["global_diagnostics"].append(f"相关阶段依赖存在环:{cycle}")
2488
+ names = {name for name, _ in visited}
2489
+ scope["evaluated_plans"] = sorted(names)
2490
+ filtered = scoped_map(text, names)
2491
+ for row in relations:
2492
+ if len(row) == 7 and {row[0], row[2]} & names and row[4] in {"evidence", "soft_context"}:
2493
+ scope["evidence_links"].append({"plan": row[0], "phase": row[1], "required_by": row[2], "relation": row[4], "evidence": row[6]})
2494
+ for row in shared:
2495
+ if len(row) == 9 and {row[0], row[2]} & names:
2496
+ scope["shared_writes"].append({"plans": [row[0], row[2]], "targets": row[5], "order": row[6], "reason": row[7], "evidence": row[8]})
2497
+ # 同伴仅检查范围声明,不投影其业务状态。
2498
+ affected = {}
2499
+ for name, data in plans.items():
2500
+ if name in names or data["status"] in WARNING_ACTIVE:
2501
+ try:
2502
+ affected[name] = extract_affected_targets(data["path"].read_text(encoding="utf-8"))
2503
+ except (OSError, UnicodeError):
2504
+ affected[name] = []
2505
+ for name in requested:
2506
+ for peer, targets in affected.items():
2507
+ if peer in requested:
2508
+ continue
2509
+ overlap = sorted({a for a in affected.get(name, []) for b in targets if a == b or a.startswith(b.rstrip("/") + "/") or b.startswith(a.rstrip("/") + "/")})
2510
+ if overlap:
2511
+ scope["shared_writes"].append({"plans": [name, peer], "targets": overlap, "reason": "影响范围重叠;需要单一写入或串行合并"})
2512
+ projection = {"index": [r for r in table_rows(text, "计划索引") if r and (link := extract_plan_link(r[0])) and Path(link).stem in names],
2513
+ "relations": [r for r in relations if len(r) < 3 or {r[0], r[2]} & names],
2514
+ "shared": scope["shared_writes"], "headers": [relation_header, shared_header],
2515
+ "scope_issues": scope["global_diagnostics"], "identity_issues": plan_index_issues(text),
2516
+ "blockers": {k: v for k, v in map_blockers(text)[0].items() if k in names}, "blocker_issues": map_blockers(text)[1]}
2517
+ contents = {}
2518
+ for name in sorted(names):
2519
+ try:
2520
+ contents[name] = sha256_file(plans[name]["path"])
2521
+ except OSError:
2522
+ contents[name] = None
2523
+ scope["facts_version"] = hashlib.sha256(json.dumps([projection, contents], ensure_ascii=False, sort_keys=True).encode()).hexdigest()
2524
+ return scope, filtered
2525
+
2526
+
2527
+ def dependency_diagnostics(root, scope, text, plans):
2528
+ errors = list(scope["global_diagnostics"])
2529
+ for dep in scope["dependencies"]:
2530
+ name, phase = dep["plan"], dep["phase"]
2531
+ data = plans[name]
2532
+ read_errors = []
2533
+ body = read_utf8(data["path"], read_errors)
2534
+ reasons = list(read_errors)
2535
+ if data["status"] in INACTIVE:
2536
+ reasons.append("必要前置已废弃/替代,须确认有效承接")
2537
+ elif phase != data["phase"]:
2538
+ review = historical_phase_review(body, phase)
2539
+ reasons.extend(review["issues"] + review["blockers"])
2540
+ if not review["passed"]:
2541
+ reasons.append(f"来源阶段 {phase} 未通过")
2542
+ elif data["status"] in COMPLETED:
2543
+ if not has_completion_evidence(body) or not has_coverage_evidence(body):
2544
+ reasons.append("已完成前置缺少完成/验证证据")
2545
+ reasons.extend(phase_gate(text, name, data, body)["blockers"])
2546
+ else:
2547
+ gate = phase_gate(text, name, data, body)
2548
+ reasons.extend(gate["issues"])
2549
+ review = gate["review"] if gate["review"]["enabled"] else legacy_review_state(body, phase)
2550
+ if not review["passed"]:
2551
+ reasons.append("必要前置当前阶段尚未通过")
2552
+ dep["phase_passed"] = not reasons
2553
+ dep["blockers"] = list(dict.fromkeys(reasons))
2554
+ errors.extend(f"{name}@{phase}: {reason}" for reason in dep["blockers"])
2555
+ return list(dict.fromkeys(errors))
2556
+
2557
+
2558
+ def scope_unchanged(root, scope):
2559
+ errors = []
2560
+ text, _, plans = load_plan_records(root, errors)
2561
+ if errors or plan_index_issues(text):
2562
+ return False
2563
+ try:
2564
+ fresh, _ = build_scope(root, scope["requested_plans"], text, plans)
2565
+ return fresh["facts_version"] == scope["facts_version"]
2566
+ except (UsageError, OSError, ValueError):
2567
+ return False
2568
+
2569
+
2570
+ def workset_payload(root, include_history=False, strict=False, requested=None):
2571
+ if not requested:
2572
+ return unscoped_workset_payload(root, include_history, strict)
2573
+ errors = []
2574
+ text, _, plans = load_plan_records(root, errors)
2575
+ errors.extend(plan_index_issues(text))
2576
+ empty = {"schema_version": 1, "source": "derived", "plans": [], "warnings": errors,
2577
+ "scope": {"requested_plans": requested, "evaluated_plans": [], "dependencies": []}}
2578
+ if errors:
2579
+ return empty, 1
2580
+ scope, selected_map = build_scope(root, requested, text, plans)
2581
+ output, status = unscoped_workset_payload(root, include_history, strict, focus=requested, supplied=(selected_map, plans))
2582
+ dep_errors = dependency_diagnostics(root, scope, selected_map, plans)
2583
+ output["warnings"].extend(dep_errors)
2584
+ output["scope"] = scope
2585
+ blocked = {d["required_by"] for d in scope["dependencies"] if not d["phase_passed"]}
2586
+ while True:
2587
+ expanded = blocked | {d["required_by"] for d in scope["dependencies"] if d["plan"] in blocked}
2588
+ if expanded == blocked:
2589
+ break
2590
+ blocked = expanded
2591
+ for item in output["plans"]:
2592
+ if item["plan"] in blocked or scope["global_diagnostics"]:
2593
+ item["readiness"] = "blocked"
2594
+ item["next_action"] = {"state": "known", "kind": "resolve_blocker", "reason": "必要前置尚未满足,见 scope.dependencies"}
2595
+ if not scope_unchanged(root, scope):
2596
+ output["warnings"].append("相关事实在读取期间发生变化;请重读聚焦工作集")
2597
+ for item in output["plans"]:
2598
+ item["readiness"] = "unknown"
2599
+ item["next_action"] = {"state": "unknown", "kind": "unknown", "reason": "相关事实已变更"}
2600
+ status = 1 if strict else status
2601
+ return output, 1 if scope["global_diagnostics"] or (strict and dep_errors) else status
2602
+
2603
+
2604
+ def unscoped_workset_payload(root, include_history=False, strict=False, focus=None, supplied=None):
2347
2605
  errors = []
2348
2606
  strict_failures = []
2349
- plan_map_text, _, plans = load_plan_records(root, errors)
2607
+ document_failures = []
2608
+ if supplied:
2609
+ plan_map_text, plans = supplied
2610
+ else:
2611
+ plan_map_text, _, plans = load_plan_records(root, errors)
2350
2612
  output = {
2351
2613
  "schema_version": 1,
2352
2614
  "source": "derived",
@@ -2368,20 +2630,23 @@ def workset_payload(root, include_history=False, strict=False):
2368
2630
  strict_failures.extend(relation_errors)
2369
2631
 
2370
2632
  for name, data in plans.items():
2371
- if not include_history and data["status"] not in WARNING_ACTIVE:
2633
+ if focus and name not in focus:
2634
+ continue
2635
+ if not focus and not include_history and data["status"] not in WARNING_ACTIVE:
2372
2636
  continue
2373
2637
  path = data["path"]
2374
2638
  if not path.exists():
2375
2639
  message = f"{name}: 计划文件不存在:{path}"
2376
2640
  output["warnings"].append(message)
2377
2641
  strict_failures.append(message)
2642
+ document_failures.append(message)
2378
2643
  continue
2379
2644
  plan_text = read_utf8(path, errors)
2380
2645
  summary = current_summary(plan_text)
2381
- gate = phase_gate(plan_map_text, name, data, plan_text)
2646
+ gate = phase_gate(plan_map_text, name, data, plan_text, selected=bool(focus) and strict and data["status"] in WARNING_ACTIVE)
2382
2647
  blockers = gate["blockers"]
2383
2648
  output["warnings"].extend(f"{name}: {message}" for message in gate["issues"] + gate["warnings"])
2384
- if data["status"] in ACTIVE:
2649
+ if data["status"] in ACTIVE or focus:
2385
2650
  strict_failures.extend(gate["issues"])
2386
2651
  uncertain = gate["issues"] or blocker_index_issues or relation_errors
2387
2652
  if blockers:
@@ -2436,6 +2701,17 @@ def workset_payload(root, include_history=False, strict=False):
2436
2701
  else:
2437
2702
  readiness = "design"
2438
2703
  action = {"state": "unknown", "kind": "unknown", "reason": "缺少结构化下一动作"}
2704
+ elif focus and data["status"] in COMPLETED:
2705
+ valid = has_completion_evidence(plan_text) and has_coverage_evidence(plan_text) and not blockers
2706
+ readiness = "unknown"
2707
+ action = {"state": "unknown", "kind": "unknown", "reason": "当前完成声明已检查;终态无下一实施动作" if valid else "完成声明缺少有效证据"}
2708
+ if not valid:
2709
+ strict_failures.append(f"{name}: 完成声明缺少有效证据")
2710
+ document_failures.append(f"{name}: 完成声明缺少有效证据")
2711
+ elif focus and data["status"] in INACTIVE:
2712
+ readiness = "unknown"
2713
+ action = {"state": "unknown", "kind": "unknown", "reason": "终态计划不能准入"}
2714
+ strict_failures.append(f"{name}: 终态计划不能准入")
2439
2715
  else:
2440
2716
  readiness = "unknown"
2441
2717
  action = {"state": "unknown", "kind": "unknown", "reason": "计划状态不在工作集派生范围"}
@@ -2460,7 +2736,7 @@ def workset_payload(root, include_history=False, strict=False):
2460
2736
  output["plans"] = []
2461
2737
  output["warnings"].extend(index_issues)
2462
2738
  strict_failures.extend(index_issues)
2463
- return output, 1 if strict and strict_failures else 0
2739
+ return output, 1 if errors or index_issues or document_failures or (strict and strict_failures) else 0
2464
2740
 
2465
2741
 
2466
2742
  def print_workset_text(payload, strict=False):
@@ -2519,26 +2795,91 @@ def limit_workset_evidence(root, payload, limit):
2519
2795
  return result
2520
2796
 
2521
2797
 
2522
- def run_workset(root, as_json=False, include_history=False, strict=False, evidence_limit=None):
2523
- payload, status = workset_payload(root, include_history=include_history, strict=strict)
2798
+ def run_workset(root, as_json=False, include_history=False, strict=False, evidence_limit=None, requested=None):
2799
+ payload, status = workset_payload(root, include_history=include_history, strict=strict, requested=requested)
2524
2800
  if as_json:
2525
2801
  if evidence_limit is not None:
2526
2802
  payload = limit_workset_evidence(root, payload, evidence_limit)
2803
+ scope = payload.get("scope")
2804
+ if scope and scope.get("facts_version") and not scope_unchanged(root, scope):
2805
+ payload["warnings"].append("证据窗口读取期间相关事实发生变化;请重读聚焦工作集")
2806
+ for item in payload["plans"]:
2807
+ item["readiness"] = "unknown"
2808
+ item["next_action"] = {"state": "unknown", "kind": "unknown", "reason": "相关事实已变更"}
2809
+ status = 1 if strict else status
2527
2810
  print(json.dumps(payload, ensure_ascii=False, indent=2))
2528
2811
  else:
2529
2812
  print_workset_text(payload, strict=strict)
2530
2813
  return status
2531
2814
 
2532
2815
 
2816
+ class DiagnosticList(list):
2817
+ def __init__(self, records, severity):
2818
+ super().__init__()
2819
+ self.records = records
2820
+ self.severity = severity
2821
+ self.context()
2822
+
2823
+ def context(self, plan=None, phase=None, category="document", code=None, path=None, section=None):
2824
+ self.metadata = {"plan": plan, "phase": phase, "category": category,
2825
+ "code": code or ("DOCUMENT_ERROR" if category == "document" else "PHASE_NOT_READY"),
2826
+ "sources": [{"path": str(path), "section": section}] if path else []}
2827
+
2828
+ def append(self, value):
2829
+ super().append(value)
2830
+ self.records.append({**self.metadata, "severity": self.severity, "message": value})
2831
+
2832
+ def extend(self, values):
2833
+ for value in values:
2834
+ self.append(value)
2835
+
2836
+
2837
+ def finish_check(args, records, scope=None, reports=None, attested_path=None, not_applicable=False):
2838
+ unique = {}
2839
+ for item in records:
2840
+ key = (item["plan"], item["phase"], item["code"], item["message"])
2841
+ if key not in unique or item["severity"] == "error":
2842
+ unique[key] = item
2843
+ diagnostics = list(unique.values())
2844
+ counts = {level + "s": sum(item["severity"] == level for item in diagnostics) for level in ("error", "warning")}
2845
+ status = "failed" if counts["errors"] else "not_applicable" if not_applicable else "warning" if counts["warnings"] else "passed"
2846
+ code = 1 if counts["errors"] else 0
2847
+ result = {"schema_version": 1, "command": "check", "mode": "readiness" if args.strict_readiness else "consistency",
2848
+ "status": status, "exit_code": code, "scope": scope or {"requested_plans": [], "evaluated_plans": getattr(args, "evaluated_plans", []), "dependencies": []},
2849
+ "summary": counts, "diagnostics": diagnostics}
2850
+ if reports:
2851
+ result["attestations"] = reports
2852
+ if attested_path:
2853
+ result["created_attestation"] = str(attested_path)
2854
+ if args.json:
2855
+ print(json.dumps(result, ensure_ascii=False, indent=2))
2856
+ else:
2857
+ for item in diagnostics:
2858
+ print(f"{item['severity'].upper()}: {item['message']}", file=sys.stderr if item["severity"] == "error" else sys.stdout)
2859
+ for report in reports or []:
2860
+ print(f"ATTESTATION: {report}")
2861
+ if attested_path:
2862
+ print(f"已创建完成快照:{attested_path}")
2863
+ if not code:
2864
+ if not_applicable:
2865
+ print("未执行:当前目录未启用计划治理。")
2866
+ elif counts["warnings"]:
2867
+ print(f"{'准入' if args.strict_readiness else '文档'}检查完成,有 {counts['warnings']} 项告警。")
2868
+ else:
2869
+ print("准入检查通过。" if args.strict_readiness else "计划治理检查通过(文档一致性)。")
2870
+ return code
2871
+
2872
+
2533
2873
  def parse_args(argv):
2534
- parser = argparse.ArgumentParser(description="检查计划治理文档的一致性。")
2535
- parser.add_argument("root", nargs="?", default=".", help="仓库根目录,默认当前目录。")
2874
+ parser = argparse.ArgumentParser(description="检查计划治理文档的一致性;--strict-readiness 用于准入。", allow_abbrev=False)
2875
+ parser.add_argument("root", nargs="?", type=nonempty, default=None, help="仓库根目录,默认当前目录。")
2536
2876
  parser.add_argument("--workset", action="store_true", help="输出只读当前工作集。")
2537
2877
  parser.add_argument("--json", action="store_true", help="以 JSON 输出工作集。")
2538
2878
  parser.add_argument("--include-history", action="store_true", help="工作集包含历史计划。")
2539
2879
  parser.add_argument("--evidence-limit", type=int, metavar="N",
2540
2880
  help="仅工作集 JSON:每个计划显示末尾 N 条记录(正整数),省略时返回全部。")
2541
- parser.add_argument("--root", dest="mode_root", help="工作集的仓库根目录。")
2881
+ parser.add_argument("--root", dest="mode_root", type=nonempty, help="仓库根目录,与 positional root 二选一。")
2882
+ parser.add_argument("--plan", action="append", type=nonempty, default=[], help="可重复:聚焦已登记计划 ID,各会话独立传入。")
2542
2883
  parser.add_argument("--drift", action="store_true", help="检查工作区变更是否被活跃计划影响范围覆盖。")
2543
2884
  parser.add_argument("--pre-commit", action="store_true", help="检查 staged 变更是否被活跃计划影响范围覆盖。")
2544
2885
  parser.add_argument(
@@ -2546,15 +2887,15 @@ def parse_args(argv):
2546
2887
  action="store_true",
2547
2888
  help="将待实施/实施中计划的阶段准入结构缺陷从 WARNING 提升为 ERROR。",
2548
2889
  )
2549
- parser.add_argument("--attest", metavar="PLAN", help="为已登记计划创建或覆盖完成快照。")
2550
- parser.add_argument("--attest-file", action="append", metavar="PATH",
2890
+ parser.add_argument("--attest", type=nonempty, metavar="PLAN", help="为已登记计划创建或覆盖完成快照。")
2891
+ parser.add_argument("--attest-file", type=nonempty, action="append", metavar="PATH",
2551
2892
  help="可重复:与 --attest/--attest-purpose 显式绑定被审查的普通文件。")
2552
2893
  parser.add_argument(
2553
2894
  "--attest-purpose",
2554
2895
  choices=sorted(ATTESTATION_PURPOSES),
2555
2896
  help="创建带 purpose/snapshot_id 关系的完成快照;不传时保持旧 <plan>.json 格式。",
2556
2897
  )
2557
- parser.add_argument("--supersedes", help="新 attestation 要替代的仓库内相对快照路径。")
2898
+ parser.add_argument("--supersedes", type=nonempty, help="新 attestation 要替代的仓库内相对快照路径。")
2558
2899
  parser.add_argument(
2559
2900
  "--review-status",
2560
2901
  choices=sorted(ATTESTATION_REVIEW_STATUSES),
@@ -2570,7 +2911,30 @@ def parse_args(argv):
2570
2911
  default=None,
2571
2912
  help="检查活跃计划是否超过 N 天未更新;省略 N 时默认 10 天。",
2572
2913
  )
2914
+ validate_options(parser, argv, {"plan", "attest_file"})
2573
2915
  args = parser.parse_args(argv)
2916
+ if args.mode_root is not None and args.root is not None:
2917
+ parser.error("root 与 --root 不能同时使用")
2918
+ args.root = args.mode_root or args.root or "."
2919
+ if len(args.plan) != len(set(args.plan)):
2920
+ parser.error("--plan 不接受重复 ID")
2921
+ if args.workset:
2922
+ if any((args.drift, args.pre_commit, args.stale_days is not None, args.attest, args.attest_file is not None, args.attest_purpose, args.supersedes, args.check_attestations)):
2923
+ parser.error("workset 不接受 check 的检查/快照参数")
2924
+ elif args.include_history:
2925
+ parser.error("--include-history 仅适用于 workset")
2926
+ if args.attest and any((args.plan, args.drift, args.pre_commit, args.workset)):
2927
+ parser.error("--attest 不能与 workset/plan/drift/pre-commit 混用")
2928
+ if args.attest_file is not None and (not args.attest or not args.attest_purpose):
2929
+ parser.error("--attest-file 必须与 --attest/--attest-purpose 一起使用")
2930
+ if (args.attest_purpose or args.supersedes) and not args.attest:
2931
+ parser.error("--attest-purpose/--supersedes 必须与 --attest 一起使用")
2932
+ if any(token.split("=", 1)[0] == "--review-status" for token in argv) and not args.attest_purpose:
2933
+ parser.error("--review-status 必须与 --attest/--attest-purpose 一起使用")
2934
+ if args.supersedes and not args.attest_purpose:
2935
+ parser.error("--supersedes 必须与 --attest-purpose 一起使用")
2936
+ if args.stale_days is not None and args.stale_days < 0:
2937
+ parser.error("--stale-days 必须是非负整数")
2574
2938
  if args.evidence_limit is not None:
2575
2939
  if args.evidence_limit <= 0:
2576
2940
  parser.error("--evidence-limit 必须为正整数")
@@ -2584,63 +2948,61 @@ def main(argv=None):
2584
2948
  if args.attest_file is not None and (not args.attest or not args.attest_purpose or args.workset):
2585
2949
  print("ERROR: --attest-file 必须与 --attest/--attest-purpose 一起使用,不能用于 workset")
2586
2950
  return 1
2951
+ root = Path(args.root).expanduser().resolve()
2587
2952
  if args.workset:
2953
+ if not root.is_dir():
2954
+ payload = {"schema_version": 1, "source": "derived", "plans": [], "warnings": [f"root 不存在或不是目录:{root}"]}
2955
+ print(json.dumps(payload, ensure_ascii=False) if args.json else payload["warnings"][0])
2956
+ return 1
2588
2957
  return run_workset(
2589
- Path(args.mode_root or args.root),
2958
+ root,
2590
2959
  as_json=args.json,
2591
2960
  include_history=args.include_history,
2592
2961
  strict=args.strict_readiness,
2593
2962
  evidence_limit=args.evidence_limit,
2963
+ requested=args.plan,
2594
2964
  )
2595
- root = Path(args.root)
2596
2965
  docs = root / "docs"
2597
2966
  plan_map = docs / "PLAN_MAP.md"
2598
- errors = []
2599
- warnings = []
2967
+ records = []
2968
+ errors = DiagnosticList(records, "error")
2969
+ warnings = DiagnosticList(records, "warning")
2970
+ scope = None
2971
+ if not root.is_dir():
2972
+ errors.context(category="runtime", code="INVALID_ROOT")
2973
+ errors.append(f"root 不存在或不是目录:{root}")
2974
+ return finish_check(args, records)
2600
2975
 
2601
2976
  try:
2602
2977
  bound_input = args.attest_file is not None or (args.check_attestations and has_attestation_binding(root))
2603
2978
  except (ValueError, OSError) as exc:
2604
- level = "ERROR" if args.strict_readiness else "WARNING"
2605
- print(f"{level}: 快照清单无法读取:{exc}")
2606
- return 1 if args.strict_readiness else 0
2979
+ attestation_issue(warnings, errors, args.strict_readiness, f"快照清单无法读取:{exc}")
2980
+ return finish_check(args, records)
2607
2981
  unsafe_map = False
2608
2982
  if bound_input:
2609
2983
  try:
2610
2984
  binding_file(root, "docs/PLAN_MAP.md")
2611
2985
  except (ValueError, OSError) as exc:
2612
2986
  if args.attest_file is not None:
2613
- print(f"ERROR: binding 创建预检失败:{exc}")
2614
- return 1
2987
+ errors.append(f"binding 创建预检失败:{exc}")
2988
+ return finish_check(args, records)
2615
2989
  unsafe_map = True
2616
2990
  if unsafe_map:
2617
2991
  reports = []
2618
2992
  warn_attestation_drift(warnings, root, {}, errors=errors, strict=args.strict_readiness, reports=reports)
2619
- for warning in warnings:
2620
- print(f"WARNING: {warning}")
2621
- for report in reports:
2622
- print(f"ATTESTATION: {report}")
2623
- for error in errors:
2624
- print(f"ERROR: {error}")
2625
- return 1 if errors else 0
2993
+ return finish_check(args, records, reports=reports)
2626
2994
  if not plan_map.exists():
2995
+ reports = []
2627
2996
  if args.check_attestations:
2628
- reports = []
2629
2997
  warn_attestation_drift(warnings, root, {}, errors=errors, strict=args.strict_readiness, reports=reports)
2630
- if reports or errors:
2631
- for warning in warnings:
2632
- print(f"WARNING: {warning}")
2633
- for report in reports:
2634
- print(f"ATTESTATION: {report}")
2635
- for error in errors:
2636
- print(f"ERROR: {error}")
2637
- return 1 if errors else 0
2638
- print("未找到 docs/PLAN_MAP.md;当前仓库尚未初始化计划治理。")
2639
- return 0
2998
+ readiness_issue(warnings, errors, args.strict_readiness or bool(args.plan), "未找到 docs/PLAN_MAP.md;当前仓库尚未初始化计划治理")
2999
+ return finish_check(args, records, reports=reports, not_applicable=not errors)
2640
3000
 
2641
3001
  text = read_utf8(plan_map, errors)
2642
3002
  _, blocker_index_issues = map_blockers(text)
2643
- for issue in plan_index_issues(text) + blocker_index_issues:
3003
+ for issue in plan_index_issues(text):
3004
+ fail(errors, issue)
3005
+ for issue in blocker_index_issues:
2644
3006
  readiness_issue(warnings, errors, args.strict_readiness, issue)
2645
3007
  plan_rows = table_rows(text, "计划索引")
2646
3008
  if not plan_rows:
@@ -2674,7 +3036,7 @@ def main(argv=None):
2674
3036
  attestation_issue(warnings, errors, args.attest_file is not None or args.strict_readiness,
2675
3037
  f"{name}: binding 计划读取预检失败:{exc}")
2676
3038
  unsafe_plans.add(name)
2677
- if not path.exists():
3039
+ if not path.exists() and (not args.plan or name in args.plan):
2678
3040
  fail(errors, f"docs/PLAN_MAP.md: 引用的计划文件不存在:{link}")
2679
3041
  plans[name] = {
2680
3042
  "path": path,
@@ -2686,6 +3048,12 @@ def main(argv=None):
2686
3048
  else:
2687
3049
  fail(errors, f"docs/PLAN_MAP.md: 计划行缺少 docs/plans 链接:{row[0]}")
2688
3050
 
3051
+ if args.plan and not errors:
3052
+ scope, text = build_scope(root, args.plan, text, plans)
3053
+ elif args.plan:
3054
+ return finish_check(args, records)
3055
+
3056
+ args.evaluated_plans = list(plans)
2689
3057
  readable_plans = {name: data for name, data in plans.items() if name not in unsafe_plans}
2690
3058
  relation_errors, relation_warnings = validate_relation_graph(text, readable_plans)
2691
3059
  warnings.extend(relation_warnings)
@@ -2698,31 +3066,38 @@ def main(argv=None):
2698
3066
  inactive = {name for name, data in plans.items() if data["status"] in INACTIVE}
2699
3067
  for name, data in plans.items():
2700
3068
  deps = extract_declared_dependencies(data["depends"])
3069
+ if not scope:
3070
+ for missing in set(deps) - set(plans):
3071
+ errors.append(f"{name}: 声明依赖的计划不存在:{missing}")
2701
3072
  edges[name] = [dep for dep in deps if dep in plans]
2702
- if data["status"] in IMPLEMENTING:
3073
+ if data["status"] in IMPLEMENTING and (not scope or name in scope["evaluated_plans"]):
2703
3074
  for dep in edges[name]:
2704
3075
  if dep in inactive:
2705
3076
  fail(errors, f"{name}: 实施中计划依赖了非活跃计划 {dep}")
2706
3077
 
2707
3078
  for cycle in detect_dependency_cycles(edges):
2708
- fail(errors, f"计划依赖存在环:{cycle}")
3079
+ if not scope:
3080
+ fail(errors, f"计划依赖存在环:{cycle}")
2709
3081
 
2710
- for orphan in find_orphan_plans(docs, plans):
2711
- warn(warnings, f"{orphan}: docs/plans 中存在未登记到 PLAN_MAP.md 的孤立计划")
3082
+ if not scope:
3083
+ for orphan in find_orphan_plans(docs, plans):
3084
+ warn(warnings, f"{orphan}: docs/plans 中存在未登记到 PLAN_MAP.md 的孤立计划")
2712
3085
 
2713
3086
  active_plan_targets = {}
2714
3087
  plan_texts = {}
2715
3088
  for name, data in plans.items():
2716
- if name in unsafe_plans:
3089
+ if name in unsafe_plans or (scope and name not in args.plan):
2717
3090
  continue
3091
+ for target in (errors, warnings):
3092
+ target.context(name, data["phase"], path=data["path"].relative_to(root))
2718
3093
  plan_text = read_utf8(data["path"], errors)
2719
3094
  plan_texts[name] = plan_text
2720
3095
  if data["status"] in COMPLETED and not has_completion_evidence(plan_text):
2721
3096
  fail(errors, f"{data['path']}: 已完成计划缺少有效 Step 0 证据或验证方式")
2722
3097
  if data["status"] in COMPLETED and not has_coverage_evidence(plan_text):
2723
3098
  fail(errors, f"{data['path']}: 已完成计划缺少测试覆盖率证据")
2724
- if data["status"] in ACTIVE and has_current_blocker(plan_text):
2725
- fail(errors, f"{data['path']}: 活跃计划仍有未解决的当前阶段阻塞项")
3099
+ if data["status"] in COMPLETED and phase_gate(text, name, data, plan_text)["blockers"]:
3100
+ fail(errors, f"{data['path']}: 已完成计划仍有当前阶段阻塞/失败")
2726
3101
  if data["status"] in WARNING_ACTIVE:
2727
3102
  targets = extract_affected_targets(plan_text)
2728
3103
  plan_relative = data["path"].relative_to(root).as_posix()
@@ -2739,6 +3114,8 @@ def main(argv=None):
2739
3114
  )
2740
3115
  active_plan_targets[name] = targets
2741
3116
 
3117
+ for target in (errors, warnings):
3118
+ target.context(name, data["phase"], category="readiness", path=data["path"].relative_to(root), section="当前阶段")
2742
3119
  check_phase_readiness(
2743
3120
  text,
2744
3121
  name,
@@ -2747,15 +3124,19 @@ def main(argv=None):
2747
3124
  args.strict_readiness,
2748
3125
  warnings,
2749
3126
  errors,
3127
+ selected=bool(scope),
2750
3128
  )
2751
3129
 
3130
+ for target in (errors, warnings):
3131
+ target.context(category="advisory", code="SHARED_WRITE_RISK")
2752
3132
  for target, plan_names in detect_overlapping_targets(active_plan_targets).items():
2753
3133
  warn(warnings, f"{target}: 多个活跃计划声明相同影响目标:{', '.join(plan_names)}")
2754
3134
 
2755
3135
  known_plans = set(plans)
2756
3136
  for name, data in plans.items():
2757
- if data["status"] not in WARNING_ACTIVE:
3137
+ if data["status"] not in WARNING_ACTIVE or (scope and name not in args.plan):
2758
3138
  continue
3139
+ warnings.context(name, data["phase"], category="advisory", code="DEPENDENCY_REFERENCE", path=data["path"].relative_to(root))
2759
3140
  declared = set(edges.get(name, []))
2760
3141
  referenced = extract_plan_references(plan_texts.get(name, ""), known_plans, name)
2761
3142
  for missing in sorted(referenced - declared):
@@ -2763,9 +3144,31 @@ def main(argv=None):
2763
3144
  for unreferenced in sorted(declared - referenced):
2764
3145
  warn(warnings, f"{name}: PLAN_MAP.md 声明依赖 {unreferenced},但计划正文未引用")
2765
3146
 
3147
+ for target in (errors, warnings):
3148
+ target.context(category="advisory", code="OPTIONAL_CHECK")
2766
3149
  try:
2767
3150
  drift_files = changed_files(root) if args.drift else set()
2768
3151
  pre_commit_files = changed_files(root, staged=True) if args.pre_commit else set()
3152
+ if scope:
3153
+ peer_targets = {}
3154
+ for peer, data in plans.items():
3155
+ if peer not in args.plan and data["path"].is_file():
3156
+ body = data["path"].read_text(encoding="utf-8")
3157
+ peer_targets[peer] = extract_affected_targets(body) + [data["path"].relative_to(root).as_posix()]
3158
+ def relevant_changes(files, staged):
3159
+ selected = set()
3160
+ for file in files:
3161
+ own = any(target_matches_path(t, file) for targets in active_plan_targets.values() for t in targets)
3162
+ peers = any(target_matches_path(t, file) for targets in peer_targets.values() for t in targets)
3163
+ if file == "docs/PLAN_MAP.md":
3164
+ owners = plan_map_change_owners(root, staged, set(plans))
3165
+ if owners and all(owner and not set(owner) & set(args.plan) for owner in owners):
3166
+ continue
3167
+ if own or not peers:
3168
+ selected.add(file)
3169
+ return selected
3170
+ drift_files = relevant_changes(drift_files, False)
3171
+ pre_commit_files = relevant_changes(pre_commit_files, True)
2769
3172
  completion_window_targets = completed_plan_drift_targets(
2770
3173
  plans,
2771
3174
  plan_texts,
@@ -2802,8 +3205,16 @@ def main(argv=None):
2802
3205
  if args.stale_days < 0:
2803
3206
  fail(errors, f"--stale-days 必须是非负整数:{args.stale_days}")
2804
3207
  else:
2805
- warn_stale_plans(warnings, plans, args.stale_days)
2806
-
3208
+ warn_stale_plans(warnings, {n: d for n, d in plans.items() if not scope or n in args.plan}, args.stale_days)
3209
+
3210
+ if scope:
3211
+ errors.context(category="document", code="SCOPE_STRUCTURE_INVALID")
3212
+ errors.extend(scope["global_diagnostics"])
3213
+ warnings.context(category="readiness", code="DEPENDENCY_BLOCKED")
3214
+ errors.context(category="readiness", code="DEPENDENCY_BLOCKED")
3215
+ for message in dependency_diagnostics(root, scope, text, plans):
3216
+ if message not in scope["global_diagnostics"]:
3217
+ readiness_issue(warnings, errors, args.strict_readiness, message)
2807
3218
  if args.check_attestations:
2808
3219
  attestation_reports = []
2809
3220
  warn_attestation_drift(
@@ -2813,6 +3224,7 @@ def main(argv=None):
2813
3224
  errors=errors,
2814
3225
  strict=args.strict_readiness,
2815
3226
  reports=attestation_reports,
3227
+ selected=scope["evaluated_plans"] if scope else None,
2816
3228
  )
2817
3229
  else:
2818
3230
  attestation_reports = []
@@ -2821,6 +3233,16 @@ def main(argv=None):
2821
3233
  if (args.attest_purpose or args.supersedes) and not args.attest:
2822
3234
  fail(errors, "--attest-purpose/--supersedes 必须与 --attest 一起使用")
2823
3235
  if args.attest:
3236
+ target = plans.get(args.attest)
3237
+ if target and not errors and args.attest_purpose != "compliance":
3238
+ body = read_utf8(target["path"], errors)
3239
+ if target["status"] in INACTIVE:
3240
+ errors.append("终态目标不能创建完成/发布门快照")
3241
+ elif target["status"] not in COMPLETED:
3242
+ gate = phase_gate(text, args.attest, target, body, selected=True)
3243
+ errors.extend(gate["issues"])
3244
+ elif phase_gate(text, args.attest, target, body)["blockers"]:
3245
+ fail(errors, "完成快照目标仍有未解决阻塞")
2824
3246
  attested_path = create_attestation(
2825
3247
  root,
2826
3248
  plan_map,
@@ -2833,23 +3255,19 @@ def main(argv=None):
2833
3255
  attest_files=args.attest_file,
2834
3256
  )
2835
3257
 
2836
- for warning in warnings:
2837
- print(f"WARNING: {warning}")
2838
-
2839
- for report in attestation_reports:
2840
- print(f"ATTESTATION: {report}")
2841
-
2842
- if errors:
2843
- for error in errors:
2844
- print(f"ERROR: {error}")
2845
- return 1
2846
-
2847
- if attested_path is not None:
2848
- print(f"已创建完成快照:{attested_path}")
2849
-
2850
- print("计划治理检查通过。")
2851
- return 0
3258
+ if scope and not scope_unchanged(root, scope):
3259
+ errors.context(category="readiness", code="FACTS_CHANGED")
3260
+ warnings.context(category="readiness", code="FACTS_CHANGED")
3261
+ readiness_issue(warnings, errors, args.strict_readiness, "相关事实在读取期间发生变化;请重新检查")
3262
+ return finish_check(args, records, scope=scope, reports=attestation_reports, attested_path=attested_path)
2852
3263
 
2853
3264
 
2854
3265
  if __name__ == "__main__":
2855
- raise SystemExit(main())
3266
+ try:
3267
+ raise SystemExit(main())
3268
+ except UsageError as exc:
3269
+ print(f"ERROR: {exc}", file=sys.stderr)
3270
+ raise SystemExit(2)
3271
+ except (OSError, ValueError) as exc:
3272
+ print(f"ERROR: {exc}", file=sys.stderr)
3273
+ raise SystemExit(1)