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.
@@ -231,6 +231,17 @@ def legacy_review_method(kind):
231
231
  return "自验" if kind == "复核基线复用" or "自验" in kind else "独立"
232
232
 
233
233
 
234
+ def independent_review_requested(summary):
235
+ """只有明确的用户请求证据才将待办派给独立复核。"""
236
+ request = summary.get("独立复核请求", "").strip()
237
+ return bool(request and request != "无" and not is_placeholder(request))
238
+
239
+
240
+ def review_unavailable(conclusion):
241
+ """工具未完成不等于审出了实质问题。"""
242
+ return conclusion.strip().startswith(("超时", "复核工具超时", "复核入口不可用"))
243
+
244
+
234
245
  def phase_precedes(plan_text, candidate, current):
235
246
  """只允许路线图中更早的阶段;标准数字阶段可在精简路线图中回退比较。"""
236
247
  roadmap = [row[0] for row in phase_roadmap_rows(plan_text)]
@@ -243,7 +254,7 @@ def phase_precedes(plan_text, candidate, current):
243
254
 
244
255
 
245
256
  def legacy_review_state(plan_text, phase):
246
- """在不改写六列历史的前提下应用统一单次复核闭环。"""
257
+ """在不改写六列历史的前提下应用默认自验和真实发现闭环。"""
247
258
  result = {"passed": False, "pending_action": None, "issues": [], "blockers": []}
248
259
  issues, blockers = result["issues"], result["blockers"]
249
260
  rows = review_history_rows(plan_text)
@@ -299,7 +310,7 @@ def legacy_review_state(plan_text, phase):
299
310
  outcome = review_outcome(row[3])
300
311
  if method == "独立":
301
312
  state["reviewed"] = completed_independent(row)
302
- if outcome == "failed":
313
+ if outcome == "failed" and not review_unavailable(row[3]):
303
314
  state["unresolved"] = True
304
315
  elif (row[1] == "修复自验" and state["reviewed"] and outcome == "passed"
305
316
  and qualified(row)):
@@ -319,7 +330,7 @@ def legacy_review_state(plan_text, phase):
319
330
  issues.append("旧独立复核记录结论为空或未知")
320
331
  if method == "独立":
321
332
  reviewed = completed_independent(row)
322
- if outcome == "failed":
333
+ if outcome == "failed" and not review_unavailable(row[3]):
323
334
  unresolved = row[3]
324
335
  elif row[1] == "复核基线复用":
325
336
  referenced = [source for source in reusable_prior_phases if source in row[4]]
@@ -328,9 +339,7 @@ def legacy_review_state(plan_text, phase):
328
339
  elif outcome == "passed" and qualified(row):
329
340
  reviewed = True
330
341
  elif row[1] == "修复自验":
331
- if not reviewed:
332
- issues.append("旧格式修复自验缺少已完成的独立复核基线")
333
- elif outcome == "passed" and qualified(row):
342
+ if outcome == "passed" and qualified(row) and reviewed:
334
343
  unresolved = None
335
344
  if outcome == "passed" and not qualified(row):
336
345
  issues.append("旧独立复核记录通过缺少有效日期、类型、证据或身份")
@@ -346,15 +355,19 @@ def legacy_review_state(plan_text, phase):
346
355
  review.get(field) == current[-1][index]
347
356
  for field, index in [("日期", 0), ("阶段", 2), ("结论", 3), ("证据", 4), ("复核者", 5)]
348
357
  )
349
- result["passed"] = outcome == "passed" and latest_matches and not issues and not blockers
358
+ summary = key_value_table(fixed_section(top_level_section(plan_text, "当前阶段"), "阶段准入摘要"))
359
+ requested = independent_review_requested(summary)
360
+ awaiting_requested_review = requested and outcome == "passed" and not reviewed
361
+ result["passed"] = outcome == "passed" and latest_matches and not issues and not blockers and not awaiting_requested_review
350
362
  if outcome == "pending" and not issues and not blockers:
351
- method = legacy_review_method(current[-1][1]) if current else "独立"
352
- result["pending_action"] = "verify" if method == "自验" else "independent_review"
363
+ result["pending_action"] = "independent_review" if requested else "verify"
364
+ elif awaiting_requested_review and not issues and not blockers:
365
+ result["pending_action"] = "independent_review"
353
366
  return result
354
367
 
355
368
 
356
369
  def risk_review_state(plan_text, phase):
357
- """显式风险策略的共享事实判定;历史策略名不再改变单次复核行为。"""
370
+ """显式风险策略的共享事实判定;历史策略名不改变默认自验行为。"""
358
371
  summary_section = fixed_section(top_level_section(plan_text, "当前阶段"), "阶段准入摘要")
359
372
  policies = [row for row in markdown_table_rows(summary_section) if row[0] == "复核策略"]
360
373
  result = {"enabled": bool(policies), "passed": False, "pending_action": None,
@@ -362,8 +375,8 @@ def risk_review_state(plan_text, phase):
362
375
  if not policies:
363
376
  return result
364
377
  issues, blockers = result["issues"], result["blockers"]
365
- if len(policies) != 1 or len(policies[0]) != 2 or policies[0][1] not in {"风险分流", "单次独立复核"}:
366
- issues.append("复核策略必须唯一且为 风险分流/单次独立复核;空值、未知值或重复声明不能准入")
378
+ if len(policies) != 1 or len(policies[0]) != 2 or policies[0][1] not in {"风险分流", "单次独立复核", "默认自验"}:
379
+ issues.append("复核策略必须唯一且为 默认自验/风险分流/单次独立复核;空值、未知值或重复声明不能准入")
367
380
  risks = {"低风险", "高影响", "高风险"}
368
381
  summary = key_value_table(summary_section)
369
382
  if "最新独立准入复核" in summary:
@@ -464,7 +477,7 @@ def risk_review_state(plan_text, phase):
464
477
  else:
465
478
  state["reviewed_high"] = False
466
479
  row_outcome = review_outcome(row[5])
467
- if row_outcome == "failed":
480
+ if row_outcome == "failed" and not review_unavailable(row[5]):
468
481
  state["unresolved"] = True
469
482
  elif (row[1] == "修复自验" and state["reviewed_high"]
470
483
  and review_outcome(row[5]) == "passed"
@@ -489,7 +502,7 @@ def risk_review_state(plan_text, phase):
489
502
  reviewed_high = row[4] in {"高风险", "高影响"}
490
503
  else:
491
504
  reviewed = reviewed_high = False
492
- if review_outcome(row[5]) == "failed":
505
+ if review_outcome(row[5]) == "failed" and not review_unavailable(row[5]):
493
506
  unresolved_single = row[5]
494
507
  elif review_outcome(row[5]) == "passed" and completed(row):
495
508
  # 后续独立通过可形成新范围基线,但不能清除既有独立发现。
@@ -503,12 +516,8 @@ def risk_review_state(plan_text, phase):
503
516
  and not any(is_placeholder(value) for value in row)
504
517
  and valid_date(row[0]) is not None):
505
518
  reviewed = reviewed_high = True
506
- elif row[4] in {"高风险", "高影响"} and not reviewed_high:
507
- issues.append("高风险/高影响自验缺少同范围独立复核基线或明确的跨阶段复用记录")
508
519
  if row[1] == "修复自验":
509
- if not reviewed:
510
- issues.append("修复自验缺少已完成的独立复核基线")
511
- elif (review_outcome(row[5]) == "passed" and row[4] in risks
520
+ if (reviewed and review_outcome(row[5]) == "passed" and row[4] in risks
512
521
  and not any(is_placeholder(value) for value in row)
513
522
  and valid_date(row[0]) is not None):
514
523
  unresolved_single = None
@@ -560,7 +569,7 @@ def risk_review_state(plan_text, phase):
560
569
  row_outcome = review_outcome(row[3])
561
570
  if not qualified(row, legacy=True):
562
571
  issues.append("旧独立复核记录缺少有效日期、类型、证据或身份,迁移不能掩盖")
563
- if row_outcome == "failed":
572
+ if row_outcome == "failed" and not review_unavailable(row[3]):
564
573
  unresolved = row[3]
565
574
  elif row_outcome == "passed" and qualified(row, legacy=True):
566
575
  pass
@@ -581,15 +590,19 @@ def risk_review_state(plan_text, phase):
581
590
  elif not legacy_history or any(is_placeholder(legacy.get(field))
582
591
  for field in ["日期", "阶段", "结论", "证据", "复核者"]) or valid_date(legacy.get("日期", "")) is None:
583
592
  issues.append("旧最新独立准入复核缺少有效字段或当前阶段历史,不能降级放行")
584
- result["passed"] = outcome == "passed" and not issues and not blockers
593
+ requested = independent_review_requested(summary)
594
+ awaiting_requested_review = requested and outcome == "passed" and not reviewed
595
+ result["passed"] = outcome == "passed" and not issues and not blockers and not awaiting_requested_review
585
596
  if outcome == "pending" and not issues and not blockers:
586
- result["pending_action"] = "verify" if review.get("方式") == "自验" else "independent_review"
597
+ result["pending_action"] = "independent_review" if requested else "verify"
598
+ elif awaiting_requested_review and not issues and not blockers:
599
+ result["pending_action"] = "independent_review"
587
600
  return result
588
601
 
589
602
 
590
- 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):
591
604
  """检查待实施/实施中计划的阶段准入结构,不判断业务证据真实性。"""
592
- if data["status"] not in ACTIVE:
605
+ if data["status"] not in ACTIVE and not selected:
593
606
  return
594
607
 
595
608
  current_phase = data["phase"]
@@ -748,7 +761,7 @@ def check_phase_structure(plan_name, data, plan_text, strict, warnings, errors,
748
761
  warnings,
749
762
  errors,
750
763
  strict,
751
- f"{plan_name}: 旧格式当前阶段记录未满足统一单次复核准入",
764
+ f"{plan_name}: 旧格式当前阶段记录未满足自验或已请求复核的准入",
752
765
  )
753
766
 
754
767
 
@@ -1439,6 +1452,8 @@ def create_attestation(
1439
1452
  attest_files=None,
1440
1453
  ):
1441
1454
  binding = None
1455
+ if errors:
1456
+ return None
1442
1457
  if attest_files is not None:
1443
1458
  if errors:
1444
1459
  return None
@@ -1554,7 +1569,7 @@ def attestation_issue(warnings, errors, strict, message):
1554
1569
 
1555
1570
 
1556
1571
  def warn_attestation_drift(warnings, root, plans, errors=None, strict=False, reports=None,
1557
- pending=None, enforce_binding_review=True):
1572
+ pending=None, enforce_binding_review=True, selected=None):
1558
1573
  errors = errors if errors is not None else []
1559
1574
  attestations_dir = root / "docs" / "attestations"
1560
1575
  try:
@@ -1586,6 +1601,8 @@ def warn_attestation_drift(warnings, root, plans, errors=None, strict=False, rep
1586
1601
  bound = "binding" in attestation
1587
1602
  invalid_structure = False
1588
1603
  plan_name = str(attestation.get("plan", "")).strip()
1604
+ if selected and plan_name in plans and plan_name not in selected:
1605
+ continue
1589
1606
  if not plan_name or plan_name not in plans:
1590
1607
  message = f"{path}: 快照引用了未登记计划:{plan_name or '<missing>'}"
1591
1608
  if not bound:
@@ -1885,6 +1902,10 @@ def plan_index_issues(plan_map_text):
1885
1902
  status = row[1].strip("` ")
1886
1903
  if status not in VALID_STATUSES:
1887
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]}")
1888
1909
  return issues
1889
1910
 
1890
1911
 
@@ -1979,12 +2000,12 @@ def structural_ambiguities(plan_text, phase):
1979
2000
  return issues
1980
2001
 
1981
2002
 
1982
- 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):
1983
2004
  """共享只读派生;严重级别由 check/workset/hook 的入口契约决定。"""
1984
2005
  issues = structural_ambiguities(plan_text, data["phase"])
1985
2006
  warnings = []
1986
2007
  review_state = risk_review_state(plan_text, data["phase"])
1987
- 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)
1988
2009
  if review_state["enabled"]:
1989
2010
  issues.extend(review_state["issues"] + review_state["blockers"])
1990
2011
  if data["status"] in ACTIVE and is_placeholder(data["phase"]):
@@ -1995,17 +2016,28 @@ def phase_gate(plan_map_text, plan_name, data, plan_text):
1995
2016
  indexed = mapped.get(plan_name, [])
1996
2017
  summary = current_summary(plan_text)
1997
2018
  value = summary.get("当前阻塞项", "").strip()
1998
- 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 []
1999
2020
  blockers = list(dict.fromkeys(details + indexed + declared))
2021
+ issue_codes = {}
2000
2022
  if blockers:
2001
- issues.append("当前阶段存在未解决阻塞项:" + ";".join(blockers))
2023
+ message = "当前阶段存在未解决阻塞项:" + ";".join(blockers)
2024
+ issues.append(message)
2025
+ issue_codes[message] = "CURRENT_PHASE_BLOCKED"
2002
2026
  if ((details or indexed) and value == "无") or ((details or declared) and not indexed):
2003
2027
  issues.append("当前阻塞来源需同步:PLAN_MAP 索引、未决问题或阶段准入摘要缺失/冲突")
2004
2028
  if review_state["enabled"]:
2005
2029
  blockers.extend(review_state["blockers"])
2006
2030
  else:
2007
2031
  legacy_state = legacy_review_state(plan_text, data["phase"])
2008
- 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"])
2009
2041
  review = key_value_table(fixed_section(plan_text, "最新独立准入复核"))
2010
2042
  conclusion = review.get("结论", "").strip()
2011
2043
  if review.get("阶段") == data["phase"] and conclusion.startswith(("未通过", "不通过", "失败", "不满足", "拒绝")):
@@ -2016,17 +2048,26 @@ def phase_gate(plan_map_text, plan_name, data, plan_text):
2016
2048
  warnings.extend(recent_warnings)
2017
2049
  issues = [message.removeprefix(f"{plan_name}: ") for message in issues]
2018
2050
  return {"blockers": list(dict.fromkeys(blockers)), "issues": list(dict.fromkeys(issues)),
2019
- "warnings": warnings, "review": review_state}
2051
+ "warnings": warnings, "review": review_state, "issue_codes": issue_codes}
2020
2052
 
2021
2053
 
2022
- 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
2023
2058
  if data["status"] not in WARNING_ACTIVE:
2024
2059
  return
2025
- 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)
2026
2061
  warnings.extend(f"{plan_name}: {message}" for message in gate["warnings"])
2027
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="当前阶段")
2028
2069
  # 缺失材料的设计计划仍合法;其显式冲突和阻塞保持可见而不强制准入。
2029
- 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}")
2030
2071
 
2031
2072
 
2032
2073
  def top_level_section(text, heading):
@@ -2330,10 +2371,244 @@ def recent_evidence(plan_text, phase=None):
2330
2371
  return current_recent_evidence(plan_text, phase)[0]
2331
2372
 
2332
2373
 
2333
- 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):
2334
2605
  errors = []
2335
2606
  strict_failures = []
2336
- 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)
2337
2612
  output = {
2338
2613
  "schema_version": 1,
2339
2614
  "source": "derived",
@@ -2355,20 +2630,23 @@ def workset_payload(root, include_history=False, strict=False):
2355
2630
  strict_failures.extend(relation_errors)
2356
2631
 
2357
2632
  for name, data in plans.items():
2358
- 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:
2359
2636
  continue
2360
2637
  path = data["path"]
2361
2638
  if not path.exists():
2362
2639
  message = f"{name}: 计划文件不存在:{path}"
2363
2640
  output["warnings"].append(message)
2364
2641
  strict_failures.append(message)
2642
+ document_failures.append(message)
2365
2643
  continue
2366
2644
  plan_text = read_utf8(path, errors)
2367
2645
  summary = current_summary(plan_text)
2368
- 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)
2369
2647
  blockers = gate["blockers"]
2370
2648
  output["warnings"].extend(f"{name}: {message}" for message in gate["issues"] + gate["warnings"])
2371
- if data["status"] in ACTIVE:
2649
+ if data["status"] in ACTIVE or focus:
2372
2650
  strict_failures.extend(gate["issues"])
2373
2651
  uncertain = gate["issues"] or blocker_index_issues or relation_errors
2374
2652
  if blockers:
@@ -2406,23 +2684,34 @@ def workset_payload(root, include_history=False, strict=False):
2406
2684
  "state": "known" if kind else "unknown",
2407
2685
  "kind": kind or "unknown",
2408
2686
  "reason": ("当前阶段复核通过,待同步 PLAN_MAP 准入状态" if kind == "sync" else
2409
- "低风险阶段待自验" if kind == "verify" else
2687
+ "当前阶段待自验" if kind == "verify" else
2410
2688
  "当前阶段待独立复核" if kind == "independent_review" else
2411
2689
  "当前阶段复核声明无法确定"),
2412
2690
  }
2413
2691
  elif not current_review_passes(plan_text, data["phase"]):
2414
2692
  legacy_state = legacy_review_state(plan_text, data["phase"])
2415
- kind = legacy_state["pending_action"] or "independent_review"
2693
+ kind = legacy_state["pending_action"] or "verify"
2416
2694
  readiness = "design"
2417
2695
  action = {
2418
2696
  "state": "known",
2419
2697
  "kind": kind,
2420
2698
  "reason": ("当前阶段待自验" if kind == "verify" else
2421
- "当前阶段尚无可用的单次独立复核基线"),
2699
+ "用户已明确要求独立复核"),
2422
2700
  }
2423
2701
  else:
2424
2702
  readiness = "design"
2425
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}: 终态计划不能准入")
2426
2715
  else:
2427
2716
  readiness = "unknown"
2428
2717
  action = {"state": "unknown", "kind": "unknown", "reason": "计划状态不在工作集派生范围"}
@@ -2447,7 +2736,7 @@ def workset_payload(root, include_history=False, strict=False):
2447
2736
  output["plans"] = []
2448
2737
  output["warnings"].extend(index_issues)
2449
2738
  strict_failures.extend(index_issues)
2450
- 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
2451
2740
 
2452
2741
 
2453
2742
  def print_workset_text(payload, strict=False):
@@ -2506,26 +2795,91 @@ def limit_workset_evidence(root, payload, limit):
2506
2795
  return result
2507
2796
 
2508
2797
 
2509
- def run_workset(root, as_json=False, include_history=False, strict=False, evidence_limit=None):
2510
- 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)
2511
2800
  if as_json:
2512
2801
  if evidence_limit is not None:
2513
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
2514
2810
  print(json.dumps(payload, ensure_ascii=False, indent=2))
2515
2811
  else:
2516
2812
  print_workset_text(payload, strict=strict)
2517
2813
  return status
2518
2814
 
2519
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
+
2520
2873
  def parse_args(argv):
2521
- parser = argparse.ArgumentParser(description="检查计划治理文档的一致性。")
2522
- 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="仓库根目录,默认当前目录。")
2523
2876
  parser.add_argument("--workset", action="store_true", help="输出只读当前工作集。")
2524
2877
  parser.add_argument("--json", action="store_true", help="以 JSON 输出工作集。")
2525
2878
  parser.add_argument("--include-history", action="store_true", help="工作集包含历史计划。")
2526
2879
  parser.add_argument("--evidence-limit", type=int, metavar="N",
2527
2880
  help="仅工作集 JSON:每个计划显示末尾 N 条记录(正整数),省略时返回全部。")
2528
- 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,各会话独立传入。")
2529
2883
  parser.add_argument("--drift", action="store_true", help="检查工作区变更是否被活跃计划影响范围覆盖。")
2530
2884
  parser.add_argument("--pre-commit", action="store_true", help="检查 staged 变更是否被活跃计划影响范围覆盖。")
2531
2885
  parser.add_argument(
@@ -2533,15 +2887,15 @@ def parse_args(argv):
2533
2887
  action="store_true",
2534
2888
  help="将待实施/实施中计划的阶段准入结构缺陷从 WARNING 提升为 ERROR。",
2535
2889
  )
2536
- parser.add_argument("--attest", metavar="PLAN", help="为已登记计划创建或覆盖完成快照。")
2537
- 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",
2538
2892
  help="可重复:与 --attest/--attest-purpose 显式绑定被审查的普通文件。")
2539
2893
  parser.add_argument(
2540
2894
  "--attest-purpose",
2541
2895
  choices=sorted(ATTESTATION_PURPOSES),
2542
2896
  help="创建带 purpose/snapshot_id 关系的完成快照;不传时保持旧 <plan>.json 格式。",
2543
2897
  )
2544
- parser.add_argument("--supersedes", help="新 attestation 要替代的仓库内相对快照路径。")
2898
+ parser.add_argument("--supersedes", type=nonempty, help="新 attestation 要替代的仓库内相对快照路径。")
2545
2899
  parser.add_argument(
2546
2900
  "--review-status",
2547
2901
  choices=sorted(ATTESTATION_REVIEW_STATUSES),
@@ -2557,7 +2911,30 @@ def parse_args(argv):
2557
2911
  default=None,
2558
2912
  help="检查活跃计划是否超过 N 天未更新;省略 N 时默认 10 天。",
2559
2913
  )
2914
+ validate_options(parser, argv, {"plan", "attest_file"})
2560
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 必须是非负整数")
2561
2938
  if args.evidence_limit is not None:
2562
2939
  if args.evidence_limit <= 0:
2563
2940
  parser.error("--evidence-limit 必须为正整数")
@@ -2571,63 +2948,61 @@ def main(argv=None):
2571
2948
  if args.attest_file is not None and (not args.attest or not args.attest_purpose or args.workset):
2572
2949
  print("ERROR: --attest-file 必须与 --attest/--attest-purpose 一起使用,不能用于 workset")
2573
2950
  return 1
2951
+ root = Path(args.root).expanduser().resolve()
2574
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
2575
2957
  return run_workset(
2576
- Path(args.mode_root or args.root),
2958
+ root,
2577
2959
  as_json=args.json,
2578
2960
  include_history=args.include_history,
2579
2961
  strict=args.strict_readiness,
2580
2962
  evidence_limit=args.evidence_limit,
2963
+ requested=args.plan,
2581
2964
  )
2582
- root = Path(args.root)
2583
2965
  docs = root / "docs"
2584
2966
  plan_map = docs / "PLAN_MAP.md"
2585
- errors = []
2586
- 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)
2587
2975
 
2588
2976
  try:
2589
2977
  bound_input = args.attest_file is not None or (args.check_attestations and has_attestation_binding(root))
2590
2978
  except (ValueError, OSError) as exc:
2591
- level = "ERROR" if args.strict_readiness else "WARNING"
2592
- print(f"{level}: 快照清单无法读取:{exc}")
2593
- return 1 if args.strict_readiness else 0
2979
+ attestation_issue(warnings, errors, args.strict_readiness, f"快照清单无法读取:{exc}")
2980
+ return finish_check(args, records)
2594
2981
  unsafe_map = False
2595
2982
  if bound_input:
2596
2983
  try:
2597
2984
  binding_file(root, "docs/PLAN_MAP.md")
2598
2985
  except (ValueError, OSError) as exc:
2599
2986
  if args.attest_file is not None:
2600
- print(f"ERROR: binding 创建预检失败:{exc}")
2601
- return 1
2987
+ errors.append(f"binding 创建预检失败:{exc}")
2988
+ return finish_check(args, records)
2602
2989
  unsafe_map = True
2603
2990
  if unsafe_map:
2604
2991
  reports = []
2605
2992
  warn_attestation_drift(warnings, root, {}, errors=errors, strict=args.strict_readiness, reports=reports)
2606
- for warning in warnings:
2607
- print(f"WARNING: {warning}")
2608
- for report in reports:
2609
- print(f"ATTESTATION: {report}")
2610
- for error in errors:
2611
- print(f"ERROR: {error}")
2612
- return 1 if errors else 0
2993
+ return finish_check(args, records, reports=reports)
2613
2994
  if not plan_map.exists():
2995
+ reports = []
2614
2996
  if args.check_attestations:
2615
- reports = []
2616
2997
  warn_attestation_drift(warnings, root, {}, errors=errors, strict=args.strict_readiness, reports=reports)
2617
- if reports or errors:
2618
- for warning in warnings:
2619
- print(f"WARNING: {warning}")
2620
- for report in reports:
2621
- print(f"ATTESTATION: {report}")
2622
- for error in errors:
2623
- print(f"ERROR: {error}")
2624
- return 1 if errors else 0
2625
- print("未找到 docs/PLAN_MAP.md;当前仓库尚未初始化计划治理。")
2626
- 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)
2627
3000
 
2628
3001
  text = read_utf8(plan_map, errors)
2629
3002
  _, blocker_index_issues = map_blockers(text)
2630
- 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:
2631
3006
  readiness_issue(warnings, errors, args.strict_readiness, issue)
2632
3007
  plan_rows = table_rows(text, "计划索引")
2633
3008
  if not plan_rows:
@@ -2661,7 +3036,7 @@ def main(argv=None):
2661
3036
  attestation_issue(warnings, errors, args.attest_file is not None or args.strict_readiness,
2662
3037
  f"{name}: binding 计划读取预检失败:{exc}")
2663
3038
  unsafe_plans.add(name)
2664
- if not path.exists():
3039
+ if not path.exists() and (not args.plan or name in args.plan):
2665
3040
  fail(errors, f"docs/PLAN_MAP.md: 引用的计划文件不存在:{link}")
2666
3041
  plans[name] = {
2667
3042
  "path": path,
@@ -2673,6 +3048,12 @@ def main(argv=None):
2673
3048
  else:
2674
3049
  fail(errors, f"docs/PLAN_MAP.md: 计划行缺少 docs/plans 链接:{row[0]}")
2675
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)
2676
3057
  readable_plans = {name: data for name, data in plans.items() if name not in unsafe_plans}
2677
3058
  relation_errors, relation_warnings = validate_relation_graph(text, readable_plans)
2678
3059
  warnings.extend(relation_warnings)
@@ -2685,31 +3066,38 @@ def main(argv=None):
2685
3066
  inactive = {name for name, data in plans.items() if data["status"] in INACTIVE}
2686
3067
  for name, data in plans.items():
2687
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}")
2688
3072
  edges[name] = [dep for dep in deps if dep in plans]
2689
- if data["status"] in IMPLEMENTING:
3073
+ if data["status"] in IMPLEMENTING and (not scope or name in scope["evaluated_plans"]):
2690
3074
  for dep in edges[name]:
2691
3075
  if dep in inactive:
2692
3076
  fail(errors, f"{name}: 实施中计划依赖了非活跃计划 {dep}")
2693
3077
 
2694
3078
  for cycle in detect_dependency_cycles(edges):
2695
- fail(errors, f"计划依赖存在环:{cycle}")
3079
+ if not scope:
3080
+ fail(errors, f"计划依赖存在环:{cycle}")
2696
3081
 
2697
- for orphan in find_orphan_plans(docs, plans):
2698
- 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 的孤立计划")
2699
3085
 
2700
3086
  active_plan_targets = {}
2701
3087
  plan_texts = {}
2702
3088
  for name, data in plans.items():
2703
- if name in unsafe_plans:
3089
+ if name in unsafe_plans or (scope and name not in args.plan):
2704
3090
  continue
3091
+ for target in (errors, warnings):
3092
+ target.context(name, data["phase"], path=data["path"].relative_to(root))
2705
3093
  plan_text = read_utf8(data["path"], errors)
2706
3094
  plan_texts[name] = plan_text
2707
3095
  if data["status"] in COMPLETED and not has_completion_evidence(plan_text):
2708
3096
  fail(errors, f"{data['path']}: 已完成计划缺少有效 Step 0 证据或验证方式")
2709
3097
  if data["status"] in COMPLETED and not has_coverage_evidence(plan_text):
2710
3098
  fail(errors, f"{data['path']}: 已完成计划缺少测试覆盖率证据")
2711
- if data["status"] in ACTIVE and has_current_blocker(plan_text):
2712
- 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']}: 已完成计划仍有当前阶段阻塞/失败")
2713
3101
  if data["status"] in WARNING_ACTIVE:
2714
3102
  targets = extract_affected_targets(plan_text)
2715
3103
  plan_relative = data["path"].relative_to(root).as_posix()
@@ -2726,6 +3114,8 @@ def main(argv=None):
2726
3114
  )
2727
3115
  active_plan_targets[name] = targets
2728
3116
 
3117
+ for target in (errors, warnings):
3118
+ target.context(name, data["phase"], category="readiness", path=data["path"].relative_to(root), section="当前阶段")
2729
3119
  check_phase_readiness(
2730
3120
  text,
2731
3121
  name,
@@ -2734,15 +3124,19 @@ def main(argv=None):
2734
3124
  args.strict_readiness,
2735
3125
  warnings,
2736
3126
  errors,
3127
+ selected=bool(scope),
2737
3128
  )
2738
3129
 
3130
+ for target in (errors, warnings):
3131
+ target.context(category="advisory", code="SHARED_WRITE_RISK")
2739
3132
  for target, plan_names in detect_overlapping_targets(active_plan_targets).items():
2740
3133
  warn(warnings, f"{target}: 多个活跃计划声明相同影响目标:{', '.join(plan_names)}")
2741
3134
 
2742
3135
  known_plans = set(plans)
2743
3136
  for name, data in plans.items():
2744
- if data["status"] not in WARNING_ACTIVE:
3137
+ if data["status"] not in WARNING_ACTIVE or (scope and name not in args.plan):
2745
3138
  continue
3139
+ warnings.context(name, data["phase"], category="advisory", code="DEPENDENCY_REFERENCE", path=data["path"].relative_to(root))
2746
3140
  declared = set(edges.get(name, []))
2747
3141
  referenced = extract_plan_references(plan_texts.get(name, ""), known_plans, name)
2748
3142
  for missing in sorted(referenced - declared):
@@ -2750,9 +3144,31 @@ def main(argv=None):
2750
3144
  for unreferenced in sorted(declared - referenced):
2751
3145
  warn(warnings, f"{name}: PLAN_MAP.md 声明依赖 {unreferenced},但计划正文未引用")
2752
3146
 
3147
+ for target in (errors, warnings):
3148
+ target.context(category="advisory", code="OPTIONAL_CHECK")
2753
3149
  try:
2754
3150
  drift_files = changed_files(root) if args.drift else set()
2755
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)
2756
3172
  completion_window_targets = completed_plan_drift_targets(
2757
3173
  plans,
2758
3174
  plan_texts,
@@ -2789,8 +3205,16 @@ def main(argv=None):
2789
3205
  if args.stale_days < 0:
2790
3206
  fail(errors, f"--stale-days 必须是非负整数:{args.stale_days}")
2791
3207
  else:
2792
- warn_stale_plans(warnings, plans, args.stale_days)
2793
-
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)
2794
3218
  if args.check_attestations:
2795
3219
  attestation_reports = []
2796
3220
  warn_attestation_drift(
@@ -2800,6 +3224,7 @@ def main(argv=None):
2800
3224
  errors=errors,
2801
3225
  strict=args.strict_readiness,
2802
3226
  reports=attestation_reports,
3227
+ selected=scope["evaluated_plans"] if scope else None,
2803
3228
  )
2804
3229
  else:
2805
3230
  attestation_reports = []
@@ -2808,6 +3233,16 @@ def main(argv=None):
2808
3233
  if (args.attest_purpose or args.supersedes) and not args.attest:
2809
3234
  fail(errors, "--attest-purpose/--supersedes 必须与 --attest 一起使用")
2810
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, "完成快照目标仍有未解决阻塞")
2811
3246
  attested_path = create_attestation(
2812
3247
  root,
2813
3248
  plan_map,
@@ -2820,23 +3255,19 @@ def main(argv=None):
2820
3255
  attest_files=args.attest_file,
2821
3256
  )
2822
3257
 
2823
- for warning in warnings:
2824
- print(f"WARNING: {warning}")
2825
-
2826
- for report in attestation_reports:
2827
- print(f"ATTESTATION: {report}")
2828
-
2829
- if errors:
2830
- for error in errors:
2831
- print(f"ERROR: {error}")
2832
- return 1
2833
-
2834
- if attested_path is not None:
2835
- print(f"已创建完成快照:{attested_path}")
2836
-
2837
- print("计划治理检查通过。")
2838
- 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)
2839
3263
 
2840
3264
 
2841
3265
  if __name__ == "__main__":
2842
- 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)