@ccoalm/ccl-skills 0.18.0 → 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.
Files changed (36) hide show
  1. package/README.md +2 -0
  2. package/dist/assets/marketplace/plugins/ccl-skills/agent-context/session-policy.md +50 -0
  3. package/dist/assets/marketplace/plugins/ccl-skills/agent-context/session-start.md +30 -40
  4. package/dist/assets/marketplace/plugins/ccl-skills/hooks/guard-delegation-owner.sh +9 -122
  5. package/dist/assets/marketplace/plugins/ccl-skills/hooks/guard-edit-isolation.sh +43 -9
  6. package/dist/assets/marketplace/plugins/ccl-skills/hooks/guard-merge-authorization.sh +95 -2
  7. package/dist/assets/marketplace/plugins/ccl-skills/hooks/hooks.json +32 -1
  8. package/dist/assets/marketplace/plugins/ccl-skills/hooks/host-input.py +544 -0
  9. package/dist/assets/marketplace/plugins/ccl-skills/hooks/merge-authorization-prompt.sh +103 -26
  10. package/dist/assets/marketplace/plugins/ccl-skills/hooks/owner-dispatch-guard.sh +17 -8
  11. package/dist/assets/marketplace/plugins/ccl-skills/hooks/proposed-next-stop.sh +14 -0
  12. package/dist/assets/marketplace/plugins/ccl-skills/hooks/session-start.sh +31 -2
  13. package/dist/assets/marketplace/plugins/ccl-skills/hooks/skill-context-compact.sh +10 -0
  14. package/dist/assets/marketplace/plugins/ccl-skills/hooks/skill-extraction-gate-stop.sh +13 -4
  15. package/dist/assets/marketplace/plugins/ccl-skills/hooks/skill-loading.py +277 -0
  16. package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_guard_delegation_owner.sh +61 -55
  17. package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_guard_merge_authorization.sh +140 -0
  18. package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_host_input.py +597 -0
  19. package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_merge_authorization_prompt.sh +56 -3
  20. package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_proposed_next.py +260 -0
  21. package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_session_start.sh +85 -3
  22. package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_skill_loading.py +278 -0
  23. package/dist/assets/marketplace/plugins/ccl-skills/packages/opencode-plugin/ccl-skills.ts +28 -7
  24. package/dist/assets/marketplace/plugins/ccl-skills/scripts/owner-dispatch/README.md +23 -15
  25. package/dist/assets/marketplace/plugins/ccl-skills/scripts/owner-dispatch/owner-dispatch.sh +41 -2
  26. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/pre-final-continuation-gate.md +1 -1
  27. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +4 -0
  28. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_skill_catalog.sh +5 -0
  29. package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/SKILL.md +1 -1
  30. package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/references/hook-authorization.md +13 -0
  31. package/dist/assets/release.json +80 -25
  32. package/dist/codex-hooks.d.ts +15 -0
  33. package/dist/codex-hooks.js +186 -0
  34. package/dist/opencode-adapter.js +8 -3
  35. package/dist/operations.js +13 -9
  36. package/package.json +1 -1
@@ -7,7 +7,7 @@
7
7
  set -u
8
8
 
9
9
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
10
- HOOK="$SCRIPT_DIR/merge-authorization-prompt.sh"
10
+ HOOK="${HOOK:-$SCRIPT_DIR/merge-authorization-prompt.sh}"
11
11
  [ -f "$HOOK" ] || { echo "FAIL: hook not found: $HOOK" >&2; exit 1; }
12
12
  command -v jq >/dev/null 2>&1 || { echo "FAIL: jq required for this suite" >&2; exit 1; }
13
13
 
@@ -18,11 +18,14 @@ SID="sess-prompt-test"
18
18
  AUTH_DIR="$tmp/ccl-skills-merge-auth-$(id -u)"
19
19
  SENT="$AUTH_DIR/$SID"
20
20
 
21
+ git -C "$tmp" init -q repo
22
+ git -C "$tmp/repo" remote add origin https://example.invalid/team/project.git
23
+
21
24
  pass=0; fail=0
22
25
 
23
26
  send() { # send <prompt> [sid]
24
27
  local p="$1" s="${2-$SID}"
25
- jq -nc --arg p "$p" --arg s "$s" '{prompt:$p,session_id:$s}' | TMPDIR="$tmp" bash "$HOOK"
28
+ jq -nc --arg p "$p" --arg s "$s" --arg w "$tmp/repo" '{prompt:$p,session_id:$s,cwd:$w}' | TMPDIR="$tmp" bash "$HOOK"
26
29
  }
27
30
 
28
31
  expect_armed() { # expect_armed <prompt> [expected sentinel content]
@@ -160,8 +163,9 @@ fi
160
163
  rm -f "$SENT"
161
164
 
162
165
  # --- degrade / hostile inputs: no session_id, path-traversal session_id ---
166
+ before_missing_sid=$(ls -A "$AUTH_DIR" 2>/dev/null)
163
167
  jq -nc '{prompt:"合并"}' | TMPDIR="$tmp" bash "$HOOK"
164
- if [ ! -d "$AUTH_DIR" ] || [ -z "$(ls -A "$AUTH_DIR" 2>/dev/null)" ]; then pass=$((pass+1)); else
168
+ if [ "$(ls -A "$AUTH_DIR" 2>/dev/null)" = "$before_missing_sid" ]; then pass=$((pass+1)); else
165
169
  fail=$((fail+1)); echo 'FAIL missing session_id must be a no-op' >&2
166
170
  fi
167
171
  send '合并' '../evil'
@@ -171,6 +175,55 @@ fi
171
175
  out=$(printf 'not-json' | TMPDIR="$tmp" bash "$HOOK")
172
176
  if [ -z "$out" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL bad json -> $out" >&2; fi
173
177
 
178
+ # Target goals have a closed grammar and retain their original deadline.
179
+ expect_goal() {
180
+ send "$1"
181
+ if sed -n '1p' "$SENT" 2>/dev/null | jq -e '.kind == "target-goal" and .state == "active" and .id == "123" and .repo == "example.invalid/team/project"' >/dev/null 2>&1; then
182
+ pass=$((pass+1))
183
+ else fail=$((fail+1)); echo "FAIL target goal not armed: $1" >&2; fi
184
+ }
185
+ expect_goal '完成并合并 PR #123'
186
+ expect_goal 'finish and merge PR #123'
187
+ for bad in '完成并合并 PR #123?' '不要完成并合并 PR #123' '如果通过,完成并合并 PR #123' '“完成并合并 PR #123”' '完成并合并 PR #123 然后发布' '完成并合并 PR #0123' '发布下个 npm 补丁'; do
188
+ expect_not_armed "$bad"
189
+ done
190
+ for neutral in '继续' '继续吧' '进度' '状态' 'continue' 'status' 'progress'; do
191
+ rm -f "$SENT"
192
+ send "$neutral"
193
+ if [ ! -f "$SENT" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL neutral created authority: $neutral" >&2; fi
194
+ expect_goal '完成并合并 PR #123'
195
+ old_ts=$(date -v-30M +%Y%m%d%H%M 2>/dev/null || date -d '-30 minutes' +%Y%m%d%H%M)
196
+ touch -t "$old_ts" "$SENT"
197
+ cp -p "$SENT" "$tmp/before-neutral"
198
+ send "$neutral"
199
+ if cmp -s "$SENT" "$tmp/before-neutral" && [ ! "$SENT" -nt "$tmp/before-neutral" ]; then
200
+ pass=$((pass+1))
201
+ else fail=$((fail+1)); echo "FAIL neutral must preserve scope and original expiry: $neutral" >&2; fi
202
+ done
203
+ expect_goal '完成并合并 PR #123'
204
+ send '先改 README'
205
+ if sed -n '1p' "$SENT" 2>/dev/null | jq -e '.state == "suspended"' >/dev/null 2>&1; then pass=$((pass+1)); else fail=$((fail+1)); echo 'FAIL unresolved message must suspend goal' >&2; fi
206
+ send '继续'
207
+ if sed -n '1p' "$SENT" 2>/dev/null | jq -e '.state == "suspended"' >/dev/null 2>&1; then pass=$((pass+1)); else fail=$((fail+1)); echo 'FAIL neutral must not reactivate suspended goal' >&2; fi
208
+ for revoke in '停止' '停一下' '不要合并' '撤销合并授权' 'stop' 'pause' 'cancel merge'; do
209
+ expect_goal '完成并合并 PR #123'
210
+ send "$revoke"
211
+ if [ ! -f "$SENT" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL explicit revocation: $revoke" >&2; fi
212
+ done
213
+ send '批量合并 3'
214
+ send '继续'
215
+ if [ ! -f "$SENT" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo 'FAIL legacy batch still clears on neutral prompt' >&2; fi
216
+ git -C "$tmp/repo" remote set-url origin 'https://user:password@example.invalid/team/project.git'
217
+ expect_not_armed '完成并合并 PR #123'
218
+ git -C "$tmp/repo" remote set-url origin 'git@example.invalid:team/project.git'
219
+ expect_goal '完成并合并 PR #123'
220
+ git -C "$tmp/repo" remote set-url origin 'ssh://git@example.invalid/team/project.git'
221
+ expect_goal '完成并合并 PR #123'
222
+ git -C "$tmp/repo" remote set-url origin $'https://example.invalid/team/project.git\nhttps://user:password@example.org/team/other'
223
+ expect_not_armed '完成并合并 PR #123'
224
+ git -C "$tmp/repo" remote remove origin
225
+ expect_not_armed '完成并合并 PR #123'
226
+
174
227
  if [ "$fail" -ne 0 ]; then
175
228
  echo "test_merge_authorization_prompt: FAIL pass=$pass fail=$fail" >&2
176
229
  exit 1
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env python3
2
+ """Synthetic native Stop payloads; no real host state or conversations."""
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import shutil
7
+ import subprocess
8
+ import tempfile
9
+ import unittest
10
+
11
+ ROOT = Path(__file__).resolve().parents[1]
12
+
13
+
14
+ class ProposedNextTests(unittest.TestCase):
15
+ def setUp(self):
16
+ self.tmp = tempfile.TemporaryDirectory()
17
+ self.addCleanup(self.tmp.cleanup)
18
+ self.root = Path(self.tmp.name)
19
+ self.hooks = self.root / 'hooks'
20
+ self.hooks.mkdir()
21
+ for name in ('host-input.py', 'proposed-next-stop.sh'):
22
+ source = ROOT / 'hooks' / name
23
+ if source.exists():
24
+ shutil.copyfile(source, self.hooks / name)
25
+ self.skill = self.root / 'skills/product-rd-workflow/SKILL.md'
26
+ self.skill.parent.mkdir(parents=True)
27
+ source = (ROOT / 'skills/product-rd-workflow/SKILL.md').read_text()
28
+ self.contract = next(p for p in source.split('\n\n') if p.startswith(
29
+ '**Continuation-proposal output contract (session-wide for product delivery).**'))
30
+ self.skill.write_text('---\nname: product-rd-workflow\n---\n\n' + self.contract + '\n')
31
+ self.path = self.root / 'synthetic.jsonl'
32
+ self.path.write_text('')
33
+ self.payload = {'session_id': 'synthetic', 'cwd': str(self.root),
34
+ 'transcript_path': str(self.path), 'hook_event_name': 'Stop',
35
+ 'stop_hook_active': False, 'last_assistant_message': 'Checks passed.'}
36
+
37
+ def events(self, events):
38
+ self.path.write_text(''.join(json.dumps(e) + '\n' for e in events))
39
+
40
+ def run_hook(self, payload=None):
41
+ value = self.payload if payload is None else payload
42
+ raw = value if isinstance(value, str) else json.dumps(value)
43
+ result = subprocess.run(['bash', str(self.hooks / 'proposed-next-stop.sh')],
44
+ input=raw, text=True, capture_output=True, cwd=self.root)
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 claude_load(self, name='product-rd-workflow', error=False):
50
+ return [
51
+ {'type': 'assistant', 'message': {'content': [{'type': 'tool_use', 'id': 'load',
52
+ 'name': 'Skill', 'input': {'skill': 'ccl-skills:' + name}}]}},
53
+ {'type': 'user', 'message': {'content': [{'type': 'tool_result',
54
+ 'tool_use_id': 'load', 'is_error': error, 'content': 'Skill loaded'}]}}]
55
+
56
+ def codex_read(self, command=None, body=None, code=0, call_id='load', output_id='load'):
57
+ return [
58
+ {'type': 'response_item', 'payload': {'type': 'function_call', 'name': 'exec_command',
59
+ 'call_id': call_id, 'arguments': json.dumps({'cmd': command or f'cat {self.skill}'})}},
60
+ {'type': 'response_item', 'payload': {'type': 'function_call_output', 'call_id': output_id,
61
+ 'output': f'Process exited with code {code}\nOutput:\n' + (
62
+ self.skill.read_text() if body is None else body)}}]
63
+
64
+ def assistant(self, text, host='codex', phase='final_answer'):
65
+ if host == 'claude':
66
+ return {'type': 'assistant', 'message': {'content': [{'type': 'text', 'text': text}]}}
67
+ return {'type': 'response_item', 'payload': {'type': 'message', 'role': 'assistant',
68
+ 'phase': phase, 'content': [{'type': 'output_text', 'text': text}]}}
69
+
70
+ def assert_block(self, value):
71
+ self.assertEqual(value.get('decision'), 'block', value)
72
+ self.assertEqual(set(value), {'decision', 'reason'})
73
+ self.assertIn('proposed-next:', value['reason'])
74
+
75
+ def test_native_claude_and_codex_completed_owner(self):
76
+ for host, events in [('claude', self.claude_load()), ('codex', self.codex_read())]:
77
+ with self.subTest(host=host):
78
+ self.events(events)
79
+ payload = dict(self.payload)
80
+ if host == 'codex':
81
+ payload.update(turn_id='synthetic-turn', model='synthetic-model',
82
+ permission_mode='default')
83
+ self.assert_block(self.run_hook(payload))
84
+
85
+ def test_direct_final_overrides_stale_transcript_answer(self):
86
+ self.events(self.claude_load() + [self.assistant('proposed-next: verify the local patch')])
87
+ self.assert_block(self.run_hook())
88
+ self.payload['last_assistant_message'] = 'Done.\nproposed-next: none — status only'
89
+ self.assertEqual(self.run_hook(), {})
90
+
91
+ def test_loop_and_malformed_fields_are_quiet(self):
92
+ self.events(self.claude_load())
93
+ for updates in ({'stop_hook_active': True}, {'stop_hook_active': 'false'},
94
+ {'stop_hook_active': 0}, {'last_assistant_message': None},
95
+ {'last_assistant_message': ''}, {'last_assistant_message': []},
96
+ {'hook_event_name': 'SubagentStop'}, {'hook_event_name': 'StopFailure'}):
97
+ with self.subTest(updates=updates):
98
+ self.assertEqual(self.run_hook(dict(self.payload, **updates)), {})
99
+ for field in ('last_assistant_message', 'stop_hook_active', 'hook_event_name'):
100
+ payload = dict(self.payload)
101
+ del payload[field]
102
+ self.assertEqual(self.run_hook(payload), {})
103
+ for payload in ('[]', 'null', '{}'):
104
+ self.assertEqual(self.run_hook(payload), {})
105
+
106
+ def test_plain_answers_and_unrelated_owner_do_not_trigger(self):
107
+ for events in ([], self.claude_load('testing-strategy'), self.claude_load(error=True),
108
+ self.claude_load()[:1]):
109
+ self.events(events)
110
+ self.assertEqual(self.run_hook(), {})
111
+
112
+ def test_prior_assistant_handoff_is_eligibility_for_both_hosts(self):
113
+ for host in ('claude', 'codex'):
114
+ self.events([self.assistant('proposed-next: verify the local patch', host)])
115
+ self.assert_block(self.run_hook())
116
+
117
+ def test_quoted_examples_and_commentary_do_not_establish_eligibility(self):
118
+ for text in ('> proposed-next: run checks', '```text\nproposed-next: run checks\n```',
119
+ 'Example: proposed-next: run checks', 'proposed-next: <action and scope>',
120
+ '`proposed-next: run checks`', ' proposed-next: code example',
121
+ '```text\n```not a closing fence\nproposed-next: code example\n```'):
122
+ self.events([self.assistant(text)])
123
+ self.assertEqual(self.run_hook(), {})
124
+ self.events([self.assistant('proposed-next: run checks', phase='commentary')])
125
+ self.assertEqual(self.run_hook(), {})
126
+ self.events([{'type': 'response_item', 'payload': {'type': 'message', 'role': 'user',
127
+ 'content': [{'type': 'input_text', 'text': 'proposed-next: run checks'}]}}])
128
+ self.assertEqual(self.run_hook(), {})
129
+
130
+ def test_marker_in_quote_does_not_satisfy_current_handoff(self):
131
+ self.events(self.claude_load())
132
+ for text in ('Done.\n> proposed-next: run checks',
133
+ 'Done.\n```text\nproposed-next: run checks\n```',
134
+ 'Done.\nproposed-next: <action and scope>', 'Done.\nproposed-next: '):
135
+ self.payload['last_assistant_message'] = text
136
+ self.assert_block(self.run_hook())
137
+
138
+ def test_status_question_and_user_stop_keep_truthful_formatting(self):
139
+ self.events(self.claude_load())
140
+ for text in ('Which option should I use?', 'Stopped as requested.', 'Status: checks passed.'):
141
+ self.payload['last_assistant_message'] = text
142
+ self.assert_block(self.run_hook())
143
+ for text in ('proposed-next: none — status only', '**proposed-next:** none — status only',
144
+ 'proposed-next: run the existing local verification suite'):
145
+ self.payload['last_assistant_message'] = text
146
+ self.assertEqual(self.run_hook(), {})
147
+
148
+ def test_complete_machine_artifacts_are_preserved(self):
149
+ self.events(self.claude_load())
150
+ for text in ('{"status":"done"}', '[1,2]', 'true', '"done"', '42',
151
+ '```json\n{"status":"done"}\n```', '~~~text\nexact output\n~~~'):
152
+ self.payload['last_assistant_message'] = text
153
+ self.assertEqual(self.run_hook(), {})
154
+ self.payload['last_assistant_message'] = '```json\n{}\n```\nChecks passed.\n```text\nexample\n```'
155
+ self.assert_block(self.run_hook())
156
+
157
+ def test_missing_optional_canonical_rule_does_not_disable_other_evidence(self):
158
+ self.skill.unlink()
159
+ for events in (self.claude_load(), [self.assistant('proposed-next: run local checks')]):
160
+ self.events(events)
161
+ self.assert_block(self.run_hook())
162
+ self.events([])
163
+ self.assertEqual(self.run_hook(), {})
164
+
165
+ def test_successful_fragment_read_is_visibility_not_completed_owner(self):
166
+ for command, body in [(f'sed -n \'10,12p\' {self.skill}', self.contract),
167
+ (f'printf before; sed -n \'1,20p\' {self.skill}', self.skill.read_text())]:
168
+ with self.subTest(command=command):
169
+ self.events(self.codex_read(command, body))
170
+ self.assert_block(self.run_hook())
171
+ result = subprocess.run(['python3', str(self.hooks / 'host-input.py'),
172
+ 'transcript', str(self.path), str(self.root)],
173
+ capture_output=True, text=True, check=True)
174
+ summary = json.loads(result.stdout)
175
+ self.assertEqual(summary['completed_skills'], [])
176
+ self.assertTrue(summary['continuation_contract_visible'])
177
+
178
+ def test_visibility_requires_whole_current_rule_and_terminal_matching_output(self):
179
+ command = f'sed -n \'10,12p\' {self.skill}'
180
+ for label, events in [
181
+ ('pending', self.codex_read(command)[:1]),
182
+ ('error', self.codex_read(command, self.contract, code=1)),
183
+ ('mismatch', self.codex_read(command, self.contract, output_id='unpaired')),
184
+ ('truncated', self.codex_read(command, 'Warning: truncated output\n' + self.contract)),
185
+ ('partial-rule', self.codex_read(command, self.contract[:120])),
186
+ ('strings-only', self.codex_read(command, 'proposed-next: <action and scope>')),
187
+ ]:
188
+ with self.subTest(label=label):
189
+ self.events(events)
190
+ self.assertEqual(self.run_hook(), {})
191
+ events = self.codex_read(command)
192
+ events[1]['payload']['output'] = 'Process running with session ID 1\nOutput:\n' + self.contract
193
+ self.events(events)
194
+ self.assertEqual(self.run_hook(), {})
195
+
196
+ def test_claude_successful_read_visibility_is_not_a_skill_load(self):
197
+ request = {'type': 'assistant', 'message': {'content': [{'type': 'tool_use', 'name': 'Read',
198
+ 'id': 'read', 'input': {'file_path': str(self.skill)}}]}}
199
+ for error, expected in ((False, 'block'), (True, None)):
200
+ self.events([request, {'type': 'user', 'message': {'content': [{'type': 'tool_result',
201
+ 'tool_use_id': 'read', 'is_error': error,
202
+ 'content': [{'type': 'text', 'text': self.contract}]}]}}])
203
+ self.assertEqual(self.run_hook().get('decision'), expected)
204
+
205
+ def test_visibility_tracks_canonical_rule_without_copied_matching_prose(self):
206
+ self.contract += ' Synthetic changed obligation.'
207
+ self.skill.write_text('---\nname: product-rd-workflow\n---\n\n' + self.contract + '\n')
208
+ self.events(self.codex_read('sed -n \'1,20p\' skills/product-rd-workflow/SKILL.md', self.contract))
209
+ self.assert_block(self.run_hook())
210
+
211
+ def test_internal_errors_warn_without_leaking_inputs(self):
212
+ self.events(self.claude_load())
213
+ self.payload['last_assistant_message'] = 'Private synthetic payload, never echo this.'
214
+ first = self.run_hook()
215
+ self.payload['last_assistant_message'] = 'Another synthetic payload.'
216
+ self.assertEqual(self.run_hook(), first)
217
+ self.assertNotIn('synthetic', json.dumps(first))
218
+ result = self.run_hook('{invalid secret fixture')
219
+ self.assertIn('unavailable', result.get('systemMessage', ''))
220
+ self.assertNotIn('secret', json.dumps(result))
221
+ (self.hooks / 'host-input.py').unlink()
222
+ self.assertIn('unavailable', self.run_hook().get('systemMessage', ''))
223
+
224
+ def test_scan_is_bounded_and_no_filesystem_markers_are_written(self):
225
+ self.events([{'type': 'ignored'}] * 20000 + self.claude_load())
226
+ before = set(self.root.rglob('*'))
227
+ self.assertIn('unverified', self.run_hook().get('systemMessage', '').lower())
228
+ self.assertEqual(set(self.root.rglob('*')), before)
229
+ self.events([{'type': 'ignored'}] * 20000)
230
+ self.assertEqual(self.run_hook(), {})
231
+ self.events(self.claude_load())
232
+ self.assert_block(self.run_hook())
233
+ self.assert_block(self.run_hook()) # A new host turn must not be suppressed by session id.
234
+ self.assertEqual(set(self.root.rglob('*')), before)
235
+
236
+ def test_oversized_or_malformed_input_fails_soft_without_transcript_echo(self):
237
+ self.path.write_text(json.dumps({'type': 'ignored', 'text': 'x' * (1024 * 1024)}) + '\n')
238
+ self.assertIn('unverified', self.run_hook().get('systemMessage', '').lower())
239
+ oversized = dict(self.payload, last_assistant_message='x' * (2 * 1024 * 1024))
240
+ self.assertIn('unavailable', self.run_hook(oversized).get('systemMessage', ''))
241
+ malformed = self.claude_load()
242
+ malformed[0]['message']['content'][0]['id'] = ['synthetic-private']
243
+ malformed[1]['message']['content'][0]['tool_use_id'] = ['synthetic-private']
244
+ self.events(malformed)
245
+ result = self.run_hook()
246
+ self.assertIn('unavailable', result.get('systemMessage', ''))
247
+ self.assertNotIn('synthetic-private', json.dumps(result))
248
+
249
+ def test_nonregular_transcript_does_not_block_on_a_pipe(self):
250
+ pipe = self.root / 'synthetic-pipe'
251
+ os.mkfifo(pipe)
252
+ payload = dict(self.payload, transcript_path=str(pipe))
253
+ result = subprocess.run(['python3', str(self.hooks / 'host-input.py'), 'proposed-next'],
254
+ input=json.dumps(payload), text=True, capture_output=True, timeout=2)
255
+ self.assertEqual(result.returncode, 0, result.stderr)
256
+ self.assertIn('unavailable', json.loads(result.stdout).get('systemMessage', ''))
257
+
258
+
259
+ if __name__ == '__main__':
260
+ unittest.main()
@@ -48,8 +48,9 @@ printf '%s' "$ctx" | grep -q 'app.txt' \
48
48
  printf '%s' "$ctx" | grep -q 'read the smallest relevant local session/memory slice' \
49
49
  && printf '%s' "$ctx" | grep -q 'Do not ask the user to reconstruct discoverable history' \
50
50
  && ok "capsule makes history recovery controller-owned" || bad "missing autonomous recovery contract"
51
- printf '%s' "$ctx" | grep -q 'local_history: repo-attributed-available' \
52
- && ! printf '%s' "$ctx" | grep -Eq 'codex_sessions|claude_project_sessions|cursor_sessions|opencode_sessions|\.codex|\.claude|\.cursor' \
51
+ recovery=$(printf '%s' "$ctx" | sed -n '/^<agent-context-recovery /,$p')
52
+ printf '%s' "$recovery" | grep -q 'local_history: repo-attributed-available' \
53
+ && ! printf '%s' "$recovery" | grep -Eq 'codex_sessions|claude_project_sessions|cursor_sessions|opencode_sessions|\.codex|\.claude|\.cursor' \
53
54
  && ok "capsule aggregates local history without host/tool fingerprint" || bad "capsule leaks per-tool history identity"
54
55
 
55
56
  UNRELATED_HOME="$WORK/unrelated-history-home"
@@ -89,7 +90,8 @@ printf '%s' "$unknown_ctx" | grep -q 'repo_root: unknown' \
89
90
  for i in $(seq 1 25); do printf 'dirty\n' > "$REPO/dirty-$i.txt"; done
90
91
  many_out=$(printf '{"cwd":"%s"}' "$REPO" | HOME="$HISTORY_HOME" bash "$HOOK")
91
92
  many_ctx=$(printf '%s' "$many_out" | jq -r '.hookSpecificOutput.additionalContext // empty')
92
- printf '%s' "$many_ctx" | grep -Eq '\.\.\. \(\+[0-9]+ more; refresh with git status\)' \
93
+ many_recovery=$(printf '{"cwd":"%s"}' "$REPO" | HOME="$HISTORY_HOME" bash "$(dirname "$HOOK")/session-context.sh")
94
+ printf '%s' "$many_recovery" | grep -Eq '\.\.\. \(\+[0-9]+ more; refresh with git status\)' \
93
95
  && ok "dirty-scope truncation is explicit" || bad "dirty-scope truncation is silent"
94
96
 
95
97
  # --- the optional context must never corrupt the mandatory bootstrap ----------
@@ -166,5 +168,85 @@ printf '{}' | bash "$d/hooks/session-start.sh" >/dev/null 2>"$d/err"
166
168
  grep -q 'routing layer NOT injected' "$d/err" \
167
169
  && ok "missing bootstrap is diagnosable on stderr" || bad "missing bootstrap failed silently"
168
170
 
171
+ # Keep the real merged payload inside both hosts' direct-context budgets.
172
+ # Codex estimates tokens from UTF-8 bytes; a byte cap also bounds Claude characters.
173
+ printf '%s' "$many_ctx" | grep -Fq 'proposed-next:' \
174
+ && printf '%s' "$many_ctx" | grep -Fq 'none — status only' \
175
+ && ok "delivery handoff cue remains directly visible" \
176
+ || bad "startup omits the delivery handoff cue"
177
+ ctx_bytes=$(printf '%s' "$many_ctx" | LC_ALL=C wc -c | tr -d ' ')
178
+ [ "$ctx_bytes" -le 9600 ] \
179
+ && ok "startup with bounded dirty scope fits direct-context budget" \
180
+ || bad "startup context exceeds 9600 bytes: $ctx_bytes"
181
+ for source in startup clear compact resume fork; do
182
+ matcher=$(jq -r '.hooks.SessionStart[0].matcher' "$(dirname "$HOOK")/hooks.json")
183
+ printf '%s' "$source" | grep -Eq "^($matcher)$" \
184
+ && ok "SessionStart matches $source" || bad "SessionStart omits $source"
185
+ done
186
+
187
+ policy="$(cd "$(dirname "$HOOK")/.." && pwd)/agent-context/session-policy.md"
188
+ printf '%s' "$ctx" | grep -Fq "](<$policy>)" \
189
+ && [ -r "$policy" ] \
190
+ && ok "deferred policy has a readable absolute plugin path" || bad "deferred policy pointer cannot resolve from a product cwd"
191
+
192
+ r=$(fake_root '#!/usr/bin/env bash
193
+ printf "<agent-context-recovery priority=\"high\">\n"
194
+ printf "%020000d\n" 0
195
+ printf "</agent-context-recovery>\n"')
196
+ c=$(ctx_of "$r")
197
+ [ "$(printf '%s' "$c" | LC_ALL=C wc -c | tr -d ' ')" -le 9600 ] \
198
+ && printf '%s' "$c" | grep -q '</ccl-skills-routing>' \
199
+ && printf '%s' "$c" | grep -q 'refresh live Git' \
200
+ && [ "$(tag_balance "$r")" = "1 1" ] \
201
+ && ok "oversized recovery is replaced whole while mandatory rules stay intact" \
202
+ || bad "oversized recovery spills or truncates the startup rules"
203
+
204
+ # Mandatory content cannot be truncated to satisfy a host budget. Diagnose that
205
+ # distinct limit without making it worse by adding an optional replacement frame.
206
+ r=$(fake_root '#!/usr/bin/env bash
207
+ exit 0')
208
+ printf '%010000d' 0 > "$r/agent-context/session-start.md"
209
+ c=$(ctx_of "$r")
210
+ [ "$c" = "$(cat "$r/agent-context/session-start.md")" ] \
211
+ && grep -q 'mandatory bootstrap exceeds direct-context budget' "$r/err" \
212
+ && ! grep -q 'recovery context exceeds' "$r/err" \
213
+ && ok "oversized mandatory bootstrap stays whole with an accurate degradation diagnostic" \
214
+ || bad "oversized mandatory bootstrap is altered or misdiagnosed as recovery overflow"
215
+
216
+ # A source file can fit while its installed policy link pushes the rendered
217
+ # bootstrap over budget. Use a synthetic long plugin root, not a host path.
218
+ long_root="$r/$(printf '%0180d' 0)"
219
+ mkdir -p "$long_root"
220
+ mv "$r/hooks" "$r/agent-context" "$long_root/"
221
+ r="$long_root"
222
+ printf '%09450d\n[Policy](session-policy.md)' 0 > "$r/agent-context/session-start.md"
223
+ c=$(ctx_of "$r")
224
+ expected=$(printf '%09450d\n[Policy](<%s/agent-context/session-policy.md>)' 0 "$r")
225
+ [ "$(LC_ALL=C wc -c < "$r/agent-context/session-start.md" | tr -d ' ')" -le 9600 ] \
226
+ && [ "$(printf '%s' "$c" | LC_ALL=C wc -c | tr -d ' ')" -gt 9600 ] \
227
+ && [ "$c" = "$expected" ] \
228
+ && grep -q 'mandatory bootstrap exceeds direct-context budget' "$r/err" \
229
+ && ok "policy path expansion is included in mandatory bootstrap overflow detection" \
230
+ || bad "expanded mandatory policy path overflow is silent or truncated"
231
+
232
+ r=$(fake_root '#!/usr/bin/env bash
233
+ printf "<agent-context-recovery priority=\"high\">\n"
234
+ printf "%020000d\n" 0
235
+ printf "</agent-context-recovery>\n"')
236
+ printf '%09500d' 0 > "$r/agent-context/session-start.md"
237
+ c=$(ctx_of "$r")
238
+ [ "$c" = "$(cat "$r/agent-context/session-start.md")" ] \
239
+ && grep -q 'recovery context omitted entirely' "$r/err" \
240
+ && ok "near-limit bootstrap omits a replacement capsule that would still exceed budget" \
241
+ || bad "replacement recovery capsule pushes a near-limit bootstrap over budget"
242
+
243
+ r=$(fake_root '#!/usr/bin/env bash
244
+ exit 0')
245
+ printf '%09600d' 0 > "$r/agent-context/session-start.md"
246
+ c=$(ctx_of "$r")
247
+ [ "$c" = "$(cat "$r/agent-context/session-start.md")" ] && [ ! -s "$r/err" ] \
248
+ && ok "exact-budget bootstrap with no recovery does not add a phantom separator or frame" \
249
+ || bad "exact-budget bootstrap is incorrectly treated as overflow"
250
+
169
251
  printf '%s\n' "---" "PASS=$PASS FAIL=$FAIL"
170
252
  [ "$FAIL" -eq 0 ]