@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.
- package/README.md +103 -208
- package/README.zh-CN.md +103 -208
- package/SKILL.md +27 -678
- package/agents/openai.yaml +2 -2
- package/bin/context-guard-skill.js +159 -56
- package/bin/postinstall.js +1 -1
- package/hooks.json +11 -11
- package/package.json +9 -6
- package/prototype/workbench.html +4933 -0
- package/references/bug-record-template.md +37 -0
- package/references/context-template.md +14 -336
- package/scripts/context_guard.py +577 -7655
- package/scripts/context_guard_hook.py +179 -731
- package/scripts/map_owns.py +769 -0
- package/references/feature-chain-methodology.md +0 -228
- package/references/register-template.md +0 -85
- package/references/task-case-template.md +0 -63
- package/tests/BC-20260618-063.sh +0 -116
- package/tests/BC-20260618-065.sh +0 -66
- package/tests/BC-20260626-080.sh +0 -48
- package/tests/BC-20260626-081.sh +0 -40
- package/tests/BC-20260626-082.sh +0 -32
- package/tests/BC-20260626-083.sh +0 -66
- package/tests/BC-20260627-084.sh +0 -74
- package/tests/BC-20260630-086.sh +0 -50
- package/tests/BC-20260630-087.sh +0 -103
- package/tests/BC-20260630-088.sh +0 -32
- package/tests/BC-20260630-089.sh +0 -63
- package/tests/BC-20260701-090.sh +0 -84
- package/tests/BC-20260702-096.sh +0 -48
- package/tests/BC-20260706-098.sh +0 -66
- package/tests/BC-20260707-099.sh +0 -47
- package/tests/BC-20260707-100.sh +0 -46
- package/tests/BC-20260707-101.sh +0 -47
- package/tests/BC-20260707-102.sh +0 -68
- package/tests/BC-20260707-103.sh +0 -59
- package/tests/BC-20260707-104.sh +0 -103
- package/tests/BC-20260707-105.sh +0 -109
- package/tests/BC-20260707-106.sh +0 -80
- package/tests/BC-20260707-107.sh +0 -74
- package/tests/BC-20260707-108.sh +0 -48
- package/tests/BC-20260707-109.sh +0 -56
- package/tests/BC-20260707-110.sh +0 -71
- package/tests/BC-20260707-111.sh +0 -70
- package/tests/BC-20260707-112.sh +0 -45
- package/tests/BC-20260707-113.sh +0 -73
- package/tests/BC-20260707-115.sh +0 -77
- package/tests/BC-20260707-116.sh +0 -77
- package/tests/BC-20260707-118.sh +0 -115
- package/tests/BC-20260707-119.sh +0 -47
- package/tests/BC-20260707-120.sh +0 -60
- package/tests/BC-20260707-121.sh +0 -66
- package/tests/BC-20260707-122.sh +0 -48
- package/tests/BC-20260707-123.sh +0 -43
- package/tests/BC-20260707-124.sh +0 -56
- package/tests/BC-20260707-125.sh +0 -64
- package/tests/BC-20260707-126.sh +0 -80
- package/tests/BC-20260707-127.sh +0 -88
- package/tests/BC-20260707-129.sh +0 -59
- package/tests/BC-20260707-130.sh +0 -69
- package/tests/BC-20260707-131.sh +0 -140
- package/tests/BC-20260707-132.sh +0 -150
- package/tests/BC-20260707-133.sh +0 -70
- package/tests/BC-20260708-136.sh +0 -210
- package/tests/BC-20260708-137.sh +0 -106
- package/tests/BC-20260708-138.sh +0 -168
- package/tests/BC-20260708-139.sh +0 -79
- package/tests/BC-20260709-002.sh +0 -63
- package/tests/BC-20260709-003.sh +0 -239
- package/tests/BC-20260709-006.sh +0 -76
- package/tests/BC-20260709-008.sh +0 -168
- package/tests/BC-20260710-001.sh +0 -61
- package/tests/BC-20260710-002.sh +0 -111
- package/tests/npm-install-smoke.sh +0 -53
|
@@ -1,25 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
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
|
|
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
|
|
62
|
+
text = os.path.expandvars(os.path.expanduser(candidate.strip()))
|
|
63
|
+
if not text:
|
|
86
64
|
return
|
|
87
|
-
|
|
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
|
|
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
|
|
77
|
+
walk(child)
|
|
97
78
|
elif isinstance(obj, list):
|
|
98
79
|
for child in obj:
|
|
99
|
-
walk(child
|
|
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
|
|
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
|
-
|
|
162
|
-
path
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
209
|
-
|
|
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
|
|
213
|
-
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
return
|
|
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
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
260
|
-
|
|
261
|
-
if
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
-
|
|
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
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
|
|
721
|
-
|
|
722
|
-
hook_log(
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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__":
|