@ccoalm/ccl-skills 0.18.1 → 0.18.2
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 +32 -1
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/host-input.py +544 -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 +13 -4
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/skill-loading.py +277 -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 +597 -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 +260 -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/packages/opencode-plugin/ccl-skills.ts +27 -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/product-rd-workflow/references/pre-final-continuation-gate.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +4 -0
- 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/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 +80 -25
- 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,278 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Bounded skill-loading checkpoints on synthetic host events and files."""
|
|
3
|
+
import importlib.util
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
import unittest
|
|
10
|
+
from unittest.mock import patch
|
|
11
|
+
from types import SimpleNamespace
|
|
12
|
+
|
|
13
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SkillLoadingTests(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.log = self.root / 'transcript.jsonl'
|
|
22
|
+
self.log.write_text('')
|
|
23
|
+
self.env = dict(os.environ, TMPDIR=str(self.root), CLAUDE_PLUGIN_ROOT=str(ROOT))
|
|
24
|
+
for key in ('GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE'):
|
|
25
|
+
self.env.pop(key, None)
|
|
26
|
+
|
|
27
|
+
def payload(self, tool='Edit', **extra):
|
|
28
|
+
return {'hook_event_name': 'PreToolUse', 'tool_name': tool,
|
|
29
|
+
'tool_input': {'file_path': str(self.root / 'source.py')},
|
|
30
|
+
'session_id': 'synthetic', 'transcript_path': str(self.log),
|
|
31
|
+
'cwd': str(self.root), **extra}
|
|
32
|
+
|
|
33
|
+
def run_hook(self, payload, name='owner-dispatch-guard.sh'):
|
|
34
|
+
result = subprocess.run(['bash', str(ROOT / 'hooks' / name)],
|
|
35
|
+
input=json.dumps(payload), text=True, capture_output=True,
|
|
36
|
+
env=self.env, cwd=self.root, timeout=10)
|
|
37
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
38
|
+
self.assertEqual(result.stderr, '')
|
|
39
|
+
return json.loads(result.stdout) if result.stdout else {}
|
|
40
|
+
|
|
41
|
+
def decision(self, value):
|
|
42
|
+
return value.get('hookSpecificOutput', {}).get('permissionDecision')
|
|
43
|
+
|
|
44
|
+
def append(self, *events):
|
|
45
|
+
with self.log.open('a') as stream:
|
|
46
|
+
for event in events:
|
|
47
|
+
stream.write(json.dumps(event) + '\n')
|
|
48
|
+
|
|
49
|
+
def loaded(self, ident='load'):
|
|
50
|
+
self.append({'type': 'assistant', 'message': {'content': [
|
|
51
|
+
{'type': 'tool_use', 'id': ident, 'name': 'Skill',
|
|
52
|
+
'input': {'skill': 'ccl-skills:multi-agent-delegation'}}]}},
|
|
53
|
+
{'type': 'user', 'message': {'content': [
|
|
54
|
+
{'type': 'tool_result', 'tool_use_id': ident, 'content': 'Loaded owner'}]}})
|
|
55
|
+
|
|
56
|
+
def compact_event(self, event):
|
|
57
|
+
return self.run_hook({'hook_event_name': event, 'session_id': 'synthetic',
|
|
58
|
+
'transcript_path': str(self.log), 'cwd': str(self.root)},
|
|
59
|
+
'skill-context-compact.sh')
|
|
60
|
+
|
|
61
|
+
def compact(self):
|
|
62
|
+
self.assertEqual(self.compact_event('PreCompact'), {})
|
|
63
|
+
return self.compact_event('PostCompact')
|
|
64
|
+
|
|
65
|
+
def boundary(self):
|
|
66
|
+
self.append({'type': 'compacted', 'payload': {'message': 'summary'}})
|
|
67
|
+
|
|
68
|
+
def attempts(self):
|
|
69
|
+
return len(list(self.root.glob('ccl-skill-loading-*/*/attempt-*')))
|
|
70
|
+
|
|
71
|
+
def test_default_checkpoint_without_repo_opt_in_replans_once(self):
|
|
72
|
+
result = self.run_hook(self.payload())
|
|
73
|
+
self.assertEqual(self.decision(result), 'deny')
|
|
74
|
+
reason = result['hookSpecificOutput']['permissionDecisionReason']
|
|
75
|
+
self.assertIn('multi-agent-delegation', reason)
|
|
76
|
+
self.assertIn('implementation', reason.lower())
|
|
77
|
+
self.assertNotIn('permissionDecision":"ask', json.dumps(result))
|
|
78
|
+
self.assertEqual(self.run_hook(self.payload()), {})
|
|
79
|
+
|
|
80
|
+
def test_native_patch_and_all_targets(self):
|
|
81
|
+
patch = '*** Begin Patch\n*** Add File: README.md\n+doc\n*** Add File: src/new.ts\n+code\n*** End Patch'
|
|
82
|
+
value = self.run_hook(self.payload('apply_patch', tool_input={'command': patch}))
|
|
83
|
+
self.assertEqual(self.decision(value), 'deny')
|
|
84
|
+
|
|
85
|
+
def test_read_only_document_and_unparseable_edits_do_not_consume_checkpoint(self):
|
|
86
|
+
for tool, args in [('Read', {'file_path': 'source.py'}),
|
|
87
|
+
('Bash', {'command': 'cat source.py'}),
|
|
88
|
+
('Edit', {'file_path': 'README.md'}),
|
|
89
|
+
('apply_patch', {'command': '*** Begin Patch\n*** Add File: README.md\n+doc\n*** End Patch'})]:
|
|
90
|
+
with self.subTest(tool=tool, args=args):
|
|
91
|
+
self.assertEqual(self.run_hook(self.payload(tool, tool_input=args)), {})
|
|
92
|
+
self.assertEqual(self.decision(self.run_hook(self.payload())), 'deny')
|
|
93
|
+
|
|
94
|
+
def test_existing_malformed_patch_denial_is_not_changed(self):
|
|
95
|
+
result = self.run_hook(self.payload('apply_patch', tool_input={'command': 'invalid'}))
|
|
96
|
+
self.assertEqual(self.decision(result), 'deny')
|
|
97
|
+
self.assertIn('malformed', result['hookSpecificOutput']['permissionDecisionReason'])
|
|
98
|
+
|
|
99
|
+
def test_session_actor_and_transcript_scopes_are_independent(self):
|
|
100
|
+
self.assertEqual(self.decision(self.run_hook(self.payload())), 'deny')
|
|
101
|
+
for extra in ({'session_id': 'other'}, {'agent_id': 'worker-a'}, {'agent_id': 'worker-b'}):
|
|
102
|
+
with self.subTest(extra=extra):
|
|
103
|
+
self.assertEqual(self.decision(self.run_hook(self.payload(**extra))), 'deny')
|
|
104
|
+
self.assertEqual(self.run_hook(self.payload(**extra)), {})
|
|
105
|
+
other = self.root / 'other.jsonl'
|
|
106
|
+
other.write_text('')
|
|
107
|
+
self.assertEqual(self.decision(self.run_hook(self.payload(transcript_path=str(other)))), 'deny')
|
|
108
|
+
|
|
109
|
+
def test_native_compaction_boundary_rearms_without_erasing_old_audit(self):
|
|
110
|
+
self.loaded()
|
|
111
|
+
self.assertEqual(self.decision(self.run_hook(self.payload())), 'deny')
|
|
112
|
+
self.append({'type': 'system', 'subtype': 'compact_boundary', 'compactMetadata': {'trigger': 'auto'}})
|
|
113
|
+
self.assertEqual(self.decision(self.run_hook(self.payload())), 'deny')
|
|
114
|
+
self.assertEqual(self.run_hook(self.payload()), {})
|
|
115
|
+
self.append({'type': 'compacted', 'payload': {'message': 'summary'}})
|
|
116
|
+
self.assertEqual(self.decision(self.run_hook(self.payload())), 'deny')
|
|
117
|
+
|
|
118
|
+
def test_quoted_compaction_does_not_rearm(self):
|
|
119
|
+
self.run_hook(self.payload())
|
|
120
|
+
self.append({'type': 'user', 'message': {'content': [
|
|
121
|
+
{'type': 'text', 'text': '{"type":"compacted","payload":{}}'}]}})
|
|
122
|
+
self.assertEqual(self.run_hook(self.payload()), {})
|
|
123
|
+
|
|
124
|
+
def test_postcompact_rearms_before_transcript_boundary_is_flushed(self):
|
|
125
|
+
self.loaded()
|
|
126
|
+
self.run_hook(self.payload())
|
|
127
|
+
result = self.compact()
|
|
128
|
+
self.assertEqual(result, {})
|
|
129
|
+
self.assertEqual(self.decision(self.run_hook(self.payload())), 'deny')
|
|
130
|
+
# A deferred boundary for the same completed compaction is not another
|
|
131
|
+
# checkpoint. Only an actual later PostCompact event rearms this window.
|
|
132
|
+
self.append({'type': 'compacted', 'payload': {'message': 'summary'}})
|
|
133
|
+
self.assertEqual(self.run_hook(self.payload()), {})
|
|
134
|
+
|
|
135
|
+
def test_missing_identity_and_unwritable_state_never_deny_forever(self):
|
|
136
|
+
for payload in (self.payload(session_id=''), self.payload(transcript_path=None)):
|
|
137
|
+
self.assertNotEqual(self.decision(self.run_hook(payload)), 'deny')
|
|
138
|
+
self.env['TMPDIR'] = str(self.root / 'does-not-exist')
|
|
139
|
+
self.assertNotEqual(self.decision(self.run_hook(self.payload())), 'deny')
|
|
140
|
+
|
|
141
|
+
def test_existing_engine_deny_ask_and_advisory_are_preserved(self):
|
|
142
|
+
plugin = self.root / 'plugin'
|
|
143
|
+
engine = plugin / 'scripts/owner-dispatch/owner-dispatch.sh'
|
|
144
|
+
engine.parent.mkdir(parents=True)
|
|
145
|
+
self.env['CLAUDE_PLUGIN_ROOT'] = str(plugin)
|
|
146
|
+
for decision in ('deny', 'ask'):
|
|
147
|
+
value = {'hookSpecificOutput': {'hookEventName': 'PreToolUse',
|
|
148
|
+
'permissionDecision': decision, 'permissionDecisionReason': 'existing boundary'}}
|
|
149
|
+
engine.write_text("#!/bin/sh\nprintf '%s\\n' '" + json.dumps(value) + "'\n")
|
|
150
|
+
self.assertEqual(self.run_hook(self.payload()), value)
|
|
151
|
+
advisory = {'hookSpecificOutput': {'hookEventName': 'PreToolUse', 'additionalContext': 'existing advisory'}}
|
|
152
|
+
engine.write_text("#!/bin/sh\nprintf '%s\\n' '" + json.dumps(advisory) + "'\n")
|
|
153
|
+
value = self.run_hook(self.payload())
|
|
154
|
+
self.assertEqual(self.decision(value), 'deny')
|
|
155
|
+
self.assertEqual(value['hookSpecificOutput']['additionalContext'], 'existing advisory')
|
|
156
|
+
self.assertEqual(self.run_hook(self.payload()), advisory)
|
|
157
|
+
|
|
158
|
+
def test_missing_routing_rule_does_not_spend_attempt(self):
|
|
159
|
+
spec = importlib.util.spec_from_file_location('checkpoint_rule_test', ROOT / 'hooks/skill-loading.py')
|
|
160
|
+
module = importlib.util.module_from_spec(spec)
|
|
161
|
+
spec.loader.exec_module(module)
|
|
162
|
+
with patch.dict(os.environ, self.env), patch.object(module, 'routing_rule', side_effect=ValueError):
|
|
163
|
+
with self.assertRaises(ValueError):
|
|
164
|
+
module.handle(self.payload(), {})
|
|
165
|
+
self.assertEqual(self.attempts(), 0)
|
|
166
|
+
self.assertEqual(self.decision(self.run_hook(self.payload())), 'deny')
|
|
167
|
+
|
|
168
|
+
def test_invalid_engine_output_falls_back_to_original_bytes(self):
|
|
169
|
+
engine = self.root / 'plugin/scripts/owner-dispatch/owner-dispatch.sh'
|
|
170
|
+
engine.parent.mkdir(parents=True)
|
|
171
|
+
self.env['CLAUDE_PLUGIN_ROOT'] = str(self.root / 'plugin')
|
|
172
|
+
for output in ('diagnostic\n{"decision":"block"}', '[]', '{"decision":"block"}\n{}'):
|
|
173
|
+
with self.subTest(output=output):
|
|
174
|
+
engine.write_text("#!/bin/sh\nprintf '%s' '" + output + "'\n")
|
|
175
|
+
result = subprocess.run(['bash', str(ROOT / 'hooks/owner-dispatch-guard.sh')],
|
|
176
|
+
input=json.dumps(self.payload()), text=True, capture_output=True,
|
|
177
|
+
env=self.env, cwd=self.root, timeout=10)
|
|
178
|
+
self.assertEqual(result.returncode, 0)
|
|
179
|
+
self.assertEqual(result.stderr, '')
|
|
180
|
+
self.assertEqual(result.stdout, output)
|
|
181
|
+
self.assertEqual(self.attempts(), 0)
|
|
182
|
+
|
|
183
|
+
def test_delegation_load_and_checkpoint_are_current_context_scoped(self):
|
|
184
|
+
for tool in ('Agent', 'spawn_agent'):
|
|
185
|
+
with self.subTest(tool=tool):
|
|
186
|
+
payload = self.payload(tool, session_id=tool, tool_input={'prompt': 'synthetic task'})
|
|
187
|
+
result = self.run_hook(payload, 'guard-delegation-owner.sh')
|
|
188
|
+
self.assertEqual(self.decision(result), 'deny')
|
|
189
|
+
self.assertEqual(self.run_hook(payload, 'guard-delegation-owner.sh'), {})
|
|
190
|
+
self.loaded()
|
|
191
|
+
warm = self.payload('spawn_agent', session_id='warm', tool_input={})
|
|
192
|
+
self.assertEqual(self.run_hook(warm, 'guard-delegation-owner.sh'), {})
|
|
193
|
+
self.append({'type': 'compacted', 'payload': {'message': 'summary'}})
|
|
194
|
+
self.assertEqual(self.decision(self.run_hook(warm, 'guard-delegation-owner.sh')), 'deny')
|
|
195
|
+
self.loaded('fresh')
|
|
196
|
+
self.assertEqual(self.run_hook(warm, 'guard-delegation-owner.sh'), {})
|
|
197
|
+
|
|
198
|
+
def test_postcompact_high_watermark_prevents_old_delegation_load_resurrection(self):
|
|
199
|
+
self.loaded()
|
|
200
|
+
payload = self.payload('spawn_agent', tool_input={})
|
|
201
|
+
self.assertEqual(self.run_hook(payload, 'guard-delegation-owner.sh'), {})
|
|
202
|
+
self.compact()
|
|
203
|
+
self.assertEqual(self.decision(self.run_hook(payload, 'guard-delegation-owner.sh')), 'deny')
|
|
204
|
+
self.loaded('after-event')
|
|
205
|
+
self.assertEqual(self.run_hook(payload, 'guard-delegation-owner.sh'), {})
|
|
206
|
+
|
|
207
|
+
def test_precompact_alone_preserves_warm_context_and_attempt_cap(self):
|
|
208
|
+
self.loaded()
|
|
209
|
+
self.run_hook(self.payload())
|
|
210
|
+
self.assertEqual(self.compact_event('PreCompact'), {})
|
|
211
|
+
self.assertEqual(self.run_hook(self.payload()), {})
|
|
212
|
+
self.assertEqual(self.run_hook(self.payload('spawn_agent'), 'guard-delegation-owner.sh'), {})
|
|
213
|
+
self.assertEqual(self.attempts(), 1)
|
|
214
|
+
|
|
215
|
+
def test_delayed_old_read_after_postcompact_is_not_fresh_proof(self):
|
|
216
|
+
self.boundary()
|
|
217
|
+
self.compact()
|
|
218
|
+
self.loaded('delayed-old-pair')
|
|
219
|
+
value = self.run_hook(self.payload('spawn_agent'), 'guard-delegation-owner.sh')
|
|
220
|
+
self.assertEqual(self.decision(value), 'deny')
|
|
221
|
+
self.assertEqual(self.attempts(), 1)
|
|
222
|
+
|
|
223
|
+
def test_new_boundary_and_full_load_after_postcompact_need_no_checkpoint(self):
|
|
224
|
+
self.compact()
|
|
225
|
+
self.boundary()
|
|
226
|
+
self.loaded('fresh')
|
|
227
|
+
self.assertEqual(self.run_hook(self.payload('spawn_agent'), 'guard-delegation-owner.sh'), {})
|
|
228
|
+
self.assertEqual(self.attempts(), 0)
|
|
229
|
+
|
|
230
|
+
def test_boundary_flushed_before_postcompact_is_compared_with_precompact(self):
|
|
231
|
+
self.boundary()
|
|
232
|
+
self.compact_event('PreCompact')
|
|
233
|
+
self.boundary()
|
|
234
|
+
self.compact_event('PostCompact')
|
|
235
|
+
self.loaded('fresh')
|
|
236
|
+
self.assertEqual(self.run_hook(self.payload('spawn_agent'), 'guard-delegation-owner.sh'), {})
|
|
237
|
+
self.assertEqual(self.attempts(), 0)
|
|
238
|
+
|
|
239
|
+
def test_missing_precompact_does_not_accept_existing_boundary(self):
|
|
240
|
+
self.boundary()
|
|
241
|
+
self.compact_event('PostCompact')
|
|
242
|
+
self.loaded('delayed')
|
|
243
|
+
value = self.run_hook(self.payload('spawn_agent'), 'guard-delegation-owner.sh')
|
|
244
|
+
self.assertEqual(self.decision(value), 'deny')
|
|
245
|
+
|
|
246
|
+
def test_replaced_or_shrunken_transcript_never_restores_old_proof(self):
|
|
247
|
+
self.loaded()
|
|
248
|
+
self.compact()
|
|
249
|
+
self.log.rename(self.root / 'old.jsonl')
|
|
250
|
+
self.loaded('replacement-old')
|
|
251
|
+
value = self.run_hook(self.payload('spawn_agent'), 'guard-delegation-owner.sh')
|
|
252
|
+
self.assertIn('unavailable', value.get('systemMessage', ''))
|
|
253
|
+
self.assertEqual(self.attempts(), 0)
|
|
254
|
+
self.compact()
|
|
255
|
+
self.log.write_text('')
|
|
256
|
+
value = self.run_hook(self.payload('spawn_agent'), 'guard-delegation-owner.sh')
|
|
257
|
+
self.assertIn('unavailable', value.get('systemMessage', ''))
|
|
258
|
+
|
|
259
|
+
def test_boundary_arriving_between_snapshots_cannot_upgrade_old_proof(self):
|
|
260
|
+
spec = importlib.util.spec_from_file_location('checkpoint_test', ROOT / 'hooks/skill-loading.py')
|
|
261
|
+
module = importlib.util.module_from_spec(spec)
|
|
262
|
+
spec.loader.exec_module(module)
|
|
263
|
+
record = {'generation': 'a' * 32, 'offset': 100, 'device': 1, 'inode': 1,
|
|
264
|
+
'before_context': 'start:0'}
|
|
265
|
+
state = SimpleNamespace(read=lambda: record, close=lambda: None,
|
|
266
|
+
claim_attempt=lambda *args: True)
|
|
267
|
+
snapshots = iter([
|
|
268
|
+
{'context_complete': True, 'context_id': 'offset:100',
|
|
269
|
+
'completed_skills': ['ccl-skills:multi-agent-delegation']},
|
|
270
|
+
{'context_complete': True, 'context_id': 'native:200:after', 'completed_skills': []}])
|
|
271
|
+
reader = SimpleNamespace(context_transcript=lambda *a, **kw: next(snapshots))
|
|
272
|
+
info = SimpleNamespace(st_dev=1, st_ino=1, st_size=1000)
|
|
273
|
+
with patch.object(module, 'State', return_value=state), \
|
|
274
|
+
patch.object(module, 'normalizer', return_value=reader), \
|
|
275
|
+
patch.object(module, 'regular_info', return_value=info):
|
|
276
|
+
self.assertEqual(self.decision(module.handle(self.payload('spawn_agent'), {})), 'deny')
|
|
277
|
+
if __name__ == '__main__':
|
|
278
|
+
unittest.main()
|
|
@@ -52,6 +52,7 @@ const UPDATE_REMINDER_MARKER = "CCL Skills Update Reminder"
|
|
|
52
52
|
// silently inactive in OpenCode.
|
|
53
53
|
const OPENCODE_HOOK_BINDINGS = Object.freeze({
|
|
54
54
|
"session-start.sh": "experimental.chat.system.transform",
|
|
55
|
+
"skill-context-compact.sh": "event:session.compacted:PreCompact/PostCompact bridge",
|
|
55
56
|
"guard-edit-isolation.sh": "tool.execute.before:edit/write/apply_patch",
|
|
56
57
|
"owner-dispatch-guard.sh": "tool.execute.before:edit/write/apply_patch/bash",
|
|
57
58
|
"guard-merge-authorization.sh": "tool.execute.before:bash",
|
|
@@ -64,6 +65,7 @@ const OPENCODE_HOOK_BINDINGS = Object.freeze({
|
|
|
64
65
|
"subagent-start.sh": "tool.execute.before:task/agent",
|
|
65
66
|
"owner-dispatch-stop.sh": "event:session.idle/session.status",
|
|
66
67
|
"skill-extraction-gate-stop.sh": "event:session.idle/session.status",
|
|
68
|
+
"proposed-next-stop.sh": "event:session.idle/session.status",
|
|
67
69
|
})
|
|
68
70
|
|
|
69
71
|
type HookJson = {
|
|
@@ -516,7 +518,10 @@ export const CclSkills = async (context: {
|
|
|
516
518
|
type: "assistant",
|
|
517
519
|
message: { content: [{ type: "tool_use", id: targets.length === 1 ? callID : `${callID}-${index}`, name: "Edit", input: { file_path: filePath } }] },
|
|
518
520
|
}))
|
|
519
|
-
} else {
|
|
521
|
+
} else if (tool !== "skill") {
|
|
522
|
+
// A pending native Skill is held in pendingSkills, not represented as a
|
|
523
|
+
// malformed Claude Skill request. Emit its paired evidence only after
|
|
524
|
+
// the native completion proves the loaded source belongs to CCL.
|
|
520
525
|
appendTranscript(sessionID, {
|
|
521
526
|
type: "assistant",
|
|
522
527
|
message: { content: [{ type: "tool_use", id: callID, name: toolName, input: {} }] },
|
|
@@ -535,13 +540,13 @@ export const CclSkills = async (context: {
|
|
|
535
540
|
].join("\n"),
|
|
536
541
|
)
|
|
537
542
|
}
|
|
538
|
-
const hookPayload = payload(sessionID, { tool_name: "Edit", tool_input: { ...args, file_path: filePath } })
|
|
543
|
+
const hookPayload = payload(sessionID, { hook_event_name: "PreToolUse", tool_name: "Edit", tool_input: { ...args, file_path: filePath } })
|
|
539
544
|
enforce(runHook(hooksRoot, "guard-edit-isolation.sh", hookPayload, directory, 10_000), "ccl-skills edit-isolation guard")
|
|
540
545
|
enforce(runHook(hooksRoot, "owner-dispatch-guard.sh", hookPayload, directory, 10_000), "ccl-skills owner-dispatch guard")
|
|
541
546
|
}
|
|
542
547
|
|
|
543
548
|
if (tool === "bash") {
|
|
544
|
-
const hookPayload = payload(sessionID, { tool_name: "Bash", tool_input: args })
|
|
549
|
+
const hookPayload = payload(sessionID, { hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: args })
|
|
545
550
|
enforce(runHook(hooksRoot, "owner-dispatch-guard.sh", hookPayload, directory, 10_000), "ccl-skills owner-dispatch guard")
|
|
546
551
|
const merge = runHook(hooksRoot, "guard-merge-authorization.sh", hookPayload, directory, 10_000)
|
|
547
552
|
if (merge.status !== "ok" && potentialLandingCommand(args.command)) {
|
|
@@ -561,7 +566,7 @@ export const CclSkills = async (context: {
|
|
|
561
566
|
}
|
|
562
567
|
|
|
563
568
|
if (tool === "task" || tool === "agent") {
|
|
564
|
-
const hookPayload = payload(sessionID, { tool_name: toolName, tool_input: args })
|
|
569
|
+
const hookPayload = payload(sessionID, { hook_event_name: "PreToolUse", tool_name: toolName, tool_input: args })
|
|
565
570
|
const delegation = runHook(hooksRoot, "guard-delegation-owner.sh", hookPayload, directory, 10_000)
|
|
566
571
|
const delegationReason = permission(delegation).reason ?? ""
|
|
567
572
|
enforce(delegation, "ccl-skills delegation-owner guard")
|
|
@@ -616,9 +621,21 @@ export const CclSkills = async (context: {
|
|
|
616
621
|
? (properties.info as { id: string }).id
|
|
617
622
|
: ""
|
|
618
623
|
if (!sessionID) return
|
|
619
|
-
if (event.type === "session.
|
|
624
|
+
if (event.type === "session.compacted" && (typeof properties.sessionID !== "string" || !properties.sessionID)) return
|
|
625
|
+
if (event.type === "session.deleted" || event.type === "session.compacted" || event.type === "session.idle" || (properties.status as { type?: string } | undefined)?.type === "idle") {
|
|
620
626
|
for (const key of pendingSkills.keys()) if (key.startsWith(`${sessionID}\0`)) pendingSkills.delete(key)
|
|
621
627
|
}
|
|
628
|
+
if (event.type === "session.compacted") {
|
|
629
|
+
// OpenCode emits this only after successful compaction, with sessionID.
|
|
630
|
+
// Preserve actor identity: the shared hook selects agent_transcript_path
|
|
631
|
+
// for children, so a child's reset cannot invalidate its parent's reads.
|
|
632
|
+
// PreCompact here snapshots the bridge's completed-event boundary; it is
|
|
633
|
+
// not a claim that OpenCode emitted a native pre-compaction callback.
|
|
634
|
+
runHook(hooksRoot, "skill-context-compact.sh", payload(sessionID, { hook_event_name: "PreCompact" }), directory, 5_000)
|
|
635
|
+
appendTranscript(sessionID, { type: "system", subtype: "compact_boundary" })
|
|
636
|
+
runHook(hooksRoot, "skill-context-compact.sh", payload(sessionID, { hook_event_name: "PostCompact" }), directory, 5_000)
|
|
637
|
+
return
|
|
638
|
+
}
|
|
622
639
|
if (event.type === "session.deleted") {
|
|
623
640
|
const path = transcriptPath(sessionID)
|
|
624
641
|
if (path) rmSync(path, { force: true })
|
|
@@ -630,10 +647,14 @@ export const CclSkills = async (context: {
|
|
|
630
647
|
if (!isIdle || idleInFlight.has(sessionID)) return
|
|
631
648
|
idleInFlight.add(sessionID)
|
|
632
649
|
try {
|
|
633
|
-
|
|
650
|
+
// Idle events do not carry an authoritative final assistant message.
|
|
651
|
+
// Formatting backstops stay unverifiable rather than reading stale text
|
|
652
|
+
// from the intentionally metadata-only adapter transcript.
|
|
653
|
+
const stopPayload = payload(sessionID, { hook_event_name: "Stop", stop_hook_active: false, last_assistant_message: null })
|
|
634
654
|
const results = [
|
|
635
655
|
runHook(hooksRoot, "owner-dispatch-stop.sh", stopPayload, directory, 10_000),
|
|
636
656
|
runHook(hooksRoot, "skill-extraction-gate-stop.sh", stopPayload, directory, 15_000),
|
|
657
|
+
runHook(hooksRoot, "proposed-next-stop.sh", stopPayload, directory, 5_000),
|
|
637
658
|
]
|
|
638
659
|
const reasons = results
|
|
639
660
|
.filter((result) => result.output?.decision === "block" && typeof result.output.reason === "string")
|
|
@@ -25,13 +25,21 @@ This subsystem is that gate. It mirrors the repo's existing `guard-edit-isolatio
|
|
|
25
25
|
PreToolUse pattern and is built on the hook decision model in
|
|
26
26
|
`skills/llm-inference-integration/references/agent-lifecycle-hooks.md`.
|
|
27
27
|
|
|
28
|
+
The plugin also supplies a separate default skill-loading checkpoint. On the first
|
|
29
|
+
precise source edit it returns one agent-facing denial so the agent can apply the
|
|
30
|
+
canonical routing rule, load missing implementation skills and retry. It does not
|
|
31
|
+
guess an owner from a file extension, request user approval, or verify a boundary
|
|
32
|
+
record. Its cap is per actor and context; blind retries and parallel siblings can
|
|
33
|
+
proceed. The configured engine's decisions take precedence. The opt-in and CI
|
|
34
|
+
contracts below describe this subsystem, not that bounded recovery checkpoint.
|
|
35
|
+
|
|
28
36
|
## Surfaces (defense in depth)
|
|
29
37
|
|
|
30
38
|
| Surface | When | Hard? | Bypassable? |
|
|
31
39
|
|---|---|---|---|
|
|
32
|
-
| `PreToolUse` (Edit/Write/MultiEdit/NotebookEdit + Bash) → `owner-dispatch-guard.sh` | at the first product-code edit (**fires in subagents too** — Claude Code runs PreToolUse inside dispatched workers, carrying `agent_id`) | Claude
|
|
33
|
-
| `SubagentStart` → `subagent-start.sh` | when any subagent is spawned | injects a slim, **self-gating** ccl-skills routing pointer (impl/test/design/doc workers invoke their owning skill; read-only workers ignore it)
|
|
34
|
-
| `Stop` / `SubagentStop` → `owner-dispatch-stop.sh` | at session/subagent end, **only if that actor actually changed gated code** (current tree diffed against a baseline snapshot taken at the first touch) with no boundary or with required owners not actually invoked | Claude:
|
|
40
|
+
| `PreToolUse` (Edit/Write/MultiEdit/NotebookEdit + Bash) → `owner-dispatch-guard.sh` | at the first product-code edit (**fires in subagents too** — Claude Code runs PreToolUse inside dispatched workers, carrying `agent_id`) | Claude precise edits use `ask`, or `deny` under `strict`. Codex `apply_patch` uses advisory context by default and `deny` under `strict`; its native hook parser rejects `ask`. **Bash writes are record-only — silent allow, no prompt** (they only drop an activity marker for the Stop backstop). | Yes — the Bash heuristic never blocks (it over-matches read-only commands, so prompting on it is pure noise); many Bash write forms also slip it. This surface is a fast nudge on precise edits, not the gate. |
|
|
41
|
+
| `SubagentStart` → `subagent-start.sh` | when any subagent is spawned | injects a slim, **self-gating** ccl-skills routing pointer (impl/test/design/doc workers invoke their owning skill; read-only workers ignore it) for the new actor. Informational: SubagentStart **cannot block**. | n/a — additive context only; never overrides an owner-set the controller named in the dispatch prompt. |
|
|
42
|
+
| `Stop` / `SubagentStop` → `owner-dispatch-stop.sh` | at session/subagent end, **only if that actor actually changed gated code** (current tree diffed against a baseline snapshot taken at the first touch) with no boundary or with required owners not actually invoked | Claude/Codex native hooks: block **once per actor per session** — `SubagentStop` verifies `agent_transcript_path` when present; a legacy controller-transcript fallback never uses strict empty-set proof. Zero Skill calls are a miss only when the worker tool-event shape is verifiable. `agent_id` scopes activity/cap/waiver so siblings do not contaminate each other. | one-block cap + fail-open for unreadable/malformed/shape-drifted evidence + evidence-gate (read-only / rejected / reverted / pre-existing-dirty actors never block) means it never traps the user. |
|
|
35
43
|
| `ci` subcommand (pre-commit / CI) | at commit/merge | host-agnostic; rejects gated changes lacking an updated map, and rejects changes that remove/disable/malform the config or point the artifact off-tree | The durable backstop — **but only as un-bypassable as the CI job itself** (needs pipelines-must-succeed + protected branch + CODEOWNERS, per the repo deployment checklist). |
|
|
36
44
|
|
|
37
45
|
## Safety posture (every default is the safe one)
|
|
@@ -47,18 +55,18 @@ PreToolUse pattern and is built on the hook decision model in
|
|
|
47
55
|
malformed config ⇒ silent allow (treated as not-opted-in). An unwritable/unsafe state dir ⇒
|
|
48
56
|
allow (and `strict` downgrades to `ask`). The cheap opt-in walk runs before any git/jq, so a
|
|
49
57
|
non-opted repo pays almost nothing.
|
|
50
|
-
- **
|
|
51
|
-
|
|
52
|
-
|
|
58
|
+
- **Engine default `ask`, strict `deny`.** Codex converts the default `ask` to advisory
|
|
59
|
+
context because its native parser rejects that decision. The separate default source-edit
|
|
60
|
+
checkpoint adds one bounded agent-facing denial. Engine `strict:true` applies **only to
|
|
61
|
+
precise file paths** — a write-like **Bash** command is matched only heuristically (it over-matches
|
|
53
62
|
read-only commands and can never be sure), so it is **record-only: silent allow, never a
|
|
54
63
|
prompt and never a deny**, even under `strict`. The Stop hook + `ci` catch what the Bash
|
|
55
64
|
heuristic can only hint at. `strict` also downgrades to `ask` when the state dir can't be
|
|
56
65
|
written (so it can never brick a repo).
|
|
57
|
-
- **
|
|
58
|
-
|
|
59
|
-
the boundary
|
|
60
|
-
|
|
61
|
-
the user. `record` takes out a **per-worktree TTL lease**, not a per-slice token: it stays
|
|
66
|
+
- **The agent can resolve the owner boundary.** Invoke the owning skills, then `record --owners`.
|
|
67
|
+
Claude `ask` requests host approval; explanatory text cannot suppress that prompt.
|
|
68
|
+
Resolving the boundary clears **only this gate**, and grants no authority for merge, push,
|
|
69
|
+
destructive cleanup or scope/product decisions. `record` takes out a **per-worktree TTL lease**, not a per-slice token: it stays
|
|
62
70
|
valid until the TTL expires as long as work continues forward from the recorded commit
|
|
63
71
|
(committing does NOT invalidate it; switching to a divergent line does). The gate cannot
|
|
64
72
|
tell two deliveries apart inside one lease, so a second slice on the same line within the
|
|
@@ -69,10 +77,10 @@ PreToolUse pattern and is built on the hook decision model in
|
|
|
69
77
|
- **`strict:true` is a maintainer/config decision, not an agent prompt-reduction knob, and not a
|
|
70
78
|
safety guarantee.** Flipping `strict` is committed, team-wide repo config — make it a separate
|
|
71
79
|
reviewed config-only change, never something an agent toggles inside a product edit to stop being
|
|
72
|
-
prompted.
|
|
73
|
-
|
|
74
|
-
safe on its own — it is fail-open, downgrades to `ask` when
|
|
75
|
-
hard-denies heuristic Bash,
|
|
80
|
+
prompted. For precise edits, it turns Claude `ask` or Codex advisory context into an agent-facing
|
|
81
|
+
`deny` that the agent self-clears. It does **not** make the gate
|
|
82
|
+
safe on its own — it is fail-open, downgrades to `ask` (Codex advisory) when state is unwritable, never
|
|
83
|
+
hard-denies heuristic Bash, and does not replace CI / protected branches /
|
|
76
84
|
CODEOWNERS / human map review (the durable backstop).
|
|
77
85
|
- **Stop / SubagentStop never traps the user.** It requires a real `session_id` (fail-open
|
|
78
86
|
without one), inspects only the current `PWD` repo, blocks **at most once per actor per
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
# - FAIL-OPEN: any internal error (missing jq/git, parse failure, unwritable/unsafe
|
|
21
21
|
# state dir, missing session id) ALLOWS the action. A broken gate must never brick
|
|
22
22
|
# editing, and no error path may fail-CLOSED.
|
|
23
|
-
# - DEFAULT `ask`, NOT `deny`: hard `deny` is the explicit `strict:true` opt-in
|
|
24
|
-
# Claude
|
|
23
|
+
# - DEFAULT `ask`, NOT `deny`: hard `deny` is the explicit `strict:true` opt-in
|
|
24
|
+
# on compatible native Claude/Codex hooks. It applies ONLY to precise
|
|
25
25
|
# Edit/Write/MultiEdit/NotebookEdit file paths (never the heuristic Bash match),
|
|
26
26
|
# and is downgraded to `ask` when the boundary state dir is not safely writable
|
|
27
27
|
# (so strict can never brick a repo whose state dir is unavailable).
|
|
@@ -54,10 +54,17 @@ AIDKEY="" # filename-safe, INJECTIVE key for agent_id, computed by jq @b
|
|
|
54
54
|
# collide on the same marker). Empty iff no agent_id. Scopes the activity
|
|
55
55
|
# marker / block-cap / waiver to ONE subagent.
|
|
56
56
|
|
|
57
|
+
HOST_INPUT="$(cd "$(dirname "$SELF")/../.." && pwd)/hooks/host-input.py"
|
|
58
|
+
HOST_TOOL=""
|
|
59
|
+
|
|
57
60
|
# ----- fail-open helpers ------------------------------------------------------
|
|
58
61
|
allow_pretool() { exit 0; } # no output => allow
|
|
59
62
|
allow_stop() { exit 0; } # no output => allow stop
|
|
60
63
|
emit_pretool_deny() { # $1 reason $2 decision(deny|ask)
|
|
64
|
+
if [ "$2" = ask ] && [ "$HOST_TOOL" = apply_patch ]; then
|
|
65
|
+
jq -nc --arg r "$1" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:("Advisory owner routing: " + $r)}}'
|
|
66
|
+
exit 0
|
|
67
|
+
fi
|
|
61
68
|
jq -nc --arg r "$1" --arg d "$2" \
|
|
62
69
|
'{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:$d,permissionDecisionReason:$r}}'
|
|
63
70
|
exit 0
|
|
@@ -393,6 +400,10 @@ boundary_state() { # $1 root -> echo valid|expired|discontinuous|absent
|
|
|
393
400
|
# Emit the base skill names invoked in a transcript, ONE PER LINE (strip any "prefix:").
|
|
394
401
|
invoked_skills() { # $1 transcript-path
|
|
395
402
|
[ -n "$1" ] && [ -r "$1" ] || return 0
|
|
403
|
+
if have python3 && [ -r "$HOST_INPUT" ]; then
|
|
404
|
+
python3 "$HOST_INPUT" transcript "$1" 2>/dev/null | jq -r '.requested_skills[] | if startswith("ccl-skills:") then ltrimstr("ccl-skills:") else . end' 2>/dev/null
|
|
405
|
+
return 0
|
|
406
|
+
fi
|
|
396
407
|
# Per-line jq (JSONL); tolerate malformed lines. A line's message.content[] may
|
|
397
408
|
# carry tool_use blocks. input.skill is "ccl-skills:foo" or bare "foo".
|
|
398
409
|
jq -rR '
|
|
@@ -415,6 +426,10 @@ invoked_skills() { # $1 transcript-path
|
|
|
415
426
|
# transcript or Skill-event shape drifted" without turning valid-JSON drift into a trap.
|
|
416
427
|
transcript_has_verifiable_invocation_shape() { # $1 transcript-path
|
|
417
428
|
[ -n "$1" ] && [ -r "$1" ] || return 1
|
|
429
|
+
if have python3 && [ -r "$HOST_INPUT" ]; then
|
|
430
|
+
python3 "$HOST_INPUT" transcript "$1" 2>/dev/null | jq -e '.verifiable' >/dev/null 2>&1
|
|
431
|
+
return $?
|
|
432
|
+
fi
|
|
418
433
|
jq -seR '
|
|
419
434
|
[ split("\n")[]
|
|
420
435
|
| try fromjson catch empty
|
|
@@ -666,6 +681,30 @@ cmd_pretool() {
|
|
|
666
681
|
AIDKEY=$(printf '%s' "$input" | aid_key_from) # injective key (jq, raw JSON)
|
|
667
682
|
tool=$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
668
683
|
|
|
684
|
+
HOST_TOOL="${1:-$tool}"
|
|
685
|
+
if [ "$tool" = apply_patch ]; then
|
|
686
|
+
# Inspect every target, including moves and deletions. A safe first path
|
|
687
|
+
# must not short-circuit checks of the remaining paths in the same patch.
|
|
688
|
+
have python3 && [ -r "$HOST_INPUT" ] || emit_pretool_deny "owner-dispatch cannot inspect apply_patch: Python input normalizer unavailable." deny
|
|
689
|
+
local normalized target converted response deny_response="" advisory_response=""
|
|
690
|
+
normalized=$(printf '%s' "$input" | python3 "$HOST_INPUT" paths 2>/dev/null) || emit_pretool_deny "owner-dispatch cannot inspect apply_patch input." deny
|
|
691
|
+
[ "$(printf '%s' "$normalized" | jq -r '.malformed_patch')" = false ] || emit_pretool_deny "owner-dispatch cannot inspect malformed apply_patch targets." deny
|
|
692
|
+
while IFS= read -r -d '' target; do
|
|
693
|
+
converted=$(printf '%s' "$input" | jq -c --arg p "$target" '.tool_name="Edit" | .tool_input={file_path:$p}')
|
|
694
|
+
response=$(printf '%s' "$converted" | cmd_pretool apply_patch)
|
|
695
|
+
[ -n "$response" ] || continue
|
|
696
|
+
if printf '%s' "$response" | jq -e '.hookSpecificOutput.permissionDecision == "deny"' >/dev/null 2>&1; then
|
|
697
|
+
[ -n "$deny_response" ] || deny_response="$response"
|
|
698
|
+
else
|
|
699
|
+
[ -n "$advisory_response" ] || advisory_response="$response"
|
|
700
|
+
fi
|
|
701
|
+
done < <(printf '%s' "$normalized" | jq -j '.paths[] | . + "\u0000"')
|
|
702
|
+
# An advisory first target must not hide a later strict denial. Inspect all
|
|
703
|
+
# targets before emitting one response so activity evidence is complete too.
|
|
704
|
+
[ -n "$deny_response" ] && { printf '%s\n' "$deny_response"; exit 0; }
|
|
705
|
+
[ -n "$advisory_response" ] && { printf '%s\n' "$advisory_response"; exit 0; }
|
|
706
|
+
allow_pretool
|
|
707
|
+
fi
|
|
669
708
|
case "$tool" in
|
|
670
709
|
Edit|Write|MultiEdit|NotebookEdit)
|
|
671
710
|
fp=$(printf '%s' "$input" | jq -r '.tool_input.file_path // .tool_input.notebook_path // .tool_input.path // empty' 2>/dev/null)
|
|
@@ -93,7 +93,7 @@ At the start of the next turn, recover intent in this order:
|
|
|
93
93
|
2. For short assent, read back the original wording of the most recent still-active concrete proposal and check that later messages or task state have not withdrawn or superseded its action, scope, or authority. Quote that original proposal when stating the recovered action and scope; a summary or paraphrase alone cannot bind short assent. If recovery adds an action or broadens that quoted scope, select `blocked:` and ask. One recoverable action can bind with or without a marker; a stale, repeated, or conflicting marker is an assistant formatting defect to repair.
|
|
94
94
|
3. If materially different proposals remain unresolved, or scope/authority is still unclear, ask one targeted question about that uncertainty. Do not ask the user to repair a marker or repeat a clear instruction. A marker alone never supplies missing authority.
|
|
95
95
|
|
|
96
|
-
Use the active owner's entry and safety gates for the recovered action. An authorized task includes necessary fixes, tests and review by default; neither a router nor a dispatched owner may discard that authority by relabeling its turn or exhausting an internal review sequence. Apply the owning review checkpoint and record `continuation_basis=existing-task-scope` with cumulative history in the caller-owned task artifact, not runtime JSON. Legacy `human_decision_required` / `continuation_authorization_required` values first require checking existing authority, not asking again. Explicit user cost, round-count and stop limits prevail; new scope, missing authority or real tradeoffs need a decision. Continuation grants no merge, publication or waiver authority.
|
|
96
|
+
- Use the active owner's entry and safety gates for the recovered action. An authorized task includes necessary fixes, tests and review by default; neither a router nor a dispatched owner may discard that authority by relabeling its turn or exhausting an internal review sequence. Apply the owning review checkpoint and record `continuation_basis=existing-task-scope` with cumulative history in the caller-owned task artifact, not runtime JSON. Legacy `human_decision_required` / `continuation_authorization_required` values first require checking existing authority, not asking again. Explicit user cost, round-count and stop limits prevail; new scope, missing authority or real tradeoffs need a decision. Continuation grants no merge, publication or waiver authority. Intent recovery and authorization remain prose obligations. The optional `proposed-next-stop.sh` backstop checks only missing handoff labels on hosts providing a current final message and observable delivery evidence; a label or hook receipt never proves the action is correct, authorized or complete.
|
|
97
97
|
|
|
98
98
|
## Gate triggers and outcome contract
|
|
99
99
|
|
|
@@ -697,3 +697,7 @@ The pending classification above is superseded by the executed source comparison
|
|
|
697
697
|
| 评审模式把被评审仓库自己入库的约定文件引在候选之后交给评审员:从仓库根到每个改动路径的每层目录取 `AGENTS.override.md`(否则 `AGENTS.md`)、`CLAUDE.md`、`.claude/CLAUDE.md`,根在前,32 KiB 以内;未入库和 `CLAUDE.local.md` 一律不读(不得外发给别家评审模型),链接、非 UTF-8、超预算的整份省略并记原因,永不因约定文件让评审失败;有引用时加 `repository_contract` concern,要求评审员报改动违反的规则和规则本身的缺陷(`Contract defect:`),规则只作数据、不能为缺陷开脱 | `code-review` | result-class: failure; behavioral-evidence: RED-baseline; observed-failure: no; firing-path: command:skills/code-review/scripts/test_review_gate.sh | `updated` | Owner key `code-review/SKILL.md`(入口未改);改动在 `skills/code-review/scripts/review_gate.py`(`repository_contract_section`、profile 与结果的 `repository_contract` 字段、条件 concern、trust boundary 一句)、`skills/code-review/references/staged-review-contract.md`、`skills/code-review/references/development-completion.md`(偏离约定要在 plan intent 里声明、`Contract defect:` 的处置、不许悄悄放松约定文件),`skills/code-review/scripts/test_review_client_compat.py` 的 provider mock 改为放行控制器自己的 git 读取;共享读取函数 `read_bounded_regular_file` 逐级打开目录中途失败时不再泄漏已持有的目录描述符(修复前 100 次失败读取后打开的描述符由 4 个涨到 254 个,约定文件被逐个省略时会反复触发)。发现规则按两家宿主一手文档核对(Codex:每层目录 override 优先、根到深拼接、默认 32 KiB;Claude Code:`CLAUDE.md` 或 `.claude/CLAUDE.md`,`CLAUDE.local.md` 是个人文件)。`candidate_sha256` 在追加前算定,引用内容不能增加候选路径;challenge、complete、wording-only 不附加。不读 `@path` 导入、`.claude/rules`、宿主配置的备用文件名(结果里 `sources_not_read` 列明)。RED-baseline(applied,differential):在副本上分别去掉「只收已入库文件」、去掉 override 优先、不加 concern,三条约定用例里恰好两条变红、其余用例全绿;base 版控制器配新套件时三条约定用例全红;中间目录被换成链接、带第二个硬链接的约定文件都被省略且内容不进评审包。 |
|
|
698
698
|
| 产品研发验证门里「评审后有实质改动才全量重跑」的「实质」一词删除:评审后的任何改动(含测试 / 文档)都触发全量重跑,作者不能自行判定改动无关紧要 | `product-rd-workflow` | result-class: failure; behavioral-evidence: RED-baseline; observed-failure: yes; firing-path: file:skills/product-rd-workflow/SKILL.md#any post-review change, tests/docs included | `updated` | Owner key `product-rd-workflow/SKILL.md`(改一句,字数与字节都不增)。Observed failure 同上一条生产会话:它把评审后的提交归为「只有测试和文档」而没有重审,原句的「实质」正好给了这个归类余地。RED-baseline 同上:红的一半是生产失效,隔离探针在改前文本上未复现,改动效果未被证明。 |
|
|
699
699
|
| 提炼流程的增量复查上限由两次改为五次(用户裁决),同步 `skills/skill-extraction-workflow/SKILL.md`、`dual-track-review-gate.md`、`extraction-quickstart.md` 与被测试钉住的原文;本行按指针取代 127 轮评审线那一行里的「最多两次」 | `skill-extraction-workflow` | result-class: failure; behavioral-evidence: RED-baseline; observed-failure: no; firing-path: file:skills/skill-extraction-workflow/references/dual-track-review-gate.md#After five delta passes a still-open P0/P1 | `updated` | Owner key `skill-extraction-workflow/SKILL.md`(同一行内省 6 字节,入口不增长)。delta 复查改为 `--base <已评审提交>` 取增量,使它绑定工作区并留下 PR hook 读取的本机回执。RED-baseline(applied):`test_extraction_review_gate.sh` 钉住的短语由两次改为五次后,对 base 文本变红、对 head 文本变绿。 |
|
|
700
|
+
| Host boundary normalization preserves shared isolation and authorization rules while native inventory separates installation from trust | `worktree-isolation` / `multi-agent-delegation` / `skill-extraction-workflow` | result-class: failure; behavioral-evidence: RED-baseline; observed-failure: yes; firing-path: command:hooks/guard-edit-isolation.sh | updated | `specs/129-host-hook-compatibility/plan.md` binds synthetic baseline failures, patch/delegation transcript normalization, bounded target authorization, startup context budgets and native trust diagnostics. Tests cover malformed/multiple/moved paths, incomplete skill reads, revoked/expired/wrong-target grants and incomplete or wrong-plugin hook inventories. Trust receipts remain distinct from runtime effect. |
|
|
701
|
+
| Missing delivery handoffs receive a bounded current-message formatting reminder | `product-rd-workflow` | result-class: failure; behavioral-evidence: RED-baseline; observed-failure: yes; firing-path: file:skills/product-rd-workflow/references/pre-final-continuation-gate.md#checks only missing handoff labels | updated | `product-rd-workflow/SKILL.md` owns the shared handoff rule; `specs/129-host-hook-compatibility/plan.md` records the absent-hook baseline and 18 native-shaped cases. The startup cue and Stop backstop preserve current user scope, pure artifacts and host loop prevention. A marker proves neither authorization nor completion; unsupported host final-message data remains unverifiable. |
|
|
702
|
+
| Catalog fixtures pair candidate contracts with candidate hook executables | `skill-extraction-workflow` | result-class: failure; behavioral-evidence: RED-baseline; observed-failure: yes; firing-path: command:skills/skill-extraction-workflow/scripts/test_check_ccl_skill_catalog.sh | updated | `skill-extraction-workflow/SKILL.md` owns the fixture contract. The pristine case failed when a candidate ledger referenced a new hook absent from the committed clone. Copying the candidate hook tree with its skill contracts restores the complete fixture; the existing catalog suite passes without changing gate predicates. |
|
|
703
|
+
| Source editing and delegation receive one bounded skill-loading replan per actor and current context | `product-rd-workflow` / `multi-agent-delegation` | result-class: failure; behavioral-evidence: RED-baseline; observed-failure: yes; firing-path: command:hooks/test_skill_loading.py | updated | `agent-context/session-start.md` supplies the transition rule and `hooks/skill-loading.py` implements its bounded checkpoint; both named owner skill entries are unchanged in this round. `specs/129-host-hook-compatibility/plan.md` records the missing default first-edit checkpoint, stale post-compaction evidence and incomplete-read false positive. The checkpoint defers one precise edit or cold dispatch attempt, uses the canonical transition rule, never asks the user to approve skill loading, and preserves configured decisions. PreCompact snapshots the previous native boundary; PostCompact invalidates old visibility, and only a confirmed new boundary permits fresh full-read evidence. Guidance is delivered on subsequent PreToolUse. A capped retry or parallel sibling can proceed, so a reminder is neither correct-owner proof nor a substitute for opt-in enforcement. Native execution and model adherence are separate validation claims. |
|
|
@@ -60,6 +60,11 @@ new_case() {
|
|
|
60
60
|
rm -rf "$CASE_DIR/skills"
|
|
61
61
|
mkdir -p "$CASE_DIR/skills"
|
|
62
62
|
cp -R "$candidate_skill_root/skills/." "$CASE_DIR/skills/"
|
|
63
|
+
# Candidate ledger firing paths may name a newly added shipped hook. Keep the
|
|
64
|
+
# executable surface with its candidate contracts instead of committed HEAD.
|
|
65
|
+
rm -rf "$CASE_DIR/hooks"
|
|
66
|
+
mkdir -p "$CASE_DIR/hooks"
|
|
67
|
+
cp -R "$REPO_ROOT/hooks/." "$CASE_DIR/hooks/"
|
|
63
68
|
cp "$REPO_ROOT/docs/SKILLS.md" "$CASE_DIR/docs/SKILLS.md"
|
|
64
69
|
cp "$REPO_ROOT/agent-context/session-start.md" "$CASE_DIR/agent-context/session-start.md"
|
|
65
70
|
cp "$candidate_eval_root/eval/routing-tasks.jsonl" "$CASE_DIR/eval/routing-tasks.jsonl"
|
|
@@ -147,7 +147,7 @@ worktree 的活一旦**集成进目标分支**就完了,立刻清理(唯一
|
|
|
147
147
|
**合并执行协议(canonical——always-on 层「硬纪律 1」指向本节,两面同步修改;执行配方只放这里,不进 always-on 层)**:
|
|
148
148
|
1. **按目标判断授权**:用户已要求“做完并合并”“发布这个版本”等端到端结果时,必需的提交、推送、创建/更新 MR、平台合并和既定发布步骤默认已授权;不要求等 MR 创建后再说一次“合并”。授权限于当前目标,持续至完成、撤回或范围变更;普通补充消息和范围内修复不撤销目标授权。agent 先展示已核对的范围、源→目标、MR 链接、head SHA、CI/验证状态和执行顺序;展示是执行义务,不新增审批。只要求单项、准备或待审时不得扩展成发布。单个“合并”仍指当前唯一 MR;显式“批量合并 N”仍只覆盖已展示计划内至多 N 次合并(该计数授权 4 小时有效,用户新消息清除剩余额度)。目标不明、混入无关变更或额外高风险动作时,只暂停对应动作并确认。
|
|
149
149
|
2. **变化先核验**:目标/批量授权内由 agent 完成的修复、新提交或新建 MR,先刷新检查、评审与状态,不重复请求权限。单个对象授权后 head 改变、混入第三方或目标外内容、或多个 MR 指向不明时再确认。CI 从运行中变为通过本身不是权限失效;失败和冲突先诊断修复,不能绕过门禁。
|
|
150
|
-
|
|
150
|
+
**宿主机械放行阀**:平台合并前读 `references/hook-authorization.md`,核对锚定指令、仓库/编号、额度期限及暂停/撤销。它不推导发布目标或未来 PR 归属;机械额度缺失、暂停或过期不等于目标授权不存在。若真实宿主拒绝且没有已获授权的正常审批路径,说明宿主限制并请求最小放行,不得自行写哨兵、关闸或换工具绕过。直推默认分支、auto-merge/排队/`--admin` 及一条命令内多个合并仍不放行;其他宿主按实际权限机制和上述目标边界执行。
|
|
151
151
|
3. **执行建议(agent 防呆,不增加用户负担)**:获授权后的执行一次性立即合并、不转 auto-merge/排队;显式点名目标 MR/PR(glab/gh 缺省都解析"当前分支",同分支多 MR/PR 时会合错对象);建议把自己已知的 head SHA 作为守卫传给命令:`glab mr merge <iid> --sha <head SHA> --auto-merge=false --yes` / `gh pr merge <PR号|URL> --merge --match-head-commit <head SHA>`(合并策略显式给 `--merge`/`--squash`/`--rebase`,缺省会进交互)。守卫被平台拒绝时重新读取目标并按第 2 条核验授权范围。**一次性合并授权按「命令被放行」消耗,不按「合并成功」消耗**:命令因你自己的参数错误而失败(自造不存在的 flag、SHA 用前缀而非平台现读的完整值、点错 MR 号)同样烧掉这次授权,该机械额度需重新放行;没有此宿主限制的目标授权不因参数错误失效,确认前次未合并后修正重试。所以执行前把 flag 与取值当成不可凭记忆的东西核一遍——**flag 拼写以本机该 CLI 的 `--help` 为准**(同名工具跨版本/跨平台差异很大,"我记得有这个 flag" 是最常见的烧授权方式),**SHA 一律从平台 API 现读完整值**(前缀补全会被守卫拒成 409)。已实测两次:一次前缀补全 409,一次自造 `--merge`(该版本 glab 无此 flag,合并策略缺省即 merge commit)——守卫两次都按设计挡住了错误合并,代价都是让用户重新授权一次。
|
|
152
152
|
4. **仓库策略例外**:仓库强制 merge queue / auto-merge、或只能直推默认分支时,停下把该仓的合并语义摆给用户裁决,不得套用立即合并流程近似执行。
|
|
153
153
|
5. **合并后自查**:合并后核对实际合入内容与本次交付预期一致,发现超出如实报告用户裁决(回滚/接受),不得静默带过。
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Hook 合并授权
|
|
2
|
+
|
|
3
|
+
以下执行额度规则受 [合并执行协议](../SKILL.md) 的目标授权、验证和安全边界约束。`hooks/merge-authorization-prompt.sh` 从用户指令生成额度,`hooks/guard-merge-authorization.sh` 在平台合并命令放行时消费额度。
|
|
4
|
+
|
|
5
|
+
## 指令与有效期
|
|
6
|
+
|
|
7
|
+
支持原有单独“合并/merge”和“批量合并 N”,另支持完整单行“完成并合并 PR #123 / MR !123”(英文 `finish and merge PR #123`)。原有单次/计数额度仍被任何新消息清除。
|
|
8
|
+
|
|
9
|
+
新形式只绑定当前 `origin` 仓库和指定编号,原始 60 分钟内消费一次;单独“继续/继续吧/进度/状态/continue/status/progress”保留原额度和到期时间,“停止/停一下/不要合并/撤销合并授权/stop/pause/cancel merge”撤销,其他消息暂停机械额度,之后“继续”不能恢复。
|
|
10
|
+
|
|
11
|
+
## 执行命令
|
|
12
|
+
|
|
13
|
+
新形式的执行命令必须是单条直接 `gh pr merge` 或 `glab mr merge`,显式编号;gh 使用 `--repo host/owner/repo` 并指定策略,glab 使用 `--repo https://host/namespace/repo` 并指定 `--auto-merge=false --yes`,可附完整 head SHA,其他参数和 API 形式保持未核验。
|