@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.
Files changed (36) hide show
  1. package/README.md +113 -150
  2. package/README.zh-CN.md +113 -150
  3. package/SKILL.md +46 -0
  4. package/agents/openai.yaml +4 -0
  5. package/bin/context-guard-skill.js +234 -46
  6. package/bin/postinstall.js +1 -1
  7. package/hooks.json +29 -5
  8. package/package.json +12 -5
  9. package/prototype/workbench.html +4933 -0
  10. package/references/bug-record-template.md +37 -0
  11. package/references/context-template.md +19 -0
  12. package/scripts/context_guard.py +806 -0
  13. package/scripts/context_guard_hook.py +302 -0
  14. package/scripts/map_owns.py +769 -0
  15. package/skills/context-guard/README.md +0 -234
  16. package/skills/context-guard/README.zh-CN.md +0 -234
  17. package/skills/context-guard/SKILL.md +0 -558
  18. package/skills/context-guard/agents/openai.yaml +0 -4
  19. package/skills/context-guard/references/context-template.md +0 -290
  20. package/skills/context-guard/references/register-template.md +0 -85
  21. package/skills/context-guard/references/task-case-template.md +0 -63
  22. package/skills/context-guard/scripts/context_guard.py +0 -4886
  23. package/skills/context-guard/scripts/context_guard_hook.py +0 -522
  24. package/skills/context-guard/tests/BC-20260618-063.sh +0 -116
  25. package/skills/context-guard/tests/BC-20260618-065.sh +0 -66
  26. package/skills/context-guard/tests/BC-20260626-080.sh +0 -48
  27. package/skills/context-guard/tests/BC-20260626-081.sh +0 -40
  28. package/skills/context-guard/tests/BC-20260626-082.sh +0 -32
  29. package/skills/context-guard/tests/BC-20260626-083.sh +0 -66
  30. package/skills/context-guard/tests/BC-20260627-084.sh +0 -74
  31. package/skills/context-guard/tests/BC-20260630-086.sh +0 -50
  32. package/skills/context-guard/tests/BC-20260630-087.sh +0 -103
  33. package/skills/context-guard/tests/BC-20260630-088.sh +0 -32
  34. package/skills/context-guard/tests/BC-20260630-089.sh +0 -63
  35. package/skills/context-guard/tests/BC-20260701-090.sh +0 -83
  36. 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())