@michelj/context-guard 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +74 -6
  2. package/README.zh-CN.md +74 -6
  3. package/{skills/context-guard/SKILL.md → SKILL.md} +216 -77
  4. package/bin/context-guard-skill.js +88 -3
  5. package/hooks.json +25 -1
  6. package/package.json +7 -3
  7. package/{skills/context-guard/references → references}/context-template.md +52 -1
  8. package/references/feature-chain-methodology.md +228 -0
  9. package/{skills/context-guard/scripts → scripts}/context_guard.py +3119 -121
  10. package/{skills/context-guard/scripts → scripts}/context_guard_hook.py +344 -12
  11. package/{skills/context-guard/tests → tests}/BC-20260701-090.sh +8 -7
  12. package/{skills/context-guard/tests → tests}/BC-20260702-096.sh +6 -3
  13. package/tests/BC-20260706-098.sh +66 -0
  14. package/tests/BC-20260707-099.sh +47 -0
  15. package/tests/BC-20260707-100.sh +46 -0
  16. package/tests/BC-20260707-101.sh +47 -0
  17. package/tests/BC-20260707-102.sh +68 -0
  18. package/tests/BC-20260707-103.sh +59 -0
  19. package/tests/BC-20260707-104.sh +103 -0
  20. package/tests/BC-20260707-105.sh +109 -0
  21. package/tests/BC-20260707-106.sh +80 -0
  22. package/tests/BC-20260707-107.sh +74 -0
  23. package/tests/BC-20260707-108.sh +48 -0
  24. package/tests/BC-20260707-109.sh +56 -0
  25. package/tests/BC-20260707-110.sh +71 -0
  26. package/tests/BC-20260707-111.sh +70 -0
  27. package/tests/BC-20260707-112.sh +45 -0
  28. package/tests/BC-20260707-113.sh +73 -0
  29. package/tests/BC-20260707-115.sh +77 -0
  30. package/tests/BC-20260707-116.sh +77 -0
  31. package/tests/BC-20260707-118.sh +115 -0
  32. package/tests/BC-20260707-119.sh +47 -0
  33. package/tests/BC-20260707-120.sh +60 -0
  34. package/tests/BC-20260707-121.sh +66 -0
  35. package/tests/BC-20260707-122.sh +48 -0
  36. package/tests/BC-20260707-123.sh +43 -0
  37. package/tests/BC-20260707-124.sh +56 -0
  38. package/tests/BC-20260707-125.sh +64 -0
  39. package/tests/BC-20260707-126.sh +80 -0
  40. package/tests/BC-20260707-127.sh +88 -0
  41. package/tests/BC-20260707-129.sh +59 -0
  42. package/tests/BC-20260707-130.sh +69 -0
  43. package/tests/BC-20260707-131.sh +140 -0
  44. package/tests/BC-20260707-132.sh +150 -0
  45. package/tests/BC-20260707-133.sh +70 -0
  46. package/tests/BC-20260708-136.sh +210 -0
  47. package/tests/BC-20260708-137.sh +106 -0
  48. package/tests/BC-20260708-138.sh +168 -0
  49. package/tests/BC-20260708-139.sh +79 -0
  50. package/tests/BC-20260709-002.sh +63 -0
  51. package/tests/BC-20260709-003.sh +239 -0
  52. package/tests/BC-20260709-006.sh +76 -0
  53. package/tests/BC-20260709-008.sh +168 -0
  54. package/tests/BC-20260710-001.sh +61 -0
  55. package/tests/BC-20260710-002.sh +111 -0
  56. package/tests/npm-install-smoke.sh +53 -0
  57. package/skills/context-guard/README.md +0 -234
  58. package/skills/context-guard/README.zh-CN.md +0 -234
  59. /package/{skills/context-guard/agents → agents}/openai.yaml +0 -0
  60. /package/{skills/context-guard/references → references}/register-template.md +0 -0
  61. /package/{skills/context-guard/references → references}/task-case-template.md +0 -0
  62. /package/{skills/context-guard/tests → tests}/BC-20260618-063.sh +0 -0
  63. /package/{skills/context-guard/tests → tests}/BC-20260618-065.sh +0 -0
  64. /package/{skills/context-guard/tests → tests}/BC-20260626-080.sh +0 -0
  65. /package/{skills/context-guard/tests → tests}/BC-20260626-081.sh +0 -0
  66. /package/{skills/context-guard/tests → tests}/BC-20260626-082.sh +0 -0
  67. /package/{skills/context-guard/tests → tests}/BC-20260626-083.sh +0 -0
  68. /package/{skills/context-guard/tests → tests}/BC-20260627-084.sh +0 -0
  69. /package/{skills/context-guard/tests → tests}/BC-20260630-086.sh +0 -0
  70. /package/{skills/context-guard/tests → tests}/BC-20260630-087.sh +0 -0
  71. /package/{skills/context-guard/tests → tests}/BC-20260630-088.sh +0 -0
  72. /package/{skills/context-guard/tests → tests}/BC-20260630-089.sh +0 -0
@@ -5,11 +5,13 @@ from __future__ import annotations
5
5
 
6
6
  import argparse
7
7
  from concurrent.futures import ThreadPoolExecutor, as_completed
8
+ import hashlib
8
9
  import html
9
10
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
10
11
  import json
11
12
  import os
12
13
  import re
14
+ import shlex
13
15
  import shutil
14
16
  import subprocess
15
17
  import sys
@@ -74,6 +76,90 @@ def test_hub_dir(root: Path) -> Path:
74
76
  return context_dir(root) / "test-hub"
75
77
 
76
78
 
79
+ def subagent_assignments_path(root: Path) -> Path:
80
+ return context_dir(root) / "subagents" / "assignments.json"
81
+
82
+
83
+ def load_subagent_assignments(root: Path) -> dict[str, object]:
84
+ path = subagent_assignments_path(root)
85
+ if not path.exists():
86
+ return {"version": 1, "assignments": {}}
87
+ try:
88
+ data = json.loads(path.read_text(encoding="utf-8"))
89
+ except (OSError, json.JSONDecodeError):
90
+ return {"version": 1, "assignments": {}}
91
+ if not isinstance(data, dict):
92
+ return {"version": 1, "assignments": {}}
93
+ assignments = data.get("assignments")
94
+ if not isinstance(assignments, dict):
95
+ data["assignments"] = {}
96
+ data.setdefault("version", 1)
97
+ return data
98
+
99
+
100
+ def write_subagent_assignments(root: Path, data: dict[str, object]) -> Path:
101
+ path = subagent_assignments_path(root)
102
+ path.parent.mkdir(parents=True, exist_ok=True)
103
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
104
+ return path
105
+
106
+
107
+ def resolve_registered_subagent_root(control_root: Path, agent_id: str) -> Path | None:
108
+ safe_agent = agent_id.strip()
109
+ if not safe_agent:
110
+ return None
111
+ assignments = load_subagent_assignments(control_root).get("assignments", {})
112
+ if not isinstance(assignments, dict):
113
+ return None
114
+ item = assignments.get(safe_agent)
115
+ if not isinstance(item, dict):
116
+ return None
117
+ value = str(item.get("project_root", "")).strip()
118
+ if not value:
119
+ return None
120
+ path = Path(value).expanduser().resolve()
121
+ return path if path.exists() else None
122
+
123
+
124
+ def register_subagent_assignment(control_root: Path, agent_id: str, project_root: Path, task: str = "") -> Path:
125
+ safe_agent = agent_id.strip()
126
+ if not safe_agent:
127
+ raise ValueError("subagent-register requires --agent-id")
128
+ project_root = project_root.expanduser().resolve()
129
+ if not project_root.exists():
130
+ raise ValueError(f"subagent project root does not exist: {project_root}")
131
+ if is_context_guard_skill_path(project_root):
132
+ raise ValueError("subagent project root cannot be the Context Guard skill directory")
133
+ init_context(control_root)
134
+ init_context(project_root)
135
+ data = load_subagent_assignments(control_root)
136
+ assignments = data.setdefault("assignments", {})
137
+ assert isinstance(assignments, dict)
138
+ previous = assignments.get(safe_agent)
139
+ registered_at = datetime.now().isoformat(timespec="seconds")
140
+ if isinstance(previous, dict):
141
+ registered_at = str(previous.get("registered_at") or registered_at)
142
+ record = dict(previous) if isinstance(previous, dict) else {}
143
+ record.update({
144
+ "agent_id": safe_agent,
145
+ "project_root": str(project_root),
146
+ "task": " ".join(task.strip().split())[:500],
147
+ "status": "running",
148
+ "registered_at": registered_at,
149
+ "updated_at": datetime.now().isoformat(timespec="seconds"),
150
+ })
151
+ assignments[safe_agent] = record
152
+ path = write_subagent_assignments(control_root, data)
153
+ print(f"[context-guard] subagent registered: {safe_agent}")
154
+ print(f"[context-guard] subagent project root: {project_root}")
155
+ print(f"[context-guard] assignment registry: {path}")
156
+ return path
157
+
158
+
159
+ def unique_run_id() -> str:
160
+ return datetime.now().strftime("%Y%m%d-%H%M%S-%f")
161
+
162
+
77
163
  def normalize_record_language(language: str) -> str:
78
164
  value = " ".join((language or "").strip().split())
79
165
  lowered = value.lower().replace("_", "-")
@@ -140,6 +226,27 @@ def write_if_missing(path: Path, content: str) -> bool:
140
226
  return True
141
227
 
142
228
 
229
+ def ensure_context_gitignore(root: Path) -> tuple[Path, bool]:
230
+ codex_dir = root / ".codex"
231
+ codex_dir.mkdir(parents=True, exist_ok=True)
232
+ path = codex_dir / ".gitignore"
233
+ required = [
234
+ "context/private/",
235
+ "context/**/*.local.json",
236
+ "context/**/secrets*.json",
237
+ ]
238
+ if path.exists():
239
+ current = path.read_text(encoding="utf-8")
240
+ additions = [line for line in required if line not in current.splitlines()]
241
+ if additions:
242
+ suffix = "" if current.endswith("\n") or not current else "\n"
243
+ path.write_text(current + suffix + "\n".join(additions) + "\n", encoding="utf-8")
244
+ return path, True
245
+ return path, False
246
+ path.write_text("\n".join(required) + "\n", encoding="utf-8")
247
+ return path, True
248
+
249
+
143
250
  def init_context(root: Path) -> list[Path]:
144
251
  today = datetime.now().strftime("%Y-%m-%d")
145
252
  ctx = context_dir(root)
@@ -153,10 +260,16 @@ def init_context(root: Path) -> list[Path]:
153
260
  ctx / "roadmap",
154
261
  ctx / "exports",
155
262
  ctx / "archive",
263
+ ctx / "private",
156
264
  ]:
157
265
  if not directory.exists():
158
266
  directory.mkdir(parents=True, exist_ok=True)
159
267
  created.append(directory)
268
+ if directory == ctx / "private":
269
+ try:
270
+ directory.chmod(0o700)
271
+ except OSError:
272
+ pass
160
273
 
161
274
  files = {
162
275
  ctx / "index.md": f"""# Context Index
@@ -182,6 +295,24 @@ None.
182
295
 
183
296
  Keep only concise summaries here. Move detailed stale context to `.codex/context/archive/`.
184
297
 
298
+ Last initialized: {today}
299
+ """,
300
+ ctx / "user-messages.md": f"""# User Message Memory
301
+
302
+ This file preserves concise user wording that future Codex turns may need. It is agent-readable context, not a public transcript.
303
+
304
+ ## Recent User Signals
305
+
306
+ None yet.
307
+
308
+ ## Durable User Constraints
309
+
310
+ None yet.
311
+
312
+ ## Secret Pointers
313
+
314
+ Raw secrets must stay only in `.codex/context/private/` or an OS credential store. This file may contain redacted pointers, never plaintext secrets.
315
+
185
316
  Last initialized: {today}
186
317
  """,
187
318
  ctx / "roadmap.md": f"""# Context Roadmap
@@ -213,6 +344,9 @@ Last initialized: {today}
213
344
  for path, content in files.items():
214
345
  if write_if_missing(path, content):
215
346
  created.append(path)
347
+ gitignore_path, gitignore_changed = ensure_context_gitignore(root)
348
+ if gitignore_changed and gitignore_path not in created:
349
+ created.append(gitignore_path)
216
350
  return created
217
351
 
218
352
 
@@ -368,6 +502,9 @@ const I18N = {{
368
502
  humanDetailView: "Human detail view",
369
503
  updatedLabel: "Updated:",
370
504
  roadmap: "Roadmap",
505
+ viewMode: "Roadmap view",
506
+ cardView: "Card view",
507
+ compactView: "Compact overview",
371
508
  backToRoadmap: "Back to roadmap",
372
509
  mainRoute: "Main Route",
373
510
  badCases: "Bad Cases",
@@ -403,6 +540,9 @@ const I18N = {{
403
540
  humanDetailView: "人类详情视图",
404
541
  updatedLabel: "更新:",
405
542
  roadmap: "路线图",
543
+ viewMode: "路线图视图",
544
+ cardView: "卡片视图",
545
+ compactView: "紧凑总览",
406
546
  backToRoadmap: "返回路线图",
407
547
  mainRoute: "主要路线",
408
548
  badCases: "问题案例",
@@ -457,6 +597,13 @@ function applyLang(lang) {{
457
597
  const value = lang === "zh" ? element.dataset.zh : element.dataset.en;
458
598
  if (value) element.textContent = value;
459
599
  }});
600
+ document.querySelectorAll("[data-i18n-aria]").forEach((element) => {{
601
+ const value = dictionary[element.dataset.i18nAria] || element.getAttribute("aria-label") || "";
602
+ if (value) {{
603
+ element.setAttribute("aria-label", value);
604
+ element.setAttribute("title", value);
605
+ }}
606
+ }});
460
607
  }}
461
608
 
462
609
  function connectorAnchor(element) {{
@@ -513,8 +660,9 @@ function drawRouteConnectors(stack, svg, stackRect) {{
513
660
  cards.forEach((card, index) => {{
514
661
  const next = cards[index + 1];
515
662
  if (!next) return;
516
- const start = cardConnectorPoint(card, stackRect, stack, "right");
517
- const end = cardConnectorPoint(next, stackRect, stack, "left");
663
+ const compact = document.querySelector("[data-roadmap-board]")?.dataset.roadmapView === "compact";
664
+ const start = compact ? dotConnectorPoint(card, stackRect, stack) : cardConnectorPoint(card, stackRect, stack, "right");
665
+ const end = compact ? dotConnectorPoint(next, stackRect, stack) : cardConnectorPoint(next, stackRect, stack, "left");
518
666
  const handle = Math.max(28, (end.x - start.x) * 0.45);
519
667
  const d = `M ${{start.x}} ${{start.y}} C ${{start.x + handle}} ${{start.y}} ${{end.x - handle}} ${{end.y}} ${{end.x}} ${{end.y}}`;
520
668
  createConnectorPath(svg, d, "route-connector", {{
@@ -604,9 +752,40 @@ function setupInlineDetails() {{
604
752
  showFromHash();
605
753
  }}
606
754
 
755
+ function setupRoadmapViews() {{
756
+ const board = document.querySelector("[data-roadmap-board]");
757
+ if (!board) return;
758
+ const buttons = Array.from(board.querySelectorAll("[data-roadmap-view-button]"));
759
+ if (!buttons.length) return;
760
+ const valid = (mode) => mode === "cards" || mode === "compact";
761
+ const applyView = (mode, persist = true) => {{
762
+ const next = valid(mode) ? mode : "cards";
763
+ board.dataset.roadmapView = next;
764
+ document.body.dataset.roadmapView = next;
765
+ buttons.forEach((button) => {{
766
+ button.setAttribute("aria-pressed", button.dataset.roadmapViewButton === next ? "true" : "false");
767
+ }});
768
+ if (persist) localStorage.setItem("contextGuardRoadmapView", next);
769
+ requestAnimationFrame(drawBranchConnectors);
770
+ }};
771
+ const query = new URLSearchParams(window.location.search).get("view");
772
+ const saved = localStorage.getItem("contextGuardRoadmapView");
773
+ const visibleCount = Number(board.dataset.visibleNodeCount || "0");
774
+ const initial = valid(query) ? query : (valid(saved) ? saved : (visibleCount > 16 ? "compact" : "cards"));
775
+ applyView(initial, false);
776
+ buttons.forEach((button) => button.addEventListener("click", () => {{
777
+ const mode = button.dataset.roadmapViewButton;
778
+ const url = new URL(window.location.href);
779
+ url.searchParams.set("view", mode);
780
+ window.history.replaceState(null, "", url);
781
+ applyView(mode);
782
+ }}));
783
+ }}
784
+
607
785
  document.addEventListener("DOMContentLoaded", () => {{
608
786
  const initial = resolveLang();
609
787
  applyLang(initial);
788
+ setupRoadmapViews();
610
789
  const routeButtons = Array.from(document.querySelectorAll("[data-route-filter]"));
611
790
  const routeExists = (route) => routeButtons.some((button) => button.dataset.routeFilter === route);
612
791
  const applyRoute = (route) => {{
@@ -736,6 +915,45 @@ def render_roadmap_html(ctx: Path, index: str, roadmap: str, bad_cases: str) ->
736
915
  padding: 16px 32px 30px;
737
916
  }}
738
917
  h2 {{ margin: 0 0 12px; font-family: var(--font-heading); font-size: 16px; }}
918
+ .roadmap-title-row {{
919
+ display: flex;
920
+ align-items: center;
921
+ justify-content: space-between;
922
+ gap: 12px;
923
+ margin-bottom: 12px;
924
+ }}
925
+ .roadmap-title-row h2 {{ margin: 0; }}
926
+ .view-switch {{
927
+ display: inline-flex;
928
+ align-items: center;
929
+ gap: 3px;
930
+ padding: 3px;
931
+ border: 1px solid var(--line);
932
+ border-radius: 7px;
933
+ background: color-mix(in srgb, var(--panel) 86%, var(--accent-soft));
934
+ }}
935
+ .view-switch button {{
936
+ width: 30px;
937
+ height: 28px;
938
+ display: grid;
939
+ place-items: center;
940
+ border: 0;
941
+ border-radius: 5px;
942
+ background: transparent;
943
+ color: var(--muted);
944
+ cursor: pointer;
945
+ padding: 0;
946
+ }}
947
+ .view-switch button:hover {{ color: var(--accent); }}
948
+ .view-switch button[aria-pressed="true"] {{
949
+ background: var(--panel);
950
+ color: var(--accent);
951
+ box-shadow: 0 1px 4px rgba(51, 83, 57, 0.14);
952
+ }}
953
+ .view-icon {{ width: 15px; height: 15px; display: grid; gap: 2px; }}
954
+ .view-icon i {{ display: block; border-radius: 1px; background: currentColor; }}
955
+ .view-icon-cards {{ grid-template-rows: repeat(2, 1fr); }}
956
+ .view-icon-compact {{ grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(3, 1fr); }}
739
957
  .route-filter {{
740
958
  display: flex;
741
959
  align-items: center;
@@ -819,6 +1037,22 @@ def render_roadmap_html(ctx: Path, index: str, roadmap: str, bad_cases: str) ->
819
1037
  position: relative;
820
1038
  z-index: 1;
821
1039
  }}
1040
+ .track-board[data-roadmap-view="compact"] .route-stack.branch-map .route-group {{
1041
+ min-width: max-content;
1042
+ }}
1043
+ body[data-roadmap-view="compact"] header {{ padding: 12px 32px 8px; }}
1044
+ body[data-roadmap-view="compact"] h1 {{ font-size: 20px; }}
1045
+ .track-board[data-roadmap-view="compact"] {{ padding: 11px 14px 13px; }}
1046
+ .track-board[data-roadmap-view="compact"] .roadmap-title-row {{
1047
+ justify-content: flex-end;
1048
+ margin-bottom: 3px;
1049
+ }}
1050
+ .track-board[data-roadmap-view="compact"] .roadmap-title-row h2,
1051
+ .track-board[data-roadmap-view="compact"] .route-filter {{ display: none; }}
1052
+ .track-board[data-roadmap-view="compact"] .route-stack {{ gap: 7px; }}
1053
+ .track-board[data-roadmap-view="compact"] .route-head {{ margin-bottom: 3px; }}
1054
+ .track-board[data-roadmap-view="compact"] .route-head-grid {{ margin-bottom: 3px; }}
1055
+ .track-board[data-roadmap-view="compact"] .checkpoint-strip {{ margin: -1px 0 3px; }}
822
1056
  .route-group.route-branch {{
823
1057
  position: relative;
824
1058
  padding-right: var(--branch-drift, 0px);
@@ -925,6 +1159,13 @@ def render_roadmap_html(ctx: Path, index: str, roadmap: str, bad_cases: str) ->
925
1159
  grid-auto-columns: minmax(240px, 300px);
926
1160
  min-height: 0;
927
1161
  }}
1162
+ .track-board[data-roadmap-view="compact"] .track-grid.route-only {{
1163
+ grid-template-columns: none;
1164
+ grid-auto-flow: column;
1165
+ grid-auto-columns: 132px;
1166
+ gap: 10px;
1167
+ min-height: 0;
1168
+ }}
928
1169
  .route-head-grid.track-grid.route-only {{
929
1170
  min-height: 0;
930
1171
  }}
@@ -972,6 +1213,17 @@ def render_roadmap_html(ctx: Path, index: str, roadmap: str, bad_cases: str) ->
972
1213
  min-height: 92px;
973
1214
  box-shadow: 0 1px 0 rgba(255, 255, 255, 0.72), 0 8px 20px rgba(51, 83, 57, 0.08);
974
1215
  }}
1216
+ .track-board[data-roadmap-view="compact"] .lane {{
1217
+ height: 96px;
1218
+ min-height: 96px;
1219
+ padding: 0;
1220
+ border: 0;
1221
+ background: transparent;
1222
+ box-shadow: none;
1223
+ position: relative;
1224
+ }}
1225
+ .track-board[data-roadmap-view="compact"] .lane-link {{ height: 100%; position: relative; }}
1226
+ .track-board[data-roadmap-view="compact"] .lane-link:hover h3 {{ color: var(--route-accent, var(--accent)); }}
975
1227
  .branch-map .route-test-line {{
976
1228
  min-height: 18px;
977
1229
  border-top: 2px solid color-mix(in srgb, var(--ok) 56%, transparent);
@@ -1069,6 +1321,7 @@ def render_roadmap_html(ctx: Path, index: str, roadmap: str, bad_cases: str) ->
1069
1321
  .branch-map .summary {{
1070
1322
  display: none;
1071
1323
  }}
1324
+ .track-board[data-roadmap-view="compact"] .summary {{ display: none; }}
1072
1325
  .node-heading {{
1073
1326
  display: flex;
1074
1327
  gap: 8px;
@@ -1078,6 +1331,17 @@ def render_roadmap_html(ctx: Path, index: str, roadmap: str, bad_cases: str) ->
1078
1331
  .branch-map .node-heading {{
1079
1332
  margin-bottom: 6px;
1080
1333
  }}
1334
+ .track-board[data-roadmap-view="compact"] .node-heading {{
1335
+ position: absolute;
1336
+ left: 0;
1337
+ right: 0;
1338
+ align-items: flex-start;
1339
+ gap: 5px;
1340
+ margin: 0;
1341
+ padding: 0 4px;
1342
+ }}
1343
+ .track-board[data-roadmap-view="compact"] .step-up .node-heading {{ bottom: 57px; align-items: flex-end; }}
1344
+ .track-board[data-roadmap-view="compact"] .step-down .node-heading {{ top: 57px; }}
1081
1345
  .node-number {{
1082
1346
  flex: 0 0 auto;
1083
1347
  width: 28px; height: 28px; border-radius: 50%;
@@ -1085,10 +1349,41 @@ def render_roadmap_html(ctx: Path, index: str, roadmap: str, bad_cases: str) ->
1085
1349
  background: var(--route-accent, var(--accent)); color: white; font-weight: 760;
1086
1350
  box-shadow: 0 0 0 4px var(--route-soft, var(--accent-soft));
1087
1351
  }}
1352
+ .track-board[data-roadmap-view="compact"] .node-number {{
1353
+ width: auto;
1354
+ height: auto;
1355
+ display: inline;
1356
+ color: var(--route-accent, var(--accent));
1357
+ background: transparent;
1358
+ box-shadow: none;
1359
+ font-size: 10px;
1360
+ line-height: 1.3;
1361
+ }}
1088
1362
  .lane h3 {{ margin: 0; font-family: var(--font-heading); font-size: 15px; line-height: 1.35; }}
1089
1363
  .branch-map .lane h3 {{ font-size: 14px; line-height: 1.28; }}
1364
+ .track-board[data-roadmap-view="compact"] .lane h3 {{
1365
+ font-family: var(--font-body);
1366
+ font-size: 11px;
1367
+ line-height: 1.28;
1368
+ font-weight: 680;
1369
+ display: -webkit-box;
1370
+ -webkit-line-clamp: 2;
1371
+ -webkit-box-orient: vertical;
1372
+ overflow: hidden;
1373
+ }}
1090
1374
  .node-meta {{ display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 10px; }}
1091
1375
  .branch-map .node-meta {{ margin-bottom: 0; }}
1376
+ .track-board[data-roadmap-view="compact"] .node-meta {{
1377
+ position: absolute;
1378
+ top: 43px;
1379
+ left: 50%;
1380
+ right: auto;
1381
+ transform: translate(-50%, -50%);
1382
+ margin: 0;
1383
+ z-index: 2;
1384
+ }}
1385
+ .track-board[data-roadmap-view="compact"] .node-meta .pill {{ display: none; }}
1386
+ .track-board[data-roadmap-view="compact"] .node-meta .status-dot {{ width: 8px; height: 8px; }}
1092
1387
  .pill {{
1093
1388
  display: inline-flex;
1094
1389
  align-items: center;
@@ -1206,6 +1501,8 @@ def render_roadmap_html(ctx: Path, index: str, roadmap: str, bad_cases: str) ->
1206
1501
  .quick {{ grid-template-columns: 1fr 1fr; }}
1207
1502
  .track-grid {{ grid-auto-columns: minmax(260px, 82vw); }}
1208
1503
  header {{ padding: 22px 16px 14px; }}
1504
+ body[data-roadmap-view="compact"] header {{ padding: 11px 16px 7px; }}
1505
+ .track-board[data-roadmap-view="compact"] .track-grid.route-only {{ grid-auto-columns: 118px; }}
1209
1506
  }}
1210
1507
  @media (max-width: 560px) {{
1211
1508
  h1 {{ font-size: 22px; }}
@@ -1221,8 +1518,18 @@ def render_roadmap_html(ctx: Path, index: str, roadmap: str, bad_cases: str) ->
1221
1518
  </div>
1222
1519
  </header>
1223
1520
  <div class="shell">
1224
- <main class="track-board" id="roadmap-overview">
1225
- <h2 data-i18n="roadmap">Roadmap</h2>
1521
+ <main class="track-board" id="roadmap-overview" data-roadmap-board data-visible-node-count="{sum(len(display_items_for_route(items)) for _, items in route_groups)}">
1522
+ <div class="roadmap-title-row">
1523
+ <h2 data-i18n="roadmap">Roadmap</h2>
1524
+ <div class="view-switch" role="group" data-i18n-aria="viewMode" aria-label="Roadmap view">
1525
+ <button type="button" data-roadmap-view-button="cards" data-i18n-aria="cardView" aria-label="Card view" aria-pressed="false">
1526
+ <span class="view-icon view-icon-cards" aria-hidden="true"><i></i><i></i></span>
1527
+ </button>
1528
+ <button type="button" data-roadmap-view-button="compact" data-i18n-aria="compactView" aria-label="Compact overview" aria-pressed="false">
1529
+ <span class="view-icon view-icon-compact" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></span>
1530
+ </button>
1531
+ </div>
1532
+ </div>
1226
1533
  {route_nav}
1227
1534
  <div class="route-stack{' branch-map' if branch_mode else ''}"{' data-route-map-overview' if branch_mode else ''}>{connector_layer}{route_items}</div>
1228
1535
  </main>
@@ -2429,7 +2736,6 @@ def render_route_group(
2429
2736
  route_offset = max(0, route_offset)
2430
2737
  route_spacers = render_route_spacers(route_offset) if branch_mode else ""
2431
2738
  if branch_mode:
2432
- show_route_tests = route_has_approved_tests(display_items, bad_case_cards)
2433
2739
  columns = "\n".join(
2434
2740
  render_route_column(
2435
2741
  node,
@@ -2438,7 +2744,7 @@ def render_route_group(
2438
2744
  bad_case_cards,
2439
2745
  case_anchor_map,
2440
2746
  branch_start=branch_mode and display_number == 1 and route_offset > 0,
2441
- show_test_line=show_route_tests,
2747
+ show_test_line=False,
2442
2748
  )
2443
2749
  for display_number, (source_number, node) in enumerate(display_items, 1)
2444
2750
  )
@@ -2538,7 +2844,7 @@ def render_route_column(
2538
2844
  bad_case_cards: list[dict[str, str]] | None = None,
2539
2845
  case_anchor_map: dict[str, str] | None = None,
2540
2846
  branch_start: bool = False,
2541
- show_test_line: bool = True,
2847
+ show_test_line: bool = False,
2542
2848
  ) -> str:
2543
2849
  title = localized_text(node_display_title(node, f"Node {source_number}"))
2544
2850
  status = node.get("status", "unknown")
@@ -2547,25 +2853,9 @@ def render_route_column(
2547
2853
  branch_class = " branch-start" if branch_start else ""
2548
2854
  overview_id = html.escape(node_id(node))
2549
2855
  test_line = ""
2550
- if show_test_line:
2551
- cases = approved_test_cases_for_node(node, bad_case_cards or [])
2552
- visible_cases = cases[:2]
2553
- hidden_test_count = max(0, len(cases) - len(visible_cases))
2554
- test_items = "\n".join(
2555
- render_route_test_note(card, (case_anchor_map or {}).get(card.get("title", ""), "case-1"))
2556
- for card in visible_cases
2557
- )
2558
- if hidden_test_count:
2559
- test_items += f'\n<span class="route-test-more">+{hidden_test_count}</span>'
2560
- test_line = (
2561
- f"""<article class="route-test-line has-tests" data-lane="test-chain">
2562
- {test_items}
2563
- </article>"""
2564
- if test_items
2565
- else '<div class="route-test-line route-test-empty" data-lane="test-chain" aria-hidden="true"></div>'
2566
- )
2567
- mainline_only_class = " no-test-line" if not show_test_line else ""
2568
- return f"""<section class="track-column route-column{branch_class}{mainline_only_class}" data-overview-node-id="{overview_id}">
2856
+ mainline_only_class = " no-test-line"
2857
+ step_class = " step-up" if display_number % 2 else " step-down"
2858
+ return f"""<section class="track-column route-column{branch_class}{mainline_only_class}{step_class}" data-overview-node-id="{overview_id}">
2569
2859
  <article class="lane lane-main" data-lane="main">
2570
2860
  <a class="lane-link" href="#node-{source_number}">
2571
2861
  <div class="node-heading">
@@ -2859,11 +3149,33 @@ def canonical_bad_case_key(key: str) -> str:
2859
3149
  "high-frequency note": "high-frequency note",
2860
3150
  "recurrence analysis": "recurrence analysis",
2861
3151
  "evidence": "evidence",
3152
+ "状态": "status",
3153
+ "标题": "title",
3154
+ "名称": "title",
3155
+ "标签": "tags",
3156
+ "范围": "scope",
3157
+ "作用域": "scope",
3158
+ "现象": "phenomenon",
3159
+ "问题": "phenomenon",
3160
+ "触发": "trigger / reproduction",
3161
+ "复现": "trigger / reproduction",
3162
+ "根因": "root cause",
3163
+ "原因": "root cause",
3164
+ "修复": "fix method",
3165
+ "解决": "fix method",
3166
+ "方法": "fix method",
3167
+ "验证": "guard / verification",
3168
+ "防护": "guard / verification",
3169
+ "测试链路": "test chain",
3170
+ "测试": "test chain",
3171
+ "红线": "red condition",
3172
+ "绿线": "green condition",
2862
3173
  }
2863
3174
  return aliases.get(normalized, normalized)
2864
3175
 
2865
3176
 
2866
3177
  def split_loose_bad_case_field(body: str) -> tuple[str, str] | None:
3178
+ body = body.replace(":", ":", 1)
2867
3179
  if ":" not in body:
2868
3180
  return None
2869
3181
  key, value = body.split(":", 1)
@@ -2900,9 +3212,30 @@ def split_loose_bad_case_field(body: str) -> tuple[str, str] | None:
2900
3212
  return key, value.strip()
2901
3213
 
2902
3214
 
3215
+ def parse_loose_bad_case_inline_fields(body: str) -> tuple[str, dict[str, str]]:
3216
+ title_parts: list[str] = []
3217
+ fields: dict[str, str] = {}
3218
+ for segment in [part.strip() for part in body.split("|") if part.strip()]:
3219
+ field = split_loose_bad_case_field(segment)
3220
+ if field:
3221
+ key, value = field
3222
+ fields[key] = value
3223
+ continue
3224
+ if segment:
3225
+ title_parts.append(segment.strip(" -::"))
3226
+ return " ".join(part for part in title_parts if part).strip(), fields
3227
+
3228
+
2903
3229
  def loose_bad_case_title(card: dict[str, str]) -> str:
2904
3230
  title = card.get("title", "").strip()
2905
3231
  identifier = card.pop("id", "").strip()
3232
+ if not title:
3233
+ title = first_nonempty(
3234
+ card.get("display summary", ""),
3235
+ card.get("phenomenon", ""),
3236
+ card.get("root cause", ""),
3237
+ card.get("fix method", ""),
3238
+ )
2906
3239
  if not title:
2907
3240
  title = identifier or "Untitled bad case"
2908
3241
  elif identifier and not title.startswith(identifier):
@@ -2929,15 +3262,18 @@ def parse_loose_bad_case_cards(text: str) -> list[dict[str, str]]:
2929
3262
  body = ""
2930
3263
  if stripped.startswith("- "):
2931
3264
  body = stripped[2:].strip()
2932
- elif current and re.match(r"^[A-Za-z][A-Za-z _/-]+:\s+", stripped):
3265
+ elif current and re.match(r"^[\w\u4e00-\u9fff][\w\u4e00-\u9fff _/-]+[::]\s+", stripped):
2933
3266
  body = stripped
2934
3267
  else:
2935
3268
  continue
2936
3269
 
2937
- case_line = re.match(r"^(BC-\d{8}-\d+)\s*:\s*(.+)$", body)
3270
+ case_line = re.match(r"^(BC-\d{8}-\d+)\s*(?::|:|\|)\s*(.*)$", body)
2938
3271
  if case_line:
2939
3272
  commit_loose_bad_case(cards, current)
2940
- current = {"id": case_line.group(1), "title": case_line.group(2).strip()}
3273
+ trailing_title, fields = parse_loose_bad_case_inline_fields(case_line.group(2).strip())
3274
+ current = {"id": case_line.group(1), **fields}
3275
+ if trailing_title and "title" not in current:
3276
+ current["title"] = trailing_title
2941
3277
  continue
2942
3278
 
2943
3279
  field = split_loose_bad_case_field(body)
@@ -3607,6 +3943,27 @@ def checkpoint_roadmap_node(
3607
3943
 
3608
3944
  APPROVED_TEST_STATUSES = {"approved", "active", "stable"}
3609
3945
  RUN_ALWAYS_POLICY = "every-dev-completion"
3946
+ FEATURE_CHAIN_COVERAGE_SUGGESTION_MIN_SCORE = 12
3947
+ FEATURE_CHAIN_MATCH_EVIDENCE_STOP_TERMS = {
3948
+ "bad",
3949
+ "case",
3950
+ "bc",
3951
+ "status",
3952
+ "resolved",
3953
+ "context",
3954
+ "guard",
3955
+ "feature",
3956
+ "feature-chain",
3957
+ "chain",
3958
+ "test",
3959
+ "tests",
3960
+ "local",
3961
+ "manual",
3962
+ "command",
3963
+ "用户",
3964
+ "没有",
3965
+ "为空",
3966
+ }
3610
3967
  BLOCKER_PATTERNS = [
3611
3968
  "MISSING_CREDENTIAL",
3612
3969
  "PERMISSION_DENIED",
@@ -3616,10 +3973,11 @@ BLOCKER_PATTERNS = [
3616
3973
  "USER_CONFIRMATION_REQUIRED",
3617
3974
  "DESTRUCTIVE_CONFIRMATION_REQUIRED",
3618
3975
  ]
3976
+ CHECKPOINT_MARKER_RE = re.compile(r"^CG_CHECKPOINT:(?P<name>[^:]+):(?P<status>PASS|FAIL)(?::(?P<reason>.*))?$")
3619
3977
 
3620
3978
 
3621
3979
  def normalize_test_slug(value: str) -> str:
3622
- slug = re.sub(r"[^A-Za-z0-9]+", "-", value.strip().lower()).strip("-")
3980
+ slug = re.sub(r"[^\w.-]+", "-", value.strip().lower(), flags=re.UNICODE).strip("-_.")
3623
3981
  return slug or "test"
3624
3982
 
3625
3983
 
@@ -3631,6 +3989,10 @@ def feature_chains_path(ctx: Path) -> Path:
3631
3989
  return ctx / "test-hub" / "feature-chains.json"
3632
3990
 
3633
3991
 
3992
+ def feature_chain_auto_state_path(ctx: Path) -> Path:
3993
+ return ctx / "test-hub" / "feature-chain-auto-state.json"
3994
+
3995
+
3634
3996
  def default_test_registry() -> dict[str, object]:
3635
3997
  return {
3636
3998
  "version": 1,
@@ -3718,6 +4080,14 @@ def next_feature_chain_id(ctx: Path) -> str:
3718
4080
  return f"FC-{today}-{(max(numbers) + 1 if numbers else 1):03d}"
3719
4081
 
3720
4082
 
4083
+ def next_bad_case_id(ctx: Path) -> str:
4084
+ today = datetime.now().strftime("%Y%m%d")
4085
+ path = ctx / "bad-cases.md"
4086
+ text = path.read_text(encoding="utf-8") if path.exists() else ""
4087
+ numbers = [int(match.group(1)) for match in re.finditer(rf"BC-{today}-(\d+)", text)]
4088
+ return f"BC-{today}-{(max(numbers) + 1 if numbers else 1):03d}"
4089
+
4090
+
3721
4091
  def feature_chain_find(
3722
4092
  ctx: Path, chain_id: str
3723
4093
  ) -> tuple[dict[str, object], list[dict[str, object]], dict[str, object]]:
@@ -3752,6 +4122,14 @@ def feature_chain_add(
3752
4122
  raise ValueError("feature-chain-add requires --entry")
3753
4123
  if not exit_check.strip():
3754
4124
  raise ValueError("feature-chain-add requires --exit-check")
4125
+ normalized_status = (status.strip() or "proposed").lower()
4126
+ normalized_policy = run_policy.strip() or RUN_ALWAYS_POLICY
4127
+ if normalized_status in APPROVED_TEST_STATUSES and normalized_policy == RUN_ALWAYS_POLICY:
4128
+ raise ValueError(
4129
+ "feature-chain-add cannot create approved every-dev-completion chains; "
4130
+ "create a proposed chain and use feature-chain-approve so the user confirmation "
4131
+ "and approval dry-run gates cannot be skipped"
4132
+ )
3755
4133
  registry = load_feature_chains(ctx)
3756
4134
  chains = registry.setdefault("chains", [])
3757
4135
  if not isinstance(chains, list):
@@ -3764,7 +4142,7 @@ def feature_chain_add(
3764
4142
  "id": chain_id,
3765
4143
  "title": title.strip(),
3766
4144
  "status": status.strip() or "proposed",
3767
- "run_policy": run_policy.strip() or RUN_ALWAYS_POLICY,
4145
+ "run_policy": normalized_policy,
3768
4146
  "entry": entry.strip(),
3769
4147
  "exit_check": exit_check.strip(),
3770
4148
  "type": "command" if command_text.strip() else "manual",
@@ -3835,6 +4213,8 @@ def feature_chain_attach_bc(
3835
4213
  node["checks"] = checks
3836
4214
  if check.strip() and check.strip() not in [str(item) for item in checks]:
3837
4215
  checks.append(check.strip())
4216
+ if [str(item).strip() for item in bad_cases if str(item).strip()]:
4217
+ node.pop("coverage_pending_reason", None)
3838
4218
  now = datetime.now().isoformat(timespec="seconds")
3839
4219
  node["updated_at"] = now
3840
4220
  chain["updated_at"] = now
@@ -3845,104 +4225,1908 @@ def feature_chain_attach_bc(
3845
4225
  return path
3846
4226
 
3847
4227
 
3848
- def feature_chain_list(root: Path) -> int:
3849
- init_context(root)
3850
- ctx = context_dir(root)
3851
- registry = load_feature_chains(ctx)
3852
- chains = [item for item in registry.get("chains", []) if isinstance(item, dict)]
3853
- if not chains:
3854
- print("[context-guard] feature chains: none.")
3855
- print(f"[context-guard] registry: {feature_chains_path(ctx)}")
3856
- return 0
3857
- print("[context-guard] feature chains:")
3858
- for chain in chains:
3859
- chain_id = str(chain.get("id", "unknown"))
3860
- title = str(chain.get("title", "untitled"))
3861
- status = str(chain.get("status", "unknown"))
3862
- run_policy = str(chain.get("run_policy", "unset"))
3863
- nodes = [item for item in chain.get("nodes", []) if isinstance(item, dict)]
3864
- covered = sorted(
3865
- {
3866
- str(bc)
3867
- for node in nodes
3868
- for bc in (node.get("bad_cases", []) if isinstance(node.get("bad_cases", []), list) else [])
3869
- }
3870
- )
3871
- suffix = f" | covers {', '.join(covered)}" if covered else ""
3872
- print(f"- {chain_id} | {status} | {run_policy} | {title} | {len(nodes)} node(s){suffix}")
3873
- print(f"[context-guard] registry: {feature_chains_path(ctx)}")
3874
- return 0
4228
+ def parse_bad_case_list(value: str) -> list[str]:
4229
+ items = []
4230
+ for part in re.split(r"[,;\n]+", value or ""):
4231
+ clean = part.strip()
4232
+ if clean:
4233
+ items.append(clean)
4234
+ return items
3875
4235
 
3876
4236
 
3877
- def test_hub_add(
4237
+ def feature_chain_propose(
3878
4238
  root: Path,
3879
4239
  title: str,
3880
- command_text: str,
4240
+ entry: str,
4241
+ exit_check: str,
4242
+ node_title: str,
4243
+ bad_cases: list[str],
4244
+ check: str,
4245
+ coverage_pending_reason: str = "",
3881
4246
  run_policy: str = RUN_ALWAYS_POLICY,
3882
- status: str = "approved",
3883
- timeout_seconds: int = 300,
3884
4247
  artifact_policy: str = "cleanup-on-pass",
3885
4248
  resource: str = "local",
3886
4249
  ) -> Path:
3887
4250
  init_context(root)
3888
4251
  ctx = context_dir(root)
3889
4252
  if not title.strip():
3890
- raise ValueError("test-hub-add requires --title")
3891
- if not command_text.strip():
3892
- raise ValueError("test-hub-add requires --command-text")
3893
- registry = load_test_registry(ctx)
3894
- tests = registry.setdefault("tests", [])
3895
- if not isinstance(tests, list):
3896
- tests = []
3897
- registry["tests"] = tests
3898
- test_id = next_test_id(ctx, title)
3899
- tests.append(
4253
+ raise ValueError("feature-chain-propose requires --title")
4254
+ if not entry.strip():
4255
+ raise ValueError("feature-chain-propose requires --entry")
4256
+ if not exit_check.strip():
4257
+ raise ValueError("feature-chain-propose requires --exit-check")
4258
+ if not node_title.strip():
4259
+ raise ValueError("feature-chain-propose requires --node-title")
4260
+ clean_bad_cases = [item.strip() for item in bad_cases if item.strip()]
4261
+ if not clean_bad_cases and not coverage_pending_reason.strip():
4262
+ raise ValueError("feature-chain-propose requires --bad-cases or --coverage-pending-reason")
4263
+ if not check.strip():
4264
+ raise ValueError("feature-chain-propose requires --check")
4265
+
4266
+ registry = load_feature_chains(ctx)
4267
+ chains = registry.setdefault("chains", [])
4268
+ if not isinstance(chains, list):
4269
+ chains = []
4270
+ registry["chains"] = chains
4271
+ chain_id = next_feature_chain_id(ctx)
4272
+ now = datetime.now().isoformat(timespec="seconds")
4273
+ node_slug = normalize_test_slug(node_title)
4274
+ node: dict[str, object] = {
4275
+ "id": node_slug,
4276
+ "title": node_title.strip(),
4277
+ "checks": [check.strip()],
4278
+ "bad_cases": clean_bad_cases,
4279
+ "created_at": now,
4280
+ "updated_at": now,
4281
+ }
4282
+ if coverage_pending_reason.strip() and not clean_bad_cases:
4283
+ node["coverage_pending_reason"] = coverage_pending_reason.strip()
4284
+ chains.append(
3900
4285
  {
3901
- "id": test_id,
4286
+ "id": chain_id,
3902
4287
  "title": title.strip(),
3903
- "status": status.strip() or "approved",
4288
+ "status": "proposed",
3904
4289
  "run_policy": run_policy.strip() or RUN_ALWAYS_POLICY,
3905
- "type": "command",
3906
- "command": command_text.strip(),
4290
+ "entry": entry.strip(),
4291
+ "exit_check": exit_check.strip(),
4292
+ "type": "manual",
4293
+ "command": "",
3907
4294
  "cwd": ".",
3908
4295
  "resource": resource.strip() or "local",
3909
- "timeout_seconds": int(timeout_seconds),
4296
+ "timeout_seconds": 300,
3910
4297
  "artifact_policy": artifact_policy.strip() or "cleanup-on-pass",
3911
4298
  "blocker_keywords": BLOCKER_PATTERNS,
3912
- "created_at": datetime.now().isoformat(timespec="seconds"),
3913
- "source": "human-approved registry entry",
4299
+ "nodes": [node],
4300
+ "created_at": now,
4301
+ "updated_at": now,
4302
+ "source": "feature-chain",
4303
+ "proposal_note": "Human-confirmed draft only; approve with an explicit command before it can run.",
3914
4304
  }
3915
4305
  )
3916
- path = write_test_registry(ctx, registry)
3917
- print(f"[context-guard] registered test: {test_id}")
4306
+ path = write_feature_chains(ctx, registry)
4307
+ print(f"[context-guard] feature chain proposed: {chain_id}")
4308
+ print("[context-guard] status: proposed; this chain is not executable and will not run in dev-complete.")
4309
+ if clean_bad_cases:
4310
+ print(f"[context-guard] checkpoint: {node_title.strip()} | covers {', '.join(clean_bad_cases)}")
4311
+ else:
4312
+ print(f"[context-guard] checkpoint: {node_title.strip()} | coverage pending")
4313
+ print(f"[context-guard] coverage pending reason: {coverage_pending_reason.strip()}")
4314
+ print("[context-guard] next: after the user confirms the automation, run feature-chain-approve with the approved command.")
3918
4315
  print(f"[context-guard] registry: {path}")
3919
4316
  return path
3920
4317
 
3921
4318
 
3922
- def test_hub_registry_tests(ctx: Path) -> list[dict[str, object]]:
3923
- registry = load_test_registry(ctx)
3924
- return [item for item in registry.get("tests", []) if isinstance(item, dict)]
4319
+ def feature_chain_approve(
4320
+ root: Path,
4321
+ chain_id: str,
4322
+ command_text: str = "",
4323
+ run_policy: str = RUN_ALWAYS_POLICY,
4324
+ timeout_seconds: int = 300,
4325
+ artifact_policy: str = "cleanup-on-pass",
4326
+ resource: str = "local",
4327
+ ) -> Path:
4328
+ init_context(root)
4329
+ ctx = context_dir(root)
4330
+ chain_id = chain_id.strip()
4331
+ if not chain_id:
4332
+ raise ValueError("feature-chain-approve requires --chain-id")
4333
+ chain, _chains, registry = feature_chain_find(ctx, chain_id)
4334
+ nodes = [node for node in chain.get("nodes", []) if isinstance(node, dict)]
4335
+ if not nodes:
4336
+ raise ValueError("feature-chain-approve requires at least one checkpoint node")
4337
+
4338
+ covered_nodes = []
4339
+ for node in nodes:
4340
+ checks = node.get("checks", [])
4341
+ bad_cases = node.get("bad_cases", [])
4342
+ has_check = bool(
4343
+ isinstance(checks, list)
4344
+ and [str(item).strip() for item in checks if str(item).strip()]
4345
+ )
4346
+ has_bad_case = bool(
4347
+ isinstance(bad_cases, list)
4348
+ and [str(item).strip() for item in bad_cases if str(item).strip()]
4349
+ )
4350
+ if has_check and has_bad_case:
4351
+ covered_nodes.append(node)
4352
+ if not covered_nodes:
4353
+ raise ValueError("feature-chain-approve requires a checkpoint with check text and linked bad-case coverage")
4354
+
4355
+ existing_command = str(chain.get("command", "")).strip()
4356
+ command = command_text.strip() or existing_command
4357
+ run_policy = run_policy.strip() or RUN_ALWAYS_POLICY
4358
+ if run_policy == RUN_ALWAYS_POLICY and not command:
4359
+ raise ValueError("feature-chain-approve requires --command-text for every-dev-completion")
4360
+
4361
+ if run_policy == RUN_ALWAYS_POLICY and command:
4362
+ hub = test_hub_dir(root)
4363
+ hub.mkdir(parents=True, exist_ok=True)
4364
+ run_id = unique_run_id()
4365
+ run_dir = hub / "dry-runs" / f"{run_id}-{normalize_test_slug(chain_id)}-approval"
4366
+ run_dir.mkdir(parents=True, exist_ok=True)
4367
+ preflight = dict(chain)
4368
+ preflight["id"] = chain_id
4369
+ preflight["title"] = str(chain.get("title") or chain_id)
4370
+ preflight["source"] = "feature-chain"
4371
+ preflight["type"] = "command"
4372
+ preflight["command"] = command
4373
+ preflight["timeout_seconds"] = int(timeout_seconds)
4374
+ preflight.setdefault("cwd", ".")
4375
+ preflight["artifact_policy"] = artifact_policy.strip() or "cleanup-on-pass"
4376
+ preflight["resource"] = resource.strip() or "local"
4377
+ preflight.setdefault("blocker_keywords", BLOCKER_PATTERNS)
4378
+ result = run_one_hub_test(root, run_dir, preflight)
4379
+ summary_path = hub / "last-approval-dry-run.json"
4380
+ summary_path.write_text(
4381
+ json.dumps(
4382
+ {
4383
+ "run_id": run_id,
4384
+ "root": str(root),
4385
+ "created_at": datetime.now().isoformat(timespec="seconds"),
4386
+ "chain_id": chain_id,
4387
+ "result": result,
4388
+ },
4389
+ ensure_ascii=False,
4390
+ indent=2,
4391
+ )
4392
+ + "\n",
4393
+ encoding="utf-8",
4394
+ )
4395
+ if result.get("status") != "passed":
4396
+ print(f"[context-guard] feature chain approval dry-run failed: {chain_id}", file=sys.stderr)
4397
+ if result.get("reason"):
4398
+ print(f"[context-guard] reason: {result.get('reason')}", file=sys.stderr)
4399
+ print(f"[context-guard] evidence preserved: {run_dir}", file=sys.stderr)
4400
+ print(f"[context-guard] summary: {summary_path}", file=sys.stderr)
4401
+ raise RuntimeError("feature-chain-approve requires a passing dry run before approval")
4402
+ shutil.rmtree(run_dir, ignore_errors=True)
4403
+
4404
+ now = datetime.now().isoformat(timespec="seconds")
4405
+ chain["status"] = "approved"
4406
+ chain["run_policy"] = run_policy
4407
+ chain["command"] = command
4408
+ chain["type"] = "command" if command else "manual"
4409
+ chain["timeout_seconds"] = int(timeout_seconds)
4410
+ chain["artifact_policy"] = artifact_policy.strip() or "cleanup-on-pass"
4411
+ chain["resource"] = resource.strip() or "local"
4412
+ chain["updated_at"] = now
4413
+ path = write_feature_chains(ctx, registry)
4414
+ print(f"[context-guard] feature chain approved: {chain_id}")
4415
+ print(f"[context-guard] run policy: {run_policy}")
4416
+ if run_policy == RUN_ALWAYS_POLICY and command:
4417
+ print("[context-guard] approval dry-run: passed")
4418
+ print(f"[context-guard] checkpoint coverage: {len(covered_nodes)} node(s)")
4419
+ print(f"[context-guard] registry: {path}")
4420
+ return path
3925
4421
 
3926
4422
 
3927
- def test_hub_find_registry_test(ctx: Path, test_id: str) -> tuple[dict[str, object], list[dict[str, object]], dict[str, object]]:
3928
- registry = load_test_registry(ctx)
3929
- tests = registry.setdefault("tests", [])
3930
- if not isinstance(tests, list):
3931
- tests = []
3932
- registry["tests"] = tests
3933
- matches = [item for item in tests if isinstance(item, dict) and str(item.get("id", "")) == test_id]
3934
- if not matches:
3935
- raise ValueError(f"test not found in registry: {test_id}")
3936
- return matches[0], tests, registry
4423
+ def feature_chain_set_policy(root: Path, chain_id: str, run_policy: str, reason: str = "") -> Path:
4424
+ init_context(root)
4425
+ ctx = context_dir(root)
4426
+ chain_id = chain_id.strip()
4427
+ run_policy = run_policy.strip()
4428
+ if not chain_id:
4429
+ raise ValueError("feature-chain-set-policy requires --chain-id")
4430
+ if not run_policy:
4431
+ raise ValueError("feature-chain-set-policy requires --run-policy")
4432
+ chain, _chains, registry = feature_chain_find(ctx, chain_id)
4433
+ chain["run_policy"] = run_policy
4434
+ if reason.strip():
4435
+ chain["policy_reason"] = reason.strip()
4436
+ if run_policy == "disabled-with-reason":
4437
+ chain["status"] = "disabled"
4438
+ chain["disabled_reason"] = reason.strip() or "disabled by user"
4439
+ chain["updated_at"] = datetime.now().isoformat(timespec="seconds")
4440
+ path = write_feature_chains(ctx, registry)
4441
+ print(f"[context-guard] feature chain policy updated: {chain_id}")
4442
+ print(f"[context-guard] run policy: {run_policy}")
4443
+ if reason.strip():
4444
+ print(f"[context-guard] reason: {reason.strip()}")
4445
+ print(f"[context-guard] registry: {path}")
4446
+ return path
3937
4447
 
3938
4448
 
3939
- def test_hub_list(root: Path) -> int:
4449
+ def parse_required_value(value: str) -> bool:
4450
+ normalized = value.strip().lower()
4451
+ if normalized in {"1", "true", "yes", "y", "required", "on"}:
4452
+ return True
4453
+ if normalized in {"0", "false", "no", "n", "optional", "off"}:
4454
+ return False
4455
+ raise ValueError("--required must be true/false, required/optional, yes/no, or 1/0")
4456
+
4457
+
4458
+ def feature_chain_set_checkpoint(
4459
+ root: Path,
4460
+ chain_id: str,
4461
+ node_title: str,
4462
+ required_value: str,
4463
+ reason: str = "",
4464
+ ) -> Path:
3940
4465
  init_context(root)
3941
4466
  ctx = context_dir(root)
3942
- registry_tests = test_hub_registry_tests(ctx)
3943
- task_case_tests = discover_task_case_tests(ctx)
3944
- feature_chain_tests = approved_feature_chain_tests(ctx)
3945
- if not registry_tests and not task_case_tests and not feature_chain_tests:
4467
+ chain_id = chain_id.strip()
4468
+ node_title = node_title.strip()
4469
+ if not chain_id:
4470
+ raise ValueError("feature-chain-set-checkpoint requires --chain-id")
4471
+ if not node_title:
4472
+ raise ValueError("feature-chain-set-checkpoint requires --node-title")
4473
+ required = parse_required_value(required_value)
4474
+ chain, _chains, registry = feature_chain_find(ctx, chain_id)
4475
+ nodes = chain.get("nodes", [])
4476
+ if not isinstance(nodes, list):
4477
+ raise ValueError(f"feature chain has no checkpoint nodes: {chain_id}")
4478
+
4479
+ target_slug = normalize_test_slug(node_title)
4480
+ node: dict[str, object] | None = None
4481
+ for item in nodes:
4482
+ if not isinstance(item, dict):
4483
+ continue
4484
+ item_title = str(item.get("title", "")).strip()
4485
+ item_id = str(item.get("id", "")).strip()
4486
+ if node_title in {item_title, item_id} or target_slug in {
4487
+ normalize_test_slug(item_title),
4488
+ normalize_test_slug(item_id),
4489
+ }:
4490
+ node = item
4491
+ break
4492
+ if node is None:
4493
+ raise ValueError(f"checkpoint node not found in {chain_id}: {node_title}")
4494
+
4495
+ now = datetime.now().isoformat(timespec="seconds")
4496
+ node["required"] = required
4497
+ if required:
4498
+ node.pop("optional", None)
4499
+ if reason.strip():
4500
+ node["required_reason"] = reason.strip()
4501
+ node.pop("optional_reason", None)
4502
+ else:
4503
+ node["optional"] = True
4504
+ node["optional_reason"] = reason.strip() or "marked optional by user"
4505
+ node.pop("required_reason", None)
4506
+ node["updated_at"] = now
4507
+ chain["updated_at"] = now
4508
+ path = write_feature_chains(ctx, registry)
4509
+ print(f"[context-guard] feature chain checkpoint updated: {chain_id}")
4510
+ print(f"[context-guard] checkpoint: {str(node.get('title') or node.get('id') or node_title)}")
4511
+ print(f"[context-guard] required: {str(required).lower()}")
4512
+ if reason.strip():
4513
+ print(f"[context-guard] reason: {reason.strip()}")
4514
+ print(f"[context-guard] registry: {path}")
4515
+ return path
4516
+
4517
+
4518
+ def feature_chain_list(root: Path, verbose: bool = False) -> int:
4519
+ init_context(root)
4520
+ ctx = context_dir(root)
4521
+ registry = load_feature_chains(ctx)
4522
+ chains = [item for item in registry.get("chains", []) if isinstance(item, dict)]
4523
+ if not chains:
4524
+ print("[context-guard] feature chains: none.")
4525
+ print(f"[context-guard] registry: {feature_chains_path(ctx)}")
4526
+ return 0
4527
+ print("[context-guard] feature chains:")
4528
+ for chain in chains:
4529
+ chain_id = str(chain.get("id", "unknown"))
4530
+ title = str(chain.get("title", "untitled"))
4531
+ status = str(chain.get("status", "unknown"))
4532
+ run_policy = str(chain.get("run_policy", "unset"))
4533
+ nodes = [item for item in chain.get("nodes", []) if isinstance(item, dict)]
4534
+ optional_count = sum(1 for node in nodes if is_optional_feature_checkpoint(node))
4535
+ required_count = len(nodes) - optional_count
4536
+ covered = sorted(
4537
+ {
4538
+ str(bc)
4539
+ for node in nodes
4540
+ for bc in (node.get("bad_cases", []) if isinstance(node.get("bad_cases", []), list) else [])
4541
+ }
4542
+ )
4543
+ suffix = f" | covers {', '.join(covered)}" if covered else ""
4544
+ print(
4545
+ f"- {chain_id} | {status} | {run_policy} | {title} | "
4546
+ f"{len(nodes)} node(s), {required_count} required, {optional_count} optional{suffix}"
4547
+ )
4548
+ if verbose:
4549
+ for node in nodes:
4550
+ label = str(node.get("title") or node.get("id") or "untitled").strip()
4551
+ policy = "optional" if is_optional_feature_checkpoint(node) else "required"
4552
+ reason = str(node.get("optional_reason") or node.get("required_reason") or "").strip()
4553
+ detail = f" checkpoint: {label} | {policy}"
4554
+ if reason:
4555
+ detail += f" | reason: {reason}"
4556
+ print(detail)
4557
+ print(f"[context-guard] registry: {feature_chains_path(ctx)}")
4558
+ return 0
4559
+
4560
+
4561
+ def bad_case_card_id(card: dict[str, str]) -> str:
4562
+ for value in (card.get("id", ""), card.get("title", "")):
4563
+ match = re.search(r"BC-\d{8}-\d+", value)
4564
+ if match:
4565
+ return match.group(0)
4566
+ return ""
4567
+
4568
+
4569
+ def bad_case_card_label(card: dict[str, str]) -> str:
4570
+ title = human_title(card.get("title", "bad case")).strip()
4571
+ return title or bad_case_card_id(card) or "bad case"
4572
+
4573
+
4574
+ def feature_chain_bad_case_label(
4575
+ ref: str,
4576
+ cards_by_id: dict[str, dict[str, str]],
4577
+ cards_by_title: dict[str, dict[str, str]],
4578
+ ) -> str:
4579
+ clean = ref.strip()
4580
+ match = re.search(r"BC-\d{8}-\d+", clean)
4581
+ if match:
4582
+ card = cards_by_id.get(match.group(0))
4583
+ if card:
4584
+ return bad_case_card_label(card)
4585
+ card = cards_by_title.get(clean.lower())
4586
+ if card:
4587
+ return bad_case_card_label(card)
4588
+ return clean
4589
+
4590
+
4591
+ def feature_chain_node_bad_case_refs(node: dict[str, object]) -> list[str]:
4592
+ bad_cases = node.get("bad_cases", [])
4593
+ if not isinstance(bad_cases, list):
4594
+ return []
4595
+ return [str(item).strip() for item in bad_cases if str(item).strip()]
4596
+
4597
+
4598
+ def feature_chain_summary(root: Path, verbose: bool = False) -> int:
4599
+ init_context(root)
4600
+ ctx = context_dir(root)
4601
+ registry = load_feature_chains(ctx)
4602
+ chains = [item for item in registry.get("chains", []) if isinstance(item, dict)]
4603
+ bad_case_path = ctx / "bad-cases.md"
4604
+ cards = parse_bad_case_cards(bad_case_path.read_text(encoding="utf-8") if bad_case_path.exists() else "")
4605
+ cards_by_id = {bad_case_card_id(card): card for card in cards if bad_case_card_id(card)}
4606
+ cards_by_title = {bad_case_card_label(card).lower(): card for card in cards}
4607
+
4608
+ approved_always = [
4609
+ chain
4610
+ for chain in chains
4611
+ if str(chain.get("status", "")).strip().lower() in APPROVED_TEST_STATUSES
4612
+ and str(chain.get("run_policy", "")).strip() == RUN_ALWAYS_POLICY
4613
+ ]
4614
+ proposed_count = sum(1 for chain in chains if str(chain.get("status", "")).strip().lower() == "proposed")
4615
+ covered_refs: set[str] = set()
4616
+ pending_count = 0
4617
+ covered_chain_count = 0
4618
+ max_chain_coverage = 0
4619
+ for chain in chains:
4620
+ chain_refs: set[str] = set()
4621
+ for node in chain.get("nodes", []):
4622
+ if not isinstance(node, dict):
4623
+ continue
4624
+ refs = feature_chain_node_bad_case_refs(node)
4625
+ covered_refs.update(refs)
4626
+ chain_refs.update(refs)
4627
+ if str(node.get("coverage_pending_reason", "")).strip() and not refs:
4628
+ pending_count += 1
4629
+ if chain_refs:
4630
+ covered_chain_count += 1
4631
+ max_chain_coverage = max(max_chain_coverage, len(chain_refs))
4632
+
4633
+ print("[context-guard] feature-chain summary:")
4634
+ print(f"- chains: {len(chains)}")
4635
+ print(f"- approved every-dev-completion: {len(approved_always)}")
4636
+ print(f"- proposed: {proposed_count}")
4637
+ print(f"- covered bad cases: {len(covered_refs)}")
4638
+ print(f"- pending checkpoints: {pending_count}")
4639
+ if covered_chain_count:
4640
+ density = len(covered_refs) / covered_chain_count
4641
+ print(f"- coverage density: {density:.1f} bad case(s) per covered chain")
4642
+ if max_chain_coverage > 1:
4643
+ print(f"- reuse signal: one workflow covers up to {max_chain_coverage} bad case(s)")
4644
+ if not chains:
4645
+ print("[context-guard] next: no feature chains yet; use feature-chain-plan when a concrete bad case or user-confirmed test target appears.")
4646
+ print(f"[context-guard] registry: {feature_chains_path(ctx)}")
4647
+ return 0
4648
+
4649
+ visible_chains = chains if verbose else chains[:6]
4650
+ for chain in visible_chains:
4651
+ chain_id = str(chain.get("id") or "unknown")
4652
+ title = str(chain.get("title") or "untitled")
4653
+ status = str(chain.get("status") or "unknown")
4654
+ run_policy = str(chain.get("run_policy") or "unset")
4655
+ nodes = [node for node in chain.get("nodes", []) if isinstance(node, dict)]
4656
+ chain_refs = [
4657
+ ref
4658
+ for node in nodes
4659
+ for ref in feature_chain_node_bad_case_refs(node)
4660
+ ]
4661
+ chain_pending = sum(
4662
+ 1
4663
+ for node in nodes
4664
+ if str(node.get("coverage_pending_reason", "")).strip()
4665
+ and not feature_chain_node_bad_case_refs(node)
4666
+ )
4667
+ print(f"- chain: {chain_id} | {status} | {run_policy} | {title}")
4668
+ entry = str(chain.get("entry") or "").strip()
4669
+ exit_check = str(chain.get("exit_check") or "").strip()
4670
+ if entry:
4671
+ print(f" entry: {entry}")
4672
+ if exit_check:
4673
+ print(f" exit: {exit_check}")
4674
+ print(f" coverage: {len(set(chain_refs))} bad case(s), {chain_pending} pending checkpoint(s)")
4675
+ if len(set(chain_refs)) > 1:
4676
+ print(" next: prefer extending this workflow before creating another test for similar symptoms")
4677
+ elif chain_pending:
4678
+ print(" next: attach the first matching real bad case here before approval")
4679
+ visible_nodes = nodes if verbose else nodes[:4]
4680
+ for node in visible_nodes:
4681
+ node_title = str(node.get("title") or node.get("id") or "checkpoint").strip()
4682
+ refs = feature_chain_node_bad_case_refs(node)
4683
+ print(f" checkpoint: {node_title}")
4684
+ if refs:
4685
+ labels = [
4686
+ feature_chain_bad_case_label(ref, cards_by_id, cards_by_title)
4687
+ for ref in refs[:4]
4688
+ ]
4689
+ suffix = f"; +{len(refs) - 4} more" if len(refs) > 4 else ""
4690
+ print(f" covers: {'; '.join(labels)}{suffix}")
4691
+ pending_reason = str(node.get("coverage_pending_reason", "")).strip()
4692
+ if pending_reason and not refs:
4693
+ print(f" coverage pending: {pending_reason}")
4694
+ if verbose:
4695
+ checks = node.get("checks", [])
4696
+ check_items = [str(item).strip() for item in checks if str(item).strip()] if isinstance(checks, list) else []
4697
+ if check_items:
4698
+ print(f" check: {'; '.join(check_items[:3])}")
4699
+ if len(nodes) > len(visible_nodes):
4700
+ print(f" ... {len(nodes) - len(visible_nodes)} more checkpoint(s); rerun with --verbose to show all.")
4701
+ if len(chains) > len(visible_chains):
4702
+ print(f"... {len(chains) - len(visible_chains)} more chain(s); rerun with --verbose to show all.")
4703
+ if pending_count:
4704
+ print("[context-guard] next: review pending checkpoints before proposing new chains.")
4705
+ elif covered_refs:
4706
+ print("[context-guard] next: run feature-chain-plan for new bad cases and prefer existing matching chains.")
4707
+ else:
4708
+ print("[context-guard] next: keep chains proposed until a real bad case or user-approved recurrence risk is attached.")
4709
+ print(f"[context-guard] registry: {feature_chains_path(ctx)}")
4710
+ return 0
4711
+
4712
+
4713
+ def feature_chain_overlap_bad_case_refs(chain: dict[str, object]) -> set[str]:
4714
+ refs: set[str] = set()
4715
+ for node in chain.get("nodes", []):
4716
+ if not isinstance(node, dict):
4717
+ continue
4718
+ for ref in feature_chain_node_bad_case_refs(node):
4719
+ match = re.search(r"BC-\d{8}-\d+", ref)
4720
+ refs.add(match.group(0) if match else ref.lower())
4721
+ return refs
4722
+
4723
+
4724
+ def feature_chain_overlap(root: Path, min_score: int = 6, verbose: bool = False) -> int:
4725
+ init_context(root)
4726
+ ctx = context_dir(root)
4727
+ registry = load_feature_chains(ctx)
4728
+ chains = [item for item in registry.get("chains", []) if isinstance(item, dict)]
4729
+ pairs: list[tuple[int, dict[str, object], dict[str, object], list[str], set[str]]] = []
4730
+
4731
+ for index, left in enumerate(chains):
4732
+ left_text = feature_chain_candidate_text(left)
4733
+ left_refs = feature_chain_overlap_bad_case_refs(left)
4734
+ for right in chains[index + 1 :]:
4735
+ right_text = feature_chain_candidate_text(right)
4736
+ right_refs = feature_chain_overlap_bad_case_refs(right)
4737
+ evidence = feature_chain_match_evidence(left_text, right_text, max_terms=8)
4738
+ shared_refs = left_refs & right_refs
4739
+ score = len(evidence) * 2 + len(shared_refs) * 8
4740
+ left_entry = str(left.get("entry", "")).strip().lower()
4741
+ right_entry = str(right.get("entry", "")).strip().lower()
4742
+ left_exit = str(left.get("exit_check", "")).strip().lower()
4743
+ right_exit = str(right.get("exit_check", "")).strip().lower()
4744
+ if left_entry and left_entry == right_entry:
4745
+ score += 4
4746
+ if left_exit and left_exit == right_exit:
4747
+ score += 4
4748
+ if score >= max(1, min_score) or shared_refs:
4749
+ pairs.append((score, left, right, evidence, shared_refs))
4750
+
4751
+ pairs.sort(key=lambda item: item[0], reverse=True)
4752
+ print("[context-guard] feature-chain overlap audit:")
4753
+ print(f"- chains: {len(chains)}")
4754
+ print(f"- overlapping candidates: {len(pairs)}")
4755
+ if not chains:
4756
+ print("[context-guard] next: no feature chains yet; use feature-chain-plan when a real workflow appears.")
4757
+ elif not pairs:
4758
+ print("[context-guard] next: no duplicate-chain signal; still use feature-chain-plan before creating new coverage.")
4759
+ else:
4760
+ visible_pairs = pairs if verbose else pairs[:6]
4761
+ for score, left, right, evidence, shared_refs in visible_pairs:
4762
+ left_id = str(left.get("id") or "unknown")
4763
+ right_id = str(right.get("id") or "unknown")
4764
+ left_title = str(left.get("title") or "untitled")
4765
+ right_title = str(right.get("title") or "untitled")
4766
+ print(f"- review: {left_id} | {left_title}")
4767
+ print(f" with: {right_id} | {right_title}")
4768
+ print(f" overlap score: {score}")
4769
+ if evidence:
4770
+ print(f" match evidence: {', '.join(evidence)}")
4771
+ if shared_refs:
4772
+ print(f" shared bad cases: {', '.join(sorted(shared_refs))}")
4773
+ print(" next: review whether one workflow should absorb the other before approving automation.")
4774
+ if len(pairs) > len(visible_pairs):
4775
+ print(f"[context-guard] ... {len(pairs) - len(visible_pairs)} more pair(s); rerun with --verbose to show all.")
4776
+ print("[context-guard] next: merge or extend an existing feature chain when the business workflow is the same.")
4777
+ print(f"[context-guard] registry: {feature_chains_path(ctx)}")
4778
+ return 0
4779
+
4780
+
4781
+ def feature_chain_coverage(root: Path, verbose: bool = False) -> int:
4782
+ init_context(root)
4783
+ ctx = context_dir(root)
4784
+ bad_case_path = ctx / "bad-cases.md"
4785
+ cards = parse_bad_case_cards(bad_case_path.read_text(encoding="utf-8") if bad_case_path.exists() else "")
4786
+ registry = load_feature_chains(ctx)
4787
+ chains = [item for item in registry.get("chains", []) if isinstance(item, dict)]
4788
+ cards_by_id = {bad_case_card_id(card): card for card in cards if bad_case_card_id(card)}
4789
+ cards_by_title = {bad_case_card_label(card).lower(): card for card in cards}
4790
+ covered_ids: set[str] = set()
4791
+ unknown_refs: set[str] = set()
4792
+
4793
+ print("[context-guard] feature-chain coverage:")
4794
+ print(f"- feature chains: {len(chains)}")
4795
+ print(f"- bad cases in register: {len(cards)}")
4796
+
4797
+ for chain in chains:
4798
+ chain_id = str(chain.get("id") or "unknown")
4799
+ title = str(chain.get("title") or "untitled")
4800
+ status = str(chain.get("status") or "unknown")
4801
+ nodes = [node for node in chain.get("nodes", []) if isinstance(node, dict)]
4802
+ chain_refs: list[str] = []
4803
+ for node in nodes:
4804
+ bad_cases = node.get("bad_cases", [])
4805
+ if isinstance(bad_cases, list):
4806
+ chain_refs.extend(str(item).strip() for item in bad_cases if str(item).strip())
4807
+ for ref in chain_refs:
4808
+ match = re.search(r"BC-\d{8}-\d+", ref)
4809
+ if match and match.group(0) in cards_by_id:
4810
+ covered_ids.add(match.group(0))
4811
+ continue
4812
+ matched_card = cards_by_title.get(ref.lower())
4813
+ if matched_card:
4814
+ matched_id = bad_case_card_id(matched_card)
4815
+ if matched_id:
4816
+ covered_ids.add(matched_id)
4817
+ continue
4818
+ unknown_refs.add(ref)
4819
+
4820
+ if verbose:
4821
+ print(f"- {chain_id} | {status} | {title}")
4822
+ for node in nodes:
4823
+ node_title = str(node.get("title") or node.get("id") or "checkpoint").strip()
4824
+ node_bad_cases = node.get("bad_cases", [])
4825
+ refs = (
4826
+ [str(item).strip() for item in node_bad_cases if str(item).strip()]
4827
+ if isinstance(node_bad_cases, list)
4828
+ else []
4829
+ )
4830
+ suffix = ", ".join(refs) if refs else "no linked bad case"
4831
+ print(f" checkpoint: {node_title} | covers: {suffix}")
4832
+
4833
+ uncovered_cards = [card for card in cards if bad_case_card_id(card) not in covered_ids]
4834
+ print(f"- covered by feature chains: {len(covered_ids)}")
4835
+ print(f"- unassigned candidates: {len(uncovered_cards)}")
4836
+ if unknown_refs:
4837
+ print(f"- unknown linked refs: {len(unknown_refs)}")
4838
+ if verbose:
4839
+ for ref in sorted(unknown_refs):
4840
+ print(f" unknown ref: {ref}")
4841
+
4842
+ if uncovered_cards:
4843
+ print("[context-guard] unassigned candidates are not mandatory tests; use them only when a workflow-level chain would reduce recurrence risk.")
4844
+ limit = len(uncovered_cards) if verbose else min(8, len(uncovered_cards))
4845
+ for card in uncovered_cards[:limit]:
4846
+ case_id = bad_case_card_id(card)
4847
+ label = bad_case_card_label(card)
4848
+ tags = card.get("tags", "").strip()
4849
+ suffix = f" | {tags}" if tags else ""
4850
+ print(f" candidate: {case_id or '-'} | {label}{suffix}")
4851
+ match = feature_chain_best_match_for_text(bad_case_search_text(card), chains)
4852
+ if match and match[0] >= FEATURE_CHAIN_COVERAGE_SUGGESTION_MIN_SCORE:
4853
+ score, chain, node_score, node = match
4854
+ chain_id = str(chain.get("id") or "unknown")
4855
+ chain_title = str(chain.get("title") or "untitled")
4856
+ print(f" possible chain: {chain_id} | score={score} | {chain_title}")
4857
+ if node:
4858
+ node_title = str(node.get("title") or node.get("id") or "checkpoint")
4859
+ print(f" possible checkpoint: {node_title} (score={node_score})")
4860
+ evidence = feature_chain_match_evidence(
4861
+ bad_case_search_text(card),
4862
+ feature_chain_candidate_text(chain, node),
4863
+ )
4864
+ if evidence:
4865
+ print(f" match evidence: {', '.join(evidence)}")
4866
+ if len(uncovered_cards) > limit:
4867
+ print(f" ... {len(uncovered_cards) - limit} more; rerun with --verbose to show all.")
4868
+ print(f"[context-guard] registry: {feature_chains_path(ctx)}")
4869
+ return 0
4870
+
4871
+
4872
+ def bad_case_tags(card: dict[str, str]) -> list[str]:
4873
+ tags = re.findall(r"#[\w./:-]+", str(card.get("tags", "")), flags=re.UNICODE)
4874
+ if tags:
4875
+ return [tag for tag in tags if tag.strip()]
4876
+ plain = str(card.get("tags", "")).strip()
4877
+ if not plain:
4878
+ return []
4879
+ parts = re.split(r"[,,、;;\n]+", plain)
4880
+ normalized: list[str] = []
4881
+ seen: set[str] = set()
4882
+ for part in parts:
4883
+ raw = part.strip().strip("` ")
4884
+ if not raw:
4885
+ continue
4886
+ tag = raw if raw.startswith("#") else f"#{raw}"
4887
+ key = tag.lower()
4888
+ if key not in seen:
4889
+ seen.add(key)
4890
+ normalized.append(tag)
4891
+ return normalized
4892
+
4893
+
4894
+ def readable_feature_tag(tag: str) -> str:
4895
+ raw = tag.strip().lstrip("#")
4896
+ if not raw:
4897
+ return "未命名功能区"
4898
+ known_labels = {
4899
+ "branch-map": "支线分叉图",
4900
+ "visual-regression": "前端视觉回归",
4901
+ "test-design": "测试设计",
4902
+ "coverage-audit": "覆盖审计",
4903
+ "context-bloat": "Context 精简",
4904
+ "skill-trigger-risk": "Skill 触发可靠性",
4905
+ "language-projection": "语言投影",
4906
+ "route-alignment": "路线对齐",
4907
+ "layout-model": "布局模型",
4908
+ "i18n": "多语言展示",
4909
+ "readability": "可读性",
4910
+ "remote": "远程开发",
4911
+ "state": "状态流程",
4912
+ "validation": "输入校验",
4913
+ "game-state": "游戏状态",
4914
+ "date-reset": "日期重置",
4915
+ "habit-streak": "连续天数",
4916
+ }
4917
+ if raw in known_labels:
4918
+ return known_labels[raw]
4919
+ label = raw.replace("-", " ").replace("_", " ").strip()
4920
+ if label == "roadmap ux":
4921
+ return "路线图展示体验"
4922
+ if label == "context loss":
4923
+ return "Context 持久化"
4924
+ if label == "feature chain":
4925
+ return "功能链测试入口"
4926
+ if label == "test hub":
4927
+ return "测试中台"
4928
+ return label
4929
+
4930
+
4931
+ def semantic_feature_tags(card: dict[str, str]) -> list[str]:
4932
+ """Add stable feature buckets so loose human tags can still form one workflow."""
4933
+ text_parts = [
4934
+ bad_case_card_label(card),
4935
+ str(card.get("scope", "")),
4936
+ str(card.get("phenomenon", "")),
4937
+ str(card.get("trigger / reproduction", "")),
4938
+ str(card.get("guard / verification", "")),
4939
+ " ".join(bad_case_tags(card)),
4940
+ ]
4941
+ text = " ".join(part for part in text_parts if part).lower()
4942
+ buckets: list[tuple[str, tuple[str, ...]]] = [
4943
+ ("#复制反馈", ("复制", "剪贴板", "clipboard", "copy")),
4944
+ ("#空输入保护", ("空输入", "空白", "短线索", "输入", "empty", "blank", "input")),
4945
+ ("#历史恢复", ("历史", "恢复", "history", "restore")),
4946
+ ("#导出", ("导出", "markdown", "export")),
4947
+ ("#本地存储", ("本地存储", "持久化", "localstorage", "local storage", "persist")),
4948
+ ("#模板", ("模板", "template")),
4949
+ ("#重置", ("重置", "reset")),
4950
+ ("#进度", ("进度", "完成", "progress", "complete")),
4951
+ ("#回放", ("回放", "灯序", "sequence", "replay")),
4952
+ ("#得分", ("分数", "最高分", "score")),
4953
+ ]
4954
+ matched: list[str] = []
4955
+ for bucket, keywords in buckets:
4956
+ if any(keyword in text for keyword in keywords):
4957
+ matched.append(bucket)
4958
+ return matched
4959
+
4960
+
4961
+ def feature_chain_group_tags(card: dict[str, str]) -> list[str]:
4962
+ ignored = {"feature-chain", "test-hub", "methodology", "risk-audit", "subagent"}
4963
+ tags = []
4964
+ for tag in [*bad_case_tags(card), *semantic_feature_tags(card)]:
4965
+ raw = tag.strip().lstrip("#")
4966
+ if not raw or raw in ignored:
4967
+ continue
4968
+ normalized = tag if tag.startswith("#") else f"#{tag}"
4969
+ if normalized not in tags:
4970
+ tags.append(normalized)
4971
+ return sorted(tags, key=feature_chain_tag_sort_key)
4972
+
4973
+
4974
+ def readable_feature_tag_group(tags: tuple[str, ...]) -> str:
4975
+ labels = [readable_feature_tag(tag) for tag in tags]
4976
+ return " / ".join(label for label in labels if label.strip()) or "未命名功能区"
4977
+
4978
+
4979
+ def auto_feature_chain_title(tags: tuple[str, ...], cards: list[dict[str, str]]) -> str:
4980
+ scope_counts: dict[str, int] = {}
4981
+ for card in cards:
4982
+ scope = human_title(str(card.get("scope", "")).strip())
4983
+ if scope:
4984
+ scope_counts[scope] = scope_counts.get(scope, 0) + 1
4985
+ if scope_counts:
4986
+ best_scope, best_count = sorted(scope_counts.items(), key=lambda item: (-item[1], len(item[0]), item[0]))[0]
4987
+ if best_count >= 2 or len(scope_counts) == 1:
4988
+ return best_scope
4989
+ return readable_feature_tag_group(tags)
4990
+
4991
+
4992
+ def internal_auto_feature_chain_label(value: str) -> bool:
4993
+ text = str(value or "").strip()
4994
+ if not text:
4995
+ return True
4996
+ lowered = text.lower()
4997
+ internal_terms = ("context guard", "subagent", "risk audit")
4998
+ if any(term in lowered for term in internal_terms):
4999
+ return True
5000
+ return text in {"未命名功能区", "本轮功能流程"}
5001
+
5002
+
5003
+ def internal_auto_feature_chain_tags(tags: tuple[str, ...]) -> bool:
5004
+ normalized = {tag.strip().lstrip("#").lower() for tag in tags if tag.strip()}
5005
+ return bool(normalized & {"risk-audit", "subagent", "feature-chain", "test-hub", "methodology"})
5006
+
5007
+
5008
+ def best_auto_feature_group_tags(cards: list[dict[str, str]]) -> tuple[str, ...]:
5009
+ groups: dict[tuple[str, ...], list[dict[str, str]]] = {}
5010
+ for card in cards:
5011
+ tags = feature_chain_group_tags(card)
5012
+ if len(tags) >= 2:
5013
+ for index, first_tag in enumerate(tags):
5014
+ for second_tag in tags[index + 1 :]:
5015
+ groups.setdefault((first_tag, second_tag), []).append(card)
5016
+ for tag in tags:
5017
+ groups.setdefault((tag,), []).append(card)
5018
+ if not groups:
5019
+ return tuple()
5020
+ best_coverage = max(len(group_cards) for group_cards in groups.values())
5021
+ candidates = [
5022
+ (tags, group_cards)
5023
+ for tags, group_cards in groups.items()
5024
+ if len(group_cards) == best_coverage
5025
+ ]
5026
+ candidates.sort(key=feature_chain_candidate_group_sort_key)
5027
+ return candidates[0][0]
5028
+
5029
+
5030
+ def feature_group_coverage_count(tags: tuple[str, ...], cards: list[dict[str, str]]) -> int:
5031
+ if not tags:
5032
+ return 0
5033
+ required = set(tags)
5034
+ count = 0
5035
+ for card in cards:
5036
+ if required.issubset(set(feature_chain_group_tags(card))):
5037
+ count += 1
5038
+ return count
5039
+
5040
+
5041
+ def feature_chain_tag_sort_key(tag: str) -> tuple[int, str]:
5042
+ raw = tag.strip().lstrip("#")
5043
+ priority = {
5044
+ "roadmap-ux": 0,
5045
+ "context-loss": 0,
5046
+ "feature-chain": 0,
5047
+ "test-hub": 0,
5048
+ "branch-map": 0,
5049
+ "visual-regression": 0,
5050
+ "skill-trigger-risk": 0,
5051
+ "readability": 2,
5052
+ "remote": 2,
5053
+ "test-design": 2,
5054
+ "coverage-audit": 2,
5055
+ }.get(raw, 1)
5056
+ return (priority, readable_feature_tag(tag))
5057
+
5058
+
5059
+ def feature_chain_candidate_group_sort_key(item: tuple[tuple[str, ...], list[dict[str, str]]]) -> tuple[int, int, str]:
5060
+ tags, group_cards = item
5061
+ return (-len(tags), -len(group_cards), readable_feature_tag_group(tags))
5062
+
5063
+
5064
+ def covered_bad_case_ids_from_feature_chains(cards: list[dict[str, str]], chains: list[dict[str, object]]) -> set[str]:
5065
+ cards_by_id = {bad_case_card_id(card): card for card in cards if bad_case_card_id(card)}
5066
+ cards_by_title = {bad_case_card_label(card).lower(): card for card in cards}
5067
+ covered_ids: set[str] = set()
5068
+ for chain in chains:
5069
+ for node in chain.get("nodes", []):
5070
+ if not isinstance(node, dict):
5071
+ continue
5072
+ bad_cases = node.get("bad_cases", [])
5073
+ refs = [str(item).strip() for item in bad_cases if str(item).strip()] if isinstance(bad_cases, list) else []
5074
+ for ref in refs:
5075
+ match = re.search(r"BC-\d{8}-\d+", ref)
5076
+ if match and match.group(0) in cards_by_id:
5077
+ covered_ids.add(match.group(0))
5078
+ continue
5079
+ matched_card = cards_by_title.get(ref.lower())
5080
+ if matched_card:
5081
+ matched_id = bad_case_card_id(matched_card)
5082
+ if matched_id:
5083
+ covered_ids.add(matched_id)
5084
+ return covered_ids
5085
+
5086
+
5087
+ def auto_feature_chain_node(chain_id: str, index: int, card: dict[str, str]) -> dict[str, Any]:
5088
+ case_id = bad_case_card_id(card)
5089
+ label = bad_case_card_label(card)
5090
+ check = first_nonempty(
5091
+ card.get("guard / verification", ""),
5092
+ card.get("green condition", ""),
5093
+ card.get("red condition", ""),
5094
+ card.get("trigger / reproduction", ""),
5095
+ card.get("phenomenon", ""),
5096
+ label,
5097
+ )
5098
+ return {
5099
+ "id": f"{chain_id}-N{index}",
5100
+ "title": label,
5101
+ "bad_cases": [case_id] if case_id else [],
5102
+ "checks": [check] if check else [],
5103
+ "source": "auto-proposed-from-uncovered-bad-cases",
5104
+ "requires_user_review": True,
5105
+ }
5106
+
5107
+
5108
+ def refresh_auto_feature_chain_metadata(chain: dict[str, Any], cards_by_id: dict[str, dict[str, str]]) -> bool:
5109
+ refs: list[str] = []
5110
+ nodes = chain.get("nodes")
5111
+ if not isinstance(nodes, list):
5112
+ return False
5113
+ for node in nodes:
5114
+ if not isinstance(node, dict):
5115
+ continue
5116
+ node_refs = node.get("bad_cases", [])
5117
+ if isinstance(node_refs, list):
5118
+ refs.extend(str(ref).strip() for ref in node_refs if str(ref).strip())
5119
+ source_cards = [cards_by_id[ref] for ref in refs if ref in cards_by_id]
5120
+ if not source_cards:
5121
+ return False
5122
+
5123
+ changed = False
5124
+ current_key = str(chain.get("auto_group_key") or "").strip()
5125
+ current_tags = tuple(tag for tag in current_key.split("|") if tag)
5126
+ better_tags = best_auto_feature_group_tags(source_cards)
5127
+ should_refresh_key = not current_tags or internal_auto_feature_chain_tags(current_tags)
5128
+ if better_tags and current_tags and not should_refresh_key:
5129
+ should_refresh_key = feature_group_coverage_count(better_tags, source_cards) > feature_group_coverage_count(
5130
+ current_tags, source_cards
5131
+ )
5132
+ if better_tags and should_refresh_key:
5133
+ better_key = "|".join(better_tags)
5134
+ if current_key != better_key:
5135
+ chain["auto_group_key"] = better_key
5136
+ changed = True
5137
+ tags_for_title = better_tags or current_tags
5138
+ better_title = auto_feature_chain_title(tags_for_title, source_cards)
5139
+ if better_title and internal_auto_feature_chain_label(str(chain.get("title") or "")):
5140
+ chain["title"] = better_title
5141
+ chain["entry"] = f"触发「{better_title}」相关用户流程"
5142
+ chain["exit_check"] = f"「{better_title}」相关结果保持用户可见的正确状态"
5143
+ changed = True
5144
+ return changed
5145
+
5146
+
5147
+ def expand_auto_feature_chain_nodes(chains: list[dict[str, Any]], cards: list[dict[str, str]]) -> bool:
5148
+ cards_by_id = {bad_case_card_id(card): card for card in cards if bad_case_card_id(card)}
5149
+ any_changed = False
5150
+ for chain in chains:
5151
+ status = str(chain.get("status") or "").strip().lower()
5152
+ if chain.get("auto_proposed") is not True or status != "proposed":
5153
+ continue
5154
+ nodes = chain.get("nodes")
5155
+ if not isinstance(nodes, list):
5156
+ continue
5157
+ chain_id = str(chain.get("id") or "CHAIN").strip() or "CHAIN"
5158
+ next_index = 1
5159
+ expanded_nodes: list[dict[str, Any]] = []
5160
+ chain_changed = False
5161
+ for node in nodes:
5162
+ if not isinstance(node, dict):
5163
+ continue
5164
+ refs = [str(ref).strip() for ref in node.get("bad_cases", []) if str(ref).strip()]
5165
+ if len(refs) <= 1:
5166
+ node["id"] = f"{chain_id}-N{next_index}"
5167
+ expanded_nodes.append(node)
5168
+ next_index += 1
5169
+ continue
5170
+ for ref in refs:
5171
+ card = cards_by_id.get(ref)
5172
+ if card:
5173
+ expanded_nodes.append(auto_feature_chain_node(chain_id, next_index, card))
5174
+ else:
5175
+ expanded_nodes.append(
5176
+ {
5177
+ "id": f"{chain_id}-N{next_index}",
5178
+ "title": ref,
5179
+ "bad_cases": [ref],
5180
+ "checks": [str(item) for item in node.get("checks", []) if str(item).strip()],
5181
+ "source": "auto-proposed-from-uncovered-bad-cases",
5182
+ "requires_user_review": True,
5183
+ }
5184
+ )
5185
+ next_index += 1
5186
+ chain_changed = True
5187
+ if chain_changed and expanded_nodes:
5188
+ chain["nodes"] = expanded_nodes
5189
+ any_changed = True
5190
+ if refresh_auto_feature_chain_metadata(chain, cards_by_id):
5191
+ any_changed = True
5192
+ return any_changed
5193
+
5194
+
5195
+ def feature_chain_candidates(root: Path, min_cases: int = 2, max_groups: int = 6) -> int:
5196
+ init_context(root)
5197
+ ctx = context_dir(root)
5198
+ bad_case_path = ctx / "bad-cases.md"
5199
+ cards = parse_bad_case_cards(bad_case_path.read_text(encoding="utf-8") if bad_case_path.exists() else "")
5200
+ registry = load_feature_chains(ctx)
5201
+ chains = [item for item in registry.get("chains", []) if isinstance(item, dict)]
5202
+ existing_group_chains = {
5203
+ str(chain.get("auto_group_key") or "").strip(): chain
5204
+ for chain in chains
5205
+ if str(chain.get("auto_group_key") or "").strip()
5206
+ }
5207
+ existing_group_keys = set(existing_group_chains)
5208
+ covered_ids = covered_bad_case_ids_from_feature_chains(cards, chains)
5209
+ unassigned_cards = [card for card in cards if bad_case_card_id(card) not in covered_ids]
5210
+ groups: dict[tuple[str, ...], list[dict[str, str]]] = {}
5211
+ for card in unassigned_cards:
5212
+ tags = feature_chain_group_tags(card)
5213
+ if len(tags) >= 2:
5214
+ for index, first_tag in enumerate(tags):
5215
+ for second_tag in tags[index + 1 :]:
5216
+ groups.setdefault((first_tag, second_tag), []).append(card)
5217
+ for tag in tags:
5218
+ groups.setdefault((tag,), []).append(card)
5219
+
5220
+ candidates = [
5221
+ (tags, group_cards)
5222
+ for tags, group_cards in groups.items()
5223
+ if len(group_cards) >= max(1, min_cases) or "|".join(tags) in existing_group_keys
5224
+ ]
5225
+ specific_tags = {
5226
+ tag
5227
+ for tags, _group_cards in candidates
5228
+ if len(tags) > 1
5229
+ for tag in tags
5230
+ }
5231
+ candidates = [
5232
+ (tags, group_cards)
5233
+ for tags, group_cards in candidates
5234
+ if len(tags) > 1 or tags[0] not in specific_tags or "|".join(tags) in existing_group_keys
5235
+ ]
5236
+ candidates.sort(
5237
+ key=lambda item: (
5238
+ 0 if "|".join(item[0]) in existing_group_keys else 1,
5239
+ feature_chain_candidate_group_sort_key(item),
5240
+ )
5241
+ )
5242
+ selected_candidates: list[tuple[tuple[str, ...], list[dict[str, str]], list[str]]] = []
5243
+ selected_case_ids: set[str] = set()
5244
+ for tags, group_cards in candidates:
5245
+ case_ids = [bad_case_card_id(card) for card in group_cards if bad_case_card_id(card)]
5246
+ new_case_ids = [case_id for case_id in case_ids if case_id not in selected_case_ids]
5247
+ if selected_case_ids and len(new_case_ids) < max(1, min_cases):
5248
+ continue
5249
+ selected_candidates.append((tags, group_cards, new_case_ids))
5250
+ selected_case_ids.update(new_case_ids)
5251
+ candidates = selected_candidates
5252
+
5253
+ print("[context-guard] feature-chain candidates:")
5254
+ print(f"- bad cases in register: {len(cards)}")
5255
+ print(f"- already covered by feature chains: {len(covered_ids)}")
5256
+ print(f"- unassigned bad cases: {len(unassigned_cards)}")
5257
+ if not candidates:
5258
+ print("[context-guard] candidates: none")
5259
+ print("[context-guard] next: use feature-chain-plan for an individual bad case or ask the user for a concrete feature entry.")
5260
+ print(f"[context-guard] registry: {feature_chains_path(ctx)}")
5261
+ return 0
5262
+
5263
+ print("[context-guard] candidates are planning hints only; ask the user before creating or approving a chain.")
5264
+ for tags, group_cards, new_case_ids in candidates[: max(1, max_groups)]:
5265
+ title = readable_feature_tag_group(tags)
5266
+ tag_label = ", ".join(tags)
5267
+ print(f"- candidate chain: {title} | {len(group_cards)} unassigned bad cases | new coverage: {len(new_case_ids)} | tags: {tag_label}")
5268
+ print(f" confirmation prompt: 测试创建识别:建议先确认一条功能链:从「{title}」的真实入口到用户可见的正确结果,覆盖这一组复发风险。")
5269
+ print(" seed bad cases:")
5270
+ for card in group_cards[:3]:
5271
+ case_id = bad_case_card_id(card)
5272
+ label = bad_case_card_label(card)
5273
+ print(f" - {case_id or '-'} | {label}")
5274
+ if len(group_cards) > 3:
5275
+ print(f" ... {len(group_cards) - 3} more")
5276
+ if len(candidates) > max_groups:
5277
+ print(f"[context-guard] ... {len(candidates) - max_groups} more candidate group(s); rerun with --max-groups to show more.")
5278
+ print(f"[context-guard] registry: {feature_chains_path(ctx)}")
5279
+ return 0
5280
+
5281
+
5282
+ def feature_chain_auto_propose(root: Path, min_cases: int = 2, max_groups: int = 3, hook_mode: bool = False) -> int:
5283
+ init_context(root)
5284
+ ctx = context_dir(root)
5285
+ bad_case_path = ctx / "bad-cases.md"
5286
+ cards = parse_bad_case_cards(bad_case_path.read_text(encoding="utf-8") if bad_case_path.exists() else "")
5287
+ all_case_ids = [bad_case_card_id(card) for card in cards if bad_case_card_id(card)]
5288
+ state_path = feature_chain_auto_state_path(ctx)
5289
+ seen_case_ids: set[str] = set()
5290
+ if hook_mode:
5291
+ try:
5292
+ state = json.loads(state_path.read_text(encoding="utf-8")) if state_path.exists() else {}
5293
+ except json.JSONDecodeError:
5294
+ state = {}
5295
+ if isinstance(state, dict):
5296
+ seen_case_ids = {str(item).strip() for item in state.get("seen_bad_cases", []) if str(item).strip()}
5297
+ if not state_path.exists() and len(all_case_ids) > 12:
5298
+ state_path.parent.mkdir(parents=True, exist_ok=True)
5299
+ state_path.write_text(
5300
+ json.dumps(
5301
+ {
5302
+ "version": 1,
5303
+ "seen_bad_cases": sorted(all_case_ids),
5304
+ "note": "Baseline established by hook mode to avoid auto-proposing chains from a large historical register.",
5305
+ },
5306
+ ensure_ascii=False,
5307
+ indent=2,
5308
+ )
5309
+ + "\n",
5310
+ encoding="utf-8",
5311
+ )
5312
+ print("[context-guard] auto feature-chain proposal: none")
5313
+ print(f"- historical baseline established: {len(all_case_ids)} bad cases")
5314
+ print("[context-guard] reason: large existing register; future new bad cases will be considered for proposed chains.")
5315
+ print(f"[context-guard] state: {state_path}")
5316
+ return 0
5317
+ registry = load_feature_chains(ctx)
5318
+ chains = [item for item in registry.get("chains", []) if isinstance(item, dict)]
5319
+ if expand_auto_feature_chain_nodes(chains, cards):
5320
+ registry["chains"] = chains
5321
+ write_feature_chains(ctx, registry)
5322
+ existing_group_chains = {
5323
+ str(chain.get("auto_group_key") or "").strip(): chain
5324
+ for chain in chains
5325
+ if str(chain.get("auto_group_key") or "").strip()
5326
+ }
5327
+ existing_group_keys = set(existing_group_chains)
5328
+ covered_ids = covered_bad_case_ids_from_feature_chains(cards, chains)
5329
+ unassigned_cards = [card for card in cards if bad_case_card_id(card) not in covered_ids]
5330
+ can_attach_existing_group = False
5331
+ for card in unassigned_cards:
5332
+ meaningful_tags = feature_chain_group_tags(card)
5333
+ for tag in meaningful_tags:
5334
+ if tag in existing_group_keys:
5335
+ can_attach_existing_group = True
5336
+ break
5337
+ if can_attach_existing_group:
5338
+ break
5339
+ for index, first_tag in enumerate(meaningful_tags):
5340
+ for second_tag in meaningful_tags[index + 1 :]:
5341
+ if f"{first_tag}|{second_tag}" in existing_group_keys:
5342
+ can_attach_existing_group = True
5343
+ break
5344
+ if can_attach_existing_group:
5345
+ break
5346
+ if len(unassigned_cards) < max(1, min_cases) and not can_attach_existing_group:
5347
+ print("[context-guard] auto feature-chain proposal: none")
5348
+ print(f"- unassigned bad cases: {len(unassigned_cards)}")
5349
+ print("[context-guard] reason: not enough uncovered bad cases to form a reusable workflow candidate.")
5350
+ if hook_mode:
5351
+ state_path.parent.mkdir(parents=True, exist_ok=True)
5352
+ state_path.write_text(
5353
+ json.dumps({"version": 1, "seen_bad_cases": sorted(all_case_ids)}, ensure_ascii=False, indent=2) + "\n",
5354
+ encoding="utf-8",
5355
+ )
5356
+ print(f"[context-guard] state: {state_path}")
5357
+ print(f"[context-guard] registry: {feature_chains_path(ctx)}")
5358
+ return 0
5359
+
5360
+ groups: dict[tuple[str, ...], list[dict[str, str]]] = {}
5361
+ for card in unassigned_cards:
5362
+ meaningful_tags = feature_chain_group_tags(card)
5363
+ if len(meaningful_tags) >= 2:
5364
+ for index, first_tag in enumerate(meaningful_tags):
5365
+ for second_tag in meaningful_tags[index + 1 :]:
5366
+ groups.setdefault((first_tag, second_tag), []).append(card)
5367
+ for tag in meaningful_tags:
5368
+ groups.setdefault((tag,), []).append(card)
5369
+
5370
+ candidates = [
5371
+ (tags, group_cards)
5372
+ for tags, group_cards in groups.items()
5373
+ if len(group_cards) >= max(1, min_cases) or "|".join(tags) in existing_group_keys
5374
+ ]
5375
+ specific_tags = {
5376
+ tag
5377
+ for tags, _group_cards in candidates
5378
+ if len(tags) > 1
5379
+ for tag in tags
5380
+ }
5381
+ candidates = [
5382
+ (tags, group_cards)
5383
+ for tags, group_cards in candidates
5384
+ if len(tags) > 1 or tags[0] not in specific_tags or "|".join(tags) in existing_group_keys
5385
+ ]
5386
+ candidates.sort(key=feature_chain_candidate_group_sort_key)
5387
+ if not candidates:
5388
+ print("[context-guard] auto feature-chain proposal: none")
5389
+ print(f"- unassigned bad cases: {len(unassigned_cards)}")
5390
+ print("[context-guard] reason: uncovered cases do not share enough feature tags.")
5391
+ if hook_mode:
5392
+ state_path.parent.mkdir(parents=True, exist_ok=True)
5393
+ state_path.write_text(
5394
+ json.dumps({"version": 1, "seen_bad_cases": sorted(all_case_ids)}, ensure_ascii=False, indent=2) + "\n",
5395
+ encoding="utf-8",
5396
+ )
5397
+ print(f"[context-guard] state: {state_path}")
5398
+ print(f"[context-guard] registry: {feature_chains_path(ctx)}")
5399
+ return 0
5400
+
5401
+ created: list[str] = []
5402
+ attached: list[str] = []
5403
+ skipped: list[str] = []
5404
+ run_covered_ids = set(covered_ids)
5405
+ today = datetime.now().strftime("%Y-%m-%d")
5406
+ for tags, group_cards in candidates[: max(1, max_groups)]:
5407
+ group_key = "|".join(tags)
5408
+ title = auto_feature_chain_title(tags, group_cards)
5409
+ case_ids = [bad_case_card_id(card) for card in group_cards if bad_case_card_id(card)]
5410
+ if not case_ids:
5411
+ skipped.append(f"{title}: no stable bad-case IDs")
5412
+ continue
5413
+ if group_key in existing_group_keys:
5414
+ existing_chain = existing_group_chains.get(group_key) or {}
5415
+ status = str(existing_chain.get("status") or "").strip().lower()
5416
+ if existing_chain.get("auto_proposed") is True and status == "proposed":
5417
+ nodes = existing_chain.setdefault("nodes", [])
5418
+ if not isinstance(nodes, list):
5419
+ nodes = []
5420
+ existing_chain["nodes"] = nodes
5421
+ existing_refs: set[str] = set()
5422
+ for node in nodes:
5423
+ if not isinstance(node, dict):
5424
+ continue
5425
+ refs = node.get("bad_cases", [])
5426
+ if isinstance(refs, list):
5427
+ existing_refs.update(str(item).strip() for item in refs if str(item).strip())
5428
+ new_case_ids = [
5429
+ case_id
5430
+ for case_id in case_ids
5431
+ if case_id not in existing_refs and case_id not in run_covered_ids
5432
+ ]
5433
+ if not new_case_ids:
5434
+ skipped.append(f"{title}: already proposed")
5435
+ continue
5436
+ chain_id_for_node = str(existing_chain.get("id") or "CHAIN").strip() or "CHAIN"
5437
+ next_index = len([node for node in nodes if isinstance(node, dict)]) + 1
5438
+ new_case_id_set = set(new_case_ids)
5439
+ for card in group_cards:
5440
+ if bad_case_card_id(card) not in new_case_id_set:
5441
+ continue
5442
+ nodes.append(auto_feature_chain_node(chain_id_for_node, next_index, card))
5443
+ next_index += 1
5444
+ registry["chains"] = chains
5445
+ write_feature_chains(ctx, registry)
5446
+ run_covered_ids.update(new_case_ids)
5447
+ attached.append(f"{existing_chain.get('id', 'unknown')}: {title} (+{len(new_case_ids)} bad cases)")
5448
+ else:
5449
+ skipped.append(f"{title}: existing chain requires explicit review")
5450
+ continue
5451
+ case_ids = [case_id for case_id in case_ids if case_id not in run_covered_ids]
5452
+ if len(case_ids) < max(1, min_cases):
5453
+ skipped.append(f"{title}: already covered by expanded proposed chains")
5454
+ continue
5455
+ group_cards = [card for card in group_cards if bad_case_card_id(card) in set(case_ids)]
5456
+ chain_id = next_feature_chain_id(ctx)
5457
+ chain = {
5458
+ "id": chain_id,
5459
+ "title": title,
5460
+ "status": "proposed",
5461
+ "run_policy": RUN_ALWAYS_POLICY,
5462
+ "entry": f"触发「{title}」相关用户流程",
5463
+ "exit_check": f"「{title}」相关结果保持用户可见的正确状态",
5464
+ "command": "",
5465
+ "timeout_seconds": 300,
5466
+ "artifact_policy": "cleanup-on-pass",
5467
+ "resource": "local",
5468
+ "created": today,
5469
+ "source": "feature-chain-auto-propose",
5470
+ "auto_proposed": True,
5471
+ "auto_group_key": group_key,
5472
+ "confirmation_required": True,
5473
+ "nodes": [
5474
+ auto_feature_chain_node(chain_id, index, card)
5475
+ for index, card in enumerate(group_cards, 1)
5476
+ if bad_case_card_id(card)
5477
+ ],
5478
+ }
5479
+ chains.append(chain)
5480
+ registry["chains"] = chains
5481
+ write_feature_chains(ctx, registry)
5482
+ existing_group_keys.add(group_key)
5483
+ run_covered_ids.update(case_ids)
5484
+ created.append(f"{chain_id}: {title} ({len(case_ids)} bad cases)")
5485
+
5486
+ print("[context-guard] auto feature-chain proposal:")
5487
+ if created:
5488
+ print(f"- created proposed chains: {len(created)}")
5489
+ for item in created:
5490
+ print(f" - {item}")
5491
+ print("[context-guard] next: ask the user to confirm the business flow before adding automation or approving these chains.")
5492
+ else:
5493
+ print("- created proposed chains: 0")
5494
+ if attached:
5495
+ print(f"- attached to proposed chains: {len(attached)}")
5496
+ for item in attached:
5497
+ print(f" - {item}")
5498
+ if skipped:
5499
+ print(f"- skipped: {len(skipped)}")
5500
+ for item in skipped:
5501
+ print(f" - {item}")
5502
+ if hook_mode:
5503
+ state_path.parent.mkdir(parents=True, exist_ok=True)
5504
+ state_path.write_text(
5505
+ json.dumps({"version": 1, "seen_bad_cases": sorted(all_case_ids)}, ensure_ascii=False, indent=2) + "\n",
5506
+ encoding="utf-8",
5507
+ )
5508
+ print(f"[context-guard] state: {state_path}")
5509
+ print(f"[context-guard] registry: {feature_chains_path(ctx)}")
5510
+ return 0
5511
+
5512
+
5513
+ def validate_feature_chains(root: Path, strict: bool = False, verbose: bool = False) -> int:
5514
+ init_context(root)
5515
+ ctx = context_dir(root)
5516
+ registry = load_feature_chains(ctx)
5517
+ chains = [item for item in registry.get("chains", []) if isinstance(item, dict)]
5518
+ warnings: list[str] = []
5519
+ errors: list[str] = []
5520
+
5521
+ for chain in chains:
5522
+ chain_id = str(chain.get("id") or chain.get("title") or "unknown").strip()
5523
+ label = f"{chain_id}: {str(chain.get('title') or 'untitled').strip()}"
5524
+ status = str(chain.get("status", "")).strip().lower()
5525
+ run_policy = str(chain.get("run_policy", chain.get("run policy", ""))).strip()
5526
+ command = str(chain.get("command", "")).strip()
5527
+ chain_type = str(chain.get("type", "")).strip().lower()
5528
+ artifact_policy = str(chain.get("artifact_policy", "")).strip()
5529
+ nodes = [node for node in chain.get("nodes", []) if isinstance(node, dict)]
5530
+
5531
+ def warn(message: str) -> None:
5532
+ warnings.append(f"{label}: {message}")
5533
+
5534
+ def error(message: str) -> None:
5535
+ errors.append(f"{label}: {message}")
5536
+
5537
+ if not chain_id:
5538
+ error("missing id")
5539
+ if not str(chain.get("title", "")).strip():
5540
+ error("missing title")
5541
+ if not str(chain.get("entry", "")).strip():
5542
+ error("missing entry")
5543
+ if not str(chain.get("exit_check", "")).strip():
5544
+ error("missing exit_check")
5545
+ if not run_policy:
5546
+ warn("missing run_policy")
5547
+ if artifact_policy not in {"cleanup-on-pass", "preserve-on-fail", "manual-preserve"}:
5548
+ warn("artifact_policy should be cleanup-on-pass, preserve-on-fail, or manual-preserve")
5549
+ if status in APPROVED_TEST_STATUSES and run_policy == RUN_ALWAYS_POLICY:
5550
+ if not command:
5551
+ error("approved every-dev-completion chain must have an automated command")
5552
+ if not nodes:
5553
+ error("approved every-dev-completion chain must have at least one checkpoint node")
5554
+ if chain_type == "manual":
5555
+ warn("approved every-dev-completion chain is marked manual")
5556
+
5557
+ for node in nodes:
5558
+ node_label = str(node.get("title") or node.get("id") or "unknown-node").strip()
5559
+ checks = node.get("checks", [])
5560
+ bad_cases = node.get("bad_cases", [])
5561
+ checks_list = checks if isinstance(checks, list) else []
5562
+ bad_case_list = bad_cases if isinstance(bad_cases, list) else []
5563
+ optional_reason = str(node.get("optional_reason") or node.get("required_reason") or "").strip()
5564
+ if not node_label:
5565
+ error("checkpoint node missing title/id")
5566
+ if not [str(item).strip() for item in checks_list if str(item).strip()]:
5567
+ warn(f"checkpoint `{node_label}` has no check text")
5568
+ if not [str(item).strip() for item in bad_case_list if str(item).strip()]:
5569
+ pending_reason = str(node.get("coverage_pending_reason") or "").strip()
5570
+ if status == "proposed" and pending_reason:
5571
+ warn(f"checkpoint `{node_label}` is pending bad-case coverage: {pending_reason}")
5572
+ else:
5573
+ warn(f"checkpoint `{node_label}` has no linked bad case coverage")
5574
+ if is_optional_feature_checkpoint(node) and not optional_reason:
5575
+ warn(f"optional checkpoint `{node_label}` should record why it is not required every run")
5576
+
5577
+ visible_warnings = warnings if verbose else warnings[:8]
5578
+ for item in visible_warnings:
5579
+ print(f"[context-guard] warning: feature chain {item}")
5580
+ if len(warnings) > len(visible_warnings):
5581
+ print(f"[context-guard] warning: {len(warnings) - len(visible_warnings)} more feature-chain warning(s); rerun with --verbose to show all.")
5582
+ for item in errors:
5583
+ print(f"[context-guard] error: feature chain {item}", file=sys.stderr)
5584
+ if errors or (strict and warnings):
5585
+ print(
5586
+ f"[context-guard] feature-chain validation failed: {len(errors)} error(s), {len(warnings)} warning(s).",
5587
+ file=sys.stderr,
5588
+ )
5589
+ return 1
5590
+ print(f"[context-guard] feature-chain validation passed: {len(chains)} chain(s), {len(warnings)} warning(s).")
5591
+ return 0
5592
+
5593
+
5594
+ def feature_chain_terms(text: str) -> set[str]:
5595
+ lowered = text.lower()
5596
+ terms = set(re.findall(r"[a-z0-9_./:-]{2,}", lowered))
5597
+ for chunk in re.findall(r"[\u4e00-\u9fff]+", lowered):
5598
+ if len(chunk) < 2:
5599
+ continue
5600
+ terms.add(chunk)
5601
+ terms.update(chunk[index : index + 2] for index in range(len(chunk) - 1))
5602
+ return {term for term in terms if term.strip()}
5603
+
5604
+
5605
+ def score_feature_chain_text(query_terms: set[str], text: str, weight: int = 1) -> int:
5606
+ if not query_terms or not text:
5607
+ return 0
5608
+ lowered = text.lower()
5609
+ text_terms = feature_chain_terms(text)
5610
+ score = 0
5611
+ for term in query_terms:
5612
+ if term in text_terms:
5613
+ score += 2 * weight
5614
+ elif term in lowered:
5615
+ score += weight
5616
+ return score
5617
+
5618
+
5619
+ def feature_chain_match_evidence(query_text: str, target_text: str, max_terms: int = 5) -> list[str]:
5620
+ shared = feature_chain_terms(query_text) & feature_chain_terms(target_text)
5621
+ evidence: list[str] = []
5622
+ for term in sorted(shared, key=lambda item: (-len(item), item)):
5623
+ if len(evidence) >= max_terms:
5624
+ break
5625
+ if not term or term in FEATURE_CHAIN_MATCH_EVIDENCE_STOP_TERMS:
5626
+ continue
5627
+ if term.startswith("bc-") or re.fullmatch(r"\d+", term):
5628
+ continue
5629
+ if re.search(r"[\u4e00-\u9fff]", term) and len(term) > 4:
5630
+ continue
5631
+ if re.fullmatch(r"[a-z0-9_./:-]+", term) and len(term) < 3:
5632
+ continue
5633
+ evidence.append(term)
5634
+ return evidence
5635
+
5636
+
5637
+ def feature_chain_candidate_text(chain: dict[str, object], node: dict[str, object] | None = None) -> str:
5638
+ parts = [
5639
+ str(chain.get(key, ""))
5640
+ for key in ("title", "entry", "exit_check", "resource", "source")
5641
+ ]
5642
+ if node:
5643
+ parts.extend(
5644
+ [
5645
+ str(node.get("title", "")),
5646
+ str(node.get("id", "")),
5647
+ str(node.get("coverage_pending_reason", "")),
5648
+ ]
5649
+ )
5650
+ checks = node.get("checks", [])
5651
+ bad_cases = node.get("bad_cases", [])
5652
+ if isinstance(checks, list):
5653
+ parts.extend(str(item) for item in checks)
5654
+ if isinstance(bad_cases, list):
5655
+ parts.extend(str(item) for item in bad_cases)
5656
+ return " ".join(part for part in parts if part.strip())
5657
+
5658
+
5659
+ def feature_chain_scored_candidates(
5660
+ query_terms: set[str], chains: list[dict[str, object]]
5661
+ ) -> list[tuple[int, dict[str, object], list[tuple[int, dict[str, object]]]]]:
5662
+ candidates: list[tuple[int, dict[str, object], list[tuple[int, dict[str, object]]]]] = []
5663
+ for chain in chains:
5664
+ chain_text = " ".join(
5665
+ str(chain.get(key, ""))
5666
+ for key in ("title", "entry", "exit_check", "resource", "source")
5667
+ )
5668
+ chain_score = score_feature_chain_text(query_terms, chain_text, weight=2)
5669
+ node_scores: list[tuple[int, dict[str, object]]] = []
5670
+ for node in chain.get("nodes", []):
5671
+ if not isinstance(node, dict):
5672
+ continue
5673
+ node_text_parts = [
5674
+ str(node.get("title", "")),
5675
+ str(node.get("id", "")),
5676
+ str(node.get("coverage_pending_reason", "")),
5677
+ ]
5678
+ checks = node.get("checks", [])
5679
+ bad_cases = node.get("bad_cases", [])
5680
+ if isinstance(checks, list):
5681
+ node_text_parts.extend(str(item) for item in checks)
5682
+ if isinstance(bad_cases, list):
5683
+ node_text_parts.extend(str(item) for item in bad_cases)
5684
+ node_score = score_feature_chain_text(query_terms, " ".join(node_text_parts), weight=3)
5685
+ if node_score:
5686
+ node_scores.append((node_score, node))
5687
+ node_scores.sort(key=lambda item: item[0], reverse=True)
5688
+ total_score = chain_score + sum(score for score, _node in node_scores[:3])
5689
+ if total_score:
5690
+ candidates.append((total_score, chain, node_scores[:3]))
5691
+ candidates.sort(key=lambda item: item[0], reverse=True)
5692
+ return candidates
5693
+
5694
+
5695
+ def feature_chain_best_match_for_text(
5696
+ text: str, chains: list[dict[str, object]]
5697
+ ) -> tuple[int, dict[str, object], int, dict[str, object] | None] | None:
5698
+ candidates = feature_chain_scored_candidates(feature_chain_terms(text), chains)
5699
+ if not candidates:
5700
+ return None
5701
+ score, chain, node_scores = candidates[0]
5702
+ if node_scores:
5703
+ node_score, node = node_scores[0]
5704
+ return score, chain, node_score, node
5705
+ return score, chain, 0, None
5706
+
5707
+
5708
+ def bad_case_search_text(card: dict[str, str]) -> str:
5709
+ fields = [
5710
+ bad_case_card_id(card),
5711
+ bad_case_card_label(card),
5712
+ card.get("tags", ""),
5713
+ card.get("display summary", ""),
5714
+ card.get("phenomenon", ""),
5715
+ card.get("trigger / reproduction", ""),
5716
+ card.get("root cause", ""),
5717
+ card.get("fix method", ""),
5718
+ card.get("guard / verification", ""),
5719
+ card.get("red condition", ""),
5720
+ card.get("expected failure reason", ""),
5721
+ ]
5722
+ return " ".join(str(item) for item in fields if str(item).strip())
5723
+
5724
+
5725
+ def cleaned_feature_chain_subject(text: str) -> str:
5726
+ compact = " ".join(str(text).split())
5727
+ compact = re.sub(
5728
+ r"^(请|帮我|麻烦)?\s*(写|创建|生成|设计|新增|添加|建立|做)\s*(一个|一条|一套)?\s*(测试任务|测试用例|测试|test case|task case)\s*[,,::]?\s*",
5729
+ "",
5730
+ compact,
5731
+ flags=re.IGNORECASE,
5732
+ )
5733
+ compact = re.sub(
5734
+ r"^(用来|用于|检验|验证|检查|确认|确保|覆盖)\s*(每次开发完成后|开发完成后|每次改完后|每次修改后)?\s*",
5735
+ "",
5736
+ compact,
5737
+ flags=re.IGNORECASE,
5738
+ )
5739
+ compact = re.sub(
5740
+ r"^(write|create|add|design|generate)\s+(a|an)?\s*(test|test case|task case)\s*(to|that|for)?\s*",
5741
+ "",
5742
+ compact,
5743
+ flags=re.IGNORECASE,
5744
+ )
5745
+ compact = re.sub(r"^(verify|validate|check|ensure)\s+(that\s+)?", "", compact, flags=re.IGNORECASE)
5746
+ compact = re.sub(r"^(是否|能否)", "", compact)
5747
+ return compact.replace("都能", "能").strip(" ,,::;;")
5748
+
5749
+
5750
+ def compact_feature_chain_subject(text: str, max_chars: int = 64) -> str:
5751
+ compact = cleaned_feature_chain_subject(text)
5752
+ if not compact:
5753
+ compact = "用户描述的功能流程"
5754
+ if len(compact) <= max_chars:
5755
+ return compact
5756
+ return compact[: max_chars - 1].rstrip() + "…"
5757
+
5758
+
5759
+ def feature_chain_plan_subject(query: str, expanded_cards: list[dict[str, str]]) -> str:
5760
+ if not expanded_cards:
5761
+ return compact_feature_chain_subject(query)
5762
+ card = expanded_cards[0]
5763
+ label = human_title(card.get("title", "")).strip()
5764
+ summary = str(card.get("display summary", "")).strip()
5765
+ return compact_feature_chain_subject(label or summary or bad_case_card_label(card))
5766
+
5767
+
5768
+ def clean_goal_part(text: str, max_chars: int = 44) -> str:
5769
+ cleaned = str(text).strip()
5770
+ cleaned = re.sub(r"^(这个|该|这个流程|该流程)\s*", "", cleaned)
5771
+ cleaned = cleaned.strip(" 「」\"'`,,::;;。")
5772
+ cleaned = re.sub(r"\s+", " ", cleaned)
5773
+ if len(cleaned) <= max_chars:
5774
+ return cleaned
5775
+ return cleaned[: max_chars - 1].rstrip() + "…"
5776
+
5777
+
5778
+ def parse_feature_chain_goal(query: str, expanded_cards: list[dict[str, str]]) -> dict[str, str] | None:
5779
+ if expanded_cards:
5780
+ return None
5781
+ compact = cleaned_feature_chain_subject(query)
5782
+ if not compact:
5783
+ return None
5784
+
5785
+ match = re.search(r"从\s*(?P<entry>.+?)\s*到\s*(?P<rest>.+)$", compact, flags=re.IGNORECASE)
5786
+ if match:
5787
+ entry = clean_goal_part(match.group("entry"))
5788
+ rest = match.group("rest").strip()
5789
+ risk = ""
5790
+ risk_match = re.search(
5791
+ r"(?P<exit>.+?)[,,;;。]?\s*(?:主要)?(?:验证|检验|检查|确认|确保|覆盖|防止|避免)\s*(?P<risk>.+)$",
5792
+ rest,
5793
+ flags=re.IGNORECASE,
5794
+ )
5795
+ if risk_match:
5796
+ exit_check = clean_goal_part(risk_match.group("exit"))
5797
+ risk = clean_goal_part(risk_match.group("risk"))
5798
+ else:
5799
+ exit_check = clean_goal_part(rest)
5800
+ if entry and exit_check:
5801
+ return {
5802
+ "entry": entry,
5803
+ "exit": exit_check,
5804
+ "risk": risk or "这个流程里的复发风险",
5805
+ }
5806
+
5807
+ english_match = re.search(
5808
+ r"from\s+(?P<entry>.+?)\s+to\s+(?P<rest>.+)$",
5809
+ compact,
5810
+ flags=re.IGNORECASE,
5811
+ )
5812
+ if english_match:
5813
+ entry = clean_goal_part(english_match.group("entry"))
5814
+ rest = english_match.group("rest").strip()
5815
+ risk = ""
5816
+ risk_match = re.search(
5817
+ r"(?P<exit>.+?)[,;.]?\s*(?:mainly\s+)?(?:verify|validate|check|ensure|cover|prevent)\s+(?P<risk>.+)$",
5818
+ rest,
5819
+ flags=re.IGNORECASE,
5820
+ )
5821
+ if risk_match:
5822
+ exit_check = clean_goal_part(risk_match.group("exit"))
5823
+ risk = clean_goal_part(risk_match.group("risk"))
5824
+ else:
5825
+ exit_check = clean_goal_part(rest)
5826
+ if entry and exit_check:
5827
+ return {
5828
+ "entry": entry,
5829
+ "exit": exit_check,
5830
+ "risk": risk or "recurrence risk in this workflow",
5831
+ }
5832
+ return None
5833
+
5834
+
5835
+ def feature_chain_confirmation_prompt(query: str, expanded_cards: list[dict[str, str]]) -> str:
5836
+ goal = parse_feature_chain_goal(query, expanded_cards)
5837
+ if goal:
5838
+ return (
5839
+ "测试创建识别:我会先把测试目标确认成一句话:"
5840
+ f"从「{goal['entry']}」到「{goal['exit']}」,主要验证「{goal['risk']}」。"
5841
+ )
5842
+ plan_subject = feature_chain_plan_subject(query, expanded_cards)
5843
+ return (
5844
+ "测试创建识别:我会先把测试目标确认成一句话:"
5845
+ f"从「{plan_subject}」相关入口到用户可见的正确结果,主要验证这个流程里的复发风险。"
5846
+ )
5847
+
5848
+
5849
+ def feature_chain_plan_command_parts(query: str, expanded_cards: list[dict[str, str]]) -> dict[str, str]:
5850
+ goal = parse_feature_chain_goal(query, expanded_cards)
5851
+ if goal:
5852
+ return {
5853
+ "title": "<confirmed feature title>",
5854
+ "entry": goal["entry"],
5855
+ "exit": goal["exit"],
5856
+ "checkpoint": goal["risk"],
5857
+ }
5858
+ if expanded_cards:
5859
+ subject = feature_chain_plan_subject(query, expanded_cards)
5860
+ return {
5861
+ "title": "<confirmed feature title>",
5862
+ "entry": "<confirmed user-visible entry>",
5863
+ "exit": "<confirmed strict final green condition>",
5864
+ "checkpoint": subject,
5865
+ }
5866
+ return {
5867
+ "title": "<confirmed feature title>",
5868
+ "entry": "<confirmed user-visible entry>",
5869
+ "exit": "<confirmed strict final green condition>",
5870
+ "checkpoint": "<confirmed recurrence checkpoint>",
5871
+ }
5872
+
5873
+
5874
+ def expand_feature_chain_query_from_bad_cases(ctx: Path, query: str) -> tuple[str, list[dict[str, str]]]:
5875
+ path = ctx / "bad-cases.md"
5876
+ if not path.exists():
5877
+ return query, []
5878
+ cards = parse_bad_case_cards(path.read_text(encoding="utf-8"))
5879
+ if not cards:
5880
+ return query, []
5881
+
5882
+ query_lower = query.lower()
5883
+ query_ids = set(re.findall(r"BC-\d{8}-\d+", query))
5884
+ matched: list[dict[str, str]] = []
5885
+ for card in cards:
5886
+ case_id = bad_case_card_id(card)
5887
+ label = bad_case_card_label(card).lower()
5888
+ if case_id and case_id in query_ids:
5889
+ matched.append(card)
5890
+ continue
5891
+ if label and (query_lower == label or label in query_lower):
5892
+ matched.append(card)
5893
+
5894
+ if not matched:
5895
+ return query, []
5896
+ expanded = " ".join([query] + [bad_case_search_text(card) for card in matched])
5897
+ return expanded, matched
5898
+
5899
+
5900
+ def feature_chain_suggest(root: Path, query: str, max_results: int = 5) -> int:
5901
+ init_context(root)
5902
+ ctx = context_dir(root)
5903
+ query = query.strip()
5904
+ if not query:
5905
+ raise ValueError("feature-chain-suggest requires --query")
5906
+ registry = load_feature_chains(ctx)
5907
+ chains = [item for item in registry.get("chains", []) if isinstance(item, dict)]
5908
+ expanded_query, expanded_cards = expand_feature_chain_query_from_bad_cases(ctx, query)
5909
+ query_terms = feature_chain_terms(expanded_query)
5910
+ candidates = feature_chain_scored_candidates(query_terms, chains)
5911
+ print(f"[context-guard] feature-chain query: {query}")
5912
+ if expanded_cards:
5913
+ labels = ", ".join(
5914
+ f"{bad_case_card_id(card) or '-'} {bad_case_card_label(card)}".strip()
5915
+ for card in expanded_cards
5916
+ )
5917
+ print(f"[context-guard] query expanded from bad-case register: {labels}")
5918
+ if not candidates:
5919
+ print("[context-guard] feature-chain candidates: none")
5920
+ print("[context-guard] proposal-needed: no matching feature chain; draft a proposed chain and ask the user before creating durable coverage.")
5921
+ print(f"[context-guard] registry: {feature_chains_path(ctx)}")
5922
+ return 0
5923
+
5924
+ print("[context-guard] feature-chain candidates:")
5925
+ for score, chain, node_scores in candidates[: max(1, max_results)]:
5926
+ chain_id = str(chain.get("id", "unknown"))
5927
+ title = str(chain.get("title", "untitled"))
5928
+ status = str(chain.get("status", "unknown"))
5929
+ entry = str(chain.get("entry", ""))
5930
+ print(f"- {chain_id} | score={score} | {status} | {title}")
5931
+ if entry:
5932
+ print(f" entry: {entry}")
5933
+ if node_scores:
5934
+ for node_score, node in node_scores:
5935
+ node_title = str(node.get("title") or node.get("id") or "checkpoint")
5936
+ print(f" checkpoint candidate: {node_title} (score={node_score})")
5937
+ else:
5938
+ print(" checkpoint candidate: create a proposed checkpoint under this chain after user confirmation")
5939
+ print("[context-guard] next: attach the bad case to a candidate checkpoint only when the match is semantically correct; otherwise propose a new chain.")
5940
+ print(f"[context-guard] registry: {feature_chains_path(ctx)}")
5941
+ return 0
5942
+
5943
+
5944
+ def feature_chain_plan(root: Path, query: str, max_results: int = 3) -> int:
5945
+ init_context(root)
5946
+ ctx = context_dir(root)
5947
+ query = query.strip()
5948
+ if not query:
5949
+ raise ValueError("feature-chain-plan requires --query")
5950
+ registry = load_feature_chains(ctx)
5951
+ chains = [item for item in registry.get("chains", []) if isinstance(item, dict)]
5952
+ expanded_query, expanded_cards = expand_feature_chain_query_from_bad_cases(ctx, query)
5953
+ candidates = feature_chain_scored_candidates(feature_chain_terms(expanded_query), chains)
5954
+ script_path = str(context_guard_skill_root() / "scripts" / "context_guard.py")
5955
+ print(f"[context-guard] feature-chain plan: {query}")
5956
+ if expanded_cards:
5957
+ labels = ", ".join(
5958
+ f"{bad_case_card_id(card) or '-'} {bad_case_card_label(card)}".strip()
5959
+ for card in expanded_cards
5960
+ )
5961
+ print(f"[context-guard] query expanded from bad-case register: {labels}")
5962
+
5963
+ strong_candidates = [
5964
+ (score, chain, node_scores)
5965
+ for score, chain, node_scores in candidates
5966
+ if score >= FEATURE_CHAIN_COVERAGE_SUGGESTION_MIN_SCORE
5967
+ ]
5968
+ if strong_candidates:
5969
+ print("[context-guard] action: review-existing-chain")
5970
+ print("[context-guard] meaning: a durable test may already exist; do not create a new chain unless the match is semantically wrong.")
5971
+ for score, chain, node_scores in strong_candidates[: max(1, max_results)]:
5972
+ chain_id = str(chain.get("id") or "unknown")
5973
+ title = str(chain.get("title") or "untitled")
5974
+ status = str(chain.get("status") or "unknown")
5975
+ run_policy = str(chain.get("run_policy") or "unset")
5976
+ print(f"- chain: {chain_id} | score={score} | {status} | {run_policy} | {title}")
5977
+ node: dict[str, object] | None = None
5978
+ if node_scores:
5979
+ node_score, node = node_scores[0]
5980
+ node_title = str(node.get("title") or node.get("id") or "checkpoint")
5981
+ print(f" checkpoint: {node_title} (score={node_score})")
5982
+ evidence = feature_chain_match_evidence(
5983
+ expanded_query,
5984
+ feature_chain_candidate_text(chain, node),
5985
+ )
5986
+ if evidence:
5987
+ print(f" match evidence: {', '.join(evidence)}")
5988
+ node_title_for_command = str((node or {}).get("title") or (node or {}).get("id") or "<confirmed checkpoint>").strip()
5989
+ expanded_ids = [bad_case_card_id(card) for card in expanded_cards if bad_case_card_id(card)]
5990
+ if expanded_ids:
5991
+ for case_id in expanded_ids:
5992
+ command = " ".join(
5993
+ shlex.quote(part)
5994
+ for part in [
5995
+ "python3",
5996
+ script_path,
5997
+ "feature-chain-attach-bc",
5998
+ "--root",
5999
+ str(root),
6000
+ "--chain-id",
6001
+ chain_id,
6002
+ "--node-title",
6003
+ node_title_for_command,
6004
+ "--bad-case",
6005
+ case_id,
6006
+ "--check",
6007
+ "<tighten checkpoint based on confirmed symptom>",
6008
+ ]
6009
+ )
6010
+ print(f" after-confirmation command: {command}")
6011
+ else:
6012
+ print(" after-confirmation command: record the bad case first, then attach it to this chain/checkpoint.")
6013
+ print("[context-guard] next: if the user confirms the match, attach the bad case to the listed checkpoint and strengthen that checkpoint; otherwise keep it unassigned and propose a new chain.")
6014
+ else:
6015
+ print("[context-guard] action: propose-new-chain")
6016
+ print("[context-guard] meaning: no strong existing feature chain matched this bad case or workflow.")
6017
+ print(
6018
+ "[context-guard] confirmation prompt: "
6019
+ f"{feature_chain_confirmation_prompt(query, expanded_cards)}"
6020
+ )
6021
+ command_parts = feature_chain_plan_command_parts(query, expanded_cards)
6022
+ if command_parts["checkpoint"] != "<confirmed recurrence checkpoint>":
6023
+ print(f"[context-guard] suggested checkpoint after confirmation: {command_parts['checkpoint']}")
6024
+ command_tokens = [
6025
+ "python3",
6026
+ script_path,
6027
+ "feature-chain-propose",
6028
+ "--root",
6029
+ str(root),
6030
+ "--title",
6031
+ command_parts["title"],
6032
+ "--entry",
6033
+ command_parts["entry"],
6034
+ "--exit-check",
6035
+ command_parts["exit"],
6036
+ "--node-title",
6037
+ command_parts["checkpoint"],
6038
+ "--check",
6039
+ "<confirmed checkpoint check>",
6040
+ ]
6041
+ expanded_ids = [bad_case_card_id(card) for card in expanded_cards if bad_case_card_id(card)]
6042
+ if expanded_ids:
6043
+ command_tokens.extend(["--bad-cases", ",".join(expanded_ids)])
6044
+ else:
6045
+ command_tokens.extend(
6046
+ [
6047
+ "--coverage-pending-reason",
6048
+ "<confirmed reason this chain has no linked bad case yet>",
6049
+ ]
6050
+ )
6051
+ command = " ".join(
6052
+ shlex.quote(part)
6053
+ for part in command_tokens
6054
+ )
6055
+ print(f"[context-guard] after-confirmation command: {command}")
6056
+ print("[context-guard] next: ask the user to confirm the business flow before running feature-chain-propose or writing durable automation.")
6057
+ print(f"[context-guard] registry: {feature_chains_path(ctx)}")
6058
+ return 0
6059
+
6060
+
6061
+ def test_hub_add(
6062
+ root: Path,
6063
+ title: str,
6064
+ command_text: str,
6065
+ run_policy: str = RUN_ALWAYS_POLICY,
6066
+ status: str = "approved",
6067
+ timeout_seconds: int = 300,
6068
+ artifact_policy: str = "cleanup-on-pass",
6069
+ resource: str = "local",
6070
+ ) -> Path:
6071
+ init_context(root)
6072
+ ctx = context_dir(root)
6073
+ if not title.strip():
6074
+ raise ValueError("test-hub-add requires --title")
6075
+ if not command_text.strip():
6076
+ raise ValueError("test-hub-add requires --command-text")
6077
+ registry = load_test_registry(ctx)
6078
+ tests = registry.setdefault("tests", [])
6079
+ if not isinstance(tests, list):
6080
+ tests = []
6081
+ registry["tests"] = tests
6082
+ test_id = next_test_id(ctx, title)
6083
+ tests.append(
6084
+ {
6085
+ "id": test_id,
6086
+ "title": title.strip(),
6087
+ "status": status.strip() or "approved",
6088
+ "run_policy": run_policy.strip() or RUN_ALWAYS_POLICY,
6089
+ "type": "command",
6090
+ "command": command_text.strip(),
6091
+ "cwd": ".",
6092
+ "resource": resource.strip() or "local",
6093
+ "timeout_seconds": int(timeout_seconds),
6094
+ "artifact_policy": artifact_policy.strip() or "cleanup-on-pass",
6095
+ "blocker_keywords": BLOCKER_PATTERNS,
6096
+ "created_at": datetime.now().isoformat(timespec="seconds"),
6097
+ "source": "human-approved registry entry",
6098
+ }
6099
+ )
6100
+ path = write_test_registry(ctx, registry)
6101
+ print(f"[context-guard] registered test: {test_id}")
6102
+ print(f"[context-guard] registry: {path}")
6103
+ return path
6104
+
6105
+
6106
+ def test_hub_registry_tests(ctx: Path) -> list[dict[str, object]]:
6107
+ registry = load_test_registry(ctx)
6108
+ return [item for item in registry.get("tests", []) if isinstance(item, dict)]
6109
+
6110
+
6111
+ def test_hub_find_registry_test(ctx: Path, test_id: str) -> tuple[dict[str, object], list[dict[str, object]], dict[str, object]]:
6112
+ registry = load_test_registry(ctx)
6113
+ tests = registry.setdefault("tests", [])
6114
+ if not isinstance(tests, list):
6115
+ tests = []
6116
+ registry["tests"] = tests
6117
+ matches = [item for item in tests if isinstance(item, dict) and str(item.get("id", "")) == test_id]
6118
+ if not matches:
6119
+ raise ValueError(f"test not found in registry: {test_id}")
6120
+ return matches[0], tests, registry
6121
+
6122
+
6123
+ def test_hub_list(root: Path) -> int:
6124
+ init_context(root)
6125
+ ctx = context_dir(root)
6126
+ registry_tests = test_hub_registry_tests(ctx)
6127
+ task_case_tests = discover_task_case_tests(ctx)
6128
+ feature_chain_tests = approved_feature_chain_tests(ctx)
6129
+ if not registry_tests and not task_case_tests and not feature_chain_tests:
3946
6130
  print("[context-guard] test hub: no registered tests.")
3947
6131
  print(f"[context-guard] registry: {test_registry_path(ctx)}")
3948
6132
  return 0
@@ -3989,6 +6173,29 @@ def read_test_hub_last_run(root: Path) -> dict[str, object]:
3989
6173
  return data if isinstance(data, dict) else {}
3990
6174
 
3991
6175
 
6176
+ def feature_chain_checkpoint_policy(test: dict[str, object]) -> dict[str, object]:
6177
+ nodes = [node for node in test.get("nodes", []) if isinstance(node, dict)]
6178
+ checkpoints: list[dict[str, object]] = []
6179
+ optional_count = 0
6180
+ for node in nodes:
6181
+ optional = is_optional_feature_checkpoint(node)
6182
+ if optional:
6183
+ optional_count += 1
6184
+ checkpoints.append(
6185
+ {
6186
+ "title": str(node.get("title") or node.get("id") or "checkpoint").strip(),
6187
+ "policy": "optional" if optional else "required",
6188
+ "reason": str(node.get("optional_reason") or node.get("required_reason") or "").strip(),
6189
+ }
6190
+ )
6191
+ return {
6192
+ "total": len(nodes),
6193
+ "required": len(nodes) - optional_count,
6194
+ "optional": optional_count,
6195
+ "checkpoints": checkpoints,
6196
+ }
6197
+
6198
+
3992
6199
  def test_hub_state(root: Path) -> dict[str, object]:
3993
6200
  init_context(root)
3994
6201
  ctx = context_dir(root)
@@ -4001,19 +6208,20 @@ def test_hub_state(root: Path) -> dict[str, object]:
4001
6208
  status = str(item.get("status", "unknown")).strip() or "unknown"
4002
6209
  run_policy = str(item.get("run_policy", item.get("run policy", "unset"))).strip() or "unset"
4003
6210
  command = str(item.get("command", "")).strip()
4004
- tests.append(
4005
- {
4006
- "id": str(item.get("id", "")),
4007
- "title": str(item.get("title", "untitled")),
4008
- "status": status,
4009
- "run_policy": run_policy,
4010
- "type": str(item.get("type") or ("command" if command else "manual")),
4011
- "command": command,
4012
- "resource": str(item.get("resource", "local") or "local"),
4013
- "source": source,
4014
- "eligible": status.lower() in APPROVED_TEST_STATUSES and run_policy == RUN_ALWAYS_POLICY,
4015
- }
4016
- )
6211
+ normalized = {
6212
+ "id": str(item.get("id", "")),
6213
+ "title": str(item.get("title", "untitled")),
6214
+ "status": status,
6215
+ "run_policy": run_policy,
6216
+ "type": str(item.get("type") or ("command" if command else "manual")),
6217
+ "command": command,
6218
+ "resource": str(item.get("resource", "local") or "local"),
6219
+ "source": source,
6220
+ "eligible": status.lower() in APPROVED_TEST_STATUSES and run_policy == RUN_ALWAYS_POLICY,
6221
+ }
6222
+ if source == "feature-chain":
6223
+ normalized["checkpoint_policy"] = feature_chain_checkpoint_policy(item)
6224
+ tests.append(normalized)
4017
6225
  last_run = read_test_hub_last_run(root)
4018
6226
  results = last_run.get("results", [])
4019
6227
  if not isinstance(results, list):
@@ -4064,6 +6272,31 @@ def render_test_hub_html(root: Path) -> str:
4064
6272
  }.get(result_status, "muted")
4065
6273
  command = str(test.get("command", "")).strip()
4066
6274
  command_html = f"<code>{html.escape(command)}</code>" if command else '<span class="muted">人工判断 / 未注册脚本</span>'
6275
+ checkpoint_policy = test.get("checkpoint_policy")
6276
+ checkpoint_html = ""
6277
+ if isinstance(checkpoint_policy, dict) and int(checkpoint_policy.get("total") or 0) > 0:
6278
+ checkpoints = checkpoint_policy.get("checkpoints", [])
6279
+ checkpoint_rows: list[str] = []
6280
+ if isinstance(checkpoints, list):
6281
+ for checkpoint in checkpoints:
6282
+ if not isinstance(checkpoint, dict):
6283
+ continue
6284
+ title = html.escape(str(checkpoint.get("title") or "checkpoint"))
6285
+ policy = str(checkpoint.get("policy") or "required")
6286
+ policy_text = "可选" if policy == "optional" else "必跑"
6287
+ policy_class = "muted" if policy == "optional" else "ok"
6288
+ reason = str(checkpoint.get("reason") or "").strip()
6289
+ reason_html = f'<span class="checkpoint-reason">{html.escape(reason)}</span>' if reason else ""
6290
+ checkpoint_rows.append(
6291
+ f'<li><span>{title}</span>{test_hub_badge(policy_text, policy_class)}{reason_html}</li>'
6292
+ )
6293
+ if checkpoint_rows:
6294
+ checkpoint_html = f"""
6295
+ <div class="checkpoint-policy">
6296
+ <p>检查点策略:{html.escape(str(checkpoint_policy.get("required", 0)))} 必跑 / {html.escape(str(checkpoint_policy.get("optional", 0)))} 可选</p>
6297
+ <ul>{''.join(checkpoint_rows)}</ul>
6298
+ </div>
6299
+ """
4067
6300
  rows.append(
4068
6301
  f"""
4069
6302
  <article class="test-card">
@@ -4079,6 +6312,7 @@ def render_test_hub_html(root: Path) -> str:
4079
6312
  <span class="result {result_class}">{html.escape(result_status)}</span>
4080
6313
  </div>
4081
6314
  <p class="command">{command_html}</p>
6315
+ {checkpoint_html}
4082
6316
  <p class="subtle">策略:{html.escape(run_policy)} · 资源:{html.escape(str(test.get("resource", "local")))}</p>
4083
6317
  </article>
4084
6318
  """
@@ -4239,6 +6473,33 @@ def render_test_hub_html(root: Path) -> str:
4239
6473
  .result.warn {{ background: var(--warn-soft); color: var(--warn); }}
4240
6474
  .result.muted {{ color: var(--muted); }}
4241
6475
  .command {{ margin: 12px 0 7px; }}
6476
+ .checkpoint-policy {{
6477
+ margin: 10px 0 8px;
6478
+ border-top: 1px solid var(--line);
6479
+ padding-top: 9px;
6480
+ }}
6481
+ .checkpoint-policy p {{
6482
+ margin-bottom: 5px;
6483
+ color: var(--accent);
6484
+ font-weight: 760;
6485
+ font-size: 12px;
6486
+ }}
6487
+ .checkpoint-policy ul {{
6488
+ list-style: none;
6489
+ margin: 0;
6490
+ padding: 0;
6491
+ display: grid;
6492
+ gap: 5px;
6493
+ }}
6494
+ .checkpoint-policy li {{
6495
+ display: flex;
6496
+ align-items: center;
6497
+ gap: 7px;
6498
+ flex-wrap: wrap;
6499
+ color: var(--ink);
6500
+ font-size: 12px;
6501
+ }}
6502
+ .checkpoint-reason {{ color: var(--muted); }}
4242
6503
  code {{
4243
6504
  display: inline-block;
4244
6505
  max-width: 100%;
@@ -4539,6 +6800,89 @@ def classify_test_blocker(output: str, keywords: object) -> str:
4539
6800
  return ""
4540
6801
 
4541
6802
 
6803
+ def parse_feature_chain_checkpoints(test: dict[str, object], output: str) -> list[dict[str, object]]:
6804
+ if str(test.get("source", "")) != "feature-chain":
6805
+ return []
6806
+ nodes = [node for node in test.get("nodes", []) if isinstance(node, dict)]
6807
+ labels: dict[str, str] = {}
6808
+ for node in nodes:
6809
+ title = str(node.get("title", "")).strip()
6810
+ node_id = str(node.get("id", "")).strip()
6811
+ if title:
6812
+ labels[title.lower()] = title
6813
+ labels[normalize_test_slug(title)] = title
6814
+ if node_id:
6815
+ labels[node_id.lower()] = title or node_id
6816
+ labels[normalize_test_slug(node_id)] = title or node_id
6817
+
6818
+ checkpoints: list[dict[str, object]] = []
6819
+ for raw_line in output.splitlines():
6820
+ line = raw_line.strip()
6821
+ match = CHECKPOINT_MARKER_RE.match(line)
6822
+ if not match:
6823
+ continue
6824
+ name = match.group("name").strip()
6825
+ status = match.group("status").strip().lower()
6826
+ reason = (match.group("reason") or "").strip()
6827
+ label = labels.get(name.lower()) or labels.get(normalize_test_slug(name)) or name
6828
+ known = name.lower() in labels or normalize_test_slug(name) in labels
6829
+ checkpoints.append(
6830
+ {
6831
+ "name": name,
6832
+ "label": label,
6833
+ "status": status,
6834
+ "reason": reason,
6835
+ "known": known,
6836
+ }
6837
+ )
6838
+ return checkpoints
6839
+
6840
+
6841
+ def is_optional_feature_checkpoint(node: dict[str, object]) -> bool:
6842
+ optional = str(node.get("optional", "")).strip().lower()
6843
+ required = str(node.get("required", "")).strip().lower()
6844
+ return optional in {"1", "true", "yes"} or required in {"0", "false", "no"}
6845
+
6846
+
6847
+ def feature_chain_required_checkpoints(test: dict[str, object]) -> list[dict[str, object]]:
6848
+ if str(test.get("source", "")) != "feature-chain":
6849
+ return []
6850
+ required: list[dict[str, object]] = []
6851
+ for node in test.get("nodes", []):
6852
+ if not isinstance(node, dict) or is_optional_feature_checkpoint(node):
6853
+ continue
6854
+ title = str(node.get("title", "")).strip()
6855
+ node_id = str(node.get("id", "")).strip()
6856
+ label = title or node_id
6857
+ if not label:
6858
+ continue
6859
+ aliases = {label.lower(), normalize_test_slug(label)}
6860
+ if title:
6861
+ aliases.update({title.lower(), normalize_test_slug(title)})
6862
+ if node_id:
6863
+ aliases.update({node_id.lower(), normalize_test_slug(node_id)})
6864
+ required.append({"label": label, "aliases": sorted(aliases)})
6865
+ return required
6866
+
6867
+
6868
+ def missing_feature_chain_checkpoints(
6869
+ test: dict[str, object], checkpoints: list[dict[str, object]]
6870
+ ) -> list[dict[str, object]]:
6871
+ required = feature_chain_required_checkpoints(test)
6872
+ if not required:
6873
+ return []
6874
+ seen: set[str] = set()
6875
+ for checkpoint in checkpoints:
6876
+ if not checkpoint.get("known"):
6877
+ continue
6878
+ for key in ("name", "label"):
6879
+ value = str(checkpoint.get(key, "")).strip()
6880
+ if value:
6881
+ seen.add(value.lower())
6882
+ seen.add(normalize_test_slug(value))
6883
+ return [item for item in required if not set(item.get("aliases", [])) & seen]
6884
+
6885
+
4542
6886
  def run_one_hub_test(root: Path, run_dir: Path, test: dict[str, object]) -> dict[str, object]:
4543
6887
  test_id = str(test.get("id") or test.get("title") or "unnamed")
4544
6888
  title = str(test.get("title") or test_id)
@@ -4578,7 +6922,24 @@ def run_one_hub_test(root: Path, run_dir: Path, test: dict[str, object]) -> dict
4578
6922
  )
4579
6923
  output = (completed.stdout or "") + (completed.stderr or "")
4580
6924
  blocker = classify_test_blocker(output, test.get("blocker_keywords"))
6925
+ checkpoints = parse_feature_chain_checkpoints(test, output)
6926
+ unknown_checkpoints = [item for item in checkpoints if not item.get("known")]
6927
+ failed_checkpoints = [item for item in checkpoints if item.get("status") == "fail"]
6928
+ missing_checkpoints = missing_feature_chain_checkpoints(test, checkpoints)
4581
6929
  status = "passed" if completed.returncode == 0 else ("blocked" if blocker or completed.returncode == 78 else "failed")
6930
+ checkpoint_reason = ""
6931
+ if unknown_checkpoints:
6932
+ status = "failed"
6933
+ checkpoint_reason = f"unknown checkpoint marker: {unknown_checkpoints[0].get('name')}"
6934
+ elif failed_checkpoints:
6935
+ status = "failed"
6936
+ first_failure = failed_checkpoints[0]
6937
+ checkpoint_reason = f"checkpoint failed: {first_failure.get('label')}"
6938
+ if first_failure.get("reason"):
6939
+ checkpoint_reason += f" - {first_failure.get('reason')}"
6940
+ elif missing_checkpoints:
6941
+ status = "failed"
6942
+ checkpoint_reason = f"missing checkpoint marker: {missing_checkpoints[0].get('label')}"
4582
6943
  cleaned = cleanup_registered_paths(root, test.get("cleanup_paths")) if status == "passed" else []
4583
6944
  log_path.write_text(output or "(no output)\n", encoding="utf-8")
4584
6945
  return {
@@ -4586,12 +6947,14 @@ def run_one_hub_test(root: Path, run_dir: Path, test: dict[str, object]) -> dict
4586
6947
  "title": title,
4587
6948
  "status": status,
4588
6949
  "returncode": completed.returncode,
4589
- "reason": blocker or ("exit-78" if completed.returncode == 78 else ""),
6950
+ "reason": checkpoint_reason or blocker or ("exit-78" if completed.returncode == 78 else ""),
4590
6951
  "command": command,
4591
6952
  "cwd": str(cwd),
4592
6953
  "resource": str(test.get("resource") or "local"),
4593
6954
  "log": str(log_path),
4594
6955
  "cleaned": cleaned,
6956
+ "checkpoints": checkpoints,
6957
+ "missing_checkpoints": missing_checkpoints,
4595
6958
  }
4596
6959
  except subprocess.TimeoutExpired as exc:
4597
6960
  output = (exc.stdout or "") + (exc.stderr or "")
@@ -4619,7 +6982,7 @@ def test_hub_dev_complete(root: Path, jobs: int = 1, keep_success_artifacts: boo
4619
6982
  print("[context-guard] test hub: no approved every-dev-completion tests.")
4620
6983
  return 0
4621
6984
 
4622
- run_id = datetime.now().strftime("%Y%m%d-%H%M%S")
6985
+ run_id = unique_run_id()
4623
6986
  run_dir = hub / "runs" / run_id
4624
6987
  run_dir.mkdir(parents=True, exist_ok=True)
4625
6988
  max_workers = max(1, int(jobs))
@@ -4648,6 +7011,8 @@ def test_hub_dev_complete(root: Path, jobs: int = 1, keep_success_artifacts: boo
4648
7011
  detail = f"{item.get('status')}: {item.get('title')}"
4649
7012
  if item.get("reason"):
4650
7013
  detail += f" ({item.get('reason')})"
7014
+ if item.get("status") in {"failed", "blocked"} and item.get("log"):
7015
+ detail += f" [log: {item.get('log')}]"
4651
7016
  print(f"- {detail}")
4652
7017
  if failed or blocked:
4653
7018
  print(f"[context-guard] evidence preserved: {run_dir}")
@@ -4662,6 +7027,514 @@ def test_hub_dev_complete(root: Path, jobs: int = 1, keep_success_artifacts: boo
4662
7027
  return 0
4663
7028
 
4664
7029
 
7030
+ def append_subagent_handoff(ctx: Path, agent_id: str, summary: str) -> Path:
7031
+ path = ctx / "subagents.md"
7032
+ now = datetime.now().isoformat(timespec="seconds")
7033
+ safe_agent = agent_id.strip() or "unknown"
7034
+ safe_summary = " ".join((summary or "").strip().split())
7035
+ if len(safe_summary) > 800:
7036
+ safe_summary = safe_summary[:797].rstrip() + "..."
7037
+ if not path.exists():
7038
+ path.write_text(
7039
+ "# Subagent Handoff Log\n\n"
7040
+ "This file records completed subagent work that the main agent pulled back through Context Guard.\n",
7041
+ encoding="utf-8",
7042
+ )
7043
+ with path.open("a", encoding="utf-8") as handle:
7044
+ handle.write(
7045
+ "\n"
7046
+ f"## {now}\n"
7047
+ f"- Agent: {safe_agent}\n"
7048
+ f"- Summary: {safe_summary or 'no summary provided'}\n"
7049
+ "- Required follow-up: Context Guard `subagent-complete` ran project context intake and Test Hub completion checks.\n"
7050
+ )
7051
+ return path
7052
+
7053
+
7054
+ COMPLETION_EVIDENCE_KEYS = {
7055
+ "cg_bad_case": "title",
7056
+ "cg_phenomenon": "phenomenon",
7057
+ "cg_trigger": "trigger",
7058
+ "cg_cause": "cause",
7059
+ "cg_fix": "fix",
7060
+ "cg_verification": "verification",
7061
+ "cg_scope": "scope",
7062
+ }
7063
+
7064
+
7065
+ def completion_evidence_text(summary: str, evidence_file: Path | None = None) -> str:
7066
+ parts = [summary.strip()]
7067
+ if evidence_file:
7068
+ try:
7069
+ parts.append(evidence_file.expanduser().read_text(encoding="utf-8").strip())
7070
+ except OSError as exc:
7071
+ raise ValueError(f"cannot read subagent evidence file: {evidence_file}: {exc}") from exc
7072
+ return "\n".join(part for part in parts if part).strip()
7073
+
7074
+
7075
+ def parse_structured_completion_evidence(text: str) -> list[dict[str, str]]:
7076
+ records: list[dict[str, str]] = []
7077
+ current: dict[str, str] = {}
7078
+ for raw_line in text.splitlines():
7079
+ line = raw_line.strip()
7080
+ match = re.match(r"^(CG_[A-Z_]+)\s*:\s*(.+)$", line, re.IGNORECASE)
7081
+ if not match:
7082
+ continue
7083
+ raw_key = match.group(1).strip().lower()
7084
+ key = COMPLETION_EVIDENCE_KEYS.get(raw_key)
7085
+ if not key:
7086
+ continue
7087
+ if key == "title" and current.get("title"):
7088
+ records.append(current)
7089
+ current = {}
7090
+ current[key] = " ".join(match.group(2).strip().split())
7091
+ if current.get("title"):
7092
+ records.append(current)
7093
+ return records
7094
+
7095
+
7096
+ def natural_completion_fix_evidence(text: str) -> list[dict[str, str]]:
7097
+ compact = " ".join(text.strip().split())
7098
+ if not compact:
7099
+ return []
7100
+ sentences = [part.strip(" -•") for part in re.split(r"[。!?!?\n]+", text) if part.strip(" -•")]
7101
+ problem_cues = ("问题", "错误", "bug", "误删", "丢失", "失败", "污染", "回归", "不正确", "串线", "泄漏", "重复")
7102
+ fix_cues = ("修复", "修正", "改为", "改成", "避免", "解决", "不再")
7103
+ verify_cues = ("验证", "测试", "通过", "check", "pytest", "smoke")
7104
+ records: list[dict[str, str]] = []
7105
+ for index, sentence in enumerate(sentences):
7106
+ lowered = sentence.lower()
7107
+ if not any(cue in lowered for cue in fix_cues) or not any(cue in lowered for cue in problem_cues):
7108
+ continue
7109
+ title = sentence
7110
+ title = re.sub(r"^(?:同时|另外|并且|已|已经|本轮|这次)?\s*(?:修复|修正|解决)(?:了)?\s*", "", title)
7111
+ title = re.split(r"(?:,|,|;|;)(?:改为|改成|现在|并|同时)", title, maxsplit=1)[0]
7112
+ title = re.sub(r"(?:的)?问题$", "", title).strip(" ::,,;;")
7113
+ if len(title) < 4 or title in {"问题", "相关问题", "发现的问题", "若干问题"}:
7114
+ continue
7115
+ verification = ""
7116
+ for candidate in sentences[index : index + 3]:
7117
+ if any(cue in candidate.lower() for cue in verify_cues):
7118
+ verification = candidate
7119
+ break
7120
+ records.append(
7121
+ {
7122
+ "title": title[:120],
7123
+ "phenomenon": sentence,
7124
+ "fix": sentence,
7125
+ "verification": verification,
7126
+ }
7127
+ )
7128
+ return records[:3]
7129
+
7130
+
7131
+ def completion_fix_evidence(text: str) -> list[dict[str, str]]:
7132
+ structured = parse_structured_completion_evidence(text)
7133
+ return structured if structured else natural_completion_fix_evidence(text)
7134
+
7135
+
7136
+ def completion_evidence_fingerprint(root: Path, agent_id: str, text: str) -> str:
7137
+ payload = "\0".join((str(root.resolve()), agent_id.strip(), text.strip())).encode("utf-8")
7138
+ return hashlib.sha256(payload).hexdigest()
7139
+
7140
+
7141
+ def completion_ledger_path(ctx: Path) -> Path:
7142
+ return ctx / "subagents" / "completions.json"
7143
+
7144
+
7145
+ def load_completion_ledger(ctx: Path) -> dict[str, object]:
7146
+ path = completion_ledger_path(ctx)
7147
+ if not path.exists():
7148
+ return {"version": 1, "completions": {}}
7149
+ try:
7150
+ data = json.loads(path.read_text(encoding="utf-8"))
7151
+ except (OSError, json.JSONDecodeError):
7152
+ return {"version": 1, "completions": {}}
7153
+ if not isinstance(data, dict) or not isinstance(data.get("completions"), dict):
7154
+ return {"version": 1, "completions": {}}
7155
+ return data
7156
+
7157
+
7158
+ def write_completion_ledger(ctx: Path, data: dict[str, object]) -> Path:
7159
+ path = completion_ledger_path(ctx)
7160
+ path.parent.mkdir(parents=True, exist_ok=True)
7161
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
7162
+ return path
7163
+
7164
+
7165
+ def append_completion_bad_cases(ctx: Path, evidence: list[dict[str, str]]) -> list[str]:
7166
+ if not evidence:
7167
+ return []
7168
+ path = ctx / "bad-cases.md"
7169
+ text = path.read_text(encoding="utf-8") if path.exists() else ""
7170
+ cards = parse_bad_case_cards(text)
7171
+ existing_signatures = {
7172
+ normalize_test_slug(" ".join((bad_case_card_label(card), str(card.get("phenomenon", "")))))
7173
+ for card in cards
7174
+ }
7175
+ created: list[str] = []
7176
+ blocks: list[str] = []
7177
+ today_dash = datetime.now().strftime("%Y-%m-%d")
7178
+ today_compact = datetime.now().strftime("%Y%m%d")
7179
+ existing_numbers = [int(match.group(1)) for match in re.finditer(rf"BC-{today_compact}-(\d+)", text)]
7180
+ next_number = max(existing_numbers) + 1 if existing_numbers else 1
7181
+ for item in evidence:
7182
+ title = human_title(item.get("title", "")).strip()
7183
+ phenomenon = item.get("phenomenon", "").strip() or title
7184
+ signature = normalize_test_slug(f"{title} {phenomenon}")
7185
+ if not title or any(signature and (signature in old or old in signature) for old in existing_signatures if old):
7186
+ continue
7187
+ case_id = f"BC-{today_compact}-{next_number:03d}"
7188
+ next_number += 1
7189
+ scope = item.get("scope", "").strip() or completion_risk_domain(
7190
+ " ".join(item.values()), completion_risk_buckets(" ".join(item.values()))
7191
+ )
7192
+ trigger = item.get("trigger", "").strip() or phenomenon
7193
+ cause = item.get("cause", "").strip() or "完成证据没有单独说明根因;保留真实修复现象与方法供后续追溯。"
7194
+ fix = item.get("fix", "").strip() or "Subagent 已在本轮修复该问题。"
7195
+ verification = item.get("verification", "").strip()
7196
+ status = "resolved" if verification else "open"
7197
+ guard = verification or "重新执行原始用户流程,确认该现象没有复发;形成用户批准的功能链后再复用自动化。"
7198
+ block = f"""
7199
+ ### {case_id}: {title}
7200
+
7201
+ - Status: {status}
7202
+ - First observed: {today_dash}
7203
+ - Last checked: {today_dash}
7204
+ - Scope: {scope}
7205
+ - Context task: current
7206
+ - Roadmap nodes: none
7207
+ - Tags: #subagent #observed-fix #feature-chain
7208
+ - Frequency: first-seen
7209
+ - Display summary: {phenomenon}
7210
+ - Phenomenon: {phenomenon}
7211
+ - Trigger / reproduction: {trigger}
7212
+ - Root cause: {cause}
7213
+ - Fix method: {fix}
7214
+ - Guard type: completion-evidence
7215
+ - Guard / verification: {guard}
7216
+ - Run policy: relevant-only
7217
+ - Red condition: 原始现象再次出现。
7218
+ - Green condition: {guard}
7219
+ - Expected failure reason: 如果本轮真实修复没有进入 bad-case register,后续功能链只能覆盖泛化风险,无法防止同一问题复发。
7220
+ - Reusable guard path: pending-user-approved-feature-chain
7221
+ """
7222
+ blocks.append(block.lstrip())
7223
+ created.append(case_id)
7224
+ existing_signatures.add(signature)
7225
+ text += "\n" + block
7226
+ if not blocks:
7227
+ return []
7228
+ placeholder = "## Active Cases\n\nNone.\n"
7229
+ original = path.read_text(encoding="utf-8") if path.exists() else ""
7230
+ joined = "\n".join(blocks) + "\n"
7231
+ if placeholder in original:
7232
+ path.write_text(original.replace(placeholder, "## Active Cases\n\n" + joined, 1), encoding="utf-8")
7233
+ else:
7234
+ with path.open("a", encoding="utf-8") as handle:
7235
+ if original and not original.endswith("\n"):
7236
+ handle.write("\n")
7237
+ handle.write(joined)
7238
+ return created
7239
+
7240
+
7241
+ RISK_AUDIT_BUCKETS: list[tuple[str, str, tuple[str, ...]]] = [
7242
+ ("状态切换", "#状态流程", ("状态", "模式", "练习模式", "暂停", "恢复", "失败", "成功", "state", "mode")),
7243
+ ("持久化", "#本地存储", ("持久化", "localstorage", "local storage", "刷新", "重启", "历史", "最高分", "streak", "score")),
7244
+ ("重置撤销", "#重置", ("重置", "撤销", "删除", "清空", "确认", "reset", "undo", "delete", "clear")),
7245
+ ("输入保护", "#空输入保护", ("空输入", "输入", "短线索", "表单", "校验", "input", "validation", "empty")),
7246
+ ("复制导出", "#复制反馈", ("复制", "剪贴板", "导出", "markdown", "copy", "clipboard", "export")),
7247
+ ("回放复盘", "#回放", ("回放", "复盘", "倒计时", "序列", "sequence", "replay", "countdown")),
7248
+ ("移动交互", "#ui", ("移动端", "按钮", "点击", "触摸", "布局", "mobile", "button", "click", "tap")),
7249
+ (
7250
+ "局部隔离",
7251
+ "#编辑隔离",
7252
+ (
7253
+ "不影响",
7254
+ "不会提前污染",
7255
+ "污染",
7256
+ "隔离",
7257
+ "独立",
7258
+ "局部",
7259
+ "单章",
7260
+ "这一章",
7261
+ "其他章节",
7262
+ "其他成员",
7263
+ "小队",
7264
+ "章节",
7265
+ "成员",
7266
+ "草稿",
7267
+ "isolation",
7268
+ "isolated",
7269
+ ),
7270
+ ),
7271
+ ]
7272
+
7273
+
7274
+ def completion_risk_buckets(summary: str) -> list[tuple[str, str]]:
7275
+ text = (summary or "").lower()
7276
+ matched: list[tuple[str, str]] = []
7277
+ for label, tag, keywords in RISK_AUDIT_BUCKETS:
7278
+ if any(keyword.lower() in text for keyword in keywords):
7279
+ matched.append((label, tag))
7280
+ return matched
7281
+
7282
+
7283
+ def completion_risk_domain(summary: str, buckets: list[tuple[str, str]]) -> str:
7284
+ text = (summary or "").lower()
7285
+ if any(word in text for word in ("灯", "游戏", "回放", "倒计时", "最高分", "sequence", "replay", "score")):
7286
+ return "游戏流程"
7287
+ if any(word in text for word in ("角色", "线索", "复制", "剪贴板", "markdown", "story", "card", "clipboard")):
7288
+ return "内容生成流程"
7289
+ if any(word in text for word in ("清单", "模板", "勾选", "收纳", "streak", "template")):
7290
+ return "清单模板流程"
7291
+ if buckets:
7292
+ return buckets[0][0]
7293
+ return "本轮功能流程"
7294
+
7295
+
7296
+ def append_risk_audit_bad_case(ctx: Path, summary: str, buckets: list[tuple[str, str]]) -> Path | None:
7297
+ if len(buckets) < 2:
7298
+ return None
7299
+ path = ctx / "bad-cases.md"
7300
+ text = path.read_text(encoding="utf-8") if path.exists() else ""
7301
+ cards = parse_bad_case_cards(text)
7302
+ domain = completion_risk_domain(summary, buckets)
7303
+ tags = []
7304
+ for _label, tag in buckets:
7305
+ if tag not in tags:
7306
+ tags.append(tag)
7307
+ tags.extend(tag for tag in ("#risk-audit", "#subagent") if tag not in tags)
7308
+ short_summary = " ".join((summary or "").strip().split())
7309
+ if len(short_summary) > 240:
7310
+ short_summary = short_summary[:237].rstrip() + "..."
7311
+ summary_signature = normalize_test_slug(short_summary)[:96]
7312
+ for card in cards:
7313
+ card_tags = {tag.lower() for tag in bad_case_tags(card)}
7314
+ if "#risk-audit" not in card_tags:
7315
+ continue
7316
+ card_trigger = normalize_test_slug(
7317
+ " ".join(
7318
+ [
7319
+ str(card.get("trigger / reproduction", "")),
7320
+ str(card.get("trigger", "")),
7321
+ str(card.get("display summary", "")),
7322
+ ]
7323
+ )
7324
+ )
7325
+ if summary_signature and summary_signature in card_trigger:
7326
+ return None
7327
+ today_dash = datetime.now().strftime("%Y-%m-%d")
7328
+ case_id = next_bad_case_id(ctx)
7329
+ bucket_text = "、".join(label for label, _tag in buckets[:4])
7330
+ title = f"{case_id}: {domain}{bucket_text}未验证"
7331
+ block = f"""
7332
+ ### {title}
7333
+
7334
+ - Status: open
7335
+ - First observed: {today_dash}
7336
+ - Last checked: {today_dash}
7337
+ - Scope: {domain}
7338
+ - Context task: current
7339
+ - Roadmap nodes: none
7340
+ - Tags: {" ".join(tags)}
7341
+ - Frequency: first-seen
7342
+ - Display summary: Subagent 本轮开发涉及{bucket_text},但没有对应 bad case 或功能链节点。
7343
+ - Phenomenon: Subagent 完成开发并通过 smoke 后,本轮新增流程没有留下可聚合 bad case;如果这些状态、重置、持久化或复盘风险未被记录,后续功能链无法覆盖这些复发风险。
7344
+ - Trigger / reproduction: {short_summary or "Subagent 完成一次开发任务,但没有留下 bad-case register。"}
7345
+ - Root cause: 仅依赖 subagent 自主发现风险时,普通开发总结可能只报告完成事项和 smoke 通过,不会主动写入潜在用户风险。
7346
+ - Fix method: 尚未修复;需要在下一轮开发或人工审查中确认该风险是否真实存在,并将其合并进对应功能链或关闭。
7347
+ - Guard type: risk-audit
7348
+ - Guard / verification: 运行真实用户入口,覆盖{bucket_text}相关路径;若风险不成立,将本条关闭并记录原因。
7349
+ - Run policy: relevant-only
7350
+ - Red condition: 相关入口在空状态、重复点击、失败路径、刷新/重启或重置/撤销后表现不一致。
7351
+ - Green condition: 相关入口在这些边界路径下状态一致、反馈清晰,并且必要时有功能链覆盖。
7352
+ - Expected failure reason: 如果没有这条风险审计记录,Context Guard 会因为没有 bad case 输入而无法建立或扩展功能链。
7353
+ - Reusable guard path: pending
7354
+ - Test-chain issue: missing bad-case input after subagent completion
7355
+ """
7356
+ clean_block = block.lstrip()
7357
+ placeholder = "## Active Cases\n\nNone.\n"
7358
+ if placeholder in text:
7359
+ path.write_text(text.replace(placeholder, "## Active Cases\n\n" + clean_block + "\n", 1), encoding="utf-8")
7360
+ else:
7361
+ with path.open("a", encoding="utf-8") as handle:
7362
+ if text and not text.endswith("\n"):
7363
+ handle.write("\n")
7364
+ handle.write(clean_block)
7365
+ return path
7366
+
7367
+
7368
+ def subagent_completion_risk_audit(root: Path, summary: str) -> Path | None:
7369
+ ctx = context_dir(root)
7370
+ buckets = completion_risk_buckets(summary)
7371
+ created = append_risk_audit_bad_case(ctx, summary, buckets)
7372
+ if created:
7373
+ labels = "、".join(label for label, _tag in buckets)
7374
+ print(f"[context-guard] risk audit: created bad-case candidate for {labels}.")
7375
+ print(f"[context-guard] risk audit file: {created}")
7376
+ else:
7377
+ print("[context-guard] risk audit: no new bad-case candidate.")
7378
+ return created
7379
+
7380
+
7381
+ def subagent_complete(
7382
+ root: Path,
7383
+ agent_id: str = "",
7384
+ summary: str = "",
7385
+ project_root: Path | None = None,
7386
+ evidence_file: Path | None = None,
7387
+ jobs: int = 1,
7388
+ keep_success_artifacts: bool = False,
7389
+ ) -> int:
7390
+ control_root = root.resolve()
7391
+ assigned_root = resolve_registered_subagent_root(control_root, agent_id)
7392
+ target_root = (project_root.expanduser().resolve() if project_root else assigned_root) or control_root
7393
+ if is_context_guard_skill_path(target_root):
7394
+ raise ValueError("subagent completion target cannot be the Context Guard skill directory")
7395
+ evidence_text = completion_evidence_text(summary, evidence_file)
7396
+ init_context(target_root)
7397
+ ctx = context_dir(target_root)
7398
+ fingerprint = completion_evidence_fingerprint(target_root, agent_id, evidence_text)
7399
+ ledger = load_completion_ledger(ctx)
7400
+ completions = ledger.setdefault("completions", {})
7401
+ assert isinstance(completions, dict)
7402
+ previous = completions.get(fingerprint)
7403
+ if isinstance(previous, dict) and int(previous.get("test_code", 1)) == 0:
7404
+ print(f"[context-guard] subagent completion already processed: {agent_id.strip() or 'subagent'}")
7405
+ print(f"[context-guard] subagent project root: {target_root}")
7406
+ return 0
7407
+
7408
+ handoff_path = append_subagent_handoff(ctx, agent_id, evidence_text)
7409
+ short_agent = agent_id.strip() or "subagent"
7410
+ short_summary = " ".join((summary or evidence_text or "").strip().split())
7411
+ if len(short_summary) > 220:
7412
+ short_summary = short_summary[:217].rstrip() + "..."
7413
+ checkpoint_roadmap_node(
7414
+ target_root,
7415
+ title=f"Subagent completion handoff: {short_agent}",
7416
+ branch=None,
7417
+ level="checkpoint",
7418
+ outcome="Subagent 完成后由主 agent 接管 Context Guard 收尾。",
7419
+ display_title="Subagent 完成接管",
7420
+ user_request="主 agent 在 subagent 完成后自动接管 Context Guard 和 Test Hub 收尾。",
7421
+ progress_summary=short_summary or "Subagent 已完成一次开发任务,Context Guard 已记录 handoff。",
7422
+ method_summary="写入 subagent handoff 记录,随后运行 Test Hub 的 dev-complete,并尝试自动聚合功能链候选。",
7423
+ decision="Subagent 传输不保证触发项目本地 hook,因此以已注册的项目根目录和主 agent completion handoff 作为可靠兜底。",
7424
+ avoid="不要把 wait_agent 返回视为已经完成 Context Guard;必须跑 subagent-complete 或等价收尾。",
7425
+ next_step="如果 Test Hub 失败,先修复失败项;如果出现新 bad case,挂载到已有功能链或生成 proposed 链。",
7426
+ test_chain="subagent-complete runs Test Hub dev-complete and feature-chain-auto-propose.",
7427
+ )
7428
+ print(f"[context-guard] subagent handoff: {handoff_path}")
7429
+ print(f"[context-guard] subagent project root: {target_root}")
7430
+ test_code = test_hub_dev_complete(target_root, jobs=jobs, keep_success_artifacts=keep_success_artifacts)
7431
+ concrete_cases = append_completion_bad_cases(ctx, completion_fix_evidence(evidence_text))
7432
+ if concrete_cases:
7433
+ print(f"[context-guard] completion evidence: archived concrete bad case(s): {', '.join(concrete_cases)}")
7434
+ else:
7435
+ subagent_completion_risk_audit(target_root, evidence_text)
7436
+ try:
7437
+ feature_chain_auto_propose(target_root, min_cases=2, max_groups=6, hook_mode=True)
7438
+ except Exception as exc:
7439
+ print(f"[context-guard] feature-chain auto-propose warning: {exc}", file=sys.stderr)
7440
+ completions[fingerprint] = {
7441
+ "agent_id": short_agent,
7442
+ "project_root": str(target_root),
7443
+ "completed_at": datetime.now().isoformat(timespec="seconds"),
7444
+ "test_code": test_code,
7445
+ "concrete_bad_cases": concrete_cases,
7446
+ }
7447
+ write_completion_ledger(ctx, ledger)
7448
+ assignments = load_subagent_assignments(control_root)
7449
+ assignment_map = assignments.get("assignments", {})
7450
+ if isinstance(assignment_map, dict) and isinstance(assignment_map.get(short_agent), dict):
7451
+ assignment = assignment_map[short_agent]
7452
+ assignment["status"] = "completed" if test_code == 0 else "completion-blocked"
7453
+ assignment["last_completion_at"] = datetime.now().isoformat(timespec="seconds")
7454
+ assignment["last_completion_fingerprint"] = fingerprint
7455
+ assignment["last_test_code"] = test_code
7456
+ write_subagent_assignments(control_root, assignments)
7457
+ return test_code
7458
+
7459
+
7460
+ def feature_chain_dry_run(
7461
+ root: Path,
7462
+ chain_id: str,
7463
+ command_text: str = "",
7464
+ timeout_seconds: int = 300,
7465
+ keep_success_artifacts: bool = False,
7466
+ ) -> int:
7467
+ init_context(root)
7468
+ ctx = context_dir(root)
7469
+ hub = test_hub_dir(root)
7470
+ hub.mkdir(parents=True, exist_ok=True)
7471
+ chain_id = chain_id.strip()
7472
+ if not chain_id:
7473
+ raise ValueError("feature-chain-dry-run requires --chain-id")
7474
+ chain, _chains, _registry = feature_chain_find(ctx, chain_id)
7475
+ command = command_text.strip() or str(chain.get("command", "")).strip()
7476
+ if not command:
7477
+ raise ValueError("feature-chain-dry-run requires --command-text or an existing chain command")
7478
+
7479
+ run_id = unique_run_id()
7480
+ run_dir = hub / "dry-runs" / f"{run_id}-{normalize_test_slug(chain_id)}"
7481
+ run_dir.mkdir(parents=True, exist_ok=True)
7482
+ test = dict(chain)
7483
+ test["id"] = chain_id
7484
+ test["title"] = str(chain.get("title") or chain_id)
7485
+ test["source"] = "feature-chain"
7486
+ test["type"] = "command"
7487
+ test["command"] = command
7488
+ test["timeout_seconds"] = int(timeout_seconds or chain.get("timeout_seconds") or 300)
7489
+ test.setdefault("cwd", ".")
7490
+ test.setdefault("artifact_policy", "cleanup-on-pass")
7491
+ test.setdefault("resource", "local")
7492
+ test.setdefault("blocker_keywords", BLOCKER_PATTERNS)
7493
+
7494
+ result = run_one_hub_test(root, run_dir, test)
7495
+ summary = {
7496
+ "run_id": run_id,
7497
+ "root": str(root),
7498
+ "created_at": datetime.now().isoformat(timespec="seconds"),
7499
+ "chain_id": chain_id,
7500
+ "result": result,
7501
+ }
7502
+ summary_path = hub / "last-dry-run.json"
7503
+ summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
7504
+
7505
+ status = str(result.get("status", "unknown"))
7506
+ print(f"[context-guard] feature-chain dry run: {status}: {test['title']}")
7507
+ if result.get("reason"):
7508
+ print(f"[context-guard] reason: {result.get('reason')}")
7509
+ checkpoints = result.get("checkpoints", [])
7510
+ if isinstance(checkpoints, list) and checkpoints:
7511
+ for checkpoint in checkpoints:
7512
+ if not isinstance(checkpoint, dict):
7513
+ continue
7514
+ label = checkpoint.get("label") or checkpoint.get("name")
7515
+ mark_status = checkpoint.get("status")
7516
+ reason = checkpoint.get("reason")
7517
+ line = f"- checkpoint {mark_status}: {label}"
7518
+ if reason:
7519
+ line += f" ({reason})"
7520
+ print(line)
7521
+ missing = result.get("missing_checkpoints", [])
7522
+ if isinstance(missing, list) and missing:
7523
+ for checkpoint in missing:
7524
+ if isinstance(checkpoint, dict):
7525
+ print(f"- missing checkpoint: {checkpoint.get('label')}")
7526
+ if status == "passed":
7527
+ if keep_success_artifacts:
7528
+ print(f"[context-guard] dry-run artifacts kept: {run_dir}")
7529
+ else:
7530
+ shutil.rmtree(run_dir, ignore_errors=True)
7531
+ print("[context-guard] dry-run success artifacts cleaned.")
7532
+ else:
7533
+ print(f"[context-guard] dry-run evidence preserved: {run_dir}")
7534
+ print(f"[context-guard] summary: {summary_path}")
7535
+ return 0 if status == "passed" else 1
7536
+
7537
+
4665
7538
  def create_branch_task(root: Path, title: str, branch: str, parent_node: str = "") -> tuple[str, str, Path]:
4666
7539
  init_context(root)
4667
7540
  ctx = context_dir(root)
@@ -4684,15 +7557,16 @@ def create_branch_task(root: Path, title: str, branch: str, parent_node: str = "
4684
7557
 
4685
7558
  def main() -> int:
4686
7559
  parser = argparse.ArgumentParser(description="Context Guard utilities")
4687
- parser.add_argument("command", choices=["init", "set-language", "export-roadmap", "show-roadmap", "create-branch-task", "checkpoint-roadmap-node", "validate-bad-cases", "validate-roadmap-maintenance", "test-hub-add", "test-hub-list", "test-hub-enable", "test-hub-disable", "test-hub-set-policy", "test-hub-remove", "feature-chain-add", "feature-chain-attach-bc", "feature-chain-list", "show-test-hub", "serve-test-hub", "dev-complete"])
7560
+ parser.add_argument("command", choices=["init", "set-language", "export-roadmap", "show-roadmap", "create-branch-task", "checkpoint-roadmap-node", "subagent-register", "subagent-complete", "validate-bad-cases", "validate-roadmap-maintenance", "validate-feature-chains", "test-hub-add", "test-hub-list", "test-hub-enable", "test-hub-disable", "test-hub-set-policy", "test-hub-remove", "feature-chain-add", "feature-chain-propose", "feature-chain-auto-propose", "feature-chain-attach-bc", "feature-chain-approve", "feature-chain-dry-run", "feature-chain-set-policy", "feature-chain-set-checkpoint", "feature-chain-suggest", "feature-chain-plan", "feature-chain-list", "feature-chain-summary", "feature-chain-overlap", "feature-chain-coverage", "feature-chain-candidates", "show-test-hub", "serve-test-hub", "dev-complete"])
4688
7561
  parser.add_argument("--format", choices=["html", "md"], default="html")
4689
7562
  parser.add_argument("--language", default=None, help="Folder-scoped language for future context records.")
4690
7563
  parser.add_argument("--title", default=None, help="Title for a branch task or roadmap checkpoint.")
4691
7564
  parser.add_argument("--test-id", default="", help="Test ID for test-hub management commands.")
4692
7565
  parser.add_argument("--command-text", default="", help="Shell command for a human-approved test registry entry.")
4693
7566
  parser.add_argument("--run-policy", default=RUN_ALWAYS_POLICY, help="Run policy for a test registry entry.")
4694
- parser.add_argument("--test-status", default="approved", help="Status for a test registry entry.")
7567
+ parser.add_argument("--test-status", default=None, help="Status for a test registry or feature-chain entry.")
4695
7568
  parser.add_argument("--reason", default="", help="Reason for disabling or changing a test policy.")
7569
+ parser.add_argument("--required", default="", help="Whether a feature-chain checkpoint is required every run: true/false or required/optional.")
4696
7570
  parser.add_argument("--timeout-seconds", type=int, default=300, help="Timeout for a registered test command.")
4697
7571
  parser.add_argument("--artifact-policy", default="cleanup-on-pass", help="Artifact policy for a registered test command.")
4698
7572
  parser.add_argument("--resource", default="local", help="Resource/node label for a registered test command.")
@@ -4701,11 +7575,20 @@ def main() -> int:
4701
7575
  parser.add_argument("--chain-id", default="", help="Feature chain ID for feature-chain management commands.")
4702
7576
  parser.add_argument("--node-title", default="", help="Feature-chain node title for attaching bad-case coverage.")
4703
7577
  parser.add_argument("--bad-case", default="", help="Bad-case ID or title covered by a feature-chain node.")
7578
+ parser.add_argument("--bad-cases", default="", help="Comma-separated bad-case IDs or titles covered by a proposed feature-chain checkpoint.")
7579
+ parser.add_argument("--coverage-pending-reason", default="", help="Reason a proposed feature-chain checkpoint has no linked bad case yet.")
4704
7580
  parser.add_argument("--check", default="", help="Checkpoint text for a feature-chain node.")
7581
+ parser.add_argument("--query", default="", help="Natural-language feature or bad-case text for feature-chain-suggest.")
7582
+ parser.add_argument("--agent-id", default="", help="Subagent identifier for subagent-complete handoff records.")
7583
+ parser.add_argument("--summary", default="", help="Subagent completion summary for subagent-complete handoff records.")
7584
+ parser.add_argument("--project-root", type=Path, default=None, help="Explicit project root assigned to a subagent.")
7585
+ parser.add_argument("--task", default="", help="Short ordinary product task recorded by subagent-register.")
7586
+ parser.add_argument("--evidence-file", type=Path, default=None, help="Exact subagent completion output containing optional CG_BAD_CASE evidence fields.")
4705
7587
  parser.add_argument("--jobs", type=int, default=1, help="Parallel test workers for dev-complete.")
4706
7588
  parser.add_argument("--host", default="127.0.0.1", help="Host for serve-test-hub.")
4707
7589
  parser.add_argument("--port", type=int, default=8772, help="Port for serve-test-hub.")
4708
7590
  parser.add_argument("--keep-success-artifacts", action="store_true", help="Keep test-hub run directory after all tests pass.")
7591
+ parser.add_argument("--from-hook", action="store_true", help="Run in hook-safe mode for automatic feature-chain proposals.")
4709
7592
  parser.add_argument("--branch", default=None, help="Branch/route name for a branch task or roadmap checkpoint.")
4710
7593
  parser.add_argument("--parent-node", default="", help="Roadmap node where the branch forks.")
4711
7594
  parser.add_argument("--level", choices=["major", "checkpoint"], default="checkpoint", help="Roadmap checkpoint level.")
@@ -4723,6 +7606,9 @@ def main() -> int:
4723
7606
  parser.add_argument("--strict", action="store_true", help="Fail when any resolved bad case lacks red-capable guard fields.")
4724
7607
  parser.add_argument("--verbose", action="store_true", help="Show all validation warnings.")
4725
7608
  parser.add_argument("--max-hidden-checkpoints", type=int, default=8, help="Maximum checkpoints allowed after a route's latest visible node.")
7609
+ parser.add_argument("--min-cases", type=int, default=2, help="Minimum unassigned bad cases required for a feature-chain candidate group.")
7610
+ parser.add_argument("--max-groups", type=int, default=6, help="Maximum feature-chain candidate groups to show.")
7611
+ parser.add_argument("--min-score", type=int, default=6, help="Minimum overlap score for feature-chain overlap audit.")
4726
7612
  parser.add_argument("--root", type=Path, default=None)
4727
7613
  args = parser.parse_args()
4728
7614
 
@@ -4776,6 +7662,24 @@ def main() -> int:
4776
7662
  parent_node=args.parent_node,
4777
7663
  )
4778
7664
  return 0
7665
+ if args.command == "subagent-register":
7666
+ if not args.project_root:
7667
+ parser.error("subagent-register requires --project-root")
7668
+ try:
7669
+ register_subagent_assignment(root, args.agent_id, args.project_root, args.task)
7670
+ except ValueError as exc:
7671
+ parser.error(str(exc))
7672
+ return 0
7673
+ if args.command == "subagent-complete":
7674
+ return subagent_complete(
7675
+ root,
7676
+ agent_id=args.agent_id,
7677
+ summary=args.summary,
7678
+ project_root=args.project_root,
7679
+ evidence_file=args.evidence_file,
7680
+ jobs=args.jobs,
7681
+ keep_success_artifacts=args.keep_success_artifacts,
7682
+ )
4779
7683
  if args.command == "test-hub-add":
4780
7684
  if not args.title:
4781
7685
  parser.error("test-hub-add requires --title")
@@ -4786,7 +7690,7 @@ def main() -> int:
4786
7690
  title=args.title,
4787
7691
  command_text=args.command_text,
4788
7692
  run_policy=args.run_policy,
4789
- status=args.test_status,
7693
+ status=args.test_status or "approved",
4790
7694
  timeout_seconds=args.timeout_seconds,
4791
7695
  artifact_policy=args.artifact_policy,
4792
7696
  resource=args.resource,
@@ -4806,12 +7710,41 @@ def main() -> int:
4806
7710
  exit_check=args.exit_check,
4807
7711
  command_text=args.command_text,
4808
7712
  run_policy=args.run_policy,
4809
- status=args.test_status,
7713
+ status=args.test_status or "proposed",
4810
7714
  timeout_seconds=args.timeout_seconds,
4811
7715
  artifact_policy=args.artifact_policy,
4812
7716
  resource=args.resource,
4813
7717
  )
4814
7718
  return 0
7719
+ if args.command == "feature-chain-propose":
7720
+ if not args.title:
7721
+ parser.error("feature-chain-propose requires --title")
7722
+ if not args.entry:
7723
+ parser.error("feature-chain-propose requires --entry")
7724
+ if not args.exit_check:
7725
+ parser.error("feature-chain-propose requires --exit-check")
7726
+ if not args.node_title:
7727
+ parser.error("feature-chain-propose requires --node-title")
7728
+ if not args.bad_cases and not args.coverage_pending_reason:
7729
+ parser.error("feature-chain-propose requires --bad-cases or --coverage-pending-reason")
7730
+ if not args.check:
7731
+ parser.error("feature-chain-propose requires --check")
7732
+ feature_chain_propose(
7733
+ root,
7734
+ title=args.title,
7735
+ entry=args.entry,
7736
+ exit_check=args.exit_check,
7737
+ node_title=args.node_title,
7738
+ bad_cases=parse_bad_case_list(args.bad_cases),
7739
+ check=args.check,
7740
+ coverage_pending_reason=args.coverage_pending_reason,
7741
+ run_policy=args.run_policy,
7742
+ artifact_policy=args.artifact_policy,
7743
+ resource=args.resource,
7744
+ )
7745
+ return 0
7746
+ if args.command == "feature-chain-auto-propose":
7747
+ return feature_chain_auto_propose(root, min_cases=args.min_cases, max_groups=args.max_groups, hook_mode=args.from_hook)
4815
7748
  if args.command == "feature-chain-attach-bc":
4816
7749
  if not args.chain_id:
4817
7750
  parser.error("feature-chain-attach-bc requires --chain-id")
@@ -4827,8 +7760,67 @@ def main() -> int:
4827
7760
  check=args.check,
4828
7761
  )
4829
7762
  return 0
7763
+ if args.command == "feature-chain-approve":
7764
+ if not args.chain_id:
7765
+ parser.error("feature-chain-approve requires --chain-id")
7766
+ feature_chain_approve(
7767
+ root,
7768
+ chain_id=args.chain_id,
7769
+ command_text=args.command_text,
7770
+ run_policy=args.run_policy,
7771
+ timeout_seconds=args.timeout_seconds,
7772
+ artifact_policy=args.artifact_policy,
7773
+ resource=args.resource,
7774
+ )
7775
+ return 0
7776
+ if args.command == "feature-chain-dry-run":
7777
+ if not args.chain_id:
7778
+ parser.error("feature-chain-dry-run requires --chain-id")
7779
+ return feature_chain_dry_run(
7780
+ root,
7781
+ chain_id=args.chain_id,
7782
+ command_text=args.command_text,
7783
+ timeout_seconds=args.timeout_seconds,
7784
+ keep_success_artifacts=args.keep_success_artifacts,
7785
+ )
7786
+ if args.command == "feature-chain-set-policy":
7787
+ if not args.chain_id:
7788
+ parser.error("feature-chain-set-policy requires --chain-id")
7789
+ feature_chain_set_policy(root, args.chain_id, args.run_policy, reason=args.reason)
7790
+ return 0
7791
+ if args.command == "feature-chain-set-checkpoint":
7792
+ if not args.chain_id:
7793
+ parser.error("feature-chain-set-checkpoint requires --chain-id")
7794
+ if not args.node_title:
7795
+ parser.error("feature-chain-set-checkpoint requires --node-title")
7796
+ if not args.required:
7797
+ parser.error("feature-chain-set-checkpoint requires --required")
7798
+ feature_chain_set_checkpoint(
7799
+ root,
7800
+ chain_id=args.chain_id,
7801
+ node_title=args.node_title,
7802
+ required_value=args.required,
7803
+ reason=args.reason,
7804
+ )
7805
+ return 0
7806
+ if args.command == "feature-chain-suggest":
7807
+ if not args.query:
7808
+ parser.error("feature-chain-suggest requires --query")
7809
+ return feature_chain_suggest(root, query=args.query)
7810
+ if args.command == "feature-chain-plan":
7811
+ if not args.query:
7812
+ parser.error("feature-chain-plan requires --query")
7813
+ return feature_chain_plan(root, query=args.query)
4830
7814
  if args.command == "feature-chain-list":
4831
- return feature_chain_list(root)
7815
+ return feature_chain_list(root, verbose=args.verbose)
7816
+ if args.command == "feature-chain-summary":
7817
+ return feature_chain_summary(root, verbose=args.verbose)
7818
+ if args.command == "feature-chain-overlap":
7819
+ return feature_chain_overlap(root, min_score=args.min_score, verbose=args.verbose)
7820
+ if args.command == "feature-chain-coverage":
7821
+ return feature_chain_coverage(root, verbose=args.verbose)
7822
+ if args.command == "feature-chain-candidates":
7823
+ return feature_chain_candidates(root, min_cases=args.min_cases, max_groups=args.max_groups)
4832
7824
  if args.command == "test-hub-list":
4833
7825
  return test_hub_list(root)
4834
7826
  if args.command == "test-hub-enable":
@@ -4837,7 +7829,7 @@ def main() -> int:
4837
7829
  test_hub_update_test(
4838
7830
  root,
4839
7831
  args.test_id,
4840
- status=args.test_status if args.test_status != "approved" else "approved",
7832
+ status=args.test_status or "approved",
4841
7833
  run_policy=args.run_policy or RUN_ALWAYS_POLICY,
4842
7834
  disabled_reason="",
4843
7835
  )
@@ -4879,8 +7871,14 @@ def main() -> int:
4879
7871
  return validate_bad_case_guards(root, strict=args.strict, verbose=args.verbose)
4880
7872
  if args.command == "validate-roadmap-maintenance":
4881
7873
  return validate_roadmap_maintenance(root, max_hidden_checkpoints=args.max_hidden_checkpoints)
7874
+ if args.command == "validate-feature-chains":
7875
+ return validate_feature_chains(root, strict=args.strict, verbose=args.verbose)
4882
7876
  return 1
4883
7877
 
4884
7878
 
4885
7879
  if __name__ == "__main__":
4886
- raise SystemExit(main())
7880
+ try:
7881
+ raise SystemExit(main())
7882
+ except (ValueError, RuntimeError) as exc:
7883
+ print(f"[context-guard] error: {exc}", file=sys.stderr)
7884
+ raise SystemExit(1)