@arbiterforge/ca-pi 0.15.3 → 0.15.5
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 +1 -1
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +22 -0
- package/plugins/ca-pi/hooks/_artifactauthoritylib.py +10 -0
- package/plugins/ca-pi/hooks/_contextreportlib.py +349 -0
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_intentlib.py +18 -5
- package/plugins/ca-pi/hooks/_provenancelib.py +29 -13
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ project context. You decide. codeArbiter enforces.
|
|
|
10
10
|
<img alt="Claude Code plugin" src="https://img.shields.io/badge/Claude_Code-plugin-d97757">
|
|
11
11
|
<img alt="Codex plugin" src="https://img.shields.io/badge/OpenAI_Codex-plugin-10a37f">
|
|
12
12
|
<img alt="Pi Feature Forge preview" src="https://img.shields.io/badge/ca--pi-Feature_Forge_preview-d97757">
|
|
13
|
-
<img alt="version 2.22.
|
|
13
|
+
<img alt="version 2.22.5" src="https://img.shields.io/badge/version-2.22.5-2b7489">
|
|
14
14
|
<img alt="core lanes" src="https://img.shields.io/badge/core_lanes-18-555">
|
|
15
15
|
<img alt="skills" src="https://img.shields.io/badge/skills-22-555">
|
|
16
16
|
<img alt="agents" src="https://img.shields.io/badge/agents-20-555">
|
package/package.json
CHANGED
|
@@ -4,6 +4,28 @@ All notable changes to `ca-pi` are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.15.5] - 2026-09-26
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Explain input-manifest entry kinds to independent reviewers so normative artifact
|
|
12
|
+
identities are not compared with raw HTML hashes.
|
|
13
|
+
|
|
14
|
+
## [0.15.4] - 2026-09-26
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Count measured partial token usage toward proven budget overruns while keeping
|
|
19
|
+
incomplete totals unknown; retain exact source identities without storing blobs.
|
|
20
|
+
|
|
21
|
+
- Retain strict HTML intent-response identity checks and per-document source healing.
|
|
22
|
+
- Bound oversized UTF-8 code-map diagnostics without rewriting human content.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- Internal closed scout-report decoding and test-owned observation accounting;
|
|
27
|
+
onboarding dispatch and context-writing integration are not enabled by these helpers.
|
|
28
|
+
|
|
7
29
|
## [0.15.3] - 2026-09-26
|
|
8
30
|
|
|
9
31
|
### Removed
|
|
@@ -1117,6 +1117,16 @@ def _dispatch_prompt(request: dict[str, Any], *, legacy_inline: bool = False) ->
|
|
|
1117
1117
|
"The adapter verifies its exact digest before launch and publication. Inspect its "
|
|
1118
1118
|
"activity, subject, input_sha256, task definitions and criterion coverage against "
|
|
1119
1119
|
"these bindings, then inspect the input manifest and referenced target files. "
|
|
1120
|
+
"Use the installed structured-artifact engine's snapshot operation for the frozen "
|
|
1121
|
+
"plan and compare its returned sha256 with input_sha256; that binds the complete "
|
|
1122
|
+
"frozen input_manifest. "
|
|
1123
|
+
"For kind=bytes, sha256 hashes raw file bytes; also check executable. For "
|
|
1124
|
+
"kind=directory, members lists names included under the frozen roots, "
|
|
1125
|
+
"exclude_directories and validated engine output policy, not an unfiltered "
|
|
1126
|
+
"filesystem listing. For kind=artifact_normative, "
|
|
1127
|
+
"sha256 is the normative_sha256 returned by the installed identity operation "
|
|
1128
|
+
"for that artifact, not a digest of raw rendered HTML. Do not parse rendered "
|
|
1129
|
+
"HTML to reconstruct artifact authority. "
|
|
1120
1130
|
"Do not pass if the evidence is missing, unreadable or inconsistent. The locator "
|
|
1121
1131
|
"is a reference to the complete immutable evidence, not replacement authority.\n"
|
|
1122
1132
|
)
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter -- bounded, pure decoding of internal scout evidence reports.
|
|
3
|
+
#
|
|
4
|
+
# Public API: decode_report(payload, *, expected) -> detached normalized dict.
|
|
5
|
+
# ReportError(code, field) reports only bounded code-owned diagnostics.
|
|
6
|
+
# No filesystem, subprocess, network, dispatch, approval, or artifact mutation.
|
|
7
|
+
# A matching supplied identity is not proof that the host actually inspected it.
|
|
8
|
+
# This codec admits report transport; category gaps remain gaps. It does not
|
|
9
|
+
# establish six-domain coverage, containment, truth, freshness, or readiness.
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import unicodedata
|
|
16
|
+
|
|
17
|
+
SCHEMA_VERSION = 1
|
|
18
|
+
MAX_REPORT_BYTES = 48 * 1024
|
|
19
|
+
MAX_DEPTH = 12
|
|
20
|
+
MAX_NODES = 4096
|
|
21
|
+
MAX_PATHS = 16
|
|
22
|
+
MAX_EVIDENCE = 64
|
|
23
|
+
MAX_FINDINGS = 64
|
|
24
|
+
CATEGORIES = frozenset({'stack', 'infrastructure', 'architecture', 'security', 'tests', 'data'})
|
|
25
|
+
OUTCOMES = frozenset({'observed', 'not_applicable', 'not_found_in_scope',
|
|
26
|
+
'not_inspected', 'ambiguous', 'failed', 'oversize'})
|
|
27
|
+
PREDICATES = frozenset({'manifests', 'instructions', 'configuration', 'source',
|
|
28
|
+
'entry_points', 'tests', 'infrastructure', 'security', 'data'})
|
|
29
|
+
EXCLUSIONS = frozenset({'ignored', 'vendor', 'generated', 'submodule', 'nested_repository',
|
|
30
|
+
'sparse', 'outside_authorized_scope', 'unreadable'})
|
|
31
|
+
CONFIDENCE = {'high': 'high', 'strong': 'high', 'HIGH': 'high',
|
|
32
|
+
'medium': 'medium', 'moderate': 'medium', 'MEDIUM': 'medium',
|
|
33
|
+
'low': 'low', 'weak': 'low', 'LOW': 'low'}
|
|
34
|
+
FINDING_NAMES = {
|
|
35
|
+
'stack': frozenset({'runtime', 'dependency', 'manifest'}),
|
|
36
|
+
'infrastructure': frozenset({'command_ref', 'prerequisite', 'configuration', 'deploy_entry'}),
|
|
37
|
+
'architecture': frozenset({'entry_point', 'source_of_truth', 'generated_path', 'ownership_boundary'}),
|
|
38
|
+
'security': frozenset({'boundary', 'control', 'policy_reference'}),
|
|
39
|
+
'tests': frozenset({'command_ref', 'test_boundary', 'fixture', 'verification_reference'}),
|
|
40
|
+
'data': frozenset({'datastore', 'schema', 'migration', 'dataflow'}),
|
|
41
|
+
}
|
|
42
|
+
_ID = re.compile(r'[A-Za-z][A-Za-z0-9_.-]{0,63}\Z')
|
|
43
|
+
_DIGESTS = {'sha256-raw': 64, 'git-blob-normalized-sha1': 40}
|
|
44
|
+
_RESERVED = re.compile(r'(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?\Z', re.IGNORECASE)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ReportError(ValueError):
|
|
48
|
+
"""Invalid evidence input; never echo source values into diagnostics."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, code, field='report'):
|
|
51
|
+
self.code = code
|
|
52
|
+
self.field = field
|
|
53
|
+
super().__init__(f'{code} at {field}')
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _need(condition, code, field):
|
|
57
|
+
if not condition:
|
|
58
|
+
raise ReportError(code, field)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _object(value, keys, field):
|
|
62
|
+
_need(type(value) is dict, 'OBJECT_REQUIRED', field)
|
|
63
|
+
_need(not set(value) - set(keys), 'UNKNOWN_FIELD', field)
|
|
64
|
+
_need(not set(keys) - set(value), 'MISSING_FIELD', field)
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _list(value, maximum, field):
|
|
69
|
+
_need(type(value) is list, 'ARRAY_REQUIRED', field)
|
|
70
|
+
_need(len(value) <= maximum, 'ITEM_LIMIT', field)
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _text(value, maximum, field, nullable=False):
|
|
75
|
+
if value is None and nullable:
|
|
76
|
+
return None
|
|
77
|
+
_need(type(value) is str and bool(value.strip()), 'TEXT_REQUIRED', field)
|
|
78
|
+
try:
|
|
79
|
+
size = len(value.encode('utf-8'))
|
|
80
|
+
except UnicodeError:
|
|
81
|
+
raise ReportError('INVALID_UNICODE', field) from None
|
|
82
|
+
_need(size <= maximum, 'TEXT_LIMIT', field)
|
|
83
|
+
_need(not any(unicodedata.category(c) in ('Cc', 'Cs', 'Cf') for c in value),
|
|
84
|
+
'CONTROL_CHARACTER', field)
|
|
85
|
+
return value
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _enum(value, choices, field):
|
|
89
|
+
_need(type(value) is str and value in choices, 'UNKNOWN_VALUE', field)
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _id(value, field):
|
|
94
|
+
_need(type(value) is str and _ID.fullmatch(value) is not None, 'INVALID_ID', field)
|
|
95
|
+
return value
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _digest(value, length, field):
|
|
99
|
+
_need(type(value) is str and re.fullmatch('[0-9a-f]{'+str(length)+'}', value) is not None,
|
|
100
|
+
'INVALID_DIGEST', field)
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _path(value, field, root=False):
|
|
105
|
+
value = _text(value, 1024, field)
|
|
106
|
+
if root and value == '.':
|
|
107
|
+
return value
|
|
108
|
+
parts = value.split('/')
|
|
109
|
+
_need(unicodedata.normalize('NFC', value) == value and '\\' not in value
|
|
110
|
+
and not any(c in value for c in ':*?"<>|')
|
|
111
|
+
and all(p not in ('', '.', '..') and p.casefold() != '.git'
|
|
112
|
+
and p[-1:] not in (' ', '.') and not _RESERVED.fullmatch(p) for p in parts),
|
|
113
|
+
'UNSAFE_PATH', field)
|
|
114
|
+
return value
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _within(path, parent):
|
|
118
|
+
return parent == '.' or path == parent or path.startswith(parent + '/')
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _unique(values, field, folded=False):
|
|
122
|
+
identities = [s.casefold() if folded else s for s in values]
|
|
123
|
+
_need(len(set(identities)) == len(identities), 'DUPLICATE_ID', field)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _paths(value, field, parents=None, nonempty=False):
|
|
127
|
+
result = [_path(p, field, root=True) for p in _list(value, MAX_PATHS, field)]
|
|
128
|
+
_unique(result, field, folded=True)
|
|
129
|
+
if nonempty:
|
|
130
|
+
_need(bool(result), 'SEARCH_COVERAGE', field)
|
|
131
|
+
for index, path in enumerate(result):
|
|
132
|
+
if parents is not None:
|
|
133
|
+
_need(any(_within(path, parent) for parent in parents), 'OUT_OF_SCOPE', field)
|
|
134
|
+
_need(not any(_within(path, other) or _within(other, path)
|
|
135
|
+
for other in result[index+1:]), 'OVERLAPPING_SCOPE', field)
|
|
136
|
+
return sorted(result)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _scope(value, field):
|
|
140
|
+
_object(value, ('paths', 'categories'), field)
|
|
141
|
+
paths = _paths(value['paths'], field+'.paths', nonempty=True)
|
|
142
|
+
categories = [_enum(c, CATEGORIES, field+'.categories')
|
|
143
|
+
for c in _list(value['categories'], len(CATEGORIES), field+'.categories')]
|
|
144
|
+
_unique(categories, field+'.categories')
|
|
145
|
+
_need(bool(categories), 'CATEGORY_COVERAGE', field)
|
|
146
|
+
return {'paths': paths, 'categories': sorted(categories)}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _binding(value, field):
|
|
150
|
+
_object(value, ('assignment_id', 'attempt_id', 'snapshot_id', 'scope'), field)
|
|
151
|
+
return {'assignment_id': _id(value['assignment_id'], field+'.assignment_id'),
|
|
152
|
+
'attempt_id': _id(value['attempt_id'], field+'.attempt_id'),
|
|
153
|
+
'snapshot_id': _digest(value['snapshot_id'], 64, field+'.snapshot_id'),
|
|
154
|
+
'scope': _scope(value['scope'], field+'.scope')}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _coverage(value, scope, field):
|
|
158
|
+
_object(value, ('searched_paths', 'predicates', 'excluded'), field)
|
|
159
|
+
searched = _paths(value['searched_paths'], field+'.searched_paths', scope['paths'])
|
|
160
|
+
predicates = [_enum(p, PREDICATES, field+'.predicates')
|
|
161
|
+
for p in _list(value['predicates'], len(PREDICATES), field+'.predicates')]
|
|
162
|
+
_unique(predicates, field+'.predicates')
|
|
163
|
+
excluded = []
|
|
164
|
+
for index, entry in enumerate(_list(value['excluded'], MAX_PATHS, field+'.excluded')):
|
|
165
|
+
part = field+'.excluded['+str(index)+']'
|
|
166
|
+
_object(entry, ('path', 'reason'), part)
|
|
167
|
+
path = _path(entry['path'], part+'.path', root=True)
|
|
168
|
+
_need(any(_within(path, parent) for parent in scope['paths']), 'OUT_OF_SCOPE', part)
|
|
169
|
+
_need(not any(_within(searched_path, path) for searched_path in searched),
|
|
170
|
+
'SEARCH_COVERAGE', part)
|
|
171
|
+
excluded.append({'path': path, 'reason': _enum(entry['reason'], EXCLUSIONS, part+'.reason')})
|
|
172
|
+
_unique([e['path'] for e in excluded], field+'.excluded', folded=True)
|
|
173
|
+
return {'searched_paths': searched, 'predicates': sorted(predicates),
|
|
174
|
+
'excluded': sorted(excluded, key=lambda e: e['path'])}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _evidence(value, coverage, scope, field):
|
|
178
|
+
result = []
|
|
179
|
+
for index, entry in enumerate(_list(value, MAX_EVIDENCE, field)):
|
|
180
|
+
part = field+'['+str(index)+']'
|
|
181
|
+
_object(entry, ('id', 'path', 'start_line', 'end_line', 'digest_method', 'digest'), part)
|
|
182
|
+
path = _path(entry['path'], part+'.path')
|
|
183
|
+
_need(any(_within(path, root) for root in scope['paths'])
|
|
184
|
+
and any(_within(path, root) for root in coverage['searched_paths']), 'OUT_OF_SCOPE', part)
|
|
185
|
+
_need(not any(_within(path, e['path']) for e in coverage['excluded']), 'EXCLUDED_EVIDENCE', part)
|
|
186
|
+
start, end = entry['start_line'], entry['end_line']
|
|
187
|
+
_need(type(start) is int and type(end) is int and 1 <= start <= end <= 10_000_000,
|
|
188
|
+
'INVALID_RANGE', part)
|
|
189
|
+
method = _enum(entry['digest_method'], _DIGESTS, part+'.digest_method')
|
|
190
|
+
result.append({'id': _id(entry['id'], part+'.id'), 'path': path,
|
|
191
|
+
'start_line': start, 'end_line': end, 'digest_method': method,
|
|
192
|
+
'digest': _digest(entry['digest'], _DIGESTS[method], part+'.digest')})
|
|
193
|
+
_unique([e['id'] for e in result], field)
|
|
194
|
+
spellings = {}
|
|
195
|
+
for entry in result:
|
|
196
|
+
path = entry['path']; prior = spellings.setdefault(path.casefold(), path)
|
|
197
|
+
_need(prior == path, 'PATH_COLLISION', field)
|
|
198
|
+
return sorted(result, key=lambda e: e['id'])
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _outcome(value, scope, field):
|
|
202
|
+
_object(value, ('category', 'status', 'confidence', 'reason', 'coverage', 'evidence', 'findings'), field)
|
|
203
|
+
category = _enum(value['category'], CATEGORIES, field+'.category')
|
|
204
|
+
_need(category in scope['categories'], 'CATEGORY_COVERAGE', field)
|
|
205
|
+
status = _enum(value['status'], OUTCOMES, field+'.status')
|
|
206
|
+
confidence = value['confidence']
|
|
207
|
+
if confidence is not None:
|
|
208
|
+
confidence = CONFIDENCE[_enum(confidence, CONFIDENCE, field+'.confidence')]
|
|
209
|
+
reason = _text(value['reason'], 512, field+'.reason', nullable=True)
|
|
210
|
+
coverage = _coverage(value['coverage'], scope, field+'.coverage')
|
|
211
|
+
evidence = _evidence(value['evidence'], coverage, scope, field+'.evidence')
|
|
212
|
+
evidence_ids = {e['id'] for e in evidence}
|
|
213
|
+
findings = []
|
|
214
|
+
used = set()
|
|
215
|
+
for index, entry in enumerate(_list(value['findings'], MAX_FINDINGS, field+'.findings')):
|
|
216
|
+
part = field+'.findings['+str(index)+']'
|
|
217
|
+
_object(entry, ('id', 'name', 'value', 'evidence_refs'), part)
|
|
218
|
+
refs = [_id(ref, part+'.evidence_refs') for ref in _list(entry['evidence_refs'], MAX_EVIDENCE, part)]
|
|
219
|
+
_unique(refs, part+'.evidence_refs')
|
|
220
|
+
_need(bool(refs) and set(refs) <= evidence_ids, 'DANGLING_EVIDENCE', part)
|
|
221
|
+
used.update(refs)
|
|
222
|
+
findings.append({'id': _id(entry['id'], part+'.id'),
|
|
223
|
+
'name': _enum(entry['name'], FINDING_NAMES[category], part+'.name'),
|
|
224
|
+
'value': _text(entry['value'], 256, part+'.value'), 'evidence_refs': sorted(refs)})
|
|
225
|
+
_unique([f['id'] for f in findings], field+'.findings')
|
|
226
|
+
if status in ('observed', 'ambiguous'):
|
|
227
|
+
if findings:
|
|
228
|
+
_need(confidence is not None, 'CONFIDENCE_REQUIRED', field)
|
|
229
|
+
_need(used == evidence_ids, 'UNUSED_EVIDENCE', field)
|
|
230
|
+
if status == 'observed':
|
|
231
|
+
_need(bool(findings) and confidence is not None, 'EVIDENCE_REQUIRED', field)
|
|
232
|
+
else:
|
|
233
|
+
_need(not findings, 'UNSUPPORTED_FINDING', field)
|
|
234
|
+
if status in ('not_inspected', 'failed', 'oversize'):
|
|
235
|
+
_need(confidence is None, 'UNSUPPORTED_CONFIDENCE', field)
|
|
236
|
+
if status != 'observed':
|
|
237
|
+
_need(reason is not None, 'REASON_REQUIRED', field)
|
|
238
|
+
if status == 'not_applicable':
|
|
239
|
+
_need(bool(evidence) and confidence is not None, 'EVIDENCE_REQUIRED', field)
|
|
240
|
+
if status in ('observed', 'not_applicable', 'not_found_in_scope'):
|
|
241
|
+
_need(bool(coverage['searched_paths']) and bool(coverage['predicates']), 'SEARCH_COVERAGE', field)
|
|
242
|
+
return {'category': category, 'status': status, 'confidence': confidence, 'reason': reason,
|
|
243
|
+
'coverage': coverage, 'evidence': evidence, 'findings': sorted(findings, key=lambda f: f['id'])}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _pairs(pairs):
|
|
247
|
+
result = {}
|
|
248
|
+
for key, value in pairs:
|
|
249
|
+
_need(key not in result, 'DUPLICATE_KEY', 'report')
|
|
250
|
+
result[key] = value
|
|
251
|
+
return result
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _integer(value):
|
|
255
|
+
_need(len(value) <= 16, 'NUMBER_LIMIT', 'report')
|
|
256
|
+
return int(value)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _invalid_number(_value):
|
|
260
|
+
raise ReportError('NUMBER_SHAPE')
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _decode(payload):
|
|
264
|
+
_need(type(payload) in (bytes, str), 'JSON_REQUIRED', 'report')
|
|
265
|
+
try:
|
|
266
|
+
raw = payload if type(payload) is bytes else payload.encode('utf-8')
|
|
267
|
+
_need(len(raw) <= MAX_REPORT_BYTES, 'REPORT_TOO_LARGE', 'report')
|
|
268
|
+
text = raw.decode('utf-8', errors='strict')
|
|
269
|
+
except UnicodeError:
|
|
270
|
+
raise ReportError('INVALID_UNICODE') from None
|
|
271
|
+
# Bound nesting before the JSON parser allocates nested containers. Ignore
|
|
272
|
+
# brackets inside strings, including escaped quotes and backslashes.
|
|
273
|
+
depth = 0
|
|
274
|
+
string = escaped = False
|
|
275
|
+
for char in text:
|
|
276
|
+
if string:
|
|
277
|
+
if escaped: escaped = False
|
|
278
|
+
elif char == '\\': escaped = True
|
|
279
|
+
elif char == '"': string = False
|
|
280
|
+
elif char == '"': string = True
|
|
281
|
+
elif char in '[{':
|
|
282
|
+
depth += 1
|
|
283
|
+
_need(depth <= MAX_DEPTH, 'DEPTH_LIMIT', 'report')
|
|
284
|
+
elif char in ']}': depth -= 1
|
|
285
|
+
try:
|
|
286
|
+
value = json.loads(text, object_pairs_hook=_pairs, parse_int=_integer,
|
|
287
|
+
parse_float=_invalid_number, parse_constant=_invalid_number)
|
|
288
|
+
except (ValueError, RecursionError) as error:
|
|
289
|
+
if isinstance(error, ReportError): raise
|
|
290
|
+
raise ReportError('INVALID_JSON') from None
|
|
291
|
+
pending = [value]; count = 0
|
|
292
|
+
while pending:
|
|
293
|
+
item = pending.pop(); count += 1
|
|
294
|
+
_need(count <= MAX_NODES, 'NODE_LIMIT', 'report')
|
|
295
|
+
if type(item) is dict: pending.extend(item.values())
|
|
296
|
+
elif type(item) is list: pending.extend(item)
|
|
297
|
+
return value
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def decode_report(payload, *, expected):
|
|
301
|
+
"""Decode one report against a caller-owned assignment/attempt snapshot.
|
|
302
|
+
|
|
303
|
+
`expected` has assignment_id, attempt_id, snapshot_id and exact scope.
|
|
304
|
+
Failed/oversize transport is returned distinctly with no outcome content.
|
|
305
|
+
A complete report must account for every assigned category, including gaps.
|
|
306
|
+
Returned text remains untrusted evidence, not commands or normative rules.
|
|
307
|
+
"""
|
|
308
|
+
binding = _binding(expected, 'expected')
|
|
309
|
+
value = _decode(payload)
|
|
310
|
+
keys = ('schema_version', 'assignment_id', 'attempt_id', 'snapshot_id', 'scope',
|
|
311
|
+
'transport', 'diagnostic', 'outcomes')
|
|
312
|
+
_object(value, keys, 'report')
|
|
313
|
+
_need(type(value['schema_version']) is int and value['schema_version'] == SCHEMA_VERSION,
|
|
314
|
+
'UNSUPPORTED_VERSION', 'report.schema_version')
|
|
315
|
+
actual = _binding({k: value[k] for k in binding}, 'report.binding')
|
|
316
|
+
_need(actual == binding, 'BINDING_MISMATCH', 'report.binding')
|
|
317
|
+
transport = _enum(value['transport'], ('complete', 'failed', 'oversize'), 'report.transport')
|
|
318
|
+
outcomes = _list(value['outcomes'], len(CATEGORIES), 'report.outcomes')
|
|
319
|
+
if transport != 'complete':
|
|
320
|
+
_need(not outcomes, 'TRANSPORT_CONTENT', 'report.outcomes')
|
|
321
|
+
codes = ('SCOPE_TOO_LARGE', 'REPORT_TOO_LARGE') if transport == 'oversize' else (
|
|
322
|
+
'TIMEOUT', 'UNAVAILABLE', 'TOOL_FAILURE', 'CANCELLED')
|
|
323
|
+
diagnostic = _enum(value['diagnostic'], codes, 'report.diagnostic')
|
|
324
|
+
else:
|
|
325
|
+
_need(value['diagnostic'] is None, 'TRANSPORT_CONTENT', 'report.diagnostic')
|
|
326
|
+
diagnostic = None
|
|
327
|
+
outcomes = [_outcome(o, binding['scope'], 'report.outcomes['+str(i)+']')
|
|
328
|
+
for i, o in enumerate(outcomes)]
|
|
329
|
+
_unique([o['category'] for o in outcomes], 'report.outcomes')
|
|
330
|
+
_need({o['category'] for o in outcomes} == set(binding['scope']['categories']),
|
|
331
|
+
'CATEGORY_COVERAGE', 'report.outcomes')
|
|
332
|
+
# One snapshot cannot contain conflicting identities for the same source
|
|
333
|
+
# and method, including citations in separate categories. Different named
|
|
334
|
+
# digest methods remain deliberately incomparable.
|
|
335
|
+
spellings = {}
|
|
336
|
+
source_identities = {}
|
|
337
|
+
for outcome in outcomes:
|
|
338
|
+
for source in outcome['evidence']:
|
|
339
|
+
path = source['path']
|
|
340
|
+
prior = spellings.setdefault(path.casefold(), path)
|
|
341
|
+
_need(prior == path, 'PATH_COLLISION', 'report.outcomes')
|
|
342
|
+
key = (path, source['digest_method'])
|
|
343
|
+
digest = source_identities.setdefault(key, source['digest'])
|
|
344
|
+
_need(digest == source['digest'], 'MIXED_SOURCE_IDENTITY', 'report.outcomes')
|
|
345
|
+
result = {'schema_version': SCHEMA_VERSION, **actual, 'transport': transport,
|
|
346
|
+
'diagnostic': diagnostic, 'outcomes': sorted(outcomes, key=lambda o: o['category'])}
|
|
347
|
+
size = len(json.dumps(result, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('utf-8'))
|
|
348
|
+
_need(size <= MAX_REPORT_BYTES, 'REPORT_TOO_LARGE', 'report')
|
|
349
|
+
return result
|
|
@@ -11,7 +11,7 @@ import hostapi # noqa: E402
|
|
|
11
11
|
class PiHost(hostapi.Host):
|
|
12
12
|
name = "pi"
|
|
13
13
|
adapter_name = "@arbiterforge/ca-pi"
|
|
14
|
-
adapter_version = "0.15.
|
|
14
|
+
adapter_version = "0.15.5"
|
|
15
15
|
update_target = "ca-pi"
|
|
16
16
|
update_tag_prefix = "ca-pi-v"
|
|
17
17
|
update_command = "pi update npm:@arbiterforge/ca-pi"
|
|
@@ -239,13 +239,25 @@ def main(argv):
|
|
|
239
239
|
from _artifactlib import resolve_spec_file, helper_installation
|
|
240
240
|
client, entry = resolve_spec_file(positional[0], helper_installation(__file__))
|
|
241
241
|
result = client.call("validate", {"artifact_id": entry["artifact_id"], "gate": "ready"}, permit_invalid=True)
|
|
242
|
+
# Native success uses null for an empty diagnostic slice. Normalize
|
|
243
|
+
# only that documented absence, never arbitrary falsey malformed data.
|
|
244
|
+
if (result.get("artifact_id") != entry["artifact_id"]
|
|
245
|
+
or result.get("gate") != "ready"
|
|
246
|
+
or type(result.get("valid")) is not bool
|
|
247
|
+
or not re.fullmatch(r"[0-9a-f]{64}", result.get("model_sha256", ""))):
|
|
248
|
+
raise RuntimeError("malformed HTML validation result")
|
|
242
249
|
diagnostics = result.get("diagnostics")
|
|
243
250
|
if diagnostics is None:
|
|
244
|
-
diagnostics = []
|
|
251
|
+
diagnostics = []
|
|
245
252
|
if not isinstance(diagnostics, list):
|
|
246
|
-
raise
|
|
247
|
-
findings = [
|
|
248
|
-
|
|
253
|
+
raise RuntimeError("HTML validation diagnostics must be a list or null")
|
|
254
|
+
findings = []
|
|
255
|
+
for item in diagnostics:
|
|
256
|
+
if (not isinstance(item, dict)
|
|
257
|
+
or not isinstance(item.get("code"), str)
|
|
258
|
+
or not isinstance(item.get("message"), str)):
|
|
259
|
+
raise RuntimeError("malformed HTML validation diagnostic")
|
|
260
|
+
findings.append("[INVALID-HTML-SPEC] " + item["code"] + ": " + item["message"])
|
|
249
261
|
# Explicit source->criterion mappings replace fuzzy scope matching.
|
|
250
262
|
# The legacy issue-checkbox heuristic is retained ONLY for external
|
|
251
263
|
# issue text until issue-source records receive their own schema.
|
|
@@ -259,7 +271,8 @@ def main(argv):
|
|
|
259
271
|
if not _cited(box, criteria_tokens):
|
|
260
272
|
findings.append("[UNCOVERED-CHECKBOX] " + box)
|
|
261
273
|
fresh = client.call("identity", {"artifact_id": entry["artifact_id"]})
|
|
262
|
-
if fresh
|
|
274
|
+
if (fresh.get("artifact_id") != entry["artifact_id"]
|
|
275
|
+
or fresh["model_sha256"] != result["model_sha256"]):
|
|
263
276
|
raise RuntimeError("HTML spec changed during intent verification; retry a fresh read")
|
|
264
277
|
for finding in findings:
|
|
265
278
|
print(finding)
|
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
# drift_trigger:true with a diverged or absent
|
|
90
90
|
# current hash; [] when no staged file is tracked
|
|
91
91
|
# (cost guarantee — ordinary commits pay nothing, AC-13)
|
|
92
|
-
# lint_code_map(text) -> list[str] cap / multi-line violations
|
|
92
|
+
# lint_code_map(text) -> list[str] UTF-8 size / entry cap / multi-line violations
|
|
93
93
|
# write_stub(path, doc, *, interview_derived=True, created=None) -> None
|
|
94
94
|
# write greenfield stub: interview_derived=True, entries=[]
|
|
95
95
|
|
|
@@ -123,6 +123,10 @@ GIT_TIMEOUT = 5 # seconds; a git read must never stall SessionStart
|
|
|
123
123
|
# a full file index — never allow it to grow past this without deliberate review.
|
|
124
124
|
CODE_MAP_MAX_ENTRIES = 50
|
|
125
125
|
|
|
126
|
+
# Bound actual UTF-8 content, including a single oversized role or heading.
|
|
127
|
+
# This is an advisory lint budget, never permission to truncate human content.
|
|
128
|
+
CODE_MAP_MAX_BYTES = 20 * 1024
|
|
129
|
+
|
|
126
130
|
# Regex matching a column-0 entry bullet: starts with '- `' (dash, space, backtick).
|
|
127
131
|
# Concern '## heading' lines are NOT entries. Captures the path between backticks.
|
|
128
132
|
_ENTRY_RE = re.compile(r"^- `([^`]+)`")
|
|
@@ -583,8 +587,8 @@ def heal_worklist(staged_paths, provenance, current_hashes):
|
|
|
583
587
|
(b) diverged — current_hashes.get(path) differs from the stored hash,
|
|
584
588
|
OR path is absent from current_hashes (staged deletion/rename).
|
|
585
589
|
|
|
586
|
-
A staged path
|
|
587
|
-
|
|
590
|
+
A staged path is excluded only when it has no enabled dependency or ALL
|
|
591
|
+
enabled document baselines match. Preserves staged_paths order; deduplicates.
|
|
588
592
|
Empty staged_paths, or no staged file tracked → [] (cost guarantee: ordinary
|
|
589
593
|
commits touching no provenance source do zero re-scout work, AC-13).
|
|
590
594
|
|
|
@@ -594,8 +598,8 @@ def heal_worklist(staged_paths, provenance, current_hashes):
|
|
|
594
598
|
if not staged_paths:
|
|
595
599
|
return []
|
|
596
600
|
|
|
597
|
-
#
|
|
598
|
-
#
|
|
601
|
+
# Keep every enrolled document baseline until divergence is evaluated.
|
|
602
|
+
# Reducing to the legacy path-only worklist must not hide a later stale doc.
|
|
599
603
|
drift_trigger_map = {}
|
|
600
604
|
try:
|
|
601
605
|
items = provenance.items()
|
|
@@ -616,8 +620,7 @@ def heal_worklist(staged_paths, provenance, current_hashes):
|
|
|
616
620
|
path = entry.get("path")
|
|
617
621
|
if path is None:
|
|
618
622
|
continue
|
|
619
|
-
|
|
620
|
-
drift_trigger_map[path] = entry.get("hash")
|
|
623
|
+
drift_trigger_map.setdefault(path, []).append(entry.get("hash"))
|
|
621
624
|
except Exception:
|
|
622
625
|
continue
|
|
623
626
|
except Exception:
|
|
@@ -640,10 +643,10 @@ def heal_worklist(staged_paths, provenance, current_hashes):
|
|
|
640
643
|
seen.add(path)
|
|
641
644
|
if path not in drift_trigger_map:
|
|
642
645
|
continue
|
|
643
|
-
|
|
644
|
-
#
|
|
645
|
-
#
|
|
646
|
-
if path not in hashes or hashes[path] !=
|
|
646
|
+
stored_hashes = drift_trigger_map[path]
|
|
647
|
+
# A mismatch in ANY dependent document schedules this source.
|
|
648
|
+
# compute_drift/changed_scope retain the per-document detail.
|
|
649
|
+
if path not in hashes or any(hashes[path] != stored for stored in stored_hashes):
|
|
647
650
|
result.append(path)
|
|
648
651
|
except Exception:
|
|
649
652
|
continue
|
|
@@ -888,12 +891,14 @@ def startup_drift_line(root, runner=None, cmd_ref=None):
|
|
|
888
891
|
|
|
889
892
|
|
|
890
893
|
def lint_code_map(text):
|
|
891
|
-
"""Lint a code
|
|
894
|
+
"""Lint a code map for UTF-8 size, entry-cap and multi-line-role violations.
|
|
892
895
|
|
|
893
896
|
Code-map format: markdown where entries are column-0 '- `path` -- role'
|
|
894
897
|
bullets. Concern '## <name>' headings are structural labels, NOT entries.
|
|
895
898
|
|
|
896
899
|
Checks applied:
|
|
900
|
+
0. UTF-8 byte length > CODE_MAP_MAX_BYTES -> one advisory warning.
|
|
901
|
+
Invalid Unicode/text is diagnosed, never silently called clean.
|
|
897
902
|
1. Entry count > CODE_MAP_MAX_ENTRIES -> one warning naming count and cap.
|
|
898
903
|
The cap enforces module/concern granularity: the code map must stay a
|
|
899
904
|
coarse index, never a full file listing.
|
|
@@ -909,6 +914,17 @@ def lint_code_map(text):
|
|
|
909
914
|
"""
|
|
910
915
|
if not text:
|
|
911
916
|
return []
|
|
917
|
+
try:
|
|
918
|
+
size = len(text.encode("utf-8"))
|
|
919
|
+
except (AttributeError, UnicodeError):
|
|
920
|
+
return ["code map is not valid UTF-8 text"]
|
|
921
|
+
byte_warnings = []
|
|
922
|
+
if size > CODE_MAP_MAX_BYTES:
|
|
923
|
+
byte_warnings.append(
|
|
924
|
+
"code map has {} UTF-8 bytes (cap {}) -- coarsen without truncating guidance".format(
|
|
925
|
+
size, CODE_MAP_MAX_BYTES
|
|
926
|
+
)
|
|
927
|
+
)
|
|
912
928
|
try:
|
|
913
929
|
lines = text.splitlines()
|
|
914
930
|
entry_paths = [] # ordered list of extracted paths (one per entry)
|
|
@@ -935,7 +951,7 @@ def lint_code_map(text):
|
|
|
935
951
|
)
|
|
936
952
|
)
|
|
937
953
|
|
|
938
|
-
result =
|
|
954
|
+
result = list(byte_warnings)
|
|
939
955
|
count = len(entry_paths)
|
|
940
956
|
if count > CODE_MAP_MAX_ENTRIES:
|
|
941
957
|
result.append(
|
|
@@ -313,7 +313,7 @@ class Host:
|
|
|
313
313
|
|
|
314
314
|
name = "claude"
|
|
315
315
|
adapter_name = "ca"
|
|
316
|
-
adapter_version = "2.22.
|
|
316
|
+
adapter_version = "2.22.5"
|
|
317
317
|
|
|
318
318
|
# Update-notifier descriptor. Each independently versioned host overrides
|
|
319
319
|
# these three values in its per-plugin _host.py. Keeping the target,
|