@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,806 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Context Guard CLI: initialize project memory and run its local workbench."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import signal
|
|
11
|
+
import socket
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
import webbrowser
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from socketserver import TCPServer
|
|
22
|
+
from urllib.parse import unquote, urlsplit
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def configure_stdio() -> None:
|
|
26
|
+
"""Use UTF-8 for client JSON and logs even on legacy Windows code pages."""
|
|
27
|
+
for stream in (sys.stdout, sys.stderr):
|
|
28
|
+
if hasattr(stream, "reconfigure"):
|
|
29
|
+
stream.reconfigure(encoding="utf-8", errors="backslashreplace")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
PARKED = (
|
|
33
|
+
"export-roadmap",
|
|
34
|
+
"create-branch-task",
|
|
35
|
+
"checkpoint-roadmap-node",
|
|
36
|
+
"subagent-register",
|
|
37
|
+
"subagent-complete",
|
|
38
|
+
"validate-bad-cases",
|
|
39
|
+
"validate-roadmap-maintenance",
|
|
40
|
+
"validate-feature-chains",
|
|
41
|
+
"test-hub-add",
|
|
42
|
+
"test-hub-list",
|
|
43
|
+
"test-hub-enable",
|
|
44
|
+
"test-hub-disable",
|
|
45
|
+
"test-hub-set-policy",
|
|
46
|
+
"test-hub-remove",
|
|
47
|
+
"feature-chain-add",
|
|
48
|
+
"feature-chain-propose",
|
|
49
|
+
"feature-chain-auto-propose",
|
|
50
|
+
"feature-chain-attach-bc",
|
|
51
|
+
"feature-chain-approve",
|
|
52
|
+
"feature-chain-dry-run",
|
|
53
|
+
"feature-chain-set-policy",
|
|
54
|
+
"feature-chain-set-checkpoint",
|
|
55
|
+
"feature-chain-suggest",
|
|
56
|
+
"feature-chain-plan",
|
|
57
|
+
"feature-chain-list",
|
|
58
|
+
"feature-chain-summary",
|
|
59
|
+
"feature-chain-overlap",
|
|
60
|
+
"feature-chain-coverage",
|
|
61
|
+
"feature-chain-candidates",
|
|
62
|
+
"show-test-hub",
|
|
63
|
+
"serve-test-hub",
|
|
64
|
+
"dev-complete",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
INDEX_MD = """# Context Index
|
|
68
|
+
|
|
69
|
+
- Current: none
|
|
70
|
+
- Map: `.codex/context/map.json` (human workbench)
|
|
71
|
+
- How to jump: `.codex/context/FIND.md`
|
|
72
|
+
|
|
73
|
+
Last initialized: {today}
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
USER_MESSAGES_MD = """# User Message Memory
|
|
77
|
+
|
|
78
|
+
## Recent User Signals
|
|
79
|
+
|
|
80
|
+
None yet.
|
|
81
|
+
|
|
82
|
+
## Durable User Constraints
|
|
83
|
+
|
|
84
|
+
None yet.
|
|
85
|
+
|
|
86
|
+
## Secret Pointers
|
|
87
|
+
|
|
88
|
+
None.
|
|
89
|
+
|
|
90
|
+
Last initialized: {today}
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
FIND_MD = """# Four stores — jump small, then open one file
|
|
94
|
+
|
|
95
|
+
1. Sessions — `sessions.jsonl` (append-only) and `sessions/{id}.md`
|
|
96
|
+
2. Bugs — `bugs-index.json`, then `bugs/{id}.md` and `fixes/{id}.md`
|
|
97
|
+
3. Tasks — `tasks/{id}.md`
|
|
98
|
+
4. Map — workbench writes `map.json`; agent uses `owns-index.json` and `cards/`
|
|
99
|
+
|
|
100
|
+
Do not paste `map.json` or `jump-index.json`. Do not Grep this whole folder.
|
|
101
|
+
After the map changes: `python3 scripts/map_owns.py cards`.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
ARCHITECTURE_MD = """# Architecture Map
|
|
105
|
+
|
|
106
|
+
Status: pending
|
|
107
|
+
Later sessions: open `.codex/context/map.json`. Do not re-analyze unless asked.
|
|
108
|
+
|
|
109
|
+
Last initialized: {today}
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def context_guard_skill_root() -> Path:
|
|
114
|
+
return Path(__file__).resolve().parents[1]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def is_inside(path: Path, parent: Path) -> bool:
|
|
118
|
+
try:
|
|
119
|
+
path.resolve().relative_to(parent.resolve())
|
|
120
|
+
return True
|
|
121
|
+
except ValueError:
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def is_context_guard_skill_path(path: Path) -> bool:
|
|
126
|
+
return is_inside(path, context_guard_skill_root())
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def folder_root(cwd: Path) -> Path:
|
|
130
|
+
try:
|
|
131
|
+
out = subprocess.check_output(
|
|
132
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
133
|
+
cwd=str(cwd),
|
|
134
|
+
stderr=subprocess.DEVNULL,
|
|
135
|
+
text=True,
|
|
136
|
+
timeout=2,
|
|
137
|
+
).strip()
|
|
138
|
+
if out:
|
|
139
|
+
return Path(out)
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
return cwd
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def guard_implicit_skill_root(root: Path, explicit_root: bool) -> int:
|
|
146
|
+
if explicit_root or not is_context_guard_skill_path(root):
|
|
147
|
+
return 0
|
|
148
|
+
print(
|
|
149
|
+
"[context-guard] refusing to use the skill directory as a project root. "
|
|
150
|
+
"Pass --root <opened project>.",
|
|
151
|
+
file=sys.stderr,
|
|
152
|
+
)
|
|
153
|
+
return 2
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def context_dir(root: Path) -> Path:
|
|
157
|
+
return root / ".codex" / "context"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def normalize_record_language(language: str) -> str:
|
|
161
|
+
value = " ".join((language or "").strip().split())
|
|
162
|
+
lowered = value.lower().replace("_", "-")
|
|
163
|
+
aliases = {
|
|
164
|
+
"zh": "zh",
|
|
165
|
+
"zh-cn": "zh",
|
|
166
|
+
"zh-hans": "zh",
|
|
167
|
+
"cn": "zh",
|
|
168
|
+
"chinese": "zh",
|
|
169
|
+
"中文": "zh",
|
|
170
|
+
"简体中文": "zh",
|
|
171
|
+
"en": "en",
|
|
172
|
+
"en-us": "en",
|
|
173
|
+
"english": "en",
|
|
174
|
+
"英文": "en",
|
|
175
|
+
}
|
|
176
|
+
return aliases.get(lowered, value or "unset")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def display_language_code(language: str) -> str:
|
|
180
|
+
normalized = normalize_record_language(language)
|
|
181
|
+
return normalized if normalized in {"zh", "en"} else "auto"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def default_preferences(today: str | None = None) -> dict[str, str]:
|
|
185
|
+
return {
|
|
186
|
+
"record_language": "unset",
|
|
187
|
+
"display_language": "auto",
|
|
188
|
+
"map_bootstrap": "pending",
|
|
189
|
+
"last_updated": today or datetime.now().strftime("%Y-%m-%d"),
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def read_json(path: Path, default: object) -> object:
|
|
194
|
+
if not path.exists():
|
|
195
|
+
return default
|
|
196
|
+
try:
|
|
197
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
198
|
+
except (json.JSONDecodeError, OSError):
|
|
199
|
+
return default
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def write_json(path: Path, value: object) -> None:
|
|
203
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def read_preferences(ctx: Path) -> dict[str, str]:
|
|
208
|
+
data = read_json(ctx / "preferences.json", {})
|
|
209
|
+
return data if isinstance(data, dict) else {}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def write_preferences(ctx: Path, preferences: dict[str, str]) -> None:
|
|
213
|
+
write_json(ctx / "preferences.json", preferences)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def write_if_missing(path: Path, content: str) -> bool:
|
|
217
|
+
if path.exists():
|
|
218
|
+
return False
|
|
219
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
220
|
+
path.write_text(content, encoding="utf-8")
|
|
221
|
+
return True
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def ensure_context_gitignore(root: Path) -> tuple[Path, bool]:
|
|
225
|
+
codex_dir = root / ".codex"
|
|
226
|
+
codex_dir.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
path = codex_dir / ".gitignore"
|
|
228
|
+
required = [
|
|
229
|
+
"context/private/",
|
|
230
|
+
"context/**/*.local.json",
|
|
231
|
+
"context/**/secrets*.json",
|
|
232
|
+
]
|
|
233
|
+
if path.exists():
|
|
234
|
+
current = path.read_text(encoding="utf-8")
|
|
235
|
+
additions = [line for line in required if line not in current.splitlines()]
|
|
236
|
+
if additions:
|
|
237
|
+
suffix = "" if current.endswith("\n") or not current else "\n"
|
|
238
|
+
path.write_text(current + suffix + "\n".join(additions) + "\n", encoding="utf-8")
|
|
239
|
+
return path, True
|
|
240
|
+
return path, False
|
|
241
|
+
path.write_text("\n".join(required) + "\n", encoding="utf-8")
|
|
242
|
+
return path, True
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def init_context(root: Path) -> list[Path]:
|
|
246
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
247
|
+
ctx = context_dir(root)
|
|
248
|
+
created: list[Path] = []
|
|
249
|
+
for directory in [
|
|
250
|
+
ctx,
|
|
251
|
+
ctx / "tasks",
|
|
252
|
+
ctx / "bugs",
|
|
253
|
+
ctx / "fixes",
|
|
254
|
+
ctx / "cards",
|
|
255
|
+
ctx / "sessions",
|
|
256
|
+
ctx / "private",
|
|
257
|
+
]:
|
|
258
|
+
if not directory.exists():
|
|
259
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
260
|
+
created.append(directory)
|
|
261
|
+
if directory == ctx / "private":
|
|
262
|
+
try:
|
|
263
|
+
directory.chmod(0o700)
|
|
264
|
+
except OSError:
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
files = {
|
|
268
|
+
ctx / "index.md": INDEX_MD.format(today=today),
|
|
269
|
+
ctx / "user-messages.md": USER_MESSAGES_MD.format(today=today),
|
|
270
|
+
ctx / "FIND.md": FIND_MD,
|
|
271
|
+
ctx / "architecture.md": ARCHITECTURE_MD.format(today=today),
|
|
272
|
+
ctx / "preferences.json": json.dumps(default_preferences(today), ensure_ascii=False, indent=2) + "\n",
|
|
273
|
+
ctx / "sessions.jsonl": "",
|
|
274
|
+
ctx / "bugs-index.json": "{}\n",
|
|
275
|
+
ctx / "map.json": json.dumps(
|
|
276
|
+
{
|
|
277
|
+
"v": 1,
|
|
278
|
+
"bootstrap": "pending",
|
|
279
|
+
"updated": today,
|
|
280
|
+
"flows": [],
|
|
281
|
+
"root": None,
|
|
282
|
+
},
|
|
283
|
+
ensure_ascii=False,
|
|
284
|
+
indent=2,
|
|
285
|
+
)
|
|
286
|
+
+ "\n",
|
|
287
|
+
}
|
|
288
|
+
for path, content in files.items():
|
|
289
|
+
if write_if_missing(path, content):
|
|
290
|
+
created.append(path)
|
|
291
|
+
gitignore_path, gitignore_changed = ensure_context_gitignore(root)
|
|
292
|
+
if gitignore_changed and gitignore_path not in created:
|
|
293
|
+
created.append(gitignore_path)
|
|
294
|
+
return created
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def set_record_language(root: Path, language: str) -> Path:
|
|
298
|
+
init_context(root)
|
|
299
|
+
ctx = context_dir(root)
|
|
300
|
+
normalized = normalize_record_language(language)
|
|
301
|
+
preferences = default_preferences()
|
|
302
|
+
preferences.update(read_preferences(ctx))
|
|
303
|
+
preferences["record_language"] = normalized
|
|
304
|
+
preferences["display_language"] = display_language_code(normalized)
|
|
305
|
+
preferences["last_updated"] = datetime.now().strftime("%Y-%m-%d")
|
|
306
|
+
write_preferences(ctx, preferences)
|
|
307
|
+
print(f"[context-guard] record language set: {normalized}")
|
|
308
|
+
return ctx / "preferences.json"
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def utc_now() -> str:
|
|
312
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def safe_identifier(value: str, fallback: str = "session") -> str:
|
|
316
|
+
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", (value or "").strip()).strip("-._")
|
|
317
|
+
return (cleaned or fallback)[:120]
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def ensure_session_file(root: Path, session_id: str, platform: str) -> Path:
|
|
321
|
+
init_context(root)
|
|
322
|
+
path = context_dir(root) / "sessions" / f"{safe_identifier(session_id)}.md"
|
|
323
|
+
write_if_missing(
|
|
324
|
+
path,
|
|
325
|
+
"\n".join(
|
|
326
|
+
[
|
|
327
|
+
f"# Session {session_id}",
|
|
328
|
+
"",
|
|
329
|
+
f"- platform: {platform}",
|
|
330
|
+
f"- started: {utc_now()}",
|
|
331
|
+
"",
|
|
332
|
+
"## Events",
|
|
333
|
+
"",
|
|
334
|
+
]
|
|
335
|
+
),
|
|
336
|
+
)
|
|
337
|
+
return path
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def append_session_event(
|
|
341
|
+
root: Path,
|
|
342
|
+
event: str,
|
|
343
|
+
platform: str,
|
|
344
|
+
session_id: str,
|
|
345
|
+
details: dict[str, object] | None = None,
|
|
346
|
+
) -> Path:
|
|
347
|
+
init_context(root)
|
|
348
|
+
ctx = context_dir(root)
|
|
349
|
+
record: dict[str, object] = {
|
|
350
|
+
"at": utc_now(),
|
|
351
|
+
"event": event,
|
|
352
|
+
"platform": platform,
|
|
353
|
+
"session_id": session_id,
|
|
354
|
+
}
|
|
355
|
+
if details:
|
|
356
|
+
record.update(details)
|
|
357
|
+
with (ctx / "sessions.jsonl").open("a", encoding="utf-8", newline="\n") as handle:
|
|
358
|
+
handle.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
359
|
+
session_path = ensure_session_file(root, session_id, platform)
|
|
360
|
+
with session_path.open("a", encoding="utf-8", newline="\n") as handle:
|
|
361
|
+
handle.write(f"- {record['at']} · {event}\n")
|
|
362
|
+
return session_path
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def next_bug_id(ctx: Path) -> str:
|
|
366
|
+
numbers = []
|
|
367
|
+
for path in (ctx / "bugs").glob("B*.md"):
|
|
368
|
+
match = re.fullmatch(r"B(\d+)", path.stem)
|
|
369
|
+
if match:
|
|
370
|
+
numbers.append(int(match.group(1)))
|
|
371
|
+
return f"B{max(numbers, default=0) + 1}"
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def find_map_node(node: object, node_id: str) -> dict[str, object] | None:
|
|
375
|
+
if not isinstance(node, dict):
|
|
376
|
+
return None
|
|
377
|
+
if str(node.get("id", "")) == node_id:
|
|
378
|
+
return node
|
|
379
|
+
children = node.get("children")
|
|
380
|
+
if isinstance(children, list):
|
|
381
|
+
for child in children:
|
|
382
|
+
found = find_map_node(child, node_id)
|
|
383
|
+
if found:
|
|
384
|
+
return found
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def attach_bug_to_map(ctx: Path, bug: dict[str, object], node_id: str) -> None:
|
|
389
|
+
path = ctx / "map.json"
|
|
390
|
+
document = read_json(path, {})
|
|
391
|
+
if not isinstance(document, dict):
|
|
392
|
+
return
|
|
393
|
+
root = document.get("root")
|
|
394
|
+
target = find_map_node(root, node_id) if node_id else (root if isinstance(root, dict) else None)
|
|
395
|
+
if not isinstance(target, dict):
|
|
396
|
+
unassigned = document.get("unassigned_bugs")
|
|
397
|
+
if not isinstance(unassigned, list):
|
|
398
|
+
unassigned = []
|
|
399
|
+
document["unassigned_bugs"] = unassigned
|
|
400
|
+
unassigned[:] = [item for item in unassigned if not isinstance(item, dict) or item.get("id") != bug["id"]]
|
|
401
|
+
unassigned.append(bug)
|
|
402
|
+
else:
|
|
403
|
+
bugs = target.get("bugs")
|
|
404
|
+
if not isinstance(bugs, list):
|
|
405
|
+
bugs = []
|
|
406
|
+
target["bugs"] = bugs
|
|
407
|
+
bugs[:] = [item for item in bugs if not isinstance(item, dict) or item.get("id") != bug["id"]]
|
|
408
|
+
bugs.append(bug)
|
|
409
|
+
document["updated"] = datetime.now().strftime("%Y-%m-%d")
|
|
410
|
+
write_json(path, document)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def record_bad_case(
|
|
414
|
+
root: Path,
|
|
415
|
+
title: str,
|
|
416
|
+
phenomenon: str,
|
|
417
|
+
trigger: str,
|
|
418
|
+
cause: str,
|
|
419
|
+
guard: str,
|
|
420
|
+
node: str,
|
|
421
|
+
status: str,
|
|
422
|
+
keys: str,
|
|
423
|
+
) -> tuple[str, Path]:
|
|
424
|
+
init_context(root)
|
|
425
|
+
ctx = context_dir(root)
|
|
426
|
+
bug_id = next_bug_id(ctx)
|
|
427
|
+
key_list = [item.strip() for item in keys.split(",") if item.strip()]
|
|
428
|
+
bug_path = ctx / "bugs" / f"{bug_id}.md"
|
|
429
|
+
card_path = f".codex/context/cards/{node}.md" if node else ""
|
|
430
|
+
lines = [
|
|
431
|
+
f"# {bug_id} {title.strip()}",
|
|
432
|
+
"",
|
|
433
|
+
f"- node: {node or 'unassigned'}",
|
|
434
|
+
f"- status: {status}",
|
|
435
|
+
f"- 现象: {phenomenon.strip()}",
|
|
436
|
+
f"- 触发: {trigger.strip()}",
|
|
437
|
+
f"- 原因: {cause.strip() or '待确认'}",
|
|
438
|
+
f"- guard: {guard.strip() or '待补充'}",
|
|
439
|
+
f"- keys: {', '.join(key_list)}",
|
|
440
|
+
]
|
|
441
|
+
if card_path:
|
|
442
|
+
lines.append(f"- card: {card_path}")
|
|
443
|
+
bug_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
444
|
+
|
|
445
|
+
index = read_json(ctx / "bugs-index.json", {})
|
|
446
|
+
if not isinstance(index, dict):
|
|
447
|
+
index = {}
|
|
448
|
+
entry: dict[str, object] = {
|
|
449
|
+
"title": title.strip(),
|
|
450
|
+
"keys": key_list,
|
|
451
|
+
"status": status,
|
|
452
|
+
"bug": f".codex/context/bugs/{bug_id}.md",
|
|
453
|
+
"fix": f".codex/context/fixes/{bug_id}.md",
|
|
454
|
+
}
|
|
455
|
+
if card_path:
|
|
456
|
+
entry["card"] = card_path
|
|
457
|
+
index[bug_id] = entry
|
|
458
|
+
write_json(ctx / "bugs-index.json", index)
|
|
459
|
+
|
|
460
|
+
attach_bug_to_map(
|
|
461
|
+
ctx,
|
|
462
|
+
{
|
|
463
|
+
"id": bug_id,
|
|
464
|
+
"title": title.strip(),
|
|
465
|
+
"desc": phenomenon.strip(),
|
|
466
|
+
"status": status,
|
|
467
|
+
"files": "",
|
|
468
|
+
"sessions": "",
|
|
469
|
+
"record": f".codex/context/bugs/{bug_id}.md",
|
|
470
|
+
},
|
|
471
|
+
node,
|
|
472
|
+
)
|
|
473
|
+
print(f"[context-guard] recorded bad case: {bug_id} ({bug_path})")
|
|
474
|
+
return bug_id, bug_path
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def workbench_state_path(root: Path) -> Path:
|
|
478
|
+
return context_dir(root) / "private" / "workbench.json"
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def workbench_health(url: str, timeout: float = 0.5, report_error: bool = False) -> dict[str, object] | None:
|
|
482
|
+
health_url = url.split("/prototype/", 1)[0].rstrip("/") + "/__context_guard/health"
|
|
483
|
+
try:
|
|
484
|
+
with urllib.request.urlopen(health_url, timeout=timeout) as response:
|
|
485
|
+
data = json.loads(response.read().decode("utf-8"))
|
|
486
|
+
return data if isinstance(data, dict) else None
|
|
487
|
+
except (OSError, ValueError, urllib.error.URLError) as exc:
|
|
488
|
+
if report_error:
|
|
489
|
+
print(f"[context-guard] health request failed: {exc}", file=sys.stderr)
|
|
490
|
+
return None
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def running_workbench(root: Path) -> dict[str, object] | None:
|
|
494
|
+
state = read_json(workbench_state_path(root), {})
|
|
495
|
+
if not isinstance(state, dict) or not isinstance(state.get("url"), str):
|
|
496
|
+
return None
|
|
497
|
+
health = workbench_health(str(state["url"]))
|
|
498
|
+
if not health or health.get("root") != str(root.resolve()):
|
|
499
|
+
return None
|
|
500
|
+
return state
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def first_available_port(host: str, preferred: int) -> int:
|
|
504
|
+
for port in range(preferred, preferred + 21):
|
|
505
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
506
|
+
try:
|
|
507
|
+
sock.bind((host, port))
|
|
508
|
+
except OSError:
|
|
509
|
+
continue
|
|
510
|
+
return port
|
|
511
|
+
raise OSError(f"no available port from {preferred} to {preferred + 20}")
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
class WorkbenchHandler(SimpleHTTPRequestHandler):
|
|
515
|
+
server_version = "ContextGuardWorkbench/1.0"
|
|
516
|
+
|
|
517
|
+
def log_message(self, _format: str, *_args: object) -> None:
|
|
518
|
+
return
|
|
519
|
+
|
|
520
|
+
def do_GET(self) -> None: # noqa: N802 - stdlib handler API
|
|
521
|
+
if urlsplit(self.path).path == "/__context_guard/health":
|
|
522
|
+
body = json.dumps(
|
|
523
|
+
{"ok": True, "root": str(self.server.project_root), "pid": os.getpid()},
|
|
524
|
+
ensure_ascii=False,
|
|
525
|
+
).encode("utf-8")
|
|
526
|
+
self.send_response(200)
|
|
527
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
528
|
+
self.send_header("Content-Length", str(len(body)))
|
|
529
|
+
self.end_headers()
|
|
530
|
+
self.wfile.write(body)
|
|
531
|
+
return
|
|
532
|
+
if urlsplit(self.path).path == "/":
|
|
533
|
+
self.send_response(302)
|
|
534
|
+
self.send_header("Location", "/prototype/workbench.html")
|
|
535
|
+
self.end_headers()
|
|
536
|
+
return
|
|
537
|
+
super().do_GET()
|
|
538
|
+
|
|
539
|
+
def translate_path(self, request_path: str) -> str:
|
|
540
|
+
raw_path = unquote(urlsplit(request_path).path).replace("\\", "/")
|
|
541
|
+
parts = [part for part in raw_path.split("/") if part not in {"", ".", ".."}]
|
|
542
|
+
if parts and parts[0] == "prototype":
|
|
543
|
+
base = self.server.skill_root.resolve()
|
|
544
|
+
elif parts[:2] == [".codex", "context"] and parts[2:] in [
|
|
545
|
+
["map.json"],
|
|
546
|
+
["preferences.json"],
|
|
547
|
+
["l1-candidates.json"],
|
|
548
|
+
]:
|
|
549
|
+
base = self.server.project_root.resolve()
|
|
550
|
+
else:
|
|
551
|
+
return str(self.server.project_root / ".codex" / "context" / "__not_found__")
|
|
552
|
+
candidate = base.joinpath(*parts).resolve()
|
|
553
|
+
try:
|
|
554
|
+
candidate.relative_to(base)
|
|
555
|
+
except ValueError:
|
|
556
|
+
return str(base / "__not_found__")
|
|
557
|
+
return str(candidate)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
class WorkbenchServer(ThreadingHTTPServer):
|
|
561
|
+
daemon_threads = True
|
|
562
|
+
|
|
563
|
+
def server_bind(self) -> None:
|
|
564
|
+
# HTTPServer performs reverse DNS here. A loopback-only workbench does
|
|
565
|
+
# not need it, and macOS DNS lookup can stall before serving requests.
|
|
566
|
+
TCPServer.server_bind(self)
|
|
567
|
+
host, port = self.server_address[:2]
|
|
568
|
+
self.server_name = host
|
|
569
|
+
self.server_port = port
|
|
570
|
+
|
|
571
|
+
def __init__(self, address: tuple[str, int], root: Path):
|
|
572
|
+
self.project_root = root.resolve()
|
|
573
|
+
self.skill_root = context_guard_skill_root()
|
|
574
|
+
super().__init__(address, WorkbenchHandler)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def workbench_url(host: str, port: int) -> str:
|
|
578
|
+
return f"http://{host}:{port}/prototype/workbench.html"
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def validate_workbench_host(host: str) -> None:
|
|
582
|
+
if host not in {"127.0.0.1", "localhost"}:
|
|
583
|
+
raise ValueError("workbench host must be 127.0.0.1 or localhost")
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def serve_workbench(root: Path, host: str, port: int) -> int:
|
|
587
|
+
validate_workbench_host(host)
|
|
588
|
+
init_context(root)
|
|
589
|
+
print(f"[context-guard] binding workbench at {host}:{port}", flush=True)
|
|
590
|
+
server = WorkbenchServer((host, port), root)
|
|
591
|
+
actual_port = int(server.server_address[1])
|
|
592
|
+
url = workbench_url(host, actual_port)
|
|
593
|
+
state = {"pid": os.getpid(), "root": str(root.resolve()), "url": url, "started": utc_now()}
|
|
594
|
+
write_json(workbench_state_path(root), state)
|
|
595
|
+
print(f"[context-guard] workbench: {url}", flush=True)
|
|
596
|
+
try:
|
|
597
|
+
server.serve_forever(poll_interval=0.2)
|
|
598
|
+
except KeyboardInterrupt:
|
|
599
|
+
pass
|
|
600
|
+
finally:
|
|
601
|
+
server.server_close()
|
|
602
|
+
current = read_json(workbench_state_path(root), {})
|
|
603
|
+
if isinstance(current, dict) and current.get("pid") == os.getpid():
|
|
604
|
+
workbench_state_path(root).unlink(missing_ok=True)
|
|
605
|
+
return 0
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def maybe_open_browser(url: str, enabled: bool) -> None:
|
|
609
|
+
if not enabled or os.environ.get("CONTEXT_GUARD_HEADLESS") == "1" or os.environ.get("CI"):
|
|
610
|
+
return
|
|
611
|
+
try:
|
|
612
|
+
webbrowser.open(url, new=2)
|
|
613
|
+
except Exception:
|
|
614
|
+
pass
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def start_workbench(
|
|
618
|
+
root: Path,
|
|
619
|
+
host: str = "127.0.0.1",
|
|
620
|
+
port: int = 8877,
|
|
621
|
+
open_browser: bool = True,
|
|
622
|
+
) -> str | None:
|
|
623
|
+
validate_workbench_host(host)
|
|
624
|
+
if os.environ.get("CONTEXT_GUARD_DISABLE_WORKBENCH") == "1":
|
|
625
|
+
return None
|
|
626
|
+
init_context(root)
|
|
627
|
+
current = running_workbench(root)
|
|
628
|
+
if current:
|
|
629
|
+
url = str(current["url"])
|
|
630
|
+
maybe_open_browser(url, open_browser)
|
|
631
|
+
return url
|
|
632
|
+
|
|
633
|
+
port = first_available_port(host, port)
|
|
634
|
+
command = [
|
|
635
|
+
sys.executable,
|
|
636
|
+
str(Path(__file__).resolve()),
|
|
637
|
+
"workbench",
|
|
638
|
+
"--root",
|
|
639
|
+
str(root.resolve()),
|
|
640
|
+
"--host",
|
|
641
|
+
host,
|
|
642
|
+
"--port",
|
|
643
|
+
str(port),
|
|
644
|
+
"--foreground",
|
|
645
|
+
"--no-open",
|
|
646
|
+
]
|
|
647
|
+
kwargs: dict[str, object] = {
|
|
648
|
+
"cwd": str(root),
|
|
649
|
+
"stdin": subprocess.DEVNULL,
|
|
650
|
+
"stdout": subprocess.DEVNULL,
|
|
651
|
+
"stderr": subprocess.DEVNULL,
|
|
652
|
+
"close_fds": True,
|
|
653
|
+
}
|
|
654
|
+
if os.name == "nt":
|
|
655
|
+
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
|
|
656
|
+
else:
|
|
657
|
+
kwargs["start_new_session"] = True
|
|
658
|
+
log_path = workbench_state_path(root).with_suffix(".log")
|
|
659
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
660
|
+
with log_path.open("w", encoding="utf-8") as log_file:
|
|
661
|
+
kwargs["stdout"] = log_file
|
|
662
|
+
kwargs["stderr"] = log_file
|
|
663
|
+
process = subprocess.Popen(command, **kwargs)
|
|
664
|
+
url = workbench_url(host, port)
|
|
665
|
+
deadline = time.monotonic() + 10
|
|
666
|
+
health = None
|
|
667
|
+
while time.monotonic() < deadline:
|
|
668
|
+
health = workbench_health(url, timeout=0.2)
|
|
669
|
+
if health and health.get("root") == str(root.resolve()):
|
|
670
|
+
maybe_open_browser(url, open_browser)
|
|
671
|
+
return url
|
|
672
|
+
if process.poll() is not None:
|
|
673
|
+
break
|
|
674
|
+
time.sleep(0.1)
|
|
675
|
+
exit_code = process.poll()
|
|
676
|
+
workbench_health(url, report_error=True)
|
|
677
|
+
if exit_code is None:
|
|
678
|
+
process.terminate()
|
|
679
|
+
detail = log_path.read_text(encoding="utf-8", errors="replace").strip()
|
|
680
|
+
print(
|
|
681
|
+
f"[context-guard] workbench startup failed; exit={exit_code}; "
|
|
682
|
+
f"health={health!r}; state={read_json(workbench_state_path(root), {})!r}; "
|
|
683
|
+
f"expected_root={str(root.resolve())!r}; log: {log_path}"
|
|
684
|
+
+ (f"\n{detail[-2000:]}" if detail else ""),
|
|
685
|
+
file=sys.stderr,
|
|
686
|
+
)
|
|
687
|
+
return None
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def stop_workbench(root: Path) -> bool:
|
|
691
|
+
state = running_workbench(root)
|
|
692
|
+
if not state:
|
|
693
|
+
workbench_state_path(root).unlink(missing_ok=True)
|
|
694
|
+
return False
|
|
695
|
+
pid = state.get("pid")
|
|
696
|
+
if not isinstance(pid, int) or pid == os.getpid():
|
|
697
|
+
return False
|
|
698
|
+
try:
|
|
699
|
+
os.kill(pid, signal.SIGTERM)
|
|
700
|
+
except OSError:
|
|
701
|
+
return False
|
|
702
|
+
for _ in range(20):
|
|
703
|
+
if not workbench_health(str(state["url"]), timeout=0.1):
|
|
704
|
+
break
|
|
705
|
+
time.sleep(0.1)
|
|
706
|
+
workbench_state_path(root).unlink(missing_ok=True)
|
|
707
|
+
return True
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def show_roadmap(root: Path, should_open: bool) -> int:
|
|
711
|
+
url = start_workbench(root, open_browser=should_open)
|
|
712
|
+
if not url:
|
|
713
|
+
print("[context-guard] workbench could not be started", file=sys.stderr)
|
|
714
|
+
return 1
|
|
715
|
+
print(f"[context-guard] live map: {context_dir(root) / 'map.json'}")
|
|
716
|
+
print(f"[context-guard] workbench: {url}")
|
|
717
|
+
return 0
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def parked_command(name: str) -> int:
|
|
721
|
+
print(
|
|
722
|
+
f"[context-guard] `{name}` is parked. v1 is sessions / bugs / tasks / map. "
|
|
723
|
+
"See TODO.md at the repo root. Do not expand Test Hub or Roadmap HTML.",
|
|
724
|
+
file=sys.stderr,
|
|
725
|
+
)
|
|
726
|
+
return 2
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def main() -> int:
|
|
730
|
+
configure_stdio()
|
|
731
|
+
parser = argparse.ArgumentParser(description="Context Guard v1 utilities")
|
|
732
|
+
parser.add_argument(
|
|
733
|
+
"command",
|
|
734
|
+
choices=["init", "set-language", "show-roadmap", "workbench", "record-bad-case", *PARKED],
|
|
735
|
+
)
|
|
736
|
+
parser.add_argument("--root", type=Path, default=None)
|
|
737
|
+
parser.add_argument("--language", default=None)
|
|
738
|
+
parser.add_argument("--open", action="store_true")
|
|
739
|
+
parser.add_argument("--no-open", action="store_true")
|
|
740
|
+
parser.add_argument("--foreground", action="store_true")
|
|
741
|
+
parser.add_argument("--stop", action="store_true")
|
|
742
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
743
|
+
parser.add_argument("--port", type=int, default=8877)
|
|
744
|
+
parser.add_argument("--title", default="")
|
|
745
|
+
parser.add_argument("--phenomenon", default="")
|
|
746
|
+
parser.add_argument("--trigger", default="")
|
|
747
|
+
parser.add_argument("--cause", default="")
|
|
748
|
+
parser.add_argument("--guard", default="")
|
|
749
|
+
parser.add_argument("--node", default="")
|
|
750
|
+
parser.add_argument("--status", choices=["open", "fixed", "deferred", "wontfix"], default="open")
|
|
751
|
+
parser.add_argument("--keys", default="")
|
|
752
|
+
args, _unknown = parser.parse_known_args()
|
|
753
|
+
explicit = args.root is not None
|
|
754
|
+
root = (args.root or folder_root(Path.cwd())).resolve()
|
|
755
|
+
blocked = guard_implicit_skill_root(root, explicit)
|
|
756
|
+
if blocked:
|
|
757
|
+
return blocked
|
|
758
|
+
if args.command in PARKED:
|
|
759
|
+
return parked_command(args.command)
|
|
760
|
+
if args.command == "init":
|
|
761
|
+
created = init_context(root)
|
|
762
|
+
print(f"[context-guard] context: {context_dir(root)}")
|
|
763
|
+
if created:
|
|
764
|
+
print(f"[context-guard] created {len(created)} path(s)")
|
|
765
|
+
return 0
|
|
766
|
+
if args.command == "set-language":
|
|
767
|
+
if not args.language:
|
|
768
|
+
print("[context-guard] set-language needs --language", file=sys.stderr)
|
|
769
|
+
return 2
|
|
770
|
+
set_record_language(root, args.language)
|
|
771
|
+
return 0
|
|
772
|
+
if args.command == "record-bad-case":
|
|
773
|
+
if not args.title or not args.phenomenon:
|
|
774
|
+
print("[context-guard] record-bad-case needs --title and --phenomenon", file=sys.stderr)
|
|
775
|
+
return 2
|
|
776
|
+
record_bad_case(
|
|
777
|
+
root,
|
|
778
|
+
args.title,
|
|
779
|
+
args.phenomenon,
|
|
780
|
+
args.trigger,
|
|
781
|
+
args.cause,
|
|
782
|
+
args.guard,
|
|
783
|
+
args.node,
|
|
784
|
+
args.status,
|
|
785
|
+
args.keys,
|
|
786
|
+
)
|
|
787
|
+
return 0
|
|
788
|
+
if args.command == "workbench":
|
|
789
|
+
if args.stop:
|
|
790
|
+
stopped = stop_workbench(root)
|
|
791
|
+
print(f"[context-guard] workbench: {'stopped' if stopped else 'not running'}")
|
|
792
|
+
return 0
|
|
793
|
+
try:
|
|
794
|
+
if args.foreground:
|
|
795
|
+
return serve_workbench(root, args.host, args.port)
|
|
796
|
+
return show_roadmap(root, not args.no_open)
|
|
797
|
+
except (OSError, ValueError) as exc:
|
|
798
|
+
print(f"[context-guard] workbench failed: {exc}", file=sys.stderr)
|
|
799
|
+
return 1
|
|
800
|
+
if args.command == "show-roadmap":
|
|
801
|
+
return show_roadmap(root, args.open and not args.no_open)
|
|
802
|
+
return 2
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
if __name__ == "__main__":
|
|
806
|
+
raise SystemExit(main())
|