@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.
Files changed (206) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +558 -0
  3. package/package.json +35 -0
  4. package/plugins/ca-pi/CHANGELOG.md +1030 -0
  5. package/plugins/ca-pi/COMMANDS.md +90 -0
  6. package/plugins/ca-pi/ORCHESTRATOR.md +159 -0
  7. package/plugins/ca-pi/SKILLS.md +47 -0
  8. package/plugins/ca-pi/SPRINT.md +142 -0
  9. package/plugins/ca-pi/agents/INDEX.md +31 -0
  10. package/plugins/ca-pi/agents/architecture-drift-reviewer.md +86 -0
  11. package/plugins/ca-pi/agents/auth-crypto-reviewer.md +60 -0
  12. package/plugins/ca-pi/agents/backend-author.md +60 -0
  13. package/plugins/ca-pi/agents/checkpoint-aggregator.md +111 -0
  14. package/plugins/ca-pi/agents/coverage-auditor.md +71 -0
  15. package/plugins/ca-pi/agents/decision-challenger.md +116 -0
  16. package/plugins/ca-pi/agents/dependency-reviewer.md +79 -0
  17. package/plugins/ca-pi/agents/design-quality-reviewer.md +80 -0
  18. package/plugins/ca-pi/agents/finding-triage.md +86 -0
  19. package/plugins/ca-pi/agents/frontend-author.md +64 -0
  20. package/plugins/ca-pi/agents/grader.md +173 -0
  21. package/plugins/ca-pi/agents/infra-author.md +64 -0
  22. package/plugins/ca-pi/agents/map-deps.md +35 -0
  23. package/plugins/ca-pi/agents/map-structure.md +37 -0
  24. package/plugins/ca-pi/agents/migration-reviewer.md +65 -0
  25. package/plugins/ca-pi/agents/scout.md +127 -0
  26. package/plugins/ca-pi/agents/security-reviewer.md +72 -0
  27. package/plugins/ca-pi/agents/tribunal-lens-reviewer.md +65 -0
  28. package/plugins/ca-pi/extensions/codearbiter-child.js +1885 -0
  29. package/plugins/ca-pi/extensions/codearbiter.js +9802 -0
  30. package/plugins/ca-pi/generated/command-catalog.json +197 -0
  31. package/plugins/ca-pi/generated/roles.json +213 -0
  32. package/plugins/ca-pi/helpers/windows-supervisor.js +205 -0
  33. package/plugins/ca-pi/hooks/_activationlib.py +196 -0
  34. package/plugins/ca-pi/hooks/_arbiterstatelib.py +208 -0
  35. package/plugins/ca-pi/hooks/_babysitlib.py +76 -0
  36. package/plugins/ca-pi/hooks/_bashguardlib.py +1667 -0
  37. package/plugins/ca-pi/hooks/_boxlib.py +131 -0
  38. package/plugins/ca-pi/hooks/_colorlib.py +304 -0
  39. package/plugins/ca-pi/hooks/_durabilitylib.py +186 -0
  40. package/plugins/ca-pi/hooks/_entrylib.py +41 -0
  41. package/plugins/ca-pi/hooks/_fmtlib.py +161 -0
  42. package/plugins/ca-pi/hooks/_gitexec.py +45 -0
  43. package/plugins/ca-pi/hooks/_githooks.py +920 -0
  44. package/plugins/ca-pi/hooks/_gitlib.py +110 -0
  45. package/plugins/ca-pi/hooks/_hooklib.py +595 -0
  46. package/plugins/ca-pi/hooks/_host.py +115 -0
  47. package/plugins/ca-pi/hooks/_intentlib.py +242 -0
  48. package/plugins/ca-pi/hooks/_ledgerlib.py +1035 -0
  49. package/plugins/ca-pi/hooks/_metricslib.py +709 -0
  50. package/plugins/ca-pi/hooks/_pathnorm.py +74 -0
  51. package/plugins/ca-pi/hooks/_planfilelib.py +664 -0
  52. package/plugins/ca-pi/hooks/_previewlib.py +193 -0
  53. package/plugins/ca-pi/hooks/_protectedlib.py +312 -0
  54. package/plugins/ca-pi/hooks/_protectedstatelib.py +411 -0
  55. package/plugins/ca-pi/hooks/_provenancelib.py +971 -0
  56. package/plugins/ca-pi/hooks/_prunelib.py +1398 -0
  57. package/plugins/ca-pi/hooks/_prunepolicy.py +235 -0
  58. package/plugins/ca-pi/hooks/_readinjectlib.py +1080 -0
  59. package/plugins/ca-pi/hooks/_releaselib.py +2657 -0
  60. package/plugins/ca-pi/hooks/_scopelib.py +262 -0
  61. package/plugins/ca-pi/hooks/_segmentslib.py +278 -0
  62. package/plugins/ca-pi/hooks/_sensitivelib.py +270 -0
  63. package/plugins/ca-pi/hooks/_sessionlib.py +78 -0
  64. package/plugins/ca-pi/hooks/_sloplib.py +244 -0
  65. package/plugins/ca-pi/hooks/_standuplib.py +214 -0
  66. package/plugins/ca-pi/hooks/_subagentslib.py +219 -0
  67. package/plugins/ca-pi/hooks/_taskboardlib.py +1088 -0
  68. package/plugins/ca-pi/hooks/_updatelib.py +278 -0
  69. package/plugins/ca-pi/hooks/babysit.py +47 -0
  70. package/plugins/ca-pi/hooks/boardsync.py +129 -0
  71. package/plugins/ca-pi/hooks/doctor.py +420 -0
  72. package/plugins/ca-pi/hooks/git-enforce.py +325 -0
  73. package/plugins/ca-pi/hooks/hostapi.py +460 -0
  74. package/plugins/ca-pi/hooks/init-codearbiter.py +225 -0
  75. package/plugins/ca-pi/hooks/metrics.py +62 -0
  76. package/plugins/ca-pi/hooks/migration-pass.py +129 -0
  77. package/plugins/ca-pi/hooks/pi-bridge.py +543 -0
  78. package/plugins/ca-pi/hooks/post-write-edit.py +231 -0
  79. package/plugins/ca-pi/hooks/pre-bash.py +90 -0
  80. package/plugins/ca-pi/hooks/pre-edit.py +284 -0
  81. package/plugins/ca-pi/hooks/pre-read.py +81 -0
  82. package/plugins/ca-pi/hooks/pre-write.py +217 -0
  83. package/plugins/ca-pi/hooks/preview.py +69 -0
  84. package/plugins/ca-pi/hooks/prune-transcript.py +232 -0
  85. package/plugins/ca-pi/hooks/releasehash.py +216 -0
  86. package/plugins/ca-pi/hooks/security-pass.py +139 -0
  87. package/plugins/ca-pi/hooks/session-start.py +1218 -0
  88. package/plugins/ca-pi/hooks/statusline.py +736 -0
  89. package/plugins/ca-pi/hooks/taskwrite.py +351 -0
  90. package/plugins/ca-pi/hooks/update-refresh.py +51 -0
  91. package/plugins/ca-pi/hooks/wire-statusline.py +435 -0
  92. package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +55 -0
  93. package/plugins/ca-pi/includes/anti-slop-design/color.md +43 -0
  94. package/plugins/ca-pi/includes/anti-slop-design/core.md +244 -0
  95. package/plugins/ca-pi/includes/anti-slop-design/images.md +32 -0
  96. package/plugins/ca-pi/includes/anti-slop-design/layout.md +45 -0
  97. package/plugins/ca-pi/includes/anti-slop-design/medium-cli.md +39 -0
  98. package/plugins/ca-pi/includes/anti-slop-design/medium-dataviz.md +43 -0
  99. package/plugins/ca-pi/includes/anti-slop-design/medium-diagram.md +35 -0
  100. package/plugins/ca-pi/includes/anti-slop-design/medium-documents.md +70 -0
  101. package/plugins/ca-pi/includes/anti-slop-design/medium-slides.md +30 -0
  102. package/plugins/ca-pi/includes/anti-slop-design/medium-web.md +39 -0
  103. package/plugins/ca-pi/includes/anti-slop-design/typography.md +51 -0
  104. package/plugins/ca-pi/includes/author-tdd-workflow.md +14 -0
  105. package/plugins/ca-pi/includes/compaction-charter.md +16 -0
  106. package/plugins/ca-pi/includes/cut-docs.md +16 -0
  107. package/plugins/ca-pi/includes/dev-mode.md +30 -0
  108. package/plugins/ca-pi/includes/farm.md +237 -0
  109. package/plugins/ca-pi/includes/fresh-verification.md +14 -0
  110. package/plugins/ca-pi/includes/harvest.md +69 -0
  111. package/plugins/ca-pi/includes/maturity-coverage.md +102 -0
  112. package/plugins/ca-pi/includes/pi-host-notes.md +69 -0
  113. package/plugins/ca-pi/includes/redirect.md +69 -0
  114. package/plugins/ca-pi/includes/reference-map.md +22 -0
  115. package/plugins/ca-pi/includes/review-matrix.md +14 -0
  116. package/plugins/ca-pi/includes/reviewer-contract.md +53 -0
  117. package/plugins/ca-pi/includes/routing-table.md +47 -0
  118. package/plugins/ca-pi/includes/security-gate-record.md +22 -0
  119. package/plugins/ca-pi/includes/smarts/core.md +90 -0
  120. package/plugins/ca-pi/includes/smarts/decision-log-format.md +56 -0
  121. package/plugins/ca-pi/routines/INDEX.md +32 -0
  122. package/plugins/ca-pi/routines/brainstorming/SKILL.md +122 -0
  123. package/plugins/ca-pi/routines/commit-gate/SKILL.md +151 -0
  124. package/plugins/ca-pi/routines/context-check/SKILL.md +85 -0
  125. package/plugins/ca-pi/routines/context-creation/SKILL.md +171 -0
  126. package/plugins/ca-pi/routines/crypto-compliance/SKILL.md +41 -0
  127. package/plugins/ca-pi/routines/debug/SKILL.md +99 -0
  128. package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +104 -0
  129. package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +74 -0
  130. package/plugins/ca-pi/routines/decision-variance/SKILL.md +147 -0
  131. package/plugins/ca-pi/routines/decompose/SKILL.md +168 -0
  132. package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +76 -0
  133. package/plugins/ca-pi/routines/executing-plans/SKILL.md +83 -0
  134. package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +91 -0
  135. package/plugins/ca-pi/routines/post-merge-cleanup/SKILL.md +233 -0
  136. package/plugins/ca-pi/routines/refactor/SKILL.md +91 -0
  137. package/plugins/ca-pi/routines/release/SKILL.md +315 -0
  138. package/plugins/ca-pi/routines/secret-handling/SKILL.md +67 -0
  139. package/plugins/ca-pi/routines/security-architecture/SKILL.md +63 -0
  140. package/plugins/ca-pi/routines/skill-author/SKILL.md +108 -0
  141. package/plugins/ca-pi/routines/skill-author/references/skill-template.md +58 -0
  142. package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +149 -0
  143. package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +145 -0
  144. package/plugins/ca-pi/routines/tdd/SKILL.md +139 -0
  145. package/plugins/ca-pi/routines/tribunal/SKILL.md +109 -0
  146. package/plugins/ca-pi/routines/tribunal/references/ai-markers.md +29 -0
  147. package/plugins/ca-pi/routines/tribunal/references/cost-and-models.md +64 -0
  148. package/plugins/ca-pi/routines/tribunal/references/finding-record.md +27 -0
  149. package/plugins/ca-pi/routines/tribunal/references/issue-filing.md +47 -0
  150. package/plugins/ca-pi/routines/tribunal/references/lenses/appsec.md +22 -0
  151. package/plugins/ca-pi/routines/tribunal/references/lenses/architecture.md +23 -0
  152. package/plugins/ca-pi/routines/tribunal/references/lenses/coverage.md +20 -0
  153. package/plugins/ca-pi/routines/tribunal/references/lenses/infra.md +24 -0
  154. package/plugins/ca-pi/routines/tribunal/references/lenses/migration.md +22 -0
  155. package/plugins/ca-pi/routines/tribunal/references/lenses/observability.md +21 -0
  156. package/plugins/ca-pi/routines/tribunal/references/lenses/performance.md +22 -0
  157. package/plugins/ca-pi/routines/tribunal/references/lenses/reliability.md +23 -0
  158. package/plugins/ca-pi/routines/tribunal/references/lenses/secrets-supply.md +22 -0
  159. package/plugins/ca-pi/routines/tribunal/references/lenses/test-fidelity.md +24 -0
  160. package/plugins/ca-pi/routines/tribunal/references/lenses/typesafety.md +21 -0
  161. package/plugins/ca-pi/routines/tribunal/references/report.md +19 -0
  162. package/plugins/ca-pi/routines/tribunal/references/schemas.md +58 -0
  163. package/plugins/ca-pi/routines/tribunal/references/telemetry.md +28 -0
  164. package/plugins/ca-pi/routines/tribunal/references/triage.md +53 -0
  165. package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +85 -0
  166. package/plugins/ca-pi/routines/writing-plans/SKILL.md +129 -0
  167. package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +50 -0
  168. package/plugins/ca-pi/skills/ca-add-dep/SKILL.md +88 -0
  169. package/plugins/ca-pi/skills/ca-adr/SKILL.md +30 -0
  170. package/plugins/ca-pi/skills/ca-adr-status/SKILL.md +30 -0
  171. package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +36 -0
  172. package/plugins/ca-pi/skills/ca-audit/SKILL.md +51 -0
  173. package/plugins/ca-pi/skills/ca-btw/SKILL.md +23 -0
  174. package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +50 -0
  175. package/plugins/ca-pi/skills/ca-chore/SKILL.md +58 -0
  176. package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +55 -0
  177. package/plugins/ca-pi/skills/ca-commands/SKILL.md +21 -0
  178. package/plugins/ca-pi/skills/ca-commit/SKILL.md +27 -0
  179. package/plugins/ca-pi/skills/ca-conflict/SKILL.md +61 -0
  180. package/plugins/ca-pi/skills/ca-context-check/SKILL.md +32 -0
  181. package/plugins/ca-pi/skills/ca-create-context/SKILL.md +32 -0
  182. package/plugins/ca-pi/skills/ca-debug/SKILL.md +42 -0
  183. package/plugins/ca-pi/skills/ca-decompose/SKILL.md +30 -0
  184. package/plugins/ca-pi/skills/ca-dev/SKILL.md +42 -0
  185. package/plugins/ca-pi/skills/ca-doctor/SKILL.md +44 -0
  186. package/plugins/ca-pi/skills/ca-feature/SKILL.md +105 -0
  187. package/plugins/ca-pi/skills/ca-fix/SKILL.md +42 -0
  188. package/plugins/ca-pi/skills/ca-init/SKILL.md +56 -0
  189. package/plugins/ca-pi/skills/ca-metrics/SKILL.md +80 -0
  190. package/plugins/ca-pi/skills/ca-new-skill/SKILL.md +34 -0
  191. package/plugins/ca-pi/skills/ca-override/SKILL.md +72 -0
  192. package/plugins/ca-pi/skills/ca-pr/SKILL.md +61 -0
  193. package/plugins/ca-pi/skills/ca-preview/SKILL.md +86 -0
  194. package/plugins/ca-pi/skills/ca-prune/SKILL.md +100 -0
  195. package/plugins/ca-pi/skills/ca-reconcile/SKILL.md +43 -0
  196. package/plugins/ca-pi/skills/ca-refactor/SKILL.md +43 -0
  197. package/plugins/ca-pi/skills/ca-release/SKILL.md +57 -0
  198. package/plugins/ca-pi/skills/ca-review/SKILL.md +63 -0
  199. package/plugins/ca-pi/skills/ca-spike/SKILL.md +41 -0
  200. package/plugins/ca-pi/skills/ca-sprint/SKILL.md +44 -0
  201. package/plugins/ca-pi/skills/ca-standup/SKILL.md +112 -0
  202. package/plugins/ca-pi/skills/ca-status/SKILL.md +56 -0
  203. package/plugins/ca-pi/skills/ca-task/SKILL.md +61 -0
  204. package/plugins/ca-pi/skills/ca-threat-model/SKILL.md +45 -0
  205. package/plugins/ca-pi/skills/ca-tribunal/SKILL.md +44 -0
  206. package/plugins/ca-pi/skills/ca-watch/SKILL.md +78 -0
@@ -0,0 +1,1398 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter — session-transcript pruner core (the safe, testable engine).
3
+ #
4
+ # Claude Code session transcripts (~/.claude/projects/<slug>/<session>.jsonl)
5
+ # accumulate clutter — bulky `toolUseResult` sidecars, oversized tool_result
6
+ # bodies, thinking blocks, MCP/shell noise — that shortens how long a session
7
+ # stays usable before compaction. This module trims that clutter while treating
8
+ # transcript integrity as sacred.
9
+ #
10
+ # Two structural guarantees make corruption hard:
11
+ # 1. We never edit bytes. A line is parsed, its object mutated, and ONLY THEN
12
+ # re-serialized. Lines we don't touch are emitted as their original raw
13
+ # bytes — so unknown line types, exotic key order, and odd escapes survive
14
+ # byte-identical. Re-serialization drift can only affect lines we chose to
15
+ # edit.
16
+ # 2. Phases 1-4 are stub-in-place only: every line survives and the
17
+ # uuid/parentUuid chain is never altered. Whole-line deletion (with
18
+ # re-linking) is a separate, gated capability and is NOT in this module yet.
19
+ #
20
+ # Stdlib only (hooks must run on a stock interpreter — see _hooklib.py).
21
+
22
+ import calendar
23
+ import hashlib
24
+ import json
25
+ import math
26
+ import os
27
+ import re
28
+ import shutil
29
+ import sys
30
+ import time
31
+
32
+ import _hooklib
33
+ import _prunepolicy as _policy
34
+
35
+ BOM = b"\xef\xbb\xbf"
36
+ MARKER_PREFIX = _policy.MARKER_PREFIX
37
+
38
+
39
+ def _dumps(o):
40
+ """Compact, UTF-8-preserving serialization used for every line we rewrite."""
41
+ return json.dumps(o, ensure_ascii=False, separators=(",", ":"))
42
+
43
+
44
+ def _marker(orig_text):
45
+ """Self-describing elision marker. The 8-hex sha doubles as the idempotency
46
+ guard: a strategy skips any content already carrying a marker, so
47
+ prune(prune(x)) == prune(x)."""
48
+ return _policy.marker_for(orig_text)
49
+
50
+
51
+ def _has_marker(s):
52
+ return _policy.has_marker(s)
53
+
54
+
55
+ def est_tokens(nbytes):
56
+ """Dependency-free token estimate. Deliberately labeled `est≈` everywhere it
57
+ surfaces — mirrors statusline's honest `api≈` convention."""
58
+ return nbytes // 4
59
+
60
+
61
+ # --------------------------------------------------------------------------- #
62
+ # Line model + load/serialize (the byte-identity backbone)
63
+ # --------------------------------------------------------------------------- #
64
+
65
+ class Line:
66
+ __slots__ = ("idx", "raw", "obj", "dirty", "bom", "fp0")
67
+
68
+ def __init__(self, idx, raw):
69
+ self.idx = idx
70
+ self.bom = raw.startswith(BOM)
71
+ body = raw[len(BOM):] if self.bom else raw
72
+ self.raw = raw
73
+ self.dirty = False
74
+ try:
75
+ self.obj = json.loads(body) if body.strip() else None
76
+ except Exception: # noqa: BLE001 — a malformed line is preserved verbatim
77
+ self.obj = None
78
+ # Original structural fingerprint, captured BEFORE any strategy mutates
79
+ # obj, so validators compare against the true original.
80
+ if isinstance(self.obj, dict):
81
+ self.fp0 = (self.obj.get("type"), self.obj.get("uuid"),
82
+ self.obj.get("parentUuid"))
83
+ else:
84
+ self.fp0 = None
85
+
86
+ def out_bytes(self):
87
+ if not self.dirty:
88
+ return self.raw
89
+ body = _dumps(self.obj).encode("utf-8")
90
+ return (BOM + body) if self.bom else body
91
+
92
+
93
+ def load_lines(data):
94
+ """Split on b'\\n' keeping every part — including a trailing empty part for a
95
+ newline-terminated file and any blank interior lines — so b'\\n'.join of the
96
+ untouched parts reproduces the input exactly."""
97
+ return [Line(i, raw) for i, raw in enumerate(data.split(b"\n"))]
98
+
99
+
100
+ def serialize(lines):
101
+ return b"\n".join(ln.out_bytes() for ln in lines)
102
+
103
+
104
+ # --------------------------------------------------------------------------- #
105
+ # Index + protected tail
106
+ # --------------------------------------------------------------------------- #
107
+
108
+ def _tool_use_ids(o):
109
+ ids = set()
110
+ msg = o.get("message") if isinstance(o, dict) else None
111
+ if isinstance(msg, dict) and isinstance(msg.get("content"), list):
112
+ for b in msg["content"]:
113
+ if isinstance(b, dict) and b.get("type") == "tool_use" and b.get("id"):
114
+ ids.add(b["id"])
115
+ return ids
116
+
117
+
118
+ def _tool_result_ids(o):
119
+ ids = set()
120
+ msg = o.get("message") if isinstance(o, dict) else None
121
+ if isinstance(msg, dict) and isinstance(msg.get("content"), list):
122
+ for b in msg["content"]:
123
+ if isinstance(b, dict) and b.get("type") == "tool_result" and b.get("tool_use_id"):
124
+ ids.add(b["tool_use_id"])
125
+ return ids
126
+
127
+
128
+ class Index:
129
+ __slots__ = ("protected_from", "last_assistant_idx", "tu_ids", "tr_ids",
130
+ "edited_paths", "edited_at", "tool_meta")
131
+
132
+
133
+ def build_index(lines, cfg):
134
+ """Compute the protected tail and pre-strategy tool-id sets. The protected
135
+ tail is everything at/after the earlier of (a) the start of the K-th most
136
+ recent tool-bearing TURN and (b) the last assistant message (whose thinking
137
+ signature must never be touched).
138
+
139
+ keep_recent counts TURNS, not lines: a turn is anchored by its assistant
140
+ tool_use line, and protecting that line's index also protects the
141
+ tool_result lines that follow it. (Counting result lines too would silently
142
+ halve the protection an operator asked for via KEEP_RECENT.)"""
143
+ idx = Index()
144
+ last_assistant = -1
145
+ tu, tr = set(), set()
146
+ edited = set()
147
+ edited_at = {} # file_path -> highest line idx that Wrote/Edited it
148
+ tool_meta = {} # tool_use id -> {"name", "path", "idx"}
149
+ for ln in lines:
150
+ o = ln.obj
151
+ if not isinstance(o, dict):
152
+ continue
153
+ if o.get("type") == "assistant":
154
+ last_assistant = ln.idx
155
+ a, b = _tool_use_ids(o), _tool_result_ids(o)
156
+ tu |= a
157
+ tr |= b
158
+ msg = o.get("message")
159
+ if isinstance(msg, dict) and isinstance(msg.get("content"), list):
160
+ for blk in msg["content"]:
161
+ if not isinstance(blk, dict) or blk.get("type") != "tool_use":
162
+ continue
163
+ name = blk.get("name")
164
+ path = (blk.get("input") or {}).get("file_path")
165
+ if blk.get("id"):
166
+ tool_meta[blk["id"]] = {"name": name, "path": path, "idx": ln.idx}
167
+ # Track files a later Write/Edit superseded (Phase-3 strategy).
168
+ if name in ("Write", "Edit") and path:
169
+ edited.add(path)
170
+ edited_at[path] = max(edited_at.get(path, -1), ln.idx)
171
+ semantic = []
172
+ for ln in lines:
173
+ o = ln.obj if isinstance(ln.obj, dict) else {}
174
+ semantic.append(_policy.SemanticEntry(
175
+ id=str(ln.idx), ordinal=ln.idx, role=str(o.get("type", "other")),
176
+ kind=("tool-result" if _tool_result_ids(o) else "message"),
177
+ byte_size=len(ln.raw), tool_bearing=bool(_tool_use_ids(o)),
178
+ marked=_has_marker(_dumps(o)) if o else False,
179
+ ))
180
+ prot = _policy.protected_ordinal(semantic, cfg.keep_recent)
181
+ idx.protected_from = prot
182
+ idx.last_assistant_idx = last_assistant
183
+ idx.tu_ids = tu
184
+ idx.tr_ids = tr
185
+ idx.edited_paths = edited
186
+ idx.edited_at = edited_at
187
+ idx.tool_meta = tool_meta
188
+ return idx
189
+
190
+
191
+ def _is_small_scalar(v, limit=200):
192
+ if isinstance(v, bool) or v is None or isinstance(v, (int, float)):
193
+ return True
194
+ if isinstance(v, str):
195
+ return len(v) <= limit
196
+ return False
197
+
198
+
199
+ # --------------------------------------------------------------------------- #
200
+ # Strategies (Phase 1). Each mutates obj, sets dirty, records a report row.
201
+ # All obey the net-negative guard: a strategy is a no-op on any unit whose
202
+ # replacement would not be strictly smaller than the original.
203
+ # --------------------------------------------------------------------------- #
204
+
205
+ def _record(report, name, touched, before, after):
206
+ report[name] = {
207
+ "lines": touched,
208
+ "bytes_before": before,
209
+ "bytes_after": after,
210
+ "metric_scope": _policy.STRATEGY_METRIC_SCOPES[name],
211
+ }
212
+
213
+
214
+ def s_sidecar_collapse(lines, index, cfg, report):
215
+ """Replace the bulky top-level `toolUseResult` sidecar with a marker plus the
216
+ small scalar fields worth keeping (status, exit codes, agentId, paths)."""
217
+ touched = before = after = 0
218
+ for ln in lines:
219
+ if ln.idx >= index.protected_from or not isinstance(ln.obj, dict):
220
+ continue
221
+ tur = ln.obj.get("toolUseResult")
222
+ if not isinstance(tur, (dict, list, str)):
223
+ continue
224
+ orig = _dumps(tur)
225
+ if _has_marker(orig):
226
+ continue
227
+ kept = {}
228
+ if isinstance(tur, dict):
229
+ kept = {k: v for k, v in tur.items() if _is_small_scalar(v)}
230
+ new = dict(kept)
231
+ new["_ca_condensed"] = _marker(orig)
232
+ new_s = _dumps(new)
233
+ if len(new_s) >= len(orig): # preserve legacy transformation eligibility
234
+ continue
235
+ orig_size = len(orig.encode("utf-8"))
236
+ new_size = len(new_s.encode("utf-8"))
237
+ ln.obj["toolUseResult"] = new
238
+ ln.dirty = True
239
+ touched += 1
240
+ before += orig_size
241
+ after += new_size
242
+ _record(report, "sidecar-collapse", touched, before, after)
243
+
244
+
245
+ def _clamp_text(s, max_bytes, max_lines):
246
+ """Return (clamped_or_None). None means leave as-is (under threshold)."""
247
+ b = s.encode("utf-8")
248
+ if len(b) <= max_bytes and s.count("\n") < max_lines:
249
+ return None
250
+ head = b[:max_bytes].decode("utf-8", "ignore")
251
+ clamped = head + "\n" + _marker(s)
252
+ if len(clamped.encode("utf-8")) >= len(b): # net-negative guard
253
+ return None
254
+ return clamped
255
+
256
+
257
+ def s_oversize_result_clamp(lines, index, cfg, report):
258
+ """Truncate tool_result bodies over MAXBYTES or >100 lines to a head + marker.
259
+ Handles both the string form and the list-of-{type:text} form."""
260
+ touched = before = after = 0
261
+ mb, ml = cfg.max_bytes, 100
262
+ for ln in lines:
263
+ if ln.idx >= index.protected_from or not isinstance(ln.obj, dict):
264
+ continue
265
+ msg = ln.obj.get("message")
266
+ if not isinstance(msg, dict) or not isinstance(msg.get("content"), list):
267
+ continue
268
+ changed = False
269
+ for blk in msg["content"]:
270
+ if not isinstance(blk, dict) or blk.get("type") != "tool_result":
271
+ continue
272
+ c = blk.get("content")
273
+ if isinstance(c, str):
274
+ if _has_marker(c):
275
+ continue
276
+ clamped = _clamp_text(c, mb, ml)
277
+ if clamped is not None:
278
+ before += len(c.encode("utf-8"))
279
+ after += len(clamped.encode("utf-8"))
280
+ blk["content"] = clamped
281
+ changed = True
282
+ elif isinstance(c, list):
283
+ for tb in c:
284
+ if not isinstance(tb, dict) or tb.get("type") != "text":
285
+ continue
286
+ txt = tb.get("text")
287
+ if not isinstance(txt, str) or _has_marker(txt):
288
+ continue
289
+ clamped = _clamp_text(txt, mb, ml)
290
+ if clamped is not None:
291
+ before += len(txt.encode("utf-8"))
292
+ after += len(clamped.encode("utf-8"))
293
+ tb["text"] = clamped
294
+ changed = True
295
+ if changed:
296
+ ln.dirty = True
297
+ touched += 1
298
+ _record(report, "oversize-result-clamp", touched, before, after)
299
+
300
+
301
+ def _content_list(o):
302
+ msg = o.get("message")
303
+ if isinstance(msg, dict) and isinstance(msg.get("content"), list):
304
+ return msg["content"]
305
+ return None
306
+
307
+
308
+ def _clamp_in_block_content(c, max_bytes, max_lines):
309
+ """Clamp a tool_result `content` (str or list-of-{text}) in place. Returns
310
+ (changed, bytes_before, bytes_after)."""
311
+ before = after = 0
312
+ changed = False
313
+ if isinstance(c, str):
314
+ if not _has_marker(c):
315
+ clamped = _clamp_text(c, max_bytes, max_lines)
316
+ if clamped is not None:
317
+ before += len(c.encode("utf-8"))
318
+ after += len(clamped.encode("utf-8"))
319
+ return True, before, after, clamped
320
+ return False, 0, 0, c
321
+ if isinstance(c, list):
322
+ for tb in c:
323
+ if not isinstance(tb, dict) or tb.get("type") != "text":
324
+ continue
325
+ txt = tb.get("text")
326
+ if not isinstance(txt, str) or _has_marker(txt):
327
+ continue
328
+ clamped = _clamp_text(txt, max_bytes, max_lines)
329
+ if clamped is not None:
330
+ before += len(txt.encode("utf-8"))
331
+ after += len(clamped.encode("utf-8"))
332
+ tb["text"] = clamped
333
+ changed = True
334
+ return changed, before, after, c
335
+
336
+
337
+ def s_reasoning_fold(lines, index, cfg, report):
338
+ """Drop thinking blocks from older assistant turns. We REMOVE the block
339
+ rather than leave an empty-signature stub: a thinking block with an invalid
340
+ signature can be rejected on resume, whereas an absent one never is. The
341
+ most recent assistant turn is always inside the protected tail."""
342
+ touched = before = 0
343
+ for ln in lines:
344
+ if ln.idx >= index.protected_from or not isinstance(ln.obj, dict):
345
+ continue
346
+ if ln.obj.get("type") != "assistant":
347
+ continue
348
+ content = _content_list(ln.obj)
349
+ if not content:
350
+ continue
351
+ thinking = [b for b in content
352
+ if isinstance(b, dict) and b.get("type") == "thinking"]
353
+ if not thinking:
354
+ continue
355
+ keep = [b for b in content if b not in thinking]
356
+ if not keep: # never empty a message
357
+ continue
358
+ for b in thinking:
359
+ before += len(_dumps(b).encode("utf-8"))
360
+ ln.obj["message"]["content"] = keep
361
+ ln.dirty = True
362
+ touched += 1
363
+ _record(report, "reasoning-fold", touched, before, 0)
364
+
365
+
366
+ def s_aged_result_condense(lines, index, cfg, report):
367
+ """Condense any remaining (unmarked) older tool_result body to a small head
368
+ + marker — harder than the gentle clamp, for results past the protected
369
+ tail. Specific handlers (shell/superseded) run earlier and mark their own."""
370
+ touched = before = after = 0
371
+ for ln in lines:
372
+ if ln.idx >= index.protected_from or not isinstance(ln.obj, dict):
373
+ continue
374
+ content = _content_list(ln.obj)
375
+ if not content:
376
+ continue
377
+ changed = False
378
+ for blk in content:
379
+ if not isinstance(blk, dict) or blk.get("type") != "tool_result":
380
+ continue
381
+ ch, b, a, newc = _clamp_in_block_content(blk.get("content"), 200, 10 ** 9)
382
+ if ch:
383
+ blk["content"] = newc
384
+ before += b
385
+ after += a
386
+ changed = True
387
+ if changed:
388
+ ln.dirty = True
389
+ touched += 1
390
+ _record(report, "aged-result-condense", touched, before, after)
391
+
392
+
393
+ def s_mcp_payload_condense(lines, index, cfg, report):
394
+ """Condense the bulky `input` of mcp__ tool_use blocks (older turns)."""
395
+ touched = before = after = 0
396
+ for ln in lines:
397
+ if ln.idx >= index.protected_from or not isinstance(ln.obj, dict):
398
+ continue
399
+ content = _content_list(ln.obj)
400
+ if not content:
401
+ continue
402
+ changed = False
403
+ for blk in content:
404
+ if (not isinstance(blk, dict) or blk.get("type") != "tool_use"
405
+ or not str(blk.get("name", "")).startswith("mcp__")):
406
+ continue
407
+ inp = blk.get("input")
408
+ if not isinstance(inp, (dict, list, str)):
409
+ continue
410
+ orig = _dumps(inp)
411
+ if _has_marker(orig):
412
+ continue
413
+ kept = ({k: v for k, v in inp.items() if _is_small_scalar(v)}
414
+ if isinstance(inp, dict) else {})
415
+ new = dict(kept)
416
+ new["_ca_condensed"] = _marker(orig)
417
+ new_s = _dumps(new)
418
+ if len(new_s) >= len(orig): # preserve legacy transformation eligibility
419
+ continue
420
+ orig_size = len(orig.encode("utf-8"))
421
+ new_size = len(new_s.encode("utf-8"))
422
+ blk["input"] = new
423
+ before += orig_size
424
+ after += new_size
425
+ changed = True
426
+ if changed:
427
+ ln.dirty = True
428
+ touched += 1
429
+ _record(report, "mcp-payload-condense", touched, before, after)
430
+
431
+
432
+ def s_shell_tail_keep(lines, index, cfg, report):
433
+ """For Bash/PowerShell results, keep only the last N lines (the tail carries
434
+ the exit verdict). Claims its targets before the generic condenser."""
435
+ keep_lines = 30
436
+ touched = before = after = 0
437
+ for ln in lines:
438
+ if ln.idx >= index.protected_from or not isinstance(ln.obj, dict):
439
+ continue
440
+ content = _content_list(ln.obj)
441
+ if not content:
442
+ continue
443
+ changed = False
444
+ for blk in content:
445
+ if not isinstance(blk, dict) or blk.get("type") != "tool_result":
446
+ continue
447
+ meta = index.tool_meta.get(blk.get("tool_use_id"))
448
+ if not meta or meta.get("name") not in ("Bash", "PowerShell", "Shell"):
449
+ continue
450
+
451
+ def tail(s):
452
+ if _has_marker(s):
453
+ return None
454
+ parts = s.split("\n")
455
+ if len(parts) <= keep_lines:
456
+ return None
457
+ new = _marker(s) + "\n" + "\n".join(parts[-keep_lines:])
458
+ if len(new.encode("utf-8")) >= len(s.encode("utf-8")):
459
+ return None
460
+ return new
461
+ c = blk.get("content")
462
+ if isinstance(c, str):
463
+ nt = tail(c)
464
+ if nt is not None:
465
+ before += len(c.encode("utf-8"))
466
+ after += len(nt.encode("utf-8"))
467
+ blk["content"] = nt
468
+ changed = True
469
+ elif isinstance(c, list):
470
+ for tb in c:
471
+ if isinstance(tb, dict) and tb.get("type") == "text" \
472
+ and isinstance(tb.get("text"), str):
473
+ nt = tail(tb["text"])
474
+ if nt is not None:
475
+ before += len(tb["text"].encode("utf-8"))
476
+ after += len(nt.encode("utf-8"))
477
+ tb["text"] = nt
478
+ changed = True
479
+ if changed:
480
+ ln.dirty = True
481
+ touched += 1
482
+ _record(report, "shell-tail-keep", touched, before, after)
483
+
484
+
485
+ def s_superseded_read_condense(lines, index, cfg, report):
486
+ """Condense a Read result whose file was Written/Edited later in the
487
+ transcript — that snapshot is stale; the later edit is the source of truth."""
488
+ touched = before = after = 0
489
+ for ln in lines:
490
+ if ln.idx >= index.protected_from or not isinstance(ln.obj, dict):
491
+ continue
492
+ content = _content_list(ln.obj)
493
+ if not content:
494
+ continue
495
+ changed = False
496
+ for blk in content:
497
+ if not isinstance(blk, dict) or blk.get("type") != "tool_result":
498
+ continue
499
+ meta = index.tool_meta.get(blk.get("tool_use_id"))
500
+ if not meta or meta.get("name") != "Read":
501
+ continue
502
+ path = meta.get("path")
503
+ if not path or index.edited_at.get(path, -1) <= meta.get("idx", -1):
504
+ continue
505
+ ch, b, a, newc = _clamp_in_block_content(blk.get("content"), 80, 10 ** 9)
506
+ if ch:
507
+ blk["content"] = newc
508
+ before += b
509
+ after += a
510
+ changed = True
511
+ if changed:
512
+ ln.dirty = True
513
+ touched += 1
514
+ _record(report, "superseded-read-condense", touched, before, after)
515
+
516
+
517
+ def s_repeat_reminder_fold(lines, index, cfg, report):
518
+ """Dedup repeated identical <system-reminder> text blocks — keep the first,
519
+ fold later copies to a marker."""
520
+ seen = set()
521
+ touched = before = after = 0
522
+ for ln in lines:
523
+ if not isinstance(ln.obj, dict):
524
+ continue
525
+ content = _content_list(ln.obj)
526
+ if not content:
527
+ continue
528
+ changed = False
529
+ for blk in content:
530
+ if not isinstance(blk, dict) or blk.get("type") != "text":
531
+ continue
532
+ txt = blk.get("text")
533
+ if not isinstance(txt, str) or "<system-reminder>" not in txt or _has_marker(txt):
534
+ continue
535
+ key = hashlib.sha256(txt.encode("utf-8")).hexdigest()
536
+ if key not in seen:
537
+ seen.add(key)
538
+ continue
539
+ # A later duplicate: fold it (only past the protected tail).
540
+ if ln.idx >= index.protected_from:
541
+ continue
542
+ marker = _marker(txt)
543
+ if len(marker.encode("utf-8")) >= len(txt.encode("utf-8")):
544
+ continue
545
+ before += len(txt.encode("utf-8"))
546
+ after += len(marker.encode("utf-8"))
547
+ blk["text"] = marker
548
+ changed = True
549
+ if changed:
550
+ ln.dirty = True
551
+ touched += 1
552
+ _record(report, "repeat-reminder-fold", touched, before, after)
553
+
554
+
555
+ def s_inline_image_evict(lines, index, cfg, report):
556
+ """Replace base64 image payloads (older turns) with a marker."""
557
+ touched = before = after = 0
558
+
559
+ def walk(blocks):
560
+ nonlocal before, after
561
+ changed = False
562
+ for blk in blocks:
563
+ if not isinstance(blk, dict):
564
+ continue
565
+ if blk.get("type") == "image" and isinstance(blk.get("source"), dict):
566
+ src = blk["source"]
567
+ data = src.get("data")
568
+ if isinstance(data, str) and not _has_marker(data) and len(data) > 64:
569
+ before += len(data)
570
+ src["data"] = _marker(data)
571
+ after += len(src["data"])
572
+ changed = True
573
+ # tool_result content may itself be a list with image blocks
574
+ if isinstance(blk.get("content"), list):
575
+ if walk(blk["content"]):
576
+ changed = True
577
+ return changed
578
+ for ln in lines:
579
+ if ln.idx >= index.protected_from or not isinstance(ln.obj, dict):
580
+ continue
581
+ content = _content_list(ln.obj)
582
+ if not content:
583
+ continue
584
+ if walk(content):
585
+ ln.dirty = True
586
+ touched += 1
587
+ _record(report, "inline-image-evict", touched, before, after)
588
+
589
+
590
+ # name -> (tier, function). The pinned order matters: specific result handlers
591
+ # (shell tail, superseded read) and payload condensers claim and mark their
592
+ # targets BEFORE the generic aged/oversize condensers run, so each result is
593
+ # trimmed by the most appropriate strategy exactly once (the marker enforces
594
+ # single-processing). Selection preserves this order.
595
+ TIERS = _policy.TIERS
596
+ STRATEGIES = {
597
+ "sidecar-collapse": ("gentle", s_sidecar_collapse),
598
+ "reasoning-fold": ("standard", s_reasoning_fold),
599
+ "mcp-payload-condense": ("standard", s_mcp_payload_condense),
600
+ "shell-tail-keep": ("standard", s_shell_tail_keep),
601
+ "superseded-read-condense": ("aggressive", s_superseded_read_condense),
602
+ "repeat-reminder-fold": ("aggressive", s_repeat_reminder_fold),
603
+ "inline-image-evict": ("aggressive", s_inline_image_evict),
604
+ "aged-result-condense": ("standard", s_aged_result_condense),
605
+ "oversize-result-clamp": ("gentle", s_oversize_result_clamp),
606
+ }
607
+ STRATEGY_ORDER = list(_policy.STRATEGY_ORDER)
608
+
609
+
610
+ def selected_strategies(cfg):
611
+ return list(_policy.select_strategies(cfg.tier, cfg.strategies))
612
+
613
+
614
+ # --------------------------------------------------------------------------- #
615
+ # Config
616
+ # --------------------------------------------------------------------------- #
617
+
618
+ class Config:
619
+ def __init__(self, tier="gentle", strategies=None, max_bytes=8192,
620
+ keep_recent=10, min_size=1 << 20, min_growth=1 << 18,
621
+ backups=3, live_secs=90, execute=False):
622
+ self.tier = tier
623
+ self.strategies = strategies
624
+ self.max_bytes = max_bytes
625
+ self.keep_recent = keep_recent
626
+ self.min_size = min_size
627
+ self.min_growth = min_growth
628
+ self.backups = backups
629
+ self.live_secs = live_secs
630
+ self.execute = execute
631
+
632
+ @classmethod
633
+ def from_env(cls, env=None, **over):
634
+ e = env if env is not None else os.environ
635
+
636
+ def num(key, default):
637
+ try:
638
+ return int(e[key])
639
+ except Exception: # noqa: BLE001
640
+ return default
641
+ cfg = cls(
642
+ tier=e.get("CODEARBITER_PRUNE_TIER", "gentle"),
643
+ strategies=([s.strip() for s in e["CODEARBITER_PRUNE_STRATEGIES"].split(",") if s.strip()]
644
+ if e.get("CODEARBITER_PRUNE_STRATEGIES") else None),
645
+ max_bytes=num("CODEARBITER_PRUNE_MAXBYTES", 8192),
646
+ keep_recent=num("CODEARBITER_PRUNE_KEEP_RECENT", 10),
647
+ min_size=num("CODEARBITER_PRUNE_MIN_SIZE", 1 << 20),
648
+ min_growth=num("CODEARBITER_PRUNE_MIN_GROWTH", 1 << 18),
649
+ backups=num("CODEARBITER_PRUNE_BACKUPS", 3),
650
+ live_secs=num("CODEARBITER_PRUNE_LIVE_SECS", 90),
651
+ )
652
+ for k, v in over.items():
653
+ setattr(cfg, k, v)
654
+ return cfg
655
+
656
+
657
+ # --------------------------------------------------------------------------- #
658
+ # Pipeline + validation
659
+ # --------------------------------------------------------------------------- #
660
+
661
+ def apply_strategies(lines, index, cfg):
662
+ report = {}
663
+ for name in selected_strategies(cfg):
664
+ STRATEGIES[name][1](lines, index, cfg, report)
665
+ return report
666
+
667
+
668
+ def _parts_objs(data):
669
+ """Parse each line; returns list where each item is dict/None, or the tuple
670
+ ('<<bad>>', err) for an unparseable non-blank line."""
671
+ out = []
672
+ for p in data.split(b"\n"):
673
+ body = p[len(BOM):] if p.startswith(BOM) else p
674
+ if not body.strip():
675
+ out.append(None)
676
+ continue
677
+ try:
678
+ out.append(json.loads(body))
679
+ except Exception as e: # noqa: BLE001
680
+ out.append(("<<bad>>", str(e)))
681
+ return out
682
+
683
+
684
+ def _orphans(objs):
685
+ uuids = {o.get("uuid") for o in objs if isinstance(o, dict) and o.get("uuid")}
686
+ bad = set()
687
+ for o in objs:
688
+ if isinstance(o, dict):
689
+ p = o.get("parentUuid")
690
+ if p is not None and p not in uuids:
691
+ bad.add(p)
692
+ return bad
693
+
694
+
695
+ def validate(orig_bytes, new_bytes, lines, cfg, phase5=False):
696
+ """Run the full validator battery. Returns a list of error strings (empty ==
697
+ safe to write). Used both pre-write (in-memory result) and post-write (a
698
+ fresh read of what actually landed on disk)."""
699
+ errs = []
700
+ old_parts = orig_bytes.split(b"\n")
701
+ new_parts = new_bytes.split(b"\n")
702
+
703
+ # v_shrink
704
+ if len(new_bytes) > len(orig_bytes):
705
+ errs.append("v_shrink: output is larger than input")
706
+
707
+ # v_linecount (hard invariant for stub-in-place phases)
708
+ if not phase5 and len(old_parts) != len(new_parts):
709
+ errs.append(f"v_linecount: {len(old_parts)} -> {len(new_parts)} lines")
710
+ return errs # per-line comparisons below would be meaningless
711
+
712
+ new_objs = _parts_objs(new_bytes)
713
+
714
+ # v_parse
715
+ for i, o in enumerate(new_objs):
716
+ if isinstance(o, tuple):
717
+ errs.append(f"v_parse: line {i} unparseable: {o[1]}")
718
+
719
+ # v_identity (byte-identity of untouched lines; structural stability of edits)
720
+ if not phase5:
721
+ for i, ln in enumerate(lines):
722
+ if not ln.dirty:
723
+ if new_parts[i] != ln.raw:
724
+ errs.append(f"v_identity: untouched line {i} changed")
725
+ else:
726
+ no = new_objs[i]
727
+ if isinstance(no, dict) and ln.fp0 is not None:
728
+ fp1 = (no.get("type"), no.get("uuid"), no.get("parentUuid"))
729
+ if fp1 != ln.fp0:
730
+ errs.append(f"v_identity: edited line {i} changed type/uuid/parent")
731
+
732
+ # v_pairs (tool_use / tool_result id sets unchanged)
733
+ new_tu, new_tr = set(), set()
734
+ for o in new_objs:
735
+ if isinstance(o, dict):
736
+ new_tu |= _tool_use_ids(o)
737
+ new_tr |= _tool_result_ids(o)
738
+ old_objs = _parts_objs(orig_bytes)
739
+ old_tu, old_tr = set(), set()
740
+ for o in old_objs:
741
+ if isinstance(o, dict):
742
+ old_tu |= _tool_use_ids(o)
743
+ old_tr |= _tool_result_ids(o)
744
+ if new_tu != old_tu:
745
+ errs.append("v_pairs: tool_use id set changed")
746
+ if new_tr != old_tr:
747
+ errs.append("v_pairs: tool_result id set changed")
748
+
749
+ # v_chain (introduce no NEW parentUuid orphans)
750
+ new_orphans = _orphans(new_objs) - _orphans(old_objs)
751
+ if new_orphans:
752
+ errs.append(f"v_chain: new orphaned parentUuid(s): {sorted(new_orphans)}")
753
+
754
+ return errs
755
+
756
+
757
+ # --------------------------------------------------------------------------- #
758
+ # Audit / integrity report (the read-only `audit` subcommand)
759
+ # --------------------------------------------------------------------------- #
760
+
761
+ def audit(data):
762
+ """Read-only integrity checks on any transcript (touched or not). Returns a
763
+ list of (level, message) — OK / WARN / FAIL, in doctor.py's style."""
764
+ out = []
765
+ objs = _parts_objs(data)
766
+ bad = [i for i, o in enumerate(objs) if isinstance(o, tuple)]
767
+ orph = _orphans(objs)
768
+ tu = set()
769
+ tr = set()
770
+ for o in objs:
771
+ if isinstance(o, dict):
772
+ tu |= _tool_use_ids(o)
773
+ tr |= _tool_result_ids(o)
774
+ unpaired = tr - tu
775
+ levels = _policy.audit_outcomes(len(bad), len(orph), len(unpaired))
776
+ if bad:
777
+ out.append((levels[0], f"{len(bad)} unparseable line(s): {bad[:10]}"))
778
+ else:
779
+ out.append((levels[0], f"all {sum(1 for o in objs if o is not None)} non-blank lines parse"))
780
+ out.append((levels[1],
781
+ f"{len(orph)} orphaned parentUuid(s)" if orph else "uuid/parentUuid chain intact"))
782
+ out.append((levels[2],
783
+ f"{len(unpaired)} tool_result(s) with no tool_use" if unpaired
784
+ else f"{len(tu)} tool_use / {len(tr)} tool_result ids paired"))
785
+ marked = sum(1 for o in objs if MARKER_PREFIX in _dumps(o)) if objs else 0
786
+ out.append((levels[3], f"{marked} line(s) carry ca-condensed markers"))
787
+ return out
788
+
789
+
790
+ # --------------------------------------------------------------------------- #
791
+ # Write protocol (live-race-safe; see plan "Biggest risk" §3-§5)
792
+ # --------------------------------------------------------------------------- #
793
+
794
+ def backup_dir():
795
+ return os.path.join(os.path.expanduser("~"), ".codearbiter", "prune-backups")
796
+
797
+
798
+ def _safe_session(s):
799
+ """Reduce a session id to a safe single-path-component filename. The hook
800
+ payload's `session_id` is external input that feeds the backup filename and a
801
+ glob prefix in _prune_old_backups; an unsanitized value containing `..` or a
802
+ path separator could escape the backup dir. Normally a UUID, but never trust
803
+ it."""
804
+ s = str(s) if s is not None else "session"
805
+ s = re.sub(r"[^A-Za-z0-9._-]", "_", s).strip(".")
806
+ return (s or "session")[:128]
807
+
808
+
809
+ def _prune_old_backups(session, keep):
810
+ d = backup_dir()
811
+ try:
812
+ entries = sorted(f for f in os.listdir(d) if f.startswith(session + "."))
813
+ except Exception: # noqa: BLE001
814
+ return
815
+ for f in entries[:-keep] if keep > 0 else entries:
816
+ try:
817
+ os.remove(os.path.join(d, f))
818
+ except Exception: # noqa: BLE001
819
+ pass
820
+
821
+
822
+ def self_heal(path, session="session"):
823
+ """Detect and repair the mid-write crash corpse, before any new prune.
824
+
825
+ write_in_place writes new_bytes (shorter) over the original and THEN
826
+ truncates. A process death between the two leaves the file as
827
+ new_bytes + orig[len(new_bytes):] — the boundary lands inside an old line,
828
+ so exactly one line is unparseable JSON. The newest backup for the session
829
+ holds the original; restore it, preserving any lines a live appender added
830
+ after the crash (they sit beyond len(backup)).
831
+
832
+ Conservative by design: heals ONLY when the damage matches that splice
833
+ signature — one bad line, file at least backup-sized, and the bytes from
834
+ the end of the bad line up to len(backup) identical to the backup's. Any
835
+ other corruption is left alone for a human. Returns (healed, note).
836
+ """
837
+ if os.path.islink(path):
838
+ return False, "symlink"
839
+ try:
840
+ with open(path, "rb") as f:
841
+ corpse = f.read()
842
+ except OSError as e:
843
+ return False, f"unreadable ({e})"
844
+ parts = corpse.split(b"\n")
845
+ objs = _parts_objs(corpse)
846
+ bad = [i for i, o in enumerate(objs) if isinstance(o, tuple)]
847
+ if not bad:
848
+ return False, "clean"
849
+ session = _safe_session(session)
850
+ d = backup_dir()
851
+ try:
852
+ entries = sorted(f for f in os.listdir(d) if f.startswith(session + "."))
853
+ except OSError:
854
+ entries = []
855
+ if not entries:
856
+ return False, "corrupt, but no backup for this session"
857
+ bpath = os.path.join(d, entries[-1])
858
+ try:
859
+ with open(bpath, "rb") as f:
860
+ backup = f.read()
861
+ except OSError as e:
862
+ return False, f"backup unreadable ({e})"
863
+ if any(isinstance(o, tuple) for o in _parts_objs(backup)):
864
+ return False, "backup itself is corrupt"
865
+ # --- splice-signature checks -------------------------------------------
866
+ if len(bad) != 1:
867
+ return False, "corruption does not match a prune splice (multiple bad lines)"
868
+ if len(corpse) < len(backup):
869
+ return False, "file shorter than backup; not an interrupted truncate"
870
+ # Byte offset just past the bad line (its trailing \n included).
871
+ end_off = sum(len(p) + 1 for p in parts[:bad[0] + 1])
872
+ end_off = min(end_off, len(corpse))
873
+ if end_off > len(backup) or corpse[end_off:len(backup)] != backup[end_off:len(backup)]:
874
+ return False, "corruption does not match a prune splice (tail differs from backup)"
875
+ # --- heal ----------------------------------------------------------------
876
+ # Everything past len(backup) was appended by the live session AFTER the
877
+ # crash (the file was exactly backup-sized when the prune died); keep it.
878
+ tail = corpse[len(backup):]
879
+ healed = backup + tail # len(healed) == len(corpse): no truncate needed,
880
+ # and an append racing this write lands beyond it.
881
+ with open(path, "r+b") as f:
882
+ f.write(healed)
883
+ f.flush()
884
+ os.fsync(f.fileno())
885
+ append_audit_log({
886
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
887
+ "session": session, "path": path,
888
+ "verdict": f"self-healed from {entries[-1]}",
889
+ })
890
+ return True, f"healed from backup {entries[-1]}"
891
+
892
+
893
+ def write_in_place(path, new_bytes, pre_stat, cfg, session="session",
894
+ _probe=None):
895
+ """Same-inode, shrink-only rewrite that is safe against a concurrent appender.
896
+
897
+ Returns (ok: bool, verdict: str). `_probe`, if given, is invoked right after
898
+ write+fsync and before the truncate decision — a test seam to simulate a
899
+ concurrent append.
900
+ """
901
+ # Refuse to follow a symlink: we rewrite in place and back up the target, so
902
+ # a symlinked transcript path would let us rewrite (and copy out) an
903
+ # arbitrary file. Transcripts are always real files under ~/.claude/projects.
904
+ if os.path.islink(path):
905
+ return False, "skipped: refusing to write through a symlink"
906
+ session = _safe_session(session)
907
+ # Re-stat: bail if the session moved on since we read it.
908
+ try:
909
+ now = os.stat(path)
910
+ except OSError as e:
911
+ return False, f"skipped: stat failed ({e})"
912
+ if (now.st_size, now.st_mtime_ns) != (pre_stat.st_size, pre_stat.st_mtime_ns):
913
+ return False, "skipped: file changed since read"
914
+
915
+ old_size = pre_stat.st_size
916
+ with open(path, "rb") as f:
917
+ orig_bytes = f.read()
918
+
919
+ # Backup first.
920
+ d = backup_dir()
921
+ os.makedirs(d, exist_ok=True)
922
+ ts = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
923
+ bpath = os.path.join(d, f"{session}.{ts}.jsonl")
924
+ shutil.copy2(path, bpath)
925
+
926
+ def _restore_prefix():
927
+ # Rewrite the original prefix; leave anything appended past old_size.
928
+ with open(path, "r+b") as f:
929
+ f.write(orig_bytes)
930
+ f.flush()
931
+ os.fsync(f.fileno())
932
+
933
+ with open(path, "r+b") as f:
934
+ f.write(new_bytes)
935
+ f.flush()
936
+ os.fsync(f.fileno())
937
+ if _probe is not None:
938
+ _probe(path)
939
+ grown = os.fstat(f.fileno()).st_size
940
+ if grown > old_size:
941
+ # A concurrent append landed during our write. Truncating now would
942
+ # eat that line. Restore the prefix and bail.
943
+ f.close()
944
+ _restore_prefix()
945
+ _prune_old_backups(session, cfg.backups)
946
+ return False, "rolled-back: concurrent append during write"
947
+ f.truncate(len(new_bytes))
948
+ f.flush()
949
+ os.fsync(f.fileno())
950
+
951
+ # Post-write: re-read and re-validate what actually landed. We have no
952
+ # dirty-flag context here, so this is a phase-agnostic structural check of
953
+ # the disk image against the original.
954
+ with open(path, "rb") as f:
955
+ landed = f.read()
956
+ errs = _post_write_check(orig_bytes, landed)
957
+ if errs:
958
+ # Roll back without eating a concurrent append. At this point the file
959
+ # is new_bytes (we truncated above); a live appender may have added
960
+ # lines at offset len(new_bytes) since. Those bytes sit BELOW
961
+ # len(orig_bytes), so blindly rewriting the original prefix (let alone
962
+ # truncating) would destroy them. Capture the appended tail first,
963
+ # restore the original, and re-append the tail after it.
964
+ with open(path, "r+b") as f:
965
+ cur = os.fstat(f.fileno()).st_size
966
+ tail = b""
967
+ if cur > len(new_bytes):
968
+ f.seek(len(new_bytes))
969
+ tail = f.read()
970
+ f.seek(0)
971
+ f.write(orig_bytes + tail)
972
+ f.flush()
973
+ os.fsync(f.fileno())
974
+ # Mirror the main-path growth guard: truncate only if nothing newer
975
+ # landed during the restore itself.
976
+ end = os.fstat(f.fileno()).st_size
977
+ if end <= len(orig_bytes) + len(tail):
978
+ f.truncate(len(orig_bytes) + len(tail))
979
+ _prune_old_backups(session, cfg.backups)
980
+ return False, "rolled-back: post-write validation failed: " + "; ".join(errs)
981
+
982
+ _prune_old_backups(session, cfg.backups)
983
+ return True, "written"
984
+
985
+
986
+ def _post_write_check(orig_bytes, landed):
987
+ """Structural re-validation of the on-disk result (no dirty-flag context)."""
988
+ errs = []
989
+ new_objs = _parts_objs(landed)
990
+ for i, o in enumerate(new_objs):
991
+ if isinstance(o, tuple):
992
+ errs.append(f"line {i} unparseable")
993
+ if len(orig_bytes.split(b"\n")) != len(landed.split(b"\n")):
994
+ errs.append("line count changed on disk")
995
+ if len(landed) > len(orig_bytes):
996
+ errs.append("disk image larger than original")
997
+ old_objs = _parts_objs(orig_bytes)
998
+
999
+ def ids(objs):
1000
+ tu, tr = set(), set()
1001
+ for o in objs:
1002
+ if isinstance(o, dict):
1003
+ tu |= _tool_use_ids(o)
1004
+ tr |= _tool_result_ids(o)
1005
+ return tu, tr
1006
+ if ids(new_objs) != ids(old_objs):
1007
+ errs.append("tool id sets changed on disk")
1008
+ if (_orphans(new_objs) - _orphans(old_objs)):
1009
+ errs.append("new orphaned parentUuid on disk")
1010
+ return errs
1011
+
1012
+
1013
+ def state_path():
1014
+ return os.path.join(os.path.expanduser("~"), ".codearbiter", "prune-state.json")
1015
+
1016
+
1017
+ def dry_metrics_path(env=None):
1018
+ """Dedicated dry-run data-collection log. One shared append-only JSONL file
1019
+ every session writes to — the evidence base for the dry->on go/no-go.
1020
+ Defaults under ~/.codearbiter/metrics; CODEARBITER_PRUNE_METRICS overrides
1021
+ the full path (with ~ expansion)."""
1022
+ e = env if env is not None else os.environ
1023
+ override = e.get("CODEARBITER_PRUNE_METRICS")
1024
+ if override:
1025
+ return os.path.expanduser(override)
1026
+ return os.path.join(
1027
+ os.path.expanduser("~"), ".codearbiter", "metrics", "prune-dry.jsonl")
1028
+
1029
+
1030
+ def append_dry_metrics(record, env=None):
1031
+ """Append one dry-run record to the shared metrics log. Best-effort: a
1032
+ logging failure must never break the turn (the caller always exits 0)."""
1033
+ p = dry_metrics_path(env)
1034
+ try:
1035
+ os.makedirs(os.path.dirname(p), exist_ok=True)
1036
+ with open(p, "a", encoding="utf-8") as f:
1037
+ f.write(_dumps(record) + "\n")
1038
+ except Exception: # noqa: BLE001
1039
+ pass
1040
+
1041
+
1042
+ def load_state():
1043
+ try:
1044
+ with open(state_path(), encoding="utf-8") as f:
1045
+ d = json.load(f)
1046
+ return d if isinstance(d, dict) else {}
1047
+ except Exception: # noqa: BLE001
1048
+ return {}
1049
+
1050
+
1051
+ def save_state(state):
1052
+ """Persist the global cross-session prune-state.json atomically
1053
+ (reliability-008): a sibling temp file + os.replace via
1054
+ _hooklib.write_text_atomic, so a crash mid-write leaves the PRIOR valid
1055
+ state file intact instead of a torn/truncated one. Best-effort: any
1056
+ failure (including the simulated one write_text_atomic re-raises) is
1057
+ swallowed here — a prune-state write must never break the user's turn."""
1058
+ d = os.path.dirname(state_path())
1059
+ try:
1060
+ os.makedirs(d, exist_ok=True)
1061
+ _hooklib.write_text_atomic(state_path(), _dumps(state))
1062
+ except Exception: # noqa: BLE001
1063
+ pass
1064
+
1065
+
1066
+ def tail_is_settled(lines):
1067
+ """True iff the transcript tail is a clean turn boundary: the most recent
1068
+ assistant turn's tool calls are all resolved and we're not mid queue-op."""
1069
+ last_asst = None
1070
+ tr = set()
1071
+ for ln in lines:
1072
+ if isinstance(ln.obj, dict):
1073
+ if ln.obj.get("type") == "assistant":
1074
+ last_asst = ln
1075
+ tr |= _tool_result_ids(ln.obj)
1076
+ if last_asst is not None and (_tool_use_ids(last_asst.obj) - tr):
1077
+ return False # an open tool loop on the latest assistant turn
1078
+ for ln in reversed(lines):
1079
+ if isinstance(ln.obj, dict):
1080
+ if ln.obj.get("type") == "queue-operation":
1081
+ return False
1082
+ break
1083
+ return True
1084
+
1085
+
1086
+ # --------------------------------------------------------------------------- #
1087
+ # Cold-miss nudge helpers (opt-in; `CODEARBITER_PRUNE_NUDGE=on` required)
1088
+ # --------------------------------------------------------------------------- #
1089
+
1090
+ def _parse_iso8601(s):
1091
+ """Parse a UTC (`Z`-suffixed) ISO 8601 datetime to an epoch int.
1092
+
1093
+ Claude Code transcript timestamps are always UTC `Z`. We deliberately
1094
+ handle only that form plus fractional seconds; a non-`Z` numeric offset
1095
+ (`+HH:MM` / `-HH:MM`) is treated as unknown and returns None rather than
1096
+ being silently misread as UTC. Stdlib-only; never raises (fail open)."""
1097
+ try:
1098
+ s = str(s).strip()
1099
+ if not s.endswith("Z"):
1100
+ return None # offset-bearing or naive timestamp: don't guess → no nudge
1101
+ s = s[:-1].split(".")[0] # drop the Z and any fractional seconds
1102
+ return calendar.timegm(time.strptime(s, "%Y-%m-%dT%H:%M:%S"))
1103
+ except Exception: # noqa: BLE001 — fail open
1104
+ return None
1105
+
1106
+
1107
+ def _last_assistant_ts(data):
1108
+ """Return epoch secs of the most recent assistant turn's top-level
1109
+ `timestamp`, or None when absent/unparseable."""
1110
+ ts = None
1111
+ for ln in load_lines(data):
1112
+ o = ln.obj
1113
+ if isinstance(o, dict) and o.get("type") == "assistant" and o.get("timestamp"):
1114
+ ts = o.get("timestamp")
1115
+ return _parse_iso8601(ts) if ts is not None else None
1116
+
1117
+
1118
+ def _idle_seconds(data, now):
1119
+ """Seconds since the last assistant turn, or None when unknown."""
1120
+ t = _last_assistant_ts(data)
1121
+ return None if t is None else now - t
1122
+
1123
+
1124
+ def _nudge_advisory(rec):
1125
+ """Build the advisory string from state-record numbers only.
1126
+ Never includes transcript content — only context-byte/token aggregates."""
1127
+ context_freed = int(rec.get("context_bytes_freed", 0) or 0)
1128
+ context_tokens = est_tokens(context_freed)
1129
+ return (f"Cold cache miss can re-cache ~{context_tokens // 1000}k avoidable "
1130
+ f"context tokens. /compact or exit + --resume lands that re-cache "
1131
+ f"on pruned context. Submit again to proceed.")
1132
+
1133
+
1134
+ def nudge_decision(rec, idle_secs, e):
1135
+ """Decide whether to fire the cold-miss nudge. Pure + fail-open.
1136
+
1137
+ Returns (armed: bool, advisory: str, new_rec: dict). new_rec is `rec`
1138
+ unchanged unless the cold_nudged marker must flip (arm) or clear (warm
1139
+ reset). `rec` is a prune-state session record; `idle_secs` is seconds
1140
+ since the last assistant turn (None if unknown); `e` is the env mapping.
1141
+ """
1142
+ rec = rec if isinstance(rec, dict) else {}
1143
+ if (e.get("CODEARBITER_PRUNE_NUDGE", "off") or "off").lower() != "on":
1144
+ return (False, "", rec)
1145
+
1146
+ def _num(key, default):
1147
+ try:
1148
+ return int(e[key])
1149
+ except Exception: # noqa: BLE001
1150
+ return default
1151
+
1152
+ idle_floor = _num("CODEARBITER_PRUNE_NUDGE_IDLE_SECS", 240)
1153
+ min_tokens = _num("CODEARBITER_PRUNE_NUDGE_MIN_TOKENS", 80000)
1154
+ cold = isinstance(idle_secs, (int, float)) and idle_secs >= idle_floor
1155
+
1156
+ if not cold:
1157
+ if rec.get("cold_nudged"): # warm submit resets the window
1158
+ nr = dict(rec)
1159
+ nr["cold_nudged"] = False
1160
+ return (False, "", nr)
1161
+ return (False, "", rec)
1162
+
1163
+ freed = rec.get("context_bytes_freed")
1164
+ if type(freed) is not int or freed < 0 or est_tokens(freed) < min_tokens:
1165
+ return (False, "", rec)
1166
+
1167
+ if rec.get("cold_nudged"): # already fired this cold window
1168
+ return (False, "", rec)
1169
+
1170
+ nr = dict(rec)
1171
+ nr["cold_nudged"] = True
1172
+ return (True, _nudge_advisory(rec), nr)
1173
+
1174
+
1175
+ def hook_run(payload, env=None):
1176
+ """Service-mode entry: gate, short-circuit cheaply, prune at a safe point,
1177
+ and record state for the statusline. Normally returns 0; returns 2 only
1178
+ when the cold-miss nudge is armed (opt-in, see nudge_decision). A pruner
1179
+ fault must never block the user's prompt or break the session."""
1180
+ e = env if env is not None else os.environ
1181
+ mode = (e.get("CODEARBITER_PRUNE", "off") or "off").lower()
1182
+ if mode not in ("dry", "on"):
1183
+ return 0
1184
+ path = payload.get("transcript_path")
1185
+ if not path or not os.path.isfile(path):
1186
+ return 0
1187
+ # N-1: containment — transcript must live under ~/.claude/ to prevent an
1188
+ # attacker-controlled payload from pointing the pruner at arbitrary files.
1189
+ try:
1190
+ _claude_home = os.path.normcase(os.path.normpath(
1191
+ os.path.realpath(os.path.expanduser("~/.claude"))
1192
+ ))
1193
+ _real_path = os.path.normcase(os.path.normpath(os.path.realpath(path)))
1194
+ if not _real_path.startswith(_claude_home):
1195
+ return 0
1196
+ except Exception: # noqa: BLE001 — realpath can fail on unusual mounts
1197
+ return 0
1198
+ root = payload.get("cwd") or os.getcwd()
1199
+ try:
1200
+ import _hooklib
1201
+ if not _hooklib.arbiter_active(root):
1202
+ return 0
1203
+ except Exception: # noqa: BLE001
1204
+ return 0
1205
+ session = payload.get("session_id") or os.path.splitext(os.path.basename(path))[0]
1206
+ cfg = Config.from_env(e)
1207
+ cfg.execute = (mode == "on")
1208
+ if cfg.execute:
1209
+ # Repair the corpse a prune killed mid write/truncate may have left,
1210
+ # BEFORE any gate reads or short-circuits on the damaged file.
1211
+ try:
1212
+ self_heal(path, session)
1213
+ except Exception: # noqa: BLE001 — never let healing break the turn
1214
+ return 0
1215
+ try:
1216
+ st = os.stat(path)
1217
+ except OSError:
1218
+ return 0
1219
+ if st.st_size < cfg.min_size or st.st_size > (50 << 20):
1220
+ return 0
1221
+ try:
1222
+ state = load_state()
1223
+ except Exception: # noqa: BLE001 — fail open on any state-read fault
1224
+ return 0
1225
+ rec = state.get(session, {})
1226
+ if not isinstance(rec, dict):
1227
+ rec = {}
1228
+ state[session] = rec
1229
+ # Cold-miss nudge (opt-in): the ONLY non-zero return in this hook. Evaluated
1230
+ # before the growth short-circuit so an idle user (no new bytes) still nudges.
1231
+ if cfg.execute and (e.get("CODEARBITER_PRUNE_NUDGE", "off") or "off").lower() == "on":
1232
+ try:
1233
+ with open(path, "rb") as f:
1234
+ ndata = f.read()
1235
+ armed, advisory, new_rec = nudge_decision(
1236
+ rec, _idle_seconds(ndata, time.time()), e)
1237
+ if new_rec != rec:
1238
+ state[session] = new_rec
1239
+ save_state(state)
1240
+ if armed:
1241
+ sys.stderr.write(advisory + "\n")
1242
+ return 2
1243
+ except Exception: # noqa: BLE001 — fail open; pruner fault must never block
1244
+ pass
1245
+ last_pruned_size = rec.get("last_pruned_size")
1246
+ valid_last_pruned_size = (
1247
+ (type(last_pruned_size) is int and last_pruned_size >= 0)
1248
+ or (type(last_pruned_size) is float
1249
+ and last_pruned_size >= 0 and math.isfinite(last_pruned_size))
1250
+ )
1251
+ if valid_last_pruned_size \
1252
+ and (st.st_size - last_pruned_size) < cfg.min_growth:
1253
+ return 0 # cheap stat short-circuit: not enough new bytes
1254
+ # performance-001: read + parse the transcript ONCE here and thread the
1255
+ # bytes/lines/pre_stat through to run() below, instead of letting run()
1256
+ # re-open and re-parse the identical on-disk content a second time. The
1257
+ # fstat is captured from the SAME open fd as the read (not a separate
1258
+ # os.stat afterwards) so it reflects exactly the bytes just read, matching
1259
+ # what run()'s own self-contained read would have captured.
1260
+ try:
1261
+ with open(path, "rb") as f:
1262
+ data = f.read()
1263
+ pre_stat = os.fstat(f.fileno())
1264
+ except Exception: # noqa: BLE001
1265
+ return 0
1266
+ lines = load_lines(data)
1267
+ if not tail_is_settled(lines):
1268
+ return 0
1269
+ try:
1270
+ res = run(path, cfg, session=session, data=data, lines=lines, pre_stat=pre_stat)
1271
+ except Exception: # noqa: BLE001 — never let pruning break the turn
1272
+ return 0
1273
+ b0, b1 = res["bytes_before"], res["bytes_after"]
1274
+ strategy_savings = {
1275
+ name: row["bytes_before"] - row["bytes_after"]
1276
+ for name, row in res["strategies"].items()
1277
+ }
1278
+ reduction = _policy.reduction_metrics(b0, b1, strategy_savings)
1279
+ if not cfg.execute:
1280
+ # Dry mode: persist the would-be outcome to the shared data-collection
1281
+ # log so confidence accrues across sessions before flipping to on.
1282
+ append_dry_metrics({
1283
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
1284
+ "session": session,
1285
+ "mode": "dry",
1286
+ "tier": cfg.tier,
1287
+ **reduction,
1288
+ "verdict": res["verdict"],
1289
+ "validation_errors": len(res["validation_errors"]),
1290
+ "strategies": {k: v["bytes_before"] - v["bytes_after"]
1291
+ for k, v in res["strategies"].items()},
1292
+ "strategy_scopes": {k: v["metric_scope"]
1293
+ for k, v in res["strategies"].items()},
1294
+ }, env=e)
1295
+ # Carry cold_nudged forward so the once-per-cold-window marker survives a
1296
+ # normal (non-armed) prune run. Re-read from state in case the nudge block
1297
+ # above already flushed an updated rec (e.g. a warm-submit clear).
1298
+ _cold_nudged = state.get(session, {}).get("cold_nudged")
1299
+ new_state = {
1300
+ "path": path,
1301
+ "last_size": b1 if res["executed"] else b0,
1302
+ "last_pruned_size": (b1 if res["executed"] else st.st_size),
1303
+ "last_run_ts": int(time.time()),
1304
+ "pct": reduction["pct"],
1305
+ "freed_bytes": reduction["freed_bytes"],
1306
+ "file_pct": reduction["file_pct"],
1307
+ "file_bytes_freed": reduction["file_bytes_freed"],
1308
+ "file_est_tokens_freed": reduction["file_est_tokens_freed"],
1309
+ "context_bytes_freed": reduction["context_bytes_freed"],
1310
+ "context_est_tokens_freed": reduction["context_est_tokens_freed"],
1311
+ "verdict": res["verdict"],
1312
+ }
1313
+ if _cold_nudged:
1314
+ new_state["cold_nudged"] = _cold_nudged
1315
+ state[session] = new_state
1316
+ save_state(state)
1317
+ return 0
1318
+
1319
+
1320
+ def append_audit_log(record):
1321
+ d = os.path.join(os.path.expanduser("~"), ".codearbiter")
1322
+ os.makedirs(d, exist_ok=True)
1323
+ try:
1324
+ with open(os.path.join(d, "prune.log"), "a", encoding="utf-8") as f:
1325
+ f.write(_dumps(record) + "\n")
1326
+ except Exception: # noqa: BLE001
1327
+ pass
1328
+
1329
+
1330
+ # --------------------------------------------------------------------------- #
1331
+ # Top-level run (dry-run analysis; execute optionally writes)
1332
+ # --------------------------------------------------------------------------- #
1333
+
1334
+ def run(path, cfg, session="session", data=None, lines=None, pre_stat=None):
1335
+ """Prune `path` per `cfg`. Always computes the report; writes only when
1336
+ cfg.execute and validation passes. Returns a result dict.
1337
+
1338
+ performance-001: `data`/`lines`/`pre_stat` let a caller that has ALREADY
1339
+ read+parsed the transcript (hook_run's tail_is_settled check) hand that
1340
+ single read straight to the pruning pass, instead of run() re-opening and
1341
+ re-parsing the identical on-disk bytes a second time. When `data` is None
1342
+ (the default — direct/standalone callers, e.g. the CLI and tests), run()
1343
+ self-heals and reads the file itself exactly as before, so behavior for
1344
+ those callers is unchanged."""
1345
+ if data is None:
1346
+ if cfg.execute:
1347
+ # A prior prune killed between write and truncate leaves a spliced
1348
+ # file; restore it from backup before analyzing. (Dry-run never
1349
+ # writes, including this.)
1350
+ self_heal(path, session)
1351
+ with open(path, "rb") as f:
1352
+ orig_bytes = f.read()
1353
+ pre_stat = os.fstat(f.fileno())
1354
+ lines = load_lines(orig_bytes)
1355
+ else:
1356
+ # Caller already read (and self-healed, if executing) the file once —
1357
+ # reuse its bytes/parse rather than re-reading path from disk.
1358
+ orig_bytes = data
1359
+ if lines is None:
1360
+ lines = load_lines(orig_bytes)
1361
+ if pre_stat is None:
1362
+ pre_stat = os.stat(path)
1363
+ index = build_index(lines, cfg)
1364
+ report = apply_strategies(lines, index, cfg)
1365
+ new_bytes = serialize(lines)
1366
+
1367
+ errs = validate(orig_bytes, new_bytes, lines, cfg)
1368
+ strategy_savings = {
1369
+ name: row["bytes_before"] - row["bytes_after"]
1370
+ for name, row in report.items()
1371
+ }
1372
+ reduction = _policy.reduction_metrics(
1373
+ len(orig_bytes), len(new_bytes), strategy_savings)
1374
+ result = {
1375
+ "path": path,
1376
+ **reduction,
1377
+ "strategies": report,
1378
+ "validation_errors": errs,
1379
+ "executed": False,
1380
+ "verdict": "dry-run",
1381
+ }
1382
+ if errs:
1383
+ result["verdict"] = "refused: validation failed"
1384
+ return result
1385
+ if cfg.execute:
1386
+ ok, verdict = write_in_place(path, new_bytes, pre_stat, cfg, session=session)
1387
+ result["executed"] = ok
1388
+ result["verdict"] = verdict
1389
+ append_audit_log({
1390
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
1391
+ "session": session, "path": path,
1392
+ **reduction,
1393
+ "strategies": {k: v["bytes_before"] - v["bytes_after"]
1394
+ for k, v in report.items()},
1395
+ "strategy_scopes": {k: v["metric_scope"] for k, v in report.items()},
1396
+ "verdict": verdict,
1397
+ })
1398
+ return result