@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,398 @@
1
+ """Tests for _copilot_hook_lib."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ import _copilot_hook_lib as lib
9
+
10
+
11
+ class TestDetectActiveAgent:
12
+ def test_main_only_returns_none(self, fixtures_dir):
13
+ p = fixtures_dir / "transcript_main_only.jsonl"
14
+ assert lib.detect_active_agent(str(p)) is None
15
+
16
+ def test_copilot_literature(self, fixtures_dir):
17
+ p = fixtures_dir / "transcript_copilot_literature.jsonl"
18
+ assert lib.detect_active_agent(str(p)) == "copilot-literature"
19
+
20
+ def test_copilot_experiment(self, fixtures_dir):
21
+ p = fixtures_dir / "transcript_copilot_experiment_complete.jsonl"
22
+ assert lib.detect_active_agent(str(p)) == "copilot-experiment"
23
+
24
+ def test_missing_path_returns_none(self):
25
+ assert lib.detect_active_agent("") is None
26
+ assert lib.detect_active_agent("/nonexistent/path") is None
27
+
28
+
29
+ class TestScopePredicates:
30
+ def test_is_copilot_agent_positive(self):
31
+ for n in ["copilot-literature", "copilot-ideation", "copilot-experiment",
32
+ "copilot-writer", "copilot-polisher", "copilot-reviewer",
33
+ "copilot-rebuttal"]:
34
+ assert lib.is_copilot_agent(n) is True
35
+
36
+ def test_research_copilot_is_not_a_subagent(self):
37
+ assert lib.is_copilot_agent("research-copilot") is False
38
+
39
+ def test_is_copilot_agent_negative(self):
40
+ for n in [None, "", "general-purpose", "Explore", "code-reviewer", "main"]:
41
+ assert lib.is_copilot_agent(n) is False
42
+
43
+
44
+ class TestPathNormalize:
45
+ def test_backslash_to_forward(self):
46
+ assert lib.normalize_path("D:\\article\\.copilot\\state.md") == \
47
+ "d:/article/.copilot/state.md"
48
+
49
+ def test_already_forward(self):
50
+ assert lib.normalize_path(".copilot/state.md") == ".copilot/state.md"
51
+
52
+ def test_relative_workspace(self, workspace):
53
+ f = workspace / ".copilot" / "state.md"
54
+ assert lib.normalize_path(str(f), workspace=workspace) == ".copilot/state.md"
55
+
56
+ def test_empty(self):
57
+ assert lib.normalize_path("") == ""
58
+
59
+
60
+ class TestGlobMatch:
61
+ def test_exact(self):
62
+ assert lib.glob_match(".copilot/state.md", ".copilot/state.md") is True
63
+
64
+ def test_star(self):
65
+ assert lib.glob_match(".copilot/reviews/round-3.md",
66
+ ".copilot/reviews/round-*.md") is True
67
+
68
+ def test_no_match(self):
69
+ assert lib.glob_match(".copilot/ideas.md", ".copilot/state.md") is False
70
+
71
+ def test_s2_pipelines(self):
72
+ assert lib.glob_match(".copilot/pipelines/2026-05-24-S2-ideation-round-1.md",
73
+ ".copilot/pipelines/*-s2-*.md") is True
74
+ assert lib.glob_match(".copilot/pipelines/2026-05-24-S3-experiment-1.md",
75
+ ".copilot/pipelines/*-s2-*.md") is False
76
+
77
+ def test_star_does_not_cross_slash(self):
78
+ # Tightened semantics: `*` is single-segment only
79
+ assert lib.glob_match("sections/sub/foo.tex", "sections/*.tex") is False
80
+ assert lib.glob_match(".copilot/reviews/round-2/sub/x.md",
81
+ ".copilot/reviews/round-*.md") is False
82
+
83
+ def test_star_single_segment_still_matches(self):
84
+ assert lib.glob_match("sections/intro.tex", "sections/*.tex") is True
85
+ assert lib.glob_match(".copilot/pipelines/2026-05-24-S2-ideation.md",
86
+ ".copilot/pipelines/*-s2-*.md") is True
87
+
88
+ def test_empty_pattern(self):
89
+ assert lib.glob_match("", "") is True
90
+ assert lib.glob_match("anything", "") is False
91
+
92
+
93
+ class TestOwnedMatrix:
94
+ def test_literature_owns_literature(self):
95
+ assert lib.is_owned("copilot-literature", ".copilot/literature.md") is True
96
+
97
+ def test_literature_does_not_own_ideas(self):
98
+ assert lib.is_owned("copilot-literature", ".copilot/ideas.md") is False
99
+
100
+ def test_ideation_owns_s2_pipelines(self):
101
+ assert lib.is_owned("copilot-ideation",
102
+ ".copilot/pipelines/2026-05-24-s2-ideation.md") is True
103
+
104
+ def test_experiment_owns_s3_pipelines(self):
105
+ assert lib.is_owned("copilot-experiment",
106
+ ".copilot/pipelines/2026-05-24-s3-exp.md") is True
107
+
108
+ def test_writer_owns_sections_tex(self):
109
+ assert lib.is_owned("copilot-writer", "sections/intro.tex") is True
110
+
111
+ def test_writer_owns_handoff(self):
112
+ assert lib.is_owned("copilot-writer", ".copilot/handoff.md") is True
113
+
114
+ def test_unknown_agent(self):
115
+ assert lib.is_owned("unknown", ".copilot/state.md") is False
116
+
117
+
118
+ class TestIsKnownArtifact:
119
+ def test_dot_copilot(self):
120
+ assert lib.is_known_research_artifact(".copilot/state.md") is True
121
+ assert lib.is_known_research_artifact(".copilot/handoff.md") is True
122
+
123
+ def test_sections(self):
124
+ assert lib.is_known_research_artifact("sections/intro.tex") is True
125
+
126
+ def test_references_bib(self):
127
+ assert lib.is_known_research_artifact("references.bib") is True
128
+
129
+ def test_unrelated_scratch(self):
130
+ assert lib.is_known_research_artifact("scratch/note.txt") is False
131
+ assert lib.is_known_research_artifact("README.md") is False
132
+
133
+
134
+ class TestStateMachine:
135
+ def test_literature_machine_present(self):
136
+ sm = lib.STATE_MACHINE["copilot-literature"]
137
+ assert sm["UNINITIALIZED"] == ["SCANNING"]
138
+ assert "END" in sm["BASELINE_LOCKED"]
139
+
140
+ def test_experiment_machine_present(self):
141
+ sm = lib.STATE_MACHINE["copilot-experiment"]
142
+ assert sm["UNINITIALIZED"] == ["CONTEXT_LOADED"]
143
+ assert "END" in sm["JUDGED"]
144
+
145
+ def test_all_subagents_have_machines(self):
146
+ for agent in lib.COPILOT_AGENTS:
147
+ assert agent in lib.STATE_MACHINE, f"missing state machine for {agent}"
148
+
149
+ def test_transition_legal(self):
150
+ assert lib.is_transition_legal(
151
+ "copilot-literature", "UNINITIALIZED", "SCANNING") is True
152
+
153
+ def test_transition_illegal(self):
154
+ assert lib.is_transition_legal(
155
+ "copilot-experiment", "UNINITIALIZED", "END") is False
156
+
157
+ def test_transition_unknown_agent_allowed(self):
158
+ assert lib.is_transition_legal("unknown", "X", "Y") is True
159
+
160
+ def test_transition_unknown_state_allowed(self):
161
+ assert lib.is_transition_legal(
162
+ "copilot-literature", "NEW_STATE", "END") is True
163
+
164
+
165
+ class TestExtractHandoff:
166
+ def test_full_block(self):
167
+ text = """# Some artifact
168
+
169
+ Body.
170
+
171
+ ## __HANDOFF__
172
+ - last_updated: 2026-05-24T10:30:00Z
173
+ - written_by: copilot-literature
174
+ - key_facts:
175
+ - locked baseline: 1706.03762
176
+ - 3 nearest prior works
177
+ - next_owner: copilot-ideation
178
+ """
179
+ h = lib.extract_handoff(text)
180
+ assert h is not None
181
+ assert h["last_updated"] == "2026-05-24T10:30:00Z"
182
+ assert h["written_by"] == "copilot-literature"
183
+ assert h["next_owner"] == "copilot-ideation"
184
+ assert "locked baseline: 1706.03762" in h["key_facts"]
185
+
186
+ def test_no_block(self):
187
+ assert lib.extract_handoff("# header only\nno handoff block") is None
188
+
189
+ def test_empty(self):
190
+ assert lib.extract_handoff("") is None
191
+
192
+ def test_block_without_last_updated(self):
193
+ h = lib.extract_handoff("## __HANDOFF__\n- written_by: x\n")
194
+ assert h is not None
195
+ assert h.get("last_updated") is None
196
+
197
+
198
+ class TestExtractStateOutput:
199
+ def test_full_block(self):
200
+ text = """prose
201
+ [STATE_OUTPUT]
202
+ Previous: SCANNING
203
+ Current: BASELINE_LOCKED
204
+ Action completed: Locked baseline
205
+ Capability gate: passed
206
+ Evidence: literature.md:42
207
+ Next allowed: [RELATED_WORK_AUGMENTED, END]
208
+ Transition reason: 2 MCP queries
209
+ [/STATE_OUTPUT]
210
+ trailing"""
211
+ so = lib.extract_state_output(text)
212
+ assert so["Previous"] == "SCANNING"
213
+ assert so["Current"] == "BASELINE_LOCKED"
214
+ assert so["Capability gate"] == "passed"
215
+
216
+ def test_missing_block(self):
217
+ assert lib.extract_state_output("no block") is None
218
+
219
+ def test_missing_fields(self):
220
+ text = "[STATE_OUTPUT]\nPrevious: X\nCurrent: Y\n[/STATE_OUTPUT]"
221
+ so = lib.extract_state_output(text)
222
+ assert so["Previous"] == "X"
223
+ assert "Capability gate" not in so
224
+
225
+ def test_required_fields_missing_returns_list(self):
226
+ so = lib.extract_state_output("[STATE_OUTPUT]\nPrevious: X\nCurrent: Y\n[/STATE_OUTPUT]")
227
+ missing = lib.state_output_missing_fields(so)
228
+ assert set(missing) == {"Action completed", "Capability gate", "Evidence", "Next allowed"}
229
+
230
+ def test_required_fields_complete(self):
231
+ so = {
232
+ "Previous": "X", "Current": "Y", "Action completed": "did stuff",
233
+ "Capability gate": "passed", "Evidence": "x:1", "Next allowed": "[A]",
234
+ }
235
+ assert lib.state_output_missing_fields(so) == []
236
+
237
+ def test_state_output_none_returns_all_missing(self):
238
+ missing = lib.state_output_missing_fields(None)
239
+ assert len(missing) >= 6
240
+
241
+
242
+ import json as _json_in_tests
243
+
244
+
245
+ class TestSnapshotIO:
246
+ def test_read_missing(self, workspace):
247
+ assert lib.read_snapshot(workspace) == {}
248
+
249
+ def test_write_and_read(self, workspace):
250
+ lib.write_snapshot(workspace, {"state.md": "2026-05-24T10:00:00Z"})
251
+ assert lib.read_snapshot(workspace) == {"state.md": "2026-05-24T10:00:00Z"}
252
+
253
+ def test_corrupt_returns_empty(self, workspace):
254
+ (workspace / ".copilot" / ".session_snapshot.json").write_text("not json")
255
+ assert lib.read_snapshot(workspace) == {}
256
+
257
+
258
+ class TestCounterIO:
259
+ def test_read_missing(self, workspace):
260
+ assert lib.counter_read(workspace) == {}
261
+
262
+ def test_inc_from_zero(self, workspace):
263
+ assert lib.counter_inc(workspace, "copilot-literature", "literature.md") == 1
264
+ assert lib.counter_inc(workspace, "copilot-literature", "literature.md") == 2
265
+
266
+ def test_reset_bucket(self, workspace):
267
+ lib.counter_inc(workspace, "copilot-literature", "literature.md")
268
+ lib.counter_reset(workspace, "copilot-literature", "literature.md")
269
+ assert lib.counter_get(workspace, "copilot-literature", "literature.md") == 0
270
+
271
+ def test_reset_all_for_agent(self, workspace):
272
+ lib.counter_inc(workspace, "copilot-experiment", "experiments.md")
273
+ lib.counter_inc(workspace, "copilot-experiment", "other.md")
274
+ lib.counter_reset_all(workspace, "copilot-experiment")
275
+ for v in lib.counter_read(workspace).get("copilot-experiment", {}).values():
276
+ assert v["count"] == 0
277
+
278
+
279
+ class TestViolationsLog:
280
+ def test_log_appends(self, workspace):
281
+ lib.log_violation(workspace, "HARD", "DENY", "copilot-literature",
282
+ "test detail", file=".copilot/ideas.md")
283
+ log = (workspace / ".copilot" / "__violations.log").read_text(encoding="utf-8")
284
+ line = log.strip().splitlines()[-1]
285
+ rec = _json_in_tests.loads(line)
286
+ assert rec["sev"] == "HARD"
287
+ assert rec["kind"] == "DENY"
288
+ assert rec["agent"] == "copilot-literature"
289
+
290
+ def test_log_multiple(self, workspace):
291
+ lib.log_violation(workspace, "SOFT", "WARN", "copilot-experiment", "warn 1")
292
+ lib.log_violation(workspace, "SOFT", "WARN", "copilot-experiment", "warn 2")
293
+ lines = (workspace / ".copilot" / "__violations.log").read_text(encoding="utf-8").strip().splitlines()
294
+ assert len(lines) == 2
295
+
296
+
297
+ class TestOverride:
298
+ def test_env_var_off(self, monkeypatch):
299
+ monkeypatch.setenv("COPILOT_HOOK_GUARD", "off")
300
+ assert lib.env_guard_disabled() is True
301
+
302
+ def test_env_var_unset(self, monkeypatch):
303
+ monkeypatch.delenv("COPILOT_HOOK_GUARD", raising=False)
304
+ assert lib.env_guard_disabled() is False
305
+
306
+ def test_env_var_other_value(self, monkeypatch):
307
+ monkeypatch.setenv("COPILOT_HOOK_GUARD", "on")
308
+ assert lib.env_guard_disabled() is False
309
+
310
+ def test_override_file_missing(self, workspace):
311
+ assert lib.override_match(workspace, "copilot-literature", "skip-handoff-check") is False
312
+
313
+ def test_override_file_match(self, workspace):
314
+ (workspace / ".copilot" / ".guard_override").write_text(
315
+ "copilot-literature: skip-handoff-check until 2099-01-01T00:00:00Z\n",
316
+ encoding="utf-8")
317
+ assert lib.override_match(workspace, "copilot-literature", "skip-handoff-check") is True
318
+
319
+ def test_override_file_expired(self, workspace):
320
+ (workspace / ".copilot" / ".guard_override").write_text(
321
+ "copilot-literature: skip-handoff-check until 2000-01-01T00:00:00Z\n",
322
+ encoding="utf-8")
323
+ assert lib.override_match(workspace, "copilot-literature", "skip-handoff-check") is False
324
+
325
+ def test_override_skip_all(self, workspace):
326
+ (workspace / ".copilot" / ".guard_override").write_text(
327
+ "copilot-experiment: skip-all until 2099-01-01T00:00:00Z\n",
328
+ encoding="utf-8")
329
+ assert lib.override_match(workspace, "copilot-experiment", "skip-handoff-check") is True
330
+ assert lib.override_match(workspace, "copilot-experiment", "skip-owned-check") is True
331
+
332
+ def test_override_comment(self, workspace):
333
+ (workspace / ".copilot" / ".guard_override").write_text(
334
+ "# comment\ncopilot-literature: skip-handoff-check until 2099-01-01T00:00:00Z\n",
335
+ encoding="utf-8")
336
+ assert lib.override_match(workspace, "copilot-literature", "skip-handoff-check") is True
337
+
338
+ def test_override_wrong_agent(self, workspace):
339
+ (workspace / ".copilot" / ".guard_override").write_text(
340
+ "copilot-literature: skip-handoff-check until 2099-01-01T00:00:00Z\n",
341
+ encoding="utf-8")
342
+ assert lib.override_match(workspace, "copilot-experiment", "skip-handoff-check") is False
343
+
344
+
345
+ class TestDecisions:
346
+ def test_allow(self):
347
+ d = lib.allow_decision()
348
+ assert d["hookSpecificOutput"]["permissionDecision"] == "allow"
349
+
350
+ def test_deny(self):
351
+ d = lib.deny_decision("nope")
352
+ assert d["hookSpecificOutput"]["permissionDecision"] == "deny"
353
+ assert "nope" in d["hookSpecificOutput"]["permissionDecisionReason"]
354
+
355
+ def test_block(self):
356
+ d = lib.block_decision("come back and write handoff")
357
+ assert d["decision"] == "block"
358
+ assert "come back" in d["reason"]
359
+
360
+
361
+ class TestSafeMain:
362
+ def test_clean_exit(self, capsys):
363
+ def real():
364
+ print(_json_in_tests.dumps(lib.allow_decision()))
365
+ return 0
366
+ rc = lib.safe_main(real)
367
+ assert rc == 0
368
+ assert "allow" in capsys.readouterr().out
369
+
370
+ def test_exception_falls_open(self, capsys):
371
+ def real():
372
+ raise RuntimeError("boom")
373
+ rc = lib.safe_main(real)
374
+ assert rc == 0
375
+ assert "allow" in capsys.readouterr().out
376
+
377
+
378
+ class TestOriginAttribution:
379
+ def test_no_agent_id_is_main(self):
380
+ assert lib.is_main_session({"tool_name": "Bash", "tool_input": {}}) is True
381
+
382
+ def test_empty_agent_id_is_main(self):
383
+ assert lib.is_main_session({"agent_id": "", "agent_type": ""}) is True
384
+
385
+ def test_present_agent_id_is_subagent(self):
386
+ p = {"agent_id": "sa_01", "agent_type": "copilot-experiment"}
387
+ assert lib.is_main_session(p) is False
388
+
389
+ def test_exempt_copilot_subagent(self):
390
+ p = {"agent_id": "sa_01", "agent_type": "copilot-experiment"}
391
+ assert lib.is_exempt_subagent(p) is True
392
+
393
+ def test_non_copilot_subagent_not_exempt(self):
394
+ p = {"agent_id": "sa_02", "agent_type": "Explore"}
395
+ assert lib.is_exempt_subagent(p) is False
396
+
397
+ def test_main_session_not_exempt(self):
398
+ assert lib.is_exempt_subagent({"tool_name": "Bash"}) is False
@@ -0,0 +1,186 @@
1
+ """Tests for copilot_subagent_stop.py (SubagentStop)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from io import StringIO
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ import copilot_subagent_stop as guard
11
+ import _copilot_hook_lib as lib
12
+
13
+
14
+ def _run(monkeypatch, payload: dict, workspace: Path) -> dict:
15
+ monkeypatch.setattr("sys.stdin", StringIO(json.dumps(payload)))
16
+ monkeypatch.chdir(workspace)
17
+ out = StringIO()
18
+ monkeypatch.setattr("sys.stdout", out)
19
+ guard.real_main()
20
+ return json.loads(out.getvalue().strip().splitlines()[-1])
21
+
22
+
23
+ def _stop_payload(transcript_path: str, stop_hook_active: bool = False) -> dict:
24
+ return {"transcript_path": transcript_path, "stop_hook_active": stop_hook_active}
25
+
26
+
27
+ class TestScope:
28
+ def test_main_agent_allowed(self, monkeypatch, workspace, fixtures_dir):
29
+ d = _run(monkeypatch, _stop_payload(str(fixtures_dir / "transcript_main_only.jsonl")), workspace)
30
+ assert "decision" not in d
31
+ assert d["hookSpecificOutput"]["permissionDecision"] == "allow"
32
+
33
+ def test_non_copilot_agent_allowed(self, monkeypatch, workspace, tmp_path):
34
+ t = tmp_path / "trans.jsonl"
35
+ t.write_text('{"role":"assistant","metadata":{"subagent_type":"general-purpose"}}\n')
36
+ d = _run(monkeypatch, _stop_payload(str(t)), workspace)
37
+ assert d["hookSpecificOutput"]["permissionDecision"] == "allow"
38
+
39
+
40
+ class TestOverride:
41
+ def test_env_off_allows(self, monkeypatch, workspace, fixtures_dir):
42
+ monkeypatch.setenv("COPILOT_HOOK_GUARD", "off")
43
+ d = _run(monkeypatch,
44
+ _stop_payload(str(fixtures_dir / "transcript_copilot_literature.jsonl")),
45
+ workspace)
46
+ assert d["hookSpecificOutput"]["permissionDecision"] == "allow"
47
+
48
+ def test_skip_handoff_check_allows(self, monkeypatch, workspace, fixtures_dir):
49
+ (workspace / ".copilot" / ".guard_override").write_text(
50
+ "copilot-literature: skip-handoff-check until 2099-01-01T00:00:00Z\n",
51
+ encoding="utf-8")
52
+ d = _run(monkeypatch,
53
+ _stop_payload(str(fixtures_dir / "transcript_copilot_literature.jsonl")),
54
+ workspace)
55
+ assert d["hookSpecificOutput"]["permissionDecision"] == "allow"
56
+
57
+
58
+ class TestHandoffFreshness:
59
+ def test_missing_file_blocks(self, monkeypatch, workspace, fixtures_dir):
60
+ # Pre-populate snapshot so we trigger HARD_FAIL (not SOFT_FAIL first-boot path)
61
+ lib.write_snapshot(workspace, {"literature.md": None})
62
+ d = _run(monkeypatch,
63
+ _stop_payload(str(fixtures_dir / "transcript_copilot_literature.jsonl")),
64
+ workspace)
65
+ assert d.get("decision") == "block"
66
+ assert "literature.md" in d["reason"] or "HANDOFF" in d["reason"]
67
+
68
+ def test_stale_handoff_blocks(self, monkeypatch, workspace, fixtures_dir, handoff_writer):
69
+ f = workspace / ".copilot" / "literature.md"
70
+ old = "2026-05-23T00:00:00Z"
71
+ handoff_writer(f, last_updated=old)
72
+ lib.write_snapshot(workspace, {"literature.md": old})
73
+ d = _run(monkeypatch,
74
+ _stop_payload(str(fixtures_dir / "transcript_copilot_literature.jsonl")),
75
+ workspace)
76
+ assert d.get("decision") == "block"
77
+
78
+ def test_fresh_handoff_allows(self, monkeypatch, workspace, fixtures_dir, handoff_writer):
79
+ f = workspace / ".copilot" / "literature.md"
80
+ handoff_writer(f, last_updated="2026-05-24T10:00:00Z")
81
+ lib.write_snapshot(workspace, {"literature.md": "2026-05-23T00:00:00Z"})
82
+ d = _run(monkeypatch,
83
+ _stop_payload(str(fixtures_dir / "transcript_copilot_literature.jsonl")),
84
+ workspace)
85
+ assert d["hookSpecificOutput"]["permissionDecision"] == "allow"
86
+
87
+
88
+ class TestFuse:
89
+ def test_strikes_1_and_2_block(self, monkeypatch, workspace, fixtures_dir):
90
+ lib.write_snapshot(workspace, {"literature.md": None})
91
+ for _ in range(2):
92
+ d = _run(monkeypatch,
93
+ _stop_payload(str(fixtures_dir / "transcript_copilot_literature.jsonl")),
94
+ workspace)
95
+ assert d.get("decision") == "block"
96
+ assert lib.counter_get(workspace, "copilot-literature", "literature.md") == 2
97
+
98
+ def test_strike_3_releases(self, monkeypatch, workspace, fixtures_dir):
99
+ lib.write_snapshot(workspace, {"literature.md": None})
100
+ for _ in range(2):
101
+ _run(monkeypatch,
102
+ _stop_payload(str(fixtures_dir / "transcript_copilot_literature.jsonl")),
103
+ workspace)
104
+ d = _run(monkeypatch,
105
+ _stop_payload(str(fixtures_dir / "transcript_copilot_literature.jsonl")),
106
+ workspace)
107
+ assert d["hookSpecificOutput"]["permissionDecision"] == "allow"
108
+ assert lib.counter_get(workspace, "copilot-literature", "literature.md") == 0
109
+ log = (workspace / ".copilot" / "__violations.log").read_text(encoding="utf-8")
110
+ assert "RELEASE" in log
111
+
112
+ def test_pass_resets_counter(self, monkeypatch, workspace, fixtures_dir, handoff_writer):
113
+ lib.write_snapshot(workspace, {"literature.md": None})
114
+ _run(monkeypatch,
115
+ _stop_payload(str(fixtures_dir / "transcript_copilot_literature.jsonl")),
116
+ workspace)
117
+ assert lib.counter_get(workspace, "copilot-literature", "literature.md") == 1
118
+ f = workspace / ".copilot" / "literature.md"
119
+ handoff_writer(f, last_updated="2026-05-24T10:00:00Z")
120
+ lib.write_snapshot(workspace, {"literature.md": "2026-05-23T00:00:00Z"})
121
+ d = _run(monkeypatch,
122
+ _stop_payload(str(fixtures_dir / "transcript_copilot_literature.jsonl")),
123
+ workspace)
124
+ assert d["hookSpecificOutput"]["permissionDecision"] == "allow"
125
+ assert lib.counter_get(workspace, "copilot-literature", "literature.md") == 0
126
+
127
+
128
+ class TestFirstBoot:
129
+ def test_no_snapshot_fresh_handoff_passes(self, monkeypatch, workspace,
130
+ fixtures_dir, handoff_writer):
131
+ s = workspace / ".copilot" / ".session_snapshot.json"
132
+ if s.exists():
133
+ s.unlink()
134
+ f = workspace / ".copilot" / "literature.md"
135
+ handoff_writer(f, last_updated="2026-05-24T10:00:00Z")
136
+ d = _run(monkeypatch,
137
+ _stop_payload(str(fixtures_dir / "transcript_copilot_literature.jsonl")),
138
+ workspace)
139
+ assert d["hookSpecificOutput"]["permissionDecision"] == "allow"
140
+
141
+ def test_no_snapshot_no_handoff_soft_not_hard(self, monkeypatch, workspace, fixtures_dir):
142
+ d = _run(monkeypatch,
143
+ _stop_payload(str(fixtures_dir / "transcript_copilot_literature.jsonl")),
144
+ workspace)
145
+ assert d["hookSpecificOutput"]["permissionDecision"] == "allow"
146
+ log = (workspace / ".copilot" / "__violations.log").read_text(encoding="utf-8")
147
+ assert "NO-SNAPSHOT" in log
148
+
149
+
150
+ class TestSoftWarns:
151
+ def _setup_pass(self, workspace, handoff_writer, fname):
152
+ handoff_writer(workspace / ".copilot" / fname, last_updated="2026-05-24T10:00:00Z")
153
+ lib.write_snapshot(workspace, {fname: "2026-05-23T00:00:00Z"})
154
+
155
+ def test_malformed_state_output_warned(self, monkeypatch, workspace,
156
+ fixtures_dir, handoff_writer):
157
+ self._setup_pass(workspace, handoff_writer, "literature.md")
158
+ d = _run(monkeypatch,
159
+ _stop_payload(str(fixtures_dir / "transcript_malformed_state_output.jsonl")),
160
+ workspace)
161
+ assert d["hookSpecificOutput"]["permissionDecision"] == "allow"
162
+ log = (workspace / ".copilot" / "__violations.log").read_text(encoding="utf-8")
163
+ assert "SOFT" in log and "WARN" in log
164
+ assert "STATE_OUTPUT" in log
165
+
166
+ def test_illegal_state_jump_warned(self, monkeypatch, workspace,
167
+ fixtures_dir, handoff_writer):
168
+ self._setup_pass(workspace, handoff_writer, "experiments.md")
169
+ d = _run(monkeypatch,
170
+ _stop_payload(str(fixtures_dir / "transcript_copilot_experiment_state_jump.jsonl")),
171
+ workspace)
172
+ assert d["hookSpecificOutput"]["permissionDecision"] == "allow"
173
+ log = (workspace / ".copilot" / "__violations.log").read_text(encoding="utf-8")
174
+ assert "SOFT" in log
175
+ assert "UNINITIALIZED" in log or "transition" in log.lower()
176
+
177
+ def test_clean_run_no_soft_warns(self, monkeypatch, workspace,
178
+ fixtures_dir, handoff_writer):
179
+ self._setup_pass(workspace, handoff_writer, "experiments.md")
180
+ d = _run(monkeypatch,
181
+ _stop_payload(str(fixtures_dir / "transcript_copilot_experiment_complete.jsonl")),
182
+ workspace)
183
+ assert d["hookSpecificOutput"]["permissionDecision"] == "allow"
184
+ log_path = workspace / ".copilot" / "__violations.log"
185
+ if log_path.exists():
186
+ assert "SOFT" not in log_path.read_text(encoding="utf-8")