@arbiterforge/ca-pi 0.6.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.
- package/LICENSE +661 -0
- package/README.md +558 -0
- package/package.json +35 -0
- package/plugins/ca-pi/CHANGELOG.md +1030 -0
- package/plugins/ca-pi/COMMANDS.md +90 -0
- package/plugins/ca-pi/ORCHESTRATOR.md +159 -0
- package/plugins/ca-pi/SKILLS.md +47 -0
- package/plugins/ca-pi/SPRINT.md +142 -0
- package/plugins/ca-pi/agents/INDEX.md +31 -0
- package/plugins/ca-pi/agents/architecture-drift-reviewer.md +86 -0
- package/plugins/ca-pi/agents/auth-crypto-reviewer.md +60 -0
- package/plugins/ca-pi/agents/backend-author.md +60 -0
- package/plugins/ca-pi/agents/checkpoint-aggregator.md +111 -0
- package/plugins/ca-pi/agents/coverage-auditor.md +71 -0
- package/plugins/ca-pi/agents/decision-challenger.md +116 -0
- package/plugins/ca-pi/agents/dependency-reviewer.md +79 -0
- package/plugins/ca-pi/agents/design-quality-reviewer.md +80 -0
- package/plugins/ca-pi/agents/finding-triage.md +86 -0
- package/plugins/ca-pi/agents/frontend-author.md +64 -0
- package/plugins/ca-pi/agents/grader.md +173 -0
- package/plugins/ca-pi/agents/infra-author.md +64 -0
- package/plugins/ca-pi/agents/map-deps.md +35 -0
- package/plugins/ca-pi/agents/map-structure.md +37 -0
- package/plugins/ca-pi/agents/migration-reviewer.md +65 -0
- package/plugins/ca-pi/agents/scout.md +127 -0
- package/plugins/ca-pi/agents/security-reviewer.md +72 -0
- package/plugins/ca-pi/agents/tribunal-lens-reviewer.md +65 -0
- package/plugins/ca-pi/extensions/codearbiter-child.js +1885 -0
- package/plugins/ca-pi/extensions/codearbiter.js +9802 -0
- package/plugins/ca-pi/generated/command-catalog.json +197 -0
- package/plugins/ca-pi/generated/roles.json +213 -0
- package/plugins/ca-pi/helpers/windows-supervisor.js +205 -0
- package/plugins/ca-pi/hooks/_activationlib.py +196 -0
- package/plugins/ca-pi/hooks/_arbiterstatelib.py +208 -0
- package/plugins/ca-pi/hooks/_babysitlib.py +76 -0
- package/plugins/ca-pi/hooks/_bashguardlib.py +1667 -0
- package/plugins/ca-pi/hooks/_boxlib.py +131 -0
- package/plugins/ca-pi/hooks/_colorlib.py +304 -0
- package/plugins/ca-pi/hooks/_durabilitylib.py +186 -0
- package/plugins/ca-pi/hooks/_entrylib.py +41 -0
- package/plugins/ca-pi/hooks/_fmtlib.py +161 -0
- package/plugins/ca-pi/hooks/_gitexec.py +45 -0
- package/plugins/ca-pi/hooks/_githooks.py +920 -0
- package/plugins/ca-pi/hooks/_gitlib.py +110 -0
- package/plugins/ca-pi/hooks/_hooklib.py +595 -0
- package/plugins/ca-pi/hooks/_host.py +115 -0
- package/plugins/ca-pi/hooks/_intentlib.py +242 -0
- package/plugins/ca-pi/hooks/_ledgerlib.py +1035 -0
- package/plugins/ca-pi/hooks/_metricslib.py +709 -0
- package/plugins/ca-pi/hooks/_pathnorm.py +74 -0
- package/plugins/ca-pi/hooks/_planfilelib.py +664 -0
- package/plugins/ca-pi/hooks/_previewlib.py +193 -0
- package/plugins/ca-pi/hooks/_protectedlib.py +312 -0
- package/plugins/ca-pi/hooks/_protectedstatelib.py +411 -0
- package/plugins/ca-pi/hooks/_provenancelib.py +971 -0
- package/plugins/ca-pi/hooks/_prunelib.py +1398 -0
- package/plugins/ca-pi/hooks/_prunepolicy.py +235 -0
- package/plugins/ca-pi/hooks/_readinjectlib.py +1080 -0
- package/plugins/ca-pi/hooks/_releaselib.py +2657 -0
- package/plugins/ca-pi/hooks/_scopelib.py +262 -0
- package/plugins/ca-pi/hooks/_segmentslib.py +278 -0
- package/plugins/ca-pi/hooks/_sensitivelib.py +270 -0
- package/plugins/ca-pi/hooks/_sessionlib.py +78 -0
- package/plugins/ca-pi/hooks/_sloplib.py +244 -0
- package/plugins/ca-pi/hooks/_standuplib.py +214 -0
- package/plugins/ca-pi/hooks/_subagentslib.py +219 -0
- package/plugins/ca-pi/hooks/_taskboardlib.py +1088 -0
- package/plugins/ca-pi/hooks/_updatelib.py +278 -0
- package/plugins/ca-pi/hooks/babysit.py +47 -0
- package/plugins/ca-pi/hooks/boardsync.py +129 -0
- package/plugins/ca-pi/hooks/doctor.py +420 -0
- package/plugins/ca-pi/hooks/git-enforce.py +325 -0
- package/plugins/ca-pi/hooks/hostapi.py +460 -0
- package/plugins/ca-pi/hooks/init-codearbiter.py +225 -0
- package/plugins/ca-pi/hooks/metrics.py +62 -0
- package/plugins/ca-pi/hooks/migration-pass.py +129 -0
- package/plugins/ca-pi/hooks/pi-bridge.py +543 -0
- package/plugins/ca-pi/hooks/post-write-edit.py +231 -0
- package/plugins/ca-pi/hooks/pre-bash.py +90 -0
- package/plugins/ca-pi/hooks/pre-edit.py +284 -0
- package/plugins/ca-pi/hooks/pre-read.py +81 -0
- package/plugins/ca-pi/hooks/pre-write.py +217 -0
- package/plugins/ca-pi/hooks/preview.py +69 -0
- package/plugins/ca-pi/hooks/prune-transcript.py +232 -0
- package/plugins/ca-pi/hooks/releasehash.py +216 -0
- package/plugins/ca-pi/hooks/security-pass.py +139 -0
- package/plugins/ca-pi/hooks/session-start.py +1218 -0
- package/plugins/ca-pi/hooks/statusline.py +736 -0
- package/plugins/ca-pi/hooks/taskwrite.py +351 -0
- package/plugins/ca-pi/hooks/update-refresh.py +51 -0
- package/plugins/ca-pi/hooks/wire-statusline.py +435 -0
- package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +55 -0
- package/plugins/ca-pi/includes/anti-slop-design/color.md +43 -0
- package/plugins/ca-pi/includes/anti-slop-design/core.md +244 -0
- package/plugins/ca-pi/includes/anti-slop-design/images.md +32 -0
- package/plugins/ca-pi/includes/anti-slop-design/layout.md +45 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-cli.md +39 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-dataviz.md +43 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-diagram.md +35 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-documents.md +70 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-slides.md +30 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-web.md +39 -0
- package/plugins/ca-pi/includes/anti-slop-design/typography.md +51 -0
- package/plugins/ca-pi/includes/author-tdd-workflow.md +14 -0
- package/plugins/ca-pi/includes/compaction-charter.md +16 -0
- package/plugins/ca-pi/includes/cut-docs.md +16 -0
- package/plugins/ca-pi/includes/dev-mode.md +30 -0
- package/plugins/ca-pi/includes/farm.md +237 -0
- package/plugins/ca-pi/includes/fresh-verification.md +14 -0
- package/plugins/ca-pi/includes/harvest.md +69 -0
- package/plugins/ca-pi/includes/maturity-coverage.md +102 -0
- package/plugins/ca-pi/includes/pi-host-notes.md +69 -0
- package/plugins/ca-pi/includes/redirect.md +69 -0
- package/plugins/ca-pi/includes/reference-map.md +22 -0
- package/plugins/ca-pi/includes/review-matrix.md +14 -0
- package/plugins/ca-pi/includes/reviewer-contract.md +53 -0
- package/plugins/ca-pi/includes/routing-table.md +47 -0
- package/plugins/ca-pi/includes/security-gate-record.md +22 -0
- package/plugins/ca-pi/includes/smarts/core.md +90 -0
- package/plugins/ca-pi/includes/smarts/decision-log-format.md +56 -0
- package/plugins/ca-pi/routines/INDEX.md +32 -0
- package/plugins/ca-pi/routines/brainstorming/SKILL.md +122 -0
- package/plugins/ca-pi/routines/commit-gate/SKILL.md +151 -0
- package/plugins/ca-pi/routines/context-check/SKILL.md +85 -0
- package/plugins/ca-pi/routines/context-creation/SKILL.md +171 -0
- package/plugins/ca-pi/routines/crypto-compliance/SKILL.md +41 -0
- package/plugins/ca-pi/routines/debug/SKILL.md +99 -0
- package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +104 -0
- package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +74 -0
- package/plugins/ca-pi/routines/decision-variance/SKILL.md +147 -0
- package/plugins/ca-pi/routines/decompose/SKILL.md +168 -0
- package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +76 -0
- package/plugins/ca-pi/routines/executing-plans/SKILL.md +83 -0
- package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +91 -0
- package/plugins/ca-pi/routines/post-merge-cleanup/SKILL.md +233 -0
- package/plugins/ca-pi/routines/refactor/SKILL.md +91 -0
- package/plugins/ca-pi/routines/release/SKILL.md +315 -0
- package/plugins/ca-pi/routines/secret-handling/SKILL.md +67 -0
- package/plugins/ca-pi/routines/security-architecture/SKILL.md +63 -0
- package/plugins/ca-pi/routines/skill-author/SKILL.md +108 -0
- package/plugins/ca-pi/routines/skill-author/references/skill-template.md +58 -0
- package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +149 -0
- package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +145 -0
- package/plugins/ca-pi/routines/tdd/SKILL.md +139 -0
- package/plugins/ca-pi/routines/tribunal/SKILL.md +109 -0
- package/plugins/ca-pi/routines/tribunal/references/ai-markers.md +29 -0
- package/plugins/ca-pi/routines/tribunal/references/cost-and-models.md +64 -0
- package/plugins/ca-pi/routines/tribunal/references/finding-record.md +27 -0
- package/plugins/ca-pi/routines/tribunal/references/issue-filing.md +47 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/appsec.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/architecture.md +23 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/coverage.md +20 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/infra.md +24 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/migration.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/observability.md +21 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/performance.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/reliability.md +23 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/secrets-supply.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/test-fidelity.md +24 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/typesafety.md +21 -0
- package/plugins/ca-pi/routines/tribunal/references/report.md +19 -0
- package/plugins/ca-pi/routines/tribunal/references/schemas.md +58 -0
- package/plugins/ca-pi/routines/tribunal/references/telemetry.md +28 -0
- package/plugins/ca-pi/routines/tribunal/references/triage.md +53 -0
- package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +85 -0
- package/plugins/ca-pi/routines/writing-plans/SKILL.md +129 -0
- package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +50 -0
- package/plugins/ca-pi/skills/ca-add-dep/SKILL.md +88 -0
- package/plugins/ca-pi/skills/ca-adr/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-adr-status/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +36 -0
- package/plugins/ca-pi/skills/ca-audit/SKILL.md +51 -0
- package/plugins/ca-pi/skills/ca-btw/SKILL.md +23 -0
- package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +50 -0
- package/plugins/ca-pi/skills/ca-chore/SKILL.md +58 -0
- package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +55 -0
- package/plugins/ca-pi/skills/ca-commands/SKILL.md +21 -0
- package/plugins/ca-pi/skills/ca-commit/SKILL.md +27 -0
- package/plugins/ca-pi/skills/ca-conflict/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-context-check/SKILL.md +32 -0
- package/plugins/ca-pi/skills/ca-create-context/SKILL.md +32 -0
- package/plugins/ca-pi/skills/ca-debug/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-decompose/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-dev/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-doctor/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-feature/SKILL.md +105 -0
- package/plugins/ca-pi/skills/ca-fix/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-init/SKILL.md +56 -0
- package/plugins/ca-pi/skills/ca-metrics/SKILL.md +80 -0
- package/plugins/ca-pi/skills/ca-new-skill/SKILL.md +34 -0
- package/plugins/ca-pi/skills/ca-override/SKILL.md +72 -0
- package/plugins/ca-pi/skills/ca-pr/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-preview/SKILL.md +86 -0
- package/plugins/ca-pi/skills/ca-prune/SKILL.md +100 -0
- package/plugins/ca-pi/skills/ca-reconcile/SKILL.md +43 -0
- package/plugins/ca-pi/skills/ca-refactor/SKILL.md +43 -0
- package/plugins/ca-pi/skills/ca-release/SKILL.md +57 -0
- package/plugins/ca-pi/skills/ca-review/SKILL.md +63 -0
- package/plugins/ca-pi/skills/ca-spike/SKILL.md +41 -0
- package/plugins/ca-pi/skills/ca-sprint/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-standup/SKILL.md +112 -0
- package/plugins/ca-pi/skills/ca-status/SKILL.md +56 -0
- package/plugins/ca-pi/skills/ca-task/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-threat-model/SKILL.md +45 -0
- package/plugins/ca-pi/skills/ca-tribunal/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-watch/SKILL.md +78 -0
|
@@ -0,0 +1,1080 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter — read-inject helpers for file-scoped just-in-time context injection.
|
|
3
|
+
#
|
|
4
|
+
# Builds the hook response dict and (in later tasks) the file->knowledge map,
|
|
5
|
+
# budget assembler, and freshness gate for the PreToolUse:Read hook (pre-read.py).
|
|
6
|
+
# This module holds all testable pure logic; pre-read.py is the thin entry point.
|
|
7
|
+
#
|
|
8
|
+
# Design invariants (mirroring _provenancelib.py / _taskboardlib.py):
|
|
9
|
+
# - Stdlib only; no third-party imports, ever — runs on stock Python.
|
|
10
|
+
# - Zero side effects at import time: no git calls, no file I/O on import.
|
|
11
|
+
# - Pure functions, testable with synthetic input; isolate any filesystem
|
|
12
|
+
# access to explicitly-named reader functions (added in later tasks).
|
|
13
|
+
# - Never raise on malformed input — degrade gracefully (this runs on the
|
|
14
|
+
# PreToolUse hook path; a crash must never block a Read).
|
|
15
|
+
#
|
|
16
|
+
# Public API:
|
|
17
|
+
# allow_output(additional_context) -> dict build the hookSpecificOutput dict
|
|
18
|
+
# that allows the Read and injects
|
|
19
|
+
# additional_context into the model.
|
|
20
|
+
# None is coerced to "".
|
|
21
|
+
# token_estimate(s) -> int token proxy: ceil(len(s) / 4).
|
|
22
|
+
# Empty string or None -> 0. Never raises.
|
|
23
|
+
# assemble_context(pointers, budget=150) assemble the additionalContext payload
|
|
24
|
+
# -> str from an ordered pointer list; result is
|
|
25
|
+
# <= budget tokens; excess is truncated at
|
|
26
|
+
# the cap with a trailing "…" marker.
|
|
27
|
+
# security_pointer(path) -> dict | None tier 1: pointer to security-controls.md
|
|
28
|
+
# for a security-entry path (auth/middleware/
|
|
29
|
+
# jwt token match), else None. Pure; never
|
|
30
|
+
# raises. No provenance data consulted.
|
|
31
|
+
# accepted_adr_index(root) -> list tier 2 FS reader: scan
|
|
32
|
+
# <root>/.codearbiter/decisions/ for
|
|
33
|
+
# [0-9]+-.+\.md; parse frontmatter (cap 26
|
|
34
|
+
# lines); return [{"adr", "title", "globs"}]
|
|
35
|
+
# for ADRs with status==accepted (case-
|
|
36
|
+
# insensitive) AND a non-empty governs: list.
|
|
37
|
+
# Missing dir -> []. Malformed file -> skip.
|
|
38
|
+
# Never raises. No cache.
|
|
39
|
+
# adr_pointers(rel, index) -> list tier 2 pure matcher: for each index entry
|
|
40
|
+
# whose globs fnmatch-match rel, produce a
|
|
41
|
+
# pointer dict {"text", "tier": "decisions"}.
|
|
42
|
+
# [] when nothing matches, index is empty, or
|
|
43
|
+
# input is malformed. Pure; never raises.
|
|
44
|
+
# parse_spec_governs(text) -> list tier 3 PURE: find the FIRST **Governs:**
|
|
45
|
+
# line in a spec's text; return its comma-
|
|
46
|
+
# separated globs (stripped, empties dropped).
|
|
47
|
+
# No such line -> []. Case-insensitive on
|
|
48
|
+
# "Governs". Never raises.
|
|
49
|
+
# approved_spec_index(root) -> list tier 3 FS reader: scan
|
|
50
|
+
# <root>/.codearbiter/specs/*.md; keep specs
|
|
51
|
+
# with non-empty Governs AND **Status:**
|
|
52
|
+
# beginning with "approved" (case-insensitive;
|
|
53
|
+
# e.g. "approved (2026-06-26)" qualifies,
|
|
54
|
+
# "draft (pending approval)" does not).
|
|
55
|
+
# Status may be inline after · separators.
|
|
56
|
+
# Returns [{"spec": "<stem>", "globs": [...]}].
|
|
57
|
+
# Missing dir -> []. Malformed file -> skip.
|
|
58
|
+
# Never raises. No cache.
|
|
59
|
+
# spec_pointers(rel, index) -> list tier 3 PURE matcher: for each index entry
|
|
60
|
+
# whose any glob fnmatch-matches rel, produce
|
|
61
|
+
# {"text": "spec <slug> governs this file —
|
|
62
|
+
# implement to its acceptance criteria.",
|
|
63
|
+
# "tier": "specs"}. [] when nothing matches,
|
|
64
|
+
# index is empty, or input is malformed.
|
|
65
|
+
# Pure; never raises.
|
|
66
|
+
# provenance_pointer(rel, provenance, tier 4 PURE comparator: freshness-gated
|
|
67
|
+
# current_hashes) -> list pointers from provenance entries for rel.
|
|
68
|
+
# Does NOT call git or batch_hash — caller
|
|
69
|
+
# supplies precomputed current_hashes so a
|
|
70
|
+
# non-matching Read pays no git cost (AC-11).
|
|
71
|
+
# Emits one pointer per FRESH entry (stored
|
|
72
|
+
# hash non-null AND rel in current_hashes AND
|
|
73
|
+
# hashes match). SUPPRESSES diverged/
|
|
74
|
+
# unverifiable/null-hash entries. [] when
|
|
75
|
+
# provenance is empty/{} or no path match.
|
|
76
|
+
# Pointer: {"text": "<doc>.md notes …",
|
|
77
|
+
# "tier": "standards"}. Never raises.
|
|
78
|
+
# governing_docs(rel, index, compose all four tiers in priority order:
|
|
79
|
+
# runner=None) -> list security-controls > decisions > specs >
|
|
80
|
+
# standards. index is the prebuilt dict
|
|
81
|
+
# {"adr":[…], "spec":[…], "provenance":{…}}.
|
|
82
|
+
# Tier-4 (standards) applies the LAZY hashing
|
|
83
|
+
# gate (AC-11): batch_hash is called ONCE only
|
|
84
|
+
# when rel appears as a provenance entry path;
|
|
85
|
+
# a non-provenance Read makes ZERO git calls.
|
|
86
|
+
# [] on any error. Never raises.
|
|
87
|
+
# marker_path(root, session_id, rel) dedup (AC-09): return the absolute path of
|
|
88
|
+
# -> str the per-(session,file) marker under
|
|
89
|
+
# <root>/.codearbiter/.markers/. Filename
|
|
90
|
+
# derived from sha256(session_id+"\0"+rel)
|
|
91
|
+
# for filesystem-safety. PURE — no I/O.
|
|
92
|
+
# Never raises; coerces inputs to str.
|
|
93
|
+
# already_injected(root, dedup (AC-09): True iff the marker file
|
|
94
|
+
# session_id, rel) -> bool exists. On ANY error returns False —
|
|
95
|
+
# degrades toward injecting, never toward
|
|
96
|
+
# wrongly suppressing. Never raises.
|
|
97
|
+
# record_injection(root, dedup (AC-09): create the marker file
|
|
98
|
+
# session_id, rel) -> None (creates .markers/ dir if needed; atomic
|
|
99
|
+
# .tmp + os.replace write). All errors
|
|
100
|
+
# swallowed — a failed write must never
|
|
101
|
+
# break the hook. Never raises.
|
|
102
|
+
# build_index(root) -> dict assemble the prebuilt index governing_docs
|
|
103
|
+
# consumes: {"adr": accepted_adr_index(root),
|
|
104
|
+
# "spec": approved_spec_index(root),
|
|
105
|
+
# "provenance": load_provenance_dir(…)}.
|
|
106
|
+
# Caches adr+spec in <root>/.codearbiter/
|
|
107
|
+
# .markers/readinject-index-cache.json keyed
|
|
108
|
+
# on max mtime of decisions/ and specs/ dirs
|
|
109
|
+
# and their .md files; cache error degrades to
|
|
110
|
+
# fresh build; provenance always fresh; missing
|
|
111
|
+
# dirs → empty sub-structures. Never raises.
|
|
112
|
+
# compute_injection(root, end-to-end orchestrator for AC-09/10/11:
|
|
113
|
+
# session_id, rel, self-read guard → dedup → build_index →
|
|
114
|
+
# runner=None) -> str governing_docs → assemble_context →
|
|
115
|
+
# record_injection. Returns the
|
|
116
|
+
# additionalContext string (possibly "").
|
|
117
|
+
# A non-matching Read makes ZERO runner calls
|
|
118
|
+
# (AC-11 cost guarantee). Never raises.
|
|
119
|
+
|
|
120
|
+
import fnmatch
|
|
121
|
+
import hashlib
|
|
122
|
+
import json
|
|
123
|
+
import math
|
|
124
|
+
import os
|
|
125
|
+
import re
|
|
126
|
+
import unicodedata
|
|
127
|
+
|
|
128
|
+
import _provenancelib
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# Constants
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
_HOOK_EVENT_NAME = "PreToolUse"
|
|
135
|
+
_PERMISSION_ALLOW = "allow"
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# Output builder (AC-03)
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def allow_output(additional_context):
|
|
143
|
+
"""Build the hookSpecificOutput dict that always allows the Read.
|
|
144
|
+
|
|
145
|
+
Returns exactly:
|
|
146
|
+
{"hookSpecificOutput": {"hookEventName": "PreToolUse",
|
|
147
|
+
"permissionDecision": "allow",
|
|
148
|
+
"additionalContext": <additional_context>}}
|
|
149
|
+
|
|
150
|
+
`additional_context` is the string injected as additionalContext. None is
|
|
151
|
+
coerced to "" so the dict is always well-formed. The Read is NEVER denied
|
|
152
|
+
by this function — permissionDecision is always "allow".
|
|
153
|
+
|
|
154
|
+
Pure; never raises.
|
|
155
|
+
"""
|
|
156
|
+
if additional_context is None:
|
|
157
|
+
additional_context = ""
|
|
158
|
+
return {
|
|
159
|
+
"hookSpecificOutput": {
|
|
160
|
+
"hookEventName": _HOOK_EVENT_NAME,
|
|
161
|
+
"permissionDecision": _PERMISSION_ALLOW,
|
|
162
|
+
"additionalContext": additional_context,
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# Token budget helpers (AC-08)
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
_ELLIPSIS = "…" # U+2026 HORIZONTAL ELLIPSIS, len == 1
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def token_estimate(s):
|
|
175
|
+
"""Token proxy: ceil(len(s) / 4).
|
|
176
|
+
|
|
177
|
+
Returns the estimated token count for string s. Empty string or None
|
|
178
|
+
returns 0. Any other value that has a len() uses that length; anything
|
|
179
|
+
that raises returns 0. Pure; never raises.
|
|
180
|
+
"""
|
|
181
|
+
try:
|
|
182
|
+
if not s:
|
|
183
|
+
return 0
|
|
184
|
+
return math.ceil(len(s) / 4)
|
|
185
|
+
except Exception:
|
|
186
|
+
return 0
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def assemble_context(pointers, budget=150):
|
|
190
|
+
"""Assemble the additionalContext payload from an ordered list of pointers.
|
|
191
|
+
|
|
192
|
+
Each pointer must be a dict with a "text" key whose value is a str.
|
|
193
|
+
Pointers with a missing, None, or non-string "text" are silently skipped.
|
|
194
|
+
Non-dict entries in the list are skipped. A non-list `pointers` argument
|
|
195
|
+
degrades to "".
|
|
196
|
+
|
|
197
|
+
Pointers arrive ALREADY in priority order (caller T-07 orders them
|
|
198
|
+
security-controls > decisions > specs > standards). Order is preserved —
|
|
199
|
+
pointers are included from the front.
|
|
200
|
+
|
|
201
|
+
The returned string's token_estimate must be <= budget. When the full
|
|
202
|
+
joined text exceeds budget, whole pointers are included from the front
|
|
203
|
+
while they fit, then the accumulated text is truncated at budget*4 chars
|
|
204
|
+
(the proxy ceiling) and the ellipsis marker "…" is appended so the
|
|
205
|
+
payload is never silently cut.
|
|
206
|
+
|
|
207
|
+
Single-oversized-pointer edge case (T-10 fix): when even the first
|
|
208
|
+
(highest-priority) pointer's text alone exceeds budget, the greedy
|
|
209
|
+
whole-pointer loop accumulates nothing. Rather than returning a bare
|
|
210
|
+
"…" marker (which silently discards the governing note), the function
|
|
211
|
+
truncates the first valid pointer's text to `budget*4 - len("…")` chars
|
|
212
|
+
and appends the ellipsis — so real content is always preserved. The
|
|
213
|
+
result's token_estimate is still <= budget.
|
|
214
|
+
|
|
215
|
+
Empty list or all-skipped -> "". Pure; never raises.
|
|
216
|
+
"""
|
|
217
|
+
try:
|
|
218
|
+
if not isinstance(pointers, list):
|
|
219
|
+
return ""
|
|
220
|
+
|
|
221
|
+
# Collect valid texts in the given priority order.
|
|
222
|
+
valid_texts = []
|
|
223
|
+
for p in pointers:
|
|
224
|
+
if not isinstance(p, dict):
|
|
225
|
+
continue
|
|
226
|
+
text = p.get("text")
|
|
227
|
+
if not isinstance(text, str):
|
|
228
|
+
continue
|
|
229
|
+
valid_texts.append(text)
|
|
230
|
+
|
|
231
|
+
if not valid_texts:
|
|
232
|
+
return ""
|
|
233
|
+
|
|
234
|
+
# Try the full join first — the common, cheap path.
|
|
235
|
+
full_text = "\n".join(valid_texts)
|
|
236
|
+
if token_estimate(full_text) <= budget:
|
|
237
|
+
return full_text
|
|
238
|
+
|
|
239
|
+
# Over budget: greedily include whole pointers from the front.
|
|
240
|
+
accumulated = ""
|
|
241
|
+
for text in valid_texts:
|
|
242
|
+
candidate = text if not accumulated else accumulated + "\n" + text
|
|
243
|
+
if token_estimate(candidate) <= budget:
|
|
244
|
+
accumulated = candidate
|
|
245
|
+
else:
|
|
246
|
+
break # stop — preserve priority order, no skipping
|
|
247
|
+
|
|
248
|
+
# Truncate to leave room for the ellipsis marker, then append it.
|
|
249
|
+
# Single-oversized-pointer edge case: when nothing fit as a whole
|
|
250
|
+
# pointer (first pointer alone exceeds budget), fall back to
|
|
251
|
+
# truncating the first valid pointer's text — so real content is
|
|
252
|
+
# preserved and the result is never a bare ellipsis marker. The
|
|
253
|
+
# final string is at most budget*4 chars, guaranteeing
|
|
254
|
+
# token_estimate(result) <= budget.
|
|
255
|
+
max_text_len = budget * 4 - len(_ELLIPSIS)
|
|
256
|
+
if not accumulated:
|
|
257
|
+
accumulated = valid_texts[0]
|
|
258
|
+
result = accumulated[:max_text_len] + _ELLIPSIS
|
|
259
|
+
return result
|
|
260
|
+
|
|
261
|
+
except Exception:
|
|
262
|
+
return ""
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
# Tier 1 — security-controls pointer (AC-04)
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
# Concise pointer text for tier 1. Kept well under the 150-token budget so it
|
|
270
|
+
# leaves headroom for tiers 2–4 when assembled by governing_docs (T-07).
|
|
271
|
+
_SECURITY_CONTROLS_TEXT = (
|
|
272
|
+
"security-controls.md governs this file — consult it for auth, crypto,"
|
|
273
|
+
" and secret-handling constraints."
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def security_pointer(path):
|
|
278
|
+
"""Return a pointer dict for a security-entry path (tier 1), or None.
|
|
279
|
+
|
|
280
|
+
Fires ONLY for security-entry paths — files whose tokenised, lowercased
|
|
281
|
+
normalised path contains an exact member of _provenancelib._SECURITY_ENTRY_TOKENS
|
|
282
|
+
({"auth", "middleware", "jwt"}). Manifests (package.json), CI yaml, and
|
|
283
|
+
migration files are NOT security-entry paths and return None here even though
|
|
284
|
+
classify_source() returns True for them.
|
|
285
|
+
|
|
286
|
+
Reuses _provenancelib._SECURITY_ENTRY_TOKENS and _SECURITY_TOKEN_SPLIT_RE
|
|
287
|
+
directly so this predicate stays in lockstep with the shipped classifier —
|
|
288
|
+
a change to either constant propagates here automatically.
|
|
289
|
+
|
|
290
|
+
Pointer dict shape (STABLE — must match the assembler T-07):
|
|
291
|
+
{"text": <concise ≤1-sentence string naming security-controls.md>,
|
|
292
|
+
"tier": "security-controls"}
|
|
293
|
+
|
|
294
|
+
Pure; never raises. None or garbage path → None. No provenance data is
|
|
295
|
+
consulted; no git calls are made (tier 1 is provenance-free).
|
|
296
|
+
"""
|
|
297
|
+
try:
|
|
298
|
+
if not path:
|
|
299
|
+
return None
|
|
300
|
+
norm = str(path).replace("\\", "/")
|
|
301
|
+
# Apply the same whole-token rule as classify_source's security-entry
|
|
302
|
+
# branch: split on separators + camelCase boundaries, lowercase, then
|
|
303
|
+
# test for exact membership in _SECURITY_ENTRY_TOKENS. This prevents
|
|
304
|
+
# substring false-positives such as "author" (contains "auth") or
|
|
305
|
+
# "AuthorCard" (camelCase token "Author").
|
|
306
|
+
path_tokens = [
|
|
307
|
+
t.lower()
|
|
308
|
+
for t in _provenancelib._SECURITY_TOKEN_SPLIT_RE.split(norm)
|
|
309
|
+
if t
|
|
310
|
+
]
|
|
311
|
+
if not _provenancelib._SECURITY_ENTRY_TOKENS.intersection(path_tokens):
|
|
312
|
+
return None
|
|
313
|
+
return {
|
|
314
|
+
"text": _SECURITY_CONTROLS_TEXT,
|
|
315
|
+
"tier": "security-controls",
|
|
316
|
+
}
|
|
317
|
+
except Exception:
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
# Tier 2 — accepted-ADR index + pointer (AC-05)
|
|
323
|
+
# ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
_ADR_FILE_RE = re.compile(r"^[0-9]+-.+\.md$")
|
|
326
|
+
_ADR_GOVERNS_RE = re.compile(r"^governs:\s*(.+)$", re.I)
|
|
327
|
+
_ADR_TITLE_RE = re.compile(r"^title:\s*(.+)$", re.I)
|
|
328
|
+
_ADR_STATUS_RE = re.compile(r"^status:\s*(.+)$", re.I)
|
|
329
|
+
|
|
330
|
+
# Maximum number of file lines to scan for frontmatter (mirrors post-write-edit.py).
|
|
331
|
+
_ADR_SCAN_LIMIT = 26 # i > 25 breaks → lines 0-25 inclusive
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def adr_identifier(filename):
|
|
335
|
+
"""The ADR identifier for `filename` — its filename STEM, not its number.
|
|
336
|
+
|
|
337
|
+
`0014-githook-shim-dropin-fail-closed.md` → `0014-githook-shim-dropin-fail-closed`.
|
|
338
|
+
|
|
339
|
+
#416: two ADRs in this repo share the number 0014, so the old
|
|
340
|
+
`filename.split("-")[0]` derivation collapsed two distinct decisions onto one
|
|
341
|
+
id and emitted byte-identical "ADR-0014 governs this file" pointers for both.
|
|
342
|
+
The stem is unique by construction (the filesystem enforces it), so keying on
|
|
343
|
+
it makes the collision unrepresentable rather than merely unlikely.
|
|
344
|
+
|
|
345
|
+
Deliberately mirrored in post-write-edit.py — the two indexers are separate
|
|
346
|
+
payload files that cannot import each other's private helpers; the shared
|
|
347
|
+
contract test (.github/scripts/test_adr_identity.py) pins them to the same
|
|
348
|
+
answer so the derivation cannot drift apart again.
|
|
349
|
+
"""
|
|
350
|
+
return os.path.splitext(str(filename))[0]
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def accepted_adr_index(root):
|
|
354
|
+
"""Tier-2 filesystem reader. Scan <root>/.codearbiter/decisions/ for ADR files.
|
|
355
|
+
|
|
356
|
+
Returns a list of {"adr": "<filename stem>", "title": "<title>", "globs": [<glob>, …]}
|
|
357
|
+
for every ADR that satisfies BOTH conditions:
|
|
358
|
+
- governs: is present and non-empty (one or more comma-separated globs).
|
|
359
|
+
- status: is exactly "accepted" (case-insensitive).
|
|
360
|
+
|
|
361
|
+
STRICT status filter: "superseded", "rejected", "draft", "proposed", missing,
|
|
362
|
+
or any other value are ALL excluded. This is intentionally stricter than
|
|
363
|
+
post-write-edit.py's governs_index which keeps anything not in
|
|
364
|
+
{superseded, rejected}.
|
|
365
|
+
|
|
366
|
+
Missing decisions dir → []. Malformed / unreadable file → skip, never raise.
|
|
367
|
+
No mtime cache; each call re-scans (correctness over speed; caching is optional
|
|
368
|
+
per spec and deferred until benchmarked).
|
|
369
|
+
|
|
370
|
+
Pure filesystem access is isolated here; adr_pointers is the pure matcher.
|
|
371
|
+
Never raises.
|
|
372
|
+
"""
|
|
373
|
+
try:
|
|
374
|
+
ddir = os.path.join(str(root), ".codearbiter", "decisions")
|
|
375
|
+
if not os.path.isdir(ddir):
|
|
376
|
+
return []
|
|
377
|
+
files = [f for f in os.listdir(ddir) if _ADR_FILE_RE.match(f)]
|
|
378
|
+
if not files:
|
|
379
|
+
return []
|
|
380
|
+
index = []
|
|
381
|
+
for fn in files:
|
|
382
|
+
title, status, globs = fn, "", []
|
|
383
|
+
try:
|
|
384
|
+
with open(
|
|
385
|
+
os.path.join(ddir, fn), encoding="utf-8", errors="replace"
|
|
386
|
+
) as fh:
|
|
387
|
+
for i, ln in enumerate(fh):
|
|
388
|
+
if i >= _ADR_SCAN_LIMIT:
|
|
389
|
+
break
|
|
390
|
+
stripped = ln.strip()
|
|
391
|
+
m = _ADR_GOVERNS_RE.match(stripped)
|
|
392
|
+
if m:
|
|
393
|
+
globs = [
|
|
394
|
+
g.strip()
|
|
395
|
+
for g in m.group(1).split(",")
|
|
396
|
+
if g.strip()
|
|
397
|
+
]
|
|
398
|
+
continue
|
|
399
|
+
m = _ADR_TITLE_RE.match(stripped)
|
|
400
|
+
if m:
|
|
401
|
+
title = m.group(1).strip()
|
|
402
|
+
continue
|
|
403
|
+
m = _ADR_STATUS_RE.match(stripped)
|
|
404
|
+
if m:
|
|
405
|
+
status = m.group(1).strip().lower()
|
|
406
|
+
except Exception: # noqa: BLE001 — skip unreadable/malformed files
|
|
407
|
+
continue
|
|
408
|
+
# ACCEPTED ONLY — stricter than post-write-edit.py governs_index.
|
|
409
|
+
if globs and status == "accepted":
|
|
410
|
+
adr = adr_identifier(fn)
|
|
411
|
+
index.append({"adr": adr, "title": title, "globs": globs})
|
|
412
|
+
return index
|
|
413
|
+
except Exception: # noqa: BLE001
|
|
414
|
+
return []
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def adr_pointers(rel, index):
|
|
418
|
+
"""Tier-2 pure matcher. Return pointer dicts for accepted ADRs governing `rel`.
|
|
419
|
+
|
|
420
|
+
`rel` is a repo-relative path string. `index` is the output of
|
|
421
|
+
accepted_adr_index. For each index entry whose any glob fnmatch-matches
|
|
422
|
+
`rel`, produce:
|
|
423
|
+
{"text": "ADR-<id> (<title>) governs this file — do not contradict it;
|
|
424
|
+
route changes via /ca:reconcile or /ca:adr.",
|
|
425
|
+
"tier": "decisions"}
|
|
426
|
+
|
|
427
|
+
The text stays well under the 150-token budget. Returns [] when nothing
|
|
428
|
+
matches, when index is empty, or when either argument is malformed/None.
|
|
429
|
+
Pure; no filesystem access; never raises.
|
|
430
|
+
"""
|
|
431
|
+
try:
|
|
432
|
+
if not rel or not isinstance(index, list):
|
|
433
|
+
return []
|
|
434
|
+
result = []
|
|
435
|
+
for entry in index:
|
|
436
|
+
try:
|
|
437
|
+
globs = entry.get("globs", [])
|
|
438
|
+
if any(fnmatch.fnmatch(rel, g) for g in globs):
|
|
439
|
+
adr_id = entry.get("adr", "")
|
|
440
|
+
title = entry.get("title", "")
|
|
441
|
+
text = (
|
|
442
|
+
"ADR-{} ({}) governs this file"
|
|
443
|
+
" — do not contradict it;"
|
|
444
|
+
" route changes via /ca:reconcile or /ca:adr.".format(
|
|
445
|
+
adr_id, title
|
|
446
|
+
)
|
|
447
|
+
)
|
|
448
|
+
result.append({"text": text, "tier": "decisions"})
|
|
449
|
+
except Exception: # noqa: BLE001 — skip malformed entry
|
|
450
|
+
continue
|
|
451
|
+
return result
|
|
452
|
+
except Exception: # noqa: BLE001
|
|
453
|
+
return []
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# ---------------------------------------------------------------------------
|
|
457
|
+
# Tier 3 — approved-spec index + pointer (AC-06, AC-13)
|
|
458
|
+
# ---------------------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
# U+00B7 MIDDLE DOT — the · separator between inline header fields in spec files.
|
|
461
|
+
# e.g. **Slug:** `x` · **Lane:** full · **Status:** approved (2026-06-26)
|
|
462
|
+
_SPEC_SEPARATOR = "·"
|
|
463
|
+
|
|
464
|
+
# Match the first **Governs:** line (case-insensitive on the word); capture the
|
|
465
|
+
# rest of the line as the comma-separated glob list.
|
|
466
|
+
_SPEC_GOVERNS_RE = re.compile(r"\*\*governs:\*\*\s*(.+)", re.I)
|
|
467
|
+
|
|
468
|
+
# Match **Status:** anywhere on a line (inline support); capture everything up
|
|
469
|
+
# to the next · separator or end-of-line. The separator character is embedded
|
|
470
|
+
# via string concatenation so that · is expanded by Python, while the raw
|
|
471
|
+
# string keeps the regex metacharacter escapes intact.
|
|
472
|
+
_SPEC_STATUS_RE = re.compile(
|
|
473
|
+
r"\*\*status:\*\*\s*([^" + _SPEC_SEPARATOR + r"\n]+)", re.I
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def parse_spec_governs(text):
|
|
478
|
+
"""Return the glob list from the FIRST **Governs:** line in `text`.
|
|
479
|
+
|
|
480
|
+
Scans `text` line by line; returns a list of stripped, non-empty glob
|
|
481
|
+
strings from the first matching line. No such line → []. Non-string
|
|
482
|
+
input → []. Case-insensitive on "Governs". Pure; never raises.
|
|
483
|
+
"""
|
|
484
|
+
try:
|
|
485
|
+
if not isinstance(text, str):
|
|
486
|
+
return []
|
|
487
|
+
for line in text.splitlines():
|
|
488
|
+
m = _SPEC_GOVERNS_RE.search(line)
|
|
489
|
+
if m:
|
|
490
|
+
raw = m.group(1)
|
|
491
|
+
return [g.strip() for g in raw.split(",") if g.strip()]
|
|
492
|
+
return []
|
|
493
|
+
except Exception: # noqa: BLE001
|
|
494
|
+
return []
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def approved_spec_index(root):
|
|
498
|
+
"""Tier-3 filesystem reader. Scan <root>/.codearbiter/specs/ for approved specs.
|
|
499
|
+
|
|
500
|
+
For each *.md file: reads its text, extracts globs via parse_spec_governs,
|
|
501
|
+
and extracts the status by searching for **Status:** anywhere in the text
|
|
502
|
+
(inline · separator support) then capturing the value until the next ·
|
|
503
|
+
or end-of-line, lowercased+stripped.
|
|
504
|
+
|
|
505
|
+
Keeps a spec ONLY when BOTH conditions hold:
|
|
506
|
+
- non-empty globs (has a **Governs:** line), AND
|
|
507
|
+
- status begins with "approved" (case-insensitive) — e.g.
|
|
508
|
+
"approved (2026-06-26)" qualifies; "draft (pending approval)" does NOT.
|
|
509
|
+
|
|
510
|
+
Returns [{"spec": "<filename stem>", "globs": [<glob>, ...]}, ...].
|
|
511
|
+
Missing specs dir → []. Malformed/unreadable file → skip, never raise.
|
|
512
|
+
No mtime cache; each call re-scans. Never raises.
|
|
513
|
+
"""
|
|
514
|
+
try:
|
|
515
|
+
sdir = os.path.join(str(root), ".codearbiter", "specs")
|
|
516
|
+
if not os.path.isdir(sdir):
|
|
517
|
+
return []
|
|
518
|
+
files = [f for f in os.listdir(sdir) if f.endswith(".md")]
|
|
519
|
+
if not files:
|
|
520
|
+
return []
|
|
521
|
+
index = []
|
|
522
|
+
for fn in files:
|
|
523
|
+
try:
|
|
524
|
+
with open(
|
|
525
|
+
os.path.join(sdir, fn), encoding="utf-8", errors="replace"
|
|
526
|
+
) as fh:
|
|
527
|
+
text = fh.read()
|
|
528
|
+
globs = parse_spec_governs(text)
|
|
529
|
+
if not globs:
|
|
530
|
+
continue
|
|
531
|
+
m = _SPEC_STATUS_RE.search(text)
|
|
532
|
+
if not m:
|
|
533
|
+
continue
|
|
534
|
+
status = m.group(1).strip().lower()
|
|
535
|
+
if not status.startswith("approved"):
|
|
536
|
+
continue
|
|
537
|
+
stem = os.path.splitext(fn)[0]
|
|
538
|
+
index.append({"spec": stem, "globs": globs})
|
|
539
|
+
except Exception: # noqa: BLE001 — skip malformed/unreadable files
|
|
540
|
+
continue
|
|
541
|
+
return index
|
|
542
|
+
except Exception: # noqa: BLE001
|
|
543
|
+
return []
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def spec_pointers(rel, index):
|
|
547
|
+
"""Tier-3 pure matcher. Return pointer dicts for approved specs governing `rel`.
|
|
548
|
+
|
|
549
|
+
`rel` is a repo-relative path string. `index` is the output of
|
|
550
|
+
approved_spec_index. For each index entry whose any glob fnmatch-matches
|
|
551
|
+
`rel`, produce:
|
|
552
|
+
{"text": "spec <slug> governs this file — implement to its acceptance criteria.",
|
|
553
|
+
"tier": "specs"}
|
|
554
|
+
|
|
555
|
+
The text stays well under the 150-token budget. Returns [] when nothing
|
|
556
|
+
matches, when index is empty, or when either argument is malformed/None.
|
|
557
|
+
Pure; no filesystem access; never raises.
|
|
558
|
+
"""
|
|
559
|
+
try:
|
|
560
|
+
if not rel or not isinstance(index, list):
|
|
561
|
+
return []
|
|
562
|
+
result = []
|
|
563
|
+
for entry in index:
|
|
564
|
+
try:
|
|
565
|
+
globs = entry.get("globs", [])
|
|
566
|
+
if any(fnmatch.fnmatch(rel, g) for g in globs):
|
|
567
|
+
slug = entry.get("spec", "")
|
|
568
|
+
text = (
|
|
569
|
+
"spec {} governs this file"
|
|
570
|
+
" — implement to its acceptance criteria.".format(slug)
|
|
571
|
+
)
|
|
572
|
+
result.append({"text": text, "tier": "specs"})
|
|
573
|
+
except Exception: # noqa: BLE001 — skip malformed entry
|
|
574
|
+
continue
|
|
575
|
+
return result
|
|
576
|
+
except Exception: # noqa: BLE001
|
|
577
|
+
return []
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
# ---------------------------------------------------------------------------
|
|
581
|
+
# Tier 4 — provenance freshness-gated pointer (AC-07)
|
|
582
|
+
# ---------------------------------------------------------------------------
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
_CONTEXT_CONTROL_CATEGORIES = frozenset(("Cc", "Cf", "Zl", "Zp"))
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _strip_context_controls(value):
|
|
589
|
+
"""Return value as text without controls that can restructure context."""
|
|
590
|
+
try:
|
|
591
|
+
text = value if isinstance(value, str) else str(value)
|
|
592
|
+
return "".join(
|
|
593
|
+
char for char in text
|
|
594
|
+
if unicodedata.category(char) not in _CONTEXT_CONTROL_CATEGORIES
|
|
595
|
+
)
|
|
596
|
+
except Exception: # noqa: BLE001 - malformed provenance degrades to empty
|
|
597
|
+
return ""
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def provenance_pointer(rel, provenance, current_hashes):
|
|
601
|
+
"""Tier-4 pure comparator. Freshness-gated pointers from provenance entries.
|
|
602
|
+
|
|
603
|
+
PURE — does NOT call git or batch_hash. The caller (a later task) computes
|
|
604
|
+
current_hashes via _provenancelib.batch_hash ONLY for matched paths, so that
|
|
605
|
+
a non-matching Read pays zero git cost (AC-11). This function just consumes
|
|
606
|
+
the precomputed hashes.
|
|
607
|
+
|
|
608
|
+
rel — repo-relative path being read.
|
|
609
|
+
provenance — {doc_name: record} map (same shape _provenancelib produces).
|
|
610
|
+
May be {} — tier 4 is dormant until a re-scout backfills
|
|
611
|
+
.provenance/ (the live state of this repo today).
|
|
612
|
+
Non-dict → [].
|
|
613
|
+
current_hashes — {path: git_oid} for paths the caller already hashed.
|
|
614
|
+
Non-dict → [].
|
|
615
|
+
|
|
616
|
+
FRESHNESS GATE (the heart of AC-07):
|
|
617
|
+
- Find every entry across all docs whose path == rel.
|
|
618
|
+
- For each such entry: emit a pointer ONLY when ALL THREE hold:
|
|
619
|
+
(a) entry["hash"] is non-null, AND
|
|
620
|
+
(b) rel is present in current_hashes, AND
|
|
621
|
+
(c) current_hashes[rel] == entry["hash"] (FRESH)
|
|
622
|
+
- SUPPRESS (emit nothing for that entry) when:
|
|
623
|
+
- stored hash is None/null (unverifiable by design)
|
|
624
|
+
- rel absent from current_hashes (caller chose not to hash this path)
|
|
625
|
+
- current_hashes[rel] != stored hash (DIVERGED — stale note is worse
|
|
626
|
+
than none; drift system already nudges separately)
|
|
627
|
+
|
|
628
|
+
Pointer shape (stable contract):
|
|
629
|
+
{"text": "<doc>.md notes (lines L-M): <claim>", "tier": "standards"}
|
|
630
|
+
If the first claim has no "lines" key:
|
|
631
|
+
{"text": "<doc>.md notes: <claim>", "tier": "standards"}
|
|
632
|
+
Document names, claim text, and line-range metadata are stripped of
|
|
633
|
+
control, formatting, and line/paragraph separator characters before
|
|
634
|
+
pointer assembly. A document name that becomes empty is suppressed.
|
|
635
|
+
Uses the FIRST claim in entry["claims"]. Long texts are truncated at the
|
|
636
|
+
150-token budget (same proxy as token_estimate: ceil(len/4)) with a
|
|
637
|
+
trailing "…" marker so the payload is never silently cut.
|
|
638
|
+
|
|
639
|
+
Returns [] when provenance is empty/malformed, no entry's path matches rel,
|
|
640
|
+
or all matching entries are suppressed. Malformed record/entry/None → skip
|
|
641
|
+
it, never raise. Non-dict inputs → []. Pure; never raises.
|
|
642
|
+
"""
|
|
643
|
+
try:
|
|
644
|
+
if not isinstance(provenance, dict) or not isinstance(current_hashes, dict):
|
|
645
|
+
return []
|
|
646
|
+
if not rel or not provenance:
|
|
647
|
+
return []
|
|
648
|
+
|
|
649
|
+
result = []
|
|
650
|
+
for doc_name, record in provenance.items():
|
|
651
|
+
try:
|
|
652
|
+
if not isinstance(record, dict):
|
|
653
|
+
continue
|
|
654
|
+
safe_doc_name = _strip_context_controls(doc_name)
|
|
655
|
+
if not safe_doc_name:
|
|
656
|
+
continue
|
|
657
|
+
entries = record.get("entries")
|
|
658
|
+
if not isinstance(entries, list):
|
|
659
|
+
continue
|
|
660
|
+
for entry in entries:
|
|
661
|
+
try:
|
|
662
|
+
if not isinstance(entry, dict):
|
|
663
|
+
continue
|
|
664
|
+
if entry.get("path") != rel:
|
|
665
|
+
continue
|
|
666
|
+
|
|
667
|
+
stored_hash = entry.get("hash")
|
|
668
|
+
# SUPPRESS: null stored hash — cannot verify.
|
|
669
|
+
if stored_hash is None:
|
|
670
|
+
continue
|
|
671
|
+
# SUPPRESS: rel absent from current_hashes — unverifiable.
|
|
672
|
+
if rel not in current_hashes:
|
|
673
|
+
continue
|
|
674
|
+
# SUPPRESS: hash diverged — stale note is worse than none.
|
|
675
|
+
if current_hashes[rel] != stored_hash:
|
|
676
|
+
continue
|
|
677
|
+
|
|
678
|
+
# FRESH — build pointer from the first claim.
|
|
679
|
+
claims = entry.get("claims")
|
|
680
|
+
if not isinstance(claims, list) or not claims:
|
|
681
|
+
continue
|
|
682
|
+
first_claim = claims[0]
|
|
683
|
+
if not isinstance(first_claim, dict):
|
|
684
|
+
continue
|
|
685
|
+
claim_text = first_claim.get("claim", "")
|
|
686
|
+
if not isinstance(claim_text, str):
|
|
687
|
+
try:
|
|
688
|
+
claim_text = str(claim_text)
|
|
689
|
+
except Exception:
|
|
690
|
+
claim_text = ""
|
|
691
|
+
claim_text = _strip_context_controls(claim_text)
|
|
692
|
+
lines_range = first_claim.get("lines")
|
|
693
|
+
if lines_range:
|
|
694
|
+
lines_range = _strip_context_controls(lines_range)
|
|
695
|
+
|
|
696
|
+
# Assemble pointer text; include lines range when present.
|
|
697
|
+
doc_part = "{}.md".format(safe_doc_name)
|
|
698
|
+
if lines_range:
|
|
699
|
+
text = "{} notes (lines {}): {}".format(
|
|
700
|
+
doc_part, lines_range, claim_text
|
|
701
|
+
)
|
|
702
|
+
else:
|
|
703
|
+
text = "{} notes: {}".format(doc_part, claim_text)
|
|
704
|
+
|
|
705
|
+
# Enforce the 150-token budget; truncate with ellipsis marker
|
|
706
|
+
# so the payload is never silently cut (mirrors assemble_context).
|
|
707
|
+
if token_estimate(text) > 150:
|
|
708
|
+
max_text_len = 150 * 4 - len(_ELLIPSIS)
|
|
709
|
+
text = text[:max_text_len] + _ELLIPSIS
|
|
710
|
+
|
|
711
|
+
result.append({"text": text, "tier": "standards"})
|
|
712
|
+
except Exception: # noqa: BLE001 — skip malformed entry
|
|
713
|
+
continue
|
|
714
|
+
except Exception: # noqa: BLE001 — skip malformed record
|
|
715
|
+
continue
|
|
716
|
+
|
|
717
|
+
return result
|
|
718
|
+
except Exception: # noqa: BLE001
|
|
719
|
+
return []
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
# ---------------------------------------------------------------------------
|
|
723
|
+
# Four-tier composer (T-07 / AC-04/05/06/08/11)
|
|
724
|
+
# ---------------------------------------------------------------------------
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def governing_docs(rel, index, runner=None):
|
|
728
|
+
"""Return an ordered list of pointer dicts for rel — four-tier composition.
|
|
729
|
+
|
|
730
|
+
Composes the four knowledge tiers in the PRIORITY ORDER the budget assembler
|
|
731
|
+
relies on (security-controls > decisions > specs > standards):
|
|
732
|
+
|
|
733
|
+
1. security_pointer(rel) tier "security-controls"
|
|
734
|
+
2. adr_pointers(rel, index["adr"]) tier "decisions"
|
|
735
|
+
3. spec_pointers(rel, index["spec"]) tier "specs"
|
|
736
|
+
4. provenance_pointer(rel, …, current_hashes) tier "standards"
|
|
737
|
+
— ONLY when rel appears as a provenance entry path (AC-11 lazy gate).
|
|
738
|
+
|
|
739
|
+
Parameters
|
|
740
|
+
----------
|
|
741
|
+
rel : repo-relative path string being read.
|
|
742
|
+
index : prebuilt dict with the STABLE shape produced by T-09:
|
|
743
|
+
{"adr": [{adr, title, globs}, …],
|
|
744
|
+
"spec": [{spec, globs}, …],
|
|
745
|
+
"provenance": {doc_name: record, …}}
|
|
746
|
+
Missing sub-keys degrade to their empty defaults.
|
|
747
|
+
runner : injectable git runner for batch_hash; contract:
|
|
748
|
+
runner(args, stdin_text) -> str
|
|
749
|
+
Default None (batch_hash uses _default_hash_runner).
|
|
750
|
+
|
|
751
|
+
Tier-4 LAZY hashing gate (AC-11)
|
|
752
|
+
---------------------------------
|
|
753
|
+
Before calling batch_hash, a cheap dict/list walk collects all 'path' values
|
|
754
|
+
from every provenance record's 'entries' list into a set. If rel is NOT in
|
|
755
|
+
that set, tier 4 is SKIPPED ENTIRELY — no batch_hash call, zero git calls.
|
|
756
|
+
If rel IS in that set, batch_hash([rel], runner) is called ONCE to obtain
|
|
757
|
+
current_hashes, then provenance_pointer applies the freshness gate.
|
|
758
|
+
|
|
759
|
+
Returns
|
|
760
|
+
-------
|
|
761
|
+
list of pointer dicts (each has 'text': str and 'tier': str), in priority
|
|
762
|
+
order. [] when nothing matches. [] on any error. Never raises.
|
|
763
|
+
"""
|
|
764
|
+
try:
|
|
765
|
+
result = []
|
|
766
|
+
|
|
767
|
+
# Tier 1: security-controls — pure; never raises.
|
|
768
|
+
sec = security_pointer(rel)
|
|
769
|
+
if sec is not None:
|
|
770
|
+
result.append(sec)
|
|
771
|
+
|
|
772
|
+
# Safely extract index sub-maps; degrade on missing/wrong-type values.
|
|
773
|
+
if isinstance(index, dict):
|
|
774
|
+
adr_index = index.get("adr") or []
|
|
775
|
+
spec_index = index.get("spec") or []
|
|
776
|
+
provenance = index.get("provenance") or {}
|
|
777
|
+
else:
|
|
778
|
+
adr_index = []
|
|
779
|
+
spec_index = []
|
|
780
|
+
provenance = {}
|
|
781
|
+
|
|
782
|
+
# Tier 2: decisions (accepted ADRs) — pure; never raises.
|
|
783
|
+
result.extend(adr_pointers(rel, adr_index))
|
|
784
|
+
|
|
785
|
+
# Tier 3: specs (approved specs) — pure; never raises.
|
|
786
|
+
result.extend(spec_pointers(rel, spec_index))
|
|
787
|
+
|
|
788
|
+
# Tier 4: standards (provenance freshness gate) — lazy hashing (AC-11).
|
|
789
|
+
#
|
|
790
|
+
# Step A: cheap dict/list walk to build the set of all provenance entry
|
|
791
|
+
# paths. This is git-free. A non-provenance Read exits here with zero
|
|
792
|
+
# git calls.
|
|
793
|
+
entry_paths = set()
|
|
794
|
+
if isinstance(provenance, dict):
|
|
795
|
+
for record in provenance.values():
|
|
796
|
+
try:
|
|
797
|
+
if not isinstance(record, dict):
|
|
798
|
+
continue
|
|
799
|
+
entries = record.get("entries")
|
|
800
|
+
if not isinstance(entries, list):
|
|
801
|
+
continue
|
|
802
|
+
for entry in entries:
|
|
803
|
+
try:
|
|
804
|
+
if isinstance(entry, dict):
|
|
805
|
+
p = entry.get("path")
|
|
806
|
+
if p:
|
|
807
|
+
entry_paths.add(p)
|
|
808
|
+
except Exception: # noqa: BLE001
|
|
809
|
+
continue
|
|
810
|
+
except Exception: # noqa: BLE001
|
|
811
|
+
continue
|
|
812
|
+
|
|
813
|
+
# Step B: only call batch_hash when rel is a known provenance entry path.
|
|
814
|
+
# Absence from entry_paths → SKIP; zero git calls.
|
|
815
|
+
if rel and rel in entry_paths:
|
|
816
|
+
current_hashes = _provenancelib.batch_hash([rel], runner)
|
|
817
|
+
result.extend(provenance_pointer(rel, provenance, current_hashes))
|
|
818
|
+
|
|
819
|
+
return result
|
|
820
|
+
|
|
821
|
+
except Exception: # noqa: BLE001
|
|
822
|
+
return []
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
# ---------------------------------------------------------------------------
|
|
826
|
+
# Dedup gate — per-(session, file) marker (AC-09)
|
|
827
|
+
# ---------------------------------------------------------------------------
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def marker_path(root, session_id, rel):
|
|
831
|
+
"""Return the absolute path of the dedup marker for (session_id, rel).
|
|
832
|
+
|
|
833
|
+
The marker lives under <root>/.codearbiter/.markers/ with a filename
|
|
834
|
+
derived from sha256(str(session_id) + "\\0" + str(rel)).hexdigest(). The
|
|
835
|
+
null-byte separator ensures ('ab', 'c') and ('a', 'bc') hash to different
|
|
836
|
+
filenames.
|
|
837
|
+
|
|
838
|
+
PURE — no filesystem access of any kind. Inputs are coerced to str so any
|
|
839
|
+
type is accepted. Never raises; on the (essentially impossible) error path,
|
|
840
|
+
returns a fallback path whose last segment is 'readinject-error.marker'
|
|
841
|
+
which will not match any normally-written marker.
|
|
842
|
+
"""
|
|
843
|
+
try:
|
|
844
|
+
digest = hashlib.sha256(
|
|
845
|
+
(str(session_id) + "\0" + str(rel)).encode("utf-8")
|
|
846
|
+
).hexdigest()
|
|
847
|
+
return os.path.join(
|
|
848
|
+
str(root),
|
|
849
|
+
".codearbiter",
|
|
850
|
+
".markers",
|
|
851
|
+
"readinject-{}.marker".format(digest),
|
|
852
|
+
)
|
|
853
|
+
except Exception: # noqa: BLE001
|
|
854
|
+
return os.path.join(
|
|
855
|
+
str(root), ".codearbiter", ".markers", "readinject-error.marker"
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def already_injected(root, session_id, rel):
|
|
860
|
+
"""Return True iff the dedup marker for (session_id, rel) exists on disk.
|
|
861
|
+
|
|
862
|
+
On ANY error (bad path, permission error, FS error) returns False —
|
|
863
|
+
degrades toward injecting again, never toward wrongly suppressing an
|
|
864
|
+
injection. Never raises.
|
|
865
|
+
"""
|
|
866
|
+
try:
|
|
867
|
+
return os.path.isfile(marker_path(root, session_id, rel))
|
|
868
|
+
except Exception: # noqa: BLE001
|
|
869
|
+
return False
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def record_injection(root, session_id, rel):
|
|
873
|
+
"""Create the dedup marker for (session_id, rel), making .markers/ if needed.
|
|
874
|
+
|
|
875
|
+
Uses an atomic .tmp + os.replace write: a crash between the open() and the
|
|
876
|
+
rename never leaves a half-written marker at the target path. The marker
|
|
877
|
+
content is an empty string; only its existence is tested by already_injected.
|
|
878
|
+
|
|
879
|
+
All errors are silently swallowed — a failed marker write must never break
|
|
880
|
+
the hook. Worst case: the marker is absent and the same (session, file)
|
|
881
|
+
pair is injected again on the next Read. Never raises.
|
|
882
|
+
"""
|
|
883
|
+
try:
|
|
884
|
+
path = marker_path(root, session_id, rel)
|
|
885
|
+
marker_dir = os.path.dirname(path)
|
|
886
|
+
os.makedirs(marker_dir, exist_ok=True)
|
|
887
|
+
tmp = path + ".tmp"
|
|
888
|
+
try:
|
|
889
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
890
|
+
f.write("")
|
|
891
|
+
os.replace(tmp, path)
|
|
892
|
+
except Exception: # noqa: BLE001
|
|
893
|
+
try:
|
|
894
|
+
os.remove(tmp)
|
|
895
|
+
except Exception: # noqa: BLE001
|
|
896
|
+
pass
|
|
897
|
+
except Exception: # noqa: BLE001
|
|
898
|
+
pass
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
# ---------------------------------------------------------------------------
|
|
902
|
+
# Index builder with mtime caching (T-09 / AC-11)
|
|
903
|
+
# ---------------------------------------------------------------------------
|
|
904
|
+
|
|
905
|
+
_INDEX_CACHE_FILENAME = "readinject-index-cache.json"
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def _index_stamp(root):
|
|
909
|
+
"""Compute a max-mtime float stamp covering decisions/ and specs/ dirs + .md files.
|
|
910
|
+
|
|
911
|
+
Includes the directory mtime (changes on file add/remove) AND the mtime of
|
|
912
|
+
every .md file inside each directory (changes on file edits). This catches
|
|
913
|
+
all three mutation kinds: add, remove, and modify.
|
|
914
|
+
|
|
915
|
+
Returns 0.0 when neither directory exists (absence sentinel — a cache keyed
|
|
916
|
+
on 0.0 is safe to reuse while neither dir is present). Never raises.
|
|
917
|
+
"""
|
|
918
|
+
mtimes = []
|
|
919
|
+
try:
|
|
920
|
+
for subdir in ("decisions", "specs"):
|
|
921
|
+
ddir = os.path.join(str(root), ".codearbiter", subdir)
|
|
922
|
+
try:
|
|
923
|
+
if not os.path.isdir(ddir):
|
|
924
|
+
continue
|
|
925
|
+
try:
|
|
926
|
+
mtimes.append(os.path.getmtime(ddir))
|
|
927
|
+
except Exception: # noqa: BLE001
|
|
928
|
+
pass
|
|
929
|
+
try:
|
|
930
|
+
for fname in os.listdir(ddir):
|
|
931
|
+
if fname.endswith(".md"):
|
|
932
|
+
try:
|
|
933
|
+
mtimes.append(
|
|
934
|
+
os.path.getmtime(os.path.join(ddir, fname))
|
|
935
|
+
)
|
|
936
|
+
except Exception: # noqa: BLE001
|
|
937
|
+
pass
|
|
938
|
+
except Exception: # noqa: BLE001
|
|
939
|
+
pass
|
|
940
|
+
except Exception: # noqa: BLE001
|
|
941
|
+
pass
|
|
942
|
+
except Exception: # noqa: BLE001
|
|
943
|
+
pass
|
|
944
|
+
return max(mtimes) if mtimes else 0.0
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def build_index(root):
|
|
948
|
+
"""Assemble the prebuilt index dict that governing_docs consumes.
|
|
949
|
+
|
|
950
|
+
Returns:
|
|
951
|
+
{"adr": accepted_adr_index(root),
|
|
952
|
+
"spec": approved_spec_index(root),
|
|
953
|
+
"provenance": load_provenance_dir(<root>/.codearbiter/.provenance)}
|
|
954
|
+
|
|
955
|
+
CACHING (batch-2 review finding): the hook fires on every PreToolUse:Read,
|
|
956
|
+
so this function MUST NOT re-scan decisions/ + specs/ from disk on every
|
|
957
|
+
call. The 'adr' and 'spec' index lists are cached in:
|
|
958
|
+
<root>/.codearbiter/.markers/readinject-index-cache.json
|
|
959
|
+
keyed on a stamp = max mtime of the decisions/ and specs/ directories and
|
|
960
|
+
their .md files (same technique governs_index uses in post-write-edit.py).
|
|
961
|
+
On a cache hit (stamp unchanged) the cached lists are reused; on a miss the
|
|
962
|
+
lists are rebuilt and the cache is rewritten atomically (.tmp + os.replace).
|
|
963
|
+
|
|
964
|
+
Any cache read/write error degrades to a fresh uncached build — MUST NOT
|
|
965
|
+
raise and MUST NOT prevent correct results. Cache is an optimization only.
|
|
966
|
+
|
|
967
|
+
'provenance' is always loaded fresh each call (it is small; no cache needed).
|
|
968
|
+
|
|
969
|
+
Missing directories → empty sub-lists/maps. Never raises.
|
|
970
|
+
"""
|
|
971
|
+
try:
|
|
972
|
+
stamp = _index_stamp(root)
|
|
973
|
+
cache_path = os.path.join(
|
|
974
|
+
str(root), ".codearbiter", ".markers", _INDEX_CACHE_FILENAME
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
# --- Cache hit path ---
|
|
978
|
+
try:
|
|
979
|
+
with open(cache_path, encoding="utf-8") as _fh:
|
|
980
|
+
_cached = json.load(_fh)
|
|
981
|
+
if _cached.get("stamp") == stamp:
|
|
982
|
+
_adr = _cached.get("adr", [])
|
|
983
|
+
_spec = _cached.get("spec", [])
|
|
984
|
+
_prov_dir = os.path.join(
|
|
985
|
+
str(root), ".codearbiter", ".provenance"
|
|
986
|
+
)
|
|
987
|
+
_provenance = _provenancelib.load_provenance_dir(_prov_dir)
|
|
988
|
+
return {"adr": _adr, "spec": _spec, "provenance": _provenance}
|
|
989
|
+
except Exception: # noqa: BLE001 — absent / corrupt cache; rebuild
|
|
990
|
+
pass
|
|
991
|
+
|
|
992
|
+
# --- Cache miss: rebuild both indexes ---
|
|
993
|
+
adr = accepted_adr_index(root)
|
|
994
|
+
spec_list = approved_spec_index(root)
|
|
995
|
+
|
|
996
|
+
# Write the cache atomically (.tmp + os.replace).
|
|
997
|
+
# Guard: only write when cache_path is absolute — prevents creating
|
|
998
|
+
# directories relative to the CWD when root is a garbage/relative value.
|
|
999
|
+
# Any failure is swallowed — cache is an optimization, never required.
|
|
1000
|
+
if os.path.isabs(cache_path):
|
|
1001
|
+
try:
|
|
1002
|
+
_cache_dir = os.path.dirname(cache_path)
|
|
1003
|
+
os.makedirs(_cache_dir, exist_ok=True)
|
|
1004
|
+
_tmp = cache_path + ".tmp"
|
|
1005
|
+
with open(_tmp, "w", encoding="utf-8") as _fh:
|
|
1006
|
+
json.dump({"stamp": stamp, "adr": adr, "spec": spec_list}, _fh)
|
|
1007
|
+
os.replace(_tmp, cache_path)
|
|
1008
|
+
except Exception: # noqa: BLE001 — cache write failure is acceptable
|
|
1009
|
+
pass
|
|
1010
|
+
|
|
1011
|
+
prov_dir = os.path.join(str(root), ".codearbiter", ".provenance")
|
|
1012
|
+
provenance = _provenancelib.load_provenance_dir(prov_dir)
|
|
1013
|
+
return {"adr": adr, "spec": spec_list, "provenance": provenance}
|
|
1014
|
+
except Exception: # noqa: BLE001
|
|
1015
|
+
return {"adr": [], "spec": [], "provenance": {}}
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
# ---------------------------------------------------------------------------
|
|
1019
|
+
# End-to-end orchestrator (T-09 / AC-09/10/11)
|
|
1020
|
+
# ---------------------------------------------------------------------------
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def compute_injection(root, session_id, rel, runner=None):
|
|
1024
|
+
"""End-to-end orchestrator for file-scoped JIT context injection.
|
|
1025
|
+
|
|
1026
|
+
Returns the additionalContext string (possibly "") that the hook sets as
|
|
1027
|
+
hookSpecificOutput.additionalContext. Steps in order:
|
|
1028
|
+
|
|
1029
|
+
1. Self-read guard (AC-10): if rel's first path segment is '.codearbiter'
|
|
1030
|
+
(after backslash → forward-slash normalisation), return "" immediately —
|
|
1031
|
+
NO index build, NO git call, NO marker written.
|
|
1032
|
+
2. Dedup (AC-09): if already_injected(root, session_id, rel), return "".
|
|
1033
|
+
3. Build the index via build_index(root); compose pointers via
|
|
1034
|
+
governing_docs(rel, index, runner).
|
|
1035
|
+
4. No-match fast-path (AC-11): if pointers is empty, return "" WITHOUT
|
|
1036
|
+
recording a marker — markers are only written for injecting reads.
|
|
1037
|
+
5. assemble_context(pointers, budget=150). If ctx is non-empty →
|
|
1038
|
+
record_injection(root, session_id, rel) then return ctx.
|
|
1039
|
+
If somehow empty → return "" without recording.
|
|
1040
|
+
|
|
1041
|
+
COST GUARANTEE (AC-11): a non-matching Read makes ZERO runner calls.
|
|
1042
|
+
• The self-read guard exits before build_index is called.
|
|
1043
|
+
• build_index makes no git calls (reads dirs + provenance JSON only).
|
|
1044
|
+
• governing_docs gates batch_hash on provenance entry-path membership:
|
|
1045
|
+
a path absent from all provenance entries never calls the runner.
|
|
1046
|
+
Passing runner straight through to governing_docs preserves this property.
|
|
1047
|
+
|
|
1048
|
+
Never raises.
|
|
1049
|
+
"""
|
|
1050
|
+
try:
|
|
1051
|
+
# Step 1: Self-read guard (AC-10) — exit before any I/O or git call.
|
|
1052
|
+
if rel is not None:
|
|
1053
|
+
try:
|
|
1054
|
+
_norm = str(rel).replace("\\", "/")
|
|
1055
|
+
_parts = [_p for _p in _norm.split("/") if _p]
|
|
1056
|
+
if _parts and _parts[0] == ".codearbiter":
|
|
1057
|
+
return ""
|
|
1058
|
+
except Exception: # noqa: BLE001
|
|
1059
|
+
pass # Malformed rel; fall through — won't match anything below.
|
|
1060
|
+
|
|
1061
|
+
# Step 2: Per-(session, file) dedup (AC-09).
|
|
1062
|
+
if already_injected(root, session_id, rel):
|
|
1063
|
+
return ""
|
|
1064
|
+
|
|
1065
|
+
# Step 3: Build the index (cached) and compose the four-tier pointer list.
|
|
1066
|
+
index = build_index(root)
|
|
1067
|
+
pointers = governing_docs(rel, index, runner)
|
|
1068
|
+
|
|
1069
|
+
# Step 4: No-match fast-path — do NOT record a marker on a miss (AC-11).
|
|
1070
|
+
if not pointers:
|
|
1071
|
+
return ""
|
|
1072
|
+
|
|
1073
|
+
# Step 5: Assemble the context payload and deliver.
|
|
1074
|
+
ctx = assemble_context(pointers, budget=150)
|
|
1075
|
+
if ctx:
|
|
1076
|
+
record_injection(root, session_id, rel)
|
|
1077
|
+
return ctx
|
|
1078
|
+
return ""
|
|
1079
|
+
except Exception: # noqa: BLE001
|
|
1080
|
+
return ""
|