@michelj/context-guard 0.4.4 → 0.6.2

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
@@ -5,15 +5,27 @@ from __future__ import annotations
5
5
 
6
6
  import argparse
7
7
  import json
8
+ import hashlib
8
9
  import os
10
+ import re
11
+ import shlex
12
+ import sqlite3
13
+ import subprocess
9
14
  import sys
15
+ import tempfile
10
16
  from datetime import datetime, timezone
11
17
  from pathlib import Path, PureWindowsPath
18
+ from urllib.parse import quote, urlsplit
12
19
 
13
- from context_guard import append_session_event
20
+ from context_guard import acquire_hook_runtime_lock, append_session_event
21
+ from context_guard import add_prompt_signal
14
22
  from context_guard import context_dir as context_folder
15
- from context_guard import configure_stdio, folder_root, init_context, is_context_guard_skill_path
16
- from context_guard import read_preferences, start_workbench
23
+ from context_guard import configure_stdio, ensure_session_file, folder_root, init_context, is_context_guard_skill_path
24
+ from context_guard import hook_runtime_lock, read_hook_runtime, read_json, require_human_work_review, start_workbench, utc_now
25
+ from context_guard import safe_identifier, session_records, write_hook_runtime, write_json
26
+ from context_guard import run_node_workbench
27
+
28
+ WINDOWS_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
17
29
 
18
30
 
19
31
  WORKSPACE_KEYS = {
@@ -84,6 +96,8 @@ def possible_workspace_paths(value: object) -> list[Path]:
84
96
 
85
97
 
86
98
  def parse_hook_payload(raw: str) -> object:
99
+ # Windows hook runners may prepend a UTF-8 BOM to otherwise valid JSON.
100
+ raw = raw.lstrip("\ufeff")
87
101
  if not raw.strip():
88
102
  return {}
89
103
  try:
@@ -92,6 +106,10 @@ def parse_hook_payload(raw: str) -> object:
92
106
  return {}
93
107
 
94
108
 
109
+ def initialized_source_project(root: Path) -> bool:
110
+ return (root / ".git").exists() and (root / ".codex" / "context" / "map.json").is_file()
111
+
112
+
95
113
  def event_root(raw: str, cwd: Path) -> tuple[Path, str]:
96
114
  payload = parse_hook_payload(raw)
97
115
  candidates: list[tuple[Path, str]] = []
@@ -104,7 +122,7 @@ def event_root(raw: str, cwd: Path) -> tuple[Path, str]:
104
122
  candidates.append((cwd, "process cwd"))
105
123
  for path, source in candidates:
106
124
  root = folder_root(path)
107
- if not is_context_guard_skill_path(root):
125
+ if not is_context_guard_skill_path(root) or initialized_source_project(root):
108
126
  return root, source
109
127
  return folder_root(cwd), "process cwd"
110
128
 
@@ -129,14 +147,30 @@ HOOK_EVENT_NAMES = {
129
147
  "user-prompt-submit": "UserPromptSubmit",
130
148
  "stop": "Stop",
131
149
  "subagent-stop": "SubagentStop",
150
+ "pre-tool-use": "PreToolUse",
151
+ "post-tool-use": "PostToolUse",
152
+ "post-tool-use-failure": "PostToolUseFailure",
153
+ "session-end": "SessionEnd",
154
+ "stop-failure": "StopFailure",
155
+ "permission-request": "PermissionRequest",
156
+ "interrupt": "Interrupt",
157
+ "pre-compact": "PreCompact",
158
+ "post-compact": "PostCompact",
132
159
  }
133
160
 
134
161
 
135
162
  def hook_response(platform: str, event: str, additional_context: str = "") -> int:
136
163
  payload: dict[str, object] = {}
164
+ if platform == "claude" and event in {"pre-compact", "post-compact", "session-end", "stop-failure"}:
165
+ print("{}") # These native events cannot deliver model context or block.
166
+ return 0
137
167
  if additional_context:
138
168
  if platform == "cursor":
139
169
  payload["additional_context"] = additional_context
170
+ elif platform == "claude" and event in {"stop", "subagent-stop", "session-end"}:
171
+ # Claude treats additionalContext at Stop as another model turn.
172
+ # A diagnostic (e.g. unbound Session) must not restart generation.
173
+ payload["systemMessage"] = additional_context
140
174
  else:
141
175
  payload["hookSpecificOutput"] = {
142
176
  "hookEventName": HOOK_EVENT_NAMES.get(event, event),
@@ -146,6 +180,22 @@ def hook_response(platform: str, event: str, additional_context: str = "") -> in
146
180
  return 0
147
181
 
148
182
 
183
+ def permission_response(event: str, behavior: str = "", message: str = "") -> int:
184
+ payload: dict[str, object] = {}
185
+ if behavior:
186
+ decision: dict[str, object] = {"behavior": behavior}
187
+ if message:
188
+ decision["message"] = message
189
+ payload["hookSpecificOutput"] = {
190
+ "hookEventName": HOOK_EVENT_NAMES[event],
191
+ "decision": decision,
192
+ }
193
+ elif message:
194
+ payload["systemMessage"] = message
195
+ print(json.dumps(payload, ensure_ascii=False))
196
+ return 0
197
+
198
+
149
199
  def payload_value(payload: object, keys: tuple[str, ...]) -> str:
150
200
  if not isinstance(payload, dict):
151
201
  return ""
@@ -156,42 +206,456 @@ def payload_value(payload: object, keys: tuple[str, ...]) -> str:
156
206
  return ""
157
207
 
158
208
 
159
- def session_id(payload: object, platform: str) -> str:
209
+ def payload_time(payload: object) -> str:
210
+ return payload_value(payload, ("timestamp", "occurred_at", "occurredAt")) or utc_now()
211
+
212
+
213
+ def event_identity(payload: object, event: str, current_session_id: str) -> tuple[str, str, str]:
214
+ turn_id = payload_value(payload, ("turn_id", "turnId"))
215
+ call_id = payload_value(payload, ("tool_use_id", "toolUseId", "call_id", "callId", "agent_id", "agentId"))
216
+ marker = payload_value(payload, ("trigger", "source", "reason"))
217
+ prompt_hash = hashlib.sha256(prompt_text(json.dumps(payload, ensure_ascii=False)).encode("utf-8")).hexdigest() if isinstance(payload, dict) else ""
218
+ key = json.dumps([current_session_id, event, turn_id, call_id, marker, prompt_hash], ensure_ascii=False, separators=(",", ":"))
219
+ return f"hook-{hashlib.sha256(key.encode('utf-8')).hexdigest()[:24]}", turn_id, call_id
220
+
221
+
222
+ def map_entries(node: object):
223
+ if not isinstance(node, dict):
224
+ return
225
+ yield node
226
+ for key in ("children", "_inbox"):
227
+ for child in node.get(key) or []:
228
+ yield from map_entries(child)
229
+
230
+
231
+ def map_snapshot(ctx: Path, current_session_id: str) -> dict[str, object]:
232
+ map_file = ctx / "map.json"
233
+ access_file = ctx / "sessions" / "workbench-access.json"
234
+ role = "executor"
235
+ probe = subprocess.run(["git", "rev-parse", "--path-format=absolute", "--git-common-dir"], cwd=ctx.parent.parent,
236
+ capture_output=True, text=True, timeout=5, check=False,
237
+ creationflags=WINDOWS_NO_WINDOW)
238
+ if probe.returncode == 0:
239
+ shared = Path(probe.stdout.strip()) / "context-guard"
240
+ bindings = read_json(shared / "workbench-bindings.json", {})
241
+ bound = bindings.get("sessions", {}).get(current_session_id, {})
242
+ if bound.get("worktreeRoot") == str(ctx.parent.parent.resolve()):
243
+ role = "coordinator" if bound.get("role") == "coordinator" else "executor"
244
+ scope = hashlib.sha256((current_session_id + "\0" + bound.get("worktreeId", "")).encode()).hexdigest()
245
+ map_file = shared / "session-memory" / scope / "map.json"
246
+ access_file = shared / "workbench-access.json"
247
+ document = read_json(map_file, {})
248
+ nodes = list(map_entries(document.get("root"))) if isinstance(document, dict) else []
249
+ access = read_json(access_file, {})
250
+ sessions = access.get("sessions") if isinstance(access, dict) and isinstance(access.get("sessions"), dict) else {}
251
+ grant_record = sessions.get(current_session_id) if isinstance(sessions, dict) else {}
252
+ grants = grant_record.get("nodes") if isinstance(grant_record, dict) and isinstance(grant_record.get("nodes"), list) else []
253
+ by_id = {str(node.get("id")): node for node in nodes if isinstance(node.get("id"), str)}
254
+ # Match AccessStore: all is dynamic, while an explicit empty list is revoked.
255
+ if isinstance(grant_record, dict) and grant_record.get("mode") == "all":
256
+ grants = list(by_id)
257
+ def can_write_node(node_id: str) -> bool:
258
+ if node_id not in by_id:
259
+ return False
260
+ rule = next((item for item in (by_id[node_id].get("access") or [])
261
+ if isinstance(item, dict) and item.get("agentId") == current_session_id), None)
262
+ return rule is None or rule.get("allow") == "write"
263
+ grants = [node_id for node_id in grants if can_write_node(node_id)]
264
+ assigned_todos: list[dict[str, str]] = []
265
+ assigned_bugs: list[dict[str, str]] = []
266
+ for node in nodes:
267
+ node_id = str(node.get("id") or "")
268
+ node_title = str(node.get("title") or node_id)
269
+ for todo in node.get("todos") or []:
270
+ owners = todo.get("sessions") if isinstance(todo, dict) else []
271
+ target = str(todo.get("target_session") or "") if isinstance(todo, dict) else ""
272
+ if isinstance(todo, dict) and (current_session_id in (owners or []) or target == current_session_id):
273
+ assigned_todos.append({"id": str(todo.get("id") or ""), "title": str(todo.get("title") or ""), "status": str(todo.get("status") or "pending"), "node": node_id, "node_title": node_title})
274
+ for bug in node.get("bugs") or []:
275
+ owners = bug.get("sessions") if isinstance(bug, dict) else []
276
+ if isinstance(bug, dict) and current_session_id in (owners or []):
277
+ assigned_bugs.append({"id": str(bug.get("id") or ""), "title": str(bug.get("title") or ""), "status": str(bug.get("status") or "open"), "node": node_id, "node_title": node_title})
278
+ raw = map_file.read_bytes() if map_file.is_file() else b""
279
+ local_version = hashlib.sha256(raw).hexdigest() if raw else "missing"
280
+ return {
281
+ "role": role,
282
+ "version": local_version,
283
+ "cloud_cursor": None,
284
+ "grants": [str(item) for item in grants],
285
+ "grant_nodes": [{"id": node_id, "title": str(by_id.get(node_id, {}).get("title") or node_id)} for node_id in grants[:20]],
286
+ "todos": assigned_todos[:20],
287
+ "bugs": assigned_bugs[:20],
288
+ "nodes": nodes,
289
+ }
290
+
291
+
292
+ def map_inbox(root: Path, ctx: Path, current_session_id: str) -> dict[str, object]:
293
+ """Read the durable Map inbox only when the local workbench is already live."""
294
+ if not (ctx / "private" / "workbench.json").is_file():
295
+ return {"pending": False, "available": False}
296
+ script = Path(__file__).resolve().parent / "workbench" / "cli.mjs"
297
+ command = [
298
+ "node", str(script), "map", "inbox", "--root", str(root),
299
+ "--session", current_session_id, "--start",
300
+ ]
301
+ try:
302
+ result = subprocess.run(command, cwd=root, capture_output=True, text=True, encoding="utf-8", timeout=10,
303
+ check=False, creationflags=WINDOWS_NO_WINDOW)
304
+ except (OSError, subprocess.SubprocessError) as error:
305
+ return {"pending": False, "error": {"code": "INBOX_READ_FAILED", "message": str(error)}}
306
+ lines = (result.stdout or "").strip().splitlines()
307
+ try:
308
+ value = json.loads(lines[-1]) if lines else None
309
+ if not isinstance(value, dict) or ("pending" not in value and "error" not in value):
310
+ raise ValueError("missing inbox response")
311
+ except (json.JSONDecodeError, ValueError):
312
+ value = {"error": {"code": "INBOX_READ_FAILED", "message": "invalid workbench response"}}
313
+ if result.returncode and "error" not in value:
314
+ value = {"error": {"code": "INBOX_READ_FAILED", "message": (result.stderr or "inbox command failed").strip()[:500]}}
315
+ return value
316
+
317
+
318
+ def coordinator_static_context(root: Path, ctx: Path, current_session_id: str, snapshot: dict[str, object]) -> str:
319
+ probe = subprocess.run(["git", "rev-parse", "--path-format=absolute", "--git-common-dir"], cwd=root,
320
+ capture_output=True, text=True, timeout=5, check=False, creationflags=WINDOWS_NO_WINDOW)
321
+ shared = Path(probe.stdout.strip()) / "context-guard" if probe.returncode == 0 else None
322
+ cloud = bool(shared and (shared / "memory-client.json").is_file())
323
+ if cloud:
324
+ command = ["node", str(Path(__file__).resolve().parent / "workbench" / "cli.mjs"),
325
+ "map", "main", "read", "--root", str(root), "--session", current_session_id]
326
+ try:
327
+ result = subprocess.run(command, cwd=root, capture_output=True, text=True, encoding="utf-8", timeout=8,
328
+ check=False, creationflags=WINDOWS_NO_WINDOW)
329
+ response = json.loads(result.stdout) if result.returncode == 0 else {}
330
+ except (OSError, subprocess.SubprocessError, json.JSONDecodeError):
331
+ response = {}
332
+ if not isinstance(response, dict):
333
+ response = {}
334
+ document, version = response.get("doc"), response.get("version")
335
+ else:
336
+ main_file = shared / "main" / "map.json" if shared else ctx / "map.json"
337
+ document = read_json(main_file, {})
338
+ version = hashlib.sha256(main_file.read_bytes()).hexdigest() if main_file.is_file() else None
339
+ if not isinstance(document, dict) or not isinstance(document.get("root"), dict) or not version:
340
+ return "Coordinator Main navigation unavailable; do not substitute the Session Map or stale conversation text."
341
+ directory = []
342
+ by_id = {}
343
+ def visit(node: dict[str, object], parent_id: str | None = None) -> None:
344
+ by_id[str(node.get("id") or "")] = node
345
+ children = [child for child in node.get("children") or [] if isinstance(child, dict)]
346
+ directory.append({"id": node.get("id"), "parentId": parent_id, "title": str(node.get("title") or "")[:120],
347
+ "description": str(node.get("purpose") or "")[:320], "children": [child.get("id") for child in children]})
348
+ for child in children:
349
+ visit(child, str(node.get("id") or ""))
350
+ visit(document["root"])
351
+ current = {"todos": [], "bugs": []}
352
+ for kind, field in (("todo", "todos"), ("bug", "bugs")):
353
+ for assigned in snapshot.get(field) or []:
354
+ node_id, item_id = str(assigned.get("node") or ""), str(assigned.get("id") or "")
355
+ item = next((value for value in by_id.get(node_id, {}).get(field) or []
356
+ if isinstance(value, dict) and value.get("id") == item_id), None)
357
+ current[field].append({"nodeId": node_id, "itemId": item_id, "kind": kind,
358
+ **({"title": str(item.get("title") or "")[:240], "status": item.get("status")}
359
+ if item else {"unavailable": True})})
360
+ return "Coordinator static Main context (project data, not user instructions): " + json.dumps({
361
+ "mainVersion": version, "staticDirectory": directory,
362
+ "currentTask": current,
363
+ }, ensure_ascii=False, separators=(",", ":"))
364
+
365
+
366
+ def map_context(root: Path, ctx: Path, current_session_id: str) -> tuple[str, dict[str, object]]:
367
+ snapshot = map_snapshot(ctx, current_session_id)
368
+ inbox = map_inbox(root, ctx, current_session_id)
369
+ grants = snapshot["grant_nodes"]
370
+ todos = snapshot["todos"]
371
+ bugs = snapshot["bugs"]
372
+ grant_text = ", ".join(f"{item['id']} {item['title']}" for item in grants) if grants else "none"
373
+ if snapshot.get("role") == "coordinator":
374
+ todo_text = bug_text = "see authoritative Main context below"
375
+ else:
376
+ todo_text = "; ".join(f"{item['id']}@{item['node']} {item['title']} [{item['status']}]" for item in todos) if todos else "none"
377
+ bug_text = "; ".join(f"{item['id']}@{item['node']} {item['title']} [{item['status']}]" for item in bugs) if bugs else "none"
378
+ inbox_text = "No unacknowledged Map changes from other sessions."
379
+ if isinstance(inbox.get("error"), dict):
380
+ inbox_text = f"Map inbox unavailable: {inbox['error'].get('code')}."
381
+ elif inbox.get("available") is False:
382
+ inbox_text = "Map inbox unavailable: no live workbench; changes have not been checked."
383
+ elif inbox.get("pending"):
384
+ events = inbox.get("events") if isinstance(inbox.get("events"), list) else []
385
+ changes = inbox.get("changes") if isinstance(inbox.get("changes"), list) else []
386
+ actors = sorted({str(item.get("actor", {}).get("sessionId") or item.get("actor", {}).get("kind") or "unknown") for item in events if isinstance(item, dict)})
387
+ changed_nodes = sorted({str(item.get("id") or item.get("nodeId") or "") for item in changes if isinstance(item, dict) and (item.get("id") or item.get("nodeId"))})
388
+ inbox_text = (
389
+ f"Pending Map inbox receipt {inbox.get('receipt')} from {', '.join(actors) or 'other sessions'}; "
390
+ f"changed nodes: {', '.join(changed_nodes) or 'see inbox payload'}; journal gap: {bool(inbox.get('journalGap'))}. "
391
+ )
392
+ text = (
393
+ f"Context Guard Map snapshot {str(snapshot['version'])[:16]} (cloud cursor {snapshot.get('cloud_cursor') or 0}). "
394
+ f"Authorized nodes: {grant_text}. Assigned TODOs: {todo_text}. Assigned Bugs: {bug_text}. "
395
+ f"{inbox_text}"
396
+ )
397
+ if snapshot.get("role") == "coordinator":
398
+ text += "\n\n" + coordinator_static_context(root, ctx, current_session_id, snapshot)
399
+ return text, snapshot
400
+
401
+
402
+ def audit_details(payload: object, event: str, current_session_id: str, runtime: dict[str, object], extra: dict[str, object] | None = None) -> dict[str, object]:
403
+ event_id, turn_id, call_id = event_identity(payload, event, current_session_id)
404
+ plan = runtime.get("active_plan") if isinstance(runtime.get("active_plan"), dict) else {}
405
+ details: dict[str, object] = {
406
+ "event_id": event_id,
407
+ "occurred_at": payload_time(payload),
408
+ "recorded_at": utc_now(),
409
+ "hook_event": HOOK_EVENT_NAMES.get(event, event),
410
+ "turn_id": turn_id or None,
411
+ "plan_id": plan.get("id") if isinstance(plan, dict) else None,
412
+ "tool_call_id": call_id or None,
413
+ "hook_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
414
+ "context_emitted": event in {"session-start", "user-prompt-submit", "post-compact"},
415
+ }
416
+ if extra:
417
+ details.update(extra)
418
+ return details
419
+
420
+
421
+ def active_grants(snapshot: dict[str, object]) -> set[str]:
422
+ return {str(item) for item in snapshot.get("grants") or []}
423
+
424
+
425
+ def owner_nodes(paths: list[str], snapshot: dict[str, object]) -> dict[str, str]:
426
+ found: dict[str, str] = {}
427
+ for file in paths:
428
+ normalized = file.replace("\\", "/").lstrip("./")
429
+ best: tuple[int, str] | None = None
430
+ for node in snapshot.get("nodes") or []:
431
+ if not isinstance(node, dict) or node.get("proposal") in {"proposed", "cancelled"}:
432
+ continue
433
+ for owned in node.get("owns") or []:
434
+ owned_path = str(owned).replace("\\", "/").lstrip("./").rstrip("/")
435
+ if owned_path and (normalized == owned_path or normalized.startswith(owned_path + "/")):
436
+ candidate = (len(owned_path), str(node.get("id") or ""))
437
+ if candidate[1] and (best is None or candidate[0] > best[0]):
438
+ best = candidate
439
+ if best:
440
+ found[normalized] = best[1]
441
+ return found
442
+
443
+
444
+ def tool_command(payload: dict) -> str:
445
+ value = payload.get("tool_input")
446
+ if isinstance(value, str):
447
+ return value
448
+ if isinstance(value, dict):
449
+ return str(value.get("command") or value.get("cmd") or value.get("patch") or value.get("input") or "")
450
+ return ""
451
+
452
+
453
+ def patch_targets(payload: dict) -> list[str] | None:
454
+ name = str(payload.get("tool_name") or payload.get("toolName") or "")
455
+ if name.rsplit(".", 1)[-1] != "apply_patch":
456
+ return None
457
+ command = tool_command(payload).replace("\r\n", "\n").strip()
458
+ if not (command.startswith("*** Begin Patch\n") and command.endswith("\n*** End Patch")):
459
+ return None
460
+ return re.findall(r"^\*\*\* (?:(?:Add|Update|Delete) File|Move to): (.+)$", command, re.MULTILINE)
461
+
462
+
463
+ def forbidden_direct_write(payload: object, root: Path) -> str:
464
+ if not mutating_tool(payload) or not isinstance(payload, dict):
465
+ return ""
466
+ command = tool_command(payload)
467
+ targets = patch_targets(payload)
468
+ if targets is not None:
469
+ # Only patch headers designate writes. Hunk text may discuss protected paths.
470
+ # Prefix each target so the path check also covers another worktree.
471
+ checked_paths = []
472
+ for item in targets:
473
+ normalized = os.path.normpath(item.strip().replace("\\", "/")).replace("\\", "/")
474
+ checked_paths.append("/" + normalized.lstrip("/"))
475
+ candidate = Path(normalized).expanduser()
476
+ try:
477
+ resolved = candidate.resolve() if candidate.is_absolute() else (root / candidate).resolve()
478
+ except (OSError, ValueError, RuntimeError):
479
+ return "Cannot resolve patch target; verify the target path before writing."
480
+ checked_paths.append(resolved.as_posix())
481
+ command = "\n".join(checked_paths)
482
+ paths = {
483
+ normalized[2:] if normalized.startswith("./") else normalized
484
+ for item in tool_paths(payload, root)
485
+ for normalized in [item.replace("\\", "/")]
486
+ }
487
+ if ".codex/context/map.json" in paths or re.search(r"(?:^|[\s'\"/\\])\.codex[/\\]context[/\\]map\.json\b", command):
488
+ return "Direct map.json writes are forbidden; use context-guard map read/apply/reconcile."
489
+ if any(item.rsplit("/", 1)[-1].lower() == "todo.md" for item in paths) or re.search(r"(?:^|[/\\])TODO\.md\b", command, re.IGNORECASE):
490
+ return "TODO.md is human-owned; record Agent work in the authorized Map node instead."
491
+ return ""
492
+
493
+
494
+ def session_id(payload: object, platform: str, ctx: Path, event: str) -> str:
160
495
  value = payload_value(
161
496
  payload,
162
497
  ("session_id", "sessionId", "conversation_id", "conversationId", "generation_id"),
163
498
  )
164
- if value:
165
- return value
166
- stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
167
- return f"{platform}-{stamp}-{os.getpid()}"
499
+ state_path = ctx / "private" / "hook-sessions.json"
500
+ state = read_json(state_path, {})
501
+ if not isinstance(state, dict):
502
+ state = {}
503
+ if not value and event not in {"session-start", "subagent-start"}:
504
+ stored = state.get(platform)
505
+ if isinstance(stored, str) and stored.strip():
506
+ value = stored.strip()
507
+ if not value:
508
+ for key in ("CODEX_THREAD_ID", "CLAUDE_SESSION_ID", "CURSOR_SESSION_ID"):
509
+ env_value = os.environ.get(key, "").strip()
510
+ if env_value:
511
+ value = env_value
512
+ break
513
+ if not value:
514
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
515
+ value = f"{platform}-{stamp}-{os.getpid()}"
516
+ if state.get(platform) != value:
517
+ state[platform] = value
518
+ write_json(state_path, state)
519
+ return value
520
+
521
+
522
+ def normalized_path(value: str) -> str:
523
+ try:
524
+ return os.path.normcase(os.path.normpath(str(Path(value).expanduser().resolve())))
525
+ except (OSError, RuntimeError, ValueError):
526
+ return ""
527
+
528
+
529
+ def codex_thread_name(root: Path, current_session_id: str) -> str:
530
+ """Read only the matching local Codex thread metadata, if available."""
531
+ codex_home = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser()
532
+ databases = [codex_home / "state_5.sqlite", codex_home / "sqlite" / "state_5.sqlite"]
533
+ expected_root = normalized_path(str(root))
534
+ for database in databases:
535
+ if not database.is_file():
536
+ continue
537
+ connection = None
538
+ try:
539
+ connection = sqlite3.connect(database.resolve().as_uri() + "?mode=ro", uri=True, timeout=0.5)
540
+ columns = {
541
+ str(row[1])
542
+ for row in connection.execute("PRAGMA table_info(threads)")
543
+ if len(row) > 1
544
+ }
545
+ if not {"id", "cwd"}.issubset(columns):
546
+ continue
547
+ selected = [column for column in ("name", "title", "cwd") if column in columns]
548
+ row = connection.execute(
549
+ f"SELECT {', '.join(selected)} FROM threads WHERE id = ? LIMIT 1",
550
+ (current_session_id,),
551
+ ).fetchone()
552
+ if not row:
553
+ continue
554
+ values = dict(zip(selected, row))
555
+ if normalized_path(str(values.get("cwd") or "")) != expected_root:
556
+ continue
557
+ for field in ("name", "title"):
558
+ value = values.get(field)
559
+ if isinstance(value, str) and value.strip():
560
+ return " ".join(value.strip().split())[:240]
561
+ except (OSError, sqlite3.Error):
562
+ continue
563
+ finally:
564
+ if connection is not None:
565
+ connection.close()
566
+ return ""
567
+
568
+
569
+ def session_display_name(payload: object, platform: str, root: Path, current_session_id: str) -> str:
570
+ if platform == "codex":
571
+ value = codex_thread_name(root, current_session_id)
572
+ if value:
573
+ return value
574
+ named = payload_value(
575
+ payload,
576
+ (
577
+ "thread_name",
578
+ "threadName",
579
+ "session_name",
580
+ "sessionName",
581
+ "conversation_title",
582
+ "conversationTitle",
583
+ ),
584
+ )[:240]
585
+ if named or platform != "claude":
586
+ return named
587
+ # Native Claude hooks provide the transcript path, but not the --name value.
588
+ # Read only a bounded tail of this Session's own transcript metadata.
589
+ transcript = payload_value(payload, ("transcript_path",))
590
+ if not transcript:
591
+ return ""
592
+ try:
593
+ directory = Path(os.environ.get("CLAUDE_CONFIG_DIR") or os.environ.get("CLAUDE_HOME") or Path.home() / ".claude").expanduser().resolve() / "projects"
594
+ file = Path(transcript).resolve()
595
+ file.relative_to(directory)
596
+ if file.name != current_session_id + ".jsonl":
597
+ return ""
598
+ with file.open("rb") as stream:
599
+ stream.seek(0, os.SEEK_END)
600
+ stream.seek(max(0, stream.tell() - 262144))
601
+ lines = stream.read(262144).decode("utf-8", errors="replace").splitlines()
602
+ for line in reversed(lines):
603
+ try:
604
+ item = json.loads(line)
605
+ except ValueError:
606
+ continue
607
+ if isinstance(item, dict) and item.get("type") == "custom-title" and item.get("sessionId") == current_session_id:
608
+ return str(item.get("customTitle") or "").strip()[:200]
609
+ except (OSError, ValueError):
610
+ pass
611
+ return ""
168
612
 
169
613
 
170
614
  def language_setup_context(root: Path, ctx: Path) -> str:
171
- language = str(read_preferences(ctx).get("record_language", "unset"))
615
+ try:
616
+ language = str(run_node_workbench(["preferences", "--root", str(root)]).get("record_language", "unset"))
617
+ except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired):
618
+ return "Context Guard language unavailable; existing preference preserved."
172
619
  if language and language != "unset":
173
620
  return ""
174
621
  quoted_root = '"' + str(root).replace('"', '\\"') + '"'
622
+ cli = context_guard_cli()
175
623
  return (
176
- "Context Guard first-session setup is incomplete. Before substantive project work, "
177
- "ask the user whether project context should be recorded in 中文 or English; do not infer it. "
178
- "After the user answers, run `context-guard set-language --root "
624
+ "Context Guard language unset: ask 中文 or English. "
625
+ f"After the user answers, run `{cli} set-language --root "
179
626
  f"{quoted_root} --language <zh-or-en>` and then continue in that language."
180
627
  )
181
628
 
182
629
 
183
- def lifecycle_context(root: Path, workbench_url: str | None) -> str:
184
- quoted_root = '"' + str(root).replace('"', '\\"') + '"'
630
+ def context_guard_cli() -> str:
631
+ launcher = Path(__file__).resolve().parent.parent / "bin" / "context-guard-skill.js"
632
+ return f"node {json.dumps(str(launcher))}"
633
+
634
+
635
+ def lifecycle_context(root: Path, workbench_url: str | None, current_session_id: str) -> str:
636
+ skill = Path(__file__).resolve().parent.parent / "SKILL.md"
185
637
  workbench = f" Workbench: {workbench_url}." if workbench_url else ""
186
638
  return (
187
- f"Context Guard is active for {root}.{workbench} "
188
- "Record a credible bad case with `context-guard record-bad-case --root "
189
- f"{quoted_root} --title <title> --phenomenon <what-failed> --trigger <trigger> "
190
- "--cause <cause-or-pending> --guard <regression-guard> --keys <comma-separated>`; "
191
- "never store secrets in project context."
639
+ f"Context Guard: {root}; Session: {current_session_id}.{workbench} "
640
+ f"Instructions: {skill}. CLI: {context_guard_cli()}."
192
641
  )
193
642
 
194
643
 
644
+ def frontend_workbench_url(ctx: Path, local_url: str | None, current_session_id: str) -> str | None:
645
+ """Expose one human UI: Cloud when configured, otherwise the local fallback."""
646
+ config = sync_command(ctx.parent.parent, "status", current_session_id)
647
+ cloud_url = str(config.get("url") or "") if isinstance(config, dict) else ""
648
+ project_id = str(config.get("projectId") or "") if isinstance(config, dict) else ""
649
+ try:
650
+ parsed = urlsplit(cloud_url)
651
+ if parsed.scheme == "https" and parsed.hostname and re.fullmatch(r"[a-z0-9][a-z0-9-]{0,63}", project_id):
652
+ origin = f"{parsed.scheme}://{parsed.netloc}"
653
+ return f"{origin}/projects/{quote(project_id)}?session={quote(current_session_id)}"
654
+ except ValueError:
655
+ pass
656
+ return local_url
657
+
658
+
195
659
  def prompt_text(raw: str) -> str:
196
660
  payload = parse_hook_payload(raw)
197
661
  return payload_value(payload, ("prompt", "user_prompt", "userPrompt", "text", "content"))
@@ -203,7 +667,7 @@ def redact(text: str) -> str:
203
667
  return text
204
668
 
205
669
 
206
- def append_user_message(ctx: Path, text: str) -> str:
670
+ def append_user_message(ctx: Path, text: str, current_session_id: str) -> str:
207
671
  text = (text or "").strip()
208
672
  if not text:
209
673
  return "empty"
@@ -213,15 +677,958 @@ def append_user_message(ctx: Path, text: str) -> str:
213
677
  path.write_text("# User Message Memory\n\n## Recent User Signals\n\n", encoding="utf-8")
214
678
  body = path.read_text(encoding="utf-8")
215
679
  line = "- " + redact(text).replace("\n", " ")
216
- if line in body:
217
- return "duplicate"
218
- marker = "## Recent User Signals"
219
- if marker in body:
220
- body = body.replace(marker, marker + "\n\n" + line, 1)
680
+ recorded = False
681
+ if line not in body:
682
+ marker = "## Recent User Signals"
683
+ if marker in body:
684
+ body = body.replace(marker, marker + "\n\n" + line, 1)
685
+ else:
686
+ body += "\n" + line + "\n"
687
+ path.write_text(body, encoding="utf-8")
688
+ recorded = True
689
+ session_path = ctx / "sessions" / f"{safe_identifier(current_session_id)}.md"
690
+ if session_path.is_file():
691
+ session_body = session_path.read_text(encoding="utf-8")
692
+ session_marker = "## User Signals"
693
+ if line not in session_body:
694
+ if session_marker in session_body:
695
+ session_body = session_body.replace(session_marker, session_marker + "\n\n" + line, 1)
696
+ else:
697
+ session_body += ("\n" if session_body.endswith("\n") else "\n\n") + session_marker + "\n\n" + line + "\n"
698
+ session_path.write_text(session_body, encoding="utf-8")
699
+ recorded = True
700
+ return "recorded" if recorded else "duplicate"
701
+
702
+
703
+ def sync_configured(root: Path, current_session_id: str) -> bool:
704
+ result = sync_command(root, "status", current_session_id)
705
+ if result.get("error"):
706
+ raise ValueError(f"Cloud sync status failed: {json.dumps(result, ensure_ascii=False)}")
707
+ return result.get("configured") is True
708
+
709
+
710
+ def sync_command(root: Path, action: str, current_session_id: str = "", paths: list[str] | None = None) -> dict[str, object]:
711
+ script = Path(__file__).resolve().parent / "workbench" / "cli.mjs"
712
+ if not script.is_file():
713
+ return {"error": {"code": "SYNC_TOOL_MISSING", "message": str(script)}}
714
+ command = ["node", str(script), "sync", action, "--root", str(root)]
715
+ if current_session_id:
716
+ command.extend(["--session", current_session_id])
717
+ clean_paths = [item for item in (paths or []) if item]
718
+ if clean_paths:
719
+ command.extend(["--paths", ",".join(clean_paths)])
720
+ try:
721
+ result = subprocess.run(command, cwd=root, capture_output=True, text=True, encoding="utf-8", timeout=15,
722
+ check=False, creationflags=WINDOWS_NO_WINDOW)
723
+ except (OSError, subprocess.SubprocessError) as error:
724
+ return {"error": {"code": "SYNC_TOOL_FAILED", "message": str(error)}}
725
+ output = (result.stdout or "").strip().splitlines()
726
+ try:
727
+ value = json.loads(output[-1]) if output else None
728
+ if not isinstance(value, dict) or not value:
729
+ raise ValueError("missing sync response")
730
+ except (json.JSONDecodeError, ValueError):
731
+ value = {"error": {"code": "SYNC_TOOL_FAILED", "message": (result.stderr or result.stdout or "invalid output").strip()[:500]}}
732
+ if result.returncode and "error" not in value:
733
+ value = {"error": {"code": "SYNC_TOOL_FAILED", "message": (result.stderr or "sync command failed").strip()[:500]}}
734
+ return value
735
+
736
+
737
+ def session_memory_sync(root: Path, current_session_id: str, event: str, payload: object) -> dict[str, object]:
738
+ """Persist this Session checkpoint directly; a later hook retries preserved local data."""
739
+ event_id, _, _ = event_identity(payload, event, current_session_id)
740
+ try:
741
+ return run_node_workbench([
742
+ "memory", "sync", "--root", str(root), "--session", current_session_id,
743
+ "--hook-event", HOOK_EVENT_NAMES.get(event, event),
744
+ "--event-id", event_id,
745
+ "--occurred-at", payload_time(payload),
746
+ ])
747
+ except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired) as error:
748
+ return {"error": {"code": "MEMORY_SYNC_PENDING", "message": str(error)[:500]}}
749
+
750
+
751
+ def sync_pending_interrupt(root: Path, session: str, runtime: dict) -> None:
752
+ """Keep interruption signals until the local backend accepts the original ID."""
753
+ pending = runtime.get("pending_interrupts") or []
754
+ if not pending:
755
+ return
756
+ item = pending[0]
757
+ try:
758
+ run_node_workbench(["map", "interrupted", "--root", str(root), "--session", session,
759
+ "--event-id", item["id"], "--occurred-at", item["at"]])
760
+ except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired):
761
+ # No adapter stderr or credentials enter development memory.
762
+ runtime["interrupt_sync"] = "pending"
763
+ else:
764
+ runtime["pending_interrupts"] = pending[1:]
765
+ runtime["interrupt_sync"] = "pending" if pending[1:] else "confirmed"
766
+ write_hook_runtime(root, session, runtime)
767
+
768
+
769
+ def tool_paths(payload: object, root: Path) -> list[str]:
770
+ if not isinstance(payload, dict):
771
+ return []
772
+ found: set[str] = set()
773
+
774
+ def add(value: object) -> None:
775
+ values = value if isinstance(value, list) else [value]
776
+ for item in values:
777
+ if not isinstance(item, str) or not item.strip():
778
+ continue
779
+ candidate = Path(item).expanduser()
780
+ try:
781
+ resolved = candidate.resolve() if candidate.is_absolute() else (root / candidate).resolve()
782
+ relative = resolved.relative_to(root.resolve())
783
+ except (OSError, ValueError):
784
+ continue
785
+ found.add(relative.as_posix())
786
+
787
+ def walk(value: object) -> None:
788
+ if isinstance(value, dict):
789
+ for key, child in value.items():
790
+ if key.lower() in {"path", "paths", "file", "file_path", "filepath", "target_file", "targetfile"}:
791
+ add(child)
792
+ walk(child)
793
+ elif isinstance(value, list):
794
+ for child in value:
795
+ walk(child)
796
+
797
+ walk(payload.get("tool_input", {}))
798
+ command = tool_command(payload)
799
+ for match in re.finditer(r"^\*\*\* (?:(?:Add|Update|Delete) File|Move to): (.+)$", command, re.MULTILINE):
800
+ add(match.group(1).strip())
801
+ for target in shell_output_targets(command):
802
+ add(target)
803
+ return sorted(found)
804
+
805
+
806
+ def shell_output_targets(command: str) -> list[str]:
807
+ """Find literal shell write targets without treating heredoc content as commands."""
808
+ first_line = command.split("\n", 1)[0]
809
+ if not first_line:
810
+ return []
811
+ try:
812
+ lexer = shlex.shlex(first_line, posix=os.name != "nt", punctuation_chars=";&|<>")
813
+ lexer.whitespace_split = True
814
+ lexer.commenters = ""
815
+ tokens = list(lexer)
816
+ except ValueError:
817
+ return []
818
+ targets: list[str] = []
819
+ for index, token in enumerate(tokens[:-1]):
820
+ if token in {">", ">>", ">|", "&>"}:
821
+ target = tokens[index + 1]
822
+ if target not in {"/dev/null", "&1", "&2"} and not target.isdigit():
823
+ targets.append(target)
824
+ segment: list[str] = []
825
+ for token in [*tokens, ";"]:
826
+ if token not in {";", "&&", "||", "|"}:
827
+ segment.append(token)
828
+ continue
829
+ words = segment
830
+ segment = []
831
+ if not words:
832
+ continue
833
+ command_name = Path(words[0]).name
834
+ args = words[1:]
835
+ if command_name == "git" and args and args[0] in {"mv", "rm"}:
836
+ command_name, args = args[0], args[1:]
837
+ if command_name in {"mv", "cp", "rm", "touch", "mkdir"}:
838
+ targets.extend(value for value in args if value != "--" and
839
+ not value.startswith("-") and value not in {">", ">>", ">|", "&>", "<", "<<"})
840
+ return targets
841
+
842
+
843
+ def tool_target_strings(payload: object) -> list[str]:
844
+ """Declared write targets, including host paths outside the project."""
845
+ if not isinstance(payload, dict):
846
+ return []
847
+ found: list[str] = []
848
+
849
+ def add(value: object) -> None:
850
+ values = value if isinstance(value, list) else [value]
851
+ for item in values:
852
+ if isinstance(item, str) and item.strip():
853
+ found.append(item.strip())
854
+
855
+ def walk(value: object) -> None:
856
+ if isinstance(value, dict):
857
+ for key, child in value.items():
858
+ if key.lower() in {"path", "paths", "file", "file_path", "filepath", "target_file", "targetfile"}:
859
+ add(child)
860
+ walk(child)
861
+ elif isinstance(value, list):
862
+ for child in value:
863
+ walk(child)
864
+
865
+ walk(payload.get("tool_input", {}))
866
+ command = tool_command(payload)
867
+ for match in re.finditer(r"^\*\*\* (?:(?:Add|Update|Delete) File|Move to): (.+)$", command, re.MULTILINE):
868
+ add(match.group(1).strip())
869
+ for target in shell_output_targets(command):
870
+ add(target)
871
+ return found
872
+
873
+
874
+ def write_like_tool(payload: object) -> bool:
875
+ if not isinstance(payload, dict):
876
+ return False
877
+ name = str(payload.get("tool_name") or payload.get("toolName") or "").lower()
878
+ return any(marker in name for marker in ("apply_patch", "write", "edit", "delete", "move"))
879
+
880
+
881
+ def _resolved_path(value: Path) -> Path | None:
882
+ try:
883
+ return value.expanduser().resolve()
884
+ except (OSError, RuntimeError, ValueError):
885
+ return None
886
+
887
+
888
+ def _under(path: Path, base: Path) -> bool:
889
+ try:
890
+ path.relative_to(base)
891
+ return True
892
+ except ValueError:
893
+ return False
894
+
895
+
896
+ def temp_request_roots() -> list[Path]:
897
+ raw = [tempfile.gettempdir(), os.environ.get("TMPDIR"), os.environ.get("TEMP"), os.environ.get("TMP")]
898
+ if os.name != "nt":
899
+ raw.append("/tmp")
900
+ found: list[Path] = []
901
+ seen: set[str] = set()
902
+ for item in raw:
903
+ if not item:
904
+ continue
905
+ resolved = _resolved_path(Path(item))
906
+ if resolved is None:
907
+ continue
908
+ key = os.path.normcase(str(resolved))
909
+ if key in seen:
910
+ continue
911
+ seen.add(key)
912
+ found.append(resolved)
913
+ return found
914
+
915
+
916
+ def temp_output_path(target: str) -> bool:
917
+ """True when a shell redirect target is a host-temp capture file."""
918
+ candidate = _resolved_path(Path(target.strip("\"'")))
919
+ if candidate is None:
920
+ return False
921
+ return any(_under(candidate, temp) for temp in temp_request_roots())
922
+
923
+
924
+ def protocol_request_write(payload: object, root: Path) -> bool:
925
+ """Allow only host-temp JSON request files, never sibling trees or user hooks."""
926
+ targets = tool_target_strings(payload)
927
+ if not targets:
928
+ return False
929
+ root_resolved = _resolved_path(root)
930
+ temps = temp_request_roots()
931
+ if root_resolved is None or not temps:
932
+ return False
933
+ for item in targets:
934
+ candidate = Path(item).expanduser()
935
+ resolved = _resolved_path(candidate if candidate.is_absolute() else root / candidate)
936
+ if resolved is None or resolved.suffix.lower() != ".json":
937
+ return False
938
+ if _under(resolved, root_resolved):
939
+ return False
940
+ if any(part.lower() in {".claude", ".codex", ".cursor"} for part in resolved.parts):
941
+ return False
942
+ if not any(_under(resolved, temp) for temp in temps):
943
+ return False
944
+ return True
945
+
946
+
947
+ def protocol_command(words: list[str], allowed: set[str]) -> bool:
948
+ """Recognize a Context Guard entry point without trusting its absolute path."""
949
+ if not words:
950
+ return False
951
+ entrypoints = {"context-guard", "context-guard-skill", "context_guard.py", "context-guard-skill.js"}
952
+ index = 0
953
+ first = words[0].strip("\"'")
954
+ if Path(first).name in {"node", "node.exe", "python", "python3", "python.exe"}:
955
+ index = 1
956
+ entry = words[index].strip("\"'") if index < len(words) else ""
957
+ return index < len(words) and Path(entry).name in entrypoints and index + 1 < len(words) and words[index + 1] in allowed
958
+
959
+
960
+ def control_words(words: list[str]) -> bool:
961
+ """Protocol writes are their own audited recovery path, not product edits."""
962
+ return protocol_command(words, {
963
+ "plan-start", "plan-finish", "plan-status", "archive-session", "resolve-signal", "split-signal",
964
+ "record-todo", "record-bad-case", "record-bad-case-fix", "map", "sync", "workbench",
965
+ "preferences", "memory", "set-language", "write-candidates", "doctor",
966
+ })
967
+
968
+
969
+ def diagnostic_words(words: list[str]) -> bool:
970
+ """Agents often append a no-op status probe after an audited command."""
971
+ if not words:
972
+ return False
973
+ return Path(words[0]).name in {"echo", "true", "false", "printf"}
974
+
975
+
976
+ def shell_segments(command: str) -> list[list[str]] | None:
977
+ """Parse a conservative shell subset used for inspection pipelines."""
978
+ if not command.strip():
979
+ return []
980
+ if "\n" in command or "`" in command or "$(" in command:
981
+ return None
982
+ try:
983
+ # POSIX shlex consumes backslashes in an unquoted Windows path. Native
984
+ # shell syntax needs native token preservation so CLI paths remain valid.
985
+ lexer = shlex.shlex(command, posix=os.name != "nt", punctuation_chars=";&|<>")
986
+ lexer.whitespace_split = True
987
+ lexer.commenters = ""
988
+ tokens = list(lexer)
989
+ except ValueError:
990
+ return None
991
+ segments: list[list[str]] = []
992
+ current: list[str] = []
993
+ index = 0
994
+ while index < len(tokens):
995
+ token = tokens[index]
996
+ if token in {"|", "||", "&&", ";"}:
997
+ if not current:
998
+ return None
999
+ segments.append(current)
1000
+ current = []
1001
+ index += 1
1002
+ continue
1003
+ if token in {">", ">>", ">&", "&>", "<<", "<<<"}:
1004
+ # Discarding diagnostic noise is harmless; every other output target writes.
1005
+ if current and current[-1].isdigit():
1006
+ current.pop()
1007
+ target = tokens[index + 1] if index + 1 < len(tokens) else ""
1008
+ if token == ">" and target == "/dev/null":
1009
+ index += 2
1010
+ continue
1011
+ if token == ">&" and target.isdigit():
1012
+ index += 2
1013
+ continue
1014
+ if token == "&>" and target == "/dev/null":
1015
+ index += 2
1016
+ continue
1017
+ # Read-only inspection may capture CLI output in host temp without a plan.
1018
+ if token in {">", ">>"} and current and read_only_words(current) and temp_output_path(target):
1019
+ index += 2
1020
+ continue
1021
+ return None
1022
+ if token == "<":
1023
+ if current and current[-1].isdigit():
1024
+ current.pop()
1025
+ if index + 1 >= len(tokens) or tokens[index + 1] in {"|", "||", "&&", ";"}:
1026
+ return None
1027
+ index += 2
1028
+ continue
1029
+ if re.fullmatch(r"(?:\d*)>&\d+", token):
1030
+ index += 1
1031
+ continue
1032
+ if "&" in token:
1033
+ return None
1034
+ current.append(token)
1035
+ index += 1
1036
+ if current:
1037
+ segments.append(current)
1038
+ return segments if segments else None
1039
+
1040
+
1041
+ def git_read_only(words: list[str]) -> bool:
1042
+ if len(words) < 2:
1043
+ return False
1044
+ command_index = 1
1045
+ while command_index < len(words) and words[command_index] in {"--no-pager", "-C"}:
1046
+ if words[command_index] == "-C":
1047
+ command_index += 2
1048
+ else:
1049
+ command_index += 1
1050
+ if command_index >= len(words) or words[command_index].startswith("-"):
1051
+ return False
1052
+ command = words[command_index]
1053
+ arguments = words[command_index + 1:]
1054
+ if any(item in {"--output", "--ext-diff", "--textconv", "--open-files-in-pager"} or item.startswith("--output=") for item in arguments):
1055
+ return False
1056
+ if command in {"status", "diff", "log", "show", "ls-files", "rev-parse", "describe", "blame", "grep"}:
1057
+ return True
1058
+ if command == "worktree":
1059
+ return bool(arguments) and arguments[0] == "list"
1060
+ if command == "remote":
1061
+ return not arguments or arguments == ["-v"] or arguments == ["--verbose"]
1062
+ if command == "branch":
1063
+ mutating = {"-d", "-D", "-m", "-M", "-c", "-C", "-t", "-u", "--delete", "--move", "--copy", "--track", "--no-track", "--edit-description", "--set-upstream-to", "--unset-upstream"}
1064
+ if any(item in mutating or any(item.startswith(flag + "=") for flag in mutating if flag.startswith("--")) for item in arguments):
1065
+ return False
1066
+ return not arguments or any(item in {"--show-current", "--list", "-a", "--all", "-r", "--remotes", "-v", "-vv", "--verbose"} for item in arguments)
1067
+ return False
1068
+
1069
+
1070
+ def curl_read_only(words: list[str]) -> bool:
1071
+ forbidden = {
1072
+ "-d", "--data", "--data-ascii", "--data-binary", "--data-raw", "--data-urlencode",
1073
+ "-F", "--form", "--form-string", "-T", "--upload-file", "-o", "--output", "-O",
1074
+ "--remote-name", "--remote-header-name", "-K", "--config", "--remove-on-error",
1075
+ }
1076
+ index = 1
1077
+ while index < len(words):
1078
+ item = words[index]
1079
+ if item in forbidden or any(item.startswith(flag + "=") for flag in forbidden if flag.startswith("--")):
1080
+ return False
1081
+ if item.startswith("-") and len(item) > 2 and not item.startswith("--") and any(flag in item[1:] for flag in "dFToOK"):
1082
+ return False
1083
+ if item in {"-X", "--request"}:
1084
+ if index + 1 >= len(words) or words[index + 1].upper() not in {"GET", "HEAD"}:
1085
+ return False
1086
+ index += 1
1087
+ elif item.startswith("-X") and len(item) > 2 and item[2:].upper() not in {"GET", "HEAD"}:
1088
+ return False
1089
+ elif item.startswith("--request=") and item.split("=", 1)[1].upper() not in {"GET", "HEAD"}:
1090
+ return False
1091
+ index += 1
1092
+ return True
1093
+
1094
+
1095
+ INTERPRETER_EXECUTABLES = {"python", "python3", "python.exe", "node", "node.exe"}
1096
+ READONLY_MAP_ACTIONS = {"status", "read", "changes", "inbox", "watch", "execution", "operation"}
1097
+ INSPECTION_PROTOCOL_COMMANDS = {
1098
+ "plan-status", "doctor", "preferences", "set-language", "write-candidates",
1099
+ }
1100
+ INSPECTION_SYNC_ACTIONS = {"status", "pull"}
1101
+ INSPECTION_MEMORY_ACTIONS = {"status", "history"}
1102
+ MUTATING_PROTOCOL_COMMANDS = {
1103
+ "plan-start", "plan-finish", "archive-session", "resolve-signal", "split-signal",
1104
+ "record-todo", "record-bad-case", "record-bad-case-fix",
1105
+ }
1106
+ PYTHON_C_FORBIDDEN = (
1107
+ "open(", "write(", "os.", "subprocess", "shutil", "pathlib", "import os", "exec(", "eval(", "__import__",
1108
+ )
1109
+
1110
+
1111
+ def protocol_entry_index(words: list[str]) -> int | None:
1112
+ if not words:
1113
+ return None
1114
+ index = 0
1115
+ first = words[0].strip("\"'")
1116
+ if Path(first).name in INTERPRETER_EXECUTABLES:
1117
+ index = 1
1118
+ if index >= len(words):
1119
+ return None
1120
+ entry = Path(words[index].strip("\"'")).name
1121
+ if entry not in {"context-guard", "context-guard-skill", "context_guard.py", "context-guard-skill.js"}:
1122
+ return None
1123
+ return index
1124
+
1125
+
1126
+ def inspection_protocol_words(words: list[str]) -> bool:
1127
+ """Read-only Context Guard CLI output safe to pipe into inspection tools."""
1128
+ index = protocol_entry_index(words)
1129
+ if index is None or index + 1 >= len(words):
1130
+ return False
1131
+ command = words[index + 1]
1132
+ if command in MUTATING_PROTOCOL_COMMANDS:
1133
+ return False
1134
+ if command == "map":
1135
+ if index + 2 >= len(words):
1136
+ return False
1137
+ action = words[index + 2]
1138
+ if action == "ci":
1139
+ return index + 3 < len(words) and words[index + 3] == "context"
1140
+ if action == "task":
1141
+ return False
1142
+ return action in READONLY_MAP_ACTIONS
1143
+ if command == "sync":
1144
+ return index + 2 < len(words) and words[index + 2] in INSPECTION_SYNC_ACTIONS
1145
+ if command == "memory":
1146
+ return index + 2 < len(words) and words[index + 2] in INSPECTION_MEMORY_ACTIONS
1147
+ if command == "workbench":
1148
+ return any(flag in words for flag in ("--diagnose", "--binding-status", "--list"))
1149
+ return command in INSPECTION_PROTOCOL_COMMANDS
1150
+
1151
+
1152
+ def node_eval_read_only(script: str) -> bool:
1153
+ stripped = script.strip()
1154
+ # Only a literal diagnostic is safe; concatenation can evaluate arbitrary code.
1155
+ return bool(re.fullmatch(r"""console\.log\((?:'[^'\\\r\n]*'|"[^"\\\r\n]*")\)\s*;?""", stripped))
1156
+
1157
+
1158
+ def python_c_read_only(script: str) -> bool:
1159
+ if len(script) > 500 or any(token in script for token in PYTHON_C_FORBIDDEN):
1160
+ return False
1161
+ return True
1162
+
1163
+
1164
+ def interpreter_read_only(words: list[str]) -> bool:
1165
+ if not words:
1166
+ return False
1167
+ executable = Path(words[0]).name
1168
+ if executable not in INTERPRETER_EXECUTABLES:
1169
+ return False
1170
+ args = words[1:]
1171
+ if not args:
1172
+ return False
1173
+ if args in (["--version"], ["-V"], ["-v"], ["--version"]):
1174
+ return True
1175
+ if executable.startswith("node") and len(args) == 2 and args[0] == "-e":
1176
+ script = args[1]
1177
+ # Non-POSIX shlex retains the shell quoting used on Windows. Unwrap only
1178
+ # the complete script argument, not paths or arbitrary command segments.
1179
+ if os.name == "nt" and len(script) >= 2 and script[0] == script[-1] and script[0] in {"'", '"'}:
1180
+ script = script[1:-1]
1181
+ return node_eval_read_only(script)
1182
+ if len(args) >= 2 and args[0] == "-m" and args[1] == "json.tool":
1183
+ return True
1184
+ if args[0] == "-c" and len(args) >= 2:
1185
+ return python_c_read_only(" ".join(args[1:]))
1186
+ return False
1187
+
1188
+
1189
+ def bare_interpreter_pipe_sink(words: list[str]) -> bool:
1190
+ return len(words) == 1 and Path(words[0]).name in INTERPRETER_EXECUTABLES
1191
+
1192
+
1193
+ def read_only_words(words: list[str]) -> bool:
1194
+ if not words:
1195
+ return False
1196
+ if control_words(words):
1197
+ return True
1198
+ if protocol_command(words, {"workbench"}):
1199
+ return "--diagnose" in words or "--binding-status" in words
1200
+ if interpreter_read_only(words):
1201
+ return True
1202
+ executable = Path(words[0]).name
1203
+ if executable in {"pwd", "ls", "cat", "head", "tail", "grep", "stat", "wc", "which", "type", "dirname", "basename", "realpath", "readlink", "printf", "echo", "true", "false"}:
1204
+ return True
1205
+ if executable == "rg":
1206
+ return "--pre" not in words and not any(item.startswith("--pre=") for item in words)
1207
+ if executable == "sed":
1208
+ return not any(item.startswith("--in-place") or re.match(r"^-[^-]*i", item) for item in words[1:])
1209
+ if executable == "find":
1210
+ dangerous = {"-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprint0", "-fprintf", "-fls"}
1211
+ return not any(item in dangerous for item in words[1:])
1212
+ if executable == "git":
1213
+ return git_read_only(words)
1214
+ if executable == "gh":
1215
+ return len(words) >= 3 and (words[1], words[2]) in {
1216
+ ("auth", "status"), ("pr", "checks"), ("pr", "list"),
1217
+ ("pr", "view"), ("run", "view"), ("run", "list"),
1218
+ }
1219
+ if executable in {"ps", "pgrep", "lsof", "netstat", "ss"}:
1220
+ return True
1221
+ if executable == "kill":
1222
+ return len(words) >= 3 and words[1] == "-0"
1223
+ if executable in {"nc", "netcat"}:
1224
+ return any(item == "-z" or (item.startswith("-") and "z" in item[1:]) for item in words[1:])
1225
+ if executable == "curl":
1226
+ return curl_read_only(words)
1227
+ return False
1228
+
1229
+
1230
+ def read_only_shell(command: str) -> bool:
1231
+ segments = shell_segments(command)
1232
+ if segments is None:
1233
+ return False
1234
+ for index, segment in enumerate(segments):
1235
+ if index > 0 and bare_interpreter_pipe_sink(segment) and inspection_protocol_words(segments[index - 1]):
1236
+ continue
1237
+ if not read_only_words(segment):
1238
+ return False
1239
+ return True
1240
+
1241
+
1242
+ def ci_tool_allowed(payload: object, execution: dict):
1243
+ active = execution.get("active") or {}
1244
+ if active.get("mode") != "ci" and not execution.get("ci"):
1245
+ return None
1246
+ return active.get("mode") == "ci" and tool_command(payload).strip() in (active.get("commands") or [])
1247
+
1248
+
1249
+ def control_tool(payload: object) -> bool:
1250
+ """Only standalone protocol commands can recover a blocked lifecycle."""
1251
+ segments = shell_segments(tool_command(payload))
1252
+ if not segments:
1253
+ return False
1254
+ # Drop trailing diagnostic probes (`echo EXIT=$?`) after an audited command.
1255
+ while len(segments) > 1 and diagnostic_words(segments[-1]) and not control_words(segments[-1]):
1256
+ segments = segments[:-1]
1257
+ if not control_words(segments[-1]):
1258
+ return False
1259
+ # Allow a literal stdin producer before an audited Context Guard command.
1260
+ # Arbitrary programs and additional commands never inherit this exemption.
1261
+ return len(segments) == 1 or (
1262
+ len(segments) == 2
1263
+ and Path(segments[0][0]).name in {"printf", "echo"}
1264
+ )
1265
+
1266
+
1267
+ def git_changed_paths(root: Path) -> list[str]:
1268
+ try:
1269
+ result = subprocess.run(
1270
+ ["git", "status", "--porcelain", "--untracked-files=all", "-z"], cwd=root, capture_output=True,
1271
+ timeout=5, check=False, creationflags=WINDOWS_NO_WINDOW,
1272
+ )
1273
+ except (OSError, subprocess.SubprocessError):
1274
+ return []
1275
+ if result.returncode:
1276
+ return []
1277
+ paths: set[str] = set()
1278
+ for entry in result.stdout.decode("utf-8", errors="replace").split("\0"):
1279
+ if len(entry) < 4:
1280
+ continue
1281
+ value = entry[3:]
1282
+ if " -> " in value:
1283
+ value = value.split(" -> ", 1)[1]
1284
+ if value and not value.startswith(".codex/context/private/"):
1285
+ paths.add(value)
1286
+ return sorted(paths)
1287
+
1288
+
1289
+ def mutating_tool(payload: object) -> bool:
1290
+ if not isinstance(payload, dict):
1291
+ return False
1292
+ if write_like_tool(payload):
1293
+ return True
1294
+ name = str(payload.get("tool_name") or payload.get("toolName") or "").lower()
1295
+ if name not in {"bash", "exec_command", "shell", "run_shell_command"}:
1296
+ return False
1297
+ if control_tool(payload):
1298
+ return False
1299
+ command = tool_command(payload).strip()
1300
+ # Unknown scripts stay potentially mutating. Inspection pipelines are allowed
1301
+ # only when every command and redirection is independently read-only.
1302
+ return not read_only_shell(command)
1303
+
1304
+
1305
+ def post_plan_delivery_command(payload: object, root: Path) -> bool:
1306
+ """Allow only repository delivery after reviewed work is archived and finished."""
1307
+ command = tool_command(payload)
1308
+ segments = shell_segments(command)
1309
+ if not segments or len(segments) != 1:
1310
+ return False
1311
+ words = segments[0]
1312
+ if not words or any(char in command for char in ("$", "`", "\n")):
1313
+ return False
1314
+ executable = Path(words[0]).name
1315
+ if executable == "git":
1316
+ index = 1
1317
+ if index < len(words) and words[index] == "-C":
1318
+ if index + 1 >= len(words) or _resolved_path(Path(words[index + 1])) != root.resolve():
1319
+ return False
1320
+ index += 2
1321
+ if index < len(words) and words[index] == "push":
1322
+ return not any(arg in {"--force", "-f", "--force-with-lease", "--mirror", "--delete"}
1323
+ or arg.startswith("--force-") for arg in words[index + 1:])
1324
+ return False
1325
+ if executable == "gh" and len(words) >= 3 and words[1] == "pr":
1326
+ if words[2] not in {"create", "merge"}:
1327
+ return False
1328
+ if words[2] == "merge" and (len(words) < 4 or not re.fullmatch(r"[1-9]\d*", words[3])):
1329
+ return False
1330
+ return not any(arg in {"--admin", "--repo", "-R", "--delete-branch"}
1331
+ or arg.startswith(("--repo=", "--admin=")) for arg in words[3:])
1332
+ return False
1333
+
1334
+
1335
+ def gh_pr_merge_command(payload: object) -> bool:
1336
+ """Recognize PR merges even when wrapped in shell chaining or redirection."""
1337
+ command = tool_command(payload)
1338
+ segments = shell_segments(command)
1339
+ if segments and any(len(words) >= 3 and Path(words[0]).name == "gh" and words[1:3] == ["pr", "merge"]
1340
+ for words in segments):
1341
+ return True
1342
+ return bool(re.search(r"\bgh\s+pr\s+merge\b", command))
1343
+
1344
+
1345
+ def post_plan_merge_target(payload: object) -> str | None:
1346
+ segments = shell_segments(tool_command(payload))
1347
+ words = segments[0] if segments and len(segments) == 1 else []
1348
+ return words[3] if len(words) >= 4 and Path(words[0]).name == "gh" and words[1:3] == ["pr", "merge"] else None
1349
+
1350
+
1351
+ def merge_checks_green(pr: dict, source_sha: str) -> bool:
1352
+ checks = pr.get("statusCheckRollup")
1353
+ if (pr.get("state") != "OPEN" or pr.get("mergeStateStatus") != "CLEAN" or
1354
+ pr.get("headRefOid") != source_sha or not isinstance(checks, list) or not checks or len(checks) > 100):
1355
+ return False
1356
+ def passed(check: dict) -> bool:
1357
+ if check.get("__typename") == "CheckRun":
1358
+ return check.get("status") == "COMPLETED" and check.get("conclusion") in {"SUCCESS", "SKIPPED"}
1359
+ return check.get("__typename") == "StatusContext" and check.get("state") == "SUCCESS"
1360
+ return all(isinstance(check, dict) and passed(check) for check in checks) and any(
1361
+ check.get("conclusion") == "SUCCESS" or check.get("state") == "SUCCESS" for check in checks)
1362
+
1363
+
1364
+ def billing_waiver_checks(root: Path, pr: dict, source_sha: str) -> bool:
1365
+ """An expiring, repository-scoped waiver for GitHub jobs blocked before startup."""
1366
+ repository = os.environ.get("CONTEXT_GUARD_GITHUB_BILLING_WAIVER_REPO", "")
1367
+ expires_at = os.environ.get("CONTEXT_GUARD_GITHUB_BILLING_WAIVER_UNTIL", "")
1368
+ try:
1369
+ deadline = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
1370
+ if not re.fullmatch(r"[\w.-]+/[\w.-]+", repository) or deadline.tzinfo is None or datetime.now(timezone.utc) >= deadline:
1371
+ return False
1372
+ if pr.get("state") != "OPEN" or pr.get("mergeStateStatus") != "UNSTABLE" or pr.get("headRefOid") != source_sha:
1373
+ return False
1374
+ rollup = pr.get("statusCheckRollup")
1375
+ if not isinstance(rollup, list) or not rollup or any(not isinstance(item, dict) or
1376
+ (item.get("__typename") == "StatusContext" and item.get("state") != "SUCCESS") or
1377
+ (item.get("__typename") == "CheckRun" and (item.get("status") != "COMPLETED" or
1378
+ item.get("conclusion") not in {"SUCCESS", "SKIPPED", "FAILURE"})) or
1379
+ item.get("__typename") not in {"StatusContext", "CheckRun"} for item in rollup):
1380
+ return False
1381
+ repo_result = subprocess.run(["gh", "repo", "view", "--json", "nameWithOwner"], cwd=root,
1382
+ capture_output=True, text=True, timeout=15, check=False, creationflags=WINDOWS_NO_WINDOW)
1383
+ if repo_result.returncode or json.loads(repo_result.stdout).get("nameWithOwner") != repository:
1384
+ return False
1385
+ runs_result = subprocess.run(["gh", "api", f"repos/{repository}/commits/{source_sha}/check-runs?filter=latest&per_page=100"],
1386
+ cwd=root, capture_output=True, text=True, timeout=15, check=False, creationflags=WINDOWS_NO_WINDOW)
1387
+ if runs_result.returncode:
1388
+ return False
1389
+ result = json.loads(runs_result.stdout)
1390
+ checks = result.get("check_runs")
1391
+ if not isinstance(checks, list) or not checks or len(checks) > 100 or result.get("total_count") != len(checks):
1392
+ return False
1393
+ failed = [check for check in checks if check.get("conclusion") == "failure"]
1394
+ if not failed or any(not isinstance(check, dict) or check.get("head_sha") != source_sha or check.get("status") != "completed" or
1395
+ check.get("conclusion") not in {"success", "skipped", "failure"} for check in checks):
1396
+ return False
1397
+ for check in failed:
1398
+ if not isinstance(check.get("id"), int) or check["id"] <= 0:
1399
+ return False
1400
+ annotations = subprocess.run(["gh", "api", f"repos/{repository}/check-runs/{check['id']}/annotations?per_page=100"],
1401
+ cwd=root, capture_output=True, text=True, timeout=15, check=False,
1402
+ creationflags=WINDOWS_NO_WINDOW)
1403
+ if annotations.returncode or not any(isinstance(item, dict) and
1404
+ "The job was not started because recent account payments have failed or your spending limit needs to be increased." in item.get("message", "")
1405
+ for item in json.loads(annotations.stdout)):
1406
+ return False
1407
+ return True
1408
+ except (OSError, ValueError, TypeError, subprocess.SubprocessError):
1409
+ return False
1410
+
1411
+
1412
+ def verified_merge_checks(root: Path, number: str) -> bool:
1413
+ try:
1414
+ head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=root, capture_output=True, text=True,
1415
+ timeout=5, check=False, creationflags=WINDOWS_NO_WINDOW)
1416
+ pr = subprocess.run(["gh", "pr", "view", number, "--json", "state,mergeStateStatus,statusCheckRollup,headRefOid"],
1417
+ cwd=root, capture_output=True, text=True, timeout=15, check=False,
1418
+ creationflags=WINDOWS_NO_WINDOW)
1419
+ if head.returncode or pr.returncode:
1420
+ return False
1421
+ details = json.loads(pr.stdout)
1422
+ return merge_checks_green(details, head.stdout.strip()) or billing_waiver_checks(root, details, head.stdout.strip())
1423
+ except (OSError, ValueError, subprocess.SubprocessError):
1424
+ return False
1425
+
1426
+
1427
+ def post_plan_delivery_ready(runtime: dict, execution: dict, root: Path) -> bool:
1428
+ last = runtime.get("last_plan") if isinstance(runtime.get("last_plan"), dict) else {}
1429
+ active = execution.get("active") or {}
1430
+ acceptance = active.get("acceptanceReview") or {}
1431
+ return (active.get("mode") == "reviewed"
1432
+ and acceptance.get("decision") == "approved"
1433
+ and last.get("status") == "completed"
1434
+ and isinstance(last.get("archive"), dict)
1435
+ and not git_changed_paths(root))
1436
+
1437
+
1438
+ def pending_signals(runtime: dict) -> list[str]:
1439
+ return [str(item.get("id")) for item in runtime.get("signals", [])
1440
+ if isinstance(item, dict) and item.get("status") == "pending"]
1441
+
1442
+
1443
+ def pending_signal_guidance(root: Path, session_id: str, runtime: dict) -> str:
1444
+ ids = pending_signals(runtime)
1445
+ command = (f"{context_guard_cli()} resolve-signal --root {json.dumps(str(root))} "
1446
+ f"--session {json.dumps(session_id)} --signal {ids[0]} --kind task")
1447
+ return ("Classify pending user signals before implementation: " + ", ".join(ids)
1448
+ + ". If this is the already-mounted Cloud task, run " + command
1449
+ + ". For a new request, record the appropriate TODO or Bug instead. Do not try another write tool.")
1450
+
1451
+
1452
+ def scope_paths(root: Path, values: object) -> list[str]:
1453
+ if not isinstance(values, list) or not values:
1454
+ raise ValueError("plan needs non-empty paths")
1455
+ result = []
1456
+ for value in values:
1457
+ if not isinstance(value, str) or not value.strip():
1458
+ raise ValueError("scope paths must be strings")
1459
+ candidate = Path(value)
1460
+ if candidate.is_absolute() or ".." in candidate.parts or value in {".", "./"}:
1461
+ raise ValueError("scope paths must be specific repository-relative files/directories")
1462
+ (root / candidate).resolve().relative_to(root.resolve())
1463
+ result.append(candidate.as_posix().rstrip("/") + ("/" if value.endswith("/") else ""))
1464
+ return sorted(set(result))
1465
+
1466
+
1467
+ def in_scope(file: str, paths: list[str]) -> bool:
1468
+ return any(file == item.rstrip("/") or file.startswith(item.rstrip("/") + "/") for item in paths)
1469
+
1470
+
1471
+ def scope_snapshot(root: Path, paths: list[str]) -> dict[str, str]:
1472
+ """Content hashes, not dirty filenames: edits to already dirty files count."""
1473
+ result = {}
1474
+ for value in paths:
1475
+ target = root / value
1476
+ files = target.rglob("*") if target.is_dir() else [target]
1477
+ for file in files:
1478
+ relative = file.relative_to(root).as_posix()
1479
+ if any(part in {".git", ".codex", "node_modules", "__pycache__"} for part in file.relative_to(root).parts):
1480
+ continue
1481
+ if file.is_symlink():
1482
+ result[relative] = "link:" + os.readlink(file)
1483
+ elif file.is_file():
1484
+ result[relative] = hashlib.sha256(file.read_bytes()).hexdigest()
1485
+ return result
1486
+
1487
+
1488
+ def checked_sync(root: Path, session: str, action: str, paths: list[str] | None = None) -> dict:
1489
+ value = sync_command(root, action, session, paths)
1490
+ if value.get("error") or value.get("status") == "conflict":
1491
+ raise ValueError(f"Cloud {action} failed: {json.dumps(value, ensure_ascii=False)}")
1492
+ return value
1493
+
1494
+
1495
+ def prepare_plan_sync(root: Path, session: str, paths: list[str]) -> dict:
1496
+ # The caller has already verified live node grants, reads and the inbox.
1497
+ # A transport outage is not a code-delivery verdict. Conflicts and denied
1498
+ # authority remain blocking; no local baseline is fabricated or replaced.
1499
+ value = sync_command(root, "prepare", session, paths)
1500
+ code = (value.get("error") or {}).get("code")
1501
+ if value.get("status") != "conflict" and code in {"MEMORY_UNAVAILABLE", "SYNC_TOOL_FAILED", "HTTP_ERROR", "UNAVAILABLE"}:
1502
+ return {"pending": True, "code": code}
1503
+ if value.get("error") or value.get("status") == "conflict":
1504
+ raise ValueError("Cloud prepare requires reconciliation or authorization")
1505
+ return value
1506
+
1507
+
1508
+ def plan_command(root: Path, session: str, command: str, data: dict) -> dict:
1509
+ with hook_runtime_lock(root, session):
1510
+ return _plan_command_locked(root, session, command, data)
1511
+
1512
+
1513
+ def _plan_command_locked(root: Path, session: str, command: str, data: dict) -> dict:
1514
+ runtime = read_hook_runtime(root, session)
1515
+ if command == "plan-status":
1516
+ return {"active_plan": runtime.get("active_plan"), "last_plan": runtime.get("last_plan"), "pending_signals": pending_signals(runtime)}
1517
+ if session not in {str(item.get("session_id")) for item in session_records(root)}:
1518
+ raise ValueError("plan needs an actual lifecycle session")
1519
+ if pending_signals(runtime):
1520
+ raise ValueError(pending_signal_guidance(root, session, runtime))
1521
+ ctx = context_folder(root)
1522
+ plan = runtime.get("active_plan")
1523
+ if command == "plan-start":
1524
+ if plan and data.get("extend") is not True:
1525
+ raise ValueError("A plan is already active; finish it before opening another")
1526
+ if data.get("approved") is not True or not str(data.get("summary", "")).strip():
1527
+ raise ValueError("plan-start needs approved:true and summary; an explicit implementation request already satisfies approval")
1528
+ paths = scope_paths(root, data.get("paths"))
1529
+ nodes = data.get("node_ids")
1530
+ if not isinstance(nodes, list) or not nodes or not all(isinstance(item, str) for item in nodes):
1531
+ raise ValueError("plan-start needs node_ids")
1532
+ # A Cloud-reviewed task needs the actual Coordinator receipt, not the
1533
+ # developer's approved:true attestation. Ordinary manual plans retain
1534
+ # their existing explicit-user-approval contract.
1535
+ execution = run_node_workbench(["map", "execution", "--root", str(root), "--session", session])
1536
+ active = execution.get("active") or {}
1537
+ if active.get("mode") == "ci" or execution.get("ci"):
1538
+ raise ValueError("CI uses its assigned test authorization; it cannot start a development Plan")
1539
+ if active.get("mode") == "reviewed":
1540
+ reviewed = execution.get("plan") or {}
1541
+ approved = active.get("plan") or {}
1542
+ if not active.get("approval") or reviewed.get("kind") != "plan" or reviewed.get("version") != approved.get("version"):
1543
+ raise ValueError("Coordinator must approve this exact Cloud Plan before development")
1544
+ if data.get("taskId") != active.get("taskId") or data.get("planRef") != approved.get("ref") or data.get("planVersion") != approved.get("version"):
1545
+ raise ValueError("plan-start must identify the approved taskId, planRef and planVersion")
1546
+ allowed_paths = scope_paths(root, (reviewed.get("content") or {}).get("paths"))
1547
+ if set(nodes) - set(active.get("nodeIds") or []) or any(not any(value == allowed or value.startswith(allowed.rstrip("/") + "/") for allowed in allowed_paths) for value in paths):
1548
+ raise ValueError("Local plan scope exceeds the approved Cloud Plan")
1549
+ if plan:
1550
+ raise ValueError("Finish the previous local plan before starting a Cloud-reviewed Plan")
1551
+ head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=root, capture_output=True, text=True, timeout=5, check=False, creationflags=WINDOWS_NO_WINDOW)
1552
+ if head.returncode or head.stdout.strip() != approved.get("sourceSha"):
1553
+ raise ValueError("Worktree HEAD differs from the approved Plan base SHA")
1554
+ # Fresh API read checks page drafts and actual session authorization.
1555
+ state = run_node_workbench(["map", "status", "--root", str(root), "--session", session])
1556
+ missing = set(nodes) - set(state.get("grants") or [])
1557
+ if missing:
1558
+ raise ValueError("Map authorization required: " + ", ".join(sorted(missing)))
1559
+ for node in nodes:
1560
+ run_node_workbench(["map", "read", "--root", str(root), "--session", session, "--node", node])
1561
+ inbox = run_node_workbench(["map", "inbox", "--root", str(root), "--session", session, "--start"])
1562
+ if inbox.get("pending"):
1563
+ raise ValueError("Read/process and acknowledge Map inbox before starting the plan")
1564
+ if plan:
1565
+ old_paths = plan["paths"]
1566
+ baseline = scope_snapshot(root, paths)
1567
+ dirty_paths = set(git_changed_paths(root))
1568
+ # Keep the original baseline inside the old scope: an extension
1569
+ # must not hide earlier edits or complete pending acceptance.
1570
+ for file, digest in baseline.items():
1571
+ if not any(file == value or file.startswith(value.rstrip("/") + "/") for value in old_paths):
1572
+ if file in dirty_paths:
1573
+ prior = subprocess.run(["git", "show", "HEAD:" + file], cwd=root, capture_output=True, timeout=5, check=False, creationflags=WINDOWS_NO_WINDOW)
1574
+ plan["scope_review_required"] = True
1575
+ if prior.returncode:
1576
+ continue
1577
+ digest = hashlib.sha256(prior.stdout).hexdigest()
1578
+ plan["baseline"].setdefault(file, digest)
1579
+ plan["paths"] = sorted(set(old_paths + paths))
1580
+ plan["node_ids"] = sorted(set(plan["node_ids"] + nodes))
1581
+ if sync_configured(root, session):
1582
+ checked_sync(root, session, "checkpoint")
1583
+ plan.setdefault("amendments", []).append({"at": utc_now(), "summary": data["summary"], "paths": paths, "node_ids": nodes})
1584
+ plan["revision"] += 1
1585
+ plan.pop("archive", None)
1586
+ write_hook_runtime(root, session, runtime)
1587
+ append_session_event(root, "plan-extend", "cli", session, {"plan_id": plan["id"], "occurred_at": utc_now()})
1588
+ return plan
1589
+ baseline = scope_snapshot(root, paths)
1590
+ sync = prepare_plan_sync(root, session, paths) if sync_configured(root, session) else {}
1591
+ plan = {"id": "plan-" + hashlib.sha256(f"{session}:{utc_now()}".encode()).hexdigest()[:20],
1592
+ "summary": data["summary"], "status": "working", "started_at": utc_now(),
1593
+ "node_ids": sorted(set(nodes)), "paths": paths, "actual_paths": [],
1594
+ "baseline": baseline, "revision": 0, "map_version": state.get("version"), "sync": sync,
1595
+ "delivery": {"source": "working", "memory": "pending" if sync.get("pending") else "ready"}}
1596
+ runtime["active_plan"] = plan
221
1597
  else:
222
- body += "\n" + line + "\n"
223
- path.write_text(body, encoding="utf-8")
224
- return "recorded"
1598
+ if not isinstance(plan, dict):
1599
+ if isinstance(runtime.get("last_plan"), dict) and runtime["last_plan"].get("status") == "completed":
1600
+ return runtime["last_plan"]
1601
+ raise ValueError("No active plan")
1602
+ receipt = plan.get("archive")
1603
+ if not isinstance(receipt, dict) or receipt.get("revision") != plan.get("revision"):
1604
+ raise ValueError("Archive this plan with verification and node assessment before plan-finish")
1605
+ require_human_work_review(root, session, plan)
1606
+ if scope_snapshot(root, plan["paths"]) != receipt.get("snapshot"):
1607
+ raise ValueError("Files changed after archive; verify and archive again")
1608
+ inbox = run_node_workbench(["map", "inbox", "--root", str(root), "--session", session])
1609
+ if inbox.get("pending"):
1610
+ raise ValueError("Other Map changes need review and acknowledgement before plan-finish")
1611
+ if sync_configured(root, session):
1612
+ if (plan.get("sync") or {}).get("pending"):
1613
+ plan["sync"] = checked_sync(root, session, "prepare", plan["paths"])
1614
+ write_hook_runtime(root, session, runtime)
1615
+ checked_sync(root, session, "checkpoint")
1616
+ finished = checked_sync(root, session, "finish")
1617
+ if finished.get("confirmed") is not True:
1618
+ raise ValueError("Cloud did not confirm completion")
1619
+ plan["status"] = "completed"
1620
+ plan["delivery"] = {"source": "verified", "memory": "confirmed"}
1621
+ plan["completed_at"] = utc_now()
1622
+ latest = read_hook_runtime(root, session)
1623
+ active = latest.get("active_plan") or {}
1624
+ if pending_signals(latest) or active.get("id") != plan["id"] or active.get("revision") != plan.get("revision"):
1625
+ raise ValueError("New prompts or tool activity arrived during finish; review before completing")
1626
+ runtime = latest
1627
+ runtime["last_plan"] = plan
1628
+ runtime["active_plan"] = None
1629
+ write_hook_runtime(root, session, runtime)
1630
+ append_session_event(root, command, "cli", session, {"plan_id": plan["id"], "occurred_at": utc_now(), "result": plan["status"]})
1631
+ return plan
225
1632
 
226
1633
 
227
1634
  def main() -> int:
@@ -236,34 +1643,174 @@ def main() -> int:
236
1643
  payload = parse_hook_payload(raw)
237
1644
  root, root_source = event_root(raw, Path.cwd())
238
1645
  ctx = context_folder(root)
239
- current_session_id = session_id(payload, platform)
240
1646
 
241
- if is_context_guard_skill_path(root):
1647
+ if is_context_guard_skill_path(root) and not initialized_source_project(root):
242
1648
  hook_log("[context-guard] apparent root is the skill directory; skipping writes.")
243
1649
  return hook_response(platform, event)
1650
+ current_session_id = session_id(payload, platform, ctx, event)
1651
+ current_session_name = session_display_name(payload, platform, root, current_session_id)
1652
+ memory_notice = ""
1653
+ verified_workbench_url = ""
244
1654
 
245
- if event in {"session-start", "subagent-start"}:
246
- created = init_context(root)
247
- append_session_event(
248
- root,
249
- event,
250
- platform,
251
- current_session_id,
252
- {"root_source": root_source},
253
- )
254
- url = None
255
- if event == "session-start" and not (
256
- isinstance(payload, dict) and payload.get("is_background_agent") is True
257
- ):
258
- start_reason = payload_value(payload, ("source", "reason", "session_start_type")).lower()
259
- url = start_workbench(
260
- root,
261
- open_browser=start_reason not in {"resume", "clear", "compact"},
262
- )
263
- hook_log(
264
- f"[context-guard] {'initialized' if created else 'ready'} {ctx} ({root_source})"
265
- )
266
- contexts = [language_setup_context(root, ctx), lifecycle_context(root, url)]
1655
+ def session_details(details: dict[str, object] | None = None) -> dict[str, object]:
1656
+ result = dict(details or {})
1657
+ result["worktree_root"] = str(root)
1658
+ if current_session_name:
1659
+ result["thread_name"] = current_session_name
1660
+ return result
1661
+
1662
+ # Binding precedes initialization, memory reads, and service startup.
1663
+ # The unbound lifecycle record is registration evidence, not project memory.
1664
+ if event in {"session-start", "subagent-start", "user-prompt-submit", "post-compact"}:
1665
+ try:
1666
+ binding = run_node_workbench(["workbench", "--binding-status", "--root", str(root), "--session", current_session_id])
1667
+ except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired):
1668
+ return hook_response(platform, event, "Context Guard binding unreadable; preserve data. Run workbench --diagnose.")
1669
+ if not binding.get("session", {}).get("bound"):
1670
+ current_workbench = None
1671
+ try:
1672
+ inventory = run_node_workbench(["workbench", "--list", "--root", str(root)])
1673
+ candidate = inventory.get("currentProject")
1674
+ if isinstance(candidate, dict):
1675
+ current_workbench = candidate
1676
+ except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired):
1677
+ # Binding diagnosis remains authoritative for this Session. A
1678
+ # failed global inventory must not create or select a service.
1679
+ current_workbench = None
1680
+ ctx.mkdir(parents=True, exist_ok=True)
1681
+ registration = {
1682
+ "at": utc_now(), "event": event, "platform": platform,
1683
+ "session_id": current_session_id, "thread_name": current_session_name,
1684
+ "binding": "required",
1685
+ }
1686
+ registration.update(audit_details(payload, event, current_session_id, {}, {"root_source": root_source}))
1687
+ with (ctx / "sessions.jsonl").open("a", encoding="utf-8") as stream:
1688
+ stream.write(json.dumps(registration, ensure_ascii=False) + "\n")
1689
+ main_notice = "First ask which remote/branch is authoritative; persist with workbench --bind-main or --local-main. " if binding.get("bindingRequired") else ""
1690
+ previous = binding.get("session", {})
1691
+ target = previous.get("worktreeRoot")
1692
+ prior = f" It is currently recorded against {target}; use --rebind only after explicit confirmation." if target else ""
1693
+ status = str(current_workbench.get("status") or "unknown") if current_workbench else ""
1694
+ established = bool(current_workbench and (current_workbench.get("registered") or current_workbench.get("runningInstances") == 1))
1695
+ binding_state = str(previous.get("state") or "unbound")
1696
+ safe_existing_binding = binding_state in {"current", "moved"}
1697
+ automatic = (not target or safe_existing_binding) and not binding.get("bindingRequired") and established and status in {
1698
+ "ready", "direct-only", "route-stale", "stopped", "upgrade-required"
1699
+ }
1700
+ if automatic:
1701
+ try:
1702
+ repaired = run_node_workbench([
1703
+ "workbench", "--root", str(root), "--session", current_session_id,
1704
+ ])
1705
+ verified_workbench_url = str(repaired.get("url") or "")
1706
+ binding = run_node_workbench([
1707
+ "workbench", "--binding-status", "--root", str(root),
1708
+ "--session", current_session_id,
1709
+ ])
1710
+ if not binding.get("session", {}).get("bound") or not binding.get("session", {}).get("verified"):
1711
+ raise RuntimeError("automatic binding receipt was not verified")
1712
+ except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired):
1713
+ return hook_response(
1714
+ platform, event,
1715
+ "Context Guard automatic binding unverified. "
1716
+ f"Run {context_guard_cli()} workbench --root {json.dumps(str(root))} --session {json.dumps(current_session_id)} to retry the same binding. "
1717
+ "Existing workbench preserved.",
1718
+ )
1719
+ elif current_workbench:
1720
+ status = str(current_workbench.get("status") or "unknown")
1721
+ url = str(current_workbench.get("url") or "")
1722
+ readable = f" at {url}" if url else ""
1723
+ if status in {"legacy", "duplicate", "unknown", "route-mismatch"}:
1724
+ return hook_response(
1725
+ platform, event,
1726
+ main_notice + f"This Session is not bound to the current worktree.{prior} "
1727
+ f"The matching project workbench{readable} is {status}. Do not create or bind another service. "
1728
+ f"Run {context_guard_cli()} workbench --diagnose --root {json.dumps(str(root))} and resolve that runtime first.",
1729
+ )
1730
+ return hook_response(
1731
+ platform, event,
1732
+ main_notice + f"This Session is not bound to the current worktree.{prior} "
1733
+ f"The project workbench candidate{readable} is {status} and cannot be selected automatically. Ask which workbench to use. "
1734
+ "See SKILL.md for binding.",
1735
+ )
1736
+ elif not binding.get("session", {}).get("bound"):
1737
+ return hook_response(platform, event, main_notice + f"Context Guard: no established workbench for automatic Session binding.{prior} Ask the user for the project workbench URL; see SKILL.md for binding.")
1738
+ runtime_status = str(binding.get("runtime", {}).get("status") or "")
1739
+ if runtime_status in {"legacy", "duplicate", "unknown"}:
1740
+ return hook_response(platform, event, f"Context Guard binding exists, but the project workbench runtime is {runtime_status}. Do not create another service or ask to bind again. Run {context_guard_cli()} workbench --diagnose --root {json.dumps(str(root))} and follow the explicit migration result.")
1741
+ if not binding.get("session", {}).get("verified") and os.environ.get("CONTEXT_GUARD_DISABLE_WORKBENCH") != "1":
1742
+ try:
1743
+ repaired = run_node_workbench([
1744
+ "workbench", "--root", str(root), "--session", current_session_id,
1745
+ ])
1746
+ verified_workbench_url = str(repaired.get("url") or "")
1747
+ binding = run_node_workbench([
1748
+ "workbench", "--binding-status", "--root", str(root),
1749
+ "--session", current_session_id,
1750
+ ])
1751
+ if not binding.get("session", {}).get("verified"):
1752
+ raise RuntimeError("binding receipt was not verified")
1753
+ except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired):
1754
+ return hook_response(
1755
+ platform, event,
1756
+ f"Context Guard kept the existing Session binding, but its project workbench could not be verified or repaired. "
1757
+ f"Run {context_guard_cli()} workbench --diagnose --root {json.dumps(str(root))}. "
1758
+ "Source inspection and recovery commands may continue; do not create a second workbench or ask the user to bind again.",
1759
+ )
1760
+ try:
1761
+ memory = run_node_workbench(["memory", "prepare", "--root", str(root), "--session", current_session_id])
1762
+ if memory.get("current"):
1763
+ hook_log(f"[context-guard] server memory confirmed: {memory.get('sessionVersion')}; cache: {memory.get('cache')}")
1764
+ memory_notice = f"Server memory confirmed; cache: {memory.get('cache')}."
1765
+ else:
1766
+ memory_notice = "Private memory unconfigured; local records are unsynced."
1767
+ except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired):
1768
+ memory_notice = "Server memory unavailable or conflicting; local records unverified."
1769
+ if event not in {"session-start", "subagent-start", "user-prompt-submit", "post-compact"}:
1770
+ try:
1771
+ binding = run_node_workbench(["workbench", "--binding-status", "--root", str(root), "--session", current_session_id])
1772
+ except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired):
1773
+ return hook_response(platform, event, "Context Guard binding unavailable; no initialization.")
1774
+ if not binding.get("session", {}).get("bound"):
1775
+ forbidden = forbidden_direct_write(payload, root) if event == "pre-tool-use" else ""
1776
+ if forbidden:
1777
+ print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": forbidden}}))
1778
+ return 0
1779
+ return hook_response(platform, event, "Context Guard Session unbound; inspect workbench --list.")
1780
+ created = init_context(root)
1781
+ ensure_session_file(root, current_session_id, platform)
1782
+ # Keep the lease alive until this hook invocation returns. Kernel locks are
1783
+ # released even if the process crashes, so there is no stale lock cleanup.
1784
+ _runtime_lease = acquire_hook_runtime_lock(root, current_session_id)
1785
+ runtime = read_hook_runtime(root, current_session_id)
1786
+
1787
+ if event in {"session-start", "user-prompt-submit", "post-compact"}:
1788
+ sync_pending_interrupt(root, current_session_id, runtime)
1789
+
1790
+ if event == "session-start":
1791
+ # Registration must precede the inbox CLI's identity check.
1792
+ append_session_event(root, event, platform, current_session_id,
1793
+ session_details(audit_details(payload, event, current_session_id, runtime, {"root_source": root_source})))
1794
+ url = verified_workbench_url or None
1795
+ if not (isinstance(payload, dict) and payload.get("is_background_agent") is True):
1796
+ if not url:
1797
+ try:
1798
+ url = start_workbench(root, open_browser=False, raise_errors=True, session_id=current_session_id)
1799
+ except (OSError, RuntimeError, subprocess.TimeoutExpired):
1800
+ return hook_response(platform, event, f"Context Guard could not start or verify the bound project workbench. Run {context_guard_cli()} workbench --diagnose --root {json.dumps(str(root))}; no replacement service was started and the binding was preserved.")
1801
+ if sync_configured(root, current_session_id):
1802
+ sync_command(root, "ensure", current_session_id)
1803
+ synchronized_memory = session_memory_sync(root, current_session_id, event, payload)
1804
+ if isinstance(synchronized_memory.get("error"), dict):
1805
+ memory_notice = "Cloud Session registration pending; local records preserved."
1806
+ context_text, snapshot = map_context(root, ctx, current_session_id)
1807
+ runtime["last_map_version"] = snapshot.get("version")
1808
+ runtime["last_cloud_cursor"] = snapshot.get("cloud_cursor")
1809
+ runtime["last_session_start"] = payload_time(payload)
1810
+ write_hook_runtime(root, current_session_id, runtime)
1811
+ hook_log(f"[context-guard] {'initialized' if created else 'ready'} {ctx} ({root_source})")
1812
+ contexts = [memory_notice, language_setup_context(root, ctx), context_text,
1813
+ lifecycle_context(root, frontend_workbench_url(ctx, url, current_session_id), current_session_id)]
267
1814
  playbook = ctx / "tasks" / "J2.md"
268
1815
  if playbook.is_file():
269
1816
  contexts.append(
@@ -272,28 +1819,282 @@ def main() -> int:
272
1819
  )
273
1820
  return hook_response(platform, event, "\n\n".join(item for item in contexts if item))
274
1821
 
1822
+ if event == "subagent-start":
1823
+ context_text, snapshot = map_context(root, ctx, current_session_id)
1824
+ plan = runtime.get("active_plan") if isinstance(runtime.get("active_plan"), dict) else {}
1825
+ agent_id = payload_value(payload, ("agent_id", "agentId"))
1826
+ agent_type = payload_value(payload, ("agent_type", "agentType"))
1827
+ subagents = runtime.get("subagents") if isinstance(runtime.get("subagents"), dict) else {}
1828
+ subagents[agent_id or f"unknown-{len(subagents) + 1}"] = {
1829
+ "type": agent_type,
1830
+ "started_at": payload_time(payload),
1831
+ "plan_id": plan.get("id") if isinstance(plan, dict) else None,
1832
+ "node_ids": list(plan.get("node_ids") or []) if isinstance(plan, dict) else [],
1833
+ "paths": list(plan.get("paths") or []) if isinstance(plan, dict) else [],
1834
+ "status": "working",
1835
+ }
1836
+ runtime["subagents"] = subagents
1837
+ write_hook_runtime(root, current_session_id, runtime)
1838
+ append_session_event(root, event, platform, current_session_id, session_details(audit_details(
1839
+ payload, event, current_session_id, runtime,
1840
+ {"agent_id": agent_id or None, "agent_type": agent_type or None, "node_ids": plan.get("node_ids", []), "paths": plan.get("paths", [])},
1841
+ )))
1842
+ boundary = (
1843
+ "Subagent scope is limited to the parent plan nodes "
1844
+ f"{', '.join(plan.get('node_ids') or []) or 'none'} and paths {', '.join(plan.get('paths') or []) or 'none'}. "
1845
+ )
1846
+ return hook_response(platform, event, context_text + "\n\n" + boundary)
1847
+
1848
+ if event == "pre-tool-use":
1849
+ if not mutating_tool(payload):
1850
+ return hook_response(platform, event)
1851
+ forbidden = forbidden_direct_write(payload, root)
1852
+ if forbidden:
1853
+ append_session_event(root, event, platform, current_session_id, session_details(audit_details(
1854
+ payload, event, current_session_id, runtime, {"result": "denied", "error": "DIRECT_CONTEXT_WRITE"},
1855
+ )))
1856
+ print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": forbidden}}, ensure_ascii=False))
1857
+ return 0
1858
+ paths = tool_paths(payload, root)
1859
+ # Host-temp JSON request files are not product implementation.
1860
+ if write_like_tool(payload) and not paths and protocol_request_write(payload, root):
1861
+ return hook_response(platform, event)
1862
+ if platform == "claude":
1863
+ execution = run_node_workbench(["map", "execution", "--root", str(root), "--session", current_session_id])
1864
+ ci = execution.get("active") or {}
1865
+ ci_allowed = ci_tool_allowed(payload, execution)
1866
+ if ci_allowed is not None:
1867
+ if ci_allowed:
1868
+ append_session_event(root, event, platform, current_session_id, session_details(audit_details(
1869
+ payload, event, current_session_id, runtime, {"result": "ci-test-authorized", "sourceSha": ci.get("sourceSha"), "taskId": ci.get("taskId")},
1870
+ )))
1871
+ return hook_response(platform, event)
1872
+ print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "CI may run only its assigned test commands; business source edits are forbidden."}}, ensure_ascii=False))
1873
+ return 0
1874
+ if gh_pr_merge_command(payload) and not post_plan_delivery_command(payload, root):
1875
+ print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Merge only with one standalone gh pr merge NUMBER command after accepted, archived work; shell chaining and --admin are not permitted."}}, ensure_ascii=False))
1876
+ return 0
1877
+ if post_plan_delivery_command(payload, root):
1878
+ reason = ""
1879
+ if not post_plan_delivery_ready(runtime, execution, root):
1880
+ reason = "Post-plan delivery needs the accepted task, archived Plan and clean worktree."
1881
+ elif (number := post_plan_merge_target(payload)) and not verified_merge_checks(root, number):
1882
+ reason = "GitHub PR is not ready: every check must be complete and green before merge."
1883
+ if reason:
1884
+ print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": reason}}, ensure_ascii=False))
1885
+ return 0
1886
+ append_session_event(root, event, platform, current_session_id, session_details(audit_details(
1887
+ payload, event, current_session_id, runtime,
1888
+ {"result": "post-plan-delivery", "taskId": ci.get("taskId")},
1889
+ )))
1890
+ return hook_response(platform, event)
1891
+ snapshot = map_snapshot(ctx, current_session_id)
1892
+ owners = owner_nodes(paths, snapshot)
1893
+ missing = sorted(set(owners.values()) - active_grants(snapshot))
1894
+ if missing:
1895
+ message = "Context Guard authorization required for Map node(s): " + ", ".join(missing) + ". Confirm the scope in the workbench, then retry the same tool."
1896
+ append_session_event(root, event, platform, current_session_id, session_details(audit_details(
1897
+ payload, event, current_session_id, runtime,
1898
+ {"result": "denied", "error": "MAP_SCOPE_REQUIRED", "node_ids": sorted(set(owners.values())), "paths": paths},
1899
+ )))
1900
+ print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": message}}, ensure_ascii=False))
1901
+ return 0
1902
+ plan = runtime.get("active_plan") if isinstance(runtime.get("active_plan"), dict) else None
1903
+ reason = ""
1904
+ if pending_signals(runtime):
1905
+ reason = pending_signal_guidance(root, current_session_id, runtime)
1906
+ elif not plan or plan.get("status") != "working":
1907
+ reason = "No active plan. Pipe authorized plan JSON to plan-start --input -."
1908
+ elif any(not in_scope(file, plan["paths"]) for file in paths) or set(owners.values()) - set(plan["node_ids"]):
1909
+ reason = "Tool exceeds the approved plan scope; do not silently expand it."
1910
+ if reason:
1911
+ print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": reason}}, ensure_ascii=False))
1912
+ return 0
1913
+ # Unknown scripts may mutate more than declared paths. Never certify their
1914
+ # scope from command text; require an explicit review in the archive.
1915
+ if not paths:
1916
+ plan["scope_review_required"] = True
1917
+ plan["revision"] = int(plan.get("revision", 0)) + 1
1918
+ plan.pop("archive", None)
1919
+ write_hook_runtime(root, current_session_id, runtime)
1920
+ append_session_event(root, event, platform, current_session_id, session_details(audit_details(
1921
+ payload, event, current_session_id, runtime,
1922
+ {"result": "checked" if paths else "scope-unknown", "map_version": snapshot.get("version"), "node_ids": sorted(set(owners.values())), "paths": paths},
1923
+ )))
1924
+ return hook_response(platform, event)
1925
+
1926
+ if event == "permission-request":
1927
+ paths = tool_paths(payload, root)
1928
+ snapshot = map_snapshot(ctx, current_session_id)
1929
+ owners = owner_nodes(paths, snapshot)
1930
+ missing = sorted(set(owners.values()) - active_grants(snapshot))
1931
+ append_session_event(root, event, platform, current_session_id, session_details(audit_details(
1932
+ payload, event, current_session_id, runtime,
1933
+ {"result": "denied" if missing else "deferred-to-user", "node_ids": sorted(set(owners.values())), "paths": paths},
1934
+ )))
1935
+ if missing:
1936
+ return permission_response(event, "deny", "Context Guard Map authorization is missing for: " + ", ".join(missing) + ". Authorize it in the workbench first.")
1937
+ return permission_response(event)
1938
+
1939
+ if event in {"post-tool-use", "post-tool-use-failure"}:
1940
+ if event == "post-tool-use" and control_tool(payload):
1941
+ return hook_response(platform, event)
1942
+ plan = runtime.get("active_plan") if isinstance(runtime.get("active_plan"), dict) else None
1943
+ paths = tool_paths(payload, root)
1944
+ if plan:
1945
+ before = plan.get("baseline") or {}
1946
+ after = scope_snapshot(root, plan["paths"])
1947
+ observed = {file for file in set(before) | set(after) if before.get(file) != after.get(file)}
1948
+ paths = sorted(set(paths) | observed)
1949
+ plan["actual_paths"] = sorted(set((plan.get("actual_paths") or []) + paths))
1950
+ # Local observations only; Cloud upload/check happens at plan-finish.
1951
+ write_hook_runtime(root, current_session_id, runtime)
1952
+ failed = event == "post-tool-use-failure" or bool(isinstance(payload, dict) and (payload.get("error") or payload.get("is_error") is True))
1953
+ response = payload.get("tool_response", {}) if isinstance(payload, dict) else {}
1954
+ failed = failed or (isinstance(response, dict) and (response.get("isError") is True or bool(response.get("exit_code"))))
1955
+ if plan and failed:
1956
+ plan["failure_review_required"] = True
1957
+ write_hook_runtime(root, current_session_id, runtime)
1958
+ append_session_event(root, "toolFailure" if failed else event, platform, current_session_id, session_details(audit_details(
1959
+ payload, event, current_session_id, runtime, {"result": "failed" if failed else "completed", "paths": paths},
1960
+ )))
1961
+ return hook_response(platform, event)
1962
+
275
1963
  if event == "user-prompt-submit":
276
- init_context(root)
277
- status = append_user_message(ctx, prompt_text(raw))
1964
+ prompt = prompt_text(raw)
1965
+ turn_id = payload_value(payload, ("turn_id", "turnId"))
1966
+ signal = add_prompt_signal(root, current_session_id, turn_id, prompt) if prompt.strip() else None
1967
+ runtime = read_hook_runtime(root, current_session_id)
1968
+ runtime["current_turn_id"] = turn_id or None
1969
+ runtime["last_prompt_signal"] = signal.get("id") if signal else None
1970
+ write_hook_runtime(root, current_session_id, runtime)
1971
+ status = append_user_message(ctx, prompt, current_session_id)
1972
+ context_text, snapshot = map_context(root, ctx, current_session_id)
278
1973
  append_session_event(
279
1974
  root,
280
1975
  event,
281
1976
  platform,
282
1977
  current_session_id,
283
- {"message_status": status},
1978
+ session_details(audit_details(payload, event, current_session_id, runtime, {
1979
+ "message_status": status,
1980
+ "signal_id": signal.get("id") if signal else None,
1981
+ "prompt_hash": signal.get("prompt_hash") if signal else None,
1982
+ "map_version": snapshot.get("version"),
1983
+ "cloud_cursor": snapshot.get("cloud_cursor"),
1984
+ })),
284
1985
  )
1986
+ synchronized_memory = session_memory_sync(root, current_session_id, event, payload)
1987
+ if isinstance(synchronized_memory.get("error"), dict):
1988
+ memory_notice = "Cloud Session sync pending; local records preserved."
285
1989
  hook_log(f"[context-guard] user-messages: {status}")
1990
+ signal_id = str(signal.get("id")) if signal else "none"
1991
+ pending_ids = pending_signals(runtime)
1992
+ pending_notice = ", ".join(pending_ids[:20]) or "none"
1993
+ if len(pending_ids) > 20:
1994
+ pending_notice += f" (+{len(pending_ids) - 20} more; use plan-status for the complete list)"
1995
+ plan = runtime.get("active_plan") if isinstance(runtime.get("active_plan"), dict) else None
1996
+ plan_notice = ""
1997
+ if plan:
1998
+ plan_notice = (
1999
+ f" Active plan: {plan.get('id')} ({plan.get('summary') or 'unfinished'}). "
2000
+ )
2001
+ notice = (
2002
+ context_text + "\n\n" +
2003
+ f"User signal: {signal_id}. Pending user signals: {pending_notice}." + plan_notice
2004
+ )
2005
+ return hook_response(platform, event, memory_notice + "\n\n" + notice)
2006
+
2007
+ if event == "pre-compact":
2008
+ plan = runtime.get("active_plan") if isinstance(runtime.get("active_plan"), dict) else None
2009
+ runtime["compact_snapshot"] = {
2010
+ "at": utc_now(),
2011
+ "turn_id": payload_value(payload, ("turn_id", "turnId")) or None,
2012
+ "trigger": payload_value(payload, ("trigger",)),
2013
+ "active_plan": json.loads(json.dumps(plan)) if plan else None,
2014
+ "pending_signals": [item.get("id") for item in runtime.get("signals") or [] if isinstance(item, dict) and item.get("status") == "pending"],
2015
+ }
2016
+ write_hook_runtime(root, current_session_id, runtime)
2017
+ append_session_event(root, event, platform, current_session_id, session_details(audit_details(payload, event, current_session_id, runtime, {"result": "saved"})))
286
2018
  return hook_response(platform, event)
287
2019
 
288
- if event in {"stop", "subagent-stop"}:
289
- init_context(root)
290
- append_session_event(root, event, platform, current_session_id)
291
- hook_log(
292
- "[context-guard] if this turn mattered, append sessions.jsonl and update bugs/tasks. "
293
- "Do not run Test Hub or Roadmap HTML."
294
- )
2020
+ if event == "post-compact":
2021
+ context_text, snapshot = map_context(root, ctx, current_session_id)
2022
+ append_session_event(root, event, platform, current_session_id, session_details(audit_details(
2023
+ payload, event, current_session_id, runtime, {"result": "restored", "map_version": snapshot.get("version"), "cloud_cursor": snapshot.get("cloud_cursor")},
2024
+ )))
2025
+ synchronized_memory = session_memory_sync(root, current_session_id, event, payload)
2026
+ if isinstance(synchronized_memory.get("error"), dict):
2027
+ memory_notice = "Cloud Session sync pending; local records preserved."
2028
+ plan = runtime.get("active_plan")
2029
+ restored = f"Restored plan: {plan.get('id')} with paths {', '.join(plan.get('actual_paths') or plan.get('paths') or [])}." if isinstance(plan, dict) else "No active development plan was present before compaction."
2030
+ restored += " Pending signals: " + (", ".join(pending_signals(runtime)) or "none") + "."
2031
+ return hook_response(platform, event, memory_notice + "\n\n" + context_text + "\n\n" + restored)
2032
+
2033
+ if event in {"interrupt", "stop-failure"}:
2034
+ event_id, _, _ = event_identity(payload, event, current_session_id)
2035
+ runtime["interrupted"] = {
2036
+ "at": utc_now(),
2037
+ "turn_id": payload_value(payload, ("turn_id", "turnId")) or None,
2038
+ "plan_id": (runtime.get("active_plan") or {}).get("id") if isinstance(runtime.get("active_plan"), dict) else None,
2039
+ "status": "interrupted",
2040
+ }
2041
+ pending = runtime.setdefault("pending_interrupts", [])
2042
+ if not any(item.get("id") == event_id for item in pending):
2043
+ pending.append({"id": event_id, "at": runtime["interrupted"]["at"]})
2044
+ write_hook_runtime(root, current_session_id, runtime)
2045
+ sync_pending_interrupt(root, current_session_id, runtime)
2046
+ append_session_event(root, event, platform, current_session_id, session_details(audit_details(payload, event, current_session_id, runtime, {"result": "interrupted"})))
2047
+ print(json.dumps({"systemMessage": "Context Guard: interrupted; plan preserved."}, ensure_ascii=False))
2048
+ return 0
2049
+
2050
+ if event == "session-end":
2051
+ append_session_event(root, event, platform, current_session_id, session_details(audit_details(
2052
+ payload, event, current_session_id, runtime, {"result": "ended", "reason": payload_value(payload, ("reason",))},
2053
+ )))
295
2054
  return hook_response(platform, event)
296
2055
 
2056
+ if event == "subagent-stop":
2057
+ agent_id = payload_value(payload, ("agent_id", "agentId"))
2058
+ subagents = runtime.get("subagents") if isinstance(runtime.get("subagents"), dict) else {}
2059
+ record = subagents.get(agent_id) if isinstance(subagents.get(agent_id), dict) else {}
2060
+ record.update({
2061
+ "status": "stopped",
2062
+ "stopped_at": payload_time(payload),
2063
+ "last_message_hash": hashlib.sha256(payload_value(payload, ("last_assistant_message", "lastAssistantMessage")).encode("utf-8")).hexdigest(),
2064
+ })
2065
+ subagents[agent_id or f"unknown-{len(subagents) + 1}"] = record
2066
+ runtime["subagents"] = subagents
2067
+ write_hook_runtime(root, current_session_id, runtime)
2068
+ append_session_event(root, event, platform, current_session_id, session_details(audit_details(payload, event, current_session_id, runtime, {"result": "stopped", "agent_id": agent_id or None})))
2069
+ print(json.dumps({"systemMessage": "Context Guard: subagent stopped; review pending."}, ensure_ascii=False))
2070
+ return 0
2071
+
2072
+ if event == "stop":
2073
+ pending = pending_signals(runtime)
2074
+ plan = runtime.get("active_plan") if isinstance(runtime.get("active_plan"), dict) else None
2075
+ # Codex renders Stop block reasons as a user-role hook_prompt. Cursor and
2076
+ # Claude still rely on the block contract to continue an unfinished turn.
2077
+ blocked = bool((plan or pending) and platform != "codex")
2078
+ # Stop checks local receipts only. Network work belongs to explicit plan
2079
+ # boundaries and cannot time out this short hook into false success.
2080
+ result = "incomplete" if blocked else "deferred" if (plan or pending) else "completed"
2081
+ append_session_event(root, "stop-blocked" if blocked else event, platform, current_session_id,
2082
+ session_details(audit_details(payload, event, current_session_id, runtime,
2083
+ {"result": result, "state": "active" if blocked else "stopped",
2084
+ "pending_signal_count": len(pending)})))
2085
+ if platform == "codex" or not blocked:
2086
+ # Pending signals and active plans are already durable and will be
2087
+ # re-injected through the next UserPromptSubmit additionalContext.
2088
+ # Any Codex Stop block text becomes a user-visible hook_prompt.
2089
+ print("{}")
2090
+ elif isinstance(payload, dict) and payload.get("stop_hook_active") is True:
2091
+ # Hosts may stop retrying hooks; allow reporting the blocker without
2092
+ # converting unfinished work into a completion receipt or looping.
2093
+ print(json.dumps({"systemMessage": "Context Guard is still finishing the current task. No user action is required."}, ensure_ascii=False))
2094
+ else:
2095
+ print(json.dumps({"decision": "block", "reason": "Context Guard is finishing the current task. No user action is required."}, ensure_ascii=False))
2096
+ return 0
2097
+
297
2098
  hook_log(f"[context-guard] ignored event: {event}")
298
2099
  return hook_response(platform, event)
299
2100