@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.
- package/dist/.claude-plugin/plugin.json +3 -2
- package/dist/.codex-plugin/plugin.toml +2 -1
- package/dist/.cursor-plugin/plugin.json +3 -2
- package/dist/.gemini-plugin/plugin.json +3 -2
- package/dist/.opencode-plugin/plugin.json +3 -2
- package/dist/.windsurf-plugin/plugin.json +3 -2
- package/dist/agents/copilot-conductor.agent.md +60 -0
- package/dist/agents/copilot-experiment.agent.md +56 -0
- package/dist/agents/copilot-ideation.agent.md +45 -0
- package/dist/agents/copilot-literature.agent.md +34 -0
- package/dist/agents/copilot-polisher.agent.md +30 -0
- package/dist/agents/copilot-rebuttal.agent.md +35 -0
- package/dist/agents/copilot-reviewer.agent.md +35 -0
- package/dist/agents/copilot-writer.agent.md +39 -0
- package/dist/hooks/dispatch-reminder.json +17 -0
- package/dist/hooks/loop-armer.json +17 -0
- package/dist/hooks/research-copilot-guard.hook.md +51 -0
- package/dist/hooks/scientist-guardrails.json +17 -0
- package/dist/hooks/scripts/__tests__/__init__.py +0 -0
- package/dist/hooks/scripts/__tests__/test_post_tool_loop_armer.py +88 -0
- package/dist/hooks/scripts/__tests__/test_research_copilot_guard_main_session.py +150 -0
- package/dist/hooks/scripts/__tests__/test_session_start_memory_injector.py +66 -0
- package/dist/hooks/scripts/__tests__/test_user_prompt_dispatch_reminder.py +37 -0
- package/dist/hooks/scripts/_copilot_hook_lib.py +564 -0
- package/dist/hooks/scripts/copilot_subagent_stop.py +203 -0
- package/dist/hooks/scripts/copilot_write_guard.py +96 -0
- package/dist/hooks/scripts/post_tool_loop_armer.py +61 -0
- package/dist/hooks/scripts/research_copilot_guard.py +208 -0
- package/dist/hooks/scripts/scientist_guardrails.py +29 -0
- package/dist/hooks/scripts/session_start_memory_injector.py +188 -0
- package/dist/hooks/scripts/user_prompt_dispatch_reminder.py +40 -0
- package/dist/hooks/session-memory-injector.json +17 -0
- package/dist/hooks/tests/__init__.py +0 -0
- package/dist/hooks/tests/conftest.py +61 -0
- package/dist/hooks/tests/fixtures/transcript_copilot_experiment_complete.jsonl +2 -0
- package/dist/hooks/tests/fixtures/transcript_copilot_experiment_state_jump.jsonl +2 -0
- package/dist/hooks/tests/fixtures/transcript_copilot_literature.jsonl +2 -0
- package/dist/hooks/tests/fixtures/transcript_main_only.jsonl +2 -0
- package/dist/hooks/tests/fixtures/transcript_malformed_state_output.jsonl +2 -0
- package/dist/hooks/tests/integration_run.ps1 +65 -0
- package/dist/hooks/tests/test_copilot_hook_lib.py +398 -0
- package/dist/hooks/tests/test_copilot_subagent_stop.py +186 -0
- package/dist/hooks/tests/test_copilot_write_guard.py +137 -0
- package/dist/hooks/tests/test_session_start_snapshot.py +116 -0
- package/dist/hooks/tests/test_state_machine_consistency.py +75 -0
- package/dist/skills/arxivsub-skill/SKILL.md +98 -0
- package/dist/skills/arxivsub-skill/skill.json +5 -0
- package/dist/skills/de-ai-checker/SKILL.md +110 -0
- package/dist/skills/de-ai-checker/skill.json +5 -0
- package/dist/skills/deep-interview/SKILL.md +91 -0
- package/dist/skills/deep-interview/skill.json +5 -0
- package/dist/skills/grill-with-docs/SKILL.md +120 -0
- package/dist/skills/grill-with-docs/skill.json +5 -0
- package/dist/skills/init-mcp/SKILL.md +83 -0
- package/dist/skills/init-mcp/skill.json +5 -0
- package/dist/skills/model-escalation/SKILL.md +93 -0
- package/dist/skills/model-escalation/skill.json +5 -0
- package/dist/skills/paper-architecture-web-drawing/SKILL.md +282 -0
- package/dist/skills/paper-architecture-web-drawing/skill.json +5 -0
- package/dist/skills/paper-deai/SKILL.md +53 -0
- package/dist/skills/paper-deai/skill.json +5 -0
- package/dist/skills/paper-en2zh/SKILL.md +29 -0
- package/dist/skills/paper-en2zh/skill.json +5 -0
- package/dist/skills/paper-expand/SKILL.md +43 -0
- package/dist/skills/paper-expand/skill.json +5 -0
- package/dist/skills/paper-experiment-analysis/SKILL.md +38 -0
- package/dist/skills/paper-experiment-analysis/skill.json +5 -0
- package/dist/skills/paper-figure-caption/SKILL.md +29 -0
- package/dist/skills/paper-figure-caption/skill.json +5 -0
- package/dist/skills/paper-logic-check/SKILL.md +30 -0
- package/dist/skills/paper-logic-check/skill.json +5 -0
- package/dist/skills/paper-polish/SKILL.md +34 -305
- package/dist/skills/paper-polish/skill.json +5 -0
- package/dist/skills/paper-review/SKILL.md +49 -0
- package/dist/skills/paper-review/skill.json +5 -0
- package/dist/skills/paper-sanity-check/SKILL.md +122 -0
- package/dist/skills/paper-sanity-check/skill.json +5 -0
- package/dist/skills/paper-shorten/SKILL.md +42 -0
- package/dist/skills/paper-shorten/skill.json +5 -0
- package/dist/skills/paper-table-caption/SKILL.md +29 -0
- package/dist/skills/paper-table-caption/skill.json +5 -0
- package/dist/skills/paper-translate/SKILL.md +48 -0
- package/dist/skills/paper-translate/skill.json +5 -0
- package/dist/skills/plugin-dev-agent-development/SKILL.md +95 -0
- package/dist/skills/plugin-dev-agent-development/skill.json +5 -0
- package/dist/skills/research-workflow/SKILL.md +116 -0
- package/dist/skills/research-workflow/skill.json +5 -0
- package/dist/skills/scientist-experiment-runner/SKILL.md +76 -0
- package/dist/skills/scientist-experiment-runner/skill.json +5 -0
- package/dist/skills/scientist-ideation/SKILL.md +52 -0
- package/dist/skills/scientist-ideation/skill.json +5 -0
- package/dist/skills/scientist-plotting/SKILL.md +49 -0
- package/dist/skills/scientist-plotting/skill.json +5 -0
- package/dist/skills/scientist-review/SKILL.md +40 -0
- package/dist/skills/scientist-review/skill.json +5 -0
- package/dist/skills/scientist-runtime-init/SKILL.md +46 -0
- package/dist/skills/scientist-runtime-init/skill.json +5 -0
- package/dist/skills/scientist-writeup/SKILL.md +60 -0
- package/dist/skills/scientist-writeup/skill.json +5 -0
- package/dist/skills/talk-normal/SKILL.md +73 -0
- package/dist/skills/talk-normal/skill.json +5 -0
- package/package.json +1 -1
- package/dist/agents/rc-experiment.md +0 -203
- package/dist/agents/rc-ideation.md +0 -224
- package/dist/agents/rc-literature.md +0 -228
- package/dist/agents/rc-plan.md +0 -189
- package/dist/agents/rc-polisher.md +0 -166
- package/dist/agents/rc-rebuttal.md +0 -194
- package/dist/agents/rc-reviewer.md +0 -187
- package/dist/agents/rc-update-spec.md +0 -231
- package/dist/agents/rc-verify.md +0 -234
- package/dist/agents/rc-writer.md +0 -161
- package/dist/skills/experiment-design/SKILL.md +0 -331
- package/dist/skills/full-research-workflow/SKILL.md +0 -363
- package/dist/skills/literature-search/SKILL.md +0 -244
- package/dist/skills/sanity-check/SKILL.md +0 -449
- 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
|