@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.
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 +27 -6
  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
@@ -2,7 +2,7 @@
2
2
  "hooks": {
3
3
  "SessionStart": [
4
4
  {
5
- "matcher": "startup|clear|compact",
5
+ "matcher": "startup|clear|compact|resume|fork",
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
@@ -144,6 +144,37 @@
144
144
  "timeout": 15,
145
145
  "async": false,
146
146
  "statusMessage": "extraction-gate backstop"
147
+ },
148
+ {
149
+ "type": "command",
150
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/proposed-next-stop.sh\"",
151
+ "timeout": 5,
152
+ "async": false,
153
+ "statusMessage": "Checking delivery handoff..."
154
+ }
155
+ ]
156
+ }
157
+ ],
158
+ "PreCompact": [
159
+ {
160
+ "hooks": [
161
+ {
162
+ "type": "command",
163
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/skill-context-compact.sh\"",
164
+ "timeout": 5,
165
+ "async": false
166
+ }
167
+ ]
168
+ }
169
+ ],
170
+ "PostCompact": [
171
+ {
172
+ "hooks": [
173
+ {
174
+ "type": "command",
175
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/skill-context-compact.sh\"",
176
+ "timeout": 5,
177
+ "async": false
147
178
  }
148
179
  ]
149
180
  }
@@ -0,0 +1,544 @@
1
+ #!/usr/bin/env python3
2
+ """Normalize host hook inputs and transcript evidence, without executing tool text.
3
+
4
+ Codex shapes: openai/codex rust-v0.154.0 protocol/models.rs and
5
+ core/src/tools/context.rs. Unsupported evidence remains unverifiable.
6
+ """
7
+ import hashlib
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ import re
12
+ import shlex
13
+ import stat
14
+ import sys
15
+
16
+
17
+ class TranscriptTruncated(ValueError):
18
+ """The bounded scan could not establish complete transcript evidence."""
19
+
20
+
21
+ def handoff(text):
22
+ """Recognize an assistant handoff, never quoted examples or fenced output."""
23
+ if not isinstance(text, str):
24
+ return False
25
+ fence = None
26
+ for line in text.splitlines():
27
+ stripped = line.strip()
28
+ match = re.match(r'^(`{3,}|~{3,})', stripped)
29
+ if match:
30
+ token = match[1]
31
+ if fence is None:
32
+ fence = token
33
+ elif token[0] == fence[0] and len(token) >= len(fence) and stripped == token:
34
+ fence = None
35
+ continue
36
+ if fence is not None or stripped.startswith('>') or line.startswith((' ', '\t')):
37
+ continue
38
+ match = re.fullmatch(r'(?:[-*] )?(?:\*\*)?proposed-next:(?:\*\*)?\s*(.+)', stripped)
39
+ if match and match[1].strip() and not match[1].strip().startswith('<'):
40
+ return True
41
+ return False
42
+
43
+
44
+ def text_content(content):
45
+ if isinstance(content, str):
46
+ return content
47
+ if not isinstance(content, list):
48
+ return ''
49
+ return '\n'.join(item['text'] for item in content if isinstance(item, dict)
50
+ and item.get('type') in ('text', 'output_text')
51
+ and isinstance(item.get('text'), str))
52
+
53
+
54
+ def continuation_contract():
55
+ # Optional in minimal vendored runtimes. Read the canonical rule instead of
56
+ # duplicating its prose; a missing rule only disables this visibility signal.
57
+ path = Path(__file__).resolve().parents[1] / 'skills/product-rd-workflow/SKILL.md'
58
+ try:
59
+ with path.open(encoding='utf-8') as stream:
60
+ source = stream.read(128 * 1024)
61
+ except OSError:
62
+ return ''
63
+ for paragraph in source.split('\n\n'):
64
+ if paragraph.startswith('**Continuation-proposal output contract (session-wide for product delivery).**'):
65
+ return ' '.join(paragraph.split())
66
+ return ''
67
+
68
+
69
+ def visible_contract(content, contract):
70
+ text = text_content(content)
71
+ return bool(contract and 'Warning: truncated output' not in text
72
+ and '<persisted-output>' not in text and contract in ' '.join(text.split()))
73
+
74
+
75
+ def transcript_lines(path):
76
+ # Bound both event count and bytes, including a hostile oversized JSONL line.
77
+ remaining = 16 * 1024 * 1024
78
+ descriptor = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
79
+ with os.fdopen(descriptor, 'rb') as stream:
80
+ if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
81
+ raise ValueError('transcript is not a regular file')
82
+ for _ in range(20000):
83
+ limit = min(remaining, 1024 * 1024)
84
+ if limit <= 0:
85
+ if stream.read(1):
86
+ raise TranscriptTruncated()
87
+ return
88
+ line = stream.readline(limit + 1)
89
+ if not line:
90
+ return
91
+ if len(line) > limit:
92
+ raise TranscriptTruncated()
93
+ remaining -= len(line)
94
+ yield line.decode('utf-8', errors='replace')
95
+ if stream.read(1):
96
+ raise TranscriptTruncated()
97
+
98
+
99
+ def absolute(path, cwd):
100
+ return os.path.realpath(os.path.join(cwd, path))
101
+
102
+
103
+ def paths(payload):
104
+ tool = payload.get('tool_name')
105
+ args = payload.get('tool_input')
106
+ args = args if isinstance(args, dict) else {}
107
+ cwd = payload.get('cwd') or os.getcwd()
108
+ cwd = cwd if isinstance(cwd, str) else os.getcwd()
109
+ found = []
110
+ malformed = False
111
+ if tool == 'apply_patch':
112
+ patch = args.get('command', args.get('patchText', args.get('input')))
113
+ if not isinstance(patch, str):
114
+ return {'paths': [], 'malformed_patch': True}
115
+ # Patch framing splits on LF/CRLF only. Other Unicode separators are
116
+ # filename characters and must not hide a symlinked protected target.
117
+ lines = [line[:-1] if line.endswith('\r') else line
118
+ for line in patch.strip().split('\n')]
119
+ if len(lines) < 3 or lines[0] != '*** Begin Patch' or lines[-1] != '*** End Patch':
120
+ return {'paths': [], 'malformed_patch': True}
121
+ operation = None
122
+ for line in lines[1:-1]:
123
+ match = re.fullmatch(r'\*\*\* (Add|Update|Delete) File: (.+)', line)
124
+ move = re.fullmatch(r'\*\*\* Move to: (.+)', line)
125
+ if match:
126
+ operation = match[1]
127
+ found.append(match[2])
128
+ elif move and operation == 'Update':
129
+ found.append(move[1])
130
+ elif line.startswith('*** ') and line != '*** End of File':
131
+ malformed = True
132
+ malformed = malformed or not found or any(not p.strip() or '\x00' in p for p in found)
133
+ else:
134
+ for key in ('file_path', 'notebook_path', 'path'):
135
+ value = args.get(key)
136
+ if isinstance(value, str) and value and '\x00' not in value:
137
+ found.append(value)
138
+ break
139
+ return {'paths': list(dict.fromkeys(absolute(p, cwd) for p in found)),
140
+ 'malformed_patch': malformed}
141
+
142
+
143
+ def bounded_skill_source(path):
144
+ descriptor = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
145
+ with os.fdopen(descriptor, 'rb') as stream:
146
+ if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
147
+ raise ValueError('skill source is not a regular file')
148
+ raw = stream.read(128 * 1024 + 1)
149
+ if len(raw) > 128 * 1024:
150
+ raise ValueError('skill source exceeds its bounded limit')
151
+ return raw
152
+
153
+
154
+ def skill_reads(name, args, cwd, sources):
155
+ """Describe literal reads; compare output against bounded current file bytes.
156
+
157
+ None means a recognizable but unsupported/unavailable skill read. An empty
158
+ list is unrelated activity. No command is evaluated, including quoted text.
159
+ """
160
+ if name not in ('exec_command', 'shell_command') or not isinstance(args, dict):
161
+ return []
162
+ command = args.get('cmd' if name == 'exec_command' else 'command')
163
+ if not isinstance(command, str) or 'SKILL.md' not in command:
164
+ return []
165
+ if re.search(r'[\n\r;$`|&<>*?{}]', command):
166
+ return None
167
+ try:
168
+ words = shlex.split(command)
169
+ except ValueError:
170
+ return None
171
+ first, last = 1, None
172
+ if words and words[0] in ('cat', '/bin/cat', '/usr/bin/cat'):
173
+ operands = words[1:]
174
+ if operands and operands[0] == '--':
175
+ operands = operands[1:]
176
+ elif (len(words) == 4 and words[0] in ('sed', '/bin/sed', '/usr/bin/sed')
177
+ and words[1] == '-n' and re.fullmatch(r'[1-9][0-9]{0,6},[1-9][0-9]{0,6}p', words[2])):
178
+ first, last = map(int, words[2][:-1].split(','))
179
+ operands = words[3:]
180
+ if first > last:
181
+ return None
182
+ else:
183
+ return None
184
+ if not operands or len(operands) > 32 or any(p.startswith('-') for p in operands):
185
+ return None
186
+ workdir = args.get('workdir', cwd)
187
+ if not isinstance(workdir, str):
188
+ return None
189
+ result = []
190
+ for operand in operands:
191
+ path = Path(absolute(operand, absolute(workdir, cwd)))
192
+ if path.name != 'SKILL.md' or path.parent.parent.name != 'skills':
193
+ return None
194
+ if path not in sources:
195
+ # At most 64 candidate sources; each candidate/canonical read is
196
+ # capped at 128 KiB and never blocks on a FIFO.
197
+ if len(sources) >= 64:
198
+ return None
199
+ try:
200
+ raw = bounded_skill_source(path)
201
+ # The helper's installed package is the authority, never cwd
202
+ # or an environment-selected root. A same-named project file
203
+ # proves this owner only if it is a byte-identical copy.
204
+ canonical = Path(__file__).resolve().parents[1] / 'skills' / path.parent.name / 'SKILL.md'
205
+ if path != canonical.resolve() and raw != bounded_skill_source(canonical):
206
+ return None
207
+ source = raw.decode('utf-8')
208
+ frontmatter = re.match(r'\A---\r?\n(.*?)\r?\n---\r?\n(.+)', source, re.S)
209
+ if (not frontmatter or not frontmatter[2].strip() or not re.search(
210
+ r'^name:\s*' + re.escape(path.parent.name) + r'\s*$', frontmatter[1], re.M)):
211
+ return None
212
+ # sed addresses LF-delimited lines; Unicode separators remain
213
+ # literal body characters and must not shift chunk coverage.
214
+ parts = source.split('\n')
215
+ sources[path] = [part + '\n' for part in parts[:-1]]
216
+ if parts[-1]:
217
+ sources[path].append(parts[-1])
218
+ except (OSError, UnicodeError, ValueError):
219
+ return None
220
+ lines = sources[path]
221
+ end = min(last or len(lines), len(lines))
222
+ if first > end:
223
+ return None
224
+ result.append({'path': path, 'skill': 'ccl-skills:' + path.parent.name,
225
+ 'first': first, 'last': end, 'total': len(lines),
226
+ 'lines': lines})
227
+ return result
228
+
229
+
230
+ def successful_output(value):
231
+ if not isinstance(value, str):
232
+ return False
233
+ header, separator, body = value.partition('\nOutput:\n')
234
+ return bool(separator and body.strip()
235
+ and re.search(r'^Process exited with code 0$', header, re.M)
236
+ and not re.search(r'^Process running with session ID ', header, re.M)
237
+ and 'Warning: truncated output' not in value)
238
+
239
+
240
+ def compact_boundary(event):
241
+ """Only native top-level records identify a context reset."""
242
+ return (not event.get('isSidechain') and
243
+ ((event.get('type') == 'system' and event.get('subtype') == 'compact_boundary')
244
+ or (event.get('type') == 'compacted' and isinstance(event.get('payload'), dict))))
245
+
246
+
247
+ def summarize(lines, cwd, current=False):
248
+ requested, completed, edited = set(), set(), set()
249
+ pending = {}
250
+ pending_reads, coverage, sources = {}, {}, {}
251
+ pending_visibility = set()
252
+ contract = continuation_contract()
253
+ contract_visible = False
254
+ prior_handoff = False
255
+ verifiable = current
256
+ invalid = False
257
+ unverifiable_reads = False
258
+ for line in lines:
259
+ try:
260
+ event = json.loads(line)
261
+ except (ValueError, TypeError):
262
+ continue
263
+ if not isinstance(event, dict):
264
+ continue
265
+ if compact_boundary(event):
266
+ # Completed audit evidence survives; pending requests and partial
267
+ # coverage must never be joined across a native context reset.
268
+ pending.clear()
269
+ pending_reads.clear()
270
+ pending_visibility.clear()
271
+ coverage.clear()
272
+ continue
273
+ if current and event.get('isSidechain'):
274
+ continue
275
+ if event.get('type') == 'turn_context':
276
+ context = event.get('payload', {})
277
+ if isinstance(context, dict) and isinstance(context.get('cwd'), str):
278
+ cwd = context['cwd']
279
+ if event.get('type') in ('assistant', 'user'):
280
+ message = event.get('message', {})
281
+ content = message.get('content', []) if isinstance(message, dict) else []
282
+ if (event['type'] == 'assistant' and not event.get('isSidechain')
283
+ and isinstance(content, list)
284
+ and not any(isinstance(i, dict) and i.get('type') == 'tool_use' for i in content)):
285
+ prior_handoff = prior_handoff or handoff(text_content(content))
286
+ if not isinstance(content, list):
287
+ continue
288
+ for item in content:
289
+ if not isinstance(item, dict):
290
+ continue
291
+ if event['type'] == 'assistant' and item.get('type') == 'tool_use':
292
+ name, args = item.get('name'), item.get('input', {})
293
+ verifiable = True
294
+ if not isinstance(name, str):
295
+ invalid = True
296
+ if isinstance(name, str) and isinstance(item.get('id'), str):
297
+ pending_visibility.add(item['id'])
298
+ if name == 'Skill':
299
+ skill = args.get('skill') if isinstance(args, dict) else None
300
+ if isinstance(skill, str) and skill:
301
+ requested.add(skill.lower())
302
+ if item.get('id'):
303
+ pending[item['id']] = [skill.lower()]
304
+ else:
305
+ invalid = True
306
+ if name in ('Edit', 'Write', 'MultiEdit', 'NotebookEdit'):
307
+ edited.update(paths({'tool_name': name, 'tool_input': args, 'cwd': cwd})['paths'])
308
+ elif event['type'] == 'user' and item.get('type') == 'tool_result':
309
+ tool_id = item.get('tool_use_id')
310
+ if isinstance(tool_id, str) and tool_id in pending_visibility:
311
+ pending_visibility.remove(tool_id)
312
+ if not item.get('is_error'):
313
+ contract_visible = contract_visible or visible_contract(item.get('content'), contract)
314
+ skills = pending.pop(tool_id, []) if isinstance(tool_id, str) else []
315
+ if not item.get('is_error'):
316
+ completed.update(skills)
317
+ if event.get('type') != 'response_item' or not isinstance(event.get('payload'), dict):
318
+ continue
319
+ item = event['payload']
320
+ kind, name = item.get('type'), item.get('name')
321
+ if (kind == 'message' and item.get('role') == 'assistant'
322
+ and item.get('phase') in (None, 'final_answer')):
323
+ prior_handoff = prior_handoff or handoff(text_content(item.get('content')))
324
+ if kind in ('function_call', 'custom_tool_call'):
325
+ verifiable = True
326
+ if not isinstance(name, str):
327
+ invalid = True
328
+ continue
329
+ if kind == 'custom_tool_call':
330
+ args = {'command': item.get('input')}
331
+ else:
332
+ try:
333
+ args = json.loads(item.get('arguments', ''))
334
+ except (ValueError, TypeError):
335
+ invalid = True
336
+ continue
337
+ if name == 'apply_patch':
338
+ edited.update(paths({'tool_name': name, 'tool_input': args, 'cwd': cwd})['paths'])
339
+ if name in ('exec_command', 'shell_command') and isinstance(item.get('call_id'), str):
340
+ pending_visibility.add(item['call_id'])
341
+ reads = skill_reads(name, args, cwd, sources)
342
+ if reads is None:
343
+ unverifiable_reads = True
344
+ elif reads and isinstance(item.get('call_id'), str):
345
+ pending_reads[item['call_id']] = reads
346
+ elif kind == 'function_call_output':
347
+ call_id = item.get('call_id')
348
+ if isinstance(call_id, str) and call_id in pending_visibility:
349
+ pending_visibility.remove(call_id)
350
+ if successful_output(item.get('output')):
351
+ body = item['output'].partition('\nOutput:\n')[2]
352
+ contract_visible = contract_visible or visible_contract(body, contract)
353
+ reads = pending_reads.pop(call_id, []) if isinstance(call_id, str) else []
354
+ if reads and successful_output(item.get('output')):
355
+ body = item['output'].partition('\nOutput:\n')[2]
356
+ expected = ''.join(''.join(read['lines'][read['first'] - 1:read['last']])
357
+ for read in reads)
358
+ if body.rstrip('\n') != expected.rstrip('\n'):
359
+ unverifiable_reads = True
360
+ continue
361
+ for read in reads:
362
+ if read['path'] not in coverage:
363
+ coverage[read['path']] = [bytearray(read['total']), 0]
364
+ covered = coverage[read['path']]
365
+ first, last = read['first'] - 1, read['last']
366
+ covered[1] += last - first - covered[0][first:last].count(1)
367
+ covered[0][first:last] = b'\x01' * (last - first)
368
+ if covered[1] == read['total']:
369
+ completed.add(read['skill'])
370
+ # Codex has no Skill request; only a completed full read
371
+ # is invocation evidence, including proven line chunks.
372
+ requested.add(read['skill'])
373
+ return {'requested_skills': sorted(requested), 'completed_skills': sorted(completed),
374
+ 'edit_paths': sorted(edited), 'verifiable': verifiable and not invalid and not unverifiable_reads,
375
+ 'unverifiable_reads': unverifiable_reads,
376
+ 'prior_handoff': prior_handoff, 'continuation_contract_visible': contract_visible}
377
+
378
+
379
+ def transcript(path, cwd):
380
+ """Whole-session audit; exceeding any leading scan limit is an error."""
381
+ return summarize(transcript_lines(path), cwd)
382
+
383
+
384
+ def context_transcript(path, cwd, start_offset=0):
385
+ """Current-context evidence from a bounded snapshot of a regular JSONL file.
386
+
387
+ A supplied byte high-watermark excludes requests that started before actual
388
+ PostCompact delivery, even before its native boundary record is flushed.
389
+ An omitted prefix is safe only when a later native boundary is visible.
390
+ Unknown/incomplete context discards evidence; IDs contain no transcript text.
391
+ """
392
+ unknown = {'requested_skills': [], 'completed_skills': [], 'edit_paths': [],
393
+ 'verifiable': False, 'context_complete': False, 'context_id': None,
394
+ 'unverifiable_reads': False, 'prior_handoff': False,
395
+ 'continuation_contract_visible': False}
396
+ if type(start_offset) is not int or start_offset < 0:
397
+ return unknown
398
+ try:
399
+ descriptor = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
400
+ with os.fdopen(descriptor, 'rb') as stream:
401
+ metadata = os.fstat(stream.fileno())
402
+ if not stat.S_ISREG(metadata.st_mode) or start_offset > metadata.st_size:
403
+ return unknown
404
+ end = metadata.st_size
405
+ begin = max(start_offset, end - 16 * 1024 * 1024)
406
+ partial = False
407
+ if begin:
408
+ stream.seek(begin - 1)
409
+ partial = stream.read(1) != b'\n'
410
+ stream.seek(begin)
411
+ data = stream.read(end - begin)
412
+ if len(data) != end - begin:
413
+ return unknown
414
+ omitted = begin > start_offset
415
+ if partial:
416
+ newline = data.find(b'\n')
417
+ if newline < 0:
418
+ return unknown
419
+ begin += newline + 1
420
+ data = data[newline + 1:]
421
+ # rsplit bounds allocated records even for millions of tiny lines.
422
+ records = data.rsplit(b'\n', 20001)
423
+ if records and not records[-1]:
424
+ records.pop()
425
+ if len(records) > 20000:
426
+ dropped = len(records) - 20000
427
+ begin += sum(len(line) + 1 for line in records[:dropped])
428
+ records = records[dropped:]
429
+ omitted = True
430
+ context_id = None if omitted else (f'offset:{start_offset}' if start_offset else 'start:0')
431
+ selected = []
432
+ complete = not omitted
433
+ position = begin
434
+ for raw in records:
435
+ line_position = position
436
+ position += len(raw) + 1
437
+ if len(raw) > 1024 * 1024:
438
+ complete = False
439
+ continue
440
+ try:
441
+ line = raw.decode('utf-8')
442
+ event = json.loads(line)
443
+ if not isinstance(event, dict):
444
+ raise ValueError('unsupported record')
445
+ except (ValueError, UnicodeError, RecursionError):
446
+ complete = False
447
+ continue
448
+ if compact_boundary(event):
449
+ context_id = f'native:{line_position}:' + hashlib.sha256(raw).hexdigest()[:16]
450
+ selected.clear()
451
+ complete = True
452
+ else:
453
+ selected.append(line)
454
+ if not complete:
455
+ return dict(unknown, context_id=context_id)
456
+ summary = summarize(selected, cwd, current=True)
457
+ summary.update(context_complete=True, context_id=context_id)
458
+ return summary
459
+ except (OSError, ValueError, TypeError, RecursionError):
460
+ return unknown
461
+
462
+
463
+ def machine_artifact(text):
464
+ try:
465
+ json.loads(text)
466
+ return True
467
+ except ValueError:
468
+ pass
469
+ lines = text.strip().splitlines()
470
+ match = re.match(r'^(`{3,}|~{3,})', lines[0]) if lines else None
471
+ if not match:
472
+ return False
473
+ fence = match[1]
474
+ for index, line in enumerate(lines[1:], 1):
475
+ if re.fullmatch(re.escape(fence[0]) + '{' + str(len(fence)) + ',}', line.strip()):
476
+ return index == len(lines) - 1
477
+ return False
478
+
479
+
480
+ def proposed_next(payload):
481
+ if (not isinstance(payload, dict) or payload.get('hook_event_name') != 'Stop'
482
+ or payload.get('stop_hook_active') is not False):
483
+ return None
484
+ final = payload.get('last_assistant_message')
485
+ if not isinstance(final, str) or not final.strip() or machine_artifact(final) or handoff(final):
486
+ return None
487
+ path = payload.get('transcript_path')
488
+ if not isinstance(path, str) or not path:
489
+ return None
490
+ cwd = payload.get('cwd')
491
+ summary = transcript(path, cwd if isinstance(cwd, str) else os.getcwd())
492
+ eligible = (any(skill in ('product-rd-workflow', 'ccl-skills:product-rd-workflow')
493
+ for skill in summary['completed_skills']) or summary['prior_handoff']
494
+ or summary['continuation_contract_visible'])
495
+ if not eligible:
496
+ return None
497
+ # This is formatting eligibility, never intent, authorization, or completed
498
+ # owner evidence. A source review may expose the rule and receive one nudge.
499
+ return {'decision': 'block', 'reason': (
500
+ 'Delivery handoff reminder: repair one proposed-next: line with the next action and scope, '
501
+ 'or proposed-next: none — status only when there is no authorized next action. '
502
+ 'Preserve the active original goal, current user stop and requested output format. '
503
+ 'Do not invent work or authority, and do not ask the user to repair formatting. '
504
+ 'If already-authorized work remains and can be completed now, finish it instead of ending prematurely. '
505
+ 'This reminder supplies no new goal or authorization.')}
506
+
507
+
508
+ def main():
509
+ if sys.argv[1] == 'paths':
510
+ value = json.load(sys.stdin)
511
+ print(json.dumps(paths(value if isinstance(value, dict) else {})))
512
+ elif sys.argv[1] == 'transcript':
513
+ path = sys.argv[2]
514
+ if not os.path.isfile(path):
515
+ return 1
516
+ try:
517
+ print(json.dumps(transcript(path, sys.argv[3] if len(sys.argv) > 3 else os.getcwd())))
518
+ except TranscriptTruncated:
519
+ # Discard partial evidence even for callers that inspect stdout
520
+ # without propagating the subprocess failure status.
521
+ print(json.dumps({'requested_skills': [], 'completed_skills': [], 'edit_paths': [],
522
+ 'verifiable': False, 'truncated': True, 'prior_handoff': False,
523
+ 'continuation_contract_visible': False}))
524
+ return 1
525
+ elif sys.argv[1] == 'proposed-next':
526
+ try:
527
+ raw = sys.stdin.read(2 * 1024 * 1024 + 1)
528
+ if len(raw) > 2 * 1024 * 1024:
529
+ raise ValueError('oversized input')
530
+ result = proposed_next(json.loads(raw))
531
+ if result:
532
+ print(json.dumps(result))
533
+ except TranscriptTruncated:
534
+ print(json.dumps({'systemMessage': 'Delivery handoff reminder unverified: transcript scan exceeded its bounded limit.'}))
535
+ except (OSError, ValueError, TypeError, IndexError, AttributeError):
536
+ print(json.dumps({'systemMessage': 'Delivery handoff reminder unavailable: input or transcript could not be verified.'}))
537
+ return 0
538
+
539
+
540
+ if __name__ == '__main__':
541
+ try:
542
+ sys.exit(main())
543
+ except (OSError, ValueError, TypeError, IndexError):
544
+ sys.exit(1)