@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,81 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter — PreToolUse:Read hook: file-scoped just-in-time context injection.
|
|
3
|
+
#
|
|
4
|
+
# Thin entry point for the file-scoped-context-injection spec (AC-03, AC-10).
|
|
5
|
+
# All testable logic lives in _readinjectlib.py; this file wires the Claude Code
|
|
6
|
+
# hook protocol to the lib's compute_injection / allow_output helpers.
|
|
7
|
+
#
|
|
8
|
+
# Control flow:
|
|
9
|
+
# 1. utf8_stdio(); root = project_root().
|
|
10
|
+
# 2. If not arbiter_active(root): sys.exit(0) — dormant in non-opted-in repos.
|
|
11
|
+
# 3. Parse input: data = read_input(); extract file_path from tool_input(data);
|
|
12
|
+
# session_id = data.get("session_id", "").
|
|
13
|
+
# 4. rel = repo_rel(file_path, root). If rel is falsy (outside repo): sys.exit(0).
|
|
14
|
+
# 5. ctx = compute_injection(root, session_id, rel) — handles the .codearbiter/
|
|
15
|
+
# self-read guard (AC-10), per-(session, file) dedup (AC-09), four-tier index
|
|
16
|
+
# lookup, budget assembly, and marker write internally.
|
|
17
|
+
# 6. If ctx non-empty: print allow_output(ctx) as JSON (AC-03) and exit 0.
|
|
18
|
+
# If ctx empty (miss, self-read, or dedup): exit 0 with no output — silent allow.
|
|
19
|
+
#
|
|
20
|
+
# Design invariants:
|
|
21
|
+
# - Always allows the Read — never emits a deny or block decision.
|
|
22
|
+
# - Emits allow_output(ctx) ONLY when ctx is non-empty (AC-10: miss and self-read
|
|
23
|
+
# must be silent; "Done looks like" requires no output on a non-governed Read).
|
|
24
|
+
# - Fail-open (AC-12): any exception degrades to sys.exit(0) with no stdout
|
|
25
|
+
# output. A hook crash must NEVER block or stall a Read. The outer
|
|
26
|
+
# except Exception clause is the last-resort safety net; sys.exit(0) calls
|
|
27
|
+
# inside the try block raise SystemExit (a BaseException, not an Exception)
|
|
28
|
+
# so they propagate normally and are never swallowed by this clause.
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import sys
|
|
33
|
+
|
|
34
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
35
|
+
import hostapi # noqa: E402 — host seam (ADR-0011)
|
|
36
|
+
import _entrylib # noqa: E402 — shared run() dispatch (jscpd dedup)
|
|
37
|
+
from _hooklib import ( # noqa: E402
|
|
38
|
+
arbiter_active, project_root, read_input, repo_rel, set_host, tool_input,
|
|
39
|
+
utf8_stdio,
|
|
40
|
+
)
|
|
41
|
+
from _readinjectlib import allow_output, compute_injection # noqa: E402
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main():
|
|
45
|
+
try:
|
|
46
|
+
utf8_stdio()
|
|
47
|
+
root = project_root()
|
|
48
|
+
if not arbiter_active(root):
|
|
49
|
+
sys.exit(0)
|
|
50
|
+
data = read_input()
|
|
51
|
+
file_path = tool_input(data).get("file_path", "")
|
|
52
|
+
session_id = data.get("session_id", "")
|
|
53
|
+
rel = repo_rel(file_path, root)
|
|
54
|
+
if not rel:
|
|
55
|
+
sys.exit(0)
|
|
56
|
+
ctx = compute_injection(root, session_id, rel)
|
|
57
|
+
if ctx:
|
|
58
|
+
print(json.dumps(allow_output(ctx)))
|
|
59
|
+
sys.exit(0)
|
|
60
|
+
except Exception: # noqa: BLE001 — AC-12: fail-open, never block a Read
|
|
61
|
+
sys.exit(0)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run(host, argv=None):
|
|
65
|
+
"""Host-seam entry point (ADR-0011): the __main__ guard calls this with the
|
|
66
|
+
plugin's loaded Host. Wraps main() unchanged — main() still communicates
|
|
67
|
+
via sys.exit/stdout/stderr, and its return value stays discarded exactly
|
|
68
|
+
as the old bare `main()` guard discarded it (so the process still exits 0
|
|
69
|
+
on a normal fall-through).
|
|
70
|
+
|
|
71
|
+
Wires `host` live (#257): primes `_hooklib`'s process-cached Host via
|
|
72
|
+
`set_host()` BEFORE main() runs, so any `get_host()` call downstream
|
|
73
|
+
resolves to the SAME instance the caller passed here — no second
|
|
74
|
+
`hostapi.load_host()`, and `run(fake_host)` genuinely exercises
|
|
75
|
+
`fake_host`."""
|
|
76
|
+
return _entrylib.dispatch(host, argv, main, set_host,
|
|
77
|
+
pass_argv=False, propagate_result=False)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
sys.exit(run(hostapi.load_host()) or 0)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter v2 — PreToolUse(Write) guard. Activation + audit-log + marker + ADR
|
|
3
|
+
# integrity. Python port of pre-write.sh (#25): no jq, fails loud, blocks via
|
|
4
|
+
# exit 2.
|
|
5
|
+
#
|
|
6
|
+
# Guards (all on the arbiter-enabled repo only):
|
|
7
|
+
# H-18 .codearbiter/CONTEXT.md is the activation switch every hook gates on
|
|
8
|
+
# (#159) — a Write may not flip `arbiter: enabled` off or corrupt the
|
|
9
|
+
# frontmatter, which would make every gate dormant.
|
|
10
|
+
# H-19 .codearbiter/.markers/* are the gate-pass tokens that turn a hard-gate
|
|
11
|
+
# BLOCK into an allow (#160) — never writable via the Write tool.
|
|
12
|
+
# H-05 audit logs (overrides.log, triage.log, sprint-log.md, decisions/decision-log.md) are append-only.
|
|
13
|
+
# H-11 ADRs under decisions/ are authored only via /adr.
|
|
14
|
+
# H-22 the protected-state registry (B1/#564): a marker-gated entry admits
|
|
15
|
+
# only under a fresh authoring marker; a helper-only or append-only
|
|
16
|
+
# entry is hard-blocked with NO marker path at all — one generic
|
|
17
|
+
# branch resolves the entry's policy via _protectedstatelib and
|
|
18
|
+
# applies it, so a fourth registered file needs a registry entry,
|
|
19
|
+
# never a new hook branch.
|
|
20
|
+
#
|
|
21
|
+
# Every protected-path decision resolves symlinks (#162): classify_protected()
|
|
22
|
+
# checks the raw path AND its realpath-resolved repo-relative form, so a symlink
|
|
23
|
+
# alias whose visible path lacks `.codearbiter/` can't launder a write past a
|
|
24
|
+
# guard.
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
import traceback
|
|
29
|
+
|
|
30
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
31
|
+
import hostapi # noqa: E402 — host seam (ADR-0011)
|
|
32
|
+
import _entrylib # noqa: E402 — shared run() dispatch (jscpd dedup)
|
|
33
|
+
from _hooklib import ( # noqa: E402
|
|
34
|
+
MARKER_FRESHNESS_MINUTES, arbiter_active, block, classify_protected,
|
|
35
|
+
frontmatter_enabled_text, get_host, marker_fresh, project_root,
|
|
36
|
+
read_input, set_host, utf8_stdio,
|
|
37
|
+
)
|
|
38
|
+
from _protectedstatelib import ( # noqa: E402
|
|
39
|
+
ProtectedPolicy, marker_gated_write_admitted, resolve_registered_path,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _guard_op(root, op):
|
|
44
|
+
"""Run the four protected-path guards against ONE canonical file op
|
|
45
|
+
(hostapi.Host.iter_file_ops). Under Claude Code this receives exactly the
|
|
46
|
+
one "write" op its Write payload maps to, so every check, message, and
|
|
47
|
+
check ORDER below is byte-identical to the pre-seam single-payload body.
|
|
48
|
+
The "edit"/"delete" kinds arrive only from a host whose write tool batches
|
|
49
|
+
per-file operations (Codex's apply_patch, ADR-0011 M2) — a patch hunk's
|
|
50
|
+
resulting content cannot be replayed the way pre-edit.py replays an Edit,
|
|
51
|
+
so those kinds resolve CLOSED on the content-sensitive guards."""
|
|
52
|
+
fpath = op.get("file_path", "") or ""
|
|
53
|
+
kind = op.get("kind", "write")
|
|
54
|
+
|
|
55
|
+
# H-21: an "opaque" op is a host's signal that a write-batching payload
|
|
56
|
+
# carried a recognizable envelope the adapter could NOT decompose into
|
|
57
|
+
# per-file ops (e.g. an apply_patch variant outside the mirrored grammar).
|
|
58
|
+
# Whatever it touches is unknowable here, so it can never pass unguarded —
|
|
59
|
+
# ambiguity resolves CLOSED (ORCHESTRATOR §2). Claude Code never emits
|
|
60
|
+
# this kind; its Write payload always maps to exactly one "write" op.
|
|
61
|
+
if kind == "opaque":
|
|
62
|
+
block("H-21", "This patch envelope could not be decomposed into per-file operations, "
|
|
63
|
+
"so its targets cannot be guarded. Failing closed. Re-issue the change "
|
|
64
|
+
"as a plain patch in the documented apply_patch grammar (or split it "
|
|
65
|
+
"into smaller single-file patches).")
|
|
66
|
+
|
|
67
|
+
classes = classify_protected(fpath, root)
|
|
68
|
+
|
|
69
|
+
# H-19: the gate-pass markers are the mechanism that turns a hard-gate BLOCK
|
|
70
|
+
# into an allow (#160). They are recorded only by the sanctioned producers
|
|
71
|
+
# (security-pass.py / migration-pass.py) and the /adr `touch` — a Write here
|
|
72
|
+
# is a hand-forged marker. Block outright (any kind: write, edit, delete).
|
|
73
|
+
if "marker" in classes:
|
|
74
|
+
block("H-19", "The .codearbiter/.markers/ gate tokens are not writable via the Write "
|
|
75
|
+
"tool (#160) — a hand-written marker forges a security/migration/ADR gate "
|
|
76
|
+
"pass. Markers are recorded only by the sanctioned gate producers.")
|
|
77
|
+
|
|
78
|
+
# H-18: CONTEXT.md is the master switch arbiter_active() reads (#159). A Write
|
|
79
|
+
# is a full overwrite, so vet the RESULTING content: allow only if it still
|
|
80
|
+
# carries a well-formed `arbiter: enabled` frontmatter. Disabling arbiter
|
|
81
|
+
# from inside the repo it governs is exactly the kill-switch this closes (a
|
|
82
|
+
# human can still opt out by editing the file outside the agent's tools).
|
|
83
|
+
if "context" in classes:
|
|
84
|
+
if kind == "write":
|
|
85
|
+
enabled, malformed = frontmatter_enabled_text(op.get("content", "") or "")
|
|
86
|
+
if malformed or not enabled:
|
|
87
|
+
block("H-18", "This Write would remove or alter the `arbiter: enabled` frontmatter in "
|
|
88
|
+
".codearbiter/CONTEXT.md (#159) — the activation switch every enforcement "
|
|
89
|
+
"hook reads. Disabling it from inside the repo would make every gate "
|
|
90
|
+
"dormant. Keep `arbiter: enabled` in a well-formed frontmatter block.")
|
|
91
|
+
else:
|
|
92
|
+
# A patch edit's resulting frontmatter cannot be verified from its
|
|
93
|
+
# hunks, and a delete removes the activation switch outright —
|
|
94
|
+
# ambiguity resolves CLOSED (ORCHESTRATOR §2). /ca:override is the
|
|
95
|
+
# sanctioned escape hatch.
|
|
96
|
+
block("H-18", "This patch operation edits or deletes .codearbiter/CONTEXT.md (#159) — "
|
|
97
|
+
"the activation switch every enforcement hook reads — and its resulting "
|
|
98
|
+
"frontmatter cannot be verified from patch hunks. Failing closed; use the "
|
|
99
|
+
"sanctioned init path (or "
|
|
100
|
+
+ get_host().cmd_ref("override") + ").")
|
|
101
|
+
|
|
102
|
+
# H-05: the audit logs (and the /sprint decision record) are append-only —
|
|
103
|
+
# a Write is a full overwrite. (path set: _hooklib.is_audit_log)
|
|
104
|
+
if "audit" in classes:
|
|
105
|
+
if kind == "write":
|
|
106
|
+
block("H-05", "The .codearbiter audit logs (overrides.log, triage.log, sprint-log.md, decisions/decision-log.md) "
|
|
107
|
+
"are append-only (ORCHESTRATOR §7). Append with Edit or '>>', never Write.")
|
|
108
|
+
# A patch edit is positional (it can rewrite interior lines) and a
|
|
109
|
+
# patch delete destroys the trail — neither can be a verifiable pure
|
|
110
|
+
# append the way pre-edit.py's tail-anchored Edit can.
|
|
111
|
+
block("H-05", "The .codearbiter audit logs (overrides.log, triage.log, sprint-log.md, decisions/decision-log.md) "
|
|
112
|
+
"are append-only (ORCHESTRATOR §7). A patch cannot express a verifiable "
|
|
113
|
+
"pure append; append with '>>' instead.")
|
|
114
|
+
|
|
115
|
+
# H-11: ADRs may only be authored via /adr (the skill drops the marker first).
|
|
116
|
+
# Any .md anywhere under decisions/ is covered — a non-numbered draft or a
|
|
117
|
+
# nested path is still an immutable decision artifact. (set: is_decisions_path)
|
|
118
|
+
if "decisions" in classes:
|
|
119
|
+
if kind == "delete":
|
|
120
|
+
block("H-11", "ADR files under .codearbiter/decisions/ are immutable history "
|
|
121
|
+
"(ORCHESTRATOR §6) — deleting one is prohibited, marker or not.")
|
|
122
|
+
marker = os.path.join(root, ".codearbiter", ".markers", "adr-authoring-active")
|
|
123
|
+
if not os.path.isfile(marker):
|
|
124
|
+
block("H-11", "ADR files are authored only via /adr (ORCHESTRATOR §3) — user "
|
|
125
|
+
"attribution required. Subagent-authored ADRs are prohibited.")
|
|
126
|
+
if not marker_fresh(marker, MARKER_FRESHNESS_MINUTES):
|
|
127
|
+
block("H-11", f"ADR authoring marker is stale (>{MARKER_FRESHNESS_MINUTES} min). Re-run /adr.")
|
|
128
|
+
|
|
129
|
+
# H-22: the protected-state registry (B1/#564) — ONE generic branch,
|
|
130
|
+
# regardless of `kind`. classify_protected already decided "state" is a
|
|
131
|
+
# hit; resolve_registered_path only resolves WHICH registered path
|
|
132
|
+
# matched and WHICH policy it carries — never a second, independent
|
|
133
|
+
# membership check (T-06 design ruling). marker-gated admits only under a
|
|
134
|
+
# fresh authoring marker; helper-only and append-only are
|
|
135
|
+
# flank-IDENTICAL here — both hard-block unconditionally, with NO marker
|
|
136
|
+
# path at all. The policies differ only in what the SANCTIONED HELPER's
|
|
137
|
+
# own file I/O is allowed to do, which this tool-call flank never sees by
|
|
138
|
+
# construction (its argv/content never reaches this guard).
|
|
139
|
+
if "state" in classes:
|
|
140
|
+
rel, policy = resolve_registered_path(fpath, root)
|
|
141
|
+
if kind == "delete":
|
|
142
|
+
# A DELETE is never authoring, so no authoring marker admits it —
|
|
143
|
+
# for any policy, marker-gated included. The marker means "the
|
|
144
|
+
# sanctioned lane is writing a row right now"; reading it as
|
|
145
|
+
# "...and may therefore remove the file entirely" turns a
|
|
146
|
+
# self-mintable, friction-grade token (ADR-0024) into a delete
|
|
147
|
+
# capability over protected state, which is strictly more than
|
|
148
|
+
# the lane it gates can legitimately need.
|
|
149
|
+
block("H-22", f"'{rel}' is protected project state (#564) — deleting it is "
|
|
150
|
+
f"never admitted, including under a fresh authoring marker. "
|
|
151
|
+
f"An authoring lane writes rows; it does not remove the file. "
|
|
152
|
+
+ get_host().cmd_ref("override") + " if this is deliberate.")
|
|
153
|
+
if policy == ProtectedPolicy.MARKER_GATED:
|
|
154
|
+
if not marker_gated_write_admitted(rel, root):
|
|
155
|
+
block("H-22", f"'{rel}' is marker-gated protected project state (#564) — a "
|
|
156
|
+
f"Write is admitted only under a fresh authoring marker. Run "
|
|
157
|
+
f"the sanctioned authoring lane, or "
|
|
158
|
+
+ get_host().cmd_ref("override") + ".")
|
|
159
|
+
else:
|
|
160
|
+
block("H-22", f"'{rel}' is protected project state (#564) — the Write tool may "
|
|
161
|
+
f"never target it; there is no marker path for this policy. Use "
|
|
162
|
+
f"the sanctioned helper.")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _run(root):
|
|
166
|
+
# The host seam (ADR-0011, M2): iter_file_ops maps this host's native
|
|
167
|
+
# payload to canonical per-file ops. Claude's Write payload maps to exactly
|
|
168
|
+
# one "write" op (same fields the pre-seam body read straight off
|
|
169
|
+
# tool_input), so the Claude verdict path is unchanged; Codex's apply_patch
|
|
170
|
+
# envelope fans out to one op per touched file, each hitting the same
|
|
171
|
+
# guards.
|
|
172
|
+
for op in get_host().iter_file_ops(read_input()):
|
|
173
|
+
_guard_op(root, op)
|
|
174
|
+
sys.exit(0)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def main():
|
|
178
|
+
utf8_stdio()
|
|
179
|
+
root = project_root()
|
|
180
|
+
if not arbiter_active(root):
|
|
181
|
+
sys.exit(0)
|
|
182
|
+
# reliability-002 (#189): scoped to arbiter-enabled repos only (above), so a
|
|
183
|
+
# dormant/non-codeArbiter repo can never be bricked by a crash here. An
|
|
184
|
+
# uncaught exception in the scan below must fail CLOSED (exit 2 = BLOCK),
|
|
185
|
+
# not exit 1 — a non-2 exit is a NON-blocking error under the Claude Code
|
|
186
|
+
# hook contract (_hooklib.py:11-15) and would silently ALLOW the Write.
|
|
187
|
+
# read_input()'s documented fail-OPEN parse behavior is unaffected: it
|
|
188
|
+
# catches its own errors and returns {} before this wrapper is reached.
|
|
189
|
+
try:
|
|
190
|
+
_run(root)
|
|
191
|
+
except SystemExit:
|
|
192
|
+
raise
|
|
193
|
+
except Exception: # noqa: BLE001 — the fail-closed backstop of last resort
|
|
194
|
+
traceback.print_exc(file=sys.stderr)
|
|
195
|
+
block("H-00", "pre-write guard crashed while scanning this Write — failing "
|
|
196
|
+
"closed (ORCHESTRATOR §2) rather than silently allowing an "
|
|
197
|
+
"unscanned write. See the traceback above; retry, or report it.")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def run(host, argv=None):
|
|
201
|
+
"""Host-seam entry point (ADR-0011): the __main__ guard calls this with the
|
|
202
|
+
plugin's loaded Host. Wraps main() unchanged — main() still communicates
|
|
203
|
+
via sys.exit/stdout/stderr, and its return value stays discarded exactly
|
|
204
|
+
as the old bare `main()` guard discarded it (so the process still exits 0
|
|
205
|
+
on a normal fall-through).
|
|
206
|
+
|
|
207
|
+
Wires `host` live (#257): primes `_hooklib`'s process-cached Host via
|
|
208
|
+
`set_host()` BEFORE main() runs, so main()'s `get_host()` call resolves
|
|
209
|
+
to the SAME instance the caller passed here — no second
|
|
210
|
+
`hostapi.load_host()`, and `run(fake_host)` genuinely exercises
|
|
211
|
+
`fake_host`."""
|
|
212
|
+
return _entrylib.dispatch(host, argv, main, set_host,
|
|
213
|
+
pass_argv=False, propagate_result=False)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
if __name__ == "__main__":
|
|
217
|
+
sys.exit(run(hostapi.load_host()) or 0)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter — thin entry point for /ca:preview (architecture-006/#179).
|
|
3
|
+
#
|
|
4
|
+
# Wraps _previewlib.collect_diff / scan_secrets and prints the JSON-serialized
|
|
5
|
+
# form command prose needs, so the field-access/serialization step (reaching
|
|
6
|
+
# into ChangedFile.kinds and SecretFinding._asdict()) is import-covered
|
|
7
|
+
# (py_compile + unit tests) instead of living as inline `python -c`
|
|
8
|
+
# multi-statement blocks coupled to the lib's namedtuple internals. Mirrors
|
|
9
|
+
# doctor.py / taskwrite.py: entry point stays thin, all logic lives in
|
|
10
|
+
# _previewlib.
|
|
11
|
+
#
|
|
12
|
+
# Invoked by commands/preview.md as (interpreter resolved once by presence,
|
|
13
|
+
# never a `python3 X || python X` fold -- #577):
|
|
14
|
+
# "$PY" "<plugin>/hooks/preview.py" diff
|
|
15
|
+
# "$PY" "<plugin>/hooks/preview.py" secrets
|
|
16
|
+
#
|
|
17
|
+
# Read-only, mirrors _previewlib's own read-only contract (git rev-parse/diff/
|
|
18
|
+
# ls-files and read-only file opens only): this wrapper writes nothing.
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
26
|
+
import hostapi # noqa: E402 — host seam (ADR-0011)
|
|
27
|
+
import _hooklib # noqa: E402 — set_host DI seam (#257)
|
|
28
|
+
import _entrylib # noqa: E402 — shared run() dispatch (jscpd dedup)
|
|
29
|
+
import _previewlib # noqa: E402
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def diff_json():
|
|
33
|
+
"""JSON-serializable form of collect_diff(): path -> sorted kind list."""
|
|
34
|
+
return {p: sorted(cf.kinds) for p, cf in _previewlib.collect_diff().items()}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def secrets_json():
|
|
38
|
+
"""JSON-serializable form of scan_secrets(): list of finding dicts, each
|
|
39
|
+
already carrying the redacted (never plaintext) snippet."""
|
|
40
|
+
return [f._asdict() for f in _previewlib.scan_secrets()]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def main(argv=None):
|
|
44
|
+
parser = argparse.ArgumentParser(prog="preview", add_help=True)
|
|
45
|
+
parser.add_argument("mode", choices=("diff", "secrets"))
|
|
46
|
+
args = parser.parse_args(argv)
|
|
47
|
+
|
|
48
|
+
result = diff_json() if args.mode == "diff" else secrets_json()
|
|
49
|
+
json.dump(result, sys.stdout)
|
|
50
|
+
sys.stdout.write("\n")
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def run(host, argv=None):
|
|
55
|
+
"""Host-seam entry point (ADR-0011): the __main__ guard calls this with the
|
|
56
|
+
plugin's loaded Host. Delegates to main(argv) and returns its exit code,
|
|
57
|
+
exactly as the old `sys.exit(main())` guard propagated it.
|
|
58
|
+
|
|
59
|
+
Wires `host` live (#257): primes `_hooklib`'s process-cached Host via
|
|
60
|
+
`set_host()` BEFORE main() runs, so any `get_host()` call downstream
|
|
61
|
+
resolves to the SAME instance the caller passed here — no second
|
|
62
|
+
`hostapi.load_host()`, and `run(fake_host)` genuinely exercises
|
|
63
|
+
`fake_host`."""
|
|
64
|
+
return _entrylib.dispatch(host, argv, main, _hooklib.set_host,
|
|
65
|
+
pass_argv=True, propagate_result=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
sys.exit(run(hostapi.load_host()) or 0)
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter — session-transcript pruner entry point.
|
|
3
|
+
#
|
|
4
|
+
# CLI today (Phase 1); a hook mode (UserPromptSubmit/PreCompact) is wired in a
|
|
5
|
+
# later phase. The pruning engine lives in _prunelib.py.
|
|
6
|
+
#
|
|
7
|
+
# prune-transcript.py <path|session-id> [--execute] [--tier T]
|
|
8
|
+
# [--strategies a,b] [--keep-recent N] [--max-bytes N]
|
|
9
|
+
# prune-transcript.py audit <path> # read-only integrity report
|
|
10
|
+
# prune-transcript.py report <path> # size / est-token breakdown only
|
|
11
|
+
#
|
|
12
|
+
# Dry-run is the default. --execute is required to write, and refuses a live
|
|
13
|
+
# transcript (mtime within LIVE_SECS) — the live session is only ever touched
|
|
14
|
+
# by the hook path. Stdlib only.
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import glob
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
24
|
+
import hostapi # noqa: E402 — host seam (ADR-0011)
|
|
25
|
+
from _prunelib import ( # noqa: E402
|
|
26
|
+
Config, audit, est_tokens, hook_run, load_lines,
|
|
27
|
+
)
|
|
28
|
+
# Aliased (ADR-0011): `run(host)` is now this script's host-seam entry point,
|
|
29
|
+
# so the pruning engine's `run` cedes the module-level name here.
|
|
30
|
+
from _prunelib import run as _engine_run # noqa: E402
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def utf8_stdio():
|
|
34
|
+
for s in (sys.stdout, sys.stderr):
|
|
35
|
+
try:
|
|
36
|
+
s.reconfigure(encoding="utf-8", errors="replace")
|
|
37
|
+
except Exception: # noqa: BLE001
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def staleness_check(payload):
|
|
42
|
+
"""(CONFIRM-09) WARN, never block, when an active /dev or /sprint flow's
|
|
43
|
+
marker has sat past the staleness window with no matching audit-log
|
|
44
|
+
activity — see _hooklib.staleness_warning for the detection rule and
|
|
45
|
+
security-controls.md § Audit trail for the accepted-strategy rationale.
|
|
46
|
+
|
|
47
|
+
Only runs when the repo has opted in (arbiter_active). Best-effort: ANY
|
|
48
|
+
failure (missing _hooklib import, a broken root, an unreadable marker)
|
|
49
|
+
degrades to doing nothing — this must never affect prune-transcript.py's
|
|
50
|
+
hook-mode exit code (always 0) or block the user's prompt."""
|
|
51
|
+
try:
|
|
52
|
+
import _hooklib
|
|
53
|
+
# Resolve through the host seam (ADR-0011), not the raw payload cwd —
|
|
54
|
+
# a session whose cwd is a repo subdirectory must still resolve the
|
|
55
|
+
# repo root (CLAUDE_PROJECT_DIR -> payload cwd -> git toplevel ->
|
|
56
|
+
# process cwd), or this WARN silently never fires (#264).
|
|
57
|
+
root = _hooklib.get_host().project_root(payload)
|
|
58
|
+
if not _hooklib.arbiter_active(root):
|
|
59
|
+
return
|
|
60
|
+
for msg in _hooklib.staleness_warning(root):
|
|
61
|
+
_hooklib.warn(msg)
|
|
62
|
+
except Exception: # noqa: BLE001 — a missed warn is acceptable; a crash is not
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def resolve(path_or_id):
|
|
67
|
+
"""Accept a direct path or a bare session id (resolved under ~/.claude)."""
|
|
68
|
+
if os.path.isfile(path_or_id):
|
|
69
|
+
return path_or_id
|
|
70
|
+
hits = glob.glob(os.path.expanduser(
|
|
71
|
+
f"~/.claude/projects/*/{path_or_id}.jsonl"))
|
|
72
|
+
if len(hits) == 1:
|
|
73
|
+
return hits[0]
|
|
74
|
+
if len(hits) > 1:
|
|
75
|
+
sys.exit(f"ambiguous session id (matches {len(hits)} files); pass a path")
|
|
76
|
+
sys.exit(f"no such transcript: {path_or_id}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_live(path, live_secs):
|
|
80
|
+
try:
|
|
81
|
+
return (time.time() - os.path.getmtime(path)) < live_secs
|
|
82
|
+
except OSError:
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _fmt_bytes(n):
|
|
87
|
+
n = float(n)
|
|
88
|
+
for unit in ("B", "K", "M", "G"):
|
|
89
|
+
if abs(n) < 1024 or unit == "G":
|
|
90
|
+
return f"{int(n)}B" if unit == "B" else f"{n:.1f}{unit}"
|
|
91
|
+
n /= 1024
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def print_report(res):
|
|
95
|
+
rows = res["strategies"]
|
|
96
|
+
if rows:
|
|
97
|
+
print(f"{'strategy':<24}{'scope':>11}{'lines':>7}{'before':>12}"
|
|
98
|
+
f"{'after':>12}{'est≈ saved':>12}")
|
|
99
|
+
for name, r in rows.items():
|
|
100
|
+
saved = est_tokens(r["bytes_before"] - r["bytes_after"])
|
|
101
|
+
print(f"{name:<24}{r['metric_scope']:>11}{r['lines']:>7}"
|
|
102
|
+
f"{r['bytes_before']:>12}"
|
|
103
|
+
f"{r['bytes_after']:>12}{saved:>12}")
|
|
104
|
+
print(f"\nfile: {_fmt_bytes(res['bytes_before'])} -> "
|
|
105
|
+
f"{_fmt_bytes(res['bytes_after'])} "
|
|
106
|
+
f"({res['file_pct']:.1f}% smaller, "
|
|
107
|
+
f"{res['file_bytes_freed']} bytes freed)")
|
|
108
|
+
print(f"context: {res['context_est_tokens_freed']} estimated tokens freed")
|
|
109
|
+
if res["validation_errors"]:
|
|
110
|
+
print("VALIDATION FAILED:", file=sys.stderr)
|
|
111
|
+
for e in res["validation_errors"]:
|
|
112
|
+
print(" -", e, file=sys.stderr)
|
|
113
|
+
print(f"verdict: {res['verdict']}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def cmd_audit(path):
|
|
117
|
+
with open(path, "rb") as f:
|
|
118
|
+
data = f.read()
|
|
119
|
+
results = audit(data)
|
|
120
|
+
width = max(len(lvl) for lvl, _ in results)
|
|
121
|
+
for lvl, line in results:
|
|
122
|
+
print(f"{lvl:<{width}} {line}")
|
|
123
|
+
return 1 if any(lvl == "FAIL" for lvl, _ in results) else 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def cmd_report(path):
|
|
127
|
+
with open(path, "rb") as f:
|
|
128
|
+
data = f.read()
|
|
129
|
+
lines = load_lines(data)
|
|
130
|
+
n = sum(1 for ln in lines if ln.obj is not None)
|
|
131
|
+
print(f"{os.path.basename(path)}: {len(data)} bytes, {len(lines)} lines "
|
|
132
|
+
f"({n} JSON), est≈{est_tokens(len(data))} tokens")
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main(argv=None):
|
|
137
|
+
utf8_stdio()
|
|
138
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
139
|
+
|
|
140
|
+
# Serialized-transcript hook mode: invoked with no argv and one hook JSON
|
|
141
|
+
# payload on stdin. Semantic hosts use their native compaction event adapter
|
|
142
|
+
# instead; direct manual execution remains limited to inactive copies.
|
|
143
|
+
if not argv:
|
|
144
|
+
raw = ""
|
|
145
|
+
try:
|
|
146
|
+
if not sys.stdin.isatty():
|
|
147
|
+
raw = sys.stdin.read()
|
|
148
|
+
except Exception: # noqa: BLE001
|
|
149
|
+
raw = ""
|
|
150
|
+
if raw.strip():
|
|
151
|
+
try:
|
|
152
|
+
payload = json.loads(raw)
|
|
153
|
+
except Exception: # noqa: BLE001
|
|
154
|
+
payload = {}
|
|
155
|
+
if isinstance(payload, dict) and payload.get("hook_event_name"):
|
|
156
|
+
# CONFIRM-09 staleness-warn: runs unconditionally on
|
|
157
|
+
# UserPromptSubmit (independent of CODEARBITER_PRUNE, which
|
|
158
|
+
# gates hook_run's own pruning logic below) — a WARN-only
|
|
159
|
+
# check must not depend on an unrelated opt-in env var.
|
|
160
|
+
if payload.get("hook_event_name") == "UserPromptSubmit":
|
|
161
|
+
staleness_check(payload)
|
|
162
|
+
# This codec below rewrites Claude-Code-format transcript
|
|
163
|
+
# JSONL; semantic hosts plan through _prunepolicy without
|
|
164
|
+
# rewriting an active session. The staleness-warn
|
|
165
|
+
# above already ran — it reads the .codearbiter audit logs,
|
|
166
|
+
# not the transcript, so it is host-neutral (ADR-0011).
|
|
167
|
+
import _hooklib
|
|
168
|
+
if not _hooklib.get_host().has_prunable_transcript:
|
|
169
|
+
return 0
|
|
170
|
+
return hook_run(payload)
|
|
171
|
+
print("usage: prune-transcript.py <path|session-id> [--execute] ...",
|
|
172
|
+
file=sys.stderr)
|
|
173
|
+
return 2
|
|
174
|
+
|
|
175
|
+
if argv and argv[0] in ("audit", "report"):
|
|
176
|
+
if len(argv) < 2:
|
|
177
|
+
sys.exit(f"usage: prune-transcript.py {argv[0]} <path>")
|
|
178
|
+
path = resolve(argv[1])
|
|
179
|
+
return cmd_audit(path) if argv[0] == "audit" else cmd_report(path)
|
|
180
|
+
|
|
181
|
+
ap = argparse.ArgumentParser(prog="prune-transcript.py")
|
|
182
|
+
ap.add_argument("target", help="transcript path or session id")
|
|
183
|
+
ap.add_argument("--execute", action="store_true",
|
|
184
|
+
help="write changes (default: dry-run)")
|
|
185
|
+
ap.add_argument("--tier", default=None, choices=["gentle", "standard", "aggressive"])
|
|
186
|
+
ap.add_argument("--strategies", default=None,
|
|
187
|
+
help="explicit comma-separated strategy list (overrides --tier)")
|
|
188
|
+
ap.add_argument("--keep-recent", type=int, default=None)
|
|
189
|
+
ap.add_argument("--max-bytes", type=int, default=None)
|
|
190
|
+
args = ap.parse_args(argv)
|
|
191
|
+
|
|
192
|
+
path = resolve(args.target)
|
|
193
|
+
cfg = Config.from_env()
|
|
194
|
+
if args.tier:
|
|
195
|
+
cfg.tier = args.tier
|
|
196
|
+
if args.strategies:
|
|
197
|
+
cfg.strategies = [s.strip() for s in args.strategies.split(",") if s.strip()]
|
|
198
|
+
if args.keep_recent is not None:
|
|
199
|
+
cfg.keep_recent = args.keep_recent
|
|
200
|
+
if args.max_bytes is not None:
|
|
201
|
+
cfg.max_bytes = args.max_bytes
|
|
202
|
+
cfg.execute = args.execute
|
|
203
|
+
|
|
204
|
+
if cfg.execute and is_live(path, cfg.live_secs):
|
|
205
|
+
sys.exit(f"refusing --execute on a live transcript (modified < {cfg.live_secs}s ago).\n"
|
|
206
|
+
"Run against a copy, or let the hook handle the live session.")
|
|
207
|
+
|
|
208
|
+
session = os.path.splitext(os.path.basename(path))[0]
|
|
209
|
+
res = _engine_run(path, cfg, session=session)
|
|
210
|
+
print_report(res)
|
|
211
|
+
return 0 if not res["validation_errors"] else 1
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def run(host, argv=None):
|
|
215
|
+
"""Host-seam entry point (ADR-0011): the __main__ guard calls this with the
|
|
216
|
+
plugin's loaded Host. Delegates to main(argv) and returns its exit code,
|
|
217
|
+
exactly as the old `sys.exit(main())` guard propagated it.
|
|
218
|
+
|
|
219
|
+
Wires `host` live (#257): primes `_hooklib`'s process-cached Host via
|
|
220
|
+
`set_host()` BEFORE main() runs, so main()'s `_hooklib.get_host()` call
|
|
221
|
+
(hook-mode path) resolves to the SAME instance the caller passed here —
|
|
222
|
+
no second `hostapi.load_host()`, and `run(fake_host)` genuinely exercises
|
|
223
|
+
`fake_host`. Local import mirrors this module's existing lazy `_hooklib`
|
|
224
|
+
import convention (staleness_check) — the CLI (argv) paths never touch
|
|
225
|
+
_hooklib, so it stays off that path's import surface."""
|
|
226
|
+
import _hooklib
|
|
227
|
+
_hooklib.set_host(host)
|
|
228
|
+
return main(argv)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
if __name__ == "__main__":
|
|
232
|
+
sys.exit(run(hostapi.load_host()) or 0)
|