@arbiterforge/ca-pi 0.6.3 → 0.8.1

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 (37) hide show
  1. package/README.md +18 -14
  2. package/package.json +1 -1
  3. package/plugins/ca-pi/CHANGELOG.md +68 -0
  4. package/plugins/ca-pi/COMMANDS.md +0 -7
  5. package/plugins/ca-pi/SKILLS.md +0 -2
  6. package/plugins/ca-pi/agents/design-quality-reviewer.md +1 -1
  7. package/plugins/ca-pi/{ORCHESTRATOR.md → arbiter.md} +28 -36
  8. package/plugins/ca-pi/extensions/codearbiter.js +758 -18
  9. package/plugins/ca-pi/generated/command-catalog.json +0 -10
  10. package/plugins/ca-pi/hooks/_arbiterstatelib.py +59 -11
  11. package/plugins/ca-pi/hooks/_bashguardlib.py +12 -1
  12. package/plugins/ca-pi/hooks/_hooklib.py +58 -17
  13. package/plugins/ca-pi/hooks/_metricslib.py +20 -0
  14. package/plugins/ca-pi/hooks/_modelib.py +644 -0
  15. package/plugins/ca-pi/hooks/_prunelib.py +51 -12
  16. package/plugins/ca-pi/hooks/_prunepolicy.py +33 -7
  17. package/plugins/ca-pi/hooks/_readinjectlib.py +10 -4
  18. package/plugins/ca-pi/hooks/doctor.py +2 -1
  19. package/plugins/ca-pi/hooks/pi-bridge.py +10 -4
  20. package/plugins/ca-pi/hooks/prompt-submit.py +486 -0
  21. package/plugins/ca-pi/hooks/prune-transcript.py +23 -3
  22. package/plugins/ca-pi/hooks/session-start.py +526 -434
  23. package/plugins/ca-pi/hooks/statusline.py +27 -9
  24. package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +1 -1
  25. package/plugins/ca-pi/includes/dangerous-mode.md +57 -0
  26. package/plugins/ca-pi/includes/ops-mode.md +96 -0
  27. package/plugins/ca-pi/includes/pi-host-notes.md +10 -1
  28. package/plugins/ca-pi/includes/redirect.md +12 -1
  29. package/plugins/ca-pi/includes/routing-table.md +1 -0
  30. package/plugins/ca-pi/includes/safety-core.md +86 -0
  31. package/plugins/ca-pi/includes/smarts/core.md +1 -1
  32. package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +1 -1
  33. package/plugins/ca-pi/routines/decompose/SKILL.md +1 -1
  34. package/plugins/ca-pi/skills/ca-spike/SKILL.md +15 -8
  35. package/plugins/ca-pi/includes/dev-mode.md +0 -30
  36. package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +0 -36
  37. 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 = _hooklib.get_host().project_root(payload)
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):