@arbiterforge/ca-pi 0.10.13 → 0.14.6

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 (57) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -1
  3. package/plugins/ca-pi/CHANGELOG.md +159 -0
  4. package/plugins/ca-pi/SKILLS.md +1 -1
  5. package/plugins/ca-pi/SPRINT.md +40 -10
  6. package/plugins/ca-pi/agents/backend-author.md +1 -1
  7. package/plugins/ca-pi/agents/frontend-author.md +1 -1
  8. package/plugins/ca-pi/generated/command-catalog.json +1 -1
  9. package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-amd64 +0 -0
  10. package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-arm64 +0 -0
  11. package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-amd64 +0 -0
  12. package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-arm64 +0 -0
  13. package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-amd64.exe +0 -0
  14. package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-arm64.exe +0 -0
  15. package/plugins/ca-pi/helpers/artifacts/release.json +38 -0
  16. package/plugins/ca-pi/hooks/_approvallib.py +355 -0
  17. package/plugins/ca-pi/hooks/_artifactlib.py +855 -0
  18. package/plugins/ca-pi/hooks/_cleanuplib.py +1553 -0
  19. package/plugins/ca-pi/hooks/_hooklib.py +37 -13
  20. package/plugins/ca-pi/hooks/_host.py +1 -1
  21. package/plugins/ca-pi/hooks/_intentlib.py +31 -0
  22. package/plugins/ca-pi/hooks/_planfilelib.py +34 -1
  23. package/plugins/ca-pi/hooks/_protectedstatelib.py +11 -1
  24. package/plugins/ca-pi/hooks/_readinjectlib.py +120 -0
  25. package/plugins/ca-pi/hooks/_releaselib.py +933 -85
  26. package/plugins/ca-pi/hooks/_standuplib.py +38 -10
  27. package/plugins/ca-pi/hooks/doctor.py +103 -0
  28. package/plugins/ca-pi/hooks/hostapi.py +1 -1
  29. package/plugins/ca-pi/hooks/pi-bridge.py +5 -1
  30. package/plugins/ca-pi/hooks/prompt-submit.py +33 -10
  31. package/plugins/ca-pi/hooks/releasehash.py +46 -32
  32. package/plugins/ca-pi/includes/artifacts.md +166 -0
  33. package/plugins/ca-pi/includes/author-tdd-workflow.md +2 -1
  34. package/plugins/ca-pi/includes/farm.md +27 -4
  35. package/plugins/ca-pi/includes/routing-table.md +2 -2
  36. package/plugins/ca-pi/includes/verification-boundary.md +27 -0
  37. package/plugins/ca-pi/package.json +18 -0
  38. package/plugins/ca-pi/routines/INDEX.md +4 -4
  39. package/plugins/ca-pi/routines/brainstorming/SKILL.md +23 -4
  40. package/plugins/ca-pi/routines/commit-gate/SKILL.md +30 -5
  41. package/plugins/ca-pi/routines/executing-plans/SKILL.md +52 -7
  42. package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +25 -3
  43. package/plugins/ca-pi/routines/refactor/SKILL.md +3 -2
  44. package/plugins/ca-pi/routines/release/SKILL.md +150 -44
  45. package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +53 -9
  46. package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +20 -3
  47. package/plugins/ca-pi/routines/tdd/SKILL.md +27 -5
  48. package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +11 -2
  49. package/plugins/ca-pi/routines/writing-plans/SKILL.md +39 -10
  50. package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +34 -13
  51. package/plugins/ca-pi/skills/ca-chore/SKILL.md +7 -3
  52. package/plugins/ca-pi/skills/ca-commit/SKILL.md +3 -2
  53. package/plugins/ca-pi/skills/ca-doctor/SKILL.md +14 -0
  54. package/plugins/ca-pi/skills/ca-feature/SKILL.md +42 -8
  55. package/plugins/ca-pi/skills/ca-refactor/SKILL.md +4 -2
  56. package/plugins/ca-pi/skills/ca-release/SKILL.md +7 -6
  57. package/plugins/ca-pi/skills/ca-standup/SKILL.md +36 -8
@@ -92,7 +92,8 @@
92
92
  # restating the literal.
93
93
  # marker_fresh(path, minutes) -> bool True iff marker file exists and is recent
94
94
  # write_text_atomic(path, text) -> None crash-safe write (temp + os.replace)
95
- # acquire_lock(path) -> handle|None OS-owned cross-process file lock (#271 C-2);
95
+ # acquire_lock(path, wait_seconds=None) -> handle|None
96
+ # OS-owned cross-process file lock (#271 C-2);
96
97
  # non-blocking + bounded LOCK_WAIT retry spin,
97
98
  # fail-soft None on contention/timeout/OSError
98
99
  # release_lock(handle) -> None release + close; None handle is a no-op
@@ -222,6 +223,7 @@ from _sensitivelib import ( # noqa: F401
222
223
  # POSIX flock and Windows byte-range locks are process-scoped, so sibling
223
224
  # threads can otherwise burn the shared wait budget contending with each other.
224
225
  _GATE_EVENTS_PROCESS_LOCK = threading.Lock()
226
+ GATE_EVENT_LOCK_WAIT_SECONDS = 5.0
225
227
  _WINDOWS_LOCK_TIMEOUT_SECONDS = 5.0
226
228
  _WINDOWS_LOCK_RETRY_SECONDS = 0.01
227
229
  _WINDOWS_CRT_EDEADLK = 36
@@ -345,7 +347,7 @@ def write_text_atomic(path, text, newline=None):
345
347
  raise
346
348
 
347
349
 
348
- def acquire_lock(path):
350
+ def acquire_lock(path, wait_seconds=None):
349
351
  """Acquire an OS-owned cross-process file lock keyed on `path`; process
350
352
  death releases it automatically (#271 C-2 — hoisted from the
351
353
  statusline-ledger-only `_ledgerlib._acquire_lock`, now shared with
@@ -355,7 +357,8 @@ def acquire_lock(path):
355
357
  with one byte so the OS byte-range lock has a byte to lock (an empty file
356
358
  has no range to range-lock). Non-blocking (`msvcrt.locking(..., LK_NBLCK,
357
359
  1)` on Windows, `fcntl.flock(..., LOCK_EX | LOCK_NB)` elsewhere) with a
358
- bounded `LOCK_WAIT`-second retry spin; any `OSError` opening the lock file,
360
+ bounded `LOCK_WAIT`-second retry spin (or an explicit non-negative
361
+ `wait_seconds` budget); any `OSError` opening the lock file,
359
362
  or exhausting the deadline still contended, is FAIL-SOFT: returns `None`
360
363
  rather than raising or blocking indefinitely. Callers decide what
361
364
  "fail-soft" means for them — `_ledgerlib.ledger_update`/`persist_sess_start`
@@ -373,7 +376,8 @@ def acquire_lock(path):
373
376
  handle.flush()
374
377
  except OSError:
375
378
  return None
376
- deadline = time.monotonic() + LOCK_WAIT
379
+ wait_seconds = LOCK_WAIT if wait_seconds is None else max(0.0, wait_seconds)
380
+ deadline = time.monotonic() + wait_seconds
377
381
  while True:
378
382
  try:
379
383
  handle.seek(0)
@@ -493,9 +497,22 @@ def marker_fresh(path, minutes):
493
497
  def _log_gate_event(kind, tag, msg):
494
498
  """Best-effort durable append of one gate decision to
495
499
  .codearbiter/gate-events.log (observability-001, issue #186) — the durable
496
- sink block()/remind()/warn() funnel every BLOCK/REMIND/WARN through, so a
497
- decision is no longer visible ONLY in the ephemeral per-turn stderr
498
- transcript.
500
+ sink block() funnels every BLOCK through, so a decision is no longer
501
+ visible ONLY in the ephemeral per-turn stderr transcript.
502
+
503
+ remind()/warn() do NOT call this (2026-09-17 scope-down): measured on a
504
+ live repo, REMIND/WARN accounted for 76% of the file's lines and 60% were
505
+ one repeated boilerplate reminder carrying no information past its first
506
+ occurrence, while the file's git-tracked + H-05 append-only status gave
507
+ that noise the full cost of a protected audit artifact — two branches
508
+ that both merely touched it, independently, produced a real merge
509
+ conflict; _releaselib.py's clean-tree probe carries its own comment about
510
+ working around a mid-window REMIND/WARN append poisoning a release gate.
511
+ BLOCK is the one kind with real forensic value (evidence for diagnosing a
512
+ hook that misfired); REMIND/WARN remain full-strength on stderr, just not
513
+ persisted. Not ADR-governed — issue #186 shipped this as an implementation
514
+ detail of `_hooklib`, not an architectural decision, so scoping it back
515
+ down is the same kind of call.
499
516
 
500
517
  One line per event: `[ISO-8601Z] KIND [tag] host=<host> hook=<script> | msg`.
501
518
  `tag` may be None (warn() carries no tag) — the bracket is simply omitted
@@ -538,7 +555,10 @@ def _log_gate_event(kind, tag, msg):
538
555
  try:
539
556
  _GATE_EVENTS_PROCESS_LOCK.acquire()
540
557
  process_lock_acquired = True
541
- audit_lock = acquire_lock(audit_lock_key(root, log_path))
558
+ audit_lock = acquire_lock(
559
+ audit_lock_key(root, log_path),
560
+ wait_seconds=GATE_EVENT_LOCK_WAIT_SECONDS,
561
+ )
542
562
  except Exception:
543
563
  if process_lock_acquired:
544
564
  _GATE_EVENTS_PROCESS_LOCK.release()
@@ -551,7 +571,10 @@ def _log_gate_event(kind, tag, msg):
551
571
  os_lock_acquired = False
552
572
  try:
553
573
  fd = os.open(log_path, flags, 0o600)
554
- if os.name == "nt":
574
+ # The trusted sidecar already serializes current writers. Keep the
575
+ # legacy descriptor lock for compatibility once the log has data,
576
+ # but Windows cannot reliably lock byte zero of a new empty file.
577
+ if os.name == "nt" and os.fstat(fd).st_size:
555
578
  import msvcrt
556
579
  os.lseek(fd, 0, os.SEEK_SET)
557
580
  lock_mode = getattr(msvcrt, "LK_NBLCK", msvcrt.LK_LOCK)
@@ -596,14 +619,15 @@ def block(tag, msg):
596
619
 
597
620
 
598
621
  def remind(tag, msg):
599
- """Non-blocking nudge to stderr."""
600
- _log_gate_event("REMIND", tag, msg)
622
+ """Non-blocking nudge to stderr. Deliberately NOT persisted to
623
+ gate-events.log (see _log_gate_event's 2026-09-17 scope-down note)."""
601
624
  print(f"REMINDER [{tag}]: {msg}", file=sys.stderr)
602
625
 
603
626
 
604
627
  def warn(msg):
605
- """Loud degradation/diagnostic breadcrumb — never silent."""
606
- _log_gate_event("WARN", None, msg)
628
+ """Loud degradation/diagnostic breadcrumb — never silent. Deliberately
629
+ NOT persisted to gate-events.log (see _log_gate_event's 2026-09-17
630
+ scope-down note)."""
607
631
  print(f"codeArbiter hook: {msg}", file=sys.stderr)
608
632
 
609
633
 
@@ -11,7 +11,7 @@ import hostapi # noqa: E402
11
11
  class PiHost(hostapi.Host):
12
12
  name = "pi"
13
13
  adapter_name = "@arbiterforge/ca-pi"
14
- adapter_version = "0.10.13"
14
+ adapter_version = "0.14.6"
15
15
  update_target = "ca-pi"
16
16
  update_tag_prefix = "ca-pi-v"
17
17
  update_command = "pi update npm:@arbiterforge/ca-pi"
@@ -168,6 +168,8 @@ def uncovered_intent(spec_text, issue_body=None):
168
168
  docstring for what it cannot catch."""
169
169
  if not isinstance(spec_text, str):
170
170
  spec_text = ""
171
+ if spec_text.lstrip().lower().startswith(("<!doctype html", "<html")):
172
+ return ["[HTML-REQUIRES-VALIDATION] Use the path-based artifact validator; HTML is not empty Markdown."]
171
173
  criteria_tokens = set()
172
174
  for criterion in _criteria_texts(spec_text):
173
175
  criteria_tokens.update(_tokens(criterion))
@@ -232,6 +234,35 @@ def main(argv):
232
234
  sys.stderr.write(f"_intentlib.py: cannot read {issue_body_path}: {exc}\n")
233
235
  return 2
234
236
 
237
+ if positional[0].lower().endswith(".html"):
238
+ try:
239
+ from _artifactlib import resolve_spec_file, helper_installation
240
+ client, entry = resolve_spec_file(positional[0], helper_installation(__file__))
241
+ result = client.call("validate", {"artifact_id": entry["artifact_id"], "gate": "ready"}, permit_invalid=True)
242
+ findings = ["[INVALID-HTML-SPEC] " + item["code"] + ": " + item["message"]
243
+ for item in result.get("diagnostics", [])]
244
+ # Explicit source->criterion mappings replace fuzzy scope matching.
245
+ # The legacy issue-checkbox heuristic is retained ONLY for external
246
+ # issue text until issue-source records receive their own schema.
247
+ if issue_body:
248
+ criteria_tokens = set()
249
+ for record in client.outline(entry["artifact_id"]):
250
+ if record["kind"] == "criteria" and not record["retired"]:
251
+ data = client.call("read", {"artifact_id": entry["artifact_id"], "symbol": record["id"], "mode": "exact"})
252
+ criteria_tokens.update(_tokens(data["record"].get("statement", "")))
253
+ for box in _checkboxes(issue_body):
254
+ if not _cited(box, criteria_tokens):
255
+ findings.append("[UNCOVERED-CHECKBOX] " + box)
256
+ fresh = client.call("identity", {"artifact_id": entry["artifact_id"]})
257
+ if fresh["model_sha256"] != result["model_sha256"]:
258
+ raise RuntimeError("HTML spec changed during intent verification; retry a fresh read")
259
+ for finding in findings:
260
+ print(finding)
261
+ return 1 if findings or not result["valid"] else 0
262
+ except Exception as error:
263
+ sys.stderr.write("_intentlib.py: HTML validation failed: " + str(error)[:512] + "\n")
264
+ return 2
265
+
235
266
  findings = uncovered_intent(spec_text, issue_body)
236
267
  for finding in findings:
237
268
  print(finding)
@@ -70,6 +70,30 @@ def _request_path(root, slug, kind):
70
70
  return os.path.join(root, ".codearbiter", KINDS[kind], f"{slug}.md")
71
71
 
72
72
 
73
+ def _require_legacy_pair(root, slug):
74
+ """Refuse Pi's Markdown-only plan surface unless one exact legacy pair owns the slug."""
75
+ _request_path(root, slug, "spec")
76
+ root_info = os.lstat(root)
77
+ if not _directory(root_info):
78
+ raise PlanFileError("root")
79
+ state_path, _ = _exact_absolute_child(root, ".codearbiter")
80
+ for kind in KINDS:
81
+ parent_path, _ = _exact_absolute_child(state_path, KINDS[kind])
82
+ html_leaf = f"{slug}.html"
83
+ if _aliases(parent_path, html_leaf):
84
+ raise PlanFileError("html_authority")
85
+ markdown_leaf = f"{slug}.md"
86
+ aliases = _aliases(parent_path, markdown_leaf)
87
+ if not aliases:
88
+ raise PlanFileError("legacy_pair_required")
89
+ if aliases != [markdown_leaf]:
90
+ raise PlanFileError("path_alias")
91
+ path = os.path.join(parent_path, markdown_leaf)
92
+ info = os.lstat(path)
93
+ if not _regular_single(info) or os.path.realpath(path) != os.path.abspath(path):
94
+ raise PlanFileError("path_type")
95
+
96
+
73
97
  def _windows_handle_identity(handle):
74
98
  from ctypes import wintypes
75
99
 
@@ -580,7 +604,8 @@ def _committed_result(path, data, content, fault=None, initial_diagnostic=None):
580
604
  return result
581
605
 
582
606
 
583
- def plan_file_operation(root, request, *, lock_root=None, fault=None):
607
+ def plan_file_operation(root, request, *, lock_root=None, fault=None,
608
+ require_legacy_pair=False):
584
609
  """Run a bounded CAS. The lock serializes cooperating codeArbiter Pi writers only."""
585
610
  try:
586
611
  if not isinstance(request, dict) or set(request) - {"slug", "kind", "action", "expectedHash", "content"}:
@@ -593,7 +618,13 @@ def plan_file_operation(root, request, *, lock_root=None, fault=None):
593
618
  if action not in ("read", "replace"):
594
619
  raise PlanFileError("request")
595
620
  target = _request_path(root, request.get("slug"), request.get("kind"))
621
+ if require_legacy_pair is not False:
622
+ if require_legacy_pair is not True:
623
+ raise PlanFileError("request")
624
+ _require_legacy_pair(root, request["slug"])
596
625
  with _lock_for(target, lock_root), _held_path(root, request["slug"], request["kind"], fault) as path:
626
+ if require_legacy_pair:
627
+ _require_legacy_pair(root, request["slug"])
597
628
  chosen = _read_target(path, fault)
598
629
  if action == "read":
599
630
  if chosen is None:
@@ -625,6 +656,8 @@ def plan_file_operation(root, request, *, lock_root=None, fault=None):
625
656
  for phase in ("before_publish", "publish"):
626
657
  if fault:
627
658
  fault(phase, path)
659
+ if require_legacy_pair:
660
+ _require_legacy_pair(root, request["slug"])
628
661
  try:
629
662
  latest = _read_target(path)
630
663
  except Exception:
@@ -381,6 +381,16 @@ def marker_name_for(rel_path):
381
381
  (non-empty) rel_path can ever produce, since a real segment always
382
382
  contributes a non-empty stem.
383
383
 
384
+ Every segment is case-folded (`.lower()`) before encoding (#624), matching
385
+ `_canon`/`lookup_policy`'s own case-insensitive registry comparison: a
386
+ marker name computed for a differently-cased Write/Edit target must equal
387
+ the one minted under the registry's canonical casing, or a legitimate
388
+ authoring write is wrongly blocked despite a fresh, correctly-minted
389
+ marker. This widens what two paths can collide into one marker (a
390
+ same-name directory differing only by case now shares a marker with its
391
+ canonical sibling) - the same accepted "widens admission, never narrows
392
+ protection" tradeoff `lookup_policy`'s own case fold already makes.
393
+
384
394
  Residual (accepted): the escaping above closes hyphen/segment-boundary
385
395
  ambiguity but is not a fully bijective encoding against adversarially
386
396
  crafted underscore runs (e.g. a directory literally named "a_" holding
@@ -388,7 +398,7 @@ def marker_name_for(rel_path):
388
398
  small, curated, human-authored set (spec B1/B2), not attacker-chosen
389
399
  directory names, so this is judged out of proportion to close fully
390
400
  here."""
391
- parts = [p for p in norm_path(rel_path).split("/") if p not in ("", ".", "..")]
401
+ parts = [p for p in norm_path(rel_path).lower().split("/") if p not in ("", ".", "..")]
392
402
  if not parts:
393
403
  return "-authoring"
394
404
  if parts[0] == ".codearbiter" and len(parts) > 1:
@@ -1084,3 +1084,123 @@ def compute_injection(root, session_id, rel, runner=None):
1084
1084
  return ""
1085
1085
  except Exception: # noqa: BLE001
1086
1086
  return ""
1087
+
1088
+ # Typed artifact pilot bridge. Keep legacy matching unchanged; never cache HTML
1089
+ # authority by mtime. Importing this module performs no subprocess or file I/O.
1090
+ _artifact_legacy_build_index = build_index
1091
+ _artifact_legacy_spec_pointers = spec_pointers
1092
+
1093
+
1094
+ def build_index(root):
1095
+ result = _artifact_legacy_build_index(root)
1096
+ if not isinstance(result, dict):
1097
+ return result
1098
+ result = dict(result)
1099
+ try:
1100
+ from _artifactlib import ArtifactClient, helper_installation, has_html
1101
+ if not has_html(root):
1102
+ return result
1103
+ # Preserve full engine identities in the advisory snapshot. Pointer
1104
+ # matching uses only spec/path/globs, while dedup below hashes the same
1105
+ # entries including revision/model/normative identity. Do not reread the
1106
+ # artifact after this validated index stream.
1107
+ extra = []
1108
+ client = ArtifactClient(root, helper_installation(__file__))
1109
+ for entry in client.index("spec"):
1110
+ authority = entry["authority"]
1111
+ if authority["authority_verified"] and entry.get("governs"):
1112
+ extra.append({
1113
+ "spec": entry["artifact_id"],
1114
+ "path": entry["path"],
1115
+ "globs": entry["governs"],
1116
+ "artifact_html": True,
1117
+ "revision": entry.get("revision"),
1118
+ "model_sha256": entry.get("model_sha256"),
1119
+ "normative_sha256": entry.get("normative_sha256"),
1120
+ })
1121
+ except Exception as error:
1122
+ # Advisory read injection remains fail-open. This warning is NOT an
1123
+ # approved-spec pointer and MUST NOT authorize a planning/execution gate.
1124
+ code = getattr(error, "code", "CAPABILITY_MISSING")
1125
+ code = str(code)[:80]
1126
+ capability_codes = {
1127
+ "CAPABILITY_MISSING", "INVALID_INSTALLATION", "PACKAGE_INTEGRITY",
1128
+ "UNSUPPORTED_PLATFORM", "UNSUPPORTED_VERSION", "UNVERIFIED_PLATFORM",
1129
+ "INVALID_RESPONSE", "RESPONSE_TOO_LARGE", "SUBPROCESS_FAILED", "TIMEOUT",
1130
+ }
1131
+ extra = [{
1132
+ "artifact_diagnostic": code,
1133
+ "artifact_diagnostic_kind": (
1134
+ "capability" if code in capability_codes else "invalid-artifact"
1135
+ ),
1136
+ "artifact_html": True,
1137
+ }]
1138
+ result["spec"] = [item for item in result.get("spec", [])
1139
+ if not isinstance(item, dict) or not item.get("artifact_html")] + extra
1140
+ return result
1141
+
1142
+
1143
+ def spec_pointers(rel, index):
1144
+ if not isinstance(index, list):
1145
+ return _artifact_legacy_spec_pointers(rel, index)
1146
+ warnings = [item for item in index if isinstance(item, dict) and item.get("artifact_diagnostic")]
1147
+ normal = [item for item in index if not isinstance(item, dict) or not item.get("artifact_diagnostic")]
1148
+ result = _artifact_legacy_spec_pointers(rel, normal)
1149
+ if warnings:
1150
+ warning = warnings[0]
1151
+ code = warning["artifact_diagnostic"]
1152
+ if warning.get("artifact_diagnostic_kind") == "capability":
1153
+ text = (
1154
+ "HTML artifact capability unavailable (" + code + "). Repair or reinstall "
1155
+ "the pinned artifact payload. Source inspection and unrelated legacy Markdown "
1156
+ "may continue; HTML planning/execution must stop until resolved."
1157
+ )
1158
+ else:
1159
+ text = (
1160
+ "HTML specification is invalid or unreadable (" + code + "). Inspect the "
1161
+ "engine diagnostic and repair-preview before any repair. Source inspection may "
1162
+ "continue; HTML planning/execution must stop until resolved."
1163
+ )
1164
+ result.append({"tier": "artifact-diagnostic", "text": text})
1165
+ return result
1166
+
1167
+ _artifact_legacy_compute_injection = compute_injection
1168
+
1169
+
1170
+ def compute_injection(root, session_id, rel, runner=None):
1171
+ # Bind the injected pointers and dedup epoch to ONE validated HTML snapshot.
1172
+ # A second artifact read here permits B(epoch) -> A(content) and records A
1173
+ # under B's marker, suppressing a later stable B read. HTML therefore builds
1174
+ # once before dedup; the same entries drive both context and epoch. This is
1175
+ # advisory identity only, never approval or execution authority.
1176
+ if not isinstance(rel, str) or rel.replace("\\", "/").split("/", 1)[0] == ".codearbiter":
1177
+ return _artifact_legacy_compute_injection(root, session_id, rel, runner)
1178
+ try:
1179
+ from _artifactlib import has_html
1180
+ if not has_html(root):
1181
+ return _artifact_legacy_compute_injection(root, session_id, rel, runner)
1182
+ index = build_index(root)
1183
+ if not isinstance(index, dict):
1184
+ return ""
1185
+ artifact_entries = [
1186
+ item for item in index.get("spec", [])
1187
+ if isinstance(item, dict) and item.get("artifact_html") is True
1188
+ ]
1189
+ epoch = hashlib.sha256(
1190
+ json.dumps(
1191
+ artifact_entries, sort_keys=True, separators=(",", ":")
1192
+ ).encode()
1193
+ ).hexdigest()
1194
+ epoch_session = str(session_id) + "|html:" + epoch
1195
+ if already_injected(root, epoch_session, rel):
1196
+ return ""
1197
+ pointers = governing_docs(rel, index, runner)
1198
+ if not pointers:
1199
+ return ""
1200
+ context = assemble_context(pointers, budget=150)
1201
+ if context:
1202
+ record_injection(root, epoch_session, rel)
1203
+ return context
1204
+ return ""
1205
+ except Exception: # noqa: BLE001 - advisory injection always fails open
1206
+ return ""