@ccoalm/ccl-skills 0.18.1 → 0.18.3

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