@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
|
@@ -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)
|