@arbiterforge/ca-pi 0.6.3 → 0.10.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/README.md +41 -98
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +145 -0
- package/plugins/ca-pi/COMMANDS.md +138 -68
- package/plugins/ca-pi/SKILLS.md +137 -30
- package/plugins/ca-pi/agents/INDEX.md +3 -2
- package/plugins/ca-pi/agents/checkpoint-aggregator.md +8 -7
- package/plugins/ca-pi/agents/design-quality-reviewer.md +1 -1
- package/plugins/ca-pi/agents/finding-triage.md +31 -14
- package/plugins/ca-pi/agents/verdict-aggregator.md +64 -0
- package/plugins/ca-pi/{ORCHESTRATOR.md → arbiter.md} +37 -36
- package/plugins/ca-pi/extensions/codearbiter.js +844 -19
- package/plugins/ca-pi/generated/command-catalog.json +386 -196
- package/plugins/ca-pi/generated/roles.json +9 -0
- package/plugins/ca-pi/hooks/_arbiterstatelib.py +59 -11
- package/plugins/ca-pi/hooks/_bashguardlib.py +30 -12
- package/plugins/ca-pi/hooks/_gitexec.py +23 -0
- package/plugins/ca-pi/hooks/_githooks.py +50 -23
- package/plugins/ca-pi/hooks/_hooklib.py +148 -20
- package/plugins/ca-pi/hooks/_host.py +9 -1
- package/plugins/ca-pi/hooks/_metricslib.py +20 -0
- package/plugins/ca-pi/hooks/_modelib.py +762 -0
- package/plugins/ca-pi/hooks/_protectedlib.py +13 -4
- package/plugins/ca-pi/hooks/_prunelib.py +51 -12
- package/plugins/ca-pi/hooks/_prunepolicy.py +33 -7
- package/plugins/ca-pi/hooks/_readinjectlib.py +10 -4
- package/plugins/ca-pi/hooks/_releaselib.py +278 -48
- package/plugins/ca-pi/hooks/_updatelib.py +230 -50
- package/plugins/ca-pi/hooks/doctor.py +58 -9
- package/plugins/ca-pi/hooks/git-enforce.py +10 -3
- package/plugins/ca-pi/hooks/hostapi.py +220 -22
- package/plugins/ca-pi/hooks/pi-bridge.py +10 -4
- package/plugins/ca-pi/hooks/prompt-submit.py +486 -0
- package/plugins/ca-pi/hooks/prune-transcript.py +23 -3
- package/plugins/ca-pi/hooks/session-start.py +529 -435
- package/plugins/ca-pi/hooks/statusline.py +28 -10
- package/plugins/ca-pi/hooks/wire-statusline.py +13 -8
- package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +1 -1
- package/plugins/ca-pi/includes/command-compatibility.md +16 -0
- package/plugins/ca-pi/includes/dangerous-mode.md +57 -0
- package/plugins/ca-pi/includes/ops-mode.md +96 -0
- package/plugins/ca-pi/includes/pi-host-notes.md +10 -1
- package/plugins/ca-pi/includes/redirect.md +12 -1
- package/plugins/ca-pi/includes/routing-table.md +14 -5
- package/plugins/ca-pi/includes/safety-core.md +86 -0
- package/plugins/ca-pi/includes/smarts/core.md +1 -1
- package/plugins/ca-pi/routines/INDEX.md +1 -1
- package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +55 -3
- package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +9 -1
- package/plugins/ca-pi/routines/decompose/SKILL.md +1 -1
- package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +4 -4
- package/plugins/ca-pi/routines/release/SKILL.md +1 -1
- package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +5 -4
- package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +6 -0
- package/plugins/ca-pi/skills/ca-context-check/SKILL.md +6 -0
- package/plugins/ca-pi/skills/ca-create-context/SKILL.md +6 -0
- package/plugins/ca-pi/skills/ca-decompose/SKILL.md +6 -0
- package/plugins/ca-pi/skills/ca-doctor/SKILL.md +4 -0
- package/plugins/ca-pi/skills/ca-init/SKILL.md +18 -1
- package/plugins/ca-pi/skills/ca-pr/SKILL.md +17 -1
- package/plugins/ca-pi/skills/ca-review/SKILL.md +3 -4
- package/plugins/ca-pi/skills/ca-spike/SKILL.md +15 -8
- package/plugins/ca-pi/skills/ca-status/SKILL.md +13 -1
- package/plugins/ca-pi/skills/ca-watch/SKILL.md +6 -0
- package/plugins/ca-pi/includes/dev-mode.md +0 -30
- package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +0 -36
- package/plugins/ca-pi/skills/ca-dev/SKILL.md +0 -42
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter — the mode-plane prompt-seam interceptor (#437,
|
|
3
|
+
# mode-plane-deterministic-flip, T-27..T-39).
|
|
4
|
+
#
|
|
5
|
+
# Registered on UserPromptSubmit (Claude + Codex) and, Claude-only, on
|
|
6
|
+
# PreCompact (see the compaction-generation note below). Three jobs, in this
|
|
7
|
+
# priority order:
|
|
8
|
+
#
|
|
9
|
+
# 1. A whole-prompt mode control token (`mode` / `mode --arbiter|--dangerous
|
|
10
|
+
# |--ops`, matched by `_modelib.match_mode_token` — never a substring)
|
|
11
|
+
# flips or reports the mode DETERMINISTICALLY: no persona is composed or
|
|
12
|
+
# injected on this turn, and the turn never reaches the model. On Claude
|
|
13
|
+
# that is exit 2 + a named stderr line (`shouldQuery:false`, a host
|
|
14
|
+
# invariant this module treats as a design assumption, not something it
|
|
15
|
+
# re-derives). On Codex it is the seven-key `user-prompt-submit.command.
|
|
16
|
+
# output` envelope with `continue:false`/`decision:"block"`, exit 0 —
|
|
17
|
+
# the JSON body carries the verdict, not the process exit code (mirrors
|
|
18
|
+
# `plugins/ca-codex/hooks/pre-tool-adapter.py`'s own structured-block
|
|
19
|
+
# convention for a sibling event).
|
|
20
|
+
# 2. Any OTHER prompt: compose `includes/safety-core.md` + the CURRENT
|
|
21
|
+
# mode's body (refusing a non-arbiter body the audit trail does not back
|
|
22
|
+
# — AC-11) and inject it, deduplicated per (session, mode, compaction
|
|
23
|
+
# generation) so a steady-state session pays for one injection per mode
|
|
24
|
+
# change, not per turn. Claude: plain stdout (host fact — plugin-scoped
|
|
25
|
+
# `additionalContext` is unreliable, claude-code#16538; see
|
|
26
|
+
# `session-start.py:14-19`). Codex: `hookSpecificOutput.additionalContext`
|
|
27
|
+
# inside a (deliberately leaner, non-blocking) envelope.
|
|
28
|
+
# 3. PreCompact (Claude only — Codex registers no PreCompact hook at all,
|
|
29
|
+
# `.github/scripts/test_codex_adapter.py::test_ledgered_out_surfaces_
|
|
30
|
+
# not_registered` pins that absence): bump THIS session's compaction
|
|
31
|
+
# generation. `SessionStart` also fires on `compact` with no matcher
|
|
32
|
+
# (`hooks.json:3-10`), which is what makes a naive (session, mode) dedup
|
|
33
|
+
# marker go permanently persona-free after the first compaction — a
|
|
34
|
+
# green suite, because no test spans a compaction (the spec's
|
|
35
|
+
# "compaction hole"). Bumping the generation here, independent of
|
|
36
|
+
# whatever SessionStart itself does, changes the NEXT turn's dedup key
|
|
37
|
+
# so the persona re-injects. Deliberately NOT keyed off any Lane-E
|
|
38
|
+
# SessionStart internal (e.g. session-start.py's dev-session-owner
|
|
39
|
+
# bookkeeping) — that state's shape is owned by a different lane and
|
|
40
|
+
# actively changing (T-47/T-48); a silent shape drift there must never
|
|
41
|
+
# silently reopen this hole. Whether Claude fires PreCompact exactly
|
|
42
|
+
# once per compaction is an INFERRED assumption (mirrors the spec's
|
|
43
|
+
# treatment of `shouldQuery:false` — recorded, not re-verified from a
|
|
44
|
+
# live binary here) — see the module's cross-lane report for what that
|
|
45
|
+
# assumption is standing in for on Codex/Pi (neither gets a bump path).
|
|
46
|
+
#
|
|
47
|
+
# Dormant repos (no `arbiter: enabled` in CONTEXT.md) get none of this — the
|
|
48
|
+
# flip, the report, and the injection all gate on `_hooklib.arbiter_active`,
|
|
49
|
+
# the same convention every other entry script in this plugin follows
|
|
50
|
+
# (pre-bash.py, pre-write.py, pre-edit.py, post-write-edit.py, pre-read.py).
|
|
51
|
+
#
|
|
52
|
+
# AC-11 residual (NEEDS-TRIAGE, not fixed here): `_modelib.flip`/
|
|
53
|
+
# `ledger_backs` resolve `marker_root(payload)` while `session-start.py`'s
|
|
54
|
+
# `clear_dev_marker`/`_settle_dev_close` resolve `project_root(payload)` for
|
|
55
|
+
# the SAME audit log. In a linked worktree those can be different
|
|
56
|
+
# directories, so an enter/exit pair can land in two separate overrides.log
|
|
57
|
+
# files and the ledger-backing guarantee degrades silently. This module's
|
|
58
|
+
# own AC-11 test only proves the single-root case; see the plan's
|
|
59
|
+
# ROOT-RESOLUTION SPLIT entry (owner: Lane E) for the open half.
|
|
60
|
+
|
|
61
|
+
import json
|
|
62
|
+
import os
|
|
63
|
+
import sys
|
|
64
|
+
|
|
65
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
66
|
+
import hostapi # noqa: E402 — host seam (ADR-0011)
|
|
67
|
+
import _hooklib # noqa: E402
|
|
68
|
+
import _modelib # noqa: E402 — mode plane core (T-06..T-16, Lane A)
|
|
69
|
+
import _readinjectlib # noqa: E402 — marker_path(prefix=) (T-30)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Persona sources — read off the RUNNING plugin's own generated surface, not
|
|
74
|
+
# core/surface/ (that source carries {{...}} template placeholders resolved
|
|
75
|
+
# only at build-surface.py time; the vendored copy under plugin_root/ is
|
|
76
|
+
# already plain text for every host).
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
_SAFETY_CORE_RELPATH = os.path.join("includes", "safety-core.md")
|
|
80
|
+
_MODE_BODY_RELPATH = {
|
|
81
|
+
"arbiter": "arbiter.md",
|
|
82
|
+
"dangerous": os.path.join("includes", "dangerous-mode.md"),
|
|
83
|
+
"ops": os.path.join("includes", "ops-mode.md"),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
_ELLIPSIS = "…" # U+2026 HORIZONTAL ELLIPSIS — mirrors _readinjectlib's truncation marker
|
|
87
|
+
|
|
88
|
+
# Dedup marker namespace (T-30): distinct from _readinjectlib's own
|
|
89
|
+
# "readinject-" default so the two consumers' markers never collide in the
|
|
90
|
+
# shared .codearbiter/.markers/ directory.
|
|
91
|
+
MODEINJECT_PREFIX = "modeinject-"
|
|
92
|
+
|
|
93
|
+
# This script's OWN compaction-generation ledger (see the module docstring's
|
|
94
|
+
# job 3). Lives beside the mode marker and the readinject markers, but is a
|
|
95
|
+
# file this script alone reads and writes.
|
|
96
|
+
_COMPACTION_GEN_FILENAME = "mode-compaction-gen.json"
|
|
97
|
+
|
|
98
|
+
# Codex's per-hook additionalContextLimit (R-4, AC-28): the ~2,500-token
|
|
99
|
+
# default is already under the composed `arbiter` persona alone — measured
|
|
100
|
+
# at commit time, safety-core.md (~980 tokens) + arbiter.md (~2,360 tokens)
|
|
101
|
+
# is ~3,340 tokens by this codebase's established ceil(len/4) proxy
|
|
102
|
+
# (`_readinjectlib.token_estimate`) — so accepting the default would spill
|
|
103
|
+
# the arbiter persona to disk on EVERY injection. Set generously above every
|
|
104
|
+
# mode body measured today, with headroom for `ops-mode.md` (not yet
|
|
105
|
+
# authored — Lane D, T-22).
|
|
106
|
+
CODEX_ADDITIONAL_CONTEXT_LIMIT = 8000
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _read_text(path):
|
|
110
|
+
"""Best-effort UTF-8 read; "" on any error (missing file, permission,
|
|
111
|
+
encoding). Never raises — a persona source going missing must degrade,
|
|
112
|
+
never crash the turn."""
|
|
113
|
+
try:
|
|
114
|
+
with open(path, encoding="utf-8") as f:
|
|
115
|
+
return f.read()
|
|
116
|
+
except Exception: # noqa: BLE001
|
|
117
|
+
return ""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _persona_for_mode(plugin_root, mode):
|
|
121
|
+
"""(safety_core_text, body_text) for `mode`, read off `plugin_root`. An
|
|
122
|
+
unrecognized mode (should not happen — `_effective_mode` already
|
|
123
|
+
resolves through `_modelib.MODES`) falls back to the arbiter body rather
|
|
124
|
+
than raising a KeyError on a hot path.
|
|
125
|
+
|
|
126
|
+
Either text may come back "" — the callers treat BOTH as required and
|
|
127
|
+
inject neither half alone; see `_persona_unavailable`."""
|
|
128
|
+
safety = _read_text(os.path.join(plugin_root, _SAFETY_CORE_RELPATH))
|
|
129
|
+
rel = _MODE_BODY_RELPATH.get(mode, _MODE_BODY_RELPATH[_modelib.MODES[0]])
|
|
130
|
+
body = _read_text(os.path.join(plugin_root, rel))
|
|
131
|
+
return safety, body
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _persona_unavailable(safety, body):
|
|
135
|
+
"""The composed persona is BOTH halves or neither.
|
|
136
|
+
|
|
137
|
+
An earlier form rejected only the case where both reads came back empty,
|
|
138
|
+
which meant an unreadable `safety-core.md` beside a readable body injected
|
|
139
|
+
the mode body ALONE. For `dangerous` and `ops` that body is the permissive
|
|
140
|
+
half — the session would carry the posture's licence with none of its
|
|
141
|
+
floor, and nothing would say so.
|
|
142
|
+
|
|
143
|
+
Suppressing entirely is the safe direction. The model is ungoverned either
|
|
144
|
+
way, but a missing persona is conspicuous in the transcript while a half
|
|
145
|
+
persona reads exactly like a whole one."""
|
|
146
|
+
return not (safety.strip() and body.strip())
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _compose_persona(safety_core_text, body_text, limit_tokens=None):
|
|
150
|
+
"""(composed_text, truncated) — safety_core_text + body_text, joined by a
|
|
151
|
+
blank line, with `_modelib.PERSONA_SENTINEL` appended as the LAST line so
|
|
152
|
+
a later prune pass (`_prunelib`/`_prunepolicy`, T-49/T-50) can recognize
|
|
153
|
+
and pin the whole injected block (AC-26).
|
|
154
|
+
|
|
155
|
+
When `limit_tokens` is given and the composed estimate
|
|
156
|
+
(`_readinjectlib.token_estimate`) would exceed it, ONLY the MODE BODY is
|
|
157
|
+
truncated from its tail — `safety_core_text` is NEVER cut, because it
|
|
158
|
+
carries the residual invariants that hold in every mode (T-17..T-19); an
|
|
159
|
+
ellipsis marker is appended, mirroring `_readinjectlib.assemble_context`'s
|
|
160
|
+
established truncation contract in this codebase: bounded, visible, never
|
|
161
|
+
a silent drop. `truncated` is True iff that cut happened."""
|
|
162
|
+
safety_core_text = safety_core_text or ""
|
|
163
|
+
body_text = body_text or ""
|
|
164
|
+
body_for_compose = body_text
|
|
165
|
+
truncated = False
|
|
166
|
+
if limit_tokens is not None:
|
|
167
|
+
overhead = (_readinjectlib.token_estimate(safety_core_text)
|
|
168
|
+
+ _readinjectlib.token_estimate(_modelib.PERSONA_SENTINEL)
|
|
169
|
+
+ 2) # +2: the two blank-line joins, a coarse token each
|
|
170
|
+
body_budget = max(0, limit_tokens - overhead)
|
|
171
|
+
if _readinjectlib.token_estimate(body_text) > body_budget:
|
|
172
|
+
max_chars = max(0, body_budget * 4 - len(_ELLIPSIS))
|
|
173
|
+
body_for_compose = body_text[:max_chars] + _ELLIPSIS
|
|
174
|
+
truncated = True
|
|
175
|
+
composed = (safety_core_text.rstrip("\n") + "\n\n"
|
|
176
|
+
+ body_for_compose.rstrip("\n") + "\n\n"
|
|
177
|
+
+ _modelib.PERSONA_SENTINEL + "\n")
|
|
178
|
+
return composed, truncated
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _effective_mode(root, session_id, payload):
|
|
182
|
+
"""(mode, diagnostic) to COMPOSE — never `current_mode`'s raw answer
|
|
183
|
+
unchecked. AC-11: the injector refuses to compose a non-arbiter body when
|
|
184
|
+
`overrides.log` holds no matching `MODE: <mode> enter` row (or, for
|
|
185
|
+
`dangerous`, a legacy `DEV: enter` row); it resolves `arbiter` instead and
|
|
186
|
+
reports why. `diagnostic` is None on a clean resolution."""
|
|
187
|
+
mode, diag = _modelib.current_mode(session_id, root=root, payload=payload)
|
|
188
|
+
if mode != _modelib.MODES[0] and not _modelib.ledger_backs(root, mode, session_id=session_id):
|
|
189
|
+
return _modelib.MODES[0], "mode-not-ledger-backed:" + mode
|
|
190
|
+
return mode, diag
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
# Dedup — per (session, mode, compaction generation) marker (AC-23/24/25)
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def _dedup_key(mode, generation):
|
|
198
|
+
return "{}:{}".format(mode, generation)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _already_injected(root, session_id, mode, generation):
|
|
202
|
+
try:
|
|
203
|
+
path = _readinjectlib.marker_path(
|
|
204
|
+
root, session_id, _dedup_key(mode, generation), prefix=MODEINJECT_PREFIX)
|
|
205
|
+
return os.path.isfile(path)
|
|
206
|
+
except Exception: # noqa: BLE001 — degrade toward injecting again, never toward suppressing
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _record_injected(root, session_id, mode, generation):
|
|
211
|
+
try:
|
|
212
|
+
path = _readinjectlib.marker_path(
|
|
213
|
+
root, session_id, _dedup_key(mode, generation), prefix=MODEINJECT_PREFIX)
|
|
214
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
215
|
+
tmp = path + ".tmp"
|
|
216
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
217
|
+
f.write("")
|
|
218
|
+
os.replace(tmp, path)
|
|
219
|
+
except Exception: # noqa: BLE001 — a failed marker write only costs a redundant re-inject
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
# Compaction generation (job 3) — this script's own ledger
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
def _compaction_gen_path(root):
|
|
228
|
+
return os.path.join(root, ".codearbiter", ".markers", _COMPACTION_GEN_FILENAME)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _read_compaction_generation(root, session_id):
|
|
232
|
+
"""Current compaction generation for `session_id` — 0 when absent,
|
|
233
|
+
corrupt, or unrecognized. Never raises."""
|
|
234
|
+
try:
|
|
235
|
+
with open(_compaction_gen_path(root), encoding="utf-8") as f:
|
|
236
|
+
data = json.load(f)
|
|
237
|
+
if not isinstance(data, dict):
|
|
238
|
+
return 0
|
|
239
|
+
value = data.get(str(session_id))
|
|
240
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
241
|
+
return 0
|
|
242
|
+
return value
|
|
243
|
+
except Exception: # noqa: BLE001
|
|
244
|
+
return 0
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _invalidate_injection_markers(root, session_id):
|
|
248
|
+
"""Remove every `modeinject-` marker for `session_id`, across all modes.
|
|
249
|
+
|
|
250
|
+
The fallback when the generation cannot advance. Deleting the markers
|
|
251
|
+
forces the next turn's `_already_injected` to miss, so the persona is
|
|
252
|
+
re-injected — the same outcome a successful bump produces, reached by the
|
|
253
|
+
other side. Never raises: this runs on an already-degraded path, and a
|
|
254
|
+
failure here costs the re-injection it was trying to guarantee, so it must
|
|
255
|
+
not also cost the PreCompact turn."""
|
|
256
|
+
for mode in _modelib.MODES:
|
|
257
|
+
try:
|
|
258
|
+
path = _readinjectlib.marker_path(
|
|
259
|
+
root, session_id, _dedup_key(mode, _read_compaction_generation(root, session_id)),
|
|
260
|
+
prefix=MODEINJECT_PREFIX)
|
|
261
|
+
os.remove(path)
|
|
262
|
+
except Exception: # noqa: BLE001 — absent is the desired state anyway
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _bump_compaction_generation(root, session_id):
|
|
267
|
+
"""Increment and persist `session_id`'s compaction generation; returns
|
|
268
|
+
the NEW value. Best-effort (never raises) — PreCompact must not crash.
|
|
269
|
+
|
|
270
|
+
A FAILED bump is not the harmless case the earlier docstring claimed. It
|
|
271
|
+
returned the PRIOR generation, and the marker recorded under that
|
|
272
|
+
generation still existed — so the first post-compaction turn was
|
|
273
|
+
SUPPRESSED and the session continued without the persona compaction had
|
|
274
|
+
just removed. That is precisely the hole this counter exists to close,
|
|
275
|
+
reopened by its own failure path.
|
|
276
|
+
|
|
277
|
+
So when the write fails, the dedup markers are invalidated instead. The
|
|
278
|
+
generation stays put and the next turn re-injects, which fails toward a
|
|
279
|
+
redundant persona rather than a missing one."""
|
|
280
|
+
try:
|
|
281
|
+
path = _compaction_gen_path(root)
|
|
282
|
+
try:
|
|
283
|
+
with open(path, encoding="utf-8") as f:
|
|
284
|
+
data = json.load(f)
|
|
285
|
+
if not isinstance(data, dict):
|
|
286
|
+
data = {}
|
|
287
|
+
except Exception: # noqa: BLE001 — absent/corrupt -> start fresh
|
|
288
|
+
data = {}
|
|
289
|
+
key = str(session_id)
|
|
290
|
+
current = data.get(key)
|
|
291
|
+
if isinstance(current, bool) or not isinstance(current, int):
|
|
292
|
+
current = 0
|
|
293
|
+
data[key] = current + 1
|
|
294
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
295
|
+
_hooklib.write_text_atomic(path, json.dumps(data))
|
|
296
|
+
return data[key]
|
|
297
|
+
except Exception: # noqa: BLE001
|
|
298
|
+
_invalidate_injection_markers(root, session_id)
|
|
299
|
+
return _read_compaction_generation(root, session_id)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ---------------------------------------------------------------------------
|
|
303
|
+
# Claude stderr messages (AC-7/9/10)
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
def _flip_stderr_line(mode, result):
|
|
307
|
+
if result == _modelib.FLIP_FLIPPED:
|
|
308
|
+
return "codeArbiter: mode flipped to {} (MODE: {} enter logged)".format(mode, mode)
|
|
309
|
+
if result == _modelib.FLIP_NOOP:
|
|
310
|
+
return "codeArbiter: mode already {}".format(mode)
|
|
311
|
+
return ("codeArbiter: mode flip to {} FAILED (marker write error) — "
|
|
312
|
+
"the session remains in its current mode".format(mode))
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _report_stderr_line(mode, diag):
|
|
316
|
+
line = "codeArbiter: current mode is {} — legal values: {}".format(
|
|
317
|
+
mode, ", ".join(_modelib.MODES))
|
|
318
|
+
if diag:
|
|
319
|
+
line += " ({})".format(diag)
|
|
320
|
+
return line
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# ---------------------------------------------------------------------------
|
|
324
|
+
# Codex envelope (T-38, AC-13/28) — user-prompt-submit.command.output.
|
|
325
|
+
# additionalProperties:false, seven permitted keys; `permissionDecision`
|
|
326
|
+
# (the PreToolUse-schema key) must never appear here.
|
|
327
|
+
# ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
CODEX_ENVELOPE_KEYS = frozenset((
|
|
330
|
+
"continue", "decision", "hookSpecificOutput", "reason",
|
|
331
|
+
"stopReason", "suppressOutput", "systemMessage",
|
|
332
|
+
))
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _codex_block_envelope(reason, stop_reason):
|
|
336
|
+
"""The BLOCK shape (flip/report): exactly the seven schema-permitted
|
|
337
|
+
keys, every time — `decision` is Codex's `BlockDecisionWire` enum, whose
|
|
338
|
+
only member is "block", so this shape is used ONLY when actually
|
|
339
|
+
blocking (never with a placeholder/empty `decision` value, which would
|
|
340
|
+
not validate against the enum)."""
|
|
341
|
+
return {
|
|
342
|
+
"continue": False,
|
|
343
|
+
"decision": "block",
|
|
344
|
+
"hookSpecificOutput": {"hookEventName": "UserPromptSubmit"},
|
|
345
|
+
"reason": reason,
|
|
346
|
+
"stopReason": stop_reason,
|
|
347
|
+
"suppressOutput": False,
|
|
348
|
+
"systemMessage": reason,
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _codex_allow_envelope(additional_context):
|
|
353
|
+
"""The non-blocking injection shape: a valid SUBSET of the seven keys
|
|
354
|
+
(no `decision` — there is nothing to block, and an empty/placeholder
|
|
355
|
+
`decision` would not validate against the enum either)."""
|
|
356
|
+
return {
|
|
357
|
+
"continue": True,
|
|
358
|
+
"hookSpecificOutput": {
|
|
359
|
+
"hookEventName": "UserPromptSubmit",
|
|
360
|
+
"additionalContext": additional_context,
|
|
361
|
+
},
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# ---------------------------------------------------------------------------
|
|
366
|
+
# Injection (job 2), host-neutral core + per-host output
|
|
367
|
+
# ---------------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
def _inject_claude(payload, host, root, session_id):
|
|
370
|
+
mode, diag = _effective_mode(root, session_id, payload)
|
|
371
|
+
if diag:
|
|
372
|
+
sys.stderr.write("codeArbiter: " + diag + "\n")
|
|
373
|
+
generation = _read_compaction_generation(root, session_id)
|
|
374
|
+
if _already_injected(root, session_id, mode, generation):
|
|
375
|
+
return
|
|
376
|
+
safety, body = _persona_for_mode(host.plugin_root(), mode)
|
|
377
|
+
if _persona_unavailable(safety, body):
|
|
378
|
+
sys.stderr.write("codeArbiter: persona sources incomplete for mode '{}' — "
|
|
379
|
+
"injecting nothing this turn rather than a body without its "
|
|
380
|
+
"safety floor.\n".format(mode))
|
|
381
|
+
return
|
|
382
|
+
composed, _truncated = _compose_persona(safety, body)
|
|
383
|
+
print(composed)
|
|
384
|
+
_record_injected(root, session_id, mode, generation)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _inject_codex(payload, host, root, session_id):
|
|
388
|
+
mode, diag = _effective_mode(root, session_id, payload)
|
|
389
|
+
generation = _read_compaction_generation(root, session_id)
|
|
390
|
+
if _already_injected(root, session_id, mode, generation):
|
|
391
|
+
return
|
|
392
|
+
safety, body = _persona_for_mode(host.plugin_root(), mode)
|
|
393
|
+
if _persona_unavailable(safety, body):
|
|
394
|
+
sys.stderr.write("codeArbiter: persona sources incomplete for mode '{}' — "
|
|
395
|
+
"injecting nothing this turn rather than a body without its "
|
|
396
|
+
"safety floor.\n".format(mode))
|
|
397
|
+
return
|
|
398
|
+
composed, _truncated = _compose_persona(
|
|
399
|
+
safety, body, limit_tokens=CODEX_ADDITIONAL_CONTEXT_LIMIT)
|
|
400
|
+
print(json.dumps(_codex_allow_envelope(composed)))
|
|
401
|
+
_record_injected(root, session_id, mode, generation)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# ---------------------------------------------------------------------------
|
|
405
|
+
# UserPromptSubmit dispatch (jobs 1 + 2)
|
|
406
|
+
# ---------------------------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
def _handle_claude(payload, host, root, session_id, token):
|
|
409
|
+
if token == _modelib.MODE_TOKEN_REPORT:
|
|
410
|
+
mode, diag = _modelib.current_mode(session_id, root=root, payload=payload)
|
|
411
|
+
sys.stderr.write(_report_stderr_line(mode, diag) + "\n")
|
|
412
|
+
return 2
|
|
413
|
+
if token in _modelib.MODES:
|
|
414
|
+
result = _modelib.flip(session_id, token, root=root, payload=payload,
|
|
415
|
+
host_name=host.name)
|
|
416
|
+
sys.stderr.write(_flip_stderr_line(token, result) + "\n")
|
|
417
|
+
return 2
|
|
418
|
+
_inject_claude(payload, host, root, session_id)
|
|
419
|
+
return 0
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _handle_codex(payload, host, root, session_id, token):
|
|
423
|
+
if token == _modelib.MODE_TOKEN_REPORT:
|
|
424
|
+
mode, diag = _modelib.current_mode(session_id, root=root, payload=payload)
|
|
425
|
+
msg = _report_stderr_line(mode, diag)
|
|
426
|
+
print(json.dumps(_codex_block_envelope(msg, msg)))
|
|
427
|
+
return 0
|
|
428
|
+
if token in _modelib.MODES:
|
|
429
|
+
result = _modelib.flip(session_id, token, root=root, payload=payload,
|
|
430
|
+
host_name=host.name)
|
|
431
|
+
msg = _flip_stderr_line(token, result)
|
|
432
|
+
print(json.dumps(_codex_block_envelope(msg, msg)))
|
|
433
|
+
return 0
|
|
434
|
+
_inject_codex(payload, host, root, session_id)
|
|
435
|
+
return 0
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _handle_user_prompt_submit(payload, host):
|
|
439
|
+
session_id = payload.get("session_id") or ""
|
|
440
|
+
root = _hooklib.project_root(payload)
|
|
441
|
+
if not _hooklib.arbiter_active(root):
|
|
442
|
+
return 0 # dormant repo: no flip, no report, no injection
|
|
443
|
+
state_root = _hooklib.marker_root(payload)
|
|
444
|
+
prompt = payload.get("prompt")
|
|
445
|
+
prompt = prompt if isinstance(prompt, str) else ""
|
|
446
|
+
token = _modelib.match_mode_token(prompt)
|
|
447
|
+
if host.name == "codex":
|
|
448
|
+
return _handle_codex(payload, host, state_root, session_id, token)
|
|
449
|
+
return _handle_claude(payload, host, state_root, session_id, token)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _handle_precompact(payload, host):
|
|
453
|
+
"""Job 3 — bump this session's compaction generation. Claude-only (see
|
|
454
|
+
the module docstring); never blocks, never raises."""
|
|
455
|
+
session_id = payload.get("session_id") or ""
|
|
456
|
+
if not session_id:
|
|
457
|
+
return 0
|
|
458
|
+
root = _hooklib.project_root(payload)
|
|
459
|
+
if not _hooklib.arbiter_active(root):
|
|
460
|
+
return 0
|
|
461
|
+
_bump_compaction_generation(_hooklib.marker_root(payload), session_id)
|
|
462
|
+
return 0
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def main(argv=None):
|
|
466
|
+
_hooklib.utf8_stdio()
|
|
467
|
+
payload = _hooklib.read_input()
|
|
468
|
+
host = _hooklib.get_host()
|
|
469
|
+
event = payload.get("hook_event_name") if isinstance(payload, dict) else None
|
|
470
|
+
if event == "PreCompact":
|
|
471
|
+
return _handle_precompact(payload, host)
|
|
472
|
+
if event == "UserPromptSubmit":
|
|
473
|
+
return _handle_user_prompt_submit(payload, host)
|
|
474
|
+
return 0
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def run(host, argv=None):
|
|
478
|
+
"""Host-seam entry point (ADR-0011): the __main__ guard calls this with
|
|
479
|
+
the plugin's loaded Host, primes `_hooklib`'s process-cached Host BEFORE
|
|
480
|
+
main() runs (#257), then delegates."""
|
|
481
|
+
_hooklib.set_host(host)
|
|
482
|
+
return main(argv)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
if __name__ == "__main__":
|
|
486
|
+
sys.exit(run(hostapi.load_host()) or 0)
|
|
@@ -47,14 +47,34 @@ def staleness_check(payload):
|
|
|
47
47
|
Only runs when the repo has opted in (arbiter_active). Best-effort: ANY
|
|
48
48
|
failure (missing _hooklib import, a broken root, an unreadable marker)
|
|
49
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.
|
|
50
|
+
hook-mode exit code (always 0) or block the user's prompt.
|
|
51
|
+
|
|
52
|
+
[[NEEDS-TRIAGE root-resolution split]] (#437, found by Lane F, closed
|
|
53
|
+
here by Lane E — the ruling covers every site touching mode/marker
|
|
54
|
+
state, not just this one; see session-start.py's clear_mode_marker
|
|
55
|
+
module docstring for the sibling fix and the shared rationale). This
|
|
56
|
+
used to resolve `project_root(payload)` — a LINKED WORKTREE's own
|
|
57
|
+
checkout — but the flows `_hooklib.staleness_warning` inspects
|
|
58
|
+
(`.codearbiter/.markers/mode`, the sprint marker) live at `marker_root`,
|
|
59
|
+
which ESCALATES to the MAIN checkout in a linked worktree (#604):
|
|
60
|
+
`.codearbiter/.markers/` is gitignored, so a linked worktree's own
|
|
61
|
+
checkout never has a fresh copy. A genuinely stale non-arbiter session
|
|
62
|
+
recorded in the main checkout's mode marker was silently invisible to
|
|
63
|
+
this WARN whenever this hook ran from inside a worktree — quiet exactly
|
|
64
|
+
where #604 says it must not be, and this repo runs worktree agents
|
|
65
|
+
routinely. `.codearbiter/CONTEXT.md` (the arbiter_active check) is a
|
|
66
|
+
normal tracked file present in every checkout including a worktree's
|
|
67
|
+
own, so resolving IT through marker_root too changes nothing outside a
|
|
68
|
+
worktree and is the one root every mode/marker-touching caller should
|
|
69
|
+
share, per the ruling."""
|
|
51
70
|
try:
|
|
52
71
|
import _hooklib
|
|
53
72
|
# Resolve through the host seam (ADR-0011), not the raw payload cwd —
|
|
54
73
|
# a session whose cwd is a repo subdirectory must still resolve the
|
|
55
74
|
# repo root (CLAUDE_PROJECT_DIR -> payload cwd -> git toplevel ->
|
|
56
|
-
# process cwd), or this WARN silently never fires (#264).
|
|
57
|
-
root
|
|
75
|
+
# process cwd), or this WARN silently never fires (#264). marker_root
|
|
76
|
+
# (not project_root): see the root-resolution-split note above.
|
|
77
|
+
root = _hooklib.get_host().marker_root(payload)
|
|
58
78
|
if not _hooklib.arbiter_active(root):
|
|
59
79
|
return
|
|
60
80
|
for msg in _hooklib.staleness_warning(root):
|