@michelj/context-guard 0.4.3 → 0.4.4

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 (74) hide show
  1. package/README.md +103 -208
  2. package/README.zh-CN.md +103 -208
  3. package/SKILL.md +27 -678
  4. package/agents/openai.yaml +2 -2
  5. package/bin/context-guard-skill.js +159 -56
  6. package/bin/postinstall.js +1 -1
  7. package/hooks.json +11 -11
  8. package/package.json +9 -6
  9. package/prototype/workbench.html +4933 -0
  10. package/references/bug-record-template.md +37 -0
  11. package/references/context-template.md +14 -336
  12. package/scripts/context_guard.py +577 -7655
  13. package/scripts/context_guard_hook.py +179 -731
  14. package/scripts/map_owns.py +769 -0
  15. package/references/feature-chain-methodology.md +0 -228
  16. package/references/register-template.md +0 -85
  17. package/references/task-case-template.md +0 -63
  18. package/tests/BC-20260618-063.sh +0 -116
  19. package/tests/BC-20260618-065.sh +0 -66
  20. package/tests/BC-20260626-080.sh +0 -48
  21. package/tests/BC-20260626-081.sh +0 -40
  22. package/tests/BC-20260626-082.sh +0 -32
  23. package/tests/BC-20260626-083.sh +0 -66
  24. package/tests/BC-20260627-084.sh +0 -74
  25. package/tests/BC-20260630-086.sh +0 -50
  26. package/tests/BC-20260630-087.sh +0 -103
  27. package/tests/BC-20260630-088.sh +0 -32
  28. package/tests/BC-20260630-089.sh +0 -63
  29. package/tests/BC-20260701-090.sh +0 -84
  30. package/tests/BC-20260702-096.sh +0 -48
  31. package/tests/BC-20260706-098.sh +0 -66
  32. package/tests/BC-20260707-099.sh +0 -47
  33. package/tests/BC-20260707-100.sh +0 -46
  34. package/tests/BC-20260707-101.sh +0 -47
  35. package/tests/BC-20260707-102.sh +0 -68
  36. package/tests/BC-20260707-103.sh +0 -59
  37. package/tests/BC-20260707-104.sh +0 -103
  38. package/tests/BC-20260707-105.sh +0 -109
  39. package/tests/BC-20260707-106.sh +0 -80
  40. package/tests/BC-20260707-107.sh +0 -74
  41. package/tests/BC-20260707-108.sh +0 -48
  42. package/tests/BC-20260707-109.sh +0 -56
  43. package/tests/BC-20260707-110.sh +0 -71
  44. package/tests/BC-20260707-111.sh +0 -70
  45. package/tests/BC-20260707-112.sh +0 -45
  46. package/tests/BC-20260707-113.sh +0 -73
  47. package/tests/BC-20260707-115.sh +0 -77
  48. package/tests/BC-20260707-116.sh +0 -77
  49. package/tests/BC-20260707-118.sh +0 -115
  50. package/tests/BC-20260707-119.sh +0 -47
  51. package/tests/BC-20260707-120.sh +0 -60
  52. package/tests/BC-20260707-121.sh +0 -66
  53. package/tests/BC-20260707-122.sh +0 -48
  54. package/tests/BC-20260707-123.sh +0 -43
  55. package/tests/BC-20260707-124.sh +0 -56
  56. package/tests/BC-20260707-125.sh +0 -64
  57. package/tests/BC-20260707-126.sh +0 -80
  58. package/tests/BC-20260707-127.sh +0 -88
  59. package/tests/BC-20260707-129.sh +0 -59
  60. package/tests/BC-20260707-130.sh +0 -69
  61. package/tests/BC-20260707-131.sh +0 -140
  62. package/tests/BC-20260707-132.sh +0 -150
  63. package/tests/BC-20260707-133.sh +0 -70
  64. package/tests/BC-20260708-136.sh +0 -210
  65. package/tests/BC-20260708-137.sh +0 -106
  66. package/tests/BC-20260708-138.sh +0 -168
  67. package/tests/BC-20260708-139.sh +0 -79
  68. package/tests/BC-20260709-002.sh +0 -63
  69. package/tests/BC-20260709-003.sh +0 -239
  70. package/tests/BC-20260709-006.sh +0 -76
  71. package/tests/BC-20260709-008.sh +0 -168
  72. package/tests/BC-20260710-001.sh +0 -61
  73. package/tests/BC-20260710-002.sh +0 -111
  74. package/tests/npm-install-smoke.sh +0 -53
@@ -1,25 +1,21 @@
1
1
  #!/usr/bin/env python3
2
- """Lightweight lifecycle reminders for the Context Guard plugin.
3
-
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
- """
2
+ """Normalize Codex, Cursor, and Claude lifecycle hooks for Context Guard."""
8
3
 
9
4
  from __future__ import annotations
10
5
 
6
+ import argparse
11
7
  import json
12
8
  import os
13
- import re
14
- import subprocess
15
9
  import sys
16
- from datetime import datetime
17
- from pathlib import Path
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path, PureWindowsPath
18
12
 
19
- from context_guard import approved_dev_completion_tests, context_dir, init_context, resolve_registered_subagent_root
13
+ from context_guard import append_session_event
14
+ from context_guard import context_dir as context_folder
15
+ from context_guard import configure_stdio, folder_root, init_context, is_context_guard_skill_path
16
+ from context_guard import read_preferences, start_workbench
20
17
 
21
18
 
22
- SKILL_ROOT = Path(__file__).resolve().parents[1]
23
19
  WORKSPACE_KEYS = {
24
20
  "cwd",
25
21
  "current_working_directory",
@@ -28,6 +24,8 @@ WORKSPACE_KEYS = {
28
24
  "workspace_root",
29
25
  "workspaceFolder",
30
26
  "workspace_folder",
27
+ "workspace_roots",
28
+ "workspaceRoots",
31
29
  "project",
32
30
  "project_root",
33
31
  "projectRoot",
@@ -41,62 +39,45 @@ WORKSPACE_ENV_KEYS = [
41
39
  "CODEX_WORKSPACE_ROOT",
42
40
  "CODEX_PROJECT_ROOT",
43
41
  "CODEX_CWD",
42
+ "CURSOR_PROJECT_DIR",
43
+ "CURSOR_WORKSPACE_ROOT",
44
+ "CLAUDE_PROJECT_DIR",
45
+ "CLAUDE_WORKSPACE_ROOT",
44
46
  "WORKSPACE_ROOT",
45
47
  "PROJECT_ROOT",
46
48
  "PWD",
47
49
  ]
48
50
 
49
51
 
50
- def is_inside(path: Path, parent: Path) -> bool:
51
- try:
52
- path.resolve().relative_to(parent.resolve())
53
- return True
54
- except ValueError:
55
- return False
56
-
57
-
58
- def is_context_guard_skill_path(path: Path) -> bool:
59
- return is_inside(path, SKILL_ROOT)
60
-
61
-
62
- def git_root(cwd: Path) -> Path:
63
- try:
64
- out = subprocess.check_output(
65
- ["git", "rev-parse", "--show-toplevel"],
66
- cwd=str(cwd),
67
- stderr=subprocess.DEVNULL,
68
- text=True,
69
- timeout=2,
70
- ).strip()
71
- if out:
72
- return Path(out)
73
- except Exception:
74
- pass
75
- return cwd
76
-
77
-
78
52
  def possible_workspace_paths(value: object) -> list[Path]:
79
53
  paths: list[Path] = []
80
54
 
81
55
  def add_path(candidate: object) -> None:
56
+ if isinstance(candidate, list):
57
+ for item in candidate:
58
+ add_path(item)
59
+ return
82
60
  if not isinstance(candidate, str):
83
61
  return
84
- text = candidate.strip()
85
- if not text or not text.startswith("/"):
62
+ text = os.path.expandvars(os.path.expanduser(candidate.strip()))
63
+ if not text:
86
64
  return
87
- path = Path(text).expanduser()
65
+ is_absolute = Path(text).is_absolute() or PureWindowsPath(text).is_absolute()
66
+ if not is_absolute:
67
+ return
68
+ path = Path(text)
88
69
  if path.exists():
89
- paths.append(path)
70
+ paths.append(path if path.is_dir() else path.parent)
90
71
 
91
- def walk(obj: object, key_hint: str = "") -> None:
72
+ def walk(obj: object) -> None:
92
73
  if isinstance(obj, dict):
93
74
  for key, child in obj.items():
94
75
  if key in WORKSPACE_KEYS:
95
76
  add_path(child)
96
- walk(child, key)
77
+ walk(child)
97
78
  elif isinstance(obj, list):
98
79
  for child in obj:
99
- walk(child, key_hint)
80
+ walk(child)
100
81
 
101
82
  walk(value)
102
83
  return paths
@@ -111,743 +92,210 @@ def parse_hook_payload(raw: str) -> object:
111
92
  return {}
112
93
 
113
94
 
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]:
95
+ def event_root(raw: str, cwd: Path) -> tuple[Path, str]:
155
96
  payload = parse_hook_payload(raw)
156
97
  candidates: list[tuple[Path, str]] = []
157
98
  for path in possible_workspace_paths(payload):
158
99
  candidates.append((path, "hook payload"))
159
100
  for key in WORKSPACE_ENV_KEYS:
160
101
  value = os.environ.get(key, "").strip()
161
- if value.startswith("/"):
162
- path = Path(value).expanduser()
163
- if path.exists():
164
- candidates.append((path, f"${key}"))
102
+ for path in possible_workspace_paths({"root": value}):
103
+ candidates.append((path, f"${key}"))
165
104
  candidates.append((cwd, "process cwd"))
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
-
174
105
  for path, source in candidates:
175
- root = git_root(path)
106
+ root = folder_root(path)
176
107
  if not is_context_guard_skill_path(root):
177
108
  return root, source
178
-
179
- root = git_root(cwd)
180
- return root, "process cwd"
109
+ return folder_root(cwd), "process cwd"
181
110
 
182
111
 
183
112
  def read_stdin() -> str:
184
113
  try:
185
- return sys.stdin.read()
114
+ return sys.stdin.buffer.read().decode("utf-8", errors="replace")
186
115
  except Exception:
187
- return ""
116
+ try:
117
+ return sys.stdin.read()
118
+ except Exception:
119
+ return ""
188
120
 
189
121
 
190
122
  def hook_log(message: str) -> None:
191
123
  print(message, file=sys.stderr)
192
124
 
193
125
 
194
- def hook_response(**payload: object) -> int:
195
- print(json.dumps(payload, ensure_ascii=False))
196
- return 0
197
-
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})?")
126
+ HOOK_EVENT_NAMES = {
127
+ "session-start": "SessionStart",
128
+ "subagent-start": "SubagentStart",
129
+ "user-prompt-submit": "UserPromptSubmit",
130
+ "stop": "Stop",
131
+ "subagent-stop": "SubagentStop",
132
+ }
206
133
 
207
134
 
208
- def has_secret(text: str) -> bool:
209
- return any(pattern.search(text) for pattern in SECRET_PATTERNS)
135
+ def hook_response(platform: str, event: str, additional_context: str = "") -> int:
136
+ payload: dict[str, object] = {}
137
+ if additional_context:
138
+ if platform == "cursor":
139
+ payload["additional_context"] = additional_context
140
+ else:
141
+ payload["hookSpecificOutput"] = {
142
+ "hookEventName": HOOK_EVENT_NAMES.get(event, event),
143
+ "additionalContext": additional_context,
144
+ }
145
+ print(json.dumps(payload, ensure_ascii=False))
146
+ return 0
210
147
 
211
148
 
212
- def has_ephemeral_secret(text: str) -> bool:
213
- return bool(EPHEMERAL_SECRET_PATTERN.search(text))
149
+ def payload_value(payload: object, keys: tuple[str, ...]) -> str:
150
+ if not isinstance(payload, dict):
151
+ return ""
152
+ for key in keys:
153
+ value = payload.get(key)
154
+ if isinstance(value, str) and value.strip():
155
+ return value.strip()
156
+ return ""
214
157
 
215
158
 
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
159
+ def session_id(payload: object, platform: str) -> str:
160
+ value = payload_value(
161
+ payload,
162
+ ("session_id", "sessionId", "conversation_id", "conversationId", "generation_id"),
163
+ )
164
+ if value:
165
+ return value
166
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
167
+ return f"{platform}-{stamp}-{os.getpid()}"
225
168
 
226
169
 
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
- }
170
+ def language_setup_context(root: Path, ctx: Path) -> str:
171
+ language = str(read_preferences(ctx).get("record_language", "unset"))
172
+ if language and language != "unset":
173
+ return ""
174
+ quoted_root = '"' + str(root).replace('"', '\\"') + '"'
175
+ return (
176
+ "Context Guard first-session setup is incomplete. Before substantive project work, "
177
+ "ask the user whether project context should be recorded in 中文 or English; do not infer it. "
178
+ "After the user answers, run `context-guard set-language --root "
179
+ f"{quoted_root} --language <zh-or-en>` and then continue in that language."
250
180
  )
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
181
 
258
182
 
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
-
300
- def run_test_hub_completion(root: Path) -> tuple[int, str]:
301
- ctx = context_dir(root)
302
- tests = approved_dev_completion_tests(ctx)
303
- if not tests:
304
- return 0, "[context-guard] test hub: no approved every-dev-completion tests."
305
-
306
- hook_log(f"[context-guard] test hub: running {len(tests)} approved every-dev-completion test(s).")
307
- completed = subprocess.run(
308
- [
309
- sys.executable,
310
- str(SKILL_ROOT / "scripts" / "context_guard.py"),
311
- "dev-complete",
312
- "--root",
313
- str(root),
314
- ],
315
- text=True,
316
- capture_output=True,
317
- timeout=900,
183
+ def lifecycle_context(root: Path, workbench_url: str | None) -> str:
184
+ quoted_root = '"' + str(root).replace('"', '\\"') + '"'
185
+ workbench = f" Workbench: {workbench_url}." if workbench_url else ""
186
+ return (
187
+ f"Context Guard is active for {root}.{workbench} "
188
+ "Record a credible bad case with `context-guard record-bad-case --root "
189
+ f"{quoted_root} --title <title> --phenomenon <what-failed> --trigger <trigger> "
190
+ "--cause <cause-or-pending> --guard <regression-guard> --keys <comma-separated>`; "
191
+ "never store secrets in project context."
318
192
  )
319
- output = "\n".join(part for part in [completed.stdout.strip(), completed.stderr.strip()] if part)
320
- return completed.returncode, output
321
193
 
322
194
 
323
- def completion_test_summary(output: str, code: int) -> str:
324
- if "no approved every-dev-completion tests" in output:
325
- return "no approved every-dev-completion tests"
326
- for line in output.splitlines():
327
- if "[context-guard] test hub:" in line and " passed," in line and " failed," in line and " blocked" in line:
328
- summary = line.split("test hub:", 1)[1].strip().rstrip(".")
329
- if code == 0:
330
- return f"all approved tests passed ({summary})"
331
- return f"approved tests are not all passing ({summary})"
332
- return "test hub status unknown; inspect `.codex/context/test-hub/last-run.json`"
195
+ def prompt_text(raw: str) -> str:
196
+ payload = parse_hook_payload(raw)
197
+ return payload_value(payload, ("prompt", "user_prompt", "userPrompt", "text", "content"))
333
198
 
334
199
 
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)
200
+ def redact(text: str) -> str:
201
+ if len(text) > 800:
202
+ return text[:400].rstrip() + "…"
203
+ return text
370
204
 
371
205
 
372
- def prompt_text(raw: str) -> str:
373
- if not raw.strip():
374
- return ""
375
- try:
376
- data = json.loads(raw)
377
- except Exception:
378
- return raw
379
- pieces: list[str] = []
380
-
381
- def walk(value: object) -> None:
382
- if isinstance(value, str):
383
- pieces.append(value)
384
- elif isinstance(value, list):
385
- for item in value:
386
- walk(item)
387
- elif isinstance(value, dict):
388
- for key in ("prompt", "message", "text", "content", "input"):
389
- if key in value:
390
- walk(value[key])
391
-
392
- walk(data)
393
- return "\n".join(pieces)
394
-
395
-
396
- def looks_like_bad_case(text: str) -> bool:
397
- lowered = text.lower()
398
- markers = [
399
- "bug",
400
- "bad case",
401
- "regression",
402
- "broken",
403
- "error",
404
- "failed",
405
- "failing",
406
- "doesn't work",
407
- "not working",
408
- "紧急",
409
- "报错",
410
- "失败",
411
- "坏例",
412
- "复现",
413
- "回归",
414
- "不对",
415
- "有问题",
416
- ]
417
- return any(marker in lowered for marker in markers)
418
-
419
-
420
- def looks_like_task_switch(text: str) -> bool:
421
- lowered = text.lower()
422
- markers = [
423
- "urgent",
424
- "instead",
425
- "switch",
426
- "pause",
427
- "later",
428
- "different",
429
- "unrelated",
430
- "先",
431
- "暂停",
432
- "等下",
433
- "换个",
434
- "另一个",
435
- "紧急",
436
- "回头",
437
- "先不",
438
- ]
439
- return any(marker in lowered for marker in markers)
440
-
441
-
442
- def looks_like_goal_mode(text: str) -> bool:
443
- lowered = text.lower()
444
- markers = [
445
- "goal mode",
446
- "goal模式",
447
- "goal 模式",
448
- "active goal",
449
- "long-running",
450
- "autonomous",
451
- "目标模式",
452
- "长期目标",
453
- "持续执行",
454
- "自动继续",
455
- ]
456
- return any(marker in lowered for marker in markers)
457
-
458
-
459
- def looks_like_remote_work(text: str) -> bool:
460
- lowered = text.lower()
461
- markers = [
462
- "ssh",
463
- "remote server",
464
- "remote host",
465
- "dev server",
466
- "jump host",
467
- "服务器",
468
- "远程",
469
- "开发机",
470
- "跳板机",
471
- "通过ssh",
472
- "通过 ssh",
473
- ]
474
- return any(marker in lowered for marker in markers)
475
-
476
-
477
- def looks_like_test_creation(text: str) -> bool:
478
- lowered = text.lower()
479
- creation_markers = [
480
- "create",
481
- "write",
482
- "generate",
483
- "design",
484
- "add",
485
- "创建",
486
- "建立",
487
- "写",
488
- "生成",
489
- "设计",
490
- "新增",
491
- "加一个",
492
- "做一个",
493
- ]
494
- test_markers = [
495
- "test case",
496
- "task case",
497
- "test task",
498
- "testing task",
499
- "测试case",
500
- "测试 case",
501
- "测试任务",
502
- "测试用例",
503
- "测评任务",
504
- "测评case",
505
- "测评 case",
506
- "测试链路",
507
- "测试",
508
- ]
509
- return any(marker in lowered for marker in creation_markers) and any(marker in lowered for marker in test_markers)
510
-
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
-
559
- def looks_like_explicit_branch(text: str) -> bool:
560
- lowered = text.lower()
561
- markers = [
562
- "branch task",
563
- "side task",
564
- "side route",
565
- "fork this",
566
- "create a branch",
567
- "new branch",
568
- "as a branch",
569
- "支线",
570
- "分支",
571
- "开一个分支",
572
- "开一条支线",
573
- "创建支线",
574
- "创建分支",
575
- "作为支线",
576
- ]
577
- return any(marker in lowered for marker in markers)
578
-
579
-
580
- def looks_like_route_drift(text: str) -> bool:
581
- lowered = text.lower()
582
- drift_markers = [
583
- "significantly diverge",
584
- "diverge from",
585
- "different architecture",
586
- "new architecture",
587
- "new direction",
588
- "refactor direction",
589
- "偏离",
590
- "显著偏离",
591
- "新的架构",
592
- "新方向",
593
- "重构方向",
594
- "主线架构",
595
- ]
596
- return any(marker in lowered for marker in drift_markers)
597
-
598
-
599
- def bad_case_blocks(text: str) -> list[dict[str, str]]:
600
- blocks: list[dict[str, str]] = []
601
- current: dict[str, str] | None = None
602
- for line in text.splitlines():
603
- heading = line.startswith("### BC-")
604
- if heading:
605
- if current:
606
- blocks.append(current)
607
- identifier, _, title = line.removeprefix("### ").partition(":")
608
- current = {"id": identifier.strip(), "title": title.strip()}
609
- continue
610
- if current is None or not line.startswith("- ") or ":" not in line:
611
- continue
612
- key, _, value = line[2:].partition(":")
613
- current[key.strip().lower()] = value.strip()
614
- if current:
615
- blocks.append(current)
616
- return blocks
617
-
618
-
619
- def unresolved_bad_cases(path: Path) -> list[dict[str, str]]:
206
+ def append_user_message(ctx: Path, text: str) -> str:
207
+ text = (text or "").strip()
208
+ if not text:
209
+ return "empty"
210
+ ctx.mkdir(parents=True, exist_ok=True)
211
+ path = ctx / "user-messages.md"
620
212
  if not path.exists():
621
- return []
622
- resolved_statuses = {"resolved", "done", "superseded-by-route-change"}
623
- cases = bad_case_blocks(path.read_text(encoding="utf-8"))
624
- return [
625
- case
626
- for case in cases
627
- if case.get("status", "").strip().lower() not in resolved_statuses
628
- ]
629
-
630
-
631
- def format_unresolved_bad_cases(cases: list[dict[str, str]], limit: int = 5) -> str:
632
- if not cases:
633
- return "none"
634
- parts = []
635
- for case in cases[:limit]:
636
- status = case.get("status", "unknown") or "unknown"
637
- title = case.get("title", "Untitled bad case")
638
- phenomenon = case.get("phenomenon", "").strip()
639
- summary = f"{title} ({status})"
640
- if phenomenon:
641
- summary += f" - {phenomenon}"
642
- parts.append(summary)
643
- if len(cases) > limit:
644
- parts.append(f"{len(cases) - limit} more unresolved bad cases")
645
- return "; ".join(parts)
213
+ path.write_text("# User Message Memory\n\n## Recent User Signals\n\n", encoding="utf-8")
214
+ body = path.read_text(encoding="utf-8")
215
+ line = "- " + redact(text).replace("\n", " ")
216
+ if line in body:
217
+ return "duplicate"
218
+ marker = "## Recent User Signals"
219
+ if marker in body:
220
+ body = body.replace(marker, marker + "\n\n" + line, 1)
221
+ else:
222
+ body += "\n" + line + "\n"
223
+ path.write_text(body, encoding="utf-8")
224
+ return "recorded"
646
225
 
647
226
 
648
227
  def main() -> int:
649
- event = sys.argv[1] if len(sys.argv) > 1 else "unknown"
228
+ configure_stdio()
229
+ parser = argparse.ArgumentParser(description="Context Guard hook adapter")
230
+ parser.add_argument("event", nargs="?", default="unknown")
231
+ parser.add_argument("--platform", choices=["codex", "cursor", "claude"], default="codex")
232
+ args, _unknown = parser.parse_known_args()
233
+ event = args.event
234
+ platform = args.platform
650
235
  raw = read_stdin()
651
- root, root_source = event_root(raw, Path.cwd(), event)
652
- context_dir = root / ".codex" / "context"
653
- index_path = context_dir / "index.md"
654
- roadmap_path = context_dir / "roadmap.md"
655
- bad_cases_path = context_dir / "bad-cases.md"
656
- text = prompt_text(raw)
236
+ payload = parse_hook_payload(raw)
237
+ root, root_source = event_root(raw, Path.cwd())
238
+ ctx = context_folder(root)
239
+ current_session_id = session_id(payload, platform)
657
240
 
658
241
  if is_context_guard_skill_path(root):
659
- hook_log(
660
- "[context-guard] detected Context Guard skill directory as the apparent root; "
661
- "skipping project context writes. Open the target Codex folder or pass an explicit local `--root` "
662
- "when showing/updating a roadmap."
663
- )
664
- hook_log(f"[context-guard] apparent root source: {root_source}; apparent root: {root}")
665
- return hook_response()
242
+ hook_log("[context-guard] apparent root is the skill directory; skipping writes.")
243
+ return hook_response(platform, event)
666
244
 
667
245
  if event in {"session-start", "subagent-start"}:
668
246
  created = init_context(root)
669
- label = "subagent context" if event == "subagent-start" else "folder context"
670
- if created:
671
- hook_log(f"[context-guard] initialized {label}: {context_dir}")
672
- else:
673
- hook_log(f"[context-guard] {label} ready: {context_dir}")
674
- hook_log(f"[context-guard] project root: {root} ({root_source})")
675
- hook_log("[context-guard] context location rule: save project context only under `<opened local Codex project root>/.codex/context/`.")
676
- hook_log("[context-guard] use .codex/context/index.md for quick scan and .codex/context/roadmap.md for route nodes.")
677
- return hook_response()
247
+ append_session_event(
248
+ root,
249
+ event,
250
+ platform,
251
+ current_session_id,
252
+ {"root_source": root_source},
253
+ )
254
+ url = None
255
+ if event == "session-start" and not (
256
+ isinstance(payload, dict) and payload.get("is_background_agent") is True
257
+ ):
258
+ start_reason = payload_value(payload, ("source", "reason", "session_start_type")).lower()
259
+ url = start_workbench(
260
+ root,
261
+ open_browser=start_reason not in {"resume", "clear", "compact"},
262
+ )
263
+ hook_log(
264
+ f"[context-guard] {'initialized' if created else 'ready'} {ctx} ({root_source})"
265
+ )
266
+ contexts = [language_setup_context(root, ctx), lifecycle_context(root, url)]
267
+ playbook = ctx / "tasks" / "J2.md"
268
+ if playbook.is_file():
269
+ contexts.append(
270
+ "Repository development playbook: read .codex/context/tasks/J2.md "
271
+ "for the current product and testing branch rules."
272
+ )
273
+ return hook_response(platform, event, "\n\n".join(item for item in contexts if item))
678
274
 
679
275
  if event == "user-prompt-submit":
680
- record_status, record_mode = append_user_message(context_dir, text)
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})")
693
- if looks_like_goal_mode(text):
694
- hints.append("goal mode: align active goal with current context and record roadmap/bad-case checkpoints during long-running work")
695
- if looks_like_remote_work(text):
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")
697
- if looks_like_test_creation(text):
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")
701
- if looks_like_explicit_branch(text):
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:")
703
- elif looks_like_route_drift(text):
704
- hints.append("possible route drift: ask whether to create a branch before moving away from the current mainline architecture")
705
- if looks_like_task_switch(text):
706
- hints.append("possible task switch: park current context in .codex/context/index.md before switching")
707
- if looks_like_bad_case(text):
708
- hints.append("possible bad case: record/update .codex/context/bad-cases.md or task-local bad-cases.md")
709
- if not hints:
710
- hints.append("run Context Guard intake: continue current context or note no active context")
711
- hook_log("[context-guard] " + "; ".join(hints))
712
- hook_log(f"[context-guard] root source: {root_source}")
713
- hook_log(f"[context-guard] project root: {root}")
714
- hook_log(f"[context-guard] context folder: {context_dir}")
715
- hook_log(f"[context-guard] context index: {index_path}")
716
- hook_log(f"[context-guard] route map: {roadmap_path}")
717
- return hook_response()
276
+ init_context(root)
277
+ status = append_user_message(ctx, prompt_text(raw))
278
+ append_session_event(
279
+ root,
280
+ event,
281
+ platform,
282
+ current_session_id,
283
+ {"message_status": status},
284
+ )
285
+ hook_log(f"[context-guard] user-messages: {status}")
286
+ return hook_response(platform, event)
718
287
 
719
288
  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.")
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.")
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.")
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.")
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.")
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.")
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.")
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.")
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.")
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.")
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.")
733
- hook_log("[context-guard] ROADMAP MAINTENANCE GATE: run `context_guard.py validate-roadmap-maintenance` after route updates; do not let mainline/branch overview stay stale while important work is hidden as checkpoints.")
734
- hook_log("[context-guard] If frontend/UI/HTML/CSS/layout/browser behavior changed, inspect with browser/screenshot or state the exact blocker; do not claim fixed without this evidence.")
735
- hook_log("[context-guard] Branch task gate: if the user explicitly asked for a branch, ensure `context_guard.py create-branch-task --title <task title> --branch <branch name> --parent-node <parent NODE id>` has created the task folder, index current entry, and Branch/Parent roadmap node; if the work significantly drifts from the mainline architecture, ask whether to create a branch before finalizing.")
736
- hook_log("[context-guard] final answer must include verification evidence and must not say done/fixed/passing unless the gate above was satisfied.")
737
- hook_log(f"[context-guard] root source: {root_source}")
738
- hook_log(f"[context-guard] project root: {root}")
739
- hook_log(f"[context-guard] context folder: {context_dir}")
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()
794
- try:
795
- test_code, test_output = run_test_hub_completion(root)
796
- for line in test_output.splitlines():
797
- hook_log(line)
798
- hook_log(
799
- "[context-guard] final answer must include Test Hub summary: "
800
- + completion_test_summary(test_output, test_code)
801
- )
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)
806
- reason = (
807
- "Context Guard Test Hub found failed or blocked approved tests. "
808
- "Read `.codex/context/test-hub/last-run.json` and preserved run evidence, "
809
- "then fix or report the blocker before finalizing."
810
- )
811
- if details:
812
- reason += " Failing tests: " + details
813
- hook_log(f"[context-guard] TEST HUB BLOCKER: {reason}")
814
- return hook_response(decision="block", reason=reason)
815
- except subprocess.TimeoutExpired:
816
- reason = "Context Guard Test Hub timed out after 900s; report this blocker and evidence before finalizing."
817
- hook_log(f"[context-guard] TEST HUB BLOCKER: {reason}")
818
- return hook_response(decision="block", reason=reason)
819
- except Exception as exc:
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}")
844
- open_cases = unresolved_bad_cases(bad_cases_path)
845
- hook_log("[context-guard] final answer must include BC summary: archived/updated BC this turn, and current unresolved BC.")
846
- hook_log(f"[context-guard] current unresolved BC: {format_unresolved_bad_cases(open_cases)}")
847
- return hook_response()
848
-
849
- hook_log("[context-guard] unknown hook event; use the context-guard skill if context changed.")
850
- return hook_response()
289
+ init_context(root)
290
+ append_session_event(root, event, platform, current_session_id)
291
+ hook_log(
292
+ "[context-guard] if this turn mattered, append sessions.jsonl and update bugs/tasks. "
293
+ "Do not run Test Hub or Roadmap HTML."
294
+ )
295
+ return hook_response(platform, event)
296
+
297
+ hook_log(f"[context-guard] ignored event: {event}")
298
+ return hook_response(platform, event)
851
299
 
852
300
 
853
301
  if __name__ == "__main__":