@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.
- package/README.md +74 -6
- package/README.zh-CN.md +74 -6
- package/{skills/context-guard/SKILL.md → SKILL.md} +216 -77
- package/bin/context-guard-skill.js +88 -3
- package/hooks.json +25 -1
- package/package.json +7 -3
- package/{skills/context-guard/references → references}/context-template.md +52 -1
- package/references/feature-chain-methodology.md +228 -0
- package/{skills/context-guard/scripts → scripts}/context_guard.py +3119 -121
- package/{skills/context-guard/scripts → scripts}/context_guard_hook.py +344 -12
- package/{skills/context-guard/tests → tests}/BC-20260701-090.sh +8 -7
- package/{skills/context-guard/tests → tests}/BC-20260702-096.sh +6 -3
- package/tests/BC-20260706-098.sh +66 -0
- package/tests/BC-20260707-099.sh +47 -0
- package/tests/BC-20260707-100.sh +46 -0
- package/tests/BC-20260707-101.sh +47 -0
- package/tests/BC-20260707-102.sh +68 -0
- package/tests/BC-20260707-103.sh +59 -0
- package/tests/BC-20260707-104.sh +103 -0
- package/tests/BC-20260707-105.sh +109 -0
- package/tests/BC-20260707-106.sh +80 -0
- package/tests/BC-20260707-107.sh +74 -0
- package/tests/BC-20260707-108.sh +48 -0
- package/tests/BC-20260707-109.sh +56 -0
- package/tests/BC-20260707-110.sh +71 -0
- package/tests/BC-20260707-111.sh +70 -0
- package/tests/BC-20260707-112.sh +45 -0
- package/tests/BC-20260707-113.sh +73 -0
- package/tests/BC-20260707-115.sh +77 -0
- package/tests/BC-20260707-116.sh +77 -0
- package/tests/BC-20260707-118.sh +115 -0
- package/tests/BC-20260707-119.sh +47 -0
- package/tests/BC-20260707-120.sh +60 -0
- package/tests/BC-20260707-121.sh +66 -0
- package/tests/BC-20260707-122.sh +48 -0
- package/tests/BC-20260707-123.sh +43 -0
- package/tests/BC-20260707-124.sh +56 -0
- package/tests/BC-20260707-125.sh +64 -0
- package/tests/BC-20260707-126.sh +80 -0
- package/tests/BC-20260707-127.sh +88 -0
- package/tests/BC-20260707-129.sh +59 -0
- package/tests/BC-20260707-130.sh +69 -0
- package/tests/BC-20260707-131.sh +140 -0
- package/tests/BC-20260707-132.sh +150 -0
- package/tests/BC-20260707-133.sh +70 -0
- package/tests/BC-20260708-136.sh +210 -0
- package/tests/BC-20260708-137.sh +106 -0
- package/tests/BC-20260708-138.sh +168 -0
- package/tests/BC-20260708-139.sh +79 -0
- package/tests/BC-20260709-002.sh +63 -0
- package/tests/BC-20260709-003.sh +239 -0
- package/tests/BC-20260709-006.sh +76 -0
- package/tests/BC-20260709-008.sh +168 -0
- package/tests/BC-20260710-001.sh +61 -0
- package/tests/BC-20260710-002.sh +111 -0
- package/tests/npm-install-smoke.sh +53 -0
- package/skills/context-guard/README.md +0 -234
- package/skills/context-guard/README.zh-CN.md +0 -234
- /package/{skills/context-guard/agents → agents}/openai.yaml +0 -0
- /package/{skills/context-guard/references → references}/register-template.md +0 -0
- /package/{skills/context-guard/references → references}/task-case-template.md +0 -0
- /package/{skills/context-guard/tests → tests}/BC-20260618-063.sh +0 -0
- /package/{skills/context-guard/tests → tests}/BC-20260618-065.sh +0 -0
- /package/{skills/context-guard/tests → tests}/BC-20260626-080.sh +0 -0
- /package/{skills/context-guard/tests → tests}/BC-20260626-081.sh +0 -0
- /package/{skills/context-guard/tests → tests}/BC-20260626-082.sh +0 -0
- /package/{skills/context-guard/tests → tests}/BC-20260626-083.sh +0 -0
- /package/{skills/context-guard/tests → tests}/BC-20260627-084.sh +0 -0
- /package/{skills/context-guard/tests → tests}/BC-20260630-086.sh +0 -0
- /package/{skills/context-guard/tests → tests}/BC-20260630-087.sh +0 -0
- /package/{skills/context-guard/tests → tests}/BC-20260630-088.sh +0 -0
- /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
|
|
5
|
-
use the context-guard skill at the
|
|
6
|
-
prompt intake and
|
|
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
|
|
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
|
|
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
|
|
671
|
+
hook_log(f"[context-guard] initialized {label}: {context_dir}")
|
|
437
672
|
else:
|
|
438
|
-
hook_log(f"[context-guard]
|
|
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
|
|
471
|
-
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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"
|