@arbiterforge/ca-pi 0.15.12 → 0.16.1
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 +33 -0
- package/plugins/ca-pi/SKILLS.md +1 -1
- package/plugins/ca-pi/agents/infra-author.md +7 -1
- package/plugins/ca-pi/agents/scout.md +40 -8
- package/plugins/ca-pi/generated/command-catalog.json +1 -1
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-amd64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-arm64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-amd64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-arm64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-amd64.exe +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-arm64.exe +0 -0
- package/plugins/ca-pi/helpers/artifacts/release.json +104 -6
- package/plugins/ca-pi/hooks/_activationlib.py +97 -13
- package/plugins/ca-pi/hooks/_arbiterstatelib.py +7 -2
- package/plugins/ca-pi/hooks/_artifactauthoritylib.py +313 -76
- package/plugins/ca-pi/hooks/_artifactlib.py +166 -2
- package/plugins/ca-pi/hooks/_artifactpromptlib.py +106 -2
- package/plugins/ca-pi/hooks/_bashguardlib.py +14 -0
- package/plugins/ca-pi/hooks/_contextreportlib.py +564 -6
- package/plugins/ca-pi/hooks/_contextselectlib.py +464 -0
- package/plugins/ca-pi/hooks/_contextsnapshotlib.py +999 -0
- package/plugins/ca-pi/hooks/_debughandofflib.py +767 -0
- package/plugins/ca-pi/hooks/_hooklib.py +1 -0
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_intentlib.py +5 -5
- package/plugins/ca-pi/hooks/_protectedlib.py +3 -3
- package/plugins/ca-pi/hooks/_protectedstatelib.py +152 -2
- package/plugins/ca-pi/hooks/_provenancelib.py +604 -34
- package/plugins/ca-pi/hooks/_readinjectlib.py +131 -1
- package/plugins/ca-pi/hooks/artifact-authority.py +20 -1
- package/plugins/ca-pi/hooks/debug-handoff.py +85 -0
- package/plugins/ca-pi/hooks/doctor.py +4 -2
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/hooks/init-codearbiter.py +143 -15
- package/plugins/ca-pi/hooks/prompt-submit.py +14 -0
- package/plugins/ca-pi/hooks/session-start.py +26 -7
- package/plugins/ca-pi/includes/author-tdd-workflow.md +16 -4
- package/plugins/ca-pi/includes/helper-invocation.md +128 -0
- package/plugins/ca-pi/includes/pi-host-notes.md +13 -0
- package/plugins/ca-pi/includes/routing-table.md +1 -1
- package/plugins/ca-pi/package.json +1 -1
- package/plugins/ca-pi/routines/INDEX.md +1 -1
- package/plugins/ca-pi/routines/commit-gate/SKILL.md +5 -3
- package/plugins/ca-pi/routines/context-check/SKILL.md +103 -17
- package/plugins/ca-pi/routines/context-creation/SKILL.md +290 -54
- package/plugins/ca-pi/routines/debug/SKILL.md +303 -55
- package/plugins/ca-pi/routines/debug/references/debug-handoff.schema.json +879 -0
- package/plugins/ca-pi/routines/debug/references/handoff.md +155 -0
- package/plugins/ca-pi/routines/decompose/SKILL.md +13 -0
- package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +34 -0
- package/plugins/ca-pi/routines/tdd/SKILL.md +91 -17
- package/plugins/ca-pi/skills/ca-commit/SKILL.md +5 -3
- package/plugins/ca-pi/skills/ca-context-check/SKILL.md +103 -17
- package/plugins/ca-pi/skills/ca-create-context/SKILL.md +290 -54
- package/plugins/ca-pi/skills/ca-debug/SKILL.md +303 -55
- package/plugins/ca-pi/skills/ca-decompose/SKILL.md +13 -0
- package/plugins/ca-pi/skills/ca-feature/SKILL.md +1 -1
- package/plugins/ca-pi/skills/ca-fix/SKILL.md +36 -3
- package/plugins/ca-pi/skills/ca-init/SKILL.md +19 -0
- package/plugins/ca-pi/skills/ca-review/SKILL.md +33 -4
- package/plugins/ca-pi/skills/ca-status/SKILL.md +5 -0
- package/plugins/ca-pi/skills/ca-task/SKILL.md +55 -8
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
# Design invariants (mirroring _taskboardlib.py / _metricslib.py):
|
|
11
11
|
# - Stdlib only; no third-party imports ever — runs on stock Python.
|
|
12
12
|
# - Zero side effects at import time: no git calls, no file I/O on import.
|
|
13
|
-
# - Pure
|
|
14
|
-
#
|
|
15
|
-
#
|
|
13
|
+
# - Pure comparisons are testable with synthetic data. The v1 reader/writer,
|
|
14
|
+
# strict v2 store reader, and live T-008 snapshot probe are explicit I/O
|
|
15
|
+
# boundaries.
|
|
16
16
|
# - Never raise on malformed input — degrade gracefully (this runs on the
|
|
17
17
|
# SessionStart linchpin path).
|
|
18
18
|
# - Hashing uses git hash-object via an injectable runner (batch_hash);
|
|
@@ -41,6 +41,10 @@
|
|
|
41
41
|
# }
|
|
42
42
|
# ]
|
|
43
43
|
# }
|
|
44
|
+
# Schema v2 is a closed per-document record: canonical Markdown raw identity,
|
|
45
|
+
# stable field/claim IDs, raw-content and bounded-membership evidence methods,
|
|
46
|
+
# and separate stored semantic-review and owner references. Those references
|
|
47
|
+
# are inert; storage validation never grants approval or semantic authority.
|
|
44
48
|
#
|
|
45
49
|
# Files live at: .codearbiter/.provenance/<doc>.json
|
|
46
50
|
# JSON format: pretty-printed, sorted keys, trailing newline, utf-8, LF endings.
|
|
@@ -49,11 +53,10 @@
|
|
|
49
53
|
# new_record(doc, *, interview_derived=False, entries=None, created=None)
|
|
50
54
|
# -> dict canonical record with schema=1
|
|
51
55
|
# write_provenance(path, record) -> None pretty JSON; creates parent dirs
|
|
52
|
-
# valid_provenance_record(record) -> bool
|
|
53
|
-
# record (top-level shape)
|
|
56
|
+
# valid_provenance_record(record) -> bool v1 top-level or closed v2 shape
|
|
54
57
|
# read_provenance(path) -> dict | None None on missing/corrupt
|
|
55
58
|
# — including valid JSON whose
|
|
56
|
-
# shape is not a
|
|
59
|
+
# shape is not a known record
|
|
57
60
|
# batch_hash(paths, runner) -> dict[str, str] one git hash-object --stdin-paths
|
|
58
61
|
# call; input-order-preserving; {}
|
|
59
62
|
# on empty paths or runner failure
|
|
@@ -73,7 +76,7 @@
|
|
|
73
76
|
# never suppresses a later valid one.
|
|
74
77
|
# `skipped` (optional list) collects up to
|
|
75
78
|
# MAX_SKIPPED_REPORTED rejected basenames.
|
|
76
|
-
# startup_drift_line(root, runner=None)
|
|
79
|
+
# startup_drift_line(root, runner=None, cmd_ref=None, unknown=None)
|
|
77
80
|
# -> str "" when clean (AC-06);
|
|
78
81
|
# "context drift: N stale source(s) across M doc(s) -- run /ca:context-check"
|
|
79
82
|
# when drift > 0 (AC-07). ASCII-only, exactly one line.
|
|
@@ -89,12 +92,19 @@
|
|
|
89
92
|
# drift_trigger:true with a diverged or absent
|
|
90
93
|
# current hash; [] when no staged file is tracked
|
|
91
94
|
# (cost guarantee — ordinary commits pay nothing, AC-13)
|
|
92
|
-
# lint_code_map(text)
|
|
95
|
+
# lint_code_map(text, root=None) -> list[str] bounded map/path diagnostics
|
|
93
96
|
# write_stub(path, doc, *, interview_derived=True, created=None) -> None
|
|
94
97
|
# write greenfield stub: interview_derived=True, entries=[]
|
|
98
|
+
# assess_context_provenance(provenance_dir, expected_docs, snapshot, snapshot_probe=None)
|
|
99
|
+
# -> dict conservative v2 identity/coverage status;
|
|
100
|
+
# semantic authority always remains separate
|
|
101
|
+
# startup_context_coverage_line(root, cmd_ref=None)
|
|
102
|
+
# -> str bounded record-coverage diagnostic;
|
|
103
|
+
# does not hash source or claim freshness
|
|
95
104
|
|
|
96
105
|
import datetime
|
|
97
106
|
import glob
|
|
107
|
+
import hashlib
|
|
98
108
|
import json
|
|
99
109
|
import ntpath
|
|
100
110
|
import os
|
|
@@ -111,6 +121,29 @@ import _hooklib
|
|
|
111
121
|
# ---------------------------------------------------------------------------
|
|
112
122
|
|
|
113
123
|
SCHEMA_VERSION = 1
|
|
124
|
+
V2_SCHEMA_VERSION = 2
|
|
125
|
+
V2_CONTRACT = "provenance-v2/1"
|
|
126
|
+
# The store is shared by all three governance hosts. A v2 write needs an
|
|
127
|
+
# observation from the owning integration for every installed consumer.
|
|
128
|
+
V2_HOSTS = ("ca", "ca-codex", "ca-pi")
|
|
129
|
+
V2_DOCUMENTS = {
|
|
130
|
+
"CONTEXT": ".codearbiter/CONTEXT.md",
|
|
131
|
+
"tech-stack": ".codearbiter/tech-stack.md",
|
|
132
|
+
"coding-standards": ".codearbiter/coding-standards.md",
|
|
133
|
+
"security-controls": ".codearbiter/security-controls.md",
|
|
134
|
+
"code-map": ".codearbiter/code-map.md",
|
|
135
|
+
}
|
|
136
|
+
# Match the native context writer's 8 MiB canonical input ceiling. The same
|
|
137
|
+
# ceiling bounds the entire startup scan, including unusable JSON bytes.
|
|
138
|
+
MAX_CONTEXT_RECORD_BYTES = 8 * 1024 * 1024
|
|
139
|
+
MAX_CONTEXT_STORE_BYTES = 8 * 1024 * 1024
|
|
140
|
+
MAX_CONTEXT_STORE_FILES = 256
|
|
141
|
+
_V2_ID_RE = re.compile(r"(?:FIELD|CLAIM)-[A-Z0-9]+(?:-[A-Z0-9]+)*\Z")
|
|
142
|
+
_SHA256_RE = re.compile(r"[0-9a-f]{64}\Z")
|
|
143
|
+
_V2_MEMBERSHIP_PREDICATES = frozenset({
|
|
144
|
+
"manifests", "instructions", "configuration", "tests",
|
|
145
|
+
"infrastructure", "security", "data",
|
|
146
|
+
})
|
|
114
147
|
|
|
115
148
|
# Maximum seconds allowed for a single git read call. A hung git process must
|
|
116
149
|
# never stall the SessionStart linchpin hook indefinitely; timeout degrades to
|
|
@@ -130,6 +163,7 @@ CODE_MAP_MAX_BYTES = 20 * 1024
|
|
|
130
163
|
# Regex matching a column-0 entry bullet: starts with '- `' (dash, space, backtick).
|
|
131
164
|
# Concern '## heading' lines are NOT entries. Captures the path between backticks.
|
|
132
165
|
_ENTRY_RE = re.compile(r"^- `([^`]+)`")
|
|
166
|
+
_CODE_MAP_ROLE_RE = re.compile(r"^- `[^`]+`\s+(?:--|—)\s+(.+)$")
|
|
133
167
|
|
|
134
168
|
# ---------------------------------------------------------------------------
|
|
135
169
|
# classify_source constants
|
|
@@ -224,7 +258,8 @@ def new_record(doc, *, interview_derived=False, entries=None, created=None):
|
|
|
224
258
|
# ---------------------------------------------------------------------------
|
|
225
259
|
|
|
226
260
|
|
|
227
|
-
def write_provenance(path, record
|
|
261
|
+
def write_provenance(path, record, *, capability_probe=None, ownership_probe=None,
|
|
262
|
+
expected_previous_sha256=None):
|
|
228
263
|
"""Write `record` as pretty JSON to `path`, atomically.
|
|
229
264
|
|
|
230
265
|
Format: indent=2, sorted keys, ensure_ascii=False, trailing newline, utf-8,
|
|
@@ -233,13 +268,182 @@ def write_provenance(path, record):
|
|
|
233
268
|
temp file + os.replace) so a crash mid-write leaves the previous provenance
|
|
234
269
|
record intact instead of a truncated/corrupt file (reliability-016).
|
|
235
270
|
"""
|
|
271
|
+
if isinstance(record, dict) and record.get("schema") == V2_SCHEMA_VERSION:
|
|
272
|
+
if not valid_provenance_record(record):
|
|
273
|
+
raise ValueError("INVALID_V2_RECORD")
|
|
274
|
+
if os.path.basename(os.fspath(path)) != record["doc"] + ".json":
|
|
275
|
+
raise ValueError("V2_WRONG_RECORD_PATH")
|
|
276
|
+
# The probe is a trusted integration seam, not a record field or an
|
|
277
|
+
# approval adapter. Current installed consumers supply no such probe,
|
|
278
|
+
# so source-only deployments cannot enroll v2 accidentally.
|
|
279
|
+
if not callable(capability_probe) or not callable(ownership_probe):
|
|
280
|
+
raise ValueError("V2_CAPABILITY_UNAVAILABLE")
|
|
281
|
+
try:
|
|
282
|
+
capability = capability_probe(V2_CONTRACT, V2_HOSTS)
|
|
283
|
+
except Exception:
|
|
284
|
+
raise ValueError("V2_CAPABILITY_UNAVAILABLE") from None
|
|
285
|
+
if not _v2_capability_supported(capability):
|
|
286
|
+
raise ValueError("V2_CAPABILITY_UNAVAILABLE")
|
|
287
|
+
text = json.dumps(record, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
|
|
288
|
+
candidate_digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
289
|
+
field_ids = tuple(field["id"] for field in record["fields"])
|
|
290
|
+
try:
|
|
291
|
+
ownership = ownership_probe(candidate_digest, record["doc"], field_ids)
|
|
292
|
+
except Exception:
|
|
293
|
+
raise ValueError("V2_OWNERSHIP_UNVERIFIED") from None
|
|
294
|
+
if (type(ownership) is not dict or set(ownership) != {
|
|
295
|
+
"status", "record_digest", "doc", "field_ids"} or
|
|
296
|
+
ownership["status"] != "accepted" or
|
|
297
|
+
ownership["record_digest"] != candidate_digest or
|
|
298
|
+
ownership["doc"] != record["doc"] or
|
|
299
|
+
ownership["field_ids"] != list(field_ids)):
|
|
300
|
+
raise ValueError("V2_OWNERSHIP_UNVERIFIED")
|
|
301
|
+
if expected_previous_sha256 != "absent" and not _sha256(expected_previous_sha256):
|
|
302
|
+
raise ValueError("V2_PREIMAGE_REQUIRED")
|
|
303
|
+
try:
|
|
304
|
+
with open(path, "rb") as prior_file:
|
|
305
|
+
prior = prior_file.read()
|
|
306
|
+
except FileNotFoundError:
|
|
307
|
+
prior = None
|
|
308
|
+
except OSError:
|
|
309
|
+
raise ValueError("V2_PREIMAGE_UNKNOWN") from None
|
|
310
|
+
if prior is None:
|
|
311
|
+
if expected_previous_sha256 != "absent":
|
|
312
|
+
raise ValueError("V2_PREIMAGE_CHANGED")
|
|
313
|
+
else:
|
|
314
|
+
if hashlib.sha256(prior).hexdigest() != expected_previous_sha256:
|
|
315
|
+
raise ValueError("V2_PREIMAGE_CHANGED")
|
|
316
|
+
existing = read_provenance(path)
|
|
317
|
+
if existing is None or existing.get("doc") != record["doc"]:
|
|
318
|
+
raise ValueError("V2_UNSUPPORTED_PREVIOUS_RECORD")
|
|
319
|
+
else:
|
|
320
|
+
# Legacy creation and supported v1 updates retain their contract.
|
|
321
|
+
# An unreadable or unsupported existing record is not an absent slot;
|
|
322
|
+
# preserve it for explicit reconciliation even through an older caller.
|
|
323
|
+
existing = read_provenance(path)
|
|
324
|
+
if existing is None and os.path.lexists(path):
|
|
325
|
+
raise ValueError("UNSUPPORTED_PREVIOUS_RECORD")
|
|
326
|
+
if existing is not None and existing.get("schema") == V2_SCHEMA_VERSION:
|
|
327
|
+
raise ValueError("V2_DOWNGRADE_UNSUPPORTED")
|
|
328
|
+
text = json.dumps(record, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
|
|
236
329
|
parent = os.path.dirname(path)
|
|
237
330
|
if parent:
|
|
238
331
|
os.makedirs(parent, exist_ok=True)
|
|
239
|
-
text = json.dumps(record, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
|
|
240
332
|
_hooklib.write_text_atomic(path, text, newline="\n")
|
|
241
333
|
|
|
242
334
|
|
|
335
|
+
def _sha256(value):
|
|
336
|
+
return type(value) is str and _SHA256_RE.fullmatch(value) is not None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _v2_capability_supported(value):
|
|
340
|
+
if type(value) is not dict or set(value) != {"contract", "producer", "consumers"}:
|
|
341
|
+
return False
|
|
342
|
+
if value["contract"] != V2_CONTRACT:
|
|
343
|
+
return False
|
|
344
|
+
producer = value["producer"]
|
|
345
|
+
consumers = value["consumers"]
|
|
346
|
+
if type(producer) is not dict or set(producer) != {"status", "contract"}:
|
|
347
|
+
return False
|
|
348
|
+
if producer != {"status": "supported", "contract": V2_CONTRACT}:
|
|
349
|
+
return False
|
|
350
|
+
if type(consumers) is not dict or set(consumers) != set(V2_HOSTS):
|
|
351
|
+
return False
|
|
352
|
+
return all(type(cell) is dict and cell == {
|
|
353
|
+
"status": "supported", "contract": V2_CONTRACT}
|
|
354
|
+
for cell in consumers.values())
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _v2_text(value):
|
|
358
|
+
return (type(value) is str and 0 < len(value) <= 256 and
|
|
359
|
+
not any(ord(char) < 32 or ord(char) == 127 for char in value))
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _v2_path(value, *, root=False):
|
|
363
|
+
if not _v2_text(value):
|
|
364
|
+
return False
|
|
365
|
+
if root and value == ".":
|
|
366
|
+
return True
|
|
367
|
+
if value.startswith(("/", "\\")) or "\\" in value or ":" in value:
|
|
368
|
+
return False
|
|
369
|
+
return all(part not in ("", ".", "..") for part in value.split("/"))
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _valid_v2_evidence(evidence):
|
|
373
|
+
if type(evidence) is not dict or evidence.get("kind") not in ("content", "membership"):
|
|
374
|
+
return False
|
|
375
|
+
if evidence["kind"] == "content":
|
|
376
|
+
return (set(evidence) == {"kind", "path", "digest_method", "digest"} and
|
|
377
|
+
_v2_path(evidence["path"]) and
|
|
378
|
+
evidence["digest_method"] == "sha256-raw" and
|
|
379
|
+
_sha256(evidence["digest"]))
|
|
380
|
+
scopes = evidence.get("scope_paths")
|
|
381
|
+
return (set(evidence) == {"kind", "predicate", "scope_paths", "digest_method", "digest"} and
|
|
382
|
+
evidence["predicate"] in _V2_MEMBERSHIP_PREDICATES and
|
|
383
|
+
type(scopes) is list and 0 < len(scopes) <= 32 and
|
|
384
|
+
len(scopes) == len(set(scopes)) and
|
|
385
|
+
all(_v2_path(scope, root=True) for scope in scopes) and
|
|
386
|
+
evidence["digest_method"] == "sha256-membership-v1" and
|
|
387
|
+
_sha256(evidence["digest"]))
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _valid_v2_record(record):
|
|
391
|
+
"""Closed storage shape; review labels and references are inert data."""
|
|
392
|
+
if set(record) != {"schema", "doc", "created", "document", "fields"}:
|
|
393
|
+
return False
|
|
394
|
+
doc = record["doc"]
|
|
395
|
+
if doc not in V2_DOCUMENTS or not _v2_text(record["created"]):
|
|
396
|
+
return False
|
|
397
|
+
document = record["document"]
|
|
398
|
+
if (type(document) is not dict or
|
|
399
|
+
set(document) != {"path", "digest_method", "digest"} or
|
|
400
|
+
document["path"] != V2_DOCUMENTS[doc] or
|
|
401
|
+
document["digest_method"] != "sha256-raw" or
|
|
402
|
+
not _sha256(document["digest"])):
|
|
403
|
+
return False
|
|
404
|
+
fields = record["fields"]
|
|
405
|
+
if type(fields) is not list or not 0 < len(fields) <= 128:
|
|
406
|
+
return False
|
|
407
|
+
ids = set()
|
|
408
|
+
for field in fields:
|
|
409
|
+
if type(field) is not dict or set(field) != {"id", "owner_ref", "claims"}:
|
|
410
|
+
return False
|
|
411
|
+
field_id = field["id"]
|
|
412
|
+
if (type(field_id) is not str or not _V2_ID_RE.fullmatch(field_id) or
|
|
413
|
+
not field_id.startswith("FIELD-") or field_id in ids or
|
|
414
|
+
not _v2_text(field["owner_ref"])):
|
|
415
|
+
return False
|
|
416
|
+
ids.add(field_id)
|
|
417
|
+
claims = field["claims"]
|
|
418
|
+
if type(claims) is not list or not 0 < len(claims) <= 128:
|
|
419
|
+
return False
|
|
420
|
+
for claim in claims:
|
|
421
|
+
if type(claim) is not dict or set(claim) != {
|
|
422
|
+
"id", "semantic_review", "evidence", "effective_authority_refs"}:
|
|
423
|
+
return False
|
|
424
|
+
claim_id = claim["id"]
|
|
425
|
+
if (type(claim_id) is not str or not _V2_ID_RE.fullmatch(claim_id) or
|
|
426
|
+
not claim_id.startswith("CLAIM-") or claim_id in ids):
|
|
427
|
+
return False
|
|
428
|
+
ids.add(claim_id)
|
|
429
|
+
review = claim["semantic_review"]
|
|
430
|
+
if (type(review) is not dict or set(review) != {"state", "reference"} or
|
|
431
|
+
review["state"] not in ("unreviewed", "reviewed", "identity_acknowledged") or
|
|
432
|
+
(review["reference"] is not None and not _v2_text(review["reference"])) or
|
|
433
|
+
(review["state"] == "reviewed" and not _v2_text(review["reference"]))):
|
|
434
|
+
return False
|
|
435
|
+
evidence = claim["evidence"]
|
|
436
|
+
if type(evidence) is not list or not 0 < len(evidence) <= 128:
|
|
437
|
+
return False
|
|
438
|
+
if not all(_valid_v2_evidence(item) for item in evidence):
|
|
439
|
+
return False
|
|
440
|
+
refs = claim["effective_authority_refs"]
|
|
441
|
+
if (type(refs) is not list or len(refs) > 32 or
|
|
442
|
+
not all(_v2_text(ref) for ref in refs) or len(refs) != len(set(refs))):
|
|
443
|
+
return False
|
|
444
|
+
return True
|
|
445
|
+
|
|
446
|
+
|
|
243
447
|
# Top-level provenance-record contract (v1). Each required key maps to the
|
|
244
448
|
# type(s) a canonical record — the one new_record() builds — always carries.
|
|
245
449
|
# `bool` is excluded from the `schema` check explicitly because in Python
|
|
@@ -253,17 +457,14 @@ _RECORD_FIELD_TYPES = (
|
|
|
253
457
|
|
|
254
458
|
|
|
255
459
|
def valid_provenance_record(record):
|
|
256
|
-
"""True iff `record` is a
|
|
460
|
+
"""True iff `record` is a known v1 or closed v2 provenance record.
|
|
257
461
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
odd claim never costs a doc its entire provenance.
|
|
462
|
+
For legacy v1, check the top-level frame only. Entry-level tolerance stays
|
|
463
|
+
where it already lives — compute_drift and read-inject skip malformed
|
|
464
|
+
entries without dropping a record. V2 is closed at every nested level.
|
|
262
465
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
unknown version would mean reading it with the wrong rules. Bumping
|
|
266
|
-
SCHEMA_VERSION therefore requires revisiting this function deliberately.
|
|
466
|
+
Unknown versions are rejected rather than best-effort parsed. Adding a
|
|
467
|
+
version requires a deliberate new branch; v1 never becomes v2 by rehashing.
|
|
267
468
|
Never raises.
|
|
268
469
|
"""
|
|
269
470
|
if not isinstance(record, dict):
|
|
@@ -271,12 +472,17 @@ def valid_provenance_record(record):
|
|
|
271
472
|
schema = record.get("schema")
|
|
272
473
|
if isinstance(schema, bool) or not isinstance(schema, int):
|
|
273
474
|
return False
|
|
274
|
-
if schema
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
475
|
+
if schema == V2_SCHEMA_VERSION:
|
|
476
|
+
try:
|
|
477
|
+
return _valid_v2_record(record)
|
|
478
|
+
except (KeyError, TypeError, ValueError):
|
|
278
479
|
return False
|
|
279
|
-
|
|
480
|
+
if schema == SCHEMA_VERSION:
|
|
481
|
+
for key, want in _RECORD_FIELD_TYPES:
|
|
482
|
+
if key not in record or not isinstance(record[key], want):
|
|
483
|
+
return False
|
|
484
|
+
return True
|
|
485
|
+
return False
|
|
280
486
|
|
|
281
487
|
|
|
282
488
|
def read_provenance(path):
|
|
@@ -286,7 +492,7 @@ def read_provenance(path):
|
|
|
286
492
|
a permission error, or malformed JSON all return None; the caller degrades
|
|
287
493
|
gracefully.
|
|
288
494
|
|
|
289
|
-
#410: "corrupt"
|
|
495
|
+
#410: "corrupt" includes SYNTACTICALLY VALID JSON of the wrong shape.
|
|
290
496
|
The documented return type is `dict | None`, but the raw json.load result
|
|
291
497
|
was handed straight back — so a file containing `[]` was admitted to the
|
|
292
498
|
store as a list and the first `.get()` downstream raised AttributeError.
|
|
@@ -301,6 +507,333 @@ def read_provenance(path):
|
|
|
301
507
|
return record if valid_provenance_record(record) else None
|
|
302
508
|
|
|
303
509
|
|
|
510
|
+
def _read_context_store(provenance_dir, expected_docs):
|
|
511
|
+
"""Read bounded v2 candidates, preserving valid neighbors on bad records.
|
|
512
|
+
|
|
513
|
+
The returned completeness flag covers every JSON in the store, including
|
|
514
|
+
unsupported extra files. Per-document faults remain distinguishable from
|
|
515
|
+
absent files; neither can become a verified current identity.
|
|
516
|
+
"""
|
|
517
|
+
records = {}
|
|
518
|
+
faults = {}
|
|
519
|
+
complete = True
|
|
520
|
+
truncated = False
|
|
521
|
+
total_bytes = 0
|
|
522
|
+
|
|
523
|
+
def unique_object(pairs):
|
|
524
|
+
value = {}
|
|
525
|
+
for key, item in pairs:
|
|
526
|
+
if key in value:
|
|
527
|
+
raise ValueError("duplicate JSON key")
|
|
528
|
+
value[key] = item
|
|
529
|
+
return value
|
|
530
|
+
|
|
531
|
+
try:
|
|
532
|
+
with os.scandir(provenance_dir) as listing:
|
|
533
|
+
for count, entry in enumerate(listing, 1):
|
|
534
|
+
if count > MAX_CONTEXT_STORE_FILES:
|
|
535
|
+
complete = False
|
|
536
|
+
truncated = True
|
|
537
|
+
break
|
|
538
|
+
if not entry.name.endswith(".json"):
|
|
539
|
+
continue
|
|
540
|
+
stem = entry.name[:-5]
|
|
541
|
+
try:
|
|
542
|
+
if not entry.is_file(follow_symlinks=False):
|
|
543
|
+
raise ValueError("non-file provenance entry")
|
|
544
|
+
remaining = MAX_CONTEXT_STORE_BYTES - total_bytes
|
|
545
|
+
if remaining <= 0:
|
|
546
|
+
faults[stem] = "oversize"
|
|
547
|
+
complete = False
|
|
548
|
+
truncated = True
|
|
549
|
+
break
|
|
550
|
+
limit = min(MAX_CONTEXT_RECORD_BYTES, remaining)
|
|
551
|
+
with open(entry.path, "rb") as source:
|
|
552
|
+
raw = source.read(limit + 1)
|
|
553
|
+
total_bytes += len(raw)
|
|
554
|
+
if len(raw) > limit:
|
|
555
|
+
faults[stem] = "oversize"
|
|
556
|
+
complete = False
|
|
557
|
+
if len(raw) > remaining:
|
|
558
|
+
truncated = True
|
|
559
|
+
break
|
|
560
|
+
continue
|
|
561
|
+
record = json.loads(raw.decode("utf-8"),
|
|
562
|
+
object_pairs_hook=unique_object)
|
|
563
|
+
except (OSError, UnicodeError, ValueError, RecursionError):
|
|
564
|
+
complete = False
|
|
565
|
+
faults[stem] = "corrupt"
|
|
566
|
+
continue
|
|
567
|
+
if not valid_provenance_record(record):
|
|
568
|
+
complete = False
|
|
569
|
+
faults[stem] = "unsupported" if (type(record) is dict and
|
|
570
|
+
record.get("schema") not in
|
|
571
|
+
(SCHEMA_VERSION, V2_SCHEMA_VERSION)) else "corrupt"
|
|
572
|
+
continue
|
|
573
|
+
doc = record["doc"]
|
|
574
|
+
if doc in V2_DOCUMENTS and entry.name != doc + ".json":
|
|
575
|
+
complete = False
|
|
576
|
+
faults[stem] = "corrupt"
|
|
577
|
+
continue
|
|
578
|
+
if doc in records:
|
|
579
|
+
complete = False
|
|
580
|
+
faults[doc] = "corrupt"
|
|
581
|
+
records.pop(doc, None)
|
|
582
|
+
continue
|
|
583
|
+
records[doc] = record
|
|
584
|
+
except FileNotFoundError:
|
|
585
|
+
return {}, True, {}
|
|
586
|
+
except (OSError, TypeError, ValueError):
|
|
587
|
+
return {}, False, {doc: "store_unreadable" for doc in expected_docs}
|
|
588
|
+
if truncated:
|
|
589
|
+
for doc in expected_docs:
|
|
590
|
+
if doc not in records and doc not in faults:
|
|
591
|
+
faults[doc] = "store_unreadable"
|
|
592
|
+
return {doc: records.get(doc) for doc in expected_docs}, complete, faults
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _first_manifest_after_interview_stub(provenance_dir, record, snapshot):
|
|
596
|
+
"""Return currently observed manifest paths, or [] without a safe handoff.
|
|
597
|
+
|
|
598
|
+
This is an advisory first-input acquisition for the empty greenfield stack
|
|
599
|
+
stub. It neither reads manifest content nor writes guidance or provenance.
|
|
600
|
+
The stub's empty entries mean no acquired source facts, not proof that the
|
|
601
|
+
repository was empty. A complete prior empty snapshot or a fresh nonempty
|
|
602
|
+
one can initiate acquisition. Worktree, membership and target are rechecked
|
|
603
|
+
before reporting actual current paths.
|
|
604
|
+
"""
|
|
605
|
+
if (record.get("schema") != SCHEMA_VERSION or
|
|
606
|
+
record.get("doc") != "tech-stack" or
|
|
607
|
+
record.get("interview_derived") is not True or
|
|
608
|
+
record.get("entries") != []):
|
|
609
|
+
return []
|
|
610
|
+
try:
|
|
611
|
+
import _contextsnapshotlib
|
|
612
|
+
root = snapshot["worktree"]["root"]
|
|
613
|
+
if (type(root) is not str or
|
|
614
|
+
os.path.normcase(os.path.realpath(provenance_dir)) !=
|
|
615
|
+
os.path.normcase(os.path.realpath(os.path.join(
|
|
616
|
+
root, ".codearbiter", ".provenance")))):
|
|
617
|
+
return []
|
|
618
|
+
saved = next(item for item in snapshot["source"]["membership"]
|
|
619
|
+
if item["predicate"] == "manifests" and
|
|
620
|
+
item["scope_paths"] == ["."])
|
|
621
|
+
if saved["status"] != "complete":
|
|
622
|
+
return []
|
|
623
|
+
checked = _contextsnapshotlib.compare_context_dependencies(
|
|
624
|
+
snapshot, membership=(("manifests", (".",)),),
|
|
625
|
+
target_paths=(".codearbiter/tech-stack.md",))
|
|
626
|
+
if not checked["targets_current"]:
|
|
627
|
+
return []
|
|
628
|
+
current = _contextsnapshotlib.membership_snapshot(root, "manifests")
|
|
629
|
+
if current["status"] != "complete" or not current["matches"]:
|
|
630
|
+
return []
|
|
631
|
+
return current["matches"]
|
|
632
|
+
except (KeyError, TypeError, ValueError, AttributeError, StopIteration,
|
|
633
|
+
OSError, _contextsnapshotlib.SnapshotError,
|
|
634
|
+
_contextsnapshotlib.MembershipError):
|
|
635
|
+
return []
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def assess_context_provenance(provenance_dir, expected_docs, snapshot,
|
|
639
|
+
snapshot_probe=None):
|
|
640
|
+
"""Report bounded v2 coverage and current identities, never semantic approval.
|
|
641
|
+
|
|
642
|
+
Reads the bounded on-disk JSON store and flags malformed/unsupported files
|
|
643
|
+
without losing valid neighbors. The legacy loader intentionally skips bad
|
|
644
|
+
records for advisory availability; task-time reliance must not call that
|
|
645
|
+
omission complete coverage.
|
|
646
|
+
`snapshot` is the T-008 source/target snapshot; the default probe reacquires
|
|
647
|
+
it at an action boundary. An integration may supply an equivalent trusted
|
|
648
|
+
probe, but a stored record or source snapshot alone is never live truth.
|
|
649
|
+
The result cannot authorize the future native repository-context kind.
|
|
650
|
+
"""
|
|
651
|
+
result = {"coverage": "incomplete", "identity": "unknown",
|
|
652
|
+
"semantic": "unverified", "verified_fresh": False,
|
|
653
|
+
"documents": {}, "first_input": {}}
|
|
654
|
+
if (not isinstance(provenance_dir, (str, os.PathLike)) or
|
|
655
|
+
type(expected_docs) not in (list, tuple) or
|
|
656
|
+
not expected_docs):
|
|
657
|
+
return result
|
|
658
|
+
try:
|
|
659
|
+
if (len(expected_docs) != len(set(expected_docs)) or
|
|
660
|
+
any(doc not in V2_DOCUMENTS for doc in expected_docs)):
|
|
661
|
+
return result
|
|
662
|
+
except TypeError:
|
|
663
|
+
return result
|
|
664
|
+
records, store_complete, faults = _read_context_store(provenance_dir, expected_docs)
|
|
665
|
+
for doc in expected_docs:
|
|
666
|
+
record = records.get(doc)
|
|
667
|
+
if doc in faults:
|
|
668
|
+
result["documents"][doc] = faults[doc]
|
|
669
|
+
elif record is None:
|
|
670
|
+
result["documents"][doc] = "missing"
|
|
671
|
+
elif not valid_provenance_record(record) or record.get("doc") != doc:
|
|
672
|
+
result["documents"][doc] = "unsupported"
|
|
673
|
+
elif record["schema"] == SCHEMA_VERSION:
|
|
674
|
+
result["documents"][doc] = "legacy_unverified"
|
|
675
|
+
if doc == "tech-stack" and snapshot_probe is None:
|
|
676
|
+
paths = _first_manifest_after_interview_stub(
|
|
677
|
+
provenance_dir, record, snapshot)
|
|
678
|
+
if paths:
|
|
679
|
+
result["documents"][doc] = "first_input_pending"
|
|
680
|
+
result["first_input"][doc] = {
|
|
681
|
+
"paths": paths, "refresh_docs": ["tech-stack"]}
|
|
682
|
+
else:
|
|
683
|
+
result["documents"][doc] = "v2_unchecked"
|
|
684
|
+
eligible = [doc for doc in expected_docs
|
|
685
|
+
if result["documents"][doc] == "v2_unchecked"]
|
|
686
|
+
if not eligible:
|
|
687
|
+
return result
|
|
688
|
+
probes = {}
|
|
689
|
+
if snapshot_probe is None:
|
|
690
|
+
# A provenance store belongs to one worktree. Its own JSON and marker
|
|
691
|
+
# writes are output effects, never source dependencies for a new scout.
|
|
692
|
+
try:
|
|
693
|
+
import _contextsnapshotlib
|
|
694
|
+
root = snapshot["worktree"]["root"]
|
|
695
|
+
if (type(root) is not str or
|
|
696
|
+
os.path.normcase(os.path.realpath(provenance_dir)) !=
|
|
697
|
+
os.path.normcase(os.path.realpath(os.path.join(
|
|
698
|
+
root, ".codearbiter", ".provenance")))):
|
|
699
|
+
return result
|
|
700
|
+
except (KeyError, TypeError, ValueError, OSError):
|
|
701
|
+
return result
|
|
702
|
+
for doc in eligible:
|
|
703
|
+
record = records[doc]
|
|
704
|
+
evidence = [item for field in record["fields"]
|
|
705
|
+
for claim in field["claims"] for item in claim["evidence"]]
|
|
706
|
+
paths = sorted({item["path"] for item in evidence
|
|
707
|
+
if item["kind"] == "content"})
|
|
708
|
+
members = sorted({(item["predicate"], tuple(item["scope_paths"]))
|
|
709
|
+
for item in evidence if item["kind"] == "membership"})
|
|
710
|
+
try:
|
|
711
|
+
probes[doc] = _contextsnapshotlib.compare_context_dependencies(
|
|
712
|
+
snapshot, source_paths=paths, membership=members,
|
|
713
|
+
target_paths=(record["document"]["path"],))
|
|
714
|
+
except Exception:
|
|
715
|
+
# Missing/unreadable inputs or a changed worktree cannot be
|
|
716
|
+
# promoted to current by another document's successful check.
|
|
717
|
+
continue
|
|
718
|
+
else:
|
|
719
|
+
try:
|
|
720
|
+
probe = snapshot_probe(snapshot)
|
|
721
|
+
if (type(probe) is not dict or
|
|
722
|
+
type(probe.get("source_current")) is not bool or
|
|
723
|
+
type(probe.get("targets_current")) is not bool):
|
|
724
|
+
return result
|
|
725
|
+
except Exception:
|
|
726
|
+
return result
|
|
727
|
+
probes = {doc: probe for doc in eligible}
|
|
728
|
+
if not probe["source_current"] or not probe["targets_current"]:
|
|
729
|
+
if store_complete and len(eligible) == len(expected_docs):
|
|
730
|
+
result["coverage"] = "complete"
|
|
731
|
+
result["identity"] = "stale"
|
|
732
|
+
for doc in eligible:
|
|
733
|
+
result["documents"][doc] = "v2_stale"
|
|
734
|
+
return result
|
|
735
|
+
try:
|
|
736
|
+
files = snapshot["source"]["files"]
|
|
737
|
+
membership = snapshot["source"]["membership"]
|
|
738
|
+
targets = snapshot["targets"]
|
|
739
|
+
if type(files) is not dict or type(membership) is not list or type(targets) is not dict:
|
|
740
|
+
return result
|
|
741
|
+
membership_by_key = {
|
|
742
|
+
(item["predicate"], tuple(item["scope_paths"])): item
|
|
743
|
+
for item in membership if type(item) is dict
|
|
744
|
+
}
|
|
745
|
+
for doc in eligible:
|
|
746
|
+
probe = probes.get(doc)
|
|
747
|
+
if probe is None:
|
|
748
|
+
continue
|
|
749
|
+
if not probe["source_current"] or not probe["targets_current"]:
|
|
750
|
+
result["identity"] = "stale"
|
|
751
|
+
result["documents"][doc] = "v2_stale"
|
|
752
|
+
continue
|
|
753
|
+
record = records[doc]
|
|
754
|
+
document = record["document"]
|
|
755
|
+
target = targets.get(document["path"])
|
|
756
|
+
if target is None:
|
|
757
|
+
continue
|
|
758
|
+
if (target.get("status") != "file" or
|
|
759
|
+
target.get("digest_method") != document["digest_method"] or
|
|
760
|
+
target.get("digest") != document["digest"]):
|
|
761
|
+
result["identity"] = "stale"
|
|
762
|
+
result["documents"][doc] = "v2_stale"
|
|
763
|
+
continue
|
|
764
|
+
unresolved = False
|
|
765
|
+
for field in record["fields"]:
|
|
766
|
+
for claim in field["claims"]:
|
|
767
|
+
for evidence in claim["evidence"]:
|
|
768
|
+
if evidence["kind"] == "content":
|
|
769
|
+
current = files.get(evidence["path"])
|
|
770
|
+
else:
|
|
771
|
+
key = (evidence["predicate"], tuple(evidence["scope_paths"]))
|
|
772
|
+
current = membership_by_key.get(key)
|
|
773
|
+
if current is None:
|
|
774
|
+
unresolved = True
|
|
775
|
+
continue
|
|
776
|
+
if (current.get("digest_method") != evidence["digest_method"] or
|
|
777
|
+
current.get("digest") != evidence["digest"]):
|
|
778
|
+
result["identity"] = "stale"
|
|
779
|
+
result["documents"][doc] = "v2_stale"
|
|
780
|
+
if result["documents"][doc] == "v2_unchecked" and not unresolved:
|
|
781
|
+
result["documents"][doc] = "v2_current"
|
|
782
|
+
except (KeyError, TypeError, AttributeError, ValueError):
|
|
783
|
+
return result
|
|
784
|
+
if store_complete and all(result["documents"][doc] in
|
|
785
|
+
("v2_current", "v2_stale") for doc in expected_docs):
|
|
786
|
+
result["coverage"] = "complete"
|
|
787
|
+
if result["coverage"] == "complete" and result["identity"] != "stale":
|
|
788
|
+
result["identity"] = "current"
|
|
789
|
+
return result
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def startup_context_coverage_line(root, cmd_ref=None):
|
|
793
|
+
"""Summarize bounded record coverage without doing startup source hashing.
|
|
794
|
+
|
|
795
|
+
This is a record-presence diagnostic, never a current-identity or semantic
|
|
796
|
+
verdict. Task-time reliance uses assess_context_provenance with a fresh
|
|
797
|
+
action-boundary snapshot instead.
|
|
798
|
+
"""
|
|
799
|
+
try:
|
|
800
|
+
context = os.path.join(root, ".codearbiter", "CONTEXT.md")
|
|
801
|
+
if not os.path.isfile(context):
|
|
802
|
+
return ""
|
|
803
|
+
expected = tuple(V2_DOCUMENTS)
|
|
804
|
+
store = os.path.join(root, ".codearbiter", ".provenance")
|
|
805
|
+
records, complete, faults = _read_context_store(store, expected)
|
|
806
|
+
counts = {"v2": 0, "legacy": 0, "missing": 0,
|
|
807
|
+
"corrupt": 0, "unsupported": 0, "oversize": 0,
|
|
808
|
+
"unreadable": 0}
|
|
809
|
+
for doc in expected:
|
|
810
|
+
if doc in faults:
|
|
811
|
+
kind = faults[doc]
|
|
812
|
+
counts["unreadable" if kind == "store_unreadable" else kind] += 1
|
|
813
|
+
elif records.get(doc) is None:
|
|
814
|
+
counts["missing"] += 1
|
|
815
|
+
elif records[doc]["schema"] == V2_SCHEMA_VERSION:
|
|
816
|
+
counts["v2"] += 1
|
|
817
|
+
else:
|
|
818
|
+
counts["legacy"] += 1
|
|
819
|
+
ref = cmd_ref("context-check") if cmd_ref else "/ca:context-check"
|
|
820
|
+
if complete and counts["v2"] == len(expected):
|
|
821
|
+
return ("context coverage: {}/{} v2 records; identities and semantics "
|
|
822
|
+
"unchecked at startup -- run {}").format(
|
|
823
|
+
counts["v2"], len(expected), ref)
|
|
824
|
+
details = ["v2 {}/{}".format(counts["v2"], len(expected))]
|
|
825
|
+
details.extend("{} {}".format(key, counts[key]) for key in
|
|
826
|
+
("legacy", "missing", "corrupt", "unsupported",
|
|
827
|
+
"oversize", "unreadable")
|
|
828
|
+
if counts[key])
|
|
829
|
+
if not complete and not faults:
|
|
830
|
+
details.append("store incomplete")
|
|
831
|
+
return "context coverage: incomplete ({}); identities and semantics unchecked -- run {}".format(
|
|
832
|
+
"; ".join(details), ref)
|
|
833
|
+
except Exception:
|
|
834
|
+
return "context coverage: unknown -- run /ca:context-check"
|
|
835
|
+
|
|
836
|
+
|
|
304
837
|
# ---------------------------------------------------------------------------
|
|
305
838
|
# Git hashing (injectable runner contract)
|
|
306
839
|
# ---------------------------------------------------------------------------
|
|
@@ -461,8 +994,9 @@ def compute_drift(provenance_map, current_hashes):
|
|
|
461
994
|
doc_drifts = []
|
|
462
995
|
for entry in entries:
|
|
463
996
|
try:
|
|
464
|
-
#
|
|
465
|
-
|
|
997
|
+
# A removed map target invalidates orientation even for old
|
|
998
|
+
# records whose source was classified as ordinary code.
|
|
999
|
+
if entry.get("drift_trigger") is not True and doc_name != "code-map":
|
|
466
1000
|
continue
|
|
467
1001
|
path = entry.get("path")
|
|
468
1002
|
stored_hash = entry.get("hash")
|
|
@@ -473,6 +1007,8 @@ def compute_drift(provenance_map, current_hashes):
|
|
|
473
1007
|
# current_hashes means the source was renamed/deleted.
|
|
474
1008
|
doc_drifts.append({"path": path, "kind": "missing"})
|
|
475
1009
|
continue
|
|
1010
|
+
if entry.get("drift_trigger") is not True:
|
|
1011
|
+
continue
|
|
476
1012
|
current_hash = current_hashes[path]
|
|
477
1013
|
if current_hash != stored_hash:
|
|
478
1014
|
doc_drifts.append({"path": path, "kind": "changed"})
|
|
@@ -779,7 +1315,7 @@ def _is_confined_provenance_path(root, path):
|
|
|
779
1315
|
return False
|
|
780
1316
|
|
|
781
1317
|
|
|
782
|
-
def startup_drift_line(root, runner=None, cmd_ref=None):
|
|
1318
|
+
def startup_drift_line(root, runner=None, cmd_ref=None, unknown=None):
|
|
783
1319
|
"""Return a one-line drift summary for SessionStart, or '' when clean (AC-06).
|
|
784
1320
|
|
|
785
1321
|
Pipeline:
|
|
@@ -812,6 +1348,8 @@ def startup_drift_line(root, runner=None, cmd_ref=None):
|
|
|
812
1348
|
provenance paths are resolved from root. T-16 injects its own root-bound
|
|
813
1349
|
runner; tests inject a fake.
|
|
814
1350
|
|
|
1351
|
+
`unknown`, when a list is supplied, receives a bounded diagnostic on an
|
|
1352
|
+
incomplete hash batch. The legacy string result remains fail-soft.
|
|
815
1353
|
Never raises — all errors degrade to '' (safe for the SessionStart path).
|
|
816
1354
|
"""
|
|
817
1355
|
try:
|
|
@@ -826,6 +1364,7 @@ def startup_drift_line(root, runner=None, cmd_ref=None):
|
|
|
826
1364
|
# order-stable). Build a filtered in-memory map at the same time so a
|
|
827
1365
|
# rejected path cannot reappear as a false missing-file drift result.
|
|
828
1366
|
drift_trigger_paths = []
|
|
1367
|
+
code_map_existing = {}
|
|
829
1368
|
safe_pm = {}
|
|
830
1369
|
seen = set()
|
|
831
1370
|
for doc, record in pm.items():
|
|
@@ -834,13 +1373,18 @@ def startup_drift_line(root, runner=None, cmd_ref=None):
|
|
|
834
1373
|
safe_entries = []
|
|
835
1374
|
for entry in entries:
|
|
836
1375
|
try:
|
|
837
|
-
if entry.get("drift_trigger") is True:
|
|
1376
|
+
if entry.get("drift_trigger") is True or doc == "code-map":
|
|
838
1377
|
path = entry.get("path")
|
|
839
1378
|
if not _is_confined_provenance_path(root, path):
|
|
840
1379
|
continue
|
|
841
|
-
if path not in seen:
|
|
1380
|
+
if entry.get("drift_trigger") is True and path not in seen:
|
|
842
1381
|
drift_trigger_paths.append(path)
|
|
843
1382
|
seen.add(path)
|
|
1383
|
+
elif doc == "code-map" and os.path.exists(os.path.join(root, path)):
|
|
1384
|
+
# Ordinary source only needs a lifecycle check;
|
|
1385
|
+
# its changed contents do not ring the old drift
|
|
1386
|
+
# trigger. Preserve its stored hash for comparison.
|
|
1387
|
+
code_map_existing[path] = entry.get("hash")
|
|
844
1388
|
safe_entries.append(entry)
|
|
845
1389
|
except Exception:
|
|
846
1390
|
continue
|
|
@@ -868,8 +1412,13 @@ def startup_drift_line(root, runner=None, cmd_ref=None):
|
|
|
868
1412
|
# partial/aborted stdout). Feeding a short hash map to compute_drift would
|
|
869
1413
|
# falsely report the un-hashed files as 'missing'. Conservative: stay silent.
|
|
870
1414
|
if len(current_hashes) < len(existing_paths):
|
|
1415
|
+
if isinstance(unknown, list):
|
|
1416
|
+
unknown.append("partial hash")
|
|
871
1417
|
return ""
|
|
872
1418
|
|
|
1419
|
+
for path, stored_hash in code_map_existing.items():
|
|
1420
|
+
current_hashes.setdefault(path, stored_hash)
|
|
1421
|
+
|
|
873
1422
|
drift = compute_drift(pm, current_hashes)
|
|
874
1423
|
if not drift:
|
|
875
1424
|
return ""
|
|
@@ -890,8 +1439,8 @@ def startup_drift_line(root, runner=None, cmd_ref=None):
|
|
|
890
1439
|
# ---------------------------------------------------------------------------
|
|
891
1440
|
|
|
892
1441
|
|
|
893
|
-
def lint_code_map(text):
|
|
894
|
-
"""
|
|
1442
|
+
def lint_code_map(text, root=None):
|
|
1443
|
+
"""Diagnose a coarse code map without rewriting it or reading source files.
|
|
895
1444
|
|
|
896
1445
|
Code-map format: markdown where entries are column-0 '- `path` -- role'
|
|
897
1446
|
bullets. Concern '## <name>' headings are structural labels, NOT entries.
|
|
@@ -909,7 +1458,9 @@ def lint_code_map(text):
|
|
|
909
1458
|
whitespace so are excluded automatically.
|
|
910
1459
|
|
|
911
1460
|
Returns a list of human-readable ASCII warning strings; [] means clean.
|
|
912
|
-
|
|
1461
|
+
When root is supplied, check each safe target's existence on demand. A
|
|
1462
|
+
malformed optional map remains human-owned; warnings never block source
|
|
1463
|
+
inspection. Empty or None text -> [].
|
|
913
1464
|
Never raises.
|
|
914
1465
|
"""
|
|
915
1466
|
if not text:
|
|
@@ -929,6 +1480,7 @@ def lint_code_map(text):
|
|
|
929
1480
|
lines = text.splitlines()
|
|
930
1481
|
entry_paths = [] # ordered list of extracted paths (one per entry)
|
|
931
1482
|
multi_warnings = [] # per-entry multi-line-role warnings (built inline)
|
|
1483
|
+
path_warnings = []
|
|
932
1484
|
|
|
933
1485
|
for i, line in enumerate(lines):
|
|
934
1486
|
m = _ENTRY_RE.match(line)
|
|
@@ -938,6 +1490,23 @@ def lint_code_map(text):
|
|
|
938
1490
|
entry_num = len(entry_paths) + 1 # 1-indexed
|
|
939
1491
|
entry_paths.append(entry_path)
|
|
940
1492
|
|
|
1493
|
+
role = _CODE_MAP_ROLE_RE.match(line)
|
|
1494
|
+
if role is None or not role.group(1).strip():
|
|
1495
|
+
path_warnings.append("invalid one-line role at entry {}".format(entry_num))
|
|
1496
|
+
parts = entry_path.split("/")
|
|
1497
|
+
if parts[-1] == "" and len(parts) > 1:
|
|
1498
|
+
parts = parts[:-1] # directory concern
|
|
1499
|
+
safe = (entry_path == entry_path.strip() and "\\" not in entry_path
|
|
1500
|
+
and all(part not in ("", ".", "..") for part in parts)
|
|
1501
|
+
and not entry_path.startswith("/")
|
|
1502
|
+
and not ntpath.splitdrive(entry_path)[0]
|
|
1503
|
+
and not any(ord(char) < 32 or ord(char) == 127 for char in entry_path))
|
|
1504
|
+
if not safe or root is not None and not _is_confined_provenance_path(root, entry_path):
|
|
1505
|
+
path_warnings.append("unsafe code map path at entry {}".format(entry_num))
|
|
1506
|
+
elif root is not None and not os.path.exists(os.path.join(root, entry_path)):
|
|
1507
|
+
path_warnings.append("missing code map target at entry {} (path {})".format(
|
|
1508
|
+
entry_num, entry_path))
|
|
1509
|
+
|
|
941
1510
|
# Multi-line role: the next physical line starts with whitespace AND
|
|
942
1511
|
# contains non-whitespace content (a genuine continuation, not blank
|
|
943
1512
|
# vertical spacing). Column-0 bullets ('- ') and headings ('## ')
|
|
@@ -960,9 +1529,10 @@ def lint_code_map(text):
|
|
|
960
1529
|
)
|
|
961
1530
|
)
|
|
962
1531
|
result.extend(multi_warnings)
|
|
1532
|
+
result.extend(path_warnings)
|
|
963
1533
|
return result
|
|
964
1534
|
except Exception:
|
|
965
|
-
return []
|
|
1535
|
+
return ["code map could not be inspected"]
|
|
966
1536
|
|
|
967
1537
|
|
|
968
1538
|
# ---------------------------------------------------------------------------
|