@michelj/context-guard 0.4.3 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/Coordinator.md +88 -0
  2. package/Executor.md +53 -0
  3. package/README.md +89 -224
  4. package/README.zh-CN.md +89 -224
  5. package/SKILL.md +26 -684
  6. package/THIRD_PARTY_NOTICES.md +47 -0
  7. package/Tester.md +53 -0
  8. package/agents/openai.yaml +2 -2
  9. package/bin/build-runtime.mjs +96 -0
  10. package/bin/context-guard-skill.js +399 -78
  11. package/bin/postinstall.js +2 -2
  12. package/hooks.json +89 -13
  13. package/licenses/JSONParse-MIT.txt +24 -0
  14. package/licenses/Marked-MIT.txt +44 -0
  15. package/licenses/Portless-Apache-2.0.txt +201 -0
  16. package/package.json +35 -6
  17. package/prototype/LICENSES/Marked-MIT.txt +44 -0
  18. package/prototype/LICENSES/Ready-redistribution.txt +14 -0
  19. package/prototype/attachments.mjs +75 -0
  20. package/prototype/coordinator-markdown.mjs +283 -0
  21. package/prototype/coordinator-working-blot.mjs +124 -0
  22. package/prototype/vendor/marked.mjs +2189 -0
  23. package/prototype/workbench-app.js +5197 -0
  24. package/prototype/workbench-data.js +33 -0
  25. package/prototype/workbench-sync.mjs +898 -0
  26. package/prototype/workbench.css +1050 -0
  27. package/prototype/workbench.html +211 -0
  28. package/prototype/working-blot-atlas.png +0 -0
  29. package/references/agent-handoff.md +40 -0
  30. package/references/claude-runtime.md +120 -0
  31. package/references/cloud-sync-interface.md +66 -0
  32. package/references/design-current.md +14 -0
  33. package/references/map-mount.md +41 -0
  34. package/references/map-read.md +50 -0
  35. package/references/memory-definition.md +120 -0
  36. package/references/memory-filesystem-v2/Bug.en.md +162 -0
  37. package/references/memory-filesystem-v2/Bug.md +162 -0
  38. package/references/memory-filesystem-v2/Bug_Coordinater.md +8 -0
  39. package/references/memory-filesystem-v2/Bug_Executor.md +8 -0
  40. package/references/memory-filesystem-v2/Bug_Tester.md +7 -0
  41. package/references/memory-filesystem-v2/Idea.en.md +36 -0
  42. package/references/memory-filesystem-v2/Idea.md +36 -0
  43. package/references/memory-filesystem-v2/Node_Module_Index.en.md +88 -0
  44. package/references/memory-filesystem-v2/Node_Module_Index.md +88 -0
  45. package/references/memory-filesystem-v2/README.md +60 -0
  46. package/references/memory-filesystem-v2/Todo.en.md +137 -0
  47. package/references/memory-filesystem-v2/Todo.md +137 -0
  48. package/references/memory-filesystem-v2/Todo_Coordinater.md +7 -0
  49. package/references/memory-filesystem-v2/Todo_Executor.md +7 -0
  50. package/references/memory-filesystem-v2/Todo_Tester.md +7 -0
  51. package/references/named-workbench.md +124 -0
  52. package/references/plan-review.md +12 -0
  53. package/references/server-memory.md +276 -0
  54. package/references/test-check.md +7 -0
  55. package/references/user-reply.md +38 -0
  56. package/references/workbench-interface.md +531 -0
  57. package/roles.md +13 -0
  58. package/scripts/context_guard.py +1366 -7602
  59. package/scripts/context_guard_hook.py +1960 -711
  60. package/scripts/map_owns.py +699 -0
  61. package/scripts/shared/LICENSES/JSONParse-MIT.txt +24 -0
  62. package/scripts/shared/filesystem-v2.mjs +430 -0
  63. package/scripts/shared/io.mjs +117 -0
  64. package/scripts/shared/map-model.mjs +506 -0
  65. package/scripts/shared/memory-schema.mjs +13 -0
  66. package/scripts/shared/protocol-blobs.mjs +112 -0
  67. package/scripts/shared/protocol-map.mjs +146 -0
  68. package/scripts/shared/protocol-snapshots.mjs +84 -0
  69. package/scripts/shared/protocol-store.mjs +624 -0
  70. package/scripts/shared/protocol-workflow.mjs +226 -0
  71. package/scripts/shared/protocol.mjs +125 -0
  72. package/scripts/shared/vendor/jsonparse.cjs +413 -0
  73. package/scripts/workbench/access.mjs +496 -0
  74. package/scripts/workbench/attachments.mjs +92 -0
  75. package/scripts/workbench/browser-login.mjs +78 -0
  76. package/scripts/workbench/claude-runtime.mjs +372 -0
  77. package/scripts/workbench/cli.mjs +980 -0
  78. package/scripts/workbench/device-heartbeat.mjs +72 -0
  79. package/scripts/workbench/hook-status.mjs +38 -0
  80. package/scripts/workbench/inbox.mjs +155 -0
  81. package/scripts/workbench/journal.mjs +56 -0
  82. package/scripts/workbench/memory-merge.mjs +65 -0
  83. package/scripts/workbench/memory.mjs +252 -0
  84. package/scripts/workbench/named-proxy.mjs +108 -0
  85. package/scripts/workbench/named.mjs +152 -0
  86. package/scripts/workbench/portless-routes.mjs +51 -0
  87. package/scripts/workbench/project.mjs +327 -0
  88. package/scripts/workbench/projections.mjs +68 -0
  89. package/scripts/workbench/protocol-client.mjs +165 -0
  90. package/scripts/workbench/protocol-delivery.mjs +133 -0
  91. package/scripts/workbench/protocol-device.mjs +316 -0
  92. package/scripts/workbench/protocol-events.mjs +53 -0
  93. package/scripts/workbench/protocol-repository.mjs +58 -0
  94. package/scripts/workbench/reconcile.mjs +244 -0
  95. package/scripts/workbench/registry.mjs +111 -0
  96. package/scripts/workbench/runtime.mjs +54 -0
  97. package/scripts/workbench/server.mjs +1171 -0
  98. package/scripts/workbench/store.mjs +243 -0
  99. package/scripts/workbench/sync-coordinator.mjs +518 -0
  100. package/scripts/workbench/sync.mjs +86 -0
  101. package/references/context-template.md +0 -341
  102. package/references/feature-chain-methodology.md +0 -228
  103. package/references/register-template.md +0 -85
  104. package/references/task-case-template.md +0 -63
  105. package/tests/BC-20260618-063.sh +0 -116
  106. package/tests/BC-20260618-065.sh +0 -66
  107. package/tests/BC-20260626-080.sh +0 -48
  108. package/tests/BC-20260626-081.sh +0 -40
  109. package/tests/BC-20260626-082.sh +0 -32
  110. package/tests/BC-20260626-083.sh +0 -66
  111. package/tests/BC-20260627-084.sh +0 -74
  112. package/tests/BC-20260630-086.sh +0 -50
  113. package/tests/BC-20260630-087.sh +0 -103
  114. package/tests/BC-20260630-088.sh +0 -32
  115. package/tests/BC-20260630-089.sh +0 -63
  116. package/tests/BC-20260701-090.sh +0 -84
  117. package/tests/BC-20260702-096.sh +0 -48
  118. package/tests/BC-20260706-098.sh +0 -66
  119. package/tests/BC-20260707-099.sh +0 -47
  120. package/tests/BC-20260707-100.sh +0 -46
  121. package/tests/BC-20260707-101.sh +0 -47
  122. package/tests/BC-20260707-102.sh +0 -68
  123. package/tests/BC-20260707-103.sh +0 -59
  124. package/tests/BC-20260707-104.sh +0 -103
  125. package/tests/BC-20260707-105.sh +0 -109
  126. package/tests/BC-20260707-106.sh +0 -80
  127. package/tests/BC-20260707-107.sh +0 -74
  128. package/tests/BC-20260707-108.sh +0 -48
  129. package/tests/BC-20260707-109.sh +0 -56
  130. package/tests/BC-20260707-110.sh +0 -71
  131. package/tests/BC-20260707-111.sh +0 -70
  132. package/tests/BC-20260707-112.sh +0 -45
  133. package/tests/BC-20260707-113.sh +0 -73
  134. package/tests/BC-20260707-115.sh +0 -77
  135. package/tests/BC-20260707-116.sh +0 -77
  136. package/tests/BC-20260707-118.sh +0 -115
  137. package/tests/BC-20260707-119.sh +0 -47
  138. package/tests/BC-20260707-120.sh +0 -60
  139. package/tests/BC-20260707-121.sh +0 -66
  140. package/tests/BC-20260707-122.sh +0 -48
  141. package/tests/BC-20260707-123.sh +0 -43
  142. package/tests/BC-20260707-124.sh +0 -56
  143. package/tests/BC-20260707-125.sh +0 -64
  144. package/tests/BC-20260707-126.sh +0 -80
  145. package/tests/BC-20260707-127.sh +0 -88
  146. package/tests/BC-20260707-129.sh +0 -59
  147. package/tests/BC-20260707-130.sh +0 -69
  148. package/tests/BC-20260707-131.sh +0 -140
  149. package/tests/BC-20260707-132.sh +0 -150
  150. package/tests/BC-20260707-133.sh +0 -70
  151. package/tests/BC-20260708-136.sh +0 -210
  152. package/tests/BC-20260708-137.sh +0 -106
  153. package/tests/BC-20260708-138.sh +0 -168
  154. package/tests/BC-20260708-139.sh +0 -79
  155. package/tests/BC-20260709-002.sh +0 -63
  156. package/tests/BC-20260709-003.sh +0 -239
  157. package/tests/BC-20260709-006.sh +0 -76
  158. package/tests/BC-20260709-008.sh +0 -168
  159. package/tests/BC-20260710-001.sh +0 -61
  160. package/tests/BC-20260710-002.sh +0 -111
  161. package/tests/npm-install-smoke.sh +0 -53
@@ -1,25 +1,33 @@
1
1
  #!/usr/bin/env python3
2
- """Lightweight lifecycle reminders for the Context Guard plugin.
3
-
4
- The hook initializes folder-scoped context on session/subagent start and nudges
5
- Codex to use the context-guard skill at the moments where omission is most
6
- costly: prompt intake, turn stop, and subagent stop.
7
- """
2
+ """Normalize Codex, Cursor, and Claude lifecycle hooks for Context Guard."""
8
3
 
9
4
  from __future__ import annotations
10
5
 
6
+ import argparse
11
7
  import json
8
+ import hashlib
12
9
  import os
13
10
  import re
11
+ import shlex
12
+ import sqlite3
14
13
  import subprocess
15
14
  import sys
16
- from datetime import datetime
17
- from pathlib import Path
15
+ import tempfile
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path, PureWindowsPath
18
+ from urllib.parse import quote, urlsplit
19
+
20
+ from context_guard import acquire_hook_runtime_lock, append_session_event
21
+ from context_guard import add_prompt_signal
22
+ from context_guard import context_dir as context_folder
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
18
27
 
19
- from context_guard import approved_dev_completion_tests, context_dir, init_context, resolve_registered_subagent_root
28
+ WINDOWS_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
20
29
 
21
30
 
22
- SKILL_ROOT = Path(__file__).resolve().parents[1]
23
31
  WORKSPACE_KEYS = {
24
32
  "cwd",
25
33
  "current_working_directory",
@@ -28,6 +36,8 @@ WORKSPACE_KEYS = {
28
36
  "workspace_root",
29
37
  "workspaceFolder",
30
38
  "workspace_folder",
39
+ "workspace_roots",
40
+ "workspaceRoots",
31
41
  "project",
32
42
  "project_root",
33
43
  "projectRoot",
@@ -41,68 +51,53 @@ WORKSPACE_ENV_KEYS = [
41
51
  "CODEX_WORKSPACE_ROOT",
42
52
  "CODEX_PROJECT_ROOT",
43
53
  "CODEX_CWD",
54
+ "CURSOR_PROJECT_DIR",
55
+ "CURSOR_WORKSPACE_ROOT",
56
+ "CLAUDE_PROJECT_DIR",
57
+ "CLAUDE_WORKSPACE_ROOT",
44
58
  "WORKSPACE_ROOT",
45
59
  "PROJECT_ROOT",
46
60
  "PWD",
47
61
  ]
48
62
 
49
63
 
50
- def is_inside(path: Path, parent: Path) -> bool:
51
- try:
52
- path.resolve().relative_to(parent.resolve())
53
- return True
54
- except ValueError:
55
- return False
56
-
57
-
58
- def is_context_guard_skill_path(path: Path) -> bool:
59
- return is_inside(path, SKILL_ROOT)
60
-
61
-
62
- def git_root(cwd: Path) -> Path:
63
- try:
64
- out = subprocess.check_output(
65
- ["git", "rev-parse", "--show-toplevel"],
66
- cwd=str(cwd),
67
- stderr=subprocess.DEVNULL,
68
- text=True,
69
- timeout=2,
70
- ).strip()
71
- if out:
72
- return Path(out)
73
- except Exception:
74
- pass
75
- return cwd
76
-
77
-
78
64
  def possible_workspace_paths(value: object) -> list[Path]:
79
65
  paths: list[Path] = []
80
66
 
81
67
  def add_path(candidate: object) -> None:
68
+ if isinstance(candidate, list):
69
+ for item in candidate:
70
+ add_path(item)
71
+ return
82
72
  if not isinstance(candidate, str):
83
73
  return
84
- text = candidate.strip()
85
- if not text or not text.startswith("/"):
74
+ text = os.path.expandvars(os.path.expanduser(candidate.strip()))
75
+ if not text:
86
76
  return
87
- path = Path(text).expanduser()
77
+ is_absolute = Path(text).is_absolute() or PureWindowsPath(text).is_absolute()
78
+ if not is_absolute:
79
+ return
80
+ path = Path(text)
88
81
  if path.exists():
89
- paths.append(path)
82
+ paths.append(path if path.is_dir() else path.parent)
90
83
 
91
- def walk(obj: object, key_hint: str = "") -> None:
84
+ def walk(obj: object) -> None:
92
85
  if isinstance(obj, dict):
93
86
  for key, child in obj.items():
94
87
  if key in WORKSPACE_KEYS:
95
88
  add_path(child)
96
- walk(child, key)
89
+ walk(child)
97
90
  elif isinstance(obj, list):
98
91
  for child in obj:
99
- walk(child, key_hint)
92
+ walk(child)
100
93
 
101
94
  walk(value)
102
95
  return paths
103
96
 
104
97
 
105
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")
106
101
  if not raw.strip():
107
102
  return {}
108
103
  try:
@@ -111,743 +106,1997 @@ def parse_hook_payload(raw: str) -> object:
111
106
  return {}
112
107
 
113
108
 
114
- def hook_agent_id(payload: object) -> str:
115
- keys = {"agent_id", "agentId", "subagent_id", "subagentId"}
116
- found: list[str] = []
117
-
118
- def walk(value: object) -> None:
119
- if isinstance(value, dict):
120
- for key, child in value.items():
121
- if key in keys and isinstance(child, str) and child.strip():
122
- found.append(child.strip())
123
- walk(child)
124
- elif isinstance(value, list):
125
- for child in value:
126
- walk(child)
127
-
128
- walk(payload)
129
- return found[0] if found else ""
130
-
131
-
132
- def hook_text_field(payload: object, key: str) -> str:
133
- if not isinstance(payload, dict):
134
- return ""
135
- value = payload.get(key)
136
- return value.strip() if isinstance(value, str) else ""
137
-
138
-
139
- def registered_subagent_control_root(payload: object, cwd: Path, agent_id: str) -> Path | None:
140
- if not agent_id:
141
- return None
142
- candidates = [*possible_workspace_paths(payload), cwd]
143
- seen: set[Path] = set()
144
- for path in candidates:
145
- control_root = git_root(path).resolve()
146
- if control_root in seen:
147
- continue
148
- seen.add(control_root)
149
- if resolve_registered_subagent_root(control_root, agent_id):
150
- return control_root
151
- return None
109
+ def initialized_source_project(root: Path) -> bool:
110
+ return (root / ".git").exists() and (root / ".codex" / "context" / "map.json").is_file()
152
111
 
153
112
 
154
- def event_root(raw: str, cwd: Path, event: str = "") -> tuple[Path, str]:
113
+ def event_root(raw: str, cwd: Path) -> tuple[Path, str]:
155
114
  payload = parse_hook_payload(raw)
156
115
  candidates: list[tuple[Path, str]] = []
157
116
  for path in possible_workspace_paths(payload):
158
117
  candidates.append((path, "hook payload"))
159
118
  for key in WORKSPACE_ENV_KEYS:
160
119
  value = os.environ.get(key, "").strip()
161
- if value.startswith("/"):
162
- path = Path(value).expanduser()
163
- if path.exists():
164
- candidates.append((path, f"${key}"))
120
+ for path in possible_workspace_paths({"root": value}):
121
+ candidates.append((path, f"${key}"))
165
122
  candidates.append((cwd, "process cwd"))
166
-
167
- agent_id = hook_agent_id(payload) if event in {"subagent-start", "subagent-stop"} else ""
168
- control_root = registered_subagent_control_root(payload, cwd, agent_id)
169
- if control_root:
170
- assigned = resolve_registered_subagent_root(control_root, agent_id)
171
- if assigned and not is_context_guard_skill_path(assigned):
172
- return assigned, f"registered subagent assignment ({agent_id})"
173
-
174
123
  for path, source in candidates:
175
- root = git_root(path)
176
- if not is_context_guard_skill_path(root):
124
+ root = folder_root(path)
125
+ if not is_context_guard_skill_path(root) or initialized_source_project(root):
177
126
  return root, source
178
-
179
- root = git_root(cwd)
180
- return root, "process cwd"
127
+ return folder_root(cwd), "process cwd"
181
128
 
182
129
 
183
130
  def read_stdin() -> str:
184
131
  try:
185
- return sys.stdin.read()
132
+ return sys.stdin.buffer.read().decode("utf-8", errors="replace")
186
133
  except Exception:
187
- return ""
134
+ try:
135
+ return sys.stdin.read()
136
+ except Exception:
137
+ return ""
188
138
 
189
139
 
190
140
  def hook_log(message: str) -> None:
191
141
  print(message, file=sys.stderr)
192
142
 
193
143
 
194
- def hook_response(**payload: object) -> int:
195
- print(json.dumps(payload, ensure_ascii=False))
196
- return 0
144
+ HOOK_EVENT_NAMES = {
145
+ "session-start": "SessionStart",
146
+ "subagent-start": "SubagentStart",
147
+ "user-prompt-submit": "UserPromptSubmit",
148
+ "stop": "Stop",
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",
159
+ }
197
160
 
198
161
 
199
- SECRET_PATTERNS = [
200
- re.compile(r"(?i)\b(password|passwd|pwd|token|api[_-]?key|secret|access[_-]?key|private[_-]?key)\b\s*[:=]\s*([^\s,;]+)"),
201
- re.compile(r"\bnpm_[A-Za-z0-9]{20,}\b"),
202
- re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{20,}\b"),
203
- re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"),
204
- ]
205
- EPHEMERAL_SECRET_PATTERN = re.compile(r"(?i)\b(otp|one[- ]?time password|验证码|一次性验证码)\b\s*[:=]?\s*([0-9]{4,8})?")
162
+ def hook_response(platform: str, event: str, additional_context: str = "") -> int:
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
167
+ if additional_context:
168
+ if platform == "cursor":
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
174
+ else:
175
+ payload["hookSpecificOutput"] = {
176
+ "hookEventName": HOOK_EVENT_NAMES.get(event, event),
177
+ "additionalContext": additional_context,
178
+ }
179
+ print(json.dumps(payload, ensure_ascii=False))
180
+ return 0
206
181
 
207
182
 
208
- def has_secret(text: str) -> bool:
209
- return any(pattern.search(text) for pattern in SECRET_PATTERNS)
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
210
197
 
211
198
 
212
- def has_ephemeral_secret(text: str) -> bool:
213
- return bool(EPHEMERAL_SECRET_PATTERN.search(text))
199
+ def payload_value(payload: object, keys: tuple[str, ...]) -> str:
200
+ if not isinstance(payload, dict):
201
+ return ""
202
+ for key in keys:
203
+ value = payload.get(key)
204
+ if isinstance(value, str) and value.strip():
205
+ return value.strip()
206
+ return ""
207
+
208
+
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)
214
461
 
215
462
 
216
- def redact_user_message(text: str) -> str:
217
- redacted = text
218
- for pattern in SECRET_PATTERNS:
219
- if pattern.groups >= 2:
220
- redacted = pattern.sub(lambda m: f"{m.group(1)}=<redacted>", redacted)
221
- else:
222
- redacted = pattern.sub("<redacted-secret>", redacted)
223
- redacted = EPHEMERAL_SECRET_PATTERN.sub(lambda m: f"{m.group(1)}=<redacted-ephemeral>", redacted)
224
- return redacted
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:
495
+ value = payload_value(
496
+ payload,
497
+ ("session_id", "sessionId", "conversation_id", "conversationId", "generation_id"),
498
+ )
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 ""
225
527
 
226
528
 
227
- def write_private_secret(ctx: Path, raw_text: str, redacted_text: str) -> str:
228
- private_dir = ctx / "private"
229
- private_dir.mkdir(parents=True, exist_ok=True)
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 ""
230
592
  try:
231
- private_dir.chmod(0o700)
232
- except OSError:
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):
233
610
  pass
234
- path = private_dir / "secrets.local.json"
611
+ return ""
612
+
613
+
614
+ def language_setup_context(root: Path, ctx: Path) -> str:
235
615
  try:
236
- data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else []
237
- except json.JSONDecodeError:
238
- data = []
239
- if not isinstance(data, list):
240
- data = []
241
- secret_id = "USER-SECRET-" + datetime.now().strftime("%Y%m%d-%H%M%S")
242
- data.append(
243
- {
244
- "id": secret_id,
245
- "created": datetime.now().isoformat(timespec="seconds"),
246
- "redacted": redacted_text,
247
- "raw": raw_text,
248
- "note": "Local-only Context Guard secret memory. Do not copy into roadmap, HTML, git, logs, or final answers.",
249
- }
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."
619
+ if language and language != "unset":
620
+ return ""
621
+ quoted_root = '"' + str(root).replace('"', '\\"') + '"'
622
+ cli = context_guard_cli()
623
+ return (
624
+ "Context Guard language unset: ask 中文 or English. "
625
+ f"After the user answers, run `{cli} set-language --root "
626
+ f"{quoted_root} --language <zh-or-en>` and then continue in that language."
627
+ )
628
+
629
+
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"
637
+ workbench = f" Workbench: {workbench_url}." if workbench_url else ""
638
+ return (
639
+ f"Context Guard: {root}; Session: {current_session_id}.{workbench} "
640
+ f"Instructions: {skill}. CLI: {context_guard_cli()}."
250
641
  )
251
- path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
642
+
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 ""
252
649
  try:
253
- path.chmod(0o600)
254
- except OSError:
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:
255
655
  pass
256
- return secret_id
656
+ return local_url
257
657
 
258
658
 
259
- def append_user_message(ctx: Path, text: str) -> tuple[str, str]:
260
- clean = text.strip()
261
- if not clean:
262
- return "skipped", "empty prompt"
263
- init_context(ctx.parent.parent)
264
- path = ctx / "user-messages.md"
265
- now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
266
- is_large = len(clean) > 1600 or clean.count("\n") > 30
267
- ephemeral = has_ephemeral_secret(clean)
268
- secret = has_secret(clean)
269
- redacted = redact_user_message(clean)
270
- secret_ref = ""
271
- if secret and not ephemeral and len(clean) <= 4000:
272
- secret_ref = write_private_secret(ctx, clean, redacted)
273
- if is_large:
274
- first_line = redacted.splitlines()[0][:240]
275
- stored = f"Large user message or attachment; first line: {first_line}"
276
- mode = "summary"
277
- else:
278
- stored = redacted
279
- mode = "verbatim" if not secret and not ephemeral else "redacted"
280
- if ephemeral:
281
- secret_ref = "ephemeral-not-stored"
282
- mode = "ephemeral"
283
- body = path.read_text(encoding="utf-8") if path.exists() else "# User Message Memory\n\n## Recent User Signals\n\n"
284
- if stored and stored in body[-4000:]:
285
- return "skipped", "duplicate recent message"
286
- entry = [
287
- f"\n### {now}",
288
- f"- Mode: {mode}",
289
- f"- User message: {stored}",
290
- ]
291
- if secret_ref:
292
- entry.append(f"- Secret pointer: {secret_ref}")
293
- entry.append("- Use: Preserve this wording when deciding task direction, constraints, credentials, preferences, bad-case intake, or roadmap `User request` fields.")
294
- path.parent.mkdir(parents=True, exist_ok=True)
295
- with path.open("a", encoding="utf-8") as handle:
296
- handle.write("\n".join(entry) + "\n")
297
- return "recorded", mode
298
-
299
-
300
- def run_test_hub_completion(root: Path) -> tuple[int, str]:
301
- ctx = context_dir(root)
302
- tests = approved_dev_completion_tests(ctx)
303
- if not tests:
304
- return 0, "[context-guard] test hub: no approved every-dev-completion tests."
305
-
306
- hook_log(f"[context-guard] test hub: running {len(tests)} approved every-dev-completion test(s).")
307
- completed = subprocess.run(
308
- [
309
- sys.executable,
310
- str(SKILL_ROOT / "scripts" / "context_guard.py"),
311
- "dev-complete",
312
- "--root",
313
- str(root),
314
- ],
315
- text=True,
316
- capture_output=True,
317
- timeout=900,
318
- )
319
- output = "\n".join(part for part in [completed.stdout.strip(), completed.stderr.strip()] if part)
320
- return completed.returncode, output
659
+ def prompt_text(raw: str) -> str:
660
+ payload = parse_hook_payload(raw)
661
+ return payload_value(payload, ("prompt", "user_prompt", "userPrompt", "text", "content"))
321
662
 
322
663
 
323
- def completion_test_summary(output: str, code: int) -> str:
324
- if "no approved every-dev-completion tests" in output:
325
- return "no approved every-dev-completion tests"
326
- for line in output.splitlines():
327
- if "[context-guard] test hub:" in line and " passed," in line and " failed," in line and " blocked" in line:
328
- summary = line.split("test hub:", 1)[1].strip().rstrip(".")
329
- if code == 0:
330
- return f"all approved tests passed ({summary})"
331
- return f"approved tests are not all passing ({summary})"
332
- return "test hub status unknown; inspect `.codex/context/test-hub/last-run.json`"
664
+ def redact(text: str) -> str:
665
+ if len(text) > 800:
666
+ return text[:400].rstrip() + "…"
667
+ return text
333
668
 
334
669
 
335
- def completion_test_failure_details(root: Path, limit: int = 3) -> str:
336
- path = context_dir(root) / "test-hub" / "last-run.json"
670
+ def append_user_message(ctx: Path, text: str, current_session_id: str) -> str:
671
+ text = (text or "").strip()
672
+ if not text:
673
+ return "empty"
674
+ ctx.mkdir(parents=True, exist_ok=True)
675
+ path = ctx / "user-messages.md"
337
676
  if not path.exists():
338
- return ""
677
+ path.write_text("# User Message Memory\n\n## Recent User Signals\n\n", encoding="utf-8")
678
+ body = path.read_text(encoding="utf-8")
679
+ line = "- " + redact(text).replace("\n", " ")
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)])
339
720
  try:
340
- data = json.loads(path.read_text(encoding="utf-8"))
341
- except Exception:
342
- return ""
343
- results = data.get("results", [])
344
- if not isinstance(results, list):
345
- return ""
346
- not_passing = [
347
- item
348
- for item in results
349
- if isinstance(item, dict) and str(item.get("status", "")).strip().lower() in {"failed", "blocked"}
350
- ]
351
- if not not_passing:
352
- return ""
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)
353
767
 
354
- pieces: list[str] = []
355
- for item in not_passing[:limit]:
356
- title = str(item.get("title") or item.get("id") or "unnamed test").strip()
357
- status = str(item.get("status") or "failed").strip()
358
- reason = str(item.get("reason") or "no reason recorded").strip()
359
- log = str(item.get("log") or "").strip()
360
- if log:
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()
361
780
  try:
362
- log = str(Path(log).resolve().relative_to(root.resolve()))
363
- except Exception:
364
- pass
365
- suffix = f"; log: {log}" if log else ""
366
- pieces.append(f"{status}: {title} — {reason}{suffix}")
367
- if len(not_passing) > limit:
368
- pieces.append(f"+{len(not_passing) - limit} more; inspect `.codex/context/test-hub/last-run.json`")
369
- return "; ".join(pieces)
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())
370
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)
371
796
 
372
- def prompt_text(raw: str) -> str:
373
- if not raw.strip():
374
- return ""
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 []
375
811
  try:
376
- data = json.loads(raw)
377
- except Exception:
378
- return raw
379
- pieces: list[str] = []
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())
380
854
 
381
855
  def walk(value: object) -> None:
382
- if isinstance(value, str):
383
- pieces.append(value)
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)
384
861
  elif isinstance(value, list):
385
- for item in value:
386
- walk(item)
387
- elif isinstance(value, dict):
388
- for key in ("prompt", "message", "text", "content", "input"):
389
- if key in value:
390
- walk(value[key])
391
-
392
- walk(data)
393
- return "\n".join(pieces)
394
-
395
-
396
- def looks_like_bad_case(text: str) -> bool:
397
- lowered = text.lower()
398
- markers = [
399
- "bug",
400
- "bad case",
401
- "regression",
402
- "broken",
403
- "error",
404
- "failed",
405
- "failing",
406
- "doesn't work",
407
- "not working",
408
- "紧急",
409
- "报错",
410
- "失败",
411
- "坏例",
412
- "复现",
413
- "回归",
414
- "不对",
415
- "有问题",
416
- ]
417
- return any(marker in lowered for marker in markers)
418
-
419
-
420
- def looks_like_task_switch(text: str) -> bool:
421
- lowered = text.lower()
422
- markers = [
423
- "urgent",
424
- "instead",
425
- "switch",
426
- "pause",
427
- "later",
428
- "different",
429
- "unrelated",
430
- "先",
431
- "暂停",
432
- "等下",
433
- "换个",
434
- "另一个",
435
- "紧急",
436
- "回头",
437
- "先不",
438
- ]
439
- return any(marker in lowered for marker in markers)
440
-
441
-
442
- def looks_like_goal_mode(text: str) -> bool:
443
- lowered = text.lower()
444
- markers = [
445
- "goal mode",
446
- "goal模式",
447
- "goal 模式",
448
- "active goal",
449
- "long-running",
450
- "autonomous",
451
- "目标模式",
452
- "长期目标",
453
- "持续执行",
454
- "自动继续",
455
- ]
456
- return any(marker in lowered for marker in markers)
457
-
458
-
459
- def looks_like_remote_work(text: str) -> bool:
460
- lowered = text.lower()
461
- markers = [
462
- "ssh",
463
- "remote server",
464
- "remote host",
465
- "dev server",
466
- "jump host",
467
- "服务器",
468
- "远程",
469
- "开发机",
470
- "跳板机",
471
- "通过ssh",
472
- "通过 ssh",
473
- ]
474
- return any(marker in lowered for marker in markers)
475
-
476
-
477
- def looks_like_test_creation(text: str) -> bool:
478
- lowered = text.lower()
479
- creation_markers = [
480
- "create",
481
- "write",
482
- "generate",
483
- "design",
484
- "add",
485
- "创建",
486
- "建立",
487
- "写",
488
- "生成",
489
- "设计",
490
- "新增",
491
- "加一个",
492
- "做一个",
493
- ]
494
- test_markers = [
495
- "test case",
496
- "task case",
497
- "test task",
498
- "testing task",
499
- "测试case",
500
- "测试 case",
501
- "测试任务",
502
- "测试用例",
503
- "测评任务",
504
- "测评case",
505
- "测评 case",
506
- "测试链路",
507
- "测试",
508
- ]
509
- return any(marker in lowered for marker in creation_markers) and any(marker in lowered for marker in test_markers)
510
-
511
-
512
- def looks_like_test_opportunity(text: str) -> bool:
513
- lowered = text.lower()
514
- opportunity_markers = [
515
- "fix",
516
- "bug",
517
- "regression",
518
- "workflow",
519
- "flow",
520
- "e2e",
521
- "integration",
522
- "ui",
523
- "html",
524
- "browser",
525
- "frontend",
526
- "backend",
527
- "api",
528
- "service",
529
- "deploy",
530
- "release",
531
- "refactor",
532
- "goal mode",
533
- "long-running",
534
- "修复",
535
- "bug",
536
- "问题",
537
- "复发",
538
- "回归",
539
- "流程",
540
- "链路",
541
- "前端",
542
- "后端",
543
- "接口",
544
- "服务",
545
- "部署",
546
- "发布",
547
- "重构",
548
- "页面",
549
- "浏览器",
550
- "远程",
551
- "服务器",
552
- "goal 模式",
553
- "目标模式",
554
- "长期",
555
- ]
556
- return any(marker in lowered for marker in opportunity_markers)
557
-
558
-
559
- def looks_like_explicit_branch(text: str) -> bool:
560
- lowered = text.lower()
561
- markers = [
562
- "branch task",
563
- "side task",
564
- "side route",
565
- "fork this",
566
- "create a branch",
567
- "new branch",
568
- "as a branch",
569
- "支线",
570
- "分支",
571
- "开一个分支",
572
- "开一条支线",
573
- "创建支线",
574
- "创建分支",
575
- "作为支线",
576
- ]
577
- return any(marker in lowered for marker in markers)
578
-
579
-
580
- def looks_like_route_drift(text: str) -> bool:
581
- lowered = text.lower()
582
- drift_markers = [
583
- "significantly diverge",
584
- "diverge from",
585
- "different architecture",
586
- "new architecture",
587
- "new direction",
588
- "refactor direction",
589
- "偏离",
590
- "显著偏离",
591
- "新的架构",
592
- "新方向",
593
- "重构方向",
594
- "主线架构",
595
- ]
596
- return any(marker in lowered for marker in drift_markers)
597
-
598
-
599
- def bad_case_blocks(text: str) -> list[dict[str, str]]:
600
- blocks: list[dict[str, str]] = []
601
- current: dict[str, str] | None = None
602
- for line in text.splitlines():
603
- heading = line.startswith("### BC-")
604
- if heading:
605
- if current:
606
- blocks.append(current)
607
- identifier, _, title = line.removeprefix("### ").partition(":")
608
- current = {"id": identifier.strip(), "title": title.strip()}
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
609
1028
  continue
610
- if current is None or not line.startswith("- ") or ":" not in line:
1029
+ if re.fullmatch(r"(?:\d*)>&\d+", token):
1030
+ index += 1
611
1031
  continue
612
- key, _, value = line[2:].partition(":")
613
- current[key.strip().lower()] = value.strip()
1032
+ if "&" in token:
1033
+ return None
1034
+ current.append(token)
1035
+ index += 1
614
1036
  if current:
615
- blocks.append(current)
616
- return blocks
1037
+ segments.append(current)
1038
+ return segments if segments else None
617
1039
 
618
1040
 
619
- def unresolved_bad_cases(path: Path) -> list[dict[str, str]]:
620
- if not path.exists():
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):
621
1274
  return []
622
- resolved_statuses = {"resolved", "done", "superseded-by-route-change"}
623
- cases = bad_case_blocks(path.read_text(encoding="utf-8"))
624
- return [
625
- case
626
- for case in cases
627
- if case.get("status", "").strip().lower() not in resolved_statuses
628
- ]
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))
629
1343
 
630
1344
 
631
- def format_unresolved_bad_cases(cases: list[dict[str, str]], limit: int = 5) -> str:
632
- if not cases:
633
- return "none"
634
- parts = []
635
- for case in cases[:limit]:
636
- status = case.get("status", "unknown") or "unknown"
637
- title = case.get("title", "Untitled bad case")
638
- phenomenon = case.get("phenomenon", "").strip()
639
- summary = f"{title} ({status})"
640
- if phenomenon:
641
- summary += f" - {phenomenon}"
642
- parts.append(summary)
643
- if len(cases) > limit:
644
- parts.append(f"{len(cases) - limit} more unresolved bad cases")
645
- return "; ".join(parts)
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
1597
+ else:
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
646
1632
 
647
1633
 
648
1634
  def main() -> int:
649
- event = sys.argv[1] if len(sys.argv) > 1 else "unknown"
1635
+ configure_stdio()
1636
+ parser = argparse.ArgumentParser(description="Context Guard hook adapter")
1637
+ parser.add_argument("event", nargs="?", default="unknown")
1638
+ parser.add_argument("--platform", choices=["codex", "cursor", "claude"], default="codex")
1639
+ args, _unknown = parser.parse_known_args()
1640
+ event = args.event
1641
+ platform = args.platform
650
1642
  raw = read_stdin()
651
- root, root_source = event_root(raw, Path.cwd(), event)
652
- context_dir = root / ".codex" / "context"
653
- index_path = context_dir / "index.md"
654
- roadmap_path = context_dir / "roadmap.md"
655
- bad_cases_path = context_dir / "bad-cases.md"
656
- text = prompt_text(raw)
657
-
658
- if is_context_guard_skill_path(root):
659
- hook_log(
660
- "[context-guard] detected Context Guard skill directory as the apparent root; "
661
- "skipping project context writes. Open the target Codex folder or pass an explicit local `--root` "
662
- "when showing/updating a roadmap."
663
- )
664
- hook_log(f"[context-guard] apparent root source: {root_source}; apparent root: {root}")
665
- return hook_response()
666
-
667
- if event in {"session-start", "subagent-start"}:
668
- created = init_context(root)
669
- label = "subagent context" if event == "subagent-start" else "folder context"
670
- if created:
671
- hook_log(f"[context-guard] initialized {label}: {context_dir}")
672
- else:
673
- hook_log(f"[context-guard] {label} ready: {context_dir}")
674
- hook_log(f"[context-guard] project root: {root} ({root_source})")
675
- hook_log("[context-guard] context location rule: save project context only under `<opened local Codex project root>/.codex/context/`.")
676
- hook_log("[context-guard] use .codex/context/index.md for quick scan and .codex/context/roadmap.md for route nodes.")
677
- return hook_response()
678
-
679
- if event == "user-prompt-submit":
680
- record_status, record_mode = append_user_message(context_dir, text)
681
- hints: list[str] = []
682
- if record_status == "recorded":
683
- if record_mode == "redacted":
684
- hints.append("user message memory: saved latest prompt with secrets redacted; raw secret, if durable, is local-only under `.codex/context/private/`")
685
- elif record_mode == "ephemeral":
686
- hints.append("user message memory: saved latest prompt with one-time code redacted; raw ephemeral code was not persisted")
687
- elif record_mode == "summary":
688
- hints.append("user message memory: saved a concise summary of the latest large prompt instead of copying the full blob")
689
- else:
690
- hints.append("user message memory: saved latest short user prompt in `.codex/context/user-messages.md`")
691
- else:
692
- hints.append(f"user message memory: skipped ({record_mode})")
693
- if looks_like_goal_mode(text):
694
- hints.append("goal mode: align active goal with current context and record roadmap/bad-case checkpoints during long-running work")
695
- if looks_like_remote_work(text):
696
- hints.append("remote/SSH work: keep `.codex/context` in the local Codex workspace; record remote host/path as metadata and do not initialize roadmap context on the server unless explicitly requested")
697
- if looks_like_test_creation(text):
698
- hints.append("explicit test creation: start the user-visible response with `测试创建识别:...`, summarize the test target from state A to state B, and only create durable tests after the user's design is clear or confirmed")
699
- elif looks_like_test_opportunity(text):
700
- hints.append("test opportunity: if this task changes a reusable workflow, fixes a recurring/user-visible bug, or is likely to regress, gently ask whether the user wants to create a test task; keep it optional and do not create durable tests without approval")
701
- if looks_like_explicit_branch(text):
702
- hints.append("explicit branch task: create/select a branch task by running `context_guard.py create-branch-task --title <task title> --branch <branch name> --parent-node <parent NODE id>` before implementation; verify the roadmap node has Branch: and Parent:")
703
- elif looks_like_route_drift(text):
704
- hints.append("possible route drift: ask whether to create a branch before moving away from the current mainline architecture")
705
- if looks_like_task_switch(text):
706
- hints.append("possible task switch: park current context in .codex/context/index.md before switching")
707
- if looks_like_bad_case(text):
708
- hints.append("possible bad case: record/update .codex/context/bad-cases.md or task-local bad-cases.md")
709
- if not hints:
710
- hints.append("run Context Guard intake: continue current context or note no active context")
711
- hook_log("[context-guard] " + "; ".join(hints))
712
- hook_log(f"[context-guard] root source: {root_source}")
713
- hook_log(f"[context-guard] project root: {root}")
714
- hook_log(f"[context-guard] context folder: {context_dir}")
715
- hook_log(f"[context-guard] context index: {index_path}")
716
- hook_log(f"[context-guard] route map: {roadmap_path}")
717
- return hook_response()
718
-
719
- if event in {"stop", "subagent-stop"}:
720
- lifecycle_label = "SubagentStop" if event == "subagent-stop" else "Stop"
721
- hook_log(f"[context-guard] {lifecycle_label} checkpoint: update index, route map nodes, parked/resume tasks, and relevant bad-case/test-chain links before finalizing.")
722
- hook_log("[context-guard] COMPLETION RELIABILITY GATE: use existing user screenshots/logs/reproductions as red evidence when available; implement once the cause is clear, then run the smallest real post-fix check. Default budget is one primary check plus at most two highly relevant bad-case guards.")
723
- hook_log("[context-guard] BAD-CASE GUARD GATE: newly checked resolved or recurred BC entries need Guard type, Red condition, Green condition, Expected failure reason, and a red-capable Guard / verification; run `context_guard.py validate-bad-cases` only after register/schema/renderer edits, or `--strict` when intentionally migrating/checking all resolved cases.")
724
- hook_log("[context-guard] GUARD SELECTION GATE: do not run every historical guard and do not manufacture new red tests when credible evidence already exists. Select guards by changed files, feature area, route branch, tags, and original user-visible symptom; skip unrelated resolved cases.")
725
- hook_log("[context-guard] TEST HUB GATE: Stop/SubagentStop hooks run `context_guard.py dev-complete --root <project>` so the hub executes every human-approved `every-dev-completion` test, cleans success artifacts, and preserves failed/blocked evidence. Do not treat ordinary bad-case guards or roadmap Test chain notes as registered tests.")
726
- hook_log("[context-guard] TASK-CASE GATE: when a workflow has multiple phases, prefer one relevant task case from `.codex/context/task-cases/` with phase/checkpoint logs over many isolated bug-level tests; report the failed phase/checkpoint if it breaks.")
727
- hook_log("[context-guard] TEST BLOCKER GATE: if approved tests are blocked by credentials, external service outage, permission denial, hardware/resource limits, network, destructive-risk confirmation, or user-only judgment, stop and ask/warn the user with the exact blocker and evidence path.")
728
- hook_log("[context-guard] TASK-CASE DESIGN GATE: before writing a new durable task-case script for a complex workflow, ask the user to confirm a short business-facing proposal: from what state to what state, main task, and major risk; keep technical details inside the task-case file, or keep it `proposed` if unavailable.")
729
- hook_log("[context-guard] TEST OPPORTUNITY GATE: if this turn changed a reusable workflow, fixed a recurring/user-visible bug, or created a phase that is likely to regress, include a brief optional nudge asking whether the user wants to create a test task. Do not create durable tests unless the user confirms.")
730
- hook_log("[context-guard] GOAL-MODE TEST GATE: in goal mode, use task cases as phase gates; log current phase progress and run the smallest approved path before claiming goal completion instead of silently creating broad new tests.")
731
- hook_log("[context-guard] ROADMAP CHECKPOINT GATE: assess whether this turn deserves a roadmap node. Create one only for meaningful progress, a route decision, a fix, a branch/fork, a user-visible milestone, or stale hidden checkpoints; otherwise say no roadmap node was needed and why.")
732
- hook_log("[context-guard] If a node is needed, run `context_guard.py checkpoint-roadmap-node --title <short title> --branch <Main or route> --level <major|checkpoint> --outcome <one-line progress> --next-step <next>` and include linked BC/test-chain notes when relevant.")
733
- hook_log("[context-guard] ROADMAP MAINTENANCE GATE: run `context_guard.py validate-roadmap-maintenance` after route updates; do not let mainline/branch overview stay stale while important work is hidden as checkpoints.")
734
- hook_log("[context-guard] If frontend/UI/HTML/CSS/layout/browser behavior changed, inspect with browser/screenshot or state the exact blocker; do not claim fixed without this evidence.")
735
- hook_log("[context-guard] Branch task gate: if the user explicitly asked for a branch, ensure `context_guard.py create-branch-task --title <task title> --branch <branch name> --parent-node <parent NODE id>` has created the task folder, index current entry, and Branch/Parent roadmap node; if the work significantly drifts from the mainline architecture, ask whether to create a branch before finalizing.")
736
- hook_log("[context-guard] final answer must include verification evidence and must not say done/fixed/passing unless the gate above was satisfied.")
737
- hook_log(f"[context-guard] root source: {root_source}")
738
- hook_log(f"[context-guard] project root: {root}")
739
- hook_log(f"[context-guard] context folder: {context_dir}")
740
- hook_log(f"[context-guard] bad-case register: {bad_cases_path}")
741
- if event == "subagent-stop":
742
- payload = parse_hook_payload(raw)
743
- agent_id = hook_agent_id(payload)
744
- last_message = hook_text_field(payload, "last_assistant_message")
745
- control_root = registered_subagent_control_root(payload, Path.cwd(), agent_id)
746
- command_root = control_root or root
747
- command = [
748
- sys.executable,
749
- str(SKILL_ROOT / "scripts" / "context_guard.py"),
750
- "subagent-complete",
751
- "--root",
752
- str(command_root),
753
- "--agent-id",
754
- agent_id,
755
- "--summary",
756
- last_message,
757
- ]
1643
+ payload = parse_hook_payload(raw)
1644
+ root, root_source = event_root(raw, Path.cwd())
1645
+ ctx = context_folder(root)
1646
+
1647
+ if is_context_guard_skill_path(root) and not initialized_source_project(root):
1648
+ hook_log("[context-guard] apparent root is the skill directory; skipping writes.")
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 = ""
1654
+
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
758
1671
  try:
759
- completed = subprocess.run(
760
- command,
761
- cwd=str(root),
762
- text=True,
763
- capture_output=True,
764
- timeout=900,
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.",
765
1735
  )
766
- except subprocess.TimeoutExpired:
767
- reason = "Context Guard subagent completion timed out after 900s."
768
- hook_log(f"[context-guard] TEST HUB BLOCKER: {reason}")
769
- return hook_response(decision="block", reason=reason)
770
- except Exception as exc:
771
- reason = f"Context Guard could not run subagent completion: {exc}"
772
- hook_log(f"[context-guard] COMPLETION BLOCKER: {reason}")
773
- return hook_response(decision="block", reason=reason)
774
- completion_output = "\n".join(
775
- part for part in [completed.stdout.strip(), completed.stderr.strip()] if part
776
- )
777
- for line in completion_output.splitlines():
778
- hook_log(line)
779
- hook_log(
780
- "[context-guard] final answer must include Test Hub summary: "
781
- + completion_test_summary(completion_output, completed.returncode)
782
- )
783
- if completed.returncode != 0:
784
- details = completion_test_failure_details(root)
785
- reason = "Context Guard subagent completion found failed or blocked approved tests."
786
- if details:
787
- reason += " Failing tests: " + details
788
- hook_log(f"[context-guard] TEST HUB BLOCKER: {reason}")
789
- return hook_response(decision="block", reason=reason)
790
- open_cases = unresolved_bad_cases(bad_cases_path)
791
- hook_log("[context-guard] final answer must include BC summary: archived/updated BC this turn, and current unresolved BC.")
792
- hook_log(f"[context-guard] current unresolved BC: {format_unresolved_bad_cases(open_cases)}")
793
- return hook_response()
794
- try:
795
- test_code, test_output = run_test_hub_completion(root)
796
- for line in test_output.splitlines():
797
- hook_log(line)
798
- hook_log(
799
- "[context-guard] final answer must include Test Hub summary: "
800
- + completion_test_summary(test_output, test_code)
801
- )
802
- if test_code != 0:
803
- details = completion_test_failure_details(root)
804
- if details:
805
- hook_log("[context-guard] failing approved test details: " + details)
806
- reason = (
807
- "Context Guard Test Hub found failed or blocked approved tests. "
808
- "Read `.codex/context/test-hub/last-run.json` and preserved run evidence, "
809
- "then fix or report the blocker before finalizing."
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.",
810
1759
  )
811
- if details:
812
- reason += " Failing tests: " + details
813
- hook_log(f"[context-guard] TEST HUB BLOCKER: {reason}")
814
- return hook_response(decision="block", reason=reason)
815
- except subprocess.TimeoutExpired:
816
- reason = "Context Guard Test Hub timed out after 900s; report this blocker and evidence before finalizing."
817
- hook_log(f"[context-guard] TEST HUB BLOCKER: {reason}")
818
- return hook_response(decision="block", reason=reason)
819
- except Exception as exc:
820
- hook_log(f"[context-guard] test hub hook warning: {exc}")
821
1760
  try:
822
- auto_propose = subprocess.run(
823
- [
824
- sys.executable,
825
- str(SKILL_ROOT / "scripts" / "context_guard.py"),
826
- "feature-chain-auto-propose",
827
- "--root",
828
- str(root),
829
- "--from-hook",
830
- ],
831
- cwd=str(root),
832
- text=True,
833
- capture_output=True,
834
- timeout=10,
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)]
1814
+ playbook = ctx / "tasks" / "J2.md"
1815
+ if playbook.is_file():
1816
+ contexts.append(
1817
+ "Repository development playbook: read .codex/context/tasks/J2.md "
1818
+ "for the current product and testing branch rules."
835
1819
  )
836
- output = (auto_propose.stdout + auto_propose.stderr).strip()
837
- if output:
838
- for line in output.splitlines():
839
- hook_log(line)
840
- if auto_propose.returncode != 0:
841
- hook_log(f"[context-guard] feature-chain auto-propose warning: exit {auto_propose.returncode}")
842
- except Exception as exc:
843
- hook_log(f"[context-guard] feature-chain auto-propose warning: {exc}")
844
- open_cases = unresolved_bad_cases(bad_cases_path)
845
- hook_log("[context-guard] final answer must include BC summary: archived/updated BC this turn, and current unresolved BC.")
846
- hook_log(f"[context-guard] current unresolved BC: {format_unresolved_bad_cases(open_cases)}")
847
- return hook_response()
848
-
849
- hook_log("[context-guard] unknown hook event; use the context-guard skill if context changed.")
850
- return hook_response()
1820
+ return hook_response(platform, event, "\n\n".join(item for item in contexts if item))
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
+
1963
+ if event == "user-prompt-submit":
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)
1973
+ append_session_event(
1974
+ root,
1975
+ event,
1976
+ platform,
1977
+ current_session_id,
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
+ })),
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."
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"})))
2018
+ return hook_response(platform, event)
2019
+
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
+ )))
2054
+ return hook_response(platform, event)
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
+
2098
+ hook_log(f"[context-guard] ignored event: {event}")
2099
+ return hook_response(platform, event)
851
2100
 
852
2101
 
853
2102
  if __name__ == "__main__":