@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
@@ -123,6 +123,8 @@ import json
123
123
  import math
124
124
  import os
125
125
  import re
126
+ import tempfile
127
+ import time
126
128
  import unicodedata
127
129
 
128
130
  import _provenancelib
@@ -791,11 +793,23 @@ def governing_docs(rel, index, runner=None):
791
793
  # paths. This is git-free. A non-provenance Read exits here with zero
792
794
  # git calls.
793
795
  entry_paths = set()
796
+ v2_docs = []
794
797
  if isinstance(provenance, dict):
795
- for record in provenance.values():
798
+ for doc_name, record in provenance.items():
796
799
  try:
797
800
  if not isinstance(record, dict):
798
801
  continue
802
+ if record.get("schema") == _provenancelib.V2_SCHEMA_VERSION:
803
+ if not _provenancelib.valid_provenance_record(record):
804
+ continue
805
+ cited = any(
806
+ evidence.get("kind") == "content" and evidence.get("path") == rel
807
+ for field in record["fields"] for claim in field["claims"]
808
+ for evidence in claim["evidence"]
809
+ )
810
+ if cited or record["document"]["path"] == rel:
811
+ v2_docs.append(doc_name)
812
+ continue
799
813
  entries = record.get("entries")
800
814
  if not isinstance(entries, list):
801
815
  continue
@@ -810,6 +824,13 @@ def governing_docs(rel, index, runner=None):
810
824
  except Exception: # noqa: BLE001
811
825
  continue
812
826
 
827
+ # A v2 content dependency is relevant advisory context, but its raw
828
+ # digest and semantic review cannot be proved by the legacy Git-hash
829
+ # gate. Point to the action-boundary audit without claiming freshness.
830
+ for doc_name in v2_docs:
831
+ result.append({"text": "{}.md context identity and semantic review unverified here -- run context-check".format(
832
+ _strip_context_controls(doc_name)), "tier": "standards"})
833
+
813
834
  # Step B: only call batch_hash when rel is a known provenance entry path.
814
835
  # Absence from entry_paths → SKIP; zero git calls.
815
836
  if rel and rel in entry_paths:
@@ -1166,6 +1187,104 @@ def spec_pointers(rel, index):
1166
1187
 
1167
1188
  _artifact_legacy_compute_injection = compute_injection
1168
1189
 
1190
+ def _session_context_generation_path(root, session_id):
1191
+ # One bounded value per session avoids a shared unbounded session map and
1192
+ # lets unrelated sessions advance without a read/modify/write race.
1193
+ return marker_path(root, session_id, 'compaction-generation',
1194
+ prefix='readinject-context-')
1195
+
1196
+
1197
+ def _linklike(path):
1198
+ return os.path.islink(path) or getattr(os.path, 'isjunction', lambda _: False)(path)
1199
+
1200
+
1201
+ def _safe_context_generation_path(root, session_id):
1202
+ if not isinstance(root, (str, os.PathLike)) or not os.path.isabs(root):
1203
+ return None
1204
+ path = _session_context_generation_path(root, session_id)
1205
+ parent = os.path.dirname(path)
1206
+ if (_linklike(root) or _linklike(os.path.join(root, '.codearbiter')) or _linklike(parent)
1207
+ or _linklike(path) or _linklike(path + '.lock')):
1208
+ return None
1209
+ return path
1210
+
1211
+
1212
+ def read_context_generation(root, session_id):
1213
+ """Read a bounded worktree-local epoch; None means corrupt/unsafe/unknown."""
1214
+ try:
1215
+ path = _safe_context_generation_path(root, session_id)
1216
+ if path is None:
1217
+ return None
1218
+ with open(path, 'rb') as stream:
1219
+ raw = stream.read(257)
1220
+ if len(raw) > 256 or not raw or not raw.isdigit():
1221
+ return None
1222
+ value = int(raw)
1223
+ return value if value <= 1_000_000_000 else None
1224
+ except FileNotFoundError:
1225
+ return 0
1226
+ except Exception: # noqa: BLE001 - advisory hook never blocks a read
1227
+ return None
1228
+
1229
+
1230
+ def bump_context_generation(root, session_id):
1231
+ """Invalidate surviving read markers after compaction, without a scan."""
1232
+ if not session_id:
1233
+ return False
1234
+ lock = None
1235
+ lock_acquired = False
1236
+ temporary = None
1237
+ try:
1238
+ path = _safe_context_generation_path(root, session_id)
1239
+ if path is None:
1240
+ return False
1241
+ os.makedirs(os.path.dirname(path), exist_ok=True)
1242
+ if _safe_context_generation_path(root, session_id) != path:
1243
+ return False
1244
+ lock = path + '.lock'
1245
+ for _ in range(25):
1246
+ try:
1247
+ os.mkdir(lock)
1248
+ lock_acquired = True
1249
+ break
1250
+ except FileExistsError:
1251
+ time.sleep(0.01)
1252
+ else:
1253
+ return False
1254
+ current = read_context_generation(root, session_id)
1255
+ if current is None or current >= 1_000_000_000:
1256
+ return False # Corrupt bytes stay intact and force redelivery.
1257
+ fd, temporary = tempfile.mkstemp(prefix='readinject-context-',
1258
+ dir=os.path.dirname(path))
1259
+ with os.fdopen(fd, 'wb') as stream:
1260
+ stream.write(str(current + 1).encode('ascii'))
1261
+ if _safe_context_generation_path(root, session_id) != path:
1262
+ return False
1263
+ os.replace(temporary, path)
1264
+ temporary = None
1265
+ return True
1266
+ except Exception: # noqa: BLE001 - task-time delivery remains the primary route
1267
+ return False
1268
+ finally:
1269
+ if temporary is not None:
1270
+ try:
1271
+ os.unlink(temporary)
1272
+ except OSError:
1273
+ pass
1274
+ if lock_acquired:
1275
+ try:
1276
+ os.rmdir(lock)
1277
+ except OSError:
1278
+ pass
1279
+
1280
+
1281
+ def _context_epoch_session(root, session_id):
1282
+ generation = read_context_generation(root, session_id)
1283
+ if generation is None:
1284
+ return None
1285
+ return (str(session_id) + '|context:' + str(generation)
1286
+ if generation else session_id)
1287
+
1169
1288
 
1170
1289
  def compute_injection(root, session_id, rel, runner=None):
1171
1290
  # Bind the injected pointers and dedup epoch to ONE validated HTML snapshot.
@@ -1173,8 +1292,19 @@ def compute_injection(root, session_id, rel, runner=None):
1173
1292
  # under B's marker, suppressing a later stable B read. HTML therefore builds
1174
1293
  # once before dedup; the same entries drive both context and epoch. This is
1175
1294
  # advisory identity only, never approval or execution authority.
1295
+ # Preserve the legacy self-read zero-I/O path before any epoch lookup.
1176
1296
  if not isinstance(rel, str) or rel.replace("\\", "/").split("/", 1)[0] == ".codearbiter":
1177
1297
  return _artifact_legacy_compute_injection(root, session_id, rel, runner)
1298
+ session_id = _context_epoch_session(root, session_id)
1299
+ if session_id is None:
1300
+ # Unknown/corrupt epoch: bounded advisory redelivery without writing
1301
+ # a fresh marker for every read. The actor delivery planner owns its
1302
+ # separate two-attempt accounting and action gap.
1303
+ try:
1304
+ index = build_index(root)
1305
+ return assemble_context(governing_docs(rel, index, runner), budget=150)
1306
+ except Exception: # noqa: BLE001
1307
+ return ""
1178
1308
  try:
1179
1309
  from _artifactlib import has_html
1180
1310
  if not has_html(root):
@@ -281,7 +281,7 @@ def sensitive_scan_added_lines(diff_text):
281
281
  # bindings use a separate marker namespace; a user-authored source string or
282
282
  # an old line-only approval cannot impersonate one of these records.
283
283
  # SecurityScan(crypto, digests): classification and exact approval identities.
284
- # security_scan_lines(lines) -> SecurityScan|None: line-bound scan or refusal.
284
+ # security_scan_lines(lines, path=None) -> SecurityScan|None: line-bound scan or refusal.
285
285
  # security_scan_source(path, source, added_rows) -> SecurityScan|None.
286
286
  # security_scan_diff(text) -> SecurityScan|None: None means untrusted context.
287
287
  SecurityScan = namedtuple("SecurityScan", "crypto digests")
@@ -320,20 +320,23 @@ def _cross_line_crypto_spans(lines):
320
320
  return spans
321
321
 
322
322
 
323
- def _scan_bound_lines(lines, contextual_bindings):
323
+ def _scan_bound_lines(lines, contextual_bindings, path=None):
324
324
  """Each cross-line match needs its own recognized contextual identity."""
325
325
  spans = _cross_line_crypto_spans(lines)
326
326
  if not spans <= contextual_bindings.keys():
327
327
  return None
328
328
  return SecurityScan(bool(spans) or any(CRYPTO_RE.search(line) for line in lines), {
329
- line_digest(line) for line in lines
329
+ line_digest(line, path) for line in lines
330
330
  if CRYPTO_RE.search(line) or SECRET_RE.search(line)
331
331
  } | set(contextual_bindings.values()))
332
332
 
333
333
 
334
- def security_scan_lines(lines):
335
- """Preserve same-line bindings; refuse crypto spanning unbound lines."""
336
- return _scan_bound_lines(lines, {})
334
+ def security_scan_lines(lines, path=None):
335
+ """Preserve same-line bindings; refuse crypto spanning unbound lines.
336
+
337
+ `path` binds each approval to the file the line lives in, so a reviewed
338
+ line cannot be admitted again after moving to a different file."""
339
+ return _scan_bound_lines(lines, {}, path)
337
340
 
338
341
 
339
342
  def security_scan_source(path, source, added_rows):
@@ -348,7 +351,7 @@ def security_scan_source(path, source, added_rows):
348
351
  if any(row < 1 or row > len(lines) for row in added_rows):
349
352
  return None
350
353
  added = [lines[row - 1] for row in added_rows]
351
- result = security_scan_lines(added)
354
+ result = security_scan_lines(added, path)
352
355
  candidates = {row for row in added_rows
353
356
  if _is_context_item_line(lines[row - 1])}
354
357
  if not candidates:
@@ -405,7 +408,7 @@ def security_scan_source(path, source, added_rows):
405
408
  bindings = {(positions[first[0]], first[1], positions[last[0]], last[1]): contexts[last[0]]
406
409
  for first, last in import_spans
407
410
  if first[0] in positions and last[0] in positions}
408
- result = _scan_bound_lines(added, bindings)
411
+ result = _scan_bound_lines(added, bindings, path)
409
412
  if result is None:
410
413
  return None
411
414
  return SecurityScan(result.crypto or bool(contexts), result.digests | set(contexts.values()))
@@ -484,7 +487,9 @@ def security_scan_diff(diff_text):
484
487
  # in the legacy permissive walker before context validation even runs.
485
488
  if not any(line.startswith("+") and _is_context_item_line(line[1:])
486
489
  for line in section):
487
- contextual = security_scan_lines(added)
490
+ # One file per section: bind its approvals to that file's path.
491
+ paths = {path for path, _ in attributed if path}
492
+ contextual = security_scan_lines(added, paths.pop() if len(paths) == 1 else None)
488
493
  else:
489
494
  if not section[0].startswith("diff --git "):
490
495
  return None
@@ -508,14 +513,23 @@ def security_scan_diff(diff_text):
508
513
  return result
509
514
 
510
515
 
511
- def line_digest(line):
516
+ def line_digest(line, path=None):
512
517
  """Digest of one added diff line, for the security-gate binding
513
518
  (H-09b/H-10b). The gate-pass marker stores these digests instead of being
514
519
  an empty `touch`d file, so a recorded pass admits only the exact sensitive
515
- lines it reviewed — not whatever lands in the next 30 minutes. Trailing
516
- whitespace is stripped so CRLF translation between worktree and index
517
- never breaks the match."""
518
- return hashlib.sha256(line.rstrip().encode("utf-8", "replace")).hexdigest()
520
+ lines it reviewed. A pass has no time window, so the digest also binds the
521
+ repository-relative `path` the line was reviewed in: the identical line in
522
+ a different file is a different digest and needs its own review. Without
523
+ a path (an unattributable diff section) both the recording and the
524
+ checking side fall back to the line alone. Trailing whitespace is
525
+ stripped so CRLF translation between worktree and index never breaks the
526
+ match."""
527
+ text = line.rstrip()
528
+ if path is None:
529
+ return hashlib.sha256(text.encode("utf-8", "replace")).hexdigest()
530
+ return "path-v1:" + hashlib.sha256(json.dumps(
531
+ [path, text], separators=(",", ":"), ensure_ascii=False
532
+ ).encode("utf-8", "replace")).hexdigest()
519
533
 
520
534
 
521
535
  def content_digest(text):
@@ -64,6 +64,7 @@ def claude_main() -> int:
64
64
  _artifactauthoritylib.observe_verifier_hook(root, payload, host="claude")
65
65
  elif (
66
66
  (event in {"PreToolUse", "PostToolUse"} and tool == "Agent")
67
+ or (event == "PostToolUse" and tool == _artifactauthoritylib.CLAUDE_HANDBACK_TOOL)
67
68
  or (event == "PreToolUse" and tool == "SendMessage")
68
69
  or event in {"SubagentStart", "SubagentStop"}
69
70
  ):
@@ -11,6 +11,7 @@ import sys
11
11
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
12
12
  import _artifactauthoritylib # noqa: E402
13
13
  import _artifactlib # noqa: E402
14
+ import _artifactpromptlib # noqa: E402
14
15
  import hostapi # noqa: E402
15
16
 
16
17
 
@@ -38,6 +39,10 @@ def main(argv=None) -> int:
38
39
  choices=("verification", "spec_review", "quality_review"),
39
40
  )
40
41
  arm.add_argument("--workspace", action="append", default=[], metavar="LABEL=PATH")
42
+ arm.add_argument("--completion-receipt", action="append", metavar="RECEIPT_REF",
43
+ help="exact published verification receipt selected for completion review; repeat per task")
44
+ arm.add_argument("--supporting-file", action="append", metavar="ABSOLUTE_PATH",
45
+ help="explicit bounded regular file retained as completion review data")
41
46
  arm.add_argument(
42
47
  "--host", choices=sorted(_artifactauthoritylib.HOSTS), default=None,
43
48
  help="observing host; defaults to the host this package was built for",
@@ -50,6 +55,15 @@ def main(argv=None) -> int:
50
55
  "--codex-review-profile", choices=("native-v1",), default=None,
51
56
  help="select only after qualifying the actual registered native V1 UUID interface",
52
57
  )
58
+ context_arm = sub.add_parser("arm-context")
59
+ context_arm.add_argument("--root", required=True)
60
+ context_arm.add_argument("--request-file", required=True)
61
+ context_cancel = sub.add_parser("cancel-context")
62
+ context_cancel.add_argument("--root", required=True)
63
+ context_cancel.add_argument("--document-id", required=True)
64
+ context_apply = sub.add_parser("apply-context")
65
+ context_apply.add_argument("--root", required=True)
66
+ context_apply.add_argument("--receipt", required=True)
53
67
  verify = sub.add_parser("verify")
54
68
  verify.add_argument("--root", required=True)
55
69
  verify.add_argument("--request-id", required=True)
@@ -62,6 +76,9 @@ def main(argv=None) -> int:
62
76
  recover.add_argument("--disposition", required=True, choices=("failed", "abandoned"))
63
77
  args = parser.parse_args(argv)
64
78
  root = Path(args.root).resolve(strict=True)
79
+ if args.command == "cancel-context":
80
+ print(json.dumps(_artifactauthoritylib.cancel_context_preview(root, args.document_id), sort_keys=True))
81
+ return 0
65
82
  client = _artifactlib.ArtifactClient(
66
83
  root, _artifactlib.helper_installation(__file__)
67
84
  )
@@ -83,6 +100,7 @@ def main(argv=None) -> int:
83
100
  workspace_roots=workspaces or None, host=host,
84
101
  reviewer_model=args.reviewer_model,
85
102
  codex_review_profile=args.codex_review_profile,
103
+ completion_receipts=args.completion_receipt, supporting_files=args.supporting_file,
86
104
  )
87
105
  if host == "claude" and args.activity == "verification":
88
106
  # The Claude verifier hook pins this exact shipped script and
@@ -91,6 +109,11 @@ def main(argv=None) -> int:
91
109
  result["verify_command"] = (
92
110
  f'python "{script}" verify --root "{root}" --request-id {result["request_id"]}'
93
111
  )
112
+ elif args.command == "arm-context":
113
+ preview = _artifactlib.read_context_preview_request(root, args.request_file)
114
+ result = _artifactauthoritylib.arm_context_preview(root, client, preview)
115
+ elif args.command == "apply-context":
116
+ result = client.call("context-apply", {"receipt": args.receipt})
94
117
  elif args.command == "verify":
95
118
  result = _artifactauthoritylib.run_verification(
96
119
  root, client, args.request_id,
@@ -108,6 +131,7 @@ def main(argv=None) -> int:
108
131
  if __name__ == "__main__":
109
132
  try:
110
133
  raise SystemExit(main())
111
- except (_artifactauthoritylib.AuthorityError, _artifactlib.ArtifactError) as exc:
134
+ except (_artifactauthoritylib.AuthorityError, _artifactlib.ArtifactError,
135
+ _artifactpromptlib.PromptRouteError) as exc:
112
136
  sys.stderr.write(str(exc) + "\n")
113
137
  raise SystemExit(1)
@@ -0,0 +1,85 @@
1
+ # codeArbiter — private bounded debug handoff validation entry.
2
+ # Read only stdin and the installed validator; emit closed, redacted diagnostics.
3
+ # Bytecode suppression precedes the local import and no project state is touched.
4
+ #
5
+ # main(argv) -> int: validate one bounded packet and write one result envelope.
6
+ import sys
7
+
8
+ sys.dont_write_bytecode = True
9
+
10
+ import json
11
+ import re
12
+
13
+ import _debughandofflib as handoff
14
+
15
+
16
+ PROTOCOL = "codearbiter.debug-validation/1.0.0"
17
+ DIRECT_CODES = frozenset({
18
+ "INPUT_LIMIT", "DEPTH_LIMIT", "INVALID_UTF8", "INVALID_JSON",
19
+ "DUPLICATE_KEY", "UNSUPPORTED_PROTOCOL", "REQUIRED_FIELD",
20
+ "UNKNOWN_FIELD", "TYPE", "VALUE",
21
+ })
22
+ REFERENCE_CODES = frozenset({"DUPLICATE_ID", "DANGLING_REFERENCE"})
23
+ PATH = re.compile(r"\$(?:\.[A-Za-z_]+|\[[0-9]+\])*\Z", re.ASCII)
24
+
25
+
26
+ def _result(valid, errors, truncated=False):
27
+ return {"protocol": PROTOCOL, "valid": valid,
28
+ "errors": errors, "truncated": truncated}
29
+
30
+
31
+ def _diagnostic(code, path="$"):
32
+ if code in DIRECT_CODES or code in ("USAGE", "INPUT_IO", "INTERNAL"):
33
+ public_code = code
34
+ elif code in REFERENCE_CODES:
35
+ public_code = "REFERENCE"
36
+ else:
37
+ public_code = "SEMANTIC"
38
+ if type(path) is not str or len(path) > 256 or not PATH.fullmatch(path):
39
+ return {"code": "INTERNAL", "path": "$"}
40
+ return {"code": public_code, "path": path}
41
+
42
+
43
+ def _write(result):
44
+ raw = (json.dumps(result, ensure_ascii=True, separators=(",", ":"))
45
+ + "\n").encode("utf-8")
46
+ if len(raw) > 8192:
47
+ result = _result(False, [{"code": "INTERNAL", "path": "$"}])
48
+ raw = (json.dumps(result, separators=(",", ":")) + "\n").encode("utf-8")
49
+ exit_code = 3
50
+ else:
51
+ exit_code = 0 if result["valid"] else 2
52
+ if result["errors"] and result["errors"][0]["code"] in ("INPUT_IO", "INTERNAL"):
53
+ exit_code = 3
54
+ try:
55
+ sys.stdout.buffer.write(raw)
56
+ sys.stdout.buffer.flush()
57
+ except OSError:
58
+ return 3
59
+ return exit_code
60
+
61
+
62
+ def main(argv=None):
63
+ if argv is None:
64
+ argv = sys.argv[1:]
65
+ if argv != ["validate"]:
66
+ return _write(_result(False, [_diagnostic("USAGE")]))
67
+ try:
68
+ raw = sys.stdin.buffer.read(65537)
69
+ except OSError:
70
+ return _write(_result(False, [_diagnostic("INPUT_IO")]))
71
+ except Exception:
72
+ return _write(_result(False, [_diagnostic("INTERNAL")]))
73
+ try:
74
+ packet, error = handoff.decode_packet(raw)
75
+ if error is not None:
76
+ return _write(_result(False, [_diagnostic(error)]))
77
+ found, truncated = handoff.validate_semantics_bounded(packet, ceiling=16)
78
+ errors = [_diagnostic(code, path) for code, path in found]
79
+ return _write(_result(not found, errors, truncated))
80
+ except Exception:
81
+ return _write(_result(False, [_diagnostic("INTERNAL")]))
82
+
83
+
84
+ if __name__ == "__main__":
85
+ raise SystemExit(main())
@@ -23,7 +23,9 @@ from _gitexec import git_executable, root_bound_git_env # noqa: E402
23
23
  import hostapi # noqa: E402 — host seam (ADR-0011): plugin-root resolution
24
24
  import _entrylib # noqa: E402 — shared run() dispatch (jscpd dedup)
25
25
  import _githooks # noqa: E402 — #556: git-hook drop-in registry freshness
26
- from _hooklib import frontmatter_enabled, get_host, set_host, utf8_stdio # noqa: E402
26
+ from _hooklib import ( # noqa: E402
27
+ frontmatter_enabled, get_host, initialized_body_text, set_host, utf8_stdio,
28
+ )
27
29
 
28
30
  HOOK_SCRIPTS = ("session-start.py", "pre-bash.py", "pre-write.py",
29
31
  "pre-edit.py", "post-write-edit.py", "prune-transcript.py",
@@ -298,7 +300,7 @@ def check_repo():
298
300
  if enabled:
299
301
  with open(ctx, encoding="utf-8", errors="replace") as f:
300
302
  body = f.read()
301
- if "<!--INITIALIZED-->" in body:
303
+ if initialized_body_text(body):
302
304
  ok("project is initialized (<!--INITIALIZED--> marker present)")
303
305
  else:
304
306
  warn(f"no <!--INITIALIZED--> marker — startup will route to "
@@ -11,7 +11,7 @@
11
11
  #
12
12
  # It mirrors pre-bash.py's commit/push gates and reuses the SAME detection
13
13
  # primitives from _hooklib (CRYPTO_RE / SECRET_RE / line_digest / content_digest
14
- # / is_migration_path / marker_fresh / sensitive_scan_added_lines), so the two
14
+ # / is_migration_path / sensitive_scan_added_lines), so the two
15
15
  # enforcement points can never drift on what counts as sensitive or as a
16
16
  # migration, on how a gate-pass marker binds to the lines it approved, or on
17
17
  # which path (gate-events.log, #279) is exempt from the sensitive-line scan.
@@ -30,9 +30,9 @@ import hostapi # noqa: E402 — host seam (ADR-0011)
30
30
  import _entrylib # noqa: E402 — shared run() dispatch (jscpd dedup)
31
31
  from _gitexec import git_executable # noqa: E402
32
32
  from _hooklib import ( # noqa: E402
33
- CRYPTO_RE, MARKER_FRESHNESS_MINUTES, SECRET_RE, SECURITY_DIFF_GIT_ARGS,
33
+ CRYPTO_RE, SECRET_RE, SECURITY_DIFF_GIT_ARGS,
34
34
  arbiter_active, content_digest, is_migration_path, line_digest,
35
- marker_fresh, security_scan_diff, set_host, utf8_stdio,
35
+ security_scan_diff, set_host, utf8_stdio,
36
36
  )
37
37
 
38
38
 
@@ -223,8 +223,9 @@ def pre_commit(root):
223
223
  block("H-01", f"Direct commit to {target} is prohibited (ORCHESTRATOR §3) — this is "
224
224
  f"the git-level backstop (#161). Create a feature branch.")
225
225
 
226
- # H-09b / H-10b: a commit introducing crypto/secret changes needs a fresh,
227
- # line-covering security-gate pass. Reuses the exact _hooklib primitives.
226
+ # H-09b / H-10b: a commit introducing crypto/secret changes needs a recorded,
227
+ # line-covering security-gate pass (bound by line digest, no time window).
228
+ # Reuses the exact _hooklib primitives.
228
229
  added = cached_added_lines(cwd)
229
230
  if added is None:
230
231
  block("H-09b", "the staged diff for the crypto/secret scan could not be read — "
@@ -237,7 +238,7 @@ def pre_commit(root):
237
238
  skill = "crypto-compliance" if touches_crypto else "secret-handling"
238
239
  marker_root = _marker_root(root)
239
240
  marker = os.path.join(marker_root, ".codearbiter", ".markers", "security-gate-passed")
240
- if not marker_fresh(marker, MARKER_FRESHNESS_MINUTES):
241
+ if not os.path.isfile(marker):
241
242
  block(tag, f"This commit introduces {kind} changes, but no security-gate pass is "
242
243
  f"recorded (#161 git backstop). Run the {skill} gate, then commit.")
243
244
  approved = _marker_set(marker_root, "security-gate-passed")
@@ -313,7 +313,7 @@ class Host:
313
313
 
314
314
  name = "claude"
315
315
  adapter_name = "ca"
316
- adapter_version = "2.23.2"
316
+ adapter_version = "2.24.0"
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,