@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 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.3" src="https://img.shields.io/badge/version-2.22.3-2b7489">
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arbiterforge/ca-pi",
3
- "version": "0.15.3",
3
+ "version": "0.15.5",
4
4
  "license": "AGPL-3.0-only",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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.3"
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 = [] # The native engine serializes no findings as null.
251
+ diagnostics = []
245
252
  if not isinstance(diagnostics, list):
246
- raise ValueError("HTML validation diagnostics must be a list or null")
247
- findings = ["[INVALID-HTML-SPEC] " + item["code"] + ": " + item["message"]
248
- for item in diagnostics]
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["model_sha256"] != result["model_sha256"]:
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 that is not a provenance entry, or is drift_trigger:false, or
587
- whose hash matches → excluded. Preserves staged_paths order; deduplicates.
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
- # Build {path: stored_hash} for every drift_trigger:true entry across all docs.
598
- # First occurrence wins for a path that appears in multiple docs.
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
- if path not in drift_trigger_map:
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
- stored_hash = drift_trigger_map[path]
644
- # Include when absent from current_hashes (staged deletion/rename)
645
- # OR when the hash has diverged.
646
- if path not in hashes or hashes[path] != stored_hash:
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-map.md string for entry-cap and multi-line-role violations.
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.3"
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,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arbiterforge/ca-pi",
3
- "version": "0.15.3",
3
+ "version": "0.15.5",
4
4
  "private": true,
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",