@arbiterforge/ca-pi 0.15.10 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/plugins/ca-pi/CHANGELOG.md +45 -0
  4. package/plugins/ca-pi/SKILLS.md +1 -1
  5. package/plugins/ca-pi/agents/infra-author.md +7 -1
  6. package/plugins/ca-pi/agents/scout.md +40 -8
  7. package/plugins/ca-pi/generated/command-catalog.json +1 -1
  8. package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-amd64 +0 -0
  9. package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-arm64 +0 -0
  10. package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-amd64 +0 -0
  11. package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-arm64 +0 -0
  12. package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-amd64.exe +0 -0
  13. package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-arm64.exe +0 -0
  14. package/plugins/ca-pi/helpers/artifacts/release.json +104 -6
  15. package/plugins/ca-pi/hooks/_activationlib.py +97 -13
  16. package/plugins/ca-pi/hooks/_arbiterstatelib.py +7 -2
  17. package/plugins/ca-pi/hooks/_artifactauthoritylib.py +519 -16
  18. package/plugins/ca-pi/hooks/_artifactlib.py +244 -7
  19. package/plugins/ca-pi/hooks/_artifactpromptlib.py +106 -2
  20. package/plugins/ca-pi/hooks/_bashguardlib.py +23 -8
  21. package/plugins/ca-pi/hooks/_contextreportlib.py +564 -6
  22. package/plugins/ca-pi/hooks/_contextselectlib.py +464 -0
  23. package/plugins/ca-pi/hooks/_contextsnapshotlib.py +999 -0
  24. package/plugins/ca-pi/hooks/_debughandofflib.py +767 -0
  25. package/plugins/ca-pi/hooks/_hooklib.py +2 -1
  26. package/plugins/ca-pi/hooks/_host.py +1 -1
  27. package/plugins/ca-pi/hooks/_intentlib.py +5 -5
  28. package/plugins/ca-pi/hooks/_protectedlib.py +3 -3
  29. package/plugins/ca-pi/hooks/_protectedstatelib.py +152 -2
  30. package/plugins/ca-pi/hooks/_provenancelib.py +604 -34
  31. package/plugins/ca-pi/hooks/_readinjectlib.py +131 -1
  32. package/plugins/ca-pi/hooks/_sensitivelib.py +28 -14
  33. package/plugins/ca-pi/hooks/artifact-authority-hook.py +1 -0
  34. package/plugins/ca-pi/hooks/artifact-authority.py +25 -1
  35. package/plugins/ca-pi/hooks/debug-handoff.py +85 -0
  36. package/plugins/ca-pi/hooks/doctor.py +4 -2
  37. package/plugins/ca-pi/hooks/git-enforce.py +7 -6
  38. package/plugins/ca-pi/hooks/hostapi.py +1 -1
  39. package/plugins/ca-pi/hooks/init-codearbiter.py +143 -15
  40. package/plugins/ca-pi/hooks/prompt-submit.py +14 -0
  41. package/plugins/ca-pi/hooks/session-start.py +26 -7
  42. package/plugins/ca-pi/includes/artifacts.md +41 -0
  43. package/plugins/ca-pi/includes/author-tdd-workflow.md +16 -4
  44. package/plugins/ca-pi/includes/helper-invocation.md +128 -0
  45. package/plugins/ca-pi/includes/pi-host-notes.md +13 -0
  46. package/plugins/ca-pi/includes/routing-table.md +1 -1
  47. package/plugins/ca-pi/includes/security-gate-record.md +5 -3
  48. package/plugins/ca-pi/package.json +1 -1
  49. package/plugins/ca-pi/routines/INDEX.md +1 -1
  50. package/plugins/ca-pi/routines/commit-gate/SKILL.md +5 -3
  51. package/plugins/ca-pi/routines/context-check/SKILL.md +103 -17
  52. package/plugins/ca-pi/routines/context-creation/SKILL.md +290 -54
  53. package/plugins/ca-pi/routines/debug/SKILL.md +303 -55
  54. package/plugins/ca-pi/routines/debug/references/debug-handoff.schema.json +879 -0
  55. package/plugins/ca-pi/routines/debug/references/handoff.md +155 -0
  56. package/plugins/ca-pi/routines/decompose/SKILL.md +13 -0
  57. package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +34 -0
  58. package/plugins/ca-pi/routines/tdd/SKILL.md +91 -17
  59. package/plugins/ca-pi/skills/ca-commit/SKILL.md +5 -3
  60. package/plugins/ca-pi/skills/ca-context-check/SKILL.md +103 -17
  61. package/plugins/ca-pi/skills/ca-create-context/SKILL.md +290 -54
  62. package/plugins/ca-pi/skills/ca-debug/SKILL.md +303 -55
  63. package/plugins/ca-pi/skills/ca-decompose/SKILL.md +13 -0
  64. package/plugins/ca-pi/skills/ca-feature/SKILL.md +1 -1
  65. package/plugins/ca-pi/skills/ca-fix/SKILL.md +36 -3
  66. package/plugins/ca-pi/skills/ca-init/SKILL.md +19 -0
  67. package/plugins/ca-pi/skills/ca-review/SKILL.md +33 -4
  68. package/plugins/ca-pi/skills/ca-status/SKILL.md +5 -0
  69. 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 functions are fully testable with synthetic data (no real file
14
- # needed). write_provenance() and read_provenance() are the ONLY functions
15
- # that touch the filesystem.
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 True iff a well-formed v1
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 v1 record
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) -> list[str] UTF-8 size / entry cap / multi-line violations
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 well-formed v1 provenance record (#410).
460
+ """True iff `record` is a known v1 or closed v2 provenance record.
257
461
 
258
- Checks the TOP-LEVEL shape only: the store's own frame. Entry-level
259
- tolerance stays where it already lives — compute_drift and the read-inject
260
- pointer both skip a malformed entry without dropping its record — so one
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
- A schema other than SCHEMA_VERSION is rejected rather than best-effort
264
- parsed: every field expectation below is v1-specific, so admitting an
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 != SCHEMA_VERSION:
275
- return False
276
- for key, want in _RECORD_FIELD_TYPES:
277
- if key not in record or not isinstance(record[key], want):
475
+ if schema == V2_SCHEMA_VERSION:
476
+ try:
477
+ return _valid_v2_record(record)
478
+ except (KeyError, TypeError, ValueError):
278
479
  return False
279
- return True
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" now includes SYNTACTICALLY VALID JSON of the wrong shape.
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
- # AC-09: only explicit True fires drift; absent/falsy → skip.
465
- if entry.get("drift_trigger") is not True:
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
- """Lint a code map for UTF-8 size, entry-cap and multi-line-role violations.
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
- Empty or None text -> [] (a missing/empty code map has no violations).
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
  # ---------------------------------------------------------------------------