@research-copilot/plugin 1.1.15 → 1.1.16

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 (117) hide show
  1. package/dist/.claude-plugin/plugin.json +3 -2
  2. package/dist/.codex-plugin/plugin.toml +2 -1
  3. package/dist/.cursor-plugin/plugin.json +3 -2
  4. package/dist/.gemini-plugin/plugin.json +3 -2
  5. package/dist/.opencode-plugin/plugin.json +3 -2
  6. package/dist/.windsurf-plugin/plugin.json +3 -2
  7. package/dist/agents/copilot-conductor.agent.md +60 -0
  8. package/dist/agents/copilot-experiment.agent.md +56 -0
  9. package/dist/agents/copilot-ideation.agent.md +45 -0
  10. package/dist/agents/copilot-literature.agent.md +34 -0
  11. package/dist/agents/copilot-polisher.agent.md +30 -0
  12. package/dist/agents/copilot-rebuttal.agent.md +35 -0
  13. package/dist/agents/copilot-reviewer.agent.md +35 -0
  14. package/dist/agents/copilot-writer.agent.md +39 -0
  15. package/dist/hooks/dispatch-reminder.json +17 -0
  16. package/dist/hooks/loop-armer.json +17 -0
  17. package/dist/hooks/research-copilot-guard.hook.md +51 -0
  18. package/dist/hooks/scientist-guardrails.json +17 -0
  19. package/dist/hooks/scripts/__tests__/__init__.py +0 -0
  20. package/dist/hooks/scripts/__tests__/test_post_tool_loop_armer.py +88 -0
  21. package/dist/hooks/scripts/__tests__/test_research_copilot_guard_main_session.py +150 -0
  22. package/dist/hooks/scripts/__tests__/test_session_start_memory_injector.py +66 -0
  23. package/dist/hooks/scripts/__tests__/test_user_prompt_dispatch_reminder.py +37 -0
  24. package/dist/hooks/scripts/_copilot_hook_lib.py +564 -0
  25. package/dist/hooks/scripts/copilot_subagent_stop.py +203 -0
  26. package/dist/hooks/scripts/copilot_write_guard.py +96 -0
  27. package/dist/hooks/scripts/post_tool_loop_armer.py +61 -0
  28. package/dist/hooks/scripts/research_copilot_guard.py +208 -0
  29. package/dist/hooks/scripts/scientist_guardrails.py +29 -0
  30. package/dist/hooks/scripts/session_start_memory_injector.py +188 -0
  31. package/dist/hooks/scripts/user_prompt_dispatch_reminder.py +40 -0
  32. package/dist/hooks/session-memory-injector.json +17 -0
  33. package/dist/hooks/tests/__init__.py +0 -0
  34. package/dist/hooks/tests/conftest.py +61 -0
  35. package/dist/hooks/tests/fixtures/transcript_copilot_experiment_complete.jsonl +2 -0
  36. package/dist/hooks/tests/fixtures/transcript_copilot_experiment_state_jump.jsonl +2 -0
  37. package/dist/hooks/tests/fixtures/transcript_copilot_literature.jsonl +2 -0
  38. package/dist/hooks/tests/fixtures/transcript_main_only.jsonl +2 -0
  39. package/dist/hooks/tests/fixtures/transcript_malformed_state_output.jsonl +2 -0
  40. package/dist/hooks/tests/integration_run.ps1 +65 -0
  41. package/dist/hooks/tests/test_copilot_hook_lib.py +398 -0
  42. package/dist/hooks/tests/test_copilot_subagent_stop.py +186 -0
  43. package/dist/hooks/tests/test_copilot_write_guard.py +137 -0
  44. package/dist/hooks/tests/test_session_start_snapshot.py +116 -0
  45. package/dist/hooks/tests/test_state_machine_consistency.py +75 -0
  46. package/dist/skills/arxivsub-skill/SKILL.md +98 -0
  47. package/dist/skills/arxivsub-skill/skill.json +5 -0
  48. package/dist/skills/de-ai-checker/SKILL.md +110 -0
  49. package/dist/skills/de-ai-checker/skill.json +5 -0
  50. package/dist/skills/deep-interview/SKILL.md +91 -0
  51. package/dist/skills/deep-interview/skill.json +5 -0
  52. package/dist/skills/grill-with-docs/SKILL.md +120 -0
  53. package/dist/skills/grill-with-docs/skill.json +5 -0
  54. package/dist/skills/init-mcp/SKILL.md +83 -0
  55. package/dist/skills/init-mcp/skill.json +5 -0
  56. package/dist/skills/model-escalation/SKILL.md +93 -0
  57. package/dist/skills/model-escalation/skill.json +5 -0
  58. package/dist/skills/paper-architecture-web-drawing/SKILL.md +282 -0
  59. package/dist/skills/paper-architecture-web-drawing/skill.json +5 -0
  60. package/dist/skills/paper-deai/SKILL.md +53 -0
  61. package/dist/skills/paper-deai/skill.json +5 -0
  62. package/dist/skills/paper-en2zh/SKILL.md +29 -0
  63. package/dist/skills/paper-en2zh/skill.json +5 -0
  64. package/dist/skills/paper-expand/SKILL.md +43 -0
  65. package/dist/skills/paper-expand/skill.json +5 -0
  66. package/dist/skills/paper-experiment-analysis/SKILL.md +38 -0
  67. package/dist/skills/paper-experiment-analysis/skill.json +5 -0
  68. package/dist/skills/paper-figure-caption/SKILL.md +29 -0
  69. package/dist/skills/paper-figure-caption/skill.json +5 -0
  70. package/dist/skills/paper-logic-check/SKILL.md +30 -0
  71. package/dist/skills/paper-logic-check/skill.json +5 -0
  72. package/dist/skills/paper-polish/SKILL.md +34 -305
  73. package/dist/skills/paper-polish/skill.json +5 -0
  74. package/dist/skills/paper-review/SKILL.md +49 -0
  75. package/dist/skills/paper-review/skill.json +5 -0
  76. package/dist/skills/paper-sanity-check/SKILL.md +122 -0
  77. package/dist/skills/paper-sanity-check/skill.json +5 -0
  78. package/dist/skills/paper-shorten/SKILL.md +42 -0
  79. package/dist/skills/paper-shorten/skill.json +5 -0
  80. package/dist/skills/paper-table-caption/SKILL.md +29 -0
  81. package/dist/skills/paper-table-caption/skill.json +5 -0
  82. package/dist/skills/paper-translate/SKILL.md +48 -0
  83. package/dist/skills/paper-translate/skill.json +5 -0
  84. package/dist/skills/plugin-dev-agent-development/SKILL.md +95 -0
  85. package/dist/skills/plugin-dev-agent-development/skill.json +5 -0
  86. package/dist/skills/research-workflow/SKILL.md +116 -0
  87. package/dist/skills/research-workflow/skill.json +5 -0
  88. package/dist/skills/scientist-experiment-runner/SKILL.md +76 -0
  89. package/dist/skills/scientist-experiment-runner/skill.json +5 -0
  90. package/dist/skills/scientist-ideation/SKILL.md +52 -0
  91. package/dist/skills/scientist-ideation/skill.json +5 -0
  92. package/dist/skills/scientist-plotting/SKILL.md +49 -0
  93. package/dist/skills/scientist-plotting/skill.json +5 -0
  94. package/dist/skills/scientist-review/SKILL.md +40 -0
  95. package/dist/skills/scientist-review/skill.json +5 -0
  96. package/dist/skills/scientist-runtime-init/SKILL.md +46 -0
  97. package/dist/skills/scientist-runtime-init/skill.json +5 -0
  98. package/dist/skills/scientist-writeup/SKILL.md +60 -0
  99. package/dist/skills/scientist-writeup/skill.json +5 -0
  100. package/dist/skills/talk-normal/SKILL.md +73 -0
  101. package/dist/skills/talk-normal/skill.json +5 -0
  102. package/package.json +1 -1
  103. package/dist/agents/rc-experiment.md +0 -203
  104. package/dist/agents/rc-ideation.md +0 -224
  105. package/dist/agents/rc-literature.md +0 -228
  106. package/dist/agents/rc-plan.md +0 -189
  107. package/dist/agents/rc-polisher.md +0 -166
  108. package/dist/agents/rc-rebuttal.md +0 -194
  109. package/dist/agents/rc-reviewer.md +0 -187
  110. package/dist/agents/rc-update-spec.md +0 -231
  111. package/dist/agents/rc-verify.md +0 -234
  112. package/dist/agents/rc-writer.md +0 -161
  113. package/dist/skills/experiment-design/SKILL.md +0 -331
  114. package/dist/skills/full-research-workflow/SKILL.md +0 -363
  115. package/dist/skills/literature-search/SKILL.md +0 -244
  116. package/dist/skills/sanity-check/SKILL.md +0 -449
  117. package/dist/skills/submission-sprint/SKILL.md +0 -361
@@ -0,0 +1,564 @@
1
+ """Shared helpers for copilot_* hooks. Stateless.
2
+
3
+ Encodes the OWNED matrix (PIPELINE-OS §8) and per-agent state machines.
4
+ Provides JSON I/O, parsers, log writers, and a fail-open safe_main wrapper.
5
+
6
+ All hooks depending on this lib MUST wrap their main() with safe_main() so
7
+ any exception yields an `allow` decision rather than trapping the user.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import datetime
12
+ import json
13
+ import os
14
+ import re
15
+ import sys
16
+ import traceback
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+
21
+ COPILOT_AGENTS = frozenset([
22
+ "copilot-literature",
23
+ "copilot-ideation",
24
+ "copilot-experiment",
25
+ "copilot-writer",
26
+ "copilot-polisher",
27
+ "copilot-reviewer",
28
+ "copilot-rebuttal",
29
+ ])
30
+
31
+
32
+ def is_copilot_agent(name: str | None) -> bool:
33
+ return name in COPILOT_AGENTS
34
+
35
+
36
+ def detect_active_agent(transcript_path: str) -> str | None:
37
+ """Scan transcript JSONL in reverse, return most recent subagent_type.
38
+
39
+ Handles two formats:
40
+ 1. Flat: {"subagent_type": "...", ...}
41
+ 2. Wrapped: {"role": ..., "metadata": {"subagent_type": "..."}, ...}
42
+ """
43
+ if not transcript_path:
44
+ return None
45
+ p = Path(transcript_path)
46
+ if not p.is_file():
47
+ return None
48
+ try:
49
+ lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
50
+ except OSError:
51
+ return None
52
+ for line in reversed(lines[-200:]):
53
+ if not line.strip():
54
+ continue
55
+ try:
56
+ entry = json.loads(line)
57
+ except json.JSONDecodeError:
58
+ continue
59
+ meta = entry.get("metadata") or {}
60
+ candidate = (meta.get("subagent_type")
61
+ or entry.get("subagent_type")
62
+ or meta.get("agent")
63
+ or entry.get("agent"))
64
+ if candidate:
65
+ return str(candidate)
66
+ return None
67
+
68
+
69
+ COPILOT_SUBAGENT_PREFIX = "copilot-"
70
+
71
+
72
+ def is_main_session(payload: dict) -> bool:
73
+ """True iff this PreToolUse call originates from the MAIN session.
74
+
75
+ Authoritative per Claude Code hooks docs: `agent_id` is present ONLY
76
+ inside a sub-agent call, so its ABSENCE means the main thread. Any
77
+ ambiguity (missing/empty agent_id) resolves to main — conservative,
78
+ because a false 'main' over-applies the guard (recoverable) whereas a
79
+ false 'subagent' silently exempts the main session (defeats the guard).
80
+ """
81
+ return not payload.get("agent_id")
82
+
83
+
84
+ def is_exempt_subagent(payload: dict) -> bool:
85
+ """True iff a copilot-* sub-agent made this call (runs freely)."""
86
+ if is_main_session(payload):
87
+ return False
88
+ return str(payload.get("agent_type") or "").startswith(COPILOT_SUBAGENT_PREFIX)
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Path utilities
93
+ # ---------------------------------------------------------------------------
94
+
95
+ import fnmatch
96
+
97
+
98
+ def normalize_path(s: str, workspace: Path | None = None) -> str:
99
+ """Normalize a path for matching: lowercase, forward-slash, relative-if-possible.
100
+
101
+ - Empty string returns empty string.
102
+ - If `workspace` is given AND the path resolves inside workspace,
103
+ returns the relative path under workspace.
104
+ - Otherwise returns lowercased forward-slashed absolute path.
105
+ """
106
+ if not s:
107
+ return ""
108
+ if workspace is not None:
109
+ try:
110
+ rel = Path(s).resolve().relative_to(workspace.resolve())
111
+ return str(rel).replace("\\", "/").lower()
112
+ except (ValueError, OSError):
113
+ pass
114
+ return s.replace("\\", "/").lower()
115
+
116
+
117
+ def glob_match(path: str, pattern: str) -> bool:
118
+ """Case-insensitive single-segment glob match.
119
+
120
+ Uses `pathlib.PurePosixPath.match` semantics — `*` matches one path
121
+ segment, NOT across `/`. So `sections/*.tex` matches `sections/foo.tex`
122
+ but NOT `sections/sub/foo.tex`.
123
+
124
+ Both `path` and `pattern` are lowercased and forward-slashed before
125
+ matching, so callers don't have to pre-normalize.
126
+ """
127
+ from pathlib import PurePosixPath
128
+ p = path.replace("\\", "/").lower()
129
+ g = pattern.replace("\\", "/").lower()
130
+ if not g:
131
+ return p == g
132
+ return PurePosixPath(p).match(g)
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # OWNED matrix (PIPELINE-OS §8) and ownership predicates
137
+ # ---------------------------------------------------------------------------
138
+
139
+ OWNED: dict[str, list[str]] = {
140
+ "copilot-literature": [".copilot/literature.md"],
141
+ "copilot-ideation": [
142
+ ".copilot/ideas.md",
143
+ ".copilot/pipelines/*-s2-*.md",
144
+ ],
145
+ "copilot-experiment": [
146
+ ".copilot/experiments.md",
147
+ ".copilot/pipelines/*-s3-*.md",
148
+ ],
149
+ "copilot-writer": [
150
+ "sections/*.tex",
151
+ "references.bib",
152
+ ".copilot/handoff.md",
153
+ ],
154
+ "copilot-polisher": [
155
+ "sections/*.tex",
156
+ ".copilot/handoff.md",
157
+ ],
158
+ "copilot-reviewer": [
159
+ ".copilot/reviews/round-*.md",
160
+ ".copilot/handoff.md",
161
+ ],
162
+ "copilot-rebuttal": [".copilot/handoff.md"],
163
+ }
164
+
165
+ HANDOFF_APPEND_ONLY_AGENTS = frozenset([
166
+ "copilot-writer", "copilot-polisher",
167
+ "copilot-reviewer", "copilot-rebuttal",
168
+ ])
169
+
170
+
171
+ def is_owned(agent: str, path: str) -> bool:
172
+ """True iff `agent` is allowed to write `path` per PIPELINE-OS §8."""
173
+ if agent not in OWNED:
174
+ return False
175
+ p = path.replace("\\", "/").lower()
176
+ return any(glob_match(p, pat) for pat in OWNED[agent])
177
+
178
+
179
+ _KNOWN_ARTIFACT_GLOBS = [
180
+ ".copilot/state.md",
181
+ ".copilot/literature.md",
182
+ ".copilot/ideas.md",
183
+ ".copilot/experiments.md",
184
+ ".copilot/decisions.md",
185
+ ".copilot/handoff.md",
186
+ ".copilot/reviews/*.md",
187
+ ".copilot/pipelines/*.md",
188
+ "sections/*.tex",
189
+ "references.bib",
190
+ ]
191
+
192
+
193
+ def is_known_research_artifact(path: str) -> bool:
194
+ """True iff `path` is one of the artifacts PIPELINE-OS §8 governs.
195
+ Paths outside this universe are unconditionally allowed for any agent."""
196
+ p = path.replace("\\", "/").lower()
197
+ return any(glob_match(p, pat) for pat in _KNOWN_ARTIFACT_GLOBS)
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # STATE_MACHINE — transcribed from each *.agent.md state table
202
+ # ---------------------------------------------------------------------------
203
+
204
+ STATE_MACHINE: dict[str, dict[str, list[str]]] = {
205
+ "conductor": {
206
+ "UNINITIALIZED": ["DIAGNOSED"],
207
+ "DIAGNOSED": ["MODE_A_ROUTING", "MODE_B_PIPELINE", "PAUSED"],
208
+ "MODE_A_ROUTING": ["PLAN_PUBLISHED"],
209
+ "MODE_B_PIPELINE": ["PLAN_PUBLISHED"],
210
+ "PLAN_PUBLISHED": ["AWAIT_SUBAGENT_END"],
211
+ "AWAIT_SUBAGENT_END": ["DIAGNOSED", "BACK_EDGE_TRIGGERED", "PAUSED", "PLAN_PUBLISHED", "END"],
212
+ "BACK_EDGE_TRIGGERED": ["MODE_A_ROUTING", "MODE_B_PIPELINE", "PAUSED"],
213
+ "PAUSED": ["END"],
214
+ "END": [],
215
+ },
216
+ "copilot-literature": {
217
+ "UNINITIALIZED": ["SCANNING"],
218
+ "SCANNING": ["BASELINE_LOCKED", "RELATED_WORK_AUGMENTED"],
219
+ "BASELINE_LOCKED": ["RELATED_WORK_AUGMENTED", "END"],
220
+ "RELATED_WORK_AUGMENTED": ["END"],
221
+ "END": [],
222
+ },
223
+ "copilot-ideation": {
224
+ "UNINITIALIZED": ["CONTEXT_LOADED", "END"],
225
+ "CONTEXT_LOADED": ["INTERVIEWING"],
226
+ "INTERVIEWING": ["PREFERENCES_LOCKED"],
227
+ "PREFERENCES_LOCKED": ["CANDIDATES_GENERATED"],
228
+ "CANDIDATES_GENERATED": ["ANALOGIES_ADDED"],
229
+ "ANALOGIES_ADDED": ["FILTERED"],
230
+ "FILTERED": ["AWAITING_SELECTION"],
231
+ "AWAITING_SELECTION": ["DIRECTION_SELECTED", "PREFERENCES_LOCKED"],
232
+ "DIRECTION_SELECTED": ["VALIDATED"],
233
+ "VALIDATED": ["END"],
234
+ "END": [],
235
+ },
236
+ "copilot-experiment": {
237
+ "UNINITIALIZED": ["CONTEXT_LOADED"],
238
+ "CONTEXT_LOADED": ["DESIGN_READY"],
239
+ "DESIGN_READY": ["APPROVED"],
240
+ "APPROVED": ["EXECUTING"],
241
+ "EXECUTING": ["COMPLETED"],
242
+ "COMPLETED": ["VERIFIED"],
243
+ "VERIFIED": ["JUDGED"],
244
+ "JUDGED": ["END", "EXECUTING"],
245
+ "END": [],
246
+ },
247
+ "copilot-writer": {
248
+ "UNINITIALIZED": ["PLAN_DRAFT", "EXPAND", "SHORTEN", "TRANSLATE", "CAPTION"],
249
+ "PLAN_DRAFT": ["DRAFTING"],
250
+ "DRAFTING": ["REVIEW_SELF", "END"],
251
+ "EXPAND": ["REVIEW_SELF", "END"],
252
+ "SHORTEN": ["REVIEW_SELF", "END"],
253
+ "TRANSLATE": ["END"],
254
+ "CAPTION": ["END"],
255
+ "REVIEW_SELF": ["END"],
256
+ "END": [],
257
+ },
258
+ "copilot-polisher": {
259
+ "UNINITIALIZED": ["POLISHING"],
260
+ "POLISHING": ["DE_AI"],
261
+ "DE_AI": ["VALIDATED"],
262
+ "VALIDATED": ["END"],
263
+ "END": [],
264
+ },
265
+ "copilot-reviewer": {
266
+ "UNINITIALIZED": ["SIMULATE_REVIEW"],
267
+ "SIMULATE_REVIEW": ["EXTRACT_GAPS"],
268
+ "EXTRACT_GAPS": ["WRITE_ROUND"],
269
+ "WRITE_ROUND": ["END"],
270
+ "END": [],
271
+ },
272
+ "copilot-rebuttal": {
273
+ "UNINITIALIZED": ["PARSE_REVIEWS"],
274
+ "PARSE_REVIEWS": ["DRAFT_RESPONSE"],
275
+ "DRAFT_RESPONSE": ["RE_REVIEW", "END"],
276
+ "RE_REVIEW": ["END"],
277
+ "END": [],
278
+ },
279
+ }
280
+
281
+
282
+ def is_transition_legal(agent: str, previous: str, current: str) -> bool:
283
+ """True if `previous -> current` appears in agent's table.
284
+ Returns True (no false warns) for unknown agent OR unknown source state."""
285
+ sm = STATE_MACHINE.get(agent)
286
+ if sm is None:
287
+ return True
288
+ allowed = sm.get(previous)
289
+ if allowed is None:
290
+ return True
291
+ return current in allowed
292
+
293
+
294
+ # ---------------------------------------------------------------------------
295
+ # Parsers (HANDOFF + STATE_OUTPUT)
296
+ # ---------------------------------------------------------------------------
297
+
298
+ _HANDOFF_HEADER = "## __HANDOFF__"
299
+ _STATE_OUTPUT_RE = re.compile(
300
+ r"\[STATE_OUTPUT\](.*?)\[/STATE_OUTPUT\]",
301
+ re.DOTALL,
302
+ )
303
+ REQUIRED_STATE_OUTPUT_FIELDS = (
304
+ "Previous", "Current", "Action completed", "Capability gate",
305
+ "Evidence", "Next allowed",
306
+ )
307
+
308
+
309
+ def extract_handoff(text: str) -> dict[str, Any] | None:
310
+ """Parse the LAST `## __HANDOFF__` block in `text`.
311
+
312
+ Returns dict with keys last_updated, written_by, key_facts (list), next_owner;
313
+ None if no block present. Missing individual fields are None / [].
314
+ """
315
+ if not text:
316
+ return None
317
+ idx = text.rfind(_HANDOFF_HEADER)
318
+ if idx < 0:
319
+ return None
320
+ body = text[idx + len(_HANDOFF_HEADER):].strip()
321
+ end = body.find("\n## ")
322
+ if end >= 0:
323
+ body = body[:end]
324
+ result: dict[str, Any] = {
325
+ "last_updated": None,
326
+ "written_by": None,
327
+ "key_facts": [],
328
+ "next_owner": None,
329
+ }
330
+ in_key_facts = False
331
+ for line in body.splitlines():
332
+ s = line.strip()
333
+ if s.startswith("- last_updated:"):
334
+ result["last_updated"] = s.split(":", 1)[1].strip() or None
335
+ in_key_facts = False
336
+ elif s.startswith("- written_by:"):
337
+ result["written_by"] = s.split(":", 1)[1].strip() or None
338
+ in_key_facts = False
339
+ elif s.startswith("- next_owner:"):
340
+ result["next_owner"] = s.split(":", 1)[1].strip() or None
341
+ in_key_facts = False
342
+ elif s.startswith("- key_facts:"):
343
+ in_key_facts = True
344
+ elif in_key_facts and s.startswith("- "):
345
+ result["key_facts"].append(s[2:].strip())
346
+ elif in_key_facts and s.startswith("-"):
347
+ result["key_facts"].append(s[1:].strip())
348
+ else:
349
+ in_key_facts = False
350
+ return result
351
+
352
+
353
+ def extract_state_output(text: str) -> dict[str, str] | None:
354
+ """Parse the LAST [STATE_OUTPUT]...[/STATE_OUTPUT] block.
355
+
356
+ Missing fields are absent from the dict (NOT present as None).
357
+ Returns None if no block found at all.
358
+ """
359
+ if not text:
360
+ return None
361
+ matches = _STATE_OUTPUT_RE.findall(text)
362
+ if not matches:
363
+ return None
364
+ body = matches[-1].strip()
365
+ result: dict[str, str] = {}
366
+ for line in body.splitlines():
367
+ s = line.strip()
368
+ if ":" not in s:
369
+ continue
370
+ k, v = s.split(":", 1)
371
+ result[k.strip()] = v.strip()
372
+ return result or None
373
+
374
+
375
+ def state_output_missing_fields(so: dict[str, str] | None) -> list[str]:
376
+ """Return REQUIRED_STATE_OUTPUT_FIELDS missing from `so`.
377
+ If so is None (no block at all), all 6 are reported."""
378
+ if so is None:
379
+ return list(REQUIRED_STATE_OUTPUT_FIELDS)
380
+ return [f for f in REQUIRED_STATE_OUTPUT_FIELDS if f not in so]
381
+
382
+
383
+ # ---------------------------------------------------------------------------
384
+ # Runtime state files (under .copilot/)
385
+ # ---------------------------------------------------------------------------
386
+
387
+ SNAPSHOT_NAME = ".session_snapshot.json"
388
+ COUNTER_NAME = ".subagent_stop_block_count.json"
389
+ VIOLATIONS_NAME = "__violations.log"
390
+
391
+
392
+ def _now_iso() -> str:
393
+ return datetime.datetime.now(datetime.timezone.utc).isoformat(
394
+ timespec="seconds").replace("+00:00", "Z")
395
+
396
+
397
+ def _copilot_dir(workspace: Path) -> Path:
398
+ d = workspace / ".copilot"
399
+ d.mkdir(exist_ok=True)
400
+ return d
401
+
402
+
403
+ def _read_json(path: Path) -> dict:
404
+ if not path.is_file():
405
+ return {}
406
+ try:
407
+ return json.loads(path.read_text(encoding="utf-8"))
408
+ except (json.JSONDecodeError, OSError):
409
+ return {}
410
+
411
+
412
+ def _write_json(path: Path, data: dict) -> None:
413
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False),
414
+ encoding="utf-8")
415
+
416
+
417
+ def read_snapshot(workspace: Path) -> dict[str, Any]:
418
+ return _read_json(_copilot_dir(workspace) / SNAPSHOT_NAME)
419
+
420
+
421
+ def write_snapshot(workspace: Path, data: dict[str, Any]) -> None:
422
+ _write_json(_copilot_dir(workspace) / SNAPSHOT_NAME, data)
423
+
424
+
425
+ def counter_read(workspace: Path) -> dict[str, dict[str, dict[str, Any]]]:
426
+ return _read_json(_copilot_dir(workspace) / COUNTER_NAME)
427
+
428
+
429
+ def counter_inc(workspace: Path, agent: str, file: str) -> int:
430
+ data = counter_read(workspace)
431
+ bucket = data.setdefault(agent, {}).setdefault(
432
+ file, {"count": 0, "last_block_at": None, "reset_at": None})
433
+ bucket["count"] = int(bucket.get("count", 0)) + 1
434
+ bucket["last_block_at"] = _now_iso()
435
+ _write_json(_copilot_dir(workspace) / COUNTER_NAME, data)
436
+ return bucket["count"]
437
+
438
+
439
+ def counter_get(workspace: Path, agent: str, file: str) -> int:
440
+ return counter_read(workspace).get(agent, {}).get(file, {}).get("count", 0)
441
+
442
+
443
+ def counter_reset(workspace: Path, agent: str, file: str) -> None:
444
+ data = counter_read(workspace)
445
+ if agent in data and file in data[agent]:
446
+ data[agent][file]["count"] = 0
447
+ data[agent][file]["reset_at"] = _now_iso()
448
+ _write_json(_copilot_dir(workspace) / COUNTER_NAME, data)
449
+
450
+
451
+ def counter_reset_all(workspace: Path, agent: str) -> None:
452
+ data = counter_read(workspace)
453
+ if agent in data:
454
+ for bucket in data[agent].values():
455
+ bucket["count"] = 0
456
+ bucket["reset_at"] = _now_iso()
457
+ _write_json(_copilot_dir(workspace) / COUNTER_NAME, data)
458
+
459
+
460
+ def log_violation(workspace: Path, sev: str, kind: str, agent: str | None,
461
+ detail: str, file: str | None = None) -> None:
462
+ """Append one JSONL record to .copilot/__violations.log."""
463
+ rec = {"ts": _now_iso(), "sev": sev, "kind": kind,
464
+ "agent": agent, "detail": detail}
465
+ if file is not None:
466
+ rec["file"] = file
467
+ log = _copilot_dir(workspace) / VIOLATIONS_NAME
468
+ with log.open("a", encoding="utf-8") as f:
469
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
470
+
471
+
472
+ # ---------------------------------------------------------------------------
473
+ # Overrides
474
+ # ---------------------------------------------------------------------------
475
+
476
+ OVERRIDE_NAME = ".guard_override"
477
+ _OVERRIDE_LINE_RE = re.compile(
478
+ r"^\s*(?P<agent>[\w-]+)\s*:\s*(?P<directive>skip-[\w-]+)\s+until\s+(?P<until>\S+)\s*$"
479
+ )
480
+
481
+
482
+ def env_guard_disabled() -> bool:
483
+ """True iff the global kill-switch env var COPILOT_HOOK_GUARD is 'off'."""
484
+ return os.environ.get("COPILOT_HOOK_GUARD", "").strip().lower() == "off"
485
+
486
+
487
+ def override_match(workspace: Path, agent: str, directive: str) -> bool:
488
+ """True iff `.copilot/.guard_override` has an active entry for this
489
+ agent + directive (or `skip-all` for the same agent).
490
+
491
+ Comments (#-prefixed) and unparseable lines are ignored. Expired
492
+ entries (now >= until) are ignored.
493
+ """
494
+ f = _copilot_dir(workspace) / OVERRIDE_NAME
495
+ if not f.is_file():
496
+ return False
497
+ try:
498
+ content = f.read_text(encoding="utf-8")
499
+ except OSError:
500
+ return False
501
+ now = datetime.datetime.now(datetime.timezone.utc)
502
+ for line in content.splitlines():
503
+ if not line.strip() or line.lstrip().startswith("#"):
504
+ continue
505
+ m = _OVERRIDE_LINE_RE.match(line)
506
+ if not m or m.group("agent") != agent:
507
+ continue
508
+ d = m.group("directive")
509
+ if d != "skip-all" and d != directive:
510
+ continue
511
+ try:
512
+ until = datetime.datetime.fromisoformat(
513
+ m.group("until").replace("Z", "+00:00"))
514
+ except ValueError:
515
+ continue
516
+ if until.tzinfo is None:
517
+ until = until.replace(tzinfo=datetime.timezone.utc)
518
+ if now < until:
519
+ return True
520
+ return False
521
+
522
+
523
+ # ---------------------------------------------------------------------------
524
+ # Decision builders + safe_main
525
+ # ---------------------------------------------------------------------------
526
+
527
+ def allow_decision() -> dict[str, Any]:
528
+ return {"hookSpecificOutput": {"permissionDecision": "allow"}}
529
+
530
+
531
+ def deny_decision(reason: str) -> dict[str, Any]:
532
+ return {
533
+ "hookSpecificOutput": {
534
+ "permissionDecision": "deny",
535
+ "permissionDecisionReason": reason,
536
+ },
537
+ "systemMessage": reason,
538
+ }
539
+
540
+
541
+ def block_decision(reason: str) -> dict[str, Any]:
542
+ """SubagentStop block decision — agent resumes with `reason` appended to context."""
543
+ return {"decision": "block", "reason": reason}
544
+
545
+
546
+ def safe_main(real_main) -> int:
547
+ """Wrap a hook's main(): exceptions yield `allow` to stdout, never trap user.
548
+
549
+ Hook scripts MUST call this from their `if __name__ == "__main__"` block:
550
+ if __name__ == "__main__":
551
+ raise SystemExit(lib.safe_main(real_main))
552
+ """
553
+ try:
554
+ return int(real_main() or 0)
555
+ except SystemExit:
556
+ raise
557
+ except Exception:
558
+ sys.stderr.write(traceback.format_exc())
559
+ try:
560
+ sys.stdout.write(json.dumps(allow_decision()) + "\n")
561
+ sys.stdout.flush()
562
+ except Exception:
563
+ pass
564
+ return 0