@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
@@ -1,20 +1,22 @@
1
1
  #!/usr/bin/env python3
2
2
  """Lightweight lifecycle reminders for the Context Guard plugin.
3
3
 
4
- The hook initializes folder-scoped context on session start and nudges Codex to
5
- use the context-guard skill at the two moments where omission is most costly:
6
- prompt intake and turn stop.
4
+ The hook initializes folder-scoped context on session/subagent start and nudges
5
+ Codex to use the context-guard skill at the moments where omission is most
6
+ costly: prompt intake, turn stop, and subagent stop.
7
7
  """
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
11
  import json
12
12
  import os
13
+ import re
13
14
  import subprocess
14
15
  import sys
16
+ from datetime import datetime
15
17
  from pathlib import Path
16
18
 
17
- from context_guard import approved_dev_completion_tests, context_dir, init_context
19
+ from context_guard import approved_dev_completion_tests, context_dir, init_context, resolve_registered_subagent_root
18
20
 
19
21
 
20
22
  SKILL_ROOT = Path(__file__).resolve().parents[1]
@@ -109,7 +111,47 @@ def parse_hook_payload(raw: str) -> object:
109
111
  return {}
110
112
 
111
113
 
112
- def event_root(raw: str, cwd: Path) -> tuple[Path, str]:
114
+ def hook_agent_id(payload: object) -> str:
115
+ keys = {"agent_id", "agentId", "subagent_id", "subagentId"}
116
+ found: list[str] = []
117
+
118
+ def walk(value: object) -> None:
119
+ if isinstance(value, dict):
120
+ for key, child in value.items():
121
+ if key in keys and isinstance(child, str) and child.strip():
122
+ found.append(child.strip())
123
+ walk(child)
124
+ elif isinstance(value, list):
125
+ for child in value:
126
+ walk(child)
127
+
128
+ walk(payload)
129
+ return found[0] if found else ""
130
+
131
+
132
+ def hook_text_field(payload: object, key: str) -> str:
133
+ if not isinstance(payload, dict):
134
+ return ""
135
+ value = payload.get(key)
136
+ return value.strip() if isinstance(value, str) else ""
137
+
138
+
139
+ def registered_subagent_control_root(payload: object, cwd: Path, agent_id: str) -> Path | None:
140
+ if not agent_id:
141
+ return None
142
+ candidates = [*possible_workspace_paths(payload), cwd]
143
+ seen: set[Path] = set()
144
+ for path in candidates:
145
+ control_root = git_root(path).resolve()
146
+ if control_root in seen:
147
+ continue
148
+ seen.add(control_root)
149
+ if resolve_registered_subagent_root(control_root, agent_id):
150
+ return control_root
151
+ return None
152
+
153
+
154
+ def event_root(raw: str, cwd: Path, event: str = "") -> tuple[Path, str]:
113
155
  payload = parse_hook_payload(raw)
114
156
  candidates: list[tuple[Path, str]] = []
115
157
  for path in possible_workspace_paths(payload):
@@ -122,6 +164,13 @@ def event_root(raw: str, cwd: Path) -> tuple[Path, str]:
122
164
  candidates.append((path, f"${key}"))
123
165
  candidates.append((cwd, "process cwd"))
124
166
 
167
+ agent_id = hook_agent_id(payload) if event in {"subagent-start", "subagent-stop"} else ""
168
+ control_root = registered_subagent_control_root(payload, cwd, agent_id)
169
+ if control_root:
170
+ assigned = resolve_registered_subagent_root(control_root, agent_id)
171
+ if assigned and not is_context_guard_skill_path(assigned):
172
+ return assigned, f"registered subagent assignment ({agent_id})"
173
+
125
174
  for path, source in candidates:
126
175
  root = git_root(path)
127
176
  if not is_context_guard_skill_path(root):
@@ -147,6 +196,107 @@ def hook_response(**payload: object) -> int:
147
196
  return 0
148
197
 
149
198
 
199
+ SECRET_PATTERNS = [
200
+ re.compile(r"(?i)\b(password|passwd|pwd|token|api[_-]?key|secret|access[_-]?key|private[_-]?key)\b\s*[:=]\s*([^\s,;]+)"),
201
+ re.compile(r"\bnpm_[A-Za-z0-9]{20,}\b"),
202
+ re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{20,}\b"),
203
+ re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"),
204
+ ]
205
+ EPHEMERAL_SECRET_PATTERN = re.compile(r"(?i)\b(otp|one[- ]?time password|验证码|一次性验证码)\b\s*[:=]?\s*([0-9]{4,8})?")
206
+
207
+
208
+ def has_secret(text: str) -> bool:
209
+ return any(pattern.search(text) for pattern in SECRET_PATTERNS)
210
+
211
+
212
+ def has_ephemeral_secret(text: str) -> bool:
213
+ return bool(EPHEMERAL_SECRET_PATTERN.search(text))
214
+
215
+
216
+ def redact_user_message(text: str) -> str:
217
+ redacted = text
218
+ for pattern in SECRET_PATTERNS:
219
+ if pattern.groups >= 2:
220
+ redacted = pattern.sub(lambda m: f"{m.group(1)}=<redacted>", redacted)
221
+ else:
222
+ redacted = pattern.sub("<redacted-secret>", redacted)
223
+ redacted = EPHEMERAL_SECRET_PATTERN.sub(lambda m: f"{m.group(1)}=<redacted-ephemeral>", redacted)
224
+ return redacted
225
+
226
+
227
+ def write_private_secret(ctx: Path, raw_text: str, redacted_text: str) -> str:
228
+ private_dir = ctx / "private"
229
+ private_dir.mkdir(parents=True, exist_ok=True)
230
+ try:
231
+ private_dir.chmod(0o700)
232
+ except OSError:
233
+ pass
234
+ path = private_dir / "secrets.local.json"
235
+ try:
236
+ data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else []
237
+ except json.JSONDecodeError:
238
+ data = []
239
+ if not isinstance(data, list):
240
+ data = []
241
+ secret_id = "USER-SECRET-" + datetime.now().strftime("%Y%m%d-%H%M%S")
242
+ data.append(
243
+ {
244
+ "id": secret_id,
245
+ "created": datetime.now().isoformat(timespec="seconds"),
246
+ "redacted": redacted_text,
247
+ "raw": raw_text,
248
+ "note": "Local-only Context Guard secret memory. Do not copy into roadmap, HTML, git, logs, or final answers.",
249
+ }
250
+ )
251
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
252
+ try:
253
+ path.chmod(0o600)
254
+ except OSError:
255
+ pass
256
+ return secret_id
257
+
258
+
259
+ def append_user_message(ctx: Path, text: str) -> tuple[str, str]:
260
+ clean = text.strip()
261
+ if not clean:
262
+ return "skipped", "empty prompt"
263
+ init_context(ctx.parent.parent)
264
+ path = ctx / "user-messages.md"
265
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
266
+ is_large = len(clean) > 1600 or clean.count("\n") > 30
267
+ ephemeral = has_ephemeral_secret(clean)
268
+ secret = has_secret(clean)
269
+ redacted = redact_user_message(clean)
270
+ secret_ref = ""
271
+ if secret and not ephemeral and len(clean) <= 4000:
272
+ secret_ref = write_private_secret(ctx, clean, redacted)
273
+ if is_large:
274
+ first_line = redacted.splitlines()[0][:240]
275
+ stored = f"Large user message or attachment; first line: {first_line}"
276
+ mode = "summary"
277
+ else:
278
+ stored = redacted
279
+ mode = "verbatim" if not secret and not ephemeral else "redacted"
280
+ if ephemeral:
281
+ secret_ref = "ephemeral-not-stored"
282
+ mode = "ephemeral"
283
+ body = path.read_text(encoding="utf-8") if path.exists() else "# User Message Memory\n\n## Recent User Signals\n\n"
284
+ if stored and stored in body[-4000:]:
285
+ return "skipped", "duplicate recent message"
286
+ entry = [
287
+ f"\n### {now}",
288
+ f"- Mode: {mode}",
289
+ f"- User message: {stored}",
290
+ ]
291
+ if secret_ref:
292
+ entry.append(f"- Secret pointer: {secret_ref}")
293
+ entry.append("- Use: Preserve this wording when deciding task direction, constraints, credentials, preferences, bad-case intake, or roadmap `User request` fields.")
294
+ path.parent.mkdir(parents=True, exist_ok=True)
295
+ with path.open("a", encoding="utf-8") as handle:
296
+ handle.write("\n".join(entry) + "\n")
297
+ return "recorded", mode
298
+
299
+
150
300
  def run_test_hub_completion(root: Path) -> tuple[int, str]:
151
301
  ctx = context_dir(root)
152
302
  tests = approved_dev_completion_tests(ctx)
@@ -182,6 +332,43 @@ def completion_test_summary(output: str, code: int) -> str:
182
332
  return "test hub status unknown; inspect `.codex/context/test-hub/last-run.json`"
183
333
 
184
334
 
335
+ def completion_test_failure_details(root: Path, limit: int = 3) -> str:
336
+ path = context_dir(root) / "test-hub" / "last-run.json"
337
+ if not path.exists():
338
+ return ""
339
+ try:
340
+ data = json.loads(path.read_text(encoding="utf-8"))
341
+ except Exception:
342
+ return ""
343
+ results = data.get("results", [])
344
+ if not isinstance(results, list):
345
+ return ""
346
+ not_passing = [
347
+ item
348
+ for item in results
349
+ if isinstance(item, dict) and str(item.get("status", "")).strip().lower() in {"failed", "blocked"}
350
+ ]
351
+ if not not_passing:
352
+ return ""
353
+
354
+ pieces: list[str] = []
355
+ for item in not_passing[:limit]:
356
+ title = str(item.get("title") or item.get("id") or "unnamed test").strip()
357
+ status = str(item.get("status") or "failed").strip()
358
+ reason = str(item.get("reason") or "no reason recorded").strip()
359
+ log = str(item.get("log") or "").strip()
360
+ if log:
361
+ try:
362
+ log = str(Path(log).resolve().relative_to(root.resolve()))
363
+ except Exception:
364
+ pass
365
+ suffix = f"; log: {log}" if log else ""
366
+ pieces.append(f"{status}: {title} — {reason}{suffix}")
367
+ if len(not_passing) > limit:
368
+ pieces.append(f"+{len(not_passing) - limit} more; inspect `.codex/context/test-hub/last-run.json`")
369
+ return "; ".join(pieces)
370
+
371
+
185
372
  def prompt_text(raw: str) -> str:
186
373
  if not raw.strip():
187
374
  return ""
@@ -322,6 +509,53 @@ def looks_like_test_creation(text: str) -> bool:
322
509
  return any(marker in lowered for marker in creation_markers) and any(marker in lowered for marker in test_markers)
323
510
 
324
511
 
512
+ def looks_like_test_opportunity(text: str) -> bool:
513
+ lowered = text.lower()
514
+ opportunity_markers = [
515
+ "fix",
516
+ "bug",
517
+ "regression",
518
+ "workflow",
519
+ "flow",
520
+ "e2e",
521
+ "integration",
522
+ "ui",
523
+ "html",
524
+ "browser",
525
+ "frontend",
526
+ "backend",
527
+ "api",
528
+ "service",
529
+ "deploy",
530
+ "release",
531
+ "refactor",
532
+ "goal mode",
533
+ "long-running",
534
+ "修复",
535
+ "bug",
536
+ "问题",
537
+ "复发",
538
+ "回归",
539
+ "流程",
540
+ "链路",
541
+ "前端",
542
+ "后端",
543
+ "接口",
544
+ "服务",
545
+ "部署",
546
+ "发布",
547
+ "重构",
548
+ "页面",
549
+ "浏览器",
550
+ "远程",
551
+ "服务器",
552
+ "goal 模式",
553
+ "目标模式",
554
+ "长期",
555
+ ]
556
+ return any(marker in lowered for marker in opportunity_markers)
557
+
558
+
325
559
  def looks_like_explicit_branch(text: str) -> bool:
326
560
  lowered = text.lower()
327
561
  markers = [
@@ -414,7 +648,7 @@ def format_unresolved_bad_cases(cases: list[dict[str, str]], limit: int = 5) ->
414
648
  def main() -> int:
415
649
  event = sys.argv[1] if len(sys.argv) > 1 else "unknown"
416
650
  raw = read_stdin()
417
- root, root_source = event_root(raw, Path.cwd())
651
+ root, root_source = event_root(raw, Path.cwd(), event)
418
652
  context_dir = root / ".codex" / "context"
419
653
  index_path = context_dir / "index.md"
420
654
  roadmap_path = context_dir / "roadmap.md"
@@ -430,25 +664,40 @@ def main() -> int:
430
664
  hook_log(f"[context-guard] apparent root source: {root_source}; apparent root: {root}")
431
665
  return hook_response()
432
666
 
433
- if event == "session-start":
667
+ if event in {"session-start", "subagent-start"}:
434
668
  created = init_context(root)
669
+ label = "subagent context" if event == "subagent-start" else "folder context"
435
670
  if created:
436
- hook_log(f"[context-guard] initialized folder context: {context_dir}")
671
+ hook_log(f"[context-guard] initialized {label}: {context_dir}")
437
672
  else:
438
- hook_log(f"[context-guard] folder context ready: {context_dir}")
673
+ hook_log(f"[context-guard] {label} ready: {context_dir}")
439
674
  hook_log(f"[context-guard] project root: {root} ({root_source})")
440
675
  hook_log("[context-guard] context location rule: save project context only under `<opened local Codex project root>/.codex/context/`.")
441
676
  hook_log("[context-guard] use .codex/context/index.md for quick scan and .codex/context/roadmap.md for route nodes.")
442
677
  return hook_response()
443
678
 
444
679
  if event == "user-prompt-submit":
680
+ record_status, record_mode = append_user_message(context_dir, text)
445
681
  hints: list[str] = []
682
+ if record_status == "recorded":
683
+ if record_mode == "redacted":
684
+ hints.append("user message memory: saved latest prompt with secrets redacted; raw secret, if durable, is local-only under `.codex/context/private/`")
685
+ elif record_mode == "ephemeral":
686
+ hints.append("user message memory: saved latest prompt with one-time code redacted; raw ephemeral code was not persisted")
687
+ elif record_mode == "summary":
688
+ hints.append("user message memory: saved a concise summary of the latest large prompt instead of copying the full blob")
689
+ else:
690
+ hints.append("user message memory: saved latest short user prompt in `.codex/context/user-messages.md`")
691
+ else:
692
+ hints.append(f"user message memory: skipped ({record_mode})")
446
693
  if looks_like_goal_mode(text):
447
694
  hints.append("goal mode: align active goal with current context and record roadmap/bad-case checkpoints during long-running work")
448
695
  if looks_like_remote_work(text):
449
696
  hints.append("remote/SSH work: keep `.codex/context` in the local Codex workspace; record remote host/path as metadata and do not initialize roadmap context on the server unless explicitly requested")
450
697
  if looks_like_test_creation(text):
451
698
  hints.append("explicit test creation: start the user-visible response with `测试创建识别:...`, summarize the test target from state A to state B, and only create durable tests after the user's design is clear or confirmed")
699
+ elif looks_like_test_opportunity(text):
700
+ hints.append("test opportunity: if this task changes a reusable workflow, fixes a recurring/user-visible bug, or is likely to regress, gently ask whether the user wants to create a test task; keep it optional and do not create durable tests without approval")
452
701
  if looks_like_explicit_branch(text):
453
702
  hints.append("explicit branch task: create/select a branch task by running `context_guard.py create-branch-task --title <task title> --branch <branch name> --parent-node <parent NODE id>` before implementation; verify the roadmap node has Branch: and Parent:")
454
703
  elif looks_like_route_drift(text):
@@ -467,15 +716,17 @@ def main() -> int:
467
716
  hook_log(f"[context-guard] route map: {roadmap_path}")
468
717
  return hook_response()
469
718
 
470
- if event == "stop":
471
- hook_log("[context-guard] run turn-end checkpoint before finalizing or updating a goal: update index, route map nodes, parked/resume tasks, and relevant bad-case/test-chain links.")
719
+ if event in {"stop", "subagent-stop"}:
720
+ lifecycle_label = "SubagentStop" if event == "subagent-stop" else "Stop"
721
+ hook_log(f"[context-guard] {lifecycle_label} checkpoint: update index, route map nodes, parked/resume tasks, and relevant bad-case/test-chain links before finalizing.")
472
722
  hook_log("[context-guard] COMPLETION RELIABILITY GATE: use existing user screenshots/logs/reproductions as red evidence when available; implement once the cause is clear, then run the smallest real post-fix check. Default budget is one primary check plus at most two highly relevant bad-case guards.")
473
723
  hook_log("[context-guard] BAD-CASE GUARD GATE: newly checked resolved or recurred BC entries need Guard type, Red condition, Green condition, Expected failure reason, and a red-capable Guard / verification; run `context_guard.py validate-bad-cases` only after register/schema/renderer edits, or `--strict` when intentionally migrating/checking all resolved cases.")
474
724
  hook_log("[context-guard] GUARD SELECTION GATE: do not run every historical guard and do not manufacture new red tests when credible evidence already exists. Select guards by changed files, feature area, route branch, tags, and original user-visible symptom; skip unrelated resolved cases.")
475
- hook_log("[context-guard] TEST HUB GATE: Stop hook runs `context_guard.py dev-complete --root <project>` so the hub executes every human-approved `every-dev-completion` test, cleans success artifacts, and preserves failed/blocked evidence. Do not treat ordinary bad-case guards or roadmap Test chain notes as registered tests.")
725
+ hook_log("[context-guard] TEST HUB GATE: Stop/SubagentStop hooks run `context_guard.py dev-complete --root <project>` so the hub executes every human-approved `every-dev-completion` test, cleans success artifacts, and preserves failed/blocked evidence. Do not treat ordinary bad-case guards or roadmap Test chain notes as registered tests.")
476
726
  hook_log("[context-guard] TASK-CASE GATE: when a workflow has multiple phases, prefer one relevant task case from `.codex/context/task-cases/` with phase/checkpoint logs over many isolated bug-level tests; report the failed phase/checkpoint if it breaks.")
477
727
  hook_log("[context-guard] TEST BLOCKER GATE: if approved tests are blocked by credentials, external service outage, permission denial, hardware/resource limits, network, destructive-risk confirmation, or user-only judgment, stop and ask/warn the user with the exact blocker and evidence path.")
478
728
  hook_log("[context-guard] TASK-CASE DESIGN GATE: before writing a new durable task-case script for a complex workflow, ask the user to confirm a short business-facing proposal: from what state to what state, main task, and major risk; keep technical details inside the task-case file, or keep it `proposed` if unavailable.")
729
+ hook_log("[context-guard] TEST OPPORTUNITY GATE: if this turn changed a reusable workflow, fixed a recurring/user-visible bug, or created a phase that is likely to regress, include a brief optional nudge asking whether the user wants to create a test task. Do not create durable tests unless the user confirms.")
479
730
  hook_log("[context-guard] GOAL-MODE TEST GATE: in goal mode, use task cases as phase gates; log current phase progress and run the smallest approved path before claiming goal completion instead of silently creating broad new tests.")
480
731
  hook_log("[context-guard] ROADMAP CHECKPOINT GATE: assess whether this turn deserves a roadmap node. Create one only for meaningful progress, a route decision, a fix, a branch/fork, a user-visible milestone, or stale hidden checkpoints; otherwise say no roadmap node was needed and why.")
481
732
  hook_log("[context-guard] If a node is needed, run `context_guard.py checkpoint-roadmap-node --title <short title> --branch <Main or route> --level <major|checkpoint> --outcome <one-line progress> --next-step <next>` and include linked BC/test-chain notes when relevant.")
@@ -487,6 +738,59 @@ def main() -> int:
487
738
  hook_log(f"[context-guard] project root: {root}")
488
739
  hook_log(f"[context-guard] context folder: {context_dir}")
489
740
  hook_log(f"[context-guard] bad-case register: {bad_cases_path}")
741
+ if event == "subagent-stop":
742
+ payload = parse_hook_payload(raw)
743
+ agent_id = hook_agent_id(payload)
744
+ last_message = hook_text_field(payload, "last_assistant_message")
745
+ control_root = registered_subagent_control_root(payload, Path.cwd(), agent_id)
746
+ command_root = control_root or root
747
+ command = [
748
+ sys.executable,
749
+ str(SKILL_ROOT / "scripts" / "context_guard.py"),
750
+ "subagent-complete",
751
+ "--root",
752
+ str(command_root),
753
+ "--agent-id",
754
+ agent_id,
755
+ "--summary",
756
+ last_message,
757
+ ]
758
+ try:
759
+ completed = subprocess.run(
760
+ command,
761
+ cwd=str(root),
762
+ text=True,
763
+ capture_output=True,
764
+ timeout=900,
765
+ )
766
+ except subprocess.TimeoutExpired:
767
+ reason = "Context Guard subagent completion timed out after 900s."
768
+ hook_log(f"[context-guard] TEST HUB BLOCKER: {reason}")
769
+ return hook_response(decision="block", reason=reason)
770
+ except Exception as exc:
771
+ reason = f"Context Guard could not run subagent completion: {exc}"
772
+ hook_log(f"[context-guard] COMPLETION BLOCKER: {reason}")
773
+ return hook_response(decision="block", reason=reason)
774
+ completion_output = "\n".join(
775
+ part for part in [completed.stdout.strip(), completed.stderr.strip()] if part
776
+ )
777
+ for line in completion_output.splitlines():
778
+ hook_log(line)
779
+ hook_log(
780
+ "[context-guard] final answer must include Test Hub summary: "
781
+ + completion_test_summary(completion_output, completed.returncode)
782
+ )
783
+ if completed.returncode != 0:
784
+ details = completion_test_failure_details(root)
785
+ reason = "Context Guard subagent completion found failed or blocked approved tests."
786
+ if details:
787
+ reason += " Failing tests: " + details
788
+ hook_log(f"[context-guard] TEST HUB BLOCKER: {reason}")
789
+ return hook_response(decision="block", reason=reason)
790
+ open_cases = unresolved_bad_cases(bad_cases_path)
791
+ hook_log("[context-guard] final answer must include BC summary: archived/updated BC this turn, and current unresolved BC.")
792
+ hook_log(f"[context-guard] current unresolved BC: {format_unresolved_bad_cases(open_cases)}")
793
+ return hook_response()
490
794
  try:
491
795
  test_code, test_output = run_test_hub_completion(root)
492
796
  for line in test_output.splitlines():
@@ -496,11 +800,16 @@ def main() -> int:
496
800
  + completion_test_summary(test_output, test_code)
497
801
  )
498
802
  if test_code != 0:
803
+ details = completion_test_failure_details(root)
804
+ if details:
805
+ hook_log("[context-guard] failing approved test details: " + details)
499
806
  reason = (
500
807
  "Context Guard Test Hub found failed or blocked approved tests. "
501
808
  "Read `.codex/context/test-hub/last-run.json` and preserved run evidence, "
502
809
  "then fix or report the blocker before finalizing."
503
810
  )
811
+ if details:
812
+ reason += " Failing tests: " + details
504
813
  hook_log(f"[context-guard] TEST HUB BLOCKER: {reason}")
505
814
  return hook_response(decision="block", reason=reason)
506
815
  except subprocess.TimeoutExpired:
@@ -509,6 +818,29 @@ def main() -> int:
509
818
  return hook_response(decision="block", reason=reason)
510
819
  except Exception as exc:
511
820
  hook_log(f"[context-guard] test hub hook warning: {exc}")
821
+ try:
822
+ auto_propose = subprocess.run(
823
+ [
824
+ sys.executable,
825
+ str(SKILL_ROOT / "scripts" / "context_guard.py"),
826
+ "feature-chain-auto-propose",
827
+ "--root",
828
+ str(root),
829
+ "--from-hook",
830
+ ],
831
+ cwd=str(root),
832
+ text=True,
833
+ capture_output=True,
834
+ timeout=10,
835
+ )
836
+ output = (auto_propose.stdout + auto_propose.stderr).strip()
837
+ if output:
838
+ for line in output.splitlines():
839
+ hook_log(line)
840
+ if auto_propose.returncode != 0:
841
+ hook_log(f"[context-guard] feature-chain auto-propose warning: exit {auto_propose.returncode}")
842
+ except Exception as exc:
843
+ hook_log(f"[context-guard] feature-chain auto-propose warning: {exc}")
512
844
  open_cases = unresolved_bad_cases(bad_cases_path)
513
845
  hook_log("[context-guard] final answer must include BC summary: archived/updated BC this turn, and current unresolved BC.")
514
846
  hook_log(f"[context-guard] current unresolved BC: {format_unresolved_bad_cases(open_cases)}")
@@ -46,11 +46,11 @@ cat > "$CTX/bad-cases.md" <<'MD'
46
46
  - Phenomenon: 普通 bad case guard 被误当成用户测试。
47
47
  - Guard / verification: 这是普通复发检查,不是用户批准测试。
48
48
 
49
- ### BC-20260701-002: 用户批准测试应显示
49
+ ### BC-20260701-002: 用户批准测试也不应显示在概览
50
50
  - Status: resolved
51
51
  - Roadmap nodes: NODE-20260701-002
52
52
  - Tags: #roadmap-ux
53
- - Phenomenon: 用户批准的测试需要显示在线路上。
53
+ - Phenomenon: 用户批准的测试不应在路线图概览下方形成第二行卡片。
54
54
  - Guard / verification: 运行用户确认的测试。
55
55
  - Run policy: every-dev-completion
56
56
  MD
@@ -73,11 +73,12 @@ html = Path(sys.argv[1]).read_text()
73
73
  body = html.split("</style>", 1)[1]
74
74
  notes = re.findall(r'<a class="route-test-note"[^>]*>(.*?)</a>', body, re.S)
75
75
  joined = "\n".join(notes)
76
- if "用户批准测试应显示" not in joined:
77
- raise SystemExit("approved test did not appear in the route test line")
78
76
  if "普通 bad case guard 不应显示为测试" in joined:
79
77
  raise SystemExit("ordinary linked bad-case guard appeared as a test route item")
80
- test_items = len(notes)
81
- if test_items != 1:
82
- raise SystemExit(f"expected exactly one approved test route item, got {test_items}")
78
+ if "用户批准测试也不应显示在概览" in joined:
79
+ raise SystemExit("approved test appeared as a compact overview route item")
80
+ if notes:
81
+ raise SystemExit(f"overview should not render compact route test notes, got {len(notes)}")
82
+ if 'class="route-test-line' in body:
83
+ raise SystemExit("overview should not render compact route test line containers")
83
84
  PY
@@ -11,9 +11,7 @@ ADD_OUTPUT="$(python3 "$SCRIPT" feature-chain-add \
11
11
  --root "$ROOT" \
12
12
  --title "GPU 监控按钮" \
13
13
  --entry "点击 GPU 监控按钮" \
14
- --exit-check "打开包含有效 grafana_url 的监控页" \
15
- --command-text "printf 'feature-chain-ok\n'" \
16
- --test-status approved)"
14
+ --exit-check "打开包含有效 grafana_url 的监控页")"
17
15
 
18
16
  CHAIN_ID="$(printf '%s\n' "$ADD_OUTPUT" | awk '/feature chain:/ {print $NF}')"
19
17
  test -n "$CHAIN_ID"
@@ -25,6 +23,11 @@ python3 "$SCRIPT" feature-chain-attach-bc \
25
23
  --bad-case "BC-20260702-000" \
26
24
  --check "grafana_url 不为空,前端不会卡住" >/dev/null
27
25
 
26
+ python3 "$SCRIPT" feature-chain-approve \
27
+ --root "$ROOT" \
28
+ --chain-id "$CHAIN_ID" \
29
+ --command-text "printf 'CG_CHECKPOINT:后端返回监控 URL:PASS\n'" >/dev/null
30
+
28
31
  python3 "$SCRIPT" feature-chain-list --root "$ROOT" | grep -q "BC-20260702-000"
29
32
 
30
33
  python3 - "$ROOT/.codex/context/test-hub/feature-chains.json" "$CHAIN_ID" <<'PY'
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/scripts/context_guard.py}"
5
+ ROOT="$(mktemp -d "${TMPDIR:-/tmp}/context-guard-feature-chain-method-XXXXXX")"
6
+ trap 'rm -rf "$ROOT"' EXIT
7
+
8
+ python3 "$SCRIPT" init --root "$ROOT" >/dev/null
9
+
10
+ ADD_OUTPUT="$(python3 "$SCRIPT" feature-chain-add \
11
+ --root "$ROOT" \
12
+ --title "Markdown 公式预览" \
13
+ --entry "用户输入包含单行和多行公式的 Markdown" \
14
+ --exit-check "预览区展示 MathJax 渲染结果且没有空白或报错")"
15
+
16
+ CHAIN_ID="$(printf '%s\n' "$ADD_OUTPUT" | awk '/feature chain:/ {print $NF}')"
17
+ test -n "$CHAIN_ID"
18
+
19
+ python3 "$SCRIPT" feature-chain-attach-bc \
20
+ --root "$ROOT" \
21
+ --chain-id "$CHAIN_ID" \
22
+ --node-title "公式输入进入预览流程" \
23
+ --bad-case "BC-20260706-001" \
24
+ --check "输入包含 $ 和 $$ 公式时预览流程没有丢弃公式文本" >/dev/null
25
+
26
+ python3 "$SCRIPT" feature-chain-attach-bc \
27
+ --root "$ROOT" \
28
+ --chain-id "$CHAIN_ID" \
29
+ --node-title "MathJax 渲染完成" \
30
+ --bad-case "BC-20260706-002" \
31
+ --check "渲染完成后页面不显示原始 LaTeX、空白或错误占位" >/dev/null
32
+
33
+ python3 "$SCRIPT" feature-chain-attach-bc \
34
+ --root "$ROOT" \
35
+ --chain-id "$CHAIN_ID" \
36
+ --node-title "MathJax 渲染完成" \
37
+ --bad-case "BC-20260706-003" \
38
+ --check "重复开发后单行、多行、矩阵公式仍共用同一预览链路验证" >/dev/null
39
+
40
+ COMMAND="tmp=.codex/context/test-hub/feature-chain-evidence.tmp; printf 'CG_CHECKPOINT:公式输入进入预览流程:PASS\nCG_CHECKPOINT:MathJax 渲染完成:PASS\n' > \"\$tmp\"; cat \"\$tmp\"; rm -f \"\$tmp\""
41
+ python3 "$SCRIPT" feature-chain-approve \
42
+ --root "$ROOT" \
43
+ --chain-id "$CHAIN_ID" \
44
+ --command-text "$COMMAND" >/dev/null
45
+
46
+ python3 "$SCRIPT" feature-chain-list --root "$ROOT" | grep -q "covers BC-20260706-001, BC-20260706-002, BC-20260706-003"
47
+
48
+ python3 - "$ROOT/.codex/context/test-hub/feature-chains.json" "$CHAIN_ID" <<'PY'
49
+ from pathlib import Path
50
+ import json
51
+ import sys
52
+
53
+ data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
54
+ chain = next(item for item in data["chains"] if item["id"] == sys.argv[2])
55
+ assert chain["title"] == "Markdown 公式预览"
56
+ assert len(chain["nodes"]) == 2
57
+ covered = sorted(bc for node in chain["nodes"] for bc in node["bad_cases"])
58
+ assert covered == ["BC-20260706-001", "BC-20260706-002", "BC-20260706-003"]
59
+ assert chain["run_policy"] == "every-dev-completion"
60
+ assert chain["status"] == "approved"
61
+ PY
62
+
63
+ RUN_OUTPUT="$(python3 "$SCRIPT" dev-complete --root "$ROOT")"
64
+ printf '%s\n' "$RUN_OUTPUT" | grep -q "1 passed, 0 failed, 0 blocked"
65
+ printf '%s\n' "$RUN_OUTPUT" | grep -q "success artifacts cleaned"
66
+ test ! -e "$ROOT/.codex/context/test-hub/feature-chain-evidence.tmp"
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/scripts/context_guard.py"
5
+ ROOT="$(mktemp -d "${TMPDIR:-/tmp}/context-guard-feature-chain-validate-XXXXXX")"
6
+ trap 'rm -rf "$ROOT"' EXIT
7
+ OUT="$ROOT/validate.out"
8
+
9
+ python3 "$SCRIPT" init --root "$ROOT" >/dev/null
10
+
11
+ ADD_OUTPUT="$(python3 "$SCRIPT" feature-chain-add \
12
+ --root "$ROOT" \
13
+ --title "Markdown 公式预览" \
14
+ --entry "用户输入 Markdown 公式" \
15
+ --exit-check "预览区显示 MathJax 渲染结果" \
16
+ --command-text "printf 'ok\n'")"
17
+
18
+ CHAIN_ID="$(printf '%s\n' "$ADD_OUTPUT" | sed -n 's/.*feature chain: //p')"
19
+
20
+ python3 - "$ROOT/.codex/context/test-hub/feature-chains.json" "$CHAIN_ID" <<'PY'
21
+ from pathlib import Path
22
+ import json
23
+ import sys
24
+ path = Path(sys.argv[1])
25
+ chain_id = sys.argv[2]
26
+ data = json.loads(path.read_text(encoding="utf-8"))
27
+ chain = next(item for item in data["chains"] if item["id"] == chain_id)
28
+ chain["status"] = "approved"
29
+ chain["type"] = "command"
30
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
31
+ PY
32
+
33
+ if python3 "$SCRIPT" validate-feature-chains --root "$ROOT" >"$OUT" 2>&1; then
34
+ cat "$OUT"
35
+ exit 1
36
+ fi
37
+ grep -q "approved every-dev-completion chain must have at least one checkpoint node" "$OUT"
38
+
39
+ python3 "$SCRIPT" feature-chain-attach-bc \
40
+ --root "$ROOT" \
41
+ --chain-id "$CHAIN_ID" \
42
+ --node-title "MathJax 渲染完成" \
43
+ --bad-case "BC-20260707-099" \
44
+ --check "预览区包含渲染后的公式且没有空白" >/dev/null
45
+
46
+ python3 "$SCRIPT" validate-feature-chains --root "$ROOT" >"$OUT"
47
+ grep -q "feature-chain validation passed: 1 chain(s), 0 warning(s)" "$OUT"