@michelj/context-guard 0.4.4 → 0.6.1

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 (101) hide show
  1. package/Coordinator.md +88 -0
  2. package/Executor.md +53 -0
  3. package/README.md +72 -102
  4. package/README.zh-CN.md +72 -102
  5. package/SKILL.md +26 -33
  6. package/THIRD_PARTY_NOTICES.md +47 -0
  7. package/Tester.md +53 -0
  8. package/bin/build-runtime.mjs +96 -0
  9. package/bin/context-guard-skill.js +287 -69
  10. package/bin/postinstall.js +1 -1
  11. package/hooks.json +80 -4
  12. package/licenses/JSONParse-MIT.txt +24 -0
  13. package/licenses/Marked-MIT.txt +44 -0
  14. package/licenses/Portless-Apache-2.0.txt +201 -0
  15. package/package.json +31 -5
  16. package/prototype/LICENSES/Marked-MIT.txt +44 -0
  17. package/prototype/LICENSES/Ready-redistribution.txt +14 -0
  18. package/prototype/attachments.mjs +75 -0
  19. package/prototype/coordinator-markdown.mjs +283 -0
  20. package/prototype/coordinator-working-blot.mjs +124 -0
  21. package/prototype/vendor/marked.mjs +2189 -0
  22. package/prototype/workbench-app.js +5197 -0
  23. package/prototype/workbench-data.js +33 -0
  24. package/prototype/workbench-sync.mjs +898 -0
  25. package/prototype/workbench.css +1050 -0
  26. package/prototype/workbench.html +139 -4861
  27. package/prototype/working-blot-atlas.png +0 -0
  28. package/references/agent-handoff.md +40 -0
  29. package/references/claude-runtime.md +120 -0
  30. package/references/cloud-sync-interface.md +66 -0
  31. package/references/design-current.md +14 -0
  32. package/references/map-mount.md +41 -0
  33. package/references/map-read.md +50 -0
  34. package/references/memory-definition.md +120 -0
  35. package/references/memory-filesystem-v2/Bug.en.md +162 -0
  36. package/references/memory-filesystem-v2/Bug.md +162 -0
  37. package/references/memory-filesystem-v2/Bug_Coordinater.md +8 -0
  38. package/references/memory-filesystem-v2/Bug_Executor.md +8 -0
  39. package/references/memory-filesystem-v2/Bug_Tester.md +7 -0
  40. package/references/memory-filesystem-v2/Idea.en.md +36 -0
  41. package/references/memory-filesystem-v2/Idea.md +36 -0
  42. package/references/memory-filesystem-v2/Node_Module_Index.en.md +88 -0
  43. package/references/memory-filesystem-v2/Node_Module_Index.md +88 -0
  44. package/references/memory-filesystem-v2/README.md +60 -0
  45. package/references/memory-filesystem-v2/Todo.en.md +137 -0
  46. package/references/memory-filesystem-v2/Todo.md +137 -0
  47. package/references/memory-filesystem-v2/Todo_Coordinater.md +7 -0
  48. package/references/memory-filesystem-v2/Todo_Executor.md +7 -0
  49. package/references/memory-filesystem-v2/Todo_Tester.md +7 -0
  50. package/references/named-workbench.md +124 -0
  51. package/references/plan-review.md +12 -0
  52. package/references/server-memory.md +276 -0
  53. package/references/test-check.md +7 -0
  54. package/references/user-reply.md +38 -0
  55. package/references/workbench-interface.md +531 -0
  56. package/roles.md +13 -0
  57. package/scripts/context_guard.py +1163 -321
  58. package/scripts/context_guard_hook.py +1864 -63
  59. package/scripts/map_owns.py +68 -138
  60. package/scripts/shared/LICENSES/JSONParse-MIT.txt +24 -0
  61. package/scripts/shared/filesystem-v2.mjs +430 -0
  62. package/scripts/shared/io.mjs +117 -0
  63. package/scripts/shared/map-model.mjs +506 -0
  64. package/scripts/shared/memory-schema.mjs +13 -0
  65. package/scripts/shared/protocol-blobs.mjs +112 -0
  66. package/scripts/shared/protocol-map.mjs +146 -0
  67. package/scripts/shared/protocol-snapshots.mjs +84 -0
  68. package/scripts/shared/protocol-store.mjs +624 -0
  69. package/scripts/shared/protocol-workflow.mjs +226 -0
  70. package/scripts/shared/protocol.mjs +125 -0
  71. package/scripts/shared/vendor/jsonparse.cjs +413 -0
  72. package/scripts/workbench/access.mjs +496 -0
  73. package/scripts/workbench/attachments.mjs +92 -0
  74. package/scripts/workbench/browser-login.mjs +78 -0
  75. package/scripts/workbench/claude-runtime.mjs +372 -0
  76. package/scripts/workbench/cli.mjs +980 -0
  77. package/scripts/workbench/device-heartbeat.mjs +72 -0
  78. package/scripts/workbench/hook-status.mjs +38 -0
  79. package/scripts/workbench/inbox.mjs +155 -0
  80. package/scripts/workbench/journal.mjs +56 -0
  81. package/scripts/workbench/memory-merge.mjs +65 -0
  82. package/scripts/workbench/memory.mjs +252 -0
  83. package/scripts/workbench/named-proxy.mjs +108 -0
  84. package/scripts/workbench/named.mjs +152 -0
  85. package/scripts/workbench/portless-routes.mjs +51 -0
  86. package/scripts/workbench/project.mjs +327 -0
  87. package/scripts/workbench/projections.mjs +68 -0
  88. package/scripts/workbench/protocol-client.mjs +165 -0
  89. package/scripts/workbench/protocol-delivery.mjs +133 -0
  90. package/scripts/workbench/protocol-device.mjs +316 -0
  91. package/scripts/workbench/protocol-events.mjs +53 -0
  92. package/scripts/workbench/protocol-repository.mjs +58 -0
  93. package/scripts/workbench/reconcile.mjs +244 -0
  94. package/scripts/workbench/registry.mjs +111 -0
  95. package/scripts/workbench/runtime.mjs +54 -0
  96. package/scripts/workbench/server.mjs +1171 -0
  97. package/scripts/workbench/store.mjs +243 -0
  98. package/scripts/workbench/sync-coordinator.mjs +518 -0
  99. package/scripts/workbench/sync.mjs +86 -0
  100. package/references/bug-record-template.md +0 -37
  101. package/references/context-template.md +0 -19
@@ -4,22 +4,23 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import argparse
7
+ import errno
8
+ import functools
9
+ import hashlib
7
10
  import json
8
11
  import os
9
12
  import re
10
- import signal
11
- import socket
12
13
  import subprocess
13
14
  import sys
14
15
  import time
15
- import urllib.error
16
- import urllib.request
17
16
  import webbrowser
17
+ import uuid
18
+ from contextlib import contextmanager
18
19
  from datetime import datetime, timezone
19
- from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
20
20
  from pathlib import Path
21
- from socketserver import TCPServer
22
- from urllib.parse import unquote, urlsplit
21
+
22
+ WINDOWS_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
23
+ WRITABLE_BUG_STATUSES = ("open", "fixed", "resolved", "unfixable")
23
24
 
24
25
 
25
26
  def configure_stdio() -> None:
@@ -29,41 +30,6 @@ def configure_stdio() -> None:
29
30
  stream.reconfigure(encoding="utf-8", errors="backslashreplace")
30
31
 
31
32
 
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
33
  INDEX_MD = """# Context Index
68
34
 
69
35
  - Current: none
@@ -95,16 +61,16 @@ FIND_MD = """# Four stores — jump small, then open one file
95
61
  1. Sessions — `sessions.jsonl` (append-only) and `sessions/{id}.md`
96
62
  2. Bugs — `bugs-index.json`, then `bugs/{id}.md` and `fixes/{id}.md`
97
63
  3. Tasks — `tasks/{id}.md`
98
- 4. Map — workbench writes `map.json`; agent uses `owns-index.json` and `cards/`
64
+ 4. Map — `context-guard map read/apply` uses the authoritative map and a page synchronization checkpoint. `archive-session --files ...` records completed work on owning nodes. Unowned files stay unclassified unless `--input` explicitly assigns them or supplies an evidence-backed node proposal.
99
65
 
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`.
66
+ Do not paste `map.json` or `jump-index.json`. Do not Grep this whole folder. Do not read or update a legacy `roadmap.md`.
67
+ Before using cards/indexes, verify projection-status.json matches the current map version. Generate with `python3 scripts/map_owns.py cards --root <project>`, or read the current node through the Node CLI. See the installed skill references/workbench-interface.md.
102
68
  """
103
69
 
104
70
  ARCHITECTURE_MD = """# Architecture Map
105
71
 
106
72
  Status: pending
107
- Later sessions: open `.codex/context/map.json`. Do not re-analyze unless asked.
73
+ Later sessions: use `context-guard map read --root <project> --session <actual-session-id> --node <id>`. Do not re-analyze unless asked.
108
74
 
109
75
  Last initialized: {today}
110
76
  """
@@ -134,6 +100,7 @@ def folder_root(cwd: Path) -> Path:
134
100
  stderr=subprocess.DEVNULL,
135
101
  text=True,
136
102
  timeout=2,
103
+ creationflags=WINDOWS_NO_WINDOW,
137
104
  ).strip()
138
105
  if out:
139
106
  return Path(out)
@@ -154,6 +121,26 @@ def guard_implicit_skill_root(root: Path, explicit_root: bool) -> int:
154
121
 
155
122
 
156
123
  def context_dir(root: Path) -> Path:
124
+ root = root.resolve()
125
+ binding_file = root / ".codex/context/private/project-binding.json"
126
+ if not binding_file.exists():
127
+ return root / ".codex" / "context"
128
+ binding = json.loads(binding_file.read_text(encoding="utf-8"))
129
+ target = Path(binding.get("projectRoot", ""))
130
+ if binding.get("version") != 1 or not target.is_absolute():
131
+ raise ValueError("Invalid workbench project binding")
132
+ target = target.resolve(strict=True)
133
+ if target == root or (target / ".codex/context/private/project-binding.json").exists():
134
+ raise ValueError("Workbench binding chains are not supported")
135
+ def git_common(folder: Path) -> Path:
136
+ result = subprocess.run(["git", "rev-parse", "--git-common-dir"], cwd=folder,
137
+ text=True, capture_output=True, check=True, timeout=5,
138
+ creationflags=WINDOWS_NO_WINDOW)
139
+ return (folder / result.stdout.strip()).resolve(strict=True)
140
+ if git_common(root) != git_common(target) or not (target / ".codex/context/map.json").is_file():
141
+ raise ValueError("Bound worktree must reference an existing Map in the same Git repository")
142
+ # A legacy project binding selects the service, never another worktree's
143
+ # Session storage. Preserve the source records and registration evidence.
157
144
  return root / ".codex" / "context"
158
145
 
159
146
 
@@ -199,14 +186,43 @@ def read_json(path: Path, default: object) -> object:
199
186
  return default
200
187
 
201
188
 
202
- def write_json(path: Path, value: object) -> None:
189
+ def read_input_json(input_path: str) -> object:
190
+ """Read CLI JSON as UTF-8 bytes so Windows console encodings cannot corrupt it."""
191
+ if input_path == "-":
192
+ return json.loads(sys.stdin.buffer.read().decode("utf-8"))
193
+ return json.loads(Path(input_path).resolve().read_text(encoding="utf-8"))
194
+
195
+
196
+ def atomic_write_text(path: Path, content: str) -> None:
203
197
  path.parent.mkdir(parents=True, exist_ok=True)
204
- path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
198
+ temporary = path.with_name(f"{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
199
+ try:
200
+ with temporary.open("w", encoding="utf-8", newline="\n") as handle:
201
+ handle.write(content)
202
+ handle.flush()
203
+ os.fsync(handle.fileno())
204
+ os.replace(temporary, path)
205
+ if os.name != "nt":
206
+ directory = os.open(path.parent, os.O_RDONLY)
207
+ try:
208
+ os.fsync(directory)
209
+ finally:
210
+ os.close(directory)
211
+ finally:
212
+ temporary.unlink(missing_ok=True)
213
+
214
+
215
+ def write_json(path: Path, value: object) -> None:
216
+ atomic_write_text(path, json.dumps(value, ensure_ascii=False, indent=2) + "\n")
205
217
 
206
218
 
207
219
  def read_preferences(ctx: Path) -> dict[str, str]:
208
- data = read_json(ctx / "preferences.json", {})
209
- return data if isinstance(data, dict) else {}
220
+ local = ctx / "preferences.json"
221
+ data = json.loads(local.read_text(encoding="utf-8")) if local.exists() else {}
222
+ if not isinstance(data, dict):
223
+ raise ValueError("Invalid preferences; repair the file instead of repeating setup")
224
+ shared = run_node_workbench(["preferences", "--root", str(ctx.parent.parent)])
225
+ return {**data, **shared}
210
226
 
211
227
 
212
228
  def write_preferences(ctx: Path, preferences: dict[str, str]) -> None:
@@ -278,7 +294,8 @@ def init_context(root: Path) -> list[Path]:
278
294
  "bootstrap": "pending",
279
295
  "updated": today,
280
296
  "flows": [],
281
- "root": None,
297
+ "project": root.name,
298
+ "root": {"id": "T0", "title": root.name, "kind": "module", "state": "dirty", "children": [], "memories": [], "ideas": [], "bugs": [], "dormant": [], "files": [], "owns": []},
282
299
  },
283
300
  ensure_ascii=False,
284
301
  indent=2,
@@ -295,9 +312,10 @@ def init_context(root: Path) -> list[Path]:
295
312
 
296
313
 
297
314
  def set_record_language(root: Path, language: str) -> Path:
315
+ normalized = normalize_record_language(language)
316
+ run_node_workbench(["preferences", "--root", str(root), "--language", normalized])
298
317
  init_context(root)
299
318
  ctx = context_dir(root)
300
- normalized = normalize_record_language(language)
301
319
  preferences = default_preferences()
302
320
  preferences.update(read_preferences(ctx))
303
321
  preferences["record_language"] = normalized
@@ -346,8 +364,12 @@ def append_session_event(
346
364
  ) -> Path:
347
365
  init_context(root)
348
366
  ctx = context_dir(root)
367
+ recorded_at = utc_now()
349
368
  record: dict[str, object] = {
350
- "at": utc_now(),
369
+ "at": recorded_at,
370
+ "occurred_at": recorded_at,
371
+ "recorded_at": recorded_at,
372
+ "event_id": str(uuid.uuid4()),
351
373
  "event": event,
352
374
  "platform": platform,
353
375
  "session_id": session_id,
@@ -358,10 +380,513 @@ def append_session_event(
358
380
  handle.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
359
381
  session_path = ensure_session_file(root, session_id, platform)
360
382
  with session_path.open("a", encoding="utf-8", newline="\n") as handle:
361
- handle.write(f"- {record['at']} · {event}\n")
383
+ handle.write(f"- {record['at']} · {event} · {record['event_id']}\n")
362
384
  return session_path
363
385
 
364
386
 
387
+ def hook_runtime_path(root: Path, session_id: str) -> Path:
388
+ digest = hashlib.sha256(session_id.encode("utf-8")).hexdigest()
389
+ return context_dir(root) / "private" / "hook-runtime" / f"{digest}.json"
390
+
391
+
392
+ _HOOK_RUNTIME_LOCKS: dict[str, dict[str, object]] = {}
393
+
394
+
395
+ class _HookRuntimeLockLease:
396
+ def __init__(self, key: str):
397
+ self.key = key
398
+ self.closed = False
399
+
400
+ def close(self) -> None:
401
+ if self.closed:
402
+ return
403
+ self.closed = True
404
+ state = _HOOK_RUNTIME_LOCKS.get(self.key)
405
+ if not state:
406
+ return
407
+ count = int(state["count"]) - 1
408
+ if count > 0:
409
+ state["count"] = count
410
+ return
411
+ handle = state["handle"]
412
+ try:
413
+ if os.name == "nt":
414
+ import msvcrt
415
+ handle.seek(0)
416
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
417
+ else:
418
+ import fcntl
419
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
420
+ finally:
421
+ handle.close()
422
+ _HOOK_RUNTIME_LOCKS.pop(self.key, None)
423
+
424
+ def __enter__(self) -> "_HookRuntimeLockLease":
425
+ return self
426
+
427
+ def __exit__(self, _type: object, _value: object, _traceback: object) -> None:
428
+ self.close()
429
+
430
+ def __del__(self) -> None:
431
+ self.close()
432
+
433
+
434
+ def acquire_hook_runtime_lock(root: Path, session_id: str, timeout: float = 10.0) -> _HookRuntimeLockLease:
435
+ """Acquire a crash-safe, re-entrant process lock for one Session runtime."""
436
+ target = hook_runtime_path(root, session_id)
437
+ lock_path = target.with_suffix(".lock")
438
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
439
+ key = str(lock_path.resolve())
440
+ held = _HOOK_RUNTIME_LOCKS.get(key)
441
+ if held:
442
+ held["count"] = int(held["count"]) + 1
443
+ return _HookRuntimeLockLease(key)
444
+
445
+ handle = lock_path.open("a+b")
446
+ if os.name == "nt":
447
+ handle.seek(0, os.SEEK_END)
448
+ if handle.tell() == 0:
449
+ handle.write(b"\0")
450
+ handle.flush()
451
+ deadline = time.monotonic() + timeout
452
+ while True:
453
+ try:
454
+ if os.name == "nt":
455
+ import msvcrt
456
+ handle.seek(0)
457
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
458
+ else:
459
+ import fcntl
460
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
461
+ break
462
+ except (BlockingIOError, OSError) as exc:
463
+ if isinstance(exc, OSError) and exc.errno not in {None, errno.EACCES, errno.EAGAIN}:
464
+ handle.close()
465
+ raise
466
+ if time.monotonic() >= deadline:
467
+ handle.close()
468
+ raise TimeoutError(f"timed out waiting for hook runtime lock: {lock_path}") from exc
469
+ time.sleep(0.02)
470
+ _HOOK_RUNTIME_LOCKS[key] = {"handle": handle, "count": 1}
471
+ return _HookRuntimeLockLease(key)
472
+
473
+
474
+ @contextmanager
475
+ def hook_runtime_lock(root: Path, session_id: str, timeout: float = 10.0):
476
+ lease = acquire_hook_runtime_lock(root, session_id, timeout)
477
+ try:
478
+ yield
479
+ finally:
480
+ lease.close()
481
+
482
+
483
+ def serialize_hook_runtime(session_arg: int):
484
+ """Serialize a CLI operation that performs a runtime read-modify-write."""
485
+ def decorate(function):
486
+ @functools.wraps(function)
487
+ def wrapped(*args, **kwargs):
488
+ root = args[0] if args else kwargs["root"]
489
+ session_id = args[session_arg] if len(args) > session_arg else kwargs.get("session_id", "")
490
+ if not session_id:
491
+ return function(*args, **kwargs)
492
+ with hook_runtime_lock(root, str(session_id)):
493
+ return function(*args, **kwargs)
494
+ return wrapped
495
+ return decorate
496
+
497
+
498
+ def serialize_named_lock(name: str):
499
+ """Serialize a project-wide registry update independently of Session IDs."""
500
+ def decorate(function):
501
+ @functools.wraps(function)
502
+ def wrapped(*args, **kwargs):
503
+ root = args[0] if args else kwargs["root"]
504
+ with hook_runtime_lock(root, f"registry:{name}"):
505
+ return function(*args, **kwargs)
506
+ return wrapped
507
+ return decorate
508
+
509
+
510
+ def read_hook_runtime(root: Path, session_id: str) -> dict[str, object]:
511
+ target = hook_runtime_path(root, session_id)
512
+ if not target.exists():
513
+ value: object = {}
514
+ else:
515
+ try:
516
+ value = json.loads(target.read_text(encoding="utf-8"))
517
+ except (json.JSONDecodeError, OSError) as exc:
518
+ raise ValueError(f"hook runtime is unreadable; preserve and repair {target}") from exc
519
+ if not isinstance(value, dict):
520
+ raise ValueError(f"hook runtime must be a JSON object: {target}")
521
+ recorded_session = value.get("session_id")
522
+ if recorded_session not in {None, session_id}:
523
+ raise ValueError(f"hook runtime belongs to another Session: {target}")
524
+ if "signals" in value and not isinstance(value["signals"], list):
525
+ raise ValueError(f"hook runtime signals must be a list: {target}")
526
+ value.setdefault("v", 1)
527
+ value.setdefault("session_id", session_id)
528
+ value.setdefault("signals", [])
529
+ return value
530
+
531
+
532
+ def write_hook_runtime(root: Path, session_id: str, value: dict[str, object]) -> Path:
533
+ value["v"] = 1
534
+ value["session_id"] = session_id
535
+ value["updated_at"] = utc_now()
536
+ target = hook_runtime_path(root, session_id)
537
+ atomic_write_text(target, json.dumps(value, ensure_ascii=False, indent=2) + "\n")
538
+ return target
539
+
540
+
541
+ def prompt_signal_id(session_id: str, turn_id: str, prompt: str) -> str:
542
+ digest = hashlib.sha256(
543
+ json.dumps([session_id, turn_id, prompt], ensure_ascii=False, separators=(",", ":")).encode("utf-8")
544
+ ).hexdigest()
545
+ return f"SIG-{digest[:20]}"
546
+
547
+
548
+ @serialize_hook_runtime(1)
549
+ def add_prompt_signal(root: Path, session_id: str, turn_id: str, prompt: str) -> dict[str, object]:
550
+ runtime = read_hook_runtime(root, session_id)
551
+ signals = runtime.get("signals")
552
+ if not isinstance(signals, list):
553
+ signals = []
554
+ runtime["signals"] = signals
555
+ signal_id = prompt_signal_id(session_id, turn_id, prompt)
556
+ existing = next((item for item in signals if isinstance(item, dict) and item.get("id") == signal_id), None)
557
+ if existing:
558
+ return existing
559
+ signal: dict[str, object] = {
560
+ "id": signal_id,
561
+ "turn_id": turn_id,
562
+ "created_at": utc_now(),
563
+ "prompt_hash": hashlib.sha256(prompt.encode("utf-8")).hexdigest(),
564
+ "preview": " ".join(prompt.split())[:400],
565
+ "status": "pending",
566
+ }
567
+ signals.append(signal)
568
+ if len(signals) > 100:
569
+ unresolved = [item for item in signals if isinstance(item, dict) and item.get("status") == "pending"]
570
+ resolved = [item for item in signals if isinstance(item, dict) and item.get("status") != "pending"][-50:]
571
+ runtime["signals"] = unresolved + resolved
572
+ write_hook_runtime(root, session_id, runtime)
573
+ return signal
574
+
575
+
576
+ @serialize_hook_runtime(1)
577
+ def resolve_prompt_signal(
578
+ root: Path,
579
+ session_id: str,
580
+ signal_id: str,
581
+ kind: str,
582
+ node_id: str = "",
583
+ record_id: str = "",
584
+ ) -> dict[str, object]:
585
+ runtime = read_hook_runtime(root, session_id)
586
+ signals = runtime.get("signals")
587
+ if not isinstance(signals, list):
588
+ raise ValueError("hook runtime has no prompt signals")
589
+ signal = next((item for item in signals if isinstance(item, dict) and item.get("id") == signal_id), None)
590
+ if not signal:
591
+ raise ValueError(f"unknown prompt signal: {signal_id}")
592
+ previous = str(signal.get("kind") or "")
593
+ if signal.get("status") == "resolved" and previous and previous != kind:
594
+ upgrade = {"task": {"todo", "bad-case"}}
595
+ if kind not in upgrade.get(previous, set()):
596
+ raise ValueError(f"prompt signal is already resolved as {previous}")
597
+ signal.update({
598
+ "status": "resolved",
599
+ "kind": kind,
600
+ "resolved_at": utc_now(),
601
+ "node_id": node_id or None,
602
+ "record_id": record_id or None,
603
+ })
604
+ write_hook_runtime(root, session_id, runtime)
605
+ return signal
606
+
607
+
608
+ @serialize_hook_runtime(1)
609
+ def split_signal(root: Path, session_id: str, signal_id: str, items: object) -> list[dict]:
610
+ """The Agent separates meanings; the hook never guesses from keywords."""
611
+ if not isinstance(items, list) or not 2 <= len(items) <= 20 or not all(isinstance(item, str) and item.strip() for item in items):
612
+ raise ValueError("split-signal needs 2–20 nonempty text items")
613
+ runtime = read_hook_runtime(root, session_id)
614
+ parent = next((item for item in runtime["signals"] if item.get("id") == signal_id), None)
615
+ if not parent:
616
+ raise ValueError("unknown parent signal")
617
+ normalized = [item.strip() for item in items]
618
+ digest = hashlib.sha256(json.dumps(normalized, ensure_ascii=False).encode()).hexdigest()
619
+ if parent.get("status") == "resolved":
620
+ if parent.get("kind") == "split" and parent.get("split_hash") == digest:
621
+ return [item for item in runtime["signals"] if item.get("parent_id") == signal_id]
622
+ raise ValueError("parent signal already resolved; cannot replace its classification")
623
+ children = [{"id": prompt_signal_id(session_id, f"{signal_id}:{index}", text), "parent_id": signal_id,
624
+ "turn_id": parent.get("turn_id"), "created_at": utc_now(), "preview": text[:400],
625
+ "prompt_hash": hashlib.sha256(text.encode()).hexdigest(), "status": "pending"}
626
+ for index, text in enumerate(normalized)]
627
+ parent.update({"status": "resolved", "kind": "split", "resolved_at": utc_now(), "split_hash": digest})
628
+ runtime["signals"].extend(children)
629
+ write_hook_runtime(root, session_id, runtime)
630
+ append_session_event(root, "signal-split", session_platform(root, session_id), session_id,
631
+ {"signal_id": signal_id, "child_ids": [item["id"] for item in children]})
632
+ return children
633
+
634
+
635
+ def session_records(root: Path) -> list[dict[str, object]]:
636
+ records: list[dict[str, object]] = []
637
+ path = context_dir(root) / "sessions.jsonl"
638
+ if not path.exists():
639
+ return records
640
+ for line in path.read_text(encoding="utf-8").splitlines():
641
+ try:
642
+ value = json.loads(line)
643
+ except json.JSONDecodeError:
644
+ continue
645
+ if isinstance(value, dict) and isinstance(value.get("session_id"), str):
646
+ records.append(value)
647
+ return records
648
+
649
+
650
+ def resolve_session_id(root: Path, explicit: str = "") -> str:
651
+ if explicit.strip():
652
+ return explicit.strip()
653
+ for key in ("CODEX_THREAD_ID", "CLAUDE_SESSION_ID", "CURSOR_SESSION_ID"):
654
+ value = os.environ.get(key, "").strip()
655
+ if value:
656
+ return value
657
+ state = read_json(context_dir(root) / "private" / "hook-sessions.json", {})
658
+ if isinstance(state, dict):
659
+ values = [value.strip() for value in state.values() if isinstance(value, str) and value.strip()]
660
+ if values:
661
+ return values[-1]
662
+ return ""
663
+
664
+
665
+ def session_platform(root: Path, session_id: str) -> str:
666
+ platform = "cli"
667
+ for record in session_records(root):
668
+ if record.get("session_id") == session_id and isinstance(record.get("platform"), str):
669
+ platform = str(record["platform"])
670
+ return platform
671
+
672
+
673
+ @serialize_hook_runtime(1)
674
+ def archive_session(
675
+ root: Path,
676
+ session_id: str,
677
+ summary: str,
678
+ decisions: str,
679
+ next_steps: str,
680
+ files: str,
681
+ input_path: str = "",
682
+ ) -> Path:
683
+ init_context(root)
684
+ known = {str(item.get("session_id")) for item in session_records(root)}
685
+ if not session_id or session_id not in known:
686
+ raise ValueError("archive-session needs a session previously recorded by a lifecycle hook")
687
+ file_list = list(dict.fromkeys(item.strip() for item in files.split(",") if item.strip()))
688
+ map_result: dict[str, object] = {
689
+ "committed": True,
690
+ "reconciliation": {"files": [], "mapped": {}, "uncovered": [], "operations": []},
691
+ }
692
+ governance: dict[str, object] = {}
693
+ runtime = read_hook_runtime(root, session_id)
694
+ plan = runtime.get("active_plan")
695
+ require_human_work_review(root, session_id, plan if isinstance(plan, dict) else None)
696
+ closure: dict[str, object] = {}
697
+ if input_path:
698
+ raw_governance = read_input_json(input_path)
699
+ if not isinstance(raw_governance, dict):
700
+ raise ValueError("archive-session --input needs a JSON object")
701
+ unsupported = set(raw_governance) - {"assignments", "proposal", "verification", "assessment", "scope_review", "failure_review", "subagent_review"}
702
+ if unsupported:
703
+ raise ValueError(f"archive-session --input has unsupported fields: {', '.join(sorted(unsupported))}")
704
+ governance = {key: value for key, value in raw_governance.items() if key in {"assignments", "proposal"}}
705
+ closure = {key: value for key, value in raw_governance.items() if key not in governance}
706
+ current_snapshot = None
707
+ if isinstance(plan, dict):
708
+ from context_guard_hook import scope_snapshot
709
+ assessment = closure.get("assessment")
710
+ if not summary.strip() or not isinstance(closure.get("verification"), str) or not closure["verification"].strip():
711
+ raise ValueError("active plan archive needs summary and verification evidence in --input")
712
+ if not isinstance(assessment, dict) or assessment.get("decision") not in {"reuse", "propose", "none"} or not str(assessment.get("reason") or "").strip():
713
+ raise ValueError("archive needs assessment {decision:reuse|propose|none, reason}")
714
+ if (assessment["decision"] == "propose") != bool(governance.get("proposal")):
715
+ raise ValueError("proposal input must agree with the node/module assessment")
716
+ for flag, field in (("scope_review_required", "scope_review"), ("failure_review_required", "failure_review")):
717
+ if plan.get(flag) and not str(closure.get(field) or "").strip():
718
+ raise ValueError(f"archive needs {field}: explain actual scope or failed tool recovery")
719
+ reviews = closure.get("subagent_review") or {}
720
+ for agent_id, agent in (runtime.get("subagents") or {}).items():
721
+ if agent.get("plan_id") == plan["id"] and (not isinstance(reviews, dict) or not str(reviews.get(agent_id) or "").strip()):
722
+ raise ValueError(f"archive needs subagent_review for {agent_id}: verify or explicitly discard its result")
723
+ current_snapshot = scope_snapshot(root, plan["paths"])
724
+ baseline = plan.get("baseline") or {}
725
+ changed = {file for file in set(baseline) | set(current_snapshot) if baseline.get(file) != current_snapshot.get(file)}
726
+ if changed - set(file_list):
727
+ raise ValueError("archive omitted changed files: " + ", ".join(sorted(changed - set(file_list))))
728
+ if set(file_list) - (changed | set(plan.get("actual_paths") or [])):
729
+ raise ValueError("archive includes files not observed changed by the plan")
730
+ if governance and not file_list:
731
+ raise ValueError("archive-session --input needs --files")
732
+ if file_list or isinstance(plan, dict):
733
+ map_result = run_node_workbench(
734
+ ["map", "reconcile", "--root", str(root), "--session", session_id],
735
+ {
736
+ "summary": summary.strip(),
737
+ "decisions": decisions.strip(),
738
+ "next": next_steps.strip(),
739
+ "files": file_list,
740
+ **({"nodeIds": plan["node_ids"] if not file_list else [], "planId": plan["id"],
741
+ "verification": closure["verification"], "assessment": closure["assessment"]} if isinstance(plan, dict) else {}),
742
+ **governance,
743
+ },
744
+ )
745
+ reconciliation = map_result.get("reconciliation", {})
746
+ if not isinstance(reconciliation, dict):
747
+ reconciliation = {}
748
+ if isinstance(plan, dict) and (map_result.get("committed") is not True or reconciliation.get("unclassified") or reconciliation.get("uncovered")):
749
+ raise ValueError("plan archive incomplete: assign uncovered files to existing nodes or submit an evidence-backed proposal")
750
+ node_ids = list((reconciliation.get("mapped") or {}).keys())
751
+ proposed_id = str(reconciliation.get("proposedId") or "")
752
+ if proposed_id:
753
+ node_ids.append(proposed_id)
754
+ path = append_session_event(
755
+ root,
756
+ "archive",
757
+ session_platform(root, session_id),
758
+ session_id,
759
+ {
760
+ "has_summary": bool(summary.strip()),
761
+ "map_sync": "synced",
762
+ "map_nodes": node_ids,
763
+ "map_version": map_result.get("version"),
764
+ },
765
+ )
766
+ lines = ["", f"## Archive {utc_now()}", ""]
767
+ for heading, value in (
768
+ ("Summary", summary),
769
+ ("Decisions", decisions),
770
+ ("Next", next_steps),
771
+ ):
772
+ if value.strip():
773
+ lines.extend([f"### {heading}", "", value.strip(), ""])
774
+ if closure:
775
+ lines.extend(["### Verification and assessment", "", json.dumps(closure, ensure_ascii=False, indent=2), ""])
776
+ if file_list:
777
+ lines.extend(["### Files", "", *[f"- {item}" for item in file_list], ""])
778
+ mapped = reconciliation.get("mapped") or {}
779
+ unclassified = reconciliation.get("unclassified") or reconciliation.get("uncovered") or []
780
+ lines.extend([
781
+ "### Map",
782
+ "",
783
+ "- status: synced",
784
+ f"- existing nodes: {', '.join(mapped) if isinstance(mapped, dict) and mapped else 'none'}",
785
+ f"- proposed node: {proposed_id or 'none'}",
786
+ f"- unclassified files: {', '.join(unclassified) if isinstance(unclassified, list) and unclassified else 'none'}",
787
+ f"- version: {map_result.get('version') or 'unchanged'}",
788
+ "",
789
+ ])
790
+ with path.open("a", encoding="utf-8", newline="\n") as handle:
791
+ handle.write("\n".join(lines).rstrip() + "\n")
792
+ memory = run_node_workbench(["memory", "status", "--root", str(root), "--session", session_id])
793
+ memory_version = None
794
+ if memory.get("current"):
795
+ for attempt in range(3):
796
+ try:
797
+ receipt = run_node_workbench(["memory", "sync", "--root", str(root), "--session", session_id])
798
+ break
799
+ except RuntimeError as exc:
800
+ try:
801
+ code = json.loads(str(exc)).get("error", {}).get("code")
802
+ except ValueError:
803
+ code = None
804
+ if code != "VERSION_CONFLICT" or attempt == 2:
805
+ raise
806
+ snapshot = receipt.get("snapshot") or {}
807
+ memory_version = snapshot.get("version")
808
+ head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=root, capture_output=True, text=True,
809
+ timeout=5, check=False, creationflags=WINDOWS_NO_WINDOW)
810
+ if head.returncode or snapshot.get("sourceCommit") != head.stdout.strip():
811
+ raise ValueError("Server Session memory does not match the current Git commit")
812
+ print(f"[context-guard] server archive acknowledged: {receipt.get('snapshot', {}).get('version')}")
813
+ else:
814
+ print("[context-guard] server memory not configured; local archive remains unsynced")
815
+ if isinstance(plan, dict):
816
+ latest = read_hook_runtime(root, session_id)
817
+ active = latest.get("active_plan") or {}
818
+ if active.get("id") != plan["id"] or active.get("revision") != plan.get("revision"):
819
+ raise ValueError("plan changed during archive; Map write succeeded but completion receipt needs revalidation")
820
+ plan["archive"] = {"at": utc_now(), "revision": plan.get("revision"), "snapshot": current_snapshot,
821
+ "map_version": map_result.get("version"), "node_ids": node_ids,
822
+ **({"server_memory_version": memory_version} if memory_version else {}), **closure}
823
+ latest["active_plan"] = plan
824
+ write_hook_runtime(root, session_id, latest)
825
+ print(f"[context-guard] archived session: {session_id} ({path})")
826
+ if file_list:
827
+ print(
828
+ "[context-guard] map synchronized: "
829
+ f"{len(reconciliation.get('mapped') or {})} existing node(s), "
830
+ f"{len(reconciliation.get('unclassified') or reconciliation.get('uncovered') or [])} unclassified file(s), "
831
+ f"{'1 proposed node' if proposed_id else 'no node proposal'}"
832
+ )
833
+ return path
834
+
835
+
836
+ def validate_candidates(value: object) -> dict[str, object]:
837
+ if not isinstance(value, dict) or not isinstance(value.get("lenses"), list) or not value["lenses"]:
838
+ raise ValueError("candidate input needs a non-empty lenses array")
839
+ if len(value["lenses"]) > 12:
840
+ raise ValueError("candidate input supports at most 12 lenses")
841
+ lens_ids: set[str] = set()
842
+ candidate_ids: set[str] = set()
843
+ normalized: list[dict[str, object]] = []
844
+ for lens in value["lenses"]:
845
+ if not isinstance(lens, dict):
846
+ raise ValueError("each lens must be an object")
847
+ lens_id = safe_identifier(str(lens.get("id", "")), "")
848
+ title = str(lens.get("title", "")).strip()
849
+ candidates = lens.get("candidates")
850
+ if not lens_id or lens_id in lens_ids or not title or not isinstance(candidates, list):
851
+ raise ValueError("each lens needs a unique id, title, and candidates array")
852
+ lens_ids.add(lens_id)
853
+ items: list[dict[str, object]] = []
854
+ for candidate in candidates:
855
+ if not isinstance(candidate, dict):
856
+ raise ValueError("each candidate must be an object")
857
+ candidate_id = safe_identifier(str(candidate.get("id", "")), "")
858
+ candidate_title = str(candidate.get("title", "")).strip()
859
+ if not candidate_id or candidate_id in candidate_ids or not candidate_title:
860
+ raise ValueError("each candidate needs a globally unique id and a title")
861
+ owns = candidate.get("owns", [])
862
+ if not isinstance(owns, list) or any(not isinstance(item, str) for item in owns):
863
+ raise ValueError("candidate owns must be an array of paths")
864
+ candidate_ids.add(candidate_id)
865
+ items.append({
866
+ "id": candidate_id,
867
+ "title": candidate_title,
868
+ "purpose": str(candidate.get("purpose", "")).strip(),
869
+ "owns": [item.strip() for item in owns if item.strip()],
870
+ })
871
+ normalized.append({
872
+ "id": lens_id,
873
+ "title": title,
874
+ "why": str(lens.get("why", "")).strip(),
875
+ "candidates": items,
876
+ })
877
+ return {"v": 1, "generated_at": utc_now(), "lenses": normalized}
878
+
879
+
880
+ def write_candidates(root: Path, input_path: str) -> Path:
881
+ init_context(root)
882
+ value = read_input_json(input_path)
883
+ normalized = validate_candidates(value)
884
+ path = context_dir(root) / "l1-candidates.json"
885
+ write_json(path, normalized)
886
+ print(f"[context-guard] candidates: {path}")
887
+ return path
888
+
889
+
365
890
  def next_bug_id(ctx: Path) -> str:
366
891
  numbers = []
367
892
  for path in (ctx / "bugs").glob("B*.md"):
@@ -376,40 +901,292 @@ def find_map_node(node: object, node_id: str) -> dict[str, object] | None:
376
901
  return None
377
902
  if str(node.get("id", "")) == node_id:
378
903
  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
904
+ for key in ("children", "_inbox"):
905
+ items = node.get(key)
906
+ if isinstance(items, list):
907
+ for child in items:
908
+ found = find_map_node(child, node_id)
909
+ if found:
910
+ return found
911
+ return None
912
+
913
+
914
+ def authoritative_map_file(root: Path, session_id: str = "") -> Path:
915
+ """Resolve the same Session Map file the hook/workbench layer treats as authoritative."""
916
+ ctx = context_dir(root)
917
+ map_file = ctx / "map.json"
918
+ if not session_id:
919
+ return map_file
920
+ probe = subprocess.run(
921
+ ["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
922
+ cwd=str(root.resolve()),
923
+ capture_output=True,
924
+ text=True,
925
+ timeout=5,
926
+ check=False,
927
+ creationflags=WINDOWS_NO_WINDOW,
928
+ )
929
+ if probe.returncode != 0:
930
+ return map_file
931
+ shared = Path(probe.stdout.strip()) / "context-guard"
932
+ bindings = read_json(shared / "workbench-bindings.json", {})
933
+ sessions = bindings.get("sessions") if isinstance(bindings, dict) else None
934
+ bound = sessions.get(session_id) if isinstance(sessions, dict) else None
935
+ if isinstance(bound, dict) and bound.get("worktreeRoot") == str(root.resolve()):
936
+ scope = hashlib.sha256((session_id + "\0" + str(bound.get("worktreeId", ""))).encode("utf-8")).hexdigest()
937
+ return shared / "session-memory" / scope / "map.json"
938
+ return map_file
939
+
940
+
941
+ def load_authoritative_map(root: Path, session_id: str = "") -> dict[str, object]:
942
+ doc = read_json(authoritative_map_file(root, session_id), {})
943
+ return doc if isinstance(doc, dict) else {}
944
+
945
+
946
+ def iter_map_nodes(node: object):
947
+ if not isinstance(node, dict):
948
+ return
949
+ yield node
950
+ for key in ("children", "_inbox"):
951
+ items = node.get(key)
952
+ if isinstance(items, list):
953
+ for child in items:
954
+ yield from iter_map_nodes(child)
955
+
956
+
957
+ def approved_work_item_review(item: object, session_id: str) -> bool:
958
+ if not isinstance(item, dict):
959
+ return False
960
+ review = item.get("review")
961
+ if not isinstance(review, dict) or review.get("decision") != "approved":
962
+ return False
963
+ if str(review.get("sessionId") or "") == session_id:
964
+ return True
965
+ sessions = item.get("sessions") if isinstance(item.get("sessions"), list) else []
966
+ dispatch = item.get("dispatch") if isinstance(item.get("dispatch"), dict) else {}
967
+ return session_id in sessions or dispatch.get("session_id") == session_id
968
+
969
+
970
+ def recorded_human_work_review(root: Path, session_id: str, plan: object = None) -> dict[str, object] | None:
971
+ """Reuse Map item.review and Cloud acceptance receipts. Do not invent a Hook."""
972
+ node_ids = None
973
+ if isinstance(plan, dict):
974
+ ids = plan.get("node_ids")
975
+ if isinstance(ids, list) and any(isinstance(item, str) and item for item in ids):
976
+ node_ids = {str(item) for item in ids if item}
977
+ doc = load_authoritative_map(root, session_id)
978
+ for node in iter_map_nodes(doc.get("root") if isinstance(doc, dict) else None):
979
+ if node_ids is not None and str(node.get("id") or "") not in node_ids:
980
+ continue
981
+ for field in ("bugs", "todos"):
982
+ items = node.get(field)
983
+ if not isinstance(items, list):
984
+ continue
985
+ for item in items:
986
+ if approved_work_item_review(item, session_id):
987
+ return {"source": "map-item-review", "nodeId": node.get("id"), "itemId": item.get("id")}
988
+ try:
989
+ execution = run_node_workbench(["map", "execution", "--root", str(root), "--session", session_id])
990
+ except (OSError, RuntimeError, ValueError, json.JSONDecodeError):
991
+ execution = {}
992
+ active = execution.get("active") if isinstance(execution, dict) and isinstance(execution.get("active"), dict) else {}
993
+ acceptance = active.get("acceptanceReview") if isinstance(active.get("acceptanceReview"), dict) else {}
994
+ if acceptance.get("decision") == "approved":
995
+ return {"source": "cloud-acceptance", "taskId": active.get("taskId")}
996
+ if active.get("stage") in {"accepted", "closing", "closed"}:
997
+ return {"source": "cloud-accepted-stage", "taskId": active.get("taskId")}
385
998
  return None
386
999
 
387
1000
 
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):
1001
+ def require_human_work_review(root: Path, session_id: str, plan: object = None) -> None:
1002
+ if recorded_human_work_review(root, session_id, plan):
392
1003
  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)
1004
+ raise ValueError("Human review of this work is required before archive-session or plan-finish")
1005
+
1006
+
1007
+ def require_known_map_node(root: Path, node_id: str, session_id: str = "") -> None:
1008
+ """Validate a node against the authoritative Session Map, not the legacy disk cache."""
1009
+ if not node_id:
1010
+ return
1011
+ map_doc = load_authoritative_map(root, session_id)
1012
+ map_root = map_doc.get("root") if isinstance(map_doc, dict) else None
1013
+ if find_map_node(map_root, node_id) is None:
1014
+ raise ValueError(f"unknown map node: {node_id}")
411
1015
 
412
1016
 
1017
+ @serialize_hook_runtime(4)
1018
+ def record_todo(
1019
+ root: Path,
1020
+ title: str,
1021
+ description: str,
1022
+ node_id: str,
1023
+ session_id: str,
1024
+ signal_id: str,
1025
+ ) -> dict[str, object]:
1026
+ init_context(root)
1027
+ if not title.strip() or not node_id.strip() or not signal_id.strip():
1028
+ raise ValueError("record-todo needs --title, --node, and --signal")
1029
+ known = {str(item.get("session_id")) for item in session_records(root)}
1030
+ if not session_id or session_id not in known:
1031
+ raise ValueError("record-todo needs a session previously recorded by a lifecycle hook")
1032
+ runtime = read_hook_runtime(root, session_id)
1033
+ signals = runtime.get("signals") if isinstance(runtime.get("signals"), list) else []
1034
+ signal = next((item for item in signals if isinstance(item, dict) and item.get("id") == signal_id), None)
1035
+ if not signal:
1036
+ raise ValueError(f"unknown prompt signal: {signal_id}")
1037
+ if signal.get("status") == "resolved" and signal.get("kind") not in {None, "", "todo", "task"}:
1038
+ raise ValueError(f"prompt signal is already resolved as {signal.get('kind')}")
1039
+ require_known_map_node(root, node_id, session_id)
1040
+ todo_id = "TD-" + hashlib.sha256(f"{session_id}\0{signal_id}".encode("utf-8")).hexdigest()[:16]
1041
+ result = run_node_workbench(
1042
+ ["record-todo", "--root", str(root), "--session", session_id],
1043
+ {
1044
+ "id": todo_id,
1045
+ "node": node_id,
1046
+ "signalId": signal_id,
1047
+ "title": title.strip(),
1048
+ "description": description.strip(),
1049
+ "at": str(signal.get("created_at") or utc_now()),
1050
+ },
1051
+ )
1052
+ resolve_prompt_signal(root, session_id, signal_id, "todo", node_id, todo_id)
1053
+ append_session_event(
1054
+ root,
1055
+ "todo-recorded",
1056
+ session_platform(root, session_id),
1057
+ session_id,
1058
+ {
1059
+ "signal_id": signal_id,
1060
+ "node_ids": [node_id],
1061
+ "record_id": todo_id,
1062
+ "map_version": result.get("version"),
1063
+ },
1064
+ )
1065
+ print(f"[context-guard] recorded todo: {todo_id} ({node_id})")
1066
+ return {"id": todo_id, "node": node_id, "version": result.get("version"), "duplicate": bool(result.get("duplicate"))}
1067
+
1068
+
1069
+ @serialize_hook_runtime(1)
1070
+ def resolve_signal(root: Path, session_id: str, signal_id: str, kind: str) -> dict[str, object]:
1071
+ if kind not in {"task", "ignore"}:
1072
+ raise ValueError("resolve-signal --kind must be task or ignore; use record-todo/record-bad-case for durable records")
1073
+ signal = resolve_prompt_signal(root, session_id, signal_id, kind)
1074
+ append_session_event(
1075
+ root,
1076
+ "signal-resolved",
1077
+ session_platform(root, session_id),
1078
+ session_id,
1079
+ {"signal_id": signal_id, "signal_kind": kind, "turn_id": signal.get("turn_id")},
1080
+ )
1081
+ print(f"[context-guard] resolved signal: {signal_id} ({kind})")
1082
+ return signal
1083
+
1084
+
1085
+ def run_node_workbench(args: list[str], payload: object = None) -> dict:
1086
+ command = ["node", str(Path(__file__).resolve().parent / "workbench" / "cli.mjs"), *args]
1087
+ completed = subprocess.run(command, input=json.dumps(payload, ensure_ascii=False) if payload is not None else None,
1088
+ text=True, encoding="utf-8", capture_output=True, timeout=180,
1089
+ creationflags=WINDOWS_NO_WINDOW)
1090
+ try:
1091
+ result = json.loads(completed.stdout)
1092
+ except ValueError as exc:
1093
+ raise RuntimeError(completed.stderr or completed.stdout or "Node interface failed") from exc
1094
+ if completed.returncode or result.get("error"):
1095
+ raise RuntimeError(json.dumps(result, ensure_ascii=False))
1096
+ return result
1097
+
1098
+
1099
+ def attach_bug_to_map(ctx: Path, bug: dict[str, object], node_id: str, session_id: str) -> None:
1100
+ if not node_id:
1101
+ return
1102
+ args = ["attach-bug", "--root", str(ctx.parent.parent)]
1103
+ if session_id:
1104
+ args.extend(["--session", session_id])
1105
+ run_node_workbench(args, {"node": node_id, "bug": bug})
1106
+
1107
+
1108
+ def bad_case_transaction_dir(ctx: Path) -> Path:
1109
+ return ctx / "private" / "bad-case-transactions"
1110
+
1111
+
1112
+ def bad_case_failpoint(stage: str) -> None:
1113
+ if os.environ.get("CONTEXT_GUARD_TESTING") == "1" and os.environ.get("CONTEXT_GUARD_BAD_CASE_FAILPOINT") == stage:
1114
+ os._exit(91)
1115
+
1116
+
1117
+ def remove_durable_file(path: Path) -> None:
1118
+ path.unlink(missing_ok=True)
1119
+ if os.name != "nt" and path.parent.exists():
1120
+ directory = os.open(path.parent, os.O_RDONLY)
1121
+ try:
1122
+ os.fsync(directory)
1123
+ finally:
1124
+ os.close(directory)
1125
+
1126
+
1127
+ def bad_case_transaction_path(ctx: Path, transaction_id: str) -> Path:
1128
+ digest = hashlib.sha256(transaction_id.encode("utf-8")).hexdigest()
1129
+ return bad_case_transaction_dir(ctx) / f"{digest}.json"
1130
+
1131
+
1132
+ def apply_bad_case_transaction(root: Path, transaction: dict[str, object], journal: Path) -> None:
1133
+ ctx = context_dir(root)
1134
+ operation = str(transaction.get("operation") or "")
1135
+ bug_id = str(transaction.get("bug_id") or "")
1136
+ session_id = str(transaction.get("session_id") or "")
1137
+ if operation not in {"occurrence", "fix"} or not re.fullmatch(r"B\d+", bug_id):
1138
+ raise ValueError(f"invalid bad-case recovery journal: {journal}")
1139
+
1140
+ bug_path = ctx / "bugs" / f"{bug_id}.md"
1141
+ fix_path = ctx / "fixes" / f"{bug_id}.md"
1142
+ atomic_write_text(bug_path, str(transaction["bug_text"]))
1143
+ bad_case_failpoint("after-bug-file")
1144
+ atomic_write_text(fix_path, str(transaction["fix_text"]))
1145
+ bad_case_failpoint("after-fix-file")
1146
+
1147
+ index = read_json(ctx / "bugs-index.json", {})
1148
+ if not isinstance(index, dict):
1149
+ index = {}
1150
+ index[bug_id] = transaction["index_entry"]
1151
+ write_json(ctx / "bugs-index.json", index)
1152
+ bad_case_failpoint("after-index")
1153
+
1154
+ map_bug = transaction.get("map_bug")
1155
+ if operation == "occurrence" and isinstance(map_bug, dict):
1156
+ attach_bug_to_map(ctx, map_bug, str(transaction.get("node") or ""), session_id)
1157
+ elif operation == "fix" and isinstance(transaction.get("index_entry"), dict) and transaction["index_entry"].get("card"):
1158
+ update_bug_on_map(ctx, bug_id, str(transaction.get("status") or "fixed"), session_id)
1159
+ bad_case_failpoint("after-map")
1160
+
1161
+ events = read_json(ctx / "bad-case-events.json", [])
1162
+ if not isinstance(events, list):
1163
+ events = []
1164
+ event = transaction.get("event")
1165
+ transaction_id = str(transaction.get("transaction_id") or "")
1166
+ if isinstance(event, dict) and not any(isinstance(item, dict) and item.get("transaction_id") == transaction_id for item in events):
1167
+ events.append(event)
1168
+ write_json(ctx / "bad-case-events.json", events)
1169
+ bad_case_failpoint("after-event")
1170
+
1171
+ signal_id = str(transaction.get("signal_id") or "")
1172
+ if operation == "occurrence" and signal_id:
1173
+ with hook_runtime_lock(root, session_id):
1174
+ resolve_prompt_signal(root, session_id, signal_id, "bad-case", str(transaction.get("node") or ""), bug_id)
1175
+ remove_durable_file(journal)
1176
+
1177
+
1178
+ def recover_bad_case_transactions(root: Path) -> None:
1179
+ ctx = context_dir(root)
1180
+ directory = bad_case_transaction_dir(ctx)
1181
+ for journal in sorted(directory.glob("*.json")) if directory.exists() else []:
1182
+ transaction = read_json(journal, None)
1183
+ if not isinstance(transaction, dict):
1184
+ raise ValueError(f"bad-case recovery journal is unreadable; preserve and repair {journal}")
1185
+ apply_bad_case_transaction(root, transaction, journal)
1186
+
1187
+
1188
+ @serialize_named_lock("bad-case-registry")
1189
+ @serialize_hook_runtime(9)
413
1190
  def record_bad_case(
414
1191
  root: Path,
415
1192
  title: str,
@@ -420,12 +1197,42 @@ def record_bad_case(
420
1197
  node: str,
421
1198
  status: str,
422
1199
  keys: str,
1200
+ session_id: str,
1201
+ signal_id: str = "",
423
1202
  ) -> tuple[str, Path]:
424
1203
  init_context(root)
1204
+ if status not in WRITABLE_BUG_STATUSES:
1205
+ raise ValueError("Bugs cannot be deferred; delete the Bug file if no fix is needed, or close it as unfixable")
425
1206
  ctx = context_dir(root)
1207
+ recover_bad_case_transactions(root)
1208
+ events = read_json(ctx / "bad-case-events.json", [])
1209
+ if not isinstance(events, list):
1210
+ events = []
1211
+ if signal_id:
1212
+ prior = next((item for item in events if isinstance(item, dict) and item.get("signal_id") == signal_id), None)
1213
+ if prior and re.fullmatch(r"B\d+", str(prior.get("case") or "")):
1214
+ existing_id = str(prior["case"])
1215
+ existing_path = ctx / "bugs" / f"{existing_id}.md"
1216
+ if existing_path.is_file():
1217
+ print(f"[context-guard] recorded bad case: {existing_id} ({existing_path}) [duplicate]")
1218
+ return existing_id, existing_path
1219
+ runtime = read_hook_runtime(root, session_id)
1220
+ signals = runtime.get("signals") if isinstance(runtime.get("signals"), list) else []
1221
+ signal = next((item for item in signals if isinstance(item, dict) and item.get("id") == signal_id), None)
1222
+ if not signal:
1223
+ raise ValueError(f"unknown prompt signal: {signal_id}")
1224
+ if signal.get("status") == "resolved" and signal.get("kind") not in {None, "", "bad-case", "task"}:
1225
+ raise ValueError(f"prompt signal is already resolved as {signal.get('kind')}")
1226
+ if node:
1227
+ require_known_map_node(root, node, session_id)
1228
+ if session_id:
1229
+ known = {str(item.get("session_id")) for item in session_records(root)}
1230
+ if session_id not in known:
1231
+ raise ValueError("bad-case session must first be recorded by a lifecycle hook")
426
1232
  bug_id = next_bug_id(ctx)
427
1233
  key_list = [item.strip() for item in keys.split(",") if item.strip()]
428
1234
  bug_path = ctx / "bugs" / f"{bug_id}.md"
1235
+ fix_path = ctx / "fixes" / f"{bug_id}.md"
429
1236
  card_path = f".codex/context/cards/{node}.md" if node else ""
430
1237
  lines = [
431
1238
  f"# {bug_id} {title.strip()}",
@@ -433,14 +1240,31 @@ def record_bad_case(
433
1240
  f"- node: {node or 'unassigned'}",
434
1241
  f"- status: {status}",
435
1242
  f"- 现象: {phenomenon.strip()}",
436
- f"- 触发: {trigger.strip()}",
437
- f"- 原因: {cause.strip() or '待确认'}",
438
- f"- guard: {guard.strip() or '待补充'}",
439
1243
  f"- keys: {', '.join(key_list)}",
1244
+ f"- sessions: {session_id or 'unassigned'}",
1245
+ f"- fix: .codex/context/fixes/{bug_id}.md",
440
1246
  ]
441
1247
  if card_path:
442
1248
  lines.append(f"- card: {card_path}")
443
- bug_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
1249
+ bug_text = "\n".join(lines) + "\n"
1250
+ fix_lines = [
1251
+ f"# {bug_id} {title.strip()}",
1252
+ "",
1253
+ f"- bug: .codex/context/bugs/{bug_id}.md",
1254
+ f"- node: {node or 'unassigned'}",
1255
+ f"- status: {status}",
1256
+ ]
1257
+ if card_path:
1258
+ fix_lines.append(f"- card: {card_path}")
1259
+ fix_lines.extend([
1260
+ "", "## 触发", trigger.strip() or "待补充",
1261
+ "", "## 根因", cause.strip() or "待确认",
1262
+ "", "## 怎么修", "待记录修复方法" if status == "fixed" else "未修",
1263
+ "", "## 怎么防", guard.strip() or "待补充",
1264
+ "", "## 代码", "待补充",
1265
+ "", "## 证据", "待补充",
1266
+ ])
1267
+ fix_text = "\n".join(fix_lines) + "\n"
444
1268
 
445
1269
  index = read_json(ctx / "bugs-index.json", {})
446
1270
  if not isinstance(index, dict):
@@ -451,131 +1275,130 @@ def record_bad_case(
451
1275
  "status": status,
452
1276
  "bug": f".codex/context/bugs/{bug_id}.md",
453
1277
  "fix": f".codex/context/fixes/{bug_id}.md",
1278
+ "sessions": [session_id] if session_id else [],
454
1279
  }
455
1280
  if card_path:
456
1281
  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
- {
1282
+ transaction_id = f"bad-case:{bug_id}:occurrence"
1283
+ event = {
1284
+ "at": utc_now(),
1285
+ "event": "occurrence",
1286
+ "case": bug_id,
1287
+ "status": status,
1288
+ "session_id": session_id or None,
1289
+ "phenomenon": phenomenon.strip(),
1290
+ "trigger": trigger.strip(),
1291
+ "signal_id": signal_id or None,
1292
+ "transaction_id": transaction_id,
1293
+ }
1294
+ transaction: dict[str, object] = {
1295
+ "v": 1,
1296
+ "transaction_id": transaction_id,
1297
+ "operation": "occurrence",
1298
+ "bug_id": bug_id,
1299
+ "node": node,
1300
+ "session_id": session_id,
1301
+ "signal_id": signal_id,
1302
+ "status": status,
1303
+ "bug_text": bug_text,
1304
+ "fix_text": fix_text,
1305
+ "index_entry": entry,
1306
+ "map_bug": {
463
1307
  "id": bug_id,
464
1308
  "title": title.strip(),
465
1309
  "desc": phenomenon.strip(),
466
1310
  "status": status,
467
1311
  "files": "",
468
- "sessions": "",
1312
+ "sessions": [session_id] if session_id else [],
469
1313
  "record": f".codex/context/bugs/{bug_id}.md",
470
1314
  },
471
- node,
472
- )
1315
+ "event": event,
1316
+ }
1317
+ journal = bad_case_transaction_path(ctx, transaction_id)
1318
+ write_json(journal, transaction)
1319
+ apply_bad_case_transaction(root, transaction, journal)
473
1320
  print(f"[context-guard] recorded bad case: {bug_id} ({bug_path})")
474
1321
  return bug_id, bug_path
475
1322
 
476
1323
 
477
- def workbench_state_path(root: Path) -> Path:
478
- return context_dir(root) / "private" / "workbench.json"
1324
+ def replace_markdown_section(text: str, heading: str, value: str) -> str:
1325
+ pattern = re.compile(rf"(?ms)(^## {re.escape(heading)}\n).*?(?=^## |\Z)")
1326
+ if pattern.search(text):
1327
+ return pattern.sub(lambda match: match.group(1) + value.strip() + "\n\n", text, count=1).rstrip() + "\n"
1328
+ return text.rstrip() + f"\n\n## {heading}\n{value.strip()}\n"
479
1329
 
480
1330
 
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
1331
+ def update_bug_on_map(ctx: Path, bug_id: str, status: str, session_id: str) -> None:
1332
+ args = ["update-bug", "--root", str(ctx.parent.parent)]
1333
+ if session_id:
1334
+ args.extend(["--session", session_id])
1335
+ run_node_workbench(args, {"bug": {"id": bug_id, "status": status}})
491
1336
 
492
1337
 
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"
1338
+ @serialize_named_lock("bad-case-registry")
1339
+ @serialize_hook_runtime(5)
1340
+ def record_bad_case_fix(
1341
+ root: Path,
1342
+ bug_id: str,
1343
+ method: str,
1344
+ evidence: str,
1345
+ status: str,
1346
+ session_id: str,
1347
+ ) -> Path:
1348
+ init_context(root)
1349
+ ctx = context_dir(root)
1350
+ recover_bad_case_transactions(root)
1351
+ bug_id = bug_id.strip().upper()
1352
+ if not re.fullmatch(r"B\d+", bug_id):
1353
+ raise ValueError("case must use the B<number> identifier")
1354
+ bug_path = ctx / "bugs" / f"{bug_id}.md"
1355
+ fix_path = ctx / "fixes" / f"{bug_id}.md"
1356
+ if not bug_path.is_file() or not fix_path.is_file():
1357
+ raise ValueError(f"unknown bad case: {bug_id}")
1358
+ if not method.strip() or not evidence.strip():
1359
+ raise ValueError("record-bad-case-fix needs --method and --evidence")
1360
+ if status not in WRITABLE_BUG_STATUSES:
1361
+ raise ValueError("Bugs cannot be deferred; delete the Bug file if no fix is needed, or close it as unfixable")
1362
+ bug_text = re.sub(r"(?m)^- status: .*?$", f"- status: {status}", bug_path.read_text(encoding="utf-8"), count=1)
1363
+ fix_text = re.sub(r"(?m)^- status: .*?$", f"- status: {status}", fix_path.read_text(encoding="utf-8"), count=1)
1364
+ fix_text = replace_markdown_section(fix_text, "怎么修", method)
1365
+ fix_text = replace_markdown_section(fix_text, "证据", evidence)
1366
+ index = read_json(ctx / "bugs-index.json", {})
1367
+ if not isinstance(index, dict) or not isinstance(index.get(bug_id), dict):
1368
+ raise ValueError(f"bad case is missing from bugs-index.json: {bug_id}")
1369
+ index[bug_id]["status"] = status
1370
+ if session_id:
1371
+ sessions = index[bug_id].setdefault("sessions", [])
1372
+ if isinstance(sessions, list) and session_id not in sessions:
1373
+ sessions.append(session_id)
1374
+ transaction_id = f"bad-case:{bug_id}:fix:{hashlib.sha256(json.dumps([status, method.strip(), evidence.strip()], ensure_ascii=False).encode('utf-8')).hexdigest()[:16]}"
1375
+ event = {
1376
+ "at": utc_now(),
1377
+ "event": "fix",
1378
+ "case": bug_id,
1379
+ "status": status,
1380
+ "session_id": session_id or None,
1381
+ "method": method.strip(),
1382
+ "evidence": evidence.strip(),
1383
+ "transaction_id": transaction_id,
1384
+ }
1385
+ transaction: dict[str, object] = {
1386
+ "v": 1,
1387
+ "transaction_id": transaction_id,
1388
+ "operation": "fix",
1389
+ "bug_id": bug_id,
1390
+ "session_id": session_id,
1391
+ "status": status,
1392
+ "bug_text": bug_text,
1393
+ "fix_text": fix_text,
1394
+ "index_entry": index[bug_id],
1395
+ "event": event,
1396
+ }
1397
+ journal = bad_case_transaction_path(ctx, transaction_id)
1398
+ write_json(journal, transaction)
1399
+ apply_bad_case_transaction(root, transaction, journal)
1400
+ print(f"[context-guard] recorded bad case fix: {bug_id} ({fix_path})")
1401
+ return fix_path
579
1402
 
580
1403
 
581
1404
  def validate_workbench_host(host: str) -> None:
@@ -586,23 +1409,9 @@ def validate_workbench_host(host: str) -> None:
586
1409
  def serve_workbench(root: Path, host: str, port: int) -> int:
587
1410
  validate_workbench_host(host)
588
1411
  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
1412
+ return subprocess.call(["node", str(Path(__file__).resolve().parent / "workbench" / "cli.mjs"),
1413
+ "serve", "--root", str(root), "--host", host, "--port", str(port)],
1414
+ creationflags=WINDOWS_NO_WINDOW)
606
1415
 
607
1416
 
608
1417
  def maybe_open_browser(url: str, enabled: bool) -> None:
@@ -614,97 +1423,32 @@ def maybe_open_browser(url: str, enabled: bool) -> None:
614
1423
  pass
615
1424
 
616
1425
 
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:
1426
+ def start_workbench(root: Path, host: str = "127.0.0.1", port: int = 8877, open_browser: bool = True, raise_errors: bool = False,
1427
+ session_id: str = "") -> str | None:
623
1428
  validate_workbench_host(host)
624
1429
  if os.environ.get("CONTEXT_GUARD_DISABLE_WORKBENCH") == "1":
625
1430
  return None
626
1431
  init_context(root)
627
- current = running_workbench(root)
628
- if current:
629
- url = str(current["url"])
630
- maybe_open_browser(url, open_browser)
1432
+ try:
1433
+ should_open = open_browser and os.environ.get("CONTEXT_GUARD_HEADLESS") != "1" and not os.environ.get("CI")
1434
+ result = run_node_workbench([
1435
+ "workbench", "--root", str(root), "--port", str(port),
1436
+ *(["--session", session_id] if session_id else []),
1437
+ *(["--claim-open"] if should_open else []),
1438
+ ])
1439
+ url = result["url"]
1440
+ maybe_open_browser(url, should_open and result.get("shouldOpen", False))
631
1441
  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
1442
+ except (OSError, RuntimeError, subprocess.TimeoutExpired) as exc:
1443
+ print(f"[context-guard] Node workbench: {exc}", file=sys.stderr)
1444
+ if raise_errors:
1445
+ raise
1446
+ return None
688
1447
 
689
1448
 
690
1449
  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
1450
+ result = run_node_workbench(["workbench", "--root", str(root), "--stop"])
1451
+ return bool(result.get("stopped"))
708
1452
 
709
1453
 
710
1454
  def show_roadmap(root: Path, should_open: bool) -> int:
@@ -717,28 +1461,29 @@ def show_roadmap(root: Path, should_open: bool) -> int:
717
1461
  return 0
718
1462
 
719
1463
 
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
1464
  def main() -> int:
730
1465
  configure_stdio()
731
1466
  parser = argparse.ArgumentParser(description="Context Guard v1 utilities")
732
1467
  parser.add_argument(
733
1468
  "command",
734
- choices=["init", "set-language", "show-roadmap", "workbench", "record-bad-case", *PARKED],
1469
+ choices=[
1470
+ "init", "set-language", "workbench", "record-bad-case",
1471
+ "record-bad-case-fix", "record-todo", "resolve-signal", "archive-session",
1472
+ "write-candidates",
1473
+ "plan-start", "plan-finish", "plan-status", "split-signal",
1474
+ ],
735
1475
  )
736
1476
  parser.add_argument("--root", type=Path, default=None)
737
1477
  parser.add_argument("--language", default=None)
738
- parser.add_argument("--open", action="store_true")
739
1478
  parser.add_argument("--no-open", action="store_true")
740
1479
  parser.add_argument("--foreground", action="store_true")
741
1480
  parser.add_argument("--stop", action="store_true")
1481
+ parser.add_argument("--binding-status", action="store_true")
1482
+ parser.add_argument("--workbench-url")
1483
+ parser.add_argument("--bind-main")
1484
+ parser.add_argument("--local-main")
1485
+ parser.add_argument("--remote", default="origin")
1486
+ parser.add_argument("--rebind", action="store_true")
742
1487
  parser.add_argument("--host", default="127.0.0.1")
743
1488
  parser.add_argument("--port", type=int, default=8877)
744
1489
  parser.add_argument("--title", default="")
@@ -747,16 +1492,47 @@ def main() -> int:
747
1492
  parser.add_argument("--cause", default="")
748
1493
  parser.add_argument("--guard", default="")
749
1494
  parser.add_argument("--node", default="")
750
- parser.add_argument("--status", choices=["open", "fixed", "deferred", "wontfix"], default="open")
1495
+ parser.add_argument("--status", choices=list(WRITABLE_BUG_STATUSES), default="open")
751
1496
  parser.add_argument("--keys", default="")
1497
+ parser.add_argument("--session", default="")
1498
+ parser.add_argument("--case", dest="case_id", default="")
1499
+ parser.add_argument("--method", default="")
1500
+ parser.add_argument("--evidence", default="")
1501
+ parser.add_argument("--summary", default="")
1502
+ parser.add_argument("--description", default="")
1503
+ parser.add_argument("--signal", default="")
1504
+ parser.add_argument("--kind", default="")
1505
+ parser.add_argument("--decisions", default="")
1506
+ parser.add_argument("--next", dest="next_steps", default="")
1507
+ parser.add_argument("--files", default="")
1508
+ parser.add_argument("--input", default="")
752
1509
  args, _unknown = parser.parse_known_args()
753
1510
  explicit = args.root is not None
754
1511
  root = (args.root or folder_root(Path.cwd())).resolve()
755
1512
  blocked = guard_implicit_skill_root(root, explicit)
756
1513
  if blocked:
757
1514
  return blocked
758
- if args.command in PARKED:
759
- return parked_command(args.command)
1515
+ if args.command == "split-signal":
1516
+ try:
1517
+ data = read_input_json(args.input)
1518
+ if not isinstance(data, dict):
1519
+ raise ValueError("split-signal input needs an items array")
1520
+ print(json.dumps(split_signal(root, resolve_session_id(root, args.session), args.signal, data.get("items")), ensure_ascii=False))
1521
+ return 0
1522
+ except (OSError, RuntimeError, ValueError) as exc:
1523
+ print(f"[context-guard] split-signal failed: {exc}", file=sys.stderr)
1524
+ return 1
1525
+ if args.command.startswith("plan-"):
1526
+ try:
1527
+ from context_guard_hook import plan_command
1528
+ data = read_input_json(args.input) if args.input else {}
1529
+ if not isinstance(data, dict):
1530
+ raise ValueError("plan input must be an object")
1531
+ print(json.dumps(plan_command(root, resolve_session_id(root, args.session), args.command, data), ensure_ascii=False))
1532
+ return 0
1533
+ except (OSError, RuntimeError, ValueError) as exc:
1534
+ print(f"[context-guard] {args.command} failed: {exc}", file=sys.stderr)
1535
+ return 1
760
1536
  if args.command == "init":
761
1537
  created = init_context(root)
762
1538
  print(f"[context-guard] context: {context_dir(root)}")
@@ -773,19 +1549,87 @@ def main() -> int:
773
1549
  if not args.title or not args.phenomenon:
774
1550
  print("[context-guard] record-bad-case needs --title and --phenomenon", file=sys.stderr)
775
1551
  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
1552
+ try:
1553
+ record_bad_case(
1554
+ root,
1555
+ args.title,
1556
+ args.phenomenon,
1557
+ args.trigger,
1558
+ args.cause,
1559
+ args.guard,
1560
+ args.node,
1561
+ args.status,
1562
+ args.keys,
1563
+ resolve_session_id(root, args.session),
1564
+ args.signal,
1565
+ )
1566
+ return 0
1567
+ except (OSError, RuntimeError, ValueError) as exc:
1568
+ print(f"[context-guard] record-bad-case failed: {exc}", file=sys.stderr)
1569
+ return 1
1570
+ if args.command == "record-todo":
1571
+ try:
1572
+ record_todo(
1573
+ root, args.title, args.description, args.node,
1574
+ resolve_session_id(root, args.session), args.signal,
1575
+ )
1576
+ return 0
1577
+ except (OSError, RuntimeError, ValueError) as exc:
1578
+ print(f"[context-guard] record-todo failed: {exc}", file=sys.stderr)
1579
+ return 1
1580
+ if args.command == "resolve-signal":
1581
+ try:
1582
+ resolve_signal(root, resolve_session_id(root, args.session), args.signal, args.kind)
1583
+ return 0
1584
+ except (OSError, RuntimeError, ValueError) as exc:
1585
+ print(f"[context-guard] resolve-signal failed: {exc}", file=sys.stderr)
1586
+ return 1
1587
+ if args.command == "record-bad-case-fix":
1588
+ try:
1589
+ record_bad_case_fix(
1590
+ root, args.case_id, args.method, args.evidence, args.status,
1591
+ resolve_session_id(root, args.session),
1592
+ )
1593
+ return 0
1594
+ except (OSError, RuntimeError, ValueError) as exc:
1595
+ print(f"[context-guard] record-bad-case-fix failed: {exc}", file=sys.stderr)
1596
+ return 1
1597
+ if args.command == "archive-session":
1598
+ if not any((args.summary.strip(), args.decisions.strip(), args.next_steps.strip(), args.files.strip())):
1599
+ print("[context-guard] archive-session needs durable --summary, --decisions, --next, or --files", file=sys.stderr)
1600
+ return 2
1601
+ try:
1602
+ archive_session(
1603
+ root, resolve_session_id(root, args.session), args.summary,
1604
+ args.decisions, args.next_steps, args.files, args.input,
1605
+ )
1606
+ return 0
1607
+ except (OSError, RuntimeError, ValueError) as exc:
1608
+ print(f"[context-guard] archive-session failed: {exc}", file=sys.stderr)
1609
+ return 1
1610
+ if args.command == "write-candidates":
1611
+ if not args.input:
1612
+ print("[context-guard] write-candidates needs --input <json-file-or->", file=sys.stderr)
1613
+ return 2
1614
+ try:
1615
+ write_candidates(root, args.input)
1616
+ return 0
1617
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
1618
+ print(f"[context-guard] write-candidates failed: {exc}", file=sys.stderr)
1619
+ return 1
788
1620
  if args.command == "workbench":
1621
+ if args.binding_status or args.bind_main or args.local_main or args.session:
1622
+ command = ["workbench", "--root", str(root)]
1623
+ for key, value in [("binding-status", args.binding_status), ("workbench-url", args.workbench_url), ("bind-main", args.bind_main), ("local-main", args.local_main), ("remote", args.remote), ("session", args.session), ("rebind", args.rebind)]:
1624
+ if value:
1625
+ command.append("--" + key)
1626
+ if value is not True:
1627
+ command.append(str(value))
1628
+ result = run_node_workbench(command)
1629
+ print(json.dumps(result, ensure_ascii=False))
1630
+ if result.get("url"):
1631
+ maybe_open_browser(result["url"], not args.no_open)
1632
+ return 0
789
1633
  if args.stop:
790
1634
  stopped = stop_workbench(root)
791
1635
  print(f"[context-guard] workbench: {'stopped' if stopped else 'not running'}")
@@ -797,8 +1641,6 @@ def main() -> int:
797
1641
  except (OSError, ValueError) as exc:
798
1642
  print(f"[context-guard] workbench failed: {exc}", file=sys.stderr)
799
1643
  return 1
800
- if args.command == "show-roadmap":
801
- return show_roadmap(root, args.open and not args.no_open)
802
1644
  return 2
803
1645
 
804
1646