@ccoalm/ccl-skills 0.18.1 → 0.18.3
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/README.md +2 -0
- package/dist/assets/marketplace/plugins/ccl-skills/agent-context/session-policy.md +50 -0
- package/dist/assets/marketplace/plugins/ccl-skills/agent-context/session-start.md +30 -40
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/guard-delegation-owner.sh +9 -122
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/guard-edit-isolation.sh +43 -9
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/guard-merge-authorization.sh +95 -2
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/hooks.json +42 -1
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/host-input.py +560 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/merge-authorization-prompt.sh +103 -26
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/owner-dispatch-guard.sh +17 -8
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/proposed-next-stop.sh +14 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/session-start.sh +31 -2
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/skill-context-compact.sh +10 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/skill-extraction-gate-stop.sh +17 -4
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/skill-loading.py +277 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/task-entry.sh +46 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_guard_delegation_owner.sh +61 -55
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_guard_merge_authorization.sh +140 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_host_input.py +630 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_merge_authorization_prompt.sh +56 -3
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_proposed_next.py +292 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_session_start.sh +85 -3
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_skill_loading.py +278 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_task_entry.py +103 -0
- package/dist/assets/marketplace/plugins/ccl-skills/packages/opencode-plugin/ccl-skills.ts +36 -6
- package/dist/assets/marketplace/plugins/ccl-skills/scripts/owner-dispatch/README.md +23 -15
- package/dist/assets/marketplace/plugins/ccl-skills/scripts/owner-dispatch/owner-dispatch.sh +41 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/SKILL.md +5 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/staged-review-contract.md +12 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/review_gate.py +26 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/pre-final-continuation-gate.md +9 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +11 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/extraction_review_gate.sh +2 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_skill_catalog.sh +5 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_extraction_review_gate.sh +59 -5
- package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/SKILL.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/references/hook-authorization.md +13 -0
- package/dist/assets/release.json +95 -30
- package/dist/codex-hooks.d.ts +15 -0
- package/dist/codex-hooks.js +186 -0
- package/dist/opencode-adapter.js +8 -3
- package/dist/operations.js +13 -9
- package/package.json +1 -1
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Host boundary regressions; all repositories and transcripts are synthetic."""
|
|
3
|
+
import importlib.util
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
import unittest
|
|
11
|
+
from unittest.mock import patch
|
|
12
|
+
|
|
13
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HostInputTests(unittest.TestCase):
|
|
17
|
+
def setUp(self):
|
|
18
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
19
|
+
self.addCleanup(self.tmp.cleanup)
|
|
20
|
+
self.root = Path(self.tmp.name).resolve()
|
|
21
|
+
self.env = dict(os.environ, TMPDIR=str(self.root), GIT_CONFIG_GLOBAL='/dev/null',
|
|
22
|
+
GIT_CONFIG_SYSTEM='/dev/null')
|
|
23
|
+
for key in ('GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR'):
|
|
24
|
+
self.env.pop(key, None)
|
|
25
|
+
self.repo = self.root / 'primary'
|
|
26
|
+
self.git('init', '-q', '-b', 'main', str(self.repo), cwd=self.root)
|
|
27
|
+
(self.repo / '.worktree-only').touch()
|
|
28
|
+
(self.repo / 'src').mkdir()
|
|
29
|
+
(self.repo / 'src/file.py').write_text('before\n')
|
|
30
|
+
(self.repo / '.owner-dispatch.json').write_text(json.dumps({
|
|
31
|
+
'enabled': True, 'strict': True, 'product_globs': ['src/**']}))
|
|
32
|
+
self.git('add', '.')
|
|
33
|
+
self.git('-c', 'user.email=test@example.invalid', '-c', 'user.name=Test',
|
|
34
|
+
'commit', '-qm', 'fixture')
|
|
35
|
+
self.wt = self.root / 'linked'
|
|
36
|
+
self.git('worktree', 'add', '-q', '-b', 'feature', str(self.wt))
|
|
37
|
+
|
|
38
|
+
def git(self, *args, cwd=None):
|
|
39
|
+
return subprocess.run(['git', *args], cwd=cwd or self.repo, env=self.env,
|
|
40
|
+
check=True, capture_output=True)
|
|
41
|
+
|
|
42
|
+
def run_hook(self, name, payload, *args):
|
|
43
|
+
result = subprocess.run(['bash', str(ROOT / name), *args], input=json.dumps(payload),
|
|
44
|
+
text=True, capture_output=True, cwd=self.root, env=self.env)
|
|
45
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
46
|
+
self.assertEqual(result.stderr, '')
|
|
47
|
+
return json.loads(result.stdout) if result.stdout else {}
|
|
48
|
+
|
|
49
|
+
def patch(self, text, cwd=None):
|
|
50
|
+
return {'tool_name': 'apply_patch', 'tool_input': {'command': text},
|
|
51
|
+
'cwd': str(cwd or self.repo), 'session_id': 'synthetic'}
|
|
52
|
+
|
|
53
|
+
def decision(self, result):
|
|
54
|
+
return result.get('hookSpecificOutput', {}).get('permissionDecision')
|
|
55
|
+
|
|
56
|
+
def test_patch_targets_receive_isolation_policy(self):
|
|
57
|
+
cases = [
|
|
58
|
+
('add', '*** Add File: new/file.py\n+new', self.repo, 'deny'),
|
|
59
|
+
('delete', '*** Delete File: src/file.py', self.repo, 'deny'),
|
|
60
|
+
('update', '*** Update File: src/file.py\n@@\n-before\n+after', self.repo, 'deny'),
|
|
61
|
+
('linked', '*** Add File: new.py\n+new', self.wt, None),
|
|
62
|
+
('multi', f'*** Add File: safe.py\n+new\n*** Delete File: {self.repo}/src/file.py', self.wt, 'deny'),
|
|
63
|
+
('move-destination', f'*** Update File: src/file.py\n*** Move to: {self.repo}/new.py\n@@\n-before\n+after', self.wt, 'deny'),
|
|
64
|
+
]
|
|
65
|
+
for label, body, cwd, expected in cases:
|
|
66
|
+
with self.subTest(label=label):
|
|
67
|
+
value = self.run_hook('hooks/guard-edit-isolation.sh', self.patch(
|
|
68
|
+
'*** Begin Patch\n' + body + '\n*** End Patch', cwd))
|
|
69
|
+
self.assertEqual(self.decision(value), expected)
|
|
70
|
+
|
|
71
|
+
def test_unparseable_patch_denies_and_patch_text_is_never_executed(self):
|
|
72
|
+
for patch in ('not a patch', '*** Begin Patch\n*** Add File: \n+x\n*** End Patch',
|
|
73
|
+
'*** Begin Patch\n*** Delete File: src/file.py'):
|
|
74
|
+
with self.subTest(patch=patch):
|
|
75
|
+
self.assertEqual(self.decision(self.run_hook('hooks/guard-edit-isolation.sh',
|
|
76
|
+
self.patch(patch))), 'deny')
|
|
77
|
+
marker = self.root / 'executed'
|
|
78
|
+
body = f'*** Begin Patch\n*** Add File: $(touch {marker})\n+x\n*** End Patch'
|
|
79
|
+
self.run_hook('hooks/guard-edit-isolation.sh', self.patch(body))
|
|
80
|
+
self.assertFalse(marker.exists())
|
|
81
|
+
|
|
82
|
+
def test_owner_dispatch_checks_later_patch_targets(self):
|
|
83
|
+
payload = self.patch('*** Begin Patch\n*** Add File: README.md\n+x\n'
|
|
84
|
+
'*** Delete File: src/file.py\n*** End Patch')
|
|
85
|
+
self.assertEqual(self.decision(self.run_hook('scripts/owner-dispatch/owner-dispatch.sh',
|
|
86
|
+
payload, 'pretool')), 'deny')
|
|
87
|
+
self.git('config', 'user.email', 'test@example.invalid')
|
|
88
|
+
self.git('config', 'user.name', 'Test')
|
|
89
|
+
subprocess.run(['bash', str(ROOT / 'scripts/owner-dispatch/owner-dispatch.sh'),
|
|
90
|
+
'record', '--owners', 'testing-strategy'], cwd=self.repo,
|
|
91
|
+
env=self.env, check=True, capture_output=True)
|
|
92
|
+
self.assertEqual(self.run_hook('scripts/owner-dispatch/owner-dispatch.sh', payload, 'pretool'), {})
|
|
93
|
+
|
|
94
|
+
def transcript(self, events):
|
|
95
|
+
path = self.root / 'transcript.jsonl'
|
|
96
|
+
path.write_text(''.join(json.dumps(event) + '\n' for event in events))
|
|
97
|
+
return str(path)
|
|
98
|
+
|
|
99
|
+
def call(self, name, args, call_id='load'):
|
|
100
|
+
return {'type': 'response_item', 'payload': {'type': 'function_call',
|
|
101
|
+
'name': name, 'arguments': json.dumps(args), 'call_id': call_id}}
|
|
102
|
+
|
|
103
|
+
def output(self, output, call_id='load'):
|
|
104
|
+
return {'type': 'response_item', 'payload': {'type': 'function_call_output',
|
|
105
|
+
'call_id': call_id, 'output': output}}
|
|
106
|
+
|
|
107
|
+
def runtime_for_skill(self, skill):
|
|
108
|
+
# A synthetic canonical source belongs to an isolated helper/runtime,
|
|
109
|
+
# never to an arbitrary same-named file in the tested project.
|
|
110
|
+
runtime = self.root / 'runtime'
|
|
111
|
+
shutil.copytree(ROOT / 'hooks', runtime / 'hooks')
|
|
112
|
+
shutil.copytree(ROOT / 'scripts/owner-dispatch', runtime / 'scripts/owner-dispatch')
|
|
113
|
+
shutil.copytree(ROOT / 'agent-context', runtime / 'agent-context')
|
|
114
|
+
canonical = runtime / 'skills' / skill.parent.name / 'SKILL.md'
|
|
115
|
+
canonical.parent.mkdir(parents=True)
|
|
116
|
+
canonical.write_bytes(skill.read_bytes())
|
|
117
|
+
return runtime
|
|
118
|
+
|
|
119
|
+
def test_codex_dispatch_replans_once_and_completed_read_satisfies_it(self):
|
|
120
|
+
skill = self.repo / 'skills/multi-agent-delegation/SKILL.md'
|
|
121
|
+
skill.parent.mkdir(parents=True)
|
|
122
|
+
skill.write_text('---\nname: multi-agent-delegation\n---\n# Loaded owner\n')
|
|
123
|
+
runtime = self.runtime_for_skill(skill)
|
|
124
|
+
request = self.call('exec_command', {'cmd': f'cat {skill}'})
|
|
125
|
+
for label, events, expect_context in [
|
|
126
|
+
('cold', [], True), ('pending', [request], True),
|
|
127
|
+
('failed', [request, self.output('Process exited with code 1\nOutput:\nfailed')], True),
|
|
128
|
+
('mismatch', [request, self.output('Process exited with code 0\nOutput:\n' + skill.read_text(), 'other')], True),
|
|
129
|
+
('empty', [request, self.output('Process exited with code 0\nOutput:\n')], True),
|
|
130
|
+
('unrelated-output', [request, self.output('Process exited with code 0\nOutput:\nhello')], True),
|
|
131
|
+
('truncated', [request, self.output('Process exited with code 0\nOutput:\nWarning: truncated output\n' + skill.read_text())], True),
|
|
132
|
+
('completed', [request, self.output('Process exited with code 0\nOutput:\n' + skill.read_text())], False),
|
|
133
|
+
]:
|
|
134
|
+
with self.subTest(label=label):
|
|
135
|
+
payload = {'hook_event_name': 'PreToolUse', 'tool_name': 'spawn_agent', 'session_id': label,
|
|
136
|
+
'transcript_path': self.transcript(events)}
|
|
137
|
+
value = self.run_hook(runtime / 'hooks/guard-delegation-owner.sh', payload)
|
|
138
|
+
specific = value.get('hookSpecificOutput', {})
|
|
139
|
+
self.assertEqual(specific.get('permissionDecision'), 'deny' if expect_context else None)
|
|
140
|
+
self.assertEqual(self.run_hook(runtime / 'hooks/guard-delegation-owner.sh', payload), {})
|
|
141
|
+
|
|
142
|
+
def test_codex_patch_transcript_triggers_extraction_backstop(self):
|
|
143
|
+
marker = self.repo / 'skills/skill-extraction-workflow/SKILL.md'
|
|
144
|
+
marker.parent.mkdir(parents=True)
|
|
145
|
+
marker.write_text('---\nname: skill-extraction-workflow\n---\n# Extraction\n')
|
|
146
|
+
patch = f'*** Begin Patch\n*** Add File: {self.repo}/hooks/new.sh\n+echo x\n*** End Patch'
|
|
147
|
+
event = {'type': 'response_item', 'payload': {'type': 'custom_tool_call',
|
|
148
|
+
'name': 'apply_patch', 'call_id': 'patch', 'input': patch}}
|
|
149
|
+
value = self.run_hook('hooks/skill-extraction-gate-stop.sh', {
|
|
150
|
+
'session_id': 'stop-patch', 'cwd': str(self.repo),
|
|
151
|
+
'transcript_path': self.transcript([event])})
|
|
152
|
+
self.assertEqual(value.get('decision'), 'block')
|
|
153
|
+
|
|
154
|
+
def test_owner_dispatch_codex_completed_read_evidence(self):
|
|
155
|
+
skill = self.repo / 'skills/testing-strategy/SKILL.md'
|
|
156
|
+
skill.parent.mkdir(parents=True)
|
|
157
|
+
skill.write_text('---\nname: testing-strategy\n---\n# Owner\n')
|
|
158
|
+
runtime = self.runtime_for_skill(skill)
|
|
159
|
+
request = self.call('exec_command', {'cmd': f'cat {skill}'})
|
|
160
|
+
for completed, expected in ((False, 3), (True, 0)):
|
|
161
|
+
events = [request]
|
|
162
|
+
if completed:
|
|
163
|
+
events.append(self.output('Process exited with code 0\nOutput:\n' + skill.read_text()))
|
|
164
|
+
path = self.transcript(events)
|
|
165
|
+
result = subprocess.run(['bash', str(runtime / 'scripts/owner-dispatch/owner-dispatch.sh'),
|
|
166
|
+
'verify-invocations', '--owners', 'testing-strategy',
|
|
167
|
+
'--transcript', path, '--empty-is-missing'],
|
|
168
|
+
env=self.env, capture_output=True, text=True)
|
|
169
|
+
self.assertEqual(result.returncode, expected, result.stdout + result.stderr)
|
|
170
|
+
|
|
171
|
+
def test_owner_patch_move_destination_and_advisory_output(self):
|
|
172
|
+
patch = self.patch('*** Begin Patch\n*** Update File: README.md\n'
|
|
173
|
+
'*** Move to: src/new.py\n@@\n-before\n+after\n*** End Patch')
|
|
174
|
+
self.assertEqual(self.decision(self.run_hook('scripts/owner-dispatch/owner-dispatch.sh', patch, 'pretool')), 'deny')
|
|
175
|
+
config = self.repo / '.owner-dispatch.json'
|
|
176
|
+
value = json.loads(config.read_text())
|
|
177
|
+
value['strict'] = False
|
|
178
|
+
config.write_text(json.dumps(value))
|
|
179
|
+
result = self.run_hook('scripts/owner-dispatch/owner-dispatch.sh', patch, 'pretool')
|
|
180
|
+
self.assertNotIn('permissionDecision', result['hookSpecificOutput'])
|
|
181
|
+
self.assertIn('additionalContext', result['hookSpecificOutput'])
|
|
182
|
+
|
|
183
|
+
def test_owner_patch_deny_wins_over_an_earlier_advisory(self):
|
|
184
|
+
config = self.wt / '.owner-dispatch.json'
|
|
185
|
+
value = json.loads(config.read_text())
|
|
186
|
+
value['strict'] = False
|
|
187
|
+
config.write_text(json.dumps(value))
|
|
188
|
+
soft = f'*** Add File: {self.wt}/src/soft.py\n+new'
|
|
189
|
+
hard = f'*** Delete File: {self.repo}/src/file.py'
|
|
190
|
+
advisory = self.run_hook('scripts/owner-dispatch/owner-dispatch.sh', self.patch(
|
|
191
|
+
f'*** Begin Patch\n{soft}\n*** End Patch'), 'pretool')
|
|
192
|
+
self.assertIn('additionalContext', advisory['hookSpecificOutput'])
|
|
193
|
+
self.assertEqual(self.decision(self.run_hook('scripts/owner-dispatch/owner-dispatch.sh',
|
|
194
|
+
self.patch(f'*** Begin Patch\n{hard}\n*** End Patch'), 'pretool')), 'deny')
|
|
195
|
+
for first, second in ((soft, hard), (hard, soft)):
|
|
196
|
+
with self.subTest(first=first):
|
|
197
|
+
result = self.run_hook('scripts/owner-dispatch/owner-dispatch.sh', self.patch(
|
|
198
|
+
f'*** Begin Patch\n{first}\n{second}\n*** End Patch'), 'pretool')
|
|
199
|
+
self.assertEqual(self.decision(result), 'deny')
|
|
200
|
+
|
|
201
|
+
def test_claude_isolation_survives_missing_python_or_helper(self):
|
|
202
|
+
isolated = self.root / 'isolated'
|
|
203
|
+
isolated.mkdir()
|
|
204
|
+
copy = isolated / 'guard-edit-isolation.sh'
|
|
205
|
+
copy.write_text((ROOT / 'hooks/guard-edit-isolation.sh').read_text())
|
|
206
|
+
commands = self.root / 'no-python'
|
|
207
|
+
commands.mkdir()
|
|
208
|
+
for command in ('bash', 'cat', 'dirname', 'jq', 'git', 'awk', 'realpath'):
|
|
209
|
+
(commands / command).symlink_to(shutil.which(command))
|
|
210
|
+
original_path = self.env['PATH']
|
|
211
|
+
for mode in ('missing-helper', 'missing-python'):
|
|
212
|
+
hook = str(copy) if mode == 'missing-helper' else 'hooks/guard-edit-isolation.sh'
|
|
213
|
+
self.env['PATH'] = original_path if mode == 'missing-helper' else str(commands)
|
|
214
|
+
try:
|
|
215
|
+
for tool, field in (('Edit', 'file_path'), ('Write', 'file_path'),
|
|
216
|
+
('MultiEdit', 'file_path'), ('NotebookEdit', 'notebook_path')):
|
|
217
|
+
for cwd, expected in ((self.repo, 'deny'), (self.wt, None)):
|
|
218
|
+
for path in (str(cwd / 'src/file.py'), 'src/file.py'):
|
|
219
|
+
with self.subTest(mode=mode, tool=tool, cwd=cwd, path=path):
|
|
220
|
+
payload = {'tool_name': tool, 'cwd': str(cwd),
|
|
221
|
+
'tool_input': {field: path}}
|
|
222
|
+
value = self.run_hook(hook, payload)
|
|
223
|
+
self.assertEqual(self.decision(value), expected)
|
|
224
|
+
if expected is None:
|
|
225
|
+
self.assertEqual(value, {})
|
|
226
|
+
finally:
|
|
227
|
+
self.env['PATH'] = original_path
|
|
228
|
+
|
|
229
|
+
def test_completed_extraction_read_allows_and_prose_does_not(self):
|
|
230
|
+
skill = self.repo / 'skills/skill-extraction-workflow/SKILL.md'
|
|
231
|
+
skill.parent.mkdir(parents=True)
|
|
232
|
+
skill.write_text('---\nname: skill-extraction-workflow\n---\n# Owner\n')
|
|
233
|
+
runtime = self.runtime_for_skill(skill)
|
|
234
|
+
edit = self.call('apply_patch', {'command': '*** Begin Patch\n*** Add File: hooks/new.sh\n+x\n*** End Patch'}, 'patch')
|
|
235
|
+
request = self.call('exec_command', {'cmd': f'cat {skill}'})
|
|
236
|
+
for name, extra, expected in [
|
|
237
|
+
('prose', [{'type': 'response_item', 'payload': {'type': 'message', 'role': 'assistant', 'content': [
|
|
238
|
+
{'type': 'output_text', 'text': 'Launching skill: ccl-skills:skill-extraction-workflow'}]}}], 'block'),
|
|
239
|
+
('pending', [request], 'block'),
|
|
240
|
+
('loaded', [request, self.output('Process exited with code 0\nOutput:\n' + skill.read_text())], None),
|
|
241
|
+
]:
|
|
242
|
+
with self.subTest(name=name):
|
|
243
|
+
result = self.run_hook(runtime / 'hooks/skill-extraction-gate-stop.sh', {
|
|
244
|
+
'session_id': name, 'cwd': str(self.repo),
|
|
245
|
+
'transcript_path': self.transcript([edit, *extra])})
|
|
246
|
+
self.assertEqual(result.get('decision'), expected)
|
|
247
|
+
|
|
248
|
+
def test_relative_symlink_patch_target_and_missing_normalizer(self):
|
|
249
|
+
for link_name in ('protected', 'protected\u0085target'):
|
|
250
|
+
link = self.wt / link_name
|
|
251
|
+
link.symlink_to(self.repo, target_is_directory=True)
|
|
252
|
+
patch = self.patch(f'*** Begin Patch\n*** Add File: {link_name}/new/file.py\n+x\n*** End Patch', self.wt)
|
|
253
|
+
self.assertEqual(self.decision(self.run_hook('hooks/guard-edit-isolation.sh', patch)), 'deny')
|
|
254
|
+
isolated_hooks = self.root / 'isolated-hooks'
|
|
255
|
+
isolated_hooks.mkdir()
|
|
256
|
+
for name in ('guard-edit-isolation.sh', 'guard-delegation-owner.sh', 'skill-extraction-gate-stop.sh'):
|
|
257
|
+
(isolated_hooks / name).write_text((ROOT / 'hooks' / name).read_text())
|
|
258
|
+
self.assertEqual(self.decision(self.run_hook(str(isolated_hooks / 'guard-edit-isolation.sh'), patch)), 'deny')
|
|
259
|
+
payload = {'tool_name': 'spawn_agent', 'session_id': 'missing',
|
|
260
|
+
'transcript_path': self.transcript([])}
|
|
261
|
+
for name in ('guard-delegation-owner.sh', 'skill-extraction-gate-stop.sh'):
|
|
262
|
+
result = self.run_hook(str(isolated_hooks / name), payload)
|
|
263
|
+
self.assertIn('unavailable', result['systemMessage'])
|
|
264
|
+
|
|
265
|
+
def test_claude_extraction_invocation_semantics_are_preserved(self):
|
|
266
|
+
skill = self.repo / 'skills/skill-extraction-workflow/SKILL.md'
|
|
267
|
+
skill.parent.mkdir(parents=True)
|
|
268
|
+
skill.write_text('# Owner\n')
|
|
269
|
+
edit = {'type': 'assistant', 'message': {'content': [{'type': 'tool_use', 'name': 'Edit',
|
|
270
|
+
'input': {'file_path': str(self.repo / 'hooks/new.sh')}}]}}
|
|
271
|
+
invoke = {'type': 'assistant', 'message': {'content': [{'type': 'tool_use', 'name': 'Skill',
|
|
272
|
+
'input': {'skill': 'ccl-skills:skill-extraction-workflow'}}]}}
|
|
273
|
+
for label, events, expected in [('missing', [edit], 'block'), ('invoked', [invoke, edit], None)]:
|
|
274
|
+
result = self.run_hook('hooks/skill-extraction-gate-stop.sh', {
|
|
275
|
+
'session_id': label, 'transcript_path': self.transcript(events)})
|
|
276
|
+
self.assertEqual(result.get('decision'), expected)
|
|
277
|
+
|
|
278
|
+
def test_truncated_transcripts_never_supply_partial_verification(self):
|
|
279
|
+
marker = self.repo / 'skills/skill-extraction-workflow/SKILL.md'
|
|
280
|
+
marker.parent.mkdir(parents=True)
|
|
281
|
+
marker.write_text('# Synthetic extraction owner\n')
|
|
282
|
+
leading = [
|
|
283
|
+
{'type': 'assistant', 'message': {'content': [{'type': 'tool_use', 'id': 'owner',
|
|
284
|
+
'name': 'Skill', 'input': {'skill': 'ccl-skills:product-rd-workflow'}}]}},
|
|
285
|
+
{'type': 'user', 'message': {'content': [{'type': 'tool_result',
|
|
286
|
+
'tool_use_id': 'owner', 'content': 'Loaded'}]}}]
|
|
287
|
+
edit = {'type': 'assistant', 'message': {'content': [{'type': 'tool_use', 'name': 'Edit',
|
|
288
|
+
'input': {'file_path': str(self.repo / 'hooks/late.sh')}}]}}
|
|
289
|
+
for label, padding in (
|
|
290
|
+
('events', [{'type': 'ignored'}] * 20000),
|
|
291
|
+
('line', [{'type': 'ignored', 'text': 'x' * (1024 * 1024)}]),
|
|
292
|
+
('bytes', [{'type': 'ignored', 'text': 'x' * 900000}] * 20)):
|
|
293
|
+
path = self.transcript(leading + padding + [edit])
|
|
294
|
+
with self.subTest(limit=label, consumer='helper'):
|
|
295
|
+
result = subprocess.run(['python3', str(ROOT / 'hooks/host-input.py'),
|
|
296
|
+
'transcript', path, str(self.repo)],
|
|
297
|
+
capture_output=True, text=True)
|
|
298
|
+
self.assertNotEqual(result.returncode, 0)
|
|
299
|
+
summary = json.loads(result.stdout)
|
|
300
|
+
self.assertFalse(summary['verifiable'])
|
|
301
|
+
self.assertTrue(summary['truncated'])
|
|
302
|
+
self.assertEqual(summary['completed_skills'], [])
|
|
303
|
+
self.assertEqual(summary['edit_paths'], [])
|
|
304
|
+
for hook in ('skill-extraction-gate-stop.sh', 'proposed-next-stop.sh'):
|
|
305
|
+
with self.subTest(limit=label, consumer=hook):
|
|
306
|
+
result = self.run_hook('hooks/' + hook, {
|
|
307
|
+
'session_id': 'limit-' + label, 'cwd': str(self.repo),
|
|
308
|
+
'transcript_path': path, 'hook_event_name': 'Stop',
|
|
309
|
+
'stop_hook_active': False, 'last_assistant_message': 'Synthetic private status.'})
|
|
310
|
+
if hook == 'skill-extraction-gate-stop.sh':
|
|
311
|
+
message = result.get('systemMessage', '')
|
|
312
|
+
self.assertIn('history is too large', message)
|
|
313
|
+
self.assertIn('check is incomplete', message)
|
|
314
|
+
self.assertIn('does not block your task', message)
|
|
315
|
+
self.assertNotIn('backstop', message)
|
|
316
|
+
else:
|
|
317
|
+
self.assertIn('unverified', result.get('systemMessage', '').lower())
|
|
318
|
+
self.assertNotIn('decision', result)
|
|
319
|
+
self.assertNotIn('Synthetic private status', json.dumps(result))
|
|
320
|
+
|
|
321
|
+
def test_extraction_helper_failures_explain_task_impact_without_private_details(self):
|
|
322
|
+
runtime = self.root / 'runtime'
|
|
323
|
+
runtime.mkdir()
|
|
324
|
+
hook = runtime / 'skill-extraction-gate-stop.sh'
|
|
325
|
+
shutil.copy2(ROOT / 'hooks/skill-extraction-gate-stop.sh', hook)
|
|
326
|
+
helper = runtime / 'host-input.py'
|
|
327
|
+
payload = {'session_id': 'helper-failure', 'cwd': str(self.repo),
|
|
328
|
+
'transcript_path': self.transcript([])}
|
|
329
|
+
for label, body, explanation in (
|
|
330
|
+
('read-failure', 'raise OSError("synthetic-private-detail")\n',
|
|
331
|
+
'conversation history could not be read'),
|
|
332
|
+
('missing-helper', None, 'local helper is unavailable')):
|
|
333
|
+
with self.subTest(label=label):
|
|
334
|
+
if body is None:
|
|
335
|
+
helper.unlink(missing_ok=True)
|
|
336
|
+
else:
|
|
337
|
+
helper.write_text(body)
|
|
338
|
+
result = self.run_hook(hook, payload)
|
|
339
|
+
message = result.get('systemMessage', '')
|
|
340
|
+
self.assertIn(explanation, message)
|
|
341
|
+
self.assertIn('does not block your task', message)
|
|
342
|
+
self.assertNotIn('history is too large', message)
|
|
343
|
+
self.assertNotIn('synthetic-private-detail', message)
|
|
344
|
+
self.assertNotIn(str(self.root), message)
|
|
345
|
+
self.assertNotIn('decision', result)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class ContextTranscriptTests(unittest.TestCase):
|
|
349
|
+
def setUp(self):
|
|
350
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
351
|
+
self.addCleanup(self.tmp.cleanup)
|
|
352
|
+
self.root = Path(self.tmp.name)
|
|
353
|
+
module_path = self.root / 'hooks/host-input.py'
|
|
354
|
+
module_path.parent.mkdir()
|
|
355
|
+
module_path.write_bytes((ROOT / 'hooks/host-input.py').read_bytes())
|
|
356
|
+
spec = importlib.util.spec_from_file_location('host_input', module_path)
|
|
357
|
+
self.helper = importlib.util.module_from_spec(spec)
|
|
358
|
+
spec.loader.exec_module(self.helper)
|
|
359
|
+
self.skill = self.root / 'skills/example-owner/SKILL.md'
|
|
360
|
+
self.skill.parent.mkdir(parents=True)
|
|
361
|
+
self.source = '---\nname: example-owner\ndescription: Synthetic owner\n---\n# Owner\n\nRead all rules.\nLast required rule.\n'
|
|
362
|
+
self.skill.write_text(self.source)
|
|
363
|
+
self.path = self.root / 'transcript.jsonl'
|
|
364
|
+
self.path.touch()
|
|
365
|
+
|
|
366
|
+
def append(self, *events):
|
|
367
|
+
with self.path.open('a') as stream:
|
|
368
|
+
for event in events:
|
|
369
|
+
stream.write(json.dumps(event) + '\n')
|
|
370
|
+
|
|
371
|
+
def call(self, command=None, call_id='read'):
|
|
372
|
+
return {'type': 'response_item', 'payload': {'type': 'function_call',
|
|
373
|
+
'name': 'exec_command', 'call_id': call_id,
|
|
374
|
+
'arguments': json.dumps({'cmd': command or f'cat {self.skill}'})}}
|
|
375
|
+
|
|
376
|
+
def output(self, body=None, call_id='read', code=0):
|
|
377
|
+
return {'type': 'response_item', 'payload': {'type': 'function_call_output',
|
|
378
|
+
'call_id': call_id, 'output': f'Process exited with code {code}\nOutput:\n' +
|
|
379
|
+
(self.source if body is None else body)}}
|
|
380
|
+
|
|
381
|
+
def claude(self, call_id='owner'):
|
|
382
|
+
return ({'type': 'assistant', 'message': {'content': [{'type': 'tool_use',
|
|
383
|
+
'id': call_id, 'name': 'Skill', 'input': {'skill': 'ccl-skills:example-owner'}}]}},
|
|
384
|
+
{'type': 'user', 'message': {'content': [{'type': 'tool_result',
|
|
385
|
+
'tool_use_id': call_id, 'content': 'Loaded'}]}})
|
|
386
|
+
|
|
387
|
+
def summary(self, offset=0):
|
|
388
|
+
return self.helper.context_transcript(str(self.path), str(self.root), offset)
|
|
389
|
+
|
|
390
|
+
def test_audit_requires_full_current_body_not_name_or_frontmatter(self):
|
|
391
|
+
for output in ('name: example-owner\n', ''.join(self.source.splitlines(True)[:4]), self.source):
|
|
392
|
+
with self.subTest(output=output):
|
|
393
|
+
self.path.write_text('')
|
|
394
|
+
self.append(self.call(), self.output(output))
|
|
395
|
+
summary = self.helper.transcript(str(self.path), str(self.root))
|
|
396
|
+
self.assertEqual(summary['completed_skills'],
|
|
397
|
+
['ccl-skills:example-owner'] if output == self.source else [])
|
|
398
|
+
|
|
399
|
+
def test_foreign_short_skill_cannot_claim_canonical_owner(self):
|
|
400
|
+
canonical = self.root / 'skills/multi-agent-delegation/SKILL.md'
|
|
401
|
+
canonical.parent.mkdir()
|
|
402
|
+
canonical.write_text(self.source.replace('example-owner', 'multi-agent-delegation'))
|
|
403
|
+
foreign = self.root / 'project/skills/multi-agent-delegation/SKILL.md'
|
|
404
|
+
foreign.parent.mkdir(parents=True)
|
|
405
|
+
foreign.write_text('---\nname: multi-agent-delegation\n---\n# loaded\n')
|
|
406
|
+
self.append(self.call('cat skills/multi-agent-delegation/SKILL.md'), self.output(foreign.read_text()))
|
|
407
|
+
project = str(foreign.parents[2])
|
|
408
|
+
with patch.dict(os.environ, {'CLAUDE_PLUGIN_ROOT': project}):
|
|
409
|
+
for summary in (self.helper.context_transcript(str(self.path), project),
|
|
410
|
+
self.helper.transcript(str(self.path), project)):
|
|
411
|
+
self.assertEqual(summary['completed_skills'], [])
|
|
412
|
+
self.assertTrue(summary['unverifiable_reads'])
|
|
413
|
+
|
|
414
|
+
def test_identical_copy_preserves_full_and_chunked_source_evidence(self):
|
|
415
|
+
copy = self.root / 'project/skills/example-owner/SKILL.md'
|
|
416
|
+
copy.parent.mkdir(parents=True)
|
|
417
|
+
copy.write_bytes(self.skill.read_bytes())
|
|
418
|
+
self.append(self.call(f'cat {copy}'), self.output())
|
|
419
|
+
self.assertEqual(self.summary()['completed_skills'], ['ccl-skills:example-owner'])
|
|
420
|
+
self.path.write_text('')
|
|
421
|
+
for first, last in ((1, 4), (5, 8)):
|
|
422
|
+
self.append(self.call(f"sed -n '{first},{last}p' {copy}", str(first)),
|
|
423
|
+
self.output(''.join(self.source.splitlines(True)[first - 1:last]), str(first)))
|
|
424
|
+
self.assertEqual(self.summary()['completed_skills'], ['ccl-skills:example-owner'])
|
|
425
|
+
self.skill.write_text(self.source + 'New canonical requirement.\n')
|
|
426
|
+
self.assertEqual(self.summary()['completed_skills'], [])
|
|
427
|
+
|
|
428
|
+
def test_missing_canonical_source_cannot_be_replaced_by_project_file(self):
|
|
429
|
+
foreign = self.root / 'project/skills/example-owner/SKILL.md'
|
|
430
|
+
foreign.parent.mkdir(parents=True)
|
|
431
|
+
foreign.write_bytes(self.skill.read_bytes())
|
|
432
|
+
self.skill.unlink()
|
|
433
|
+
self.append(self.call(f'cat {foreign}'), self.output())
|
|
434
|
+
self.assertEqual(self.summary()['completed_skills'], [])
|
|
435
|
+
|
|
436
|
+
def test_native_boundaries_reset_current_evidence_but_preserve_audit(self):
|
|
437
|
+
for boundary in ({'type': 'system', 'subtype': 'compact_boundary'},
|
|
438
|
+
{'type': 'compacted', 'payload': {'message': 'Synthetic summary'}}):
|
|
439
|
+
with self.subTest(boundary=boundary):
|
|
440
|
+
self.path.write_text('')
|
|
441
|
+
self.append(*self.claude())
|
|
442
|
+
before = self.summary()
|
|
443
|
+
self.append(boundary)
|
|
444
|
+
after = self.summary()
|
|
445
|
+
self.assertTrue(after['context_complete'])
|
|
446
|
+
self.assertTrue(after['verifiable'])
|
|
447
|
+
self.assertEqual(after['completed_skills'], [])
|
|
448
|
+
self.assertNotEqual(before['context_id'], after['context_id'])
|
|
449
|
+
self.assertEqual(self.helper.transcript(str(self.path), str(self.root))['completed_skills'],
|
|
450
|
+
['ccl-skills:example-owner'])
|
|
451
|
+
self.append(*self.claude('fresh'))
|
|
452
|
+
fresh = self.summary()
|
|
453
|
+
self.assertEqual(fresh['context_id'], after['context_id'])
|
|
454
|
+
self.assertEqual(fresh['completed_skills'], ['ccl-skills:example-owner'])
|
|
455
|
+
|
|
456
|
+
def test_pending_requests_never_pair_across_either_boundary(self):
|
|
457
|
+
for boundary in ({'type': 'system', 'subtype': 'compact_boundary'},
|
|
458
|
+
{'type': 'compacted', 'payload': {}}):
|
|
459
|
+
for request, result in (self.claude(), (self.call(), self.output())):
|
|
460
|
+
with self.subTest(boundary=boundary, host=request['type']):
|
|
461
|
+
self.path.write_text('')
|
|
462
|
+
self.append(request, boundary, result)
|
|
463
|
+
self.assertEqual(self.summary()['completed_skills'], [])
|
|
464
|
+
self.assertEqual(self.helper.transcript(str(self.path), str(self.root))['completed_skills'], [])
|
|
465
|
+
|
|
466
|
+
def test_only_native_top_level_boundary_resets_context(self):
|
|
467
|
+
self.append(*self.claude())
|
|
468
|
+
first = self.summary()
|
|
469
|
+
self.append({'type': 'system', 'subtype': 'compact_boundary', 'isSidechain': True},
|
|
470
|
+
{'type': 'assistant', 'message': {'content': [{'type': 'text',
|
|
471
|
+
'text': json.dumps({'type': 'compacted', 'payload': {}})}]}},
|
|
472
|
+
{'type': 'compacted', 'payload': 'unsupported'})
|
|
473
|
+
self.assertEqual(self.summary()['context_id'], first['context_id'])
|
|
474
|
+
self.assertEqual(self.summary()['completed_skills'], first['completed_skills'])
|
|
475
|
+
|
|
476
|
+
def test_current_full_read_and_disjoint_literal_sed_coverage(self):
|
|
477
|
+
chunks = [(5, 8), (1, 2), (3, 4)]
|
|
478
|
+
for index, (first, last) in enumerate(chunks):
|
|
479
|
+
self.append(self.call(f"sed -n '{first},{last}p' {self.skill}", str(index)),
|
|
480
|
+
self.output(''.join(self.source.splitlines(True)[first - 1:last]), str(index)))
|
|
481
|
+
self.assertEqual(self.summary()['completed_skills'],
|
|
482
|
+
['ccl-skills:example-owner'] if index == 2 else [])
|
|
483
|
+
self.assertEqual(self.helper.transcript(str(self.path), str(self.root))['completed_skills'],
|
|
484
|
+
['ccl-skills:example-owner'])
|
|
485
|
+
|
|
486
|
+
def test_sed_counts_lf_lines_and_preserves_unicode_body(self):
|
|
487
|
+
self.source = self.source.replace('Read all rules.', 'Read all\u2028rules.')
|
|
488
|
+
self.skill.write_text(self.source)
|
|
489
|
+
self.append(self.call(f"sed -n '1,8p' {self.skill}"), self.output())
|
|
490
|
+
self.assertEqual(self.summary()['completed_skills'], ['ccl-skills:example-owner'])
|
|
491
|
+
|
|
492
|
+
def test_literal_cat_multiple_files_and_relative_workdir(self):
|
|
493
|
+
second = self.root / 'skills/second-owner/SKILL.md'
|
|
494
|
+
second.parent.mkdir()
|
|
495
|
+
second_source = self.source.replace('example-owner', 'second-owner')
|
|
496
|
+
second.write_text(second_source)
|
|
497
|
+
request = self.call()
|
|
498
|
+
request['payload']['arguments'] = json.dumps({
|
|
499
|
+
'cmd': 'cat -- example-owner/SKILL.md second-owner/SKILL.md', 'workdir': 'skills'})
|
|
500
|
+
self.append(request, self.output(self.source + second_source))
|
|
501
|
+
self.assertEqual(self.summary()['completed_skills'],
|
|
502
|
+
['ccl-skills:example-owner', 'ccl-skills:second-owner'])
|
|
503
|
+
|
|
504
|
+
def test_failed_claude_completion_consumes_request(self):
|
|
505
|
+
request, result = self.claude()
|
|
506
|
+
failed = json.loads(json.dumps(result))
|
|
507
|
+
failed['message']['content'][0]['is_error'] = True
|
|
508
|
+
self.append(request, failed, result)
|
|
509
|
+
self.assertEqual(self.summary()['completed_skills'], [])
|
|
510
|
+
|
|
511
|
+
def test_oversized_current_record_is_unknown_but_older_damage_can_be_discarded(self):
|
|
512
|
+
self.append({'type': 'ignored', 'text': 'x' * (1024 * 1024)}, self.call(), self.output())
|
|
513
|
+
self.assertFalse(self.summary()['context_complete'])
|
|
514
|
+
self.assertEqual(self.summary()['completed_skills'], [])
|
|
515
|
+
self.append({'type': 'system', 'subtype': 'compact_boundary'}, *self.claude())
|
|
516
|
+
self.assertTrue(self.summary()['context_complete'])
|
|
517
|
+
self.assertEqual(self.summary()['completed_skills'], ['ccl-skills:example-owner'])
|
|
518
|
+
|
|
519
|
+
def test_partial_failed_truncated_and_mismatched_reads_never_complete(self):
|
|
520
|
+
for variant in ('partial', 'failed', 'truncated', 'mismatch', 'pending'):
|
|
521
|
+
with self.subTest(variant=variant):
|
|
522
|
+
self.path.write_text('')
|
|
523
|
+
self.append(self.call(f"sed -n '1,4p' {self.skill}", 'first'),
|
|
524
|
+
self.output(''.join(self.source.splitlines(True)[:4]), 'first'))
|
|
525
|
+
self.append(self.call(f"sed -n '5,8p' {self.skill}", 'second'))
|
|
526
|
+
body = ''.join(self.source.splitlines(True)[4:])
|
|
527
|
+
if variant == 'partial':
|
|
528
|
+
body = body.splitlines(True)[0]
|
|
529
|
+
elif variant == 'truncated':
|
|
530
|
+
body += 'Warning: truncated output'
|
|
531
|
+
elif variant == 'mismatch':
|
|
532
|
+
body = 'different body\n'
|
|
533
|
+
if variant != 'pending':
|
|
534
|
+
self.append(self.output(body, 'second', 1 if variant == 'failed' else 0))
|
|
535
|
+
self.assertEqual(self.summary()['completed_skills'], [])
|
|
536
|
+
|
|
537
|
+
def test_chunk_coverage_does_not_cross_compaction_or_file_revision(self):
|
|
538
|
+
self.append(self.call(f"sed -n '1,4p' {self.skill}", 'first'),
|
|
539
|
+
self.output(''.join(self.source.splitlines(True)[:4]), 'first'),
|
|
540
|
+
{'type': 'compacted', 'payload': {}},
|
|
541
|
+
self.call(f"sed -n '5,8p' {self.skill}", 'second'),
|
|
542
|
+
self.output(''.join(self.source.splitlines(True)[4:]), 'second'))
|
|
543
|
+
self.assertEqual(self.summary()['completed_skills'], [])
|
|
544
|
+
self.assertEqual(self.helper.transcript(str(self.path), str(self.root))['completed_skills'], [])
|
|
545
|
+
self.path.write_text('')
|
|
546
|
+
self.append(self.call(), self.output())
|
|
547
|
+
self.skill.write_text(self.source + 'New required rule.\n')
|
|
548
|
+
self.assertEqual(self.summary()['completed_skills'], [])
|
|
549
|
+
|
|
550
|
+
def test_compound_skill_reads_are_explicitly_unverifiable_and_never_executed(self):
|
|
551
|
+
marker = self.root / 'executed'
|
|
552
|
+
self.append(self.call(f'cat {self.skill}; touch {marker}'), self.output())
|
|
553
|
+
summary = self.summary()
|
|
554
|
+
self.assertEqual(summary['completed_skills'], [])
|
|
555
|
+
self.assertFalse(summary['verifiable'])
|
|
556
|
+
self.assertTrue(summary['unverifiable_reads'])
|
|
557
|
+
self.assertFalse(marker.exists())
|
|
558
|
+
self.append(self.call(call_id='repair'), self.output(call_id='repair'))
|
|
559
|
+
repaired = self.summary()
|
|
560
|
+
self.assertTrue(repaired['context_complete'])
|
|
561
|
+
self.assertTrue(repaired['unverifiable_reads'])
|
|
562
|
+
self.assertEqual(repaired['completed_skills'], ['ccl-skills:example-owner'])
|
|
563
|
+
|
|
564
|
+
def test_recent_native_context_survives_large_omitted_prefix(self):
|
|
565
|
+
for padding in ([{'type': 'ignored'}] * 20010,
|
|
566
|
+
[{'type': 'ignored', 'text': 'x' * 900000}] * 20):
|
|
567
|
+
with self.subTest(padding_count=len(padding)):
|
|
568
|
+
self.path.write_text('')
|
|
569
|
+
self.append(*padding, {'type': 'compacted', 'payload': {}}, self.call(), self.output())
|
|
570
|
+
summary = self.summary()
|
|
571
|
+
self.assertTrue(summary['context_complete'])
|
|
572
|
+
self.assertTrue(summary['verifiable'])
|
|
573
|
+
self.assertEqual(summary['completed_skills'], ['ccl-skills:example-owner'])
|
|
574
|
+
|
|
575
|
+
def test_truncated_unknown_prefix_or_malformed_current_record_is_not_verified(self):
|
|
576
|
+
self.append(*([{'type': 'ignored'}] * 20001), self.call(), self.output())
|
|
577
|
+
summary = self.summary()
|
|
578
|
+
self.assertFalse(summary['context_complete'])
|
|
579
|
+
self.assertFalse(summary['verifiable'])
|
|
580
|
+
self.assertIsNone(summary['context_id'])
|
|
581
|
+
self.assertEqual(summary['completed_skills'], [])
|
|
582
|
+
self.path.write_text('')
|
|
583
|
+
self.append({'type': 'compacted', 'payload': {}}, self.call(), self.output())
|
|
584
|
+
with self.path.open('a') as stream:
|
|
585
|
+
stream.write('{incomplete')
|
|
586
|
+
self.assertFalse(self.summary()['context_complete'])
|
|
587
|
+
self.assertFalse(self.summary()['verifiable'])
|
|
588
|
+
self.assertEqual(self.summary()['completed_skills'], [])
|
|
589
|
+
|
|
590
|
+
def test_postcompact_high_watermark_handles_late_boundary_flush(self):
|
|
591
|
+
self.append(self.call(call_id='old'))
|
|
592
|
+
offset = self.path.stat().st_size
|
|
593
|
+
self.append(self.output(call_id='old'), self.call(call_id='new'), self.output(call_id='new'))
|
|
594
|
+
summary = self.summary(offset)
|
|
595
|
+
self.assertTrue(summary['context_complete'])
|
|
596
|
+
self.assertTrue(summary['verifiable'])
|
|
597
|
+
self.assertEqual(summary['completed_skills'], ['ccl-skills:example-owner'])
|
|
598
|
+
self.assertEqual(summary['context_id'], f'offset:{offset}')
|
|
599
|
+
self.path.write_text('')
|
|
600
|
+
self.append(self.call(call_id='old'))
|
|
601
|
+
offset = self.path.stat().st_size
|
|
602
|
+
self.append(self.output(call_id='old'))
|
|
603
|
+
self.assertEqual(self.summary(offset)['completed_skills'], [])
|
|
604
|
+
self.append({'type': 'compacted', 'payload': {}})
|
|
605
|
+
native = self.summary(offset)
|
|
606
|
+
self.assertNotEqual(native['context_id'], f'offset:{offset}')
|
|
607
|
+
self.append(*self.claude())
|
|
608
|
+
self.assertEqual(self.summary(offset)['context_id'], native['context_id'])
|
|
609
|
+
|
|
610
|
+
def test_invalid_offset_or_nonregular_file_is_unknown(self):
|
|
611
|
+
for offset in (-1, True, '0', 1):
|
|
612
|
+
with self.subTest(offset=offset):
|
|
613
|
+
self.assertFalse(self.summary(offset)['context_complete'])
|
|
614
|
+
self.path.unlink()
|
|
615
|
+
os.mkfifo(self.path)
|
|
616
|
+
self.assertFalse(self.summary()['context_complete'])
|
|
617
|
+
self.path.unlink()
|
|
618
|
+
self.assertFalse(self.summary()['context_complete'])
|
|
619
|
+
|
|
620
|
+
def test_deeply_nested_current_record_cannot_escape_as_parser_exception(self):
|
|
621
|
+
self.path.write_text('{"type":"ignored","payload":' + '[' * 2000 + '0' + ']' * 2000 + '}\n')
|
|
622
|
+
summary = self.summary()
|
|
623
|
+
# Older Python JSON decoders reject this depth; newer iterative decoders
|
|
624
|
+
# can parse it. Both must return a bounded summary with no skill proof.
|
|
625
|
+
self.assertIsInstance(summary['context_complete'], bool)
|
|
626
|
+
self.assertEqual(summary['completed_skills'], [])
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
if __name__ == '__main__':
|
|
630
|
+
unittest.main()
|