@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
@@ -30,10 +30,16 @@ import sys
30
30
  import time
31
31
 
32
32
  import _hooklib
33
+ import _modelib
33
34
  import _prunepolicy as _policy
34
35
 
35
36
  BOM = b"\xef\xbb\xbf"
36
37
  MARKER_PREFIX = _policy.MARKER_PREFIX
38
+ # R-5 (#437, mode-plane-deterministic-flip): the sentinel embedded in every
39
+ # composed persona injection. A line whose serialized content carries it is
40
+ # the injected persona -- built here into SemanticEntry(pinned=True) (T-50)
41
+ # so `_prunepolicy` (AC-26) refuses to fold/condense/evict it at any tier.
42
+ PERSONA_SENTINEL = _modelib.PERSONA_SENTINEL
37
43
 
38
44
 
39
45
  def _dumps(o):
@@ -127,7 +133,7 @@ def _tool_result_ids(o):
127
133
 
128
134
  class Index:
129
135
  __slots__ = ("protected_from", "last_assistant_idx", "tu_ids", "tr_ids",
130
- "edited_paths", "edited_at", "tool_meta")
136
+ "edited_paths", "edited_at", "tool_meta", "pinned_ordinals")
131
137
 
132
138
 
133
139
  def build_index(lines, cfg):
@@ -171,14 +177,35 @@ def build_index(lines, cfg):
171
177
  semantic = []
172
178
  for ln in lines:
173
179
  o = ln.obj if isinstance(ln.obj, dict) else {}
180
+ # T-50: a line whose serialized content carries the injected-persona
181
+ # sentinel is pinned -- AC-26 requires it survive every strategy at
182
+ # every tier, not just the recent-turn protected tail (the persona is
183
+ # typically injected once, early in the transcript, and stays live
184
+ # for the rest of the session -- well outside `protected_from` by the
185
+ # time pruning runs). Same _dumps(o)-based check _has_marker already
186
+ # uses for the elision marker, so re-serialization quirks can't make
187
+ # the two disagree about the same line.
188
+ dumped = _dumps(o) if o else ""
174
189
  semantic.append(_policy.SemanticEntry(
175
190
  id=str(ln.idx), ordinal=ln.idx, role=str(o.get("type", "other")),
176
191
  kind=("tool-result" if _tool_result_ids(o) else "message"),
177
192
  byte_size=len(ln.raw), tool_bearing=bool(_tool_use_ids(o)),
178
- marked=_has_marker(_dumps(o)) if o else False,
193
+ marked=_has_marker(dumped) if o else False,
194
+ # A TOOL RESULT that merely quotes the sentinel is not an injected
195
+ # persona. The literal lives in this repo's own sources
196
+ # (`_modelib.py`, `prompt-submit.py`, `extension.ts`, their tests),
197
+ # so a Read of any of them, or a Grep for the sentinel itself,
198
+ # produced a result that pinned permanently at every tier — the
199
+ # transcript kept growing while the pruner reported it protected.
200
+ # Agents here read and grep those files routinely, so this was
201
+ # reachable rather than theoretical. AC-26 needs the INJECTION
202
+ # pinned, not every copy of the string; the injection arrives as a
203
+ # message, never as a tool result.
204
+ pinned=(PERSONA_SENTINEL in dumped and not _tool_result_ids(o)) if o else False,
179
205
  ))
180
206
  prot = _policy.protected_ordinal(semantic, cfg.keep_recent)
181
207
  idx.protected_from = prot
208
+ idx.pinned_ordinals = frozenset(e.ordinal for e in semantic if e.pinned)
182
209
  idx.last_assistant_idx = last_assistant
183
210
  idx.tu_ids = tu
184
211
  idx.tr_ids = tr
@@ -196,6 +223,17 @@ def _is_small_scalar(v, limit=200):
196
223
  return False
197
224
 
198
225
 
226
+ def _protected(ln, index):
227
+ """True iff `ln` must not be touched by ANY strategy at ANY tier: either
228
+ it sits in the recent-turn protected tail, or it is PINNED (T-50/AC-26 --
229
+ the injected persona, which is typically well before `protected_from` by
230
+ the time pruning runs). Every strategy's skip-guard below routes through
231
+ this single predicate so the two protection reasons can never drift
232
+ apart -- a strategy that checked `ln.idx >= index.protected_from` alone
233
+ would silently reach a pinned line sitting earlier in the transcript."""
234
+ return ln.idx >= index.protected_from or ln.idx in index.pinned_ordinals
235
+
236
+
199
237
  # --------------------------------------------------------------------------- #
200
238
  # Strategies (Phase 1). Each mutates obj, sets dirty, records a report row.
201
239
  # All obey the net-negative guard: a strategy is a no-op on any unit whose
@@ -216,7 +254,7 @@ def s_sidecar_collapse(lines, index, cfg, report):
216
254
  small scalar fields worth keeping (status, exit codes, agentId, paths)."""
217
255
  touched = before = after = 0
218
256
  for ln in lines:
219
- if ln.idx >= index.protected_from or not isinstance(ln.obj, dict):
257
+ if _protected(ln, index) or not isinstance(ln.obj, dict):
220
258
  continue
221
259
  tur = ln.obj.get("toolUseResult")
222
260
  if not isinstance(tur, (dict, list, str)):
@@ -260,7 +298,7 @@ def s_oversize_result_clamp(lines, index, cfg, report):
260
298
  touched = before = after = 0
261
299
  mb, ml = cfg.max_bytes, 100
262
300
  for ln in lines:
263
- if ln.idx >= index.protected_from or not isinstance(ln.obj, dict):
301
+ if _protected(ln, index) or not isinstance(ln.obj, dict):
264
302
  continue
265
303
  msg = ln.obj.get("message")
266
304
  if not isinstance(msg, dict) or not isinstance(msg.get("content"), list):
@@ -341,7 +379,7 @@ def s_reasoning_fold(lines, index, cfg, report):
341
379
  most recent assistant turn is always inside the protected tail."""
342
380
  touched = before = 0
343
381
  for ln in lines:
344
- if ln.idx >= index.protected_from or not isinstance(ln.obj, dict):
382
+ if _protected(ln, index) or not isinstance(ln.obj, dict):
345
383
  continue
346
384
  if ln.obj.get("type") != "assistant":
347
385
  continue
@@ -369,7 +407,7 @@ def s_aged_result_condense(lines, index, cfg, report):
369
407
  tail. Specific handlers (shell/superseded) run earlier and mark their own."""
370
408
  touched = before = after = 0
371
409
  for ln in lines:
372
- if ln.idx >= index.protected_from or not isinstance(ln.obj, dict):
410
+ if _protected(ln, index) or not isinstance(ln.obj, dict):
373
411
  continue
374
412
  content = _content_list(ln.obj)
375
413
  if not content:
@@ -394,7 +432,7 @@ def s_mcp_payload_condense(lines, index, cfg, report):
394
432
  """Condense the bulky `input` of mcp__ tool_use blocks (older turns)."""
395
433
  touched = before = after = 0
396
434
  for ln in lines:
397
- if ln.idx >= index.protected_from or not isinstance(ln.obj, dict):
435
+ if _protected(ln, index) or not isinstance(ln.obj, dict):
398
436
  continue
399
437
  content = _content_list(ln.obj)
400
438
  if not content:
@@ -435,7 +473,7 @@ def s_shell_tail_keep(lines, index, cfg, report):
435
473
  keep_lines = 30
436
474
  touched = before = after = 0
437
475
  for ln in lines:
438
- if ln.idx >= index.protected_from or not isinstance(ln.obj, dict):
476
+ if _protected(ln, index) or not isinstance(ln.obj, dict):
439
477
  continue
440
478
  content = _content_list(ln.obj)
441
479
  if not content:
@@ -487,7 +525,7 @@ def s_superseded_read_condense(lines, index, cfg, report):
487
525
  transcript — that snapshot is stale; the later edit is the source of truth."""
488
526
  touched = before = after = 0
489
527
  for ln in lines:
490
- if ln.idx >= index.protected_from or not isinstance(ln.obj, dict):
528
+ if _protected(ln, index) or not isinstance(ln.obj, dict):
491
529
  continue
492
530
  content = _content_list(ln.obj)
493
531
  if not content:
@@ -536,8 +574,9 @@ def s_repeat_reminder_fold(lines, index, cfg, report):
536
574
  if key not in seen:
537
575
  seen.add(key)
538
576
  continue
539
- # A later duplicate: fold it (only past the protected tail).
540
- if ln.idx >= index.protected_from:
577
+ # A later duplicate: fold it (only past the protected tail, and
578
+ # never a pinned line -- T-50/AC-26).
579
+ if _protected(ln, index):
541
580
  continue
542
581
  marker = _marker(txt)
543
582
  if len(marker.encode("utf-8")) >= len(txt.encode("utf-8")):
@@ -576,7 +615,7 @@ def s_inline_image_evict(lines, index, cfg, report):
576
615
  changed = True
577
616
  return changed
578
617
  for ln in lines:
579
- if ln.idx >= index.protected_from or not isinstance(ln.obj, dict):
618
+ if _protected(ln, index) or not isinstance(ln.obj, dict):
580
619
  continue
581
620
  content = _content_list(ln.obj)
582
621
  if not content:
@@ -73,6 +73,16 @@ class SemanticEntry:
73
73
  byte_size: int
74
74
  tool_bearing: bool = False
75
75
  marked: bool = False
76
+ # R-5 / AC-26 (#437, mode-plane-deterministic-flip): True for the entry
77
+ # carrying an injected persona (`_modelib.PERSONA_SENTINEL` -- set by the
78
+ # codec that builds SemanticEntry values, e.g. `_prunelib.build_index`,
79
+ # T-50). A pinned entry is retained at EVERY tier including aggressive,
80
+ # regardless of where it sits relative to the recent-turn protected tail
81
+ # -- see plan_prune below. Distinct from `marked`: `marked` means "already
82
+ # condensed by a prior pass" (still eligible to be LEFT alone, but not
83
+ # load-bearing the way a pin is); `pinned` means "must never be folded,
84
+ # condensed, or evicted," full stop.
85
+ pinned: bool = False
76
86
 
77
87
 
78
88
  @dataclass(frozen=True)
@@ -198,19 +208,35 @@ def plan_prune(entries, policy):
198
208
  if [entry.ordinal for entry in entries] != list(range(len(entries))):
199
209
  raise ValueError("semantic entry ordinals must be contiguous and ordered")
200
210
  boundary = protected_ordinal(entries, policy.keep_recent)
201
- protected = tuple(entry.id for entry in entries if entry.ordinal >= boundary)
211
+ # AC-26: a pinned entry (the injected persona) is protected regardless of
212
+ # ordinal -- it need not sit in the recent-turn tail at all (the persona
213
+ # is typically injected once, early, and stays pinned for the rest of the
214
+ # session). Protection holds at EVERY tier, including aggressive: pinning
215
+ # is checked before tier selection even runs, so no strategy set can ever
216
+ # reach a pinned entry.
217
+ protected = tuple(entry.id for entry in entries
218
+ if entry.ordinal >= boundary or entry.pinned)
202
219
  selected = select_strategies(policy.tier, policy.strategies)
203
- actions = tuple((entry.id, _action_for(entry, selected, policy))
204
- for entry in entries if entry.ordinal < boundary)
205
- marked = sum(1 for entry in entries if entry.ordinal < boundary and entry.marked)
220
+ # `candidates` is named once and reused for the actions, the counts and the
221
+ # audit code, because those three drifted apart the moment pinning arrived:
222
+ # `marked` excluded pinned entries while `boundary` still counted them, so
223
+ # a single pinned entry below the boundary capped `marked` at `boundary-1`
224
+ # and made CA-PRUNE-IDEMPOTENT unreachable — a fully condensed transcript
225
+ # reporting CA-PRUNE-PLAN forever. That is the normal case, not an edge:
226
+ # the persona is injected once, early, which puts a pinned entry below the
227
+ # boundary in essentially every governed session.
228
+ candidates = tuple(entry for entry in entries
229
+ if entry.ordinal < boundary and not entry.pinned)
230
+ actions = tuple((entry.id, _action_for(entry, selected, policy)) for entry in candidates)
231
+ marked = sum(1 for entry in candidates if entry.marked)
206
232
  metrics = {
207
233
  "entries_before": len(entries),
208
- "candidate_entries": boundary,
234
+ "candidate_entries": len(candidates),
209
235
  "protected_entries": len(protected),
210
236
  "marked_candidates": marked,
211
237
  }
212
- audit_codes = (("CA-PRUNE-NOOP",) if boundary == 0
213
- else ("CA-PRUNE-IDEMPOTENT",) if marked == boundary
238
+ audit_codes = (("CA-PRUNE-NOOP",) if not candidates
239
+ else ("CA-PRUNE-IDEMPOTENT",) if marked == len(candidates)
214
240
  else ("CA-PRUNE-PLAN",))
215
241
  fingerprint_source = {
216
242
  "boundary": boundary,
@@ -827,7 +827,7 @@ def governing_docs(rel, index, runner=None):
827
827
  # ---------------------------------------------------------------------------
828
828
 
829
829
 
830
- def marker_path(root, session_id, rel):
830
+ def marker_path(root, session_id, rel, prefix="readinject-"):
831
831
  """Return the absolute path of the dedup marker for (session_id, rel).
832
832
 
833
833
  The marker lives under <root>/.codearbiter/.markers/ with a filename
@@ -835,9 +835,15 @@ def marker_path(root, session_id, rel):
835
835
  null-byte separator ensures ('ab', 'c') and ('a', 'bc') hash to different
836
836
  filenames.
837
837
 
838
+ `prefix` selects the marker namespace within the shared .markers/
839
+ directory. Defaults to 'readinject-' so every existing caller is
840
+ unaffected; a second consumer (e.g. mode-flip injection) can pass
841
+ prefix='modeinject-' to keep its markers distinct from the 790+
842
+ readinject- markers already on disk.
843
+
838
844
  PURE — no filesystem access of any kind. Inputs are coerced to str so any
839
845
  type is accepted. Never raises; on the (essentially impossible) error path,
840
- returns a fallback path whose last segment is 'readinject-error.marker'
846
+ returns a fallback path whose last segment is '<prefix>error.marker'
841
847
  which will not match any normally-written marker.
842
848
  """
843
849
  try:
@@ -848,11 +854,11 @@ def marker_path(root, session_id, rel):
848
854
  str(root),
849
855
  ".codearbiter",
850
856
  ".markers",
851
- "readinject-{}.marker".format(digest),
857
+ "{}{}.marker".format(prefix, digest),
852
858
  )
853
859
  except Exception: # noqa: BLE001
854
860
  return os.path.join(
855
- str(root), ".codearbiter", ".markers", "readinject-error.marker"
861
+ str(root), ".codearbiter", ".markers", "{}error.marker".format(prefix)
856
862
  )
857
863
 
858
864
 
@@ -26,7 +26,8 @@ import _githooks # noqa: E402 — #556: git-hook drop-in registry freshness
26
26
  from _hooklib import frontmatter_enabled, get_host, set_host, utf8_stdio # noqa: E402
27
27
 
28
28
  HOOK_SCRIPTS = ("session-start.py", "pre-bash.py", "pre-write.py",
29
- "pre-edit.py", "post-write-edit.py", "prune-transcript.py")
29
+ "pre-edit.py", "post-write-edit.py", "prune-transcript.py",
30
+ "prompt-submit.py")
30
31
  PI_BRIDGE_SCRIPTS = ("pi-bridge.py", "git-enforce.py", "_githooks.py")
31
32
 
32
33
  # MCP config files are read whole to be counted. `~/.claude.json` also carries
@@ -20,6 +20,7 @@ from _prunepolicy import PrunePolicy, SemanticEntry, plan_prune # noqa: E402
20
20
  import _arbiterstatelib # noqa: E402
21
21
  import _hooklib # noqa: E402
22
22
  import _ledgerlib # noqa: E402
23
+ import _modelib # noqa: E402
23
24
  import _planfilelib # noqa: E402
24
25
  import _segmentslib # noqa: E402
25
26
  import _taskboardlib # noqa: E402
@@ -402,11 +403,16 @@ def _footer_status_snapshot(request):
402
403
  prune = _bounded_footer_text(_segmentslib.seg_prune({}, session_id), FOOTER_MAX_PRUNE)
403
404
  except Exception: # noqa: BLE001 - prune is an independent optional segment
404
405
  prune = None
406
+ # #437: the mode plane replaced the `dev-active` presence check. The
407
+ # marker is NOT dual-written, so a reader still probing for it would
408
+ # report inactive forever the moment nothing writes it. `current_mode`
409
+ # resolves through marker_root, matching every other `.markers/` reader
410
+ # — a linked worktree must not read a different file (#604).
405
411
  try:
406
- dev = _arbiterstatelib.dev_active(request["cwd"])
407
- except Exception: # noqa: BLE001 - dev is a fail-soft display fact
412
+ mode = _arbiterstatelib.current_mode(session_id, root=request["cwd"])
413
+ except Exception: # noqa: BLE001 - mode is a fail-soft display fact
408
414
  return unavailable
409
- if type(dev) is not bool:
415
+ if mode not in _modelib.MODES:
410
416
  return unavailable
411
417
  return {
412
418
  "version": 1,
@@ -419,7 +425,7 @@ def _footer_status_snapshot(request):
419
425
  "questions": counts[1],
420
426
  "overrides": counts[2],
421
427
  "sprint": sprint,
422
- "dev": dev,
428
+ "mode": mode,
423
429
  "prune": prune,
424
430
  }},
425
431
  }