@michelj/context-guard 0.4.2 → 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 +113 -150
- package/README.zh-CN.md +113 -150
- package/SKILL.md +46 -0
- package/agents/openai.yaml +4 -0
- package/bin/context-guard-skill.js +234 -46
- package/bin/postinstall.js +1 -1
- package/hooks.json +29 -5
- package/package.json +12 -5
- package/prototype/workbench.html +4933 -0
- package/references/bug-record-template.md +37 -0
- package/references/context-template.md +19 -0
- package/scripts/context_guard.py +806 -0
- package/scripts/context_guard_hook.py +302 -0
- package/scripts/map_owns.py +769 -0
- package/skills/context-guard/README.md +0 -234
- package/skills/context-guard/README.zh-CN.md +0 -234
- package/skills/context-guard/SKILL.md +0 -558
- package/skills/context-guard/agents/openai.yaml +0 -4
- package/skills/context-guard/references/context-template.md +0 -290
- package/skills/context-guard/references/register-template.md +0 -85
- package/skills/context-guard/references/task-case-template.md +0 -63
- package/skills/context-guard/scripts/context_guard.py +0 -4886
- package/skills/context-guard/scripts/context_guard_hook.py +0 -522
- package/skills/context-guard/tests/BC-20260618-063.sh +0 -116
- package/skills/context-guard/tests/BC-20260618-065.sh +0 -66
- package/skills/context-guard/tests/BC-20260626-080.sh +0 -48
- package/skills/context-guard/tests/BC-20260626-081.sh +0 -40
- package/skills/context-guard/tests/BC-20260626-082.sh +0 -32
- package/skills/context-guard/tests/BC-20260626-083.sh +0 -66
- package/skills/context-guard/tests/BC-20260627-084.sh +0 -74
- package/skills/context-guard/tests/BC-20260630-086.sh +0 -50
- package/skills/context-guard/tests/BC-20260630-087.sh +0 -103
- package/skills/context-guard/tests/BC-20260630-088.sh +0 -32
- package/skills/context-guard/tests/BC-20260630-089.sh +0 -63
- package/skills/context-guard/tests/BC-20260701-090.sh +0 -83
- package/skills/context-guard/tests/BC-20260702-096.sh +0 -45
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Normalize Codex, Cursor, and Claude lifecycle hooks for Context Guard."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path, PureWindowsPath
|
|
12
|
+
|
|
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
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
WORKSPACE_KEYS = {
|
|
20
|
+
"cwd",
|
|
21
|
+
"current_working_directory",
|
|
22
|
+
"working_directory",
|
|
23
|
+
"workspace",
|
|
24
|
+
"workspace_root",
|
|
25
|
+
"workspaceFolder",
|
|
26
|
+
"workspace_folder",
|
|
27
|
+
"workspace_roots",
|
|
28
|
+
"workspaceRoots",
|
|
29
|
+
"project",
|
|
30
|
+
"project_root",
|
|
31
|
+
"projectRoot",
|
|
32
|
+
"project_path",
|
|
33
|
+
"repository",
|
|
34
|
+
"repo",
|
|
35
|
+
"repo_path",
|
|
36
|
+
"root",
|
|
37
|
+
}
|
|
38
|
+
WORKSPACE_ENV_KEYS = [
|
|
39
|
+
"CODEX_WORKSPACE_ROOT",
|
|
40
|
+
"CODEX_PROJECT_ROOT",
|
|
41
|
+
"CODEX_CWD",
|
|
42
|
+
"CURSOR_PROJECT_DIR",
|
|
43
|
+
"CURSOR_WORKSPACE_ROOT",
|
|
44
|
+
"CLAUDE_PROJECT_DIR",
|
|
45
|
+
"CLAUDE_WORKSPACE_ROOT",
|
|
46
|
+
"WORKSPACE_ROOT",
|
|
47
|
+
"PROJECT_ROOT",
|
|
48
|
+
"PWD",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def possible_workspace_paths(value: object) -> list[Path]:
|
|
53
|
+
paths: list[Path] = []
|
|
54
|
+
|
|
55
|
+
def add_path(candidate: object) -> None:
|
|
56
|
+
if isinstance(candidate, list):
|
|
57
|
+
for item in candidate:
|
|
58
|
+
add_path(item)
|
|
59
|
+
return
|
|
60
|
+
if not isinstance(candidate, str):
|
|
61
|
+
return
|
|
62
|
+
text = os.path.expandvars(os.path.expanduser(candidate.strip()))
|
|
63
|
+
if not text:
|
|
64
|
+
return
|
|
65
|
+
is_absolute = Path(text).is_absolute() or PureWindowsPath(text).is_absolute()
|
|
66
|
+
if not is_absolute:
|
|
67
|
+
return
|
|
68
|
+
path = Path(text)
|
|
69
|
+
if path.exists():
|
|
70
|
+
paths.append(path if path.is_dir() else path.parent)
|
|
71
|
+
|
|
72
|
+
def walk(obj: object) -> None:
|
|
73
|
+
if isinstance(obj, dict):
|
|
74
|
+
for key, child in obj.items():
|
|
75
|
+
if key in WORKSPACE_KEYS:
|
|
76
|
+
add_path(child)
|
|
77
|
+
walk(child)
|
|
78
|
+
elif isinstance(obj, list):
|
|
79
|
+
for child in obj:
|
|
80
|
+
walk(child)
|
|
81
|
+
|
|
82
|
+
walk(value)
|
|
83
|
+
return paths
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_hook_payload(raw: str) -> object:
|
|
87
|
+
if not raw.strip():
|
|
88
|
+
return {}
|
|
89
|
+
try:
|
|
90
|
+
return json.loads(raw)
|
|
91
|
+
except Exception:
|
|
92
|
+
return {}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def event_root(raw: str, cwd: Path) -> tuple[Path, str]:
|
|
96
|
+
payload = parse_hook_payload(raw)
|
|
97
|
+
candidates: list[tuple[Path, str]] = []
|
|
98
|
+
for path in possible_workspace_paths(payload):
|
|
99
|
+
candidates.append((path, "hook payload"))
|
|
100
|
+
for key in WORKSPACE_ENV_KEYS:
|
|
101
|
+
value = os.environ.get(key, "").strip()
|
|
102
|
+
for path in possible_workspace_paths({"root": value}):
|
|
103
|
+
candidates.append((path, f"${key}"))
|
|
104
|
+
candidates.append((cwd, "process cwd"))
|
|
105
|
+
for path, source in candidates:
|
|
106
|
+
root = folder_root(path)
|
|
107
|
+
if not is_context_guard_skill_path(root):
|
|
108
|
+
return root, source
|
|
109
|
+
return folder_root(cwd), "process cwd"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def read_stdin() -> str:
|
|
113
|
+
try:
|
|
114
|
+
return sys.stdin.buffer.read().decode("utf-8", errors="replace")
|
|
115
|
+
except Exception:
|
|
116
|
+
try:
|
|
117
|
+
return sys.stdin.read()
|
|
118
|
+
except Exception:
|
|
119
|
+
return ""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def hook_log(message: str) -> None:
|
|
123
|
+
print(message, file=sys.stderr)
|
|
124
|
+
|
|
125
|
+
|
|
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
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
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
|
|
147
|
+
|
|
148
|
+
|
|
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 ""
|
|
157
|
+
|
|
158
|
+
|
|
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()}"
|
|
168
|
+
|
|
169
|
+
|
|
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."
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
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."
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def prompt_text(raw: str) -> str:
|
|
196
|
+
payload = parse_hook_payload(raw)
|
|
197
|
+
return payload_value(payload, ("prompt", "user_prompt", "userPrompt", "text", "content"))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def redact(text: str) -> str:
|
|
201
|
+
if len(text) > 800:
|
|
202
|
+
return text[:400].rstrip() + "…"
|
|
203
|
+
return text
|
|
204
|
+
|
|
205
|
+
|
|
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"
|
|
212
|
+
if not path.exists():
|
|
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"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def main() -> int:
|
|
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
|
|
235
|
+
raw = read_stdin()
|
|
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)
|
|
240
|
+
|
|
241
|
+
if is_context_guard_skill_path(root):
|
|
242
|
+
hook_log("[context-guard] apparent root is the skill directory; skipping writes.")
|
|
243
|
+
return hook_response(platform, event)
|
|
244
|
+
|
|
245
|
+
if event in {"session-start", "subagent-start"}:
|
|
246
|
+
created = init_context(root)
|
|
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))
|
|
274
|
+
|
|
275
|
+
if event == "user-prompt-submit":
|
|
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)
|
|
287
|
+
|
|
288
|
+
if event in {"stop", "subagent-stop"}:
|
|
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)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
if __name__ == "__main__":
|
|
302
|
+
raise SystemExit(main())
|