@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.
Files changed (67) hide show
  1. package/README.md +41 -98
  2. package/package.json +1 -1
  3. package/plugins/ca-pi/CHANGELOG.md +145 -0
  4. package/plugins/ca-pi/COMMANDS.md +138 -68
  5. package/plugins/ca-pi/SKILLS.md +137 -30
  6. package/plugins/ca-pi/agents/INDEX.md +3 -2
  7. package/plugins/ca-pi/agents/checkpoint-aggregator.md +8 -7
  8. package/plugins/ca-pi/agents/design-quality-reviewer.md +1 -1
  9. package/plugins/ca-pi/agents/finding-triage.md +31 -14
  10. package/plugins/ca-pi/agents/verdict-aggregator.md +64 -0
  11. package/plugins/ca-pi/{ORCHESTRATOR.md → arbiter.md} +37 -36
  12. package/plugins/ca-pi/extensions/codearbiter.js +844 -19
  13. package/plugins/ca-pi/generated/command-catalog.json +386 -196
  14. package/plugins/ca-pi/generated/roles.json +9 -0
  15. package/plugins/ca-pi/hooks/_arbiterstatelib.py +59 -11
  16. package/plugins/ca-pi/hooks/_bashguardlib.py +30 -12
  17. package/plugins/ca-pi/hooks/_gitexec.py +23 -0
  18. package/plugins/ca-pi/hooks/_githooks.py +50 -23
  19. package/plugins/ca-pi/hooks/_hooklib.py +148 -20
  20. package/plugins/ca-pi/hooks/_host.py +9 -1
  21. package/plugins/ca-pi/hooks/_metricslib.py +20 -0
  22. package/plugins/ca-pi/hooks/_modelib.py +762 -0
  23. package/plugins/ca-pi/hooks/_protectedlib.py +13 -4
  24. package/plugins/ca-pi/hooks/_prunelib.py +51 -12
  25. package/plugins/ca-pi/hooks/_prunepolicy.py +33 -7
  26. package/plugins/ca-pi/hooks/_readinjectlib.py +10 -4
  27. package/plugins/ca-pi/hooks/_releaselib.py +278 -48
  28. package/plugins/ca-pi/hooks/_updatelib.py +230 -50
  29. package/plugins/ca-pi/hooks/doctor.py +58 -9
  30. package/plugins/ca-pi/hooks/git-enforce.py +10 -3
  31. package/plugins/ca-pi/hooks/hostapi.py +220 -22
  32. package/plugins/ca-pi/hooks/pi-bridge.py +10 -4
  33. package/plugins/ca-pi/hooks/prompt-submit.py +486 -0
  34. package/plugins/ca-pi/hooks/prune-transcript.py +23 -3
  35. package/plugins/ca-pi/hooks/session-start.py +529 -435
  36. package/plugins/ca-pi/hooks/statusline.py +28 -10
  37. package/plugins/ca-pi/hooks/wire-statusline.py +13 -8
  38. package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +1 -1
  39. package/plugins/ca-pi/includes/command-compatibility.md +16 -0
  40. package/plugins/ca-pi/includes/dangerous-mode.md +57 -0
  41. package/plugins/ca-pi/includes/ops-mode.md +96 -0
  42. package/plugins/ca-pi/includes/pi-host-notes.md +10 -1
  43. package/plugins/ca-pi/includes/redirect.md +12 -1
  44. package/plugins/ca-pi/includes/routing-table.md +14 -5
  45. package/plugins/ca-pi/includes/safety-core.md +86 -0
  46. package/plugins/ca-pi/includes/smarts/core.md +1 -1
  47. package/plugins/ca-pi/routines/INDEX.md +1 -1
  48. package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +55 -3
  49. package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +9 -1
  50. package/plugins/ca-pi/routines/decompose/SKILL.md +1 -1
  51. package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +4 -4
  52. package/plugins/ca-pi/routines/release/SKILL.md +1 -1
  53. package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +5 -4
  54. package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +6 -0
  55. package/plugins/ca-pi/skills/ca-context-check/SKILL.md +6 -0
  56. package/plugins/ca-pi/skills/ca-create-context/SKILL.md +6 -0
  57. package/plugins/ca-pi/skills/ca-decompose/SKILL.md +6 -0
  58. package/plugins/ca-pi/skills/ca-doctor/SKILL.md +4 -0
  59. package/plugins/ca-pi/skills/ca-init/SKILL.md +18 -1
  60. package/plugins/ca-pi/skills/ca-pr/SKILL.md +17 -1
  61. package/plugins/ca-pi/skills/ca-review/SKILL.md +3 -4
  62. package/plugins/ca-pi/skills/ca-spike/SKILL.md +15 -8
  63. package/plugins/ca-pi/skills/ca-status/SKILL.md +13 -1
  64. package/plugins/ca-pi/skills/ca-watch/SKILL.md +6 -0
  65. package/plugins/ca-pi/includes/dev-mode.md +0 -30
  66. package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +0 -36
  67. package/plugins/ca-pi/skills/ca-dev/SKILL.md +0 -42
@@ -27,11 +27,114 @@
27
27
  # Behavioral contract for M1: under Claude Code, everything routed through
28
28
  # this seam resolves to byte-identical results as the pre-seam inline code.
29
29
 
30
+ import json
30
31
  import os
32
+ import re
31
33
  import subprocess
32
34
  import sys
33
35
 
34
- from _gitexec import git_executable
36
+ from _gitexec import git_executable, root_bound_git_env
37
+
38
+
39
+ class PluginRootError(RuntimeError):
40
+ """A host adapter's installed package boundary cannot be authenticated."""
41
+
42
+
43
+ _VERSION_RE = re.compile(
44
+ r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?(?:\+[0-9A-Za-z][0-9A-Za-z.-]*)?$")
45
+
46
+
47
+ def _safe_relative_path(value, label):
48
+ """Reject an absolute or traversing adapter-relative path before joining."""
49
+ if not isinstance(value, str) or not value:
50
+ raise PluginRootError(f"{label} must be a non-empty relative path")
51
+ normalized = os.path.normpath(value)
52
+ if (os.path.isabs(value) or normalized == os.pardir or
53
+ normalized.startswith(os.pardir + os.sep)):
54
+ raise PluginRootError(f"{label} escapes the adapter: {value!r}")
55
+ return normalized
56
+
57
+
58
+ def _inside(root, candidate):
59
+ """Whether normalized ``candidate`` is contained by normalized ``root``."""
60
+ try:
61
+ return os.path.commonpath((root, candidate)) == root
62
+ except ValueError: # distinct Windows volumes are never contained
63
+ return False
64
+
65
+
66
+ def resolve_plugin_root(authority_file, *, adapter_name, adapter_version, manifest_relpath,
67
+ anchor_relpath, environment=None, signal_names=(),
68
+ required_signal_names=(), legacy_signal_names=()):
69
+ """Return the authenticated root of the executing adapter, or fail closed.
70
+
71
+ The authority is the file/module that is already executing. Environment
72
+ variables may only corroborate that exact real path; none can select a
73
+ different, merely plausible package. ``anchor_relpath`` and the manifest
74
+ are constrained inside that root so traversal and symlink escapes cannot
75
+ authenticate an external file.
76
+ """
77
+ if not isinstance(adapter_name, str) or not adapter_name:
78
+ raise PluginRootError("adapter name must be a non-empty string")
79
+ if not isinstance(adapter_version, str) or not _VERSION_RE.fullmatch(adapter_version):
80
+ raise PluginRootError("adapter version must be an exact SemVer string")
81
+ manifest_relpath = _safe_relative_path(manifest_relpath, "manifest path")
82
+ anchor_relpath = _safe_relative_path(anchor_relpath, "anchor path")
83
+ env = os.environ if environment is None else environment
84
+
85
+ # Keep the package boundary lexical until after its parent directories are
86
+ # derived. If hooks/hostapi.py itself is a symlink, realpathing it first
87
+ # would re-home this adapter in a complete, matching foreign package.
88
+ lexical_authority = os.path.abspath(authority_file)
89
+ root = os.path.realpath(os.path.dirname(os.path.dirname(lexical_authority)))
90
+ source = os.path.realpath(lexical_authority)
91
+ anchor = os.path.realpath(os.path.join(root, anchor_relpath))
92
+ if (not _inside(root, source) or not _inside(root, anchor) or
93
+ anchor != source or not os.path.isfile(anchor)):
94
+ raise PluginRootError(
95
+ f"executing adapter anchor is outside or missing from its package: {source}")
96
+
97
+ manifest = os.path.realpath(os.path.join(root, manifest_relpath))
98
+ if not _inside(root, manifest) or not os.path.isfile(manifest):
99
+ raise PluginRootError(
100
+ f"{adapter_name}: required manifest is missing or outside the adapter: {manifest}")
101
+ try:
102
+ with open(manifest, encoding="utf-8") as handle:
103
+ data = json.load(handle)
104
+ except (OSError, ValueError) as error:
105
+ raise PluginRootError(
106
+ f"{adapter_name}: required manifest is unreadable: {manifest} ({error})") from error
107
+ name = data.get("name") if isinstance(data, dict) else None
108
+ version = data.get("version") if isinstance(data, dict) else None
109
+ if name != adapter_name:
110
+ raise PluginRootError(
111
+ f"adapter manifest mismatch at {manifest}: expected {adapter_name!r}, got {name!r}")
112
+ if not isinstance(version, str) or not _VERSION_RE.fullmatch(version):
113
+ raise PluginRootError(
114
+ f"adapter manifest has invalid version at {manifest}: {version!r}")
115
+ if version != adapter_version:
116
+ raise PluginRootError(
117
+ f"adapter manifest version mismatch at {manifest}: "
118
+ f"expected {adapter_version!r}, got {version!r}")
119
+
120
+ for signal in required_signal_names:
121
+ if not env.get(signal):
122
+ raise PluginRootError(
123
+ f"{adapter_name}: required {signal} is absent; executing root is {root}")
124
+ for signal in signal_names:
125
+ value = env.get(signal)
126
+ if not value:
127
+ continue
128
+ claimed = os.path.realpath(os.path.abspath(value))
129
+ if claimed != root:
130
+ raise PluginRootError(
131
+ f"{adapter_name}: {signal} root {claimed} disagrees with "
132
+ f"executing adapter root {root}")
133
+ if signal in legacy_signal_names:
134
+ sys.stderr.write(
135
+ f"codeArbiter: {signal} is deprecated for {adapter_name}; "
136
+ "accepted only as matching corroboration.\n")
137
+ return root
35
138
 
36
139
 
37
140
  def git_toplevel(cwd=None):
@@ -52,7 +155,7 @@ def git_toplevel(cwd=None):
52
155
  try:
53
156
  out = subprocess.run(
54
157
  args, capture_output=True, text=True, encoding="utf-8",
55
- errors="replace", timeout=5,
158
+ errors="replace", timeout=5, env=root_bound_git_env(),
56
159
  )
57
160
  if out.returncode == 0:
58
161
  top = out.stdout.strip()
@@ -63,6 +166,38 @@ def git_toplevel(cwd=None):
63
166
  return None
64
167
 
65
168
 
169
+ def _root_bound_git_env():
170
+ """Compatibility seam for callers of the former local helper."""
171
+ return root_bound_git_env()
172
+
173
+
174
+ def _has_enabled_context(root):
175
+ """Whether a real CONTEXT.md satisfies the canonical activation parser."""
176
+ canonical_root = os.path.realpath(root)
177
+ state_dir = os.path.join(canonical_root, ".codearbiter")
178
+ if os.path.islink(state_dir) or not os.path.isdir(state_dir):
179
+ return False
180
+ context = os.path.join(state_dir, "CONTEXT.md")
181
+ canonical_context = os.path.realpath(context)
182
+ try:
183
+ contained = os.path.normcase(os.path.commonpath(
184
+ [canonical_root, canonical_context])) == os.path.normcase(canonical_root)
185
+ except (TypeError, ValueError):
186
+ contained = False
187
+ if (not contained or os.path.islink(context)
188
+ or not os.path.isfile(canonical_context)):
189
+ return False
190
+ try:
191
+ # Deferred to avoid hostapi <-> _activationlib's import-time cycle.
192
+ # This must remain the single parser for the activation contract,
193
+ # including its accepted UTF-8 BOM spelling.
194
+ from _activationlib import frontmatter_enabled
195
+ enabled, malformed = frontmatter_enabled(canonical_context)
196
+ return enabled and not malformed
197
+ except Exception: # noqa: BLE001 - root selection must fail closed
198
+ return False
199
+
200
+
66
201
  def git_worktree_main_root(root):
67
202
  """When `root` (an already-resolved project root — a `project_root()`
68
203
  answer, NOT necessarily a fresh `git_toplevel` call) is itself the
@@ -100,24 +235,71 @@ def git_worktree_main_root(root):
100
235
  FILE, but only a worktree's `gitdir:` pointer names a path under
101
236
  `.git/worktrees/<name>`; a submodule's names `.git/modules/<name>`, which
102
237
  is not a "main root" to climb to and must fall through untouched (mirrors
103
- `_durabilitylib._gitfile_points_at_worktree`'s same distinction)."""
238
+ `_durabilitylib._gitfile_points_at_worktree`'s same distinction).
239
+
240
+ The selected Git binary is the authority for accepting and resolving the
241
+ worktree metadata. That keeps native relative pointers aligned with Git,
242
+ rejects incomplete or foreign-dialect metadata Git itself cannot use, and
243
+ avoids translating a path grammar in Python. Only Git-confirmed linked
244
+ worktrees whose common directory is the main checkout's `.git` directory,
245
+ and whose linked and reported-main checkouts are both independently
246
+ arbiter-enabled, can escalate the marker root."""
104
247
  git_meta = os.path.join(root, ".git")
105
248
  if not os.path.isfile(git_meta):
106
249
  return None
107
250
  try:
108
- with open(git_meta, encoding="utf-8", errors="replace") as f:
109
- pointer = f.read().strip()
110
- except OSError:
251
+ probe = subprocess.run(
252
+ [git_executable(), "-C", root, "rev-parse", "--path-format=absolute",
253
+ "--git-dir", "--git-common-dir"],
254
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
255
+ timeout=5, env=_root_bound_git_env(),
256
+ )
257
+ except Exception: # noqa: BLE001 - root selection must fail closed
258
+ return None
259
+ lines = [line.strip() for line in probe.stdout.splitlines() if line.strip()]
260
+ if probe.returncode != 0 or len(lines) != 2:
261
+ return None
262
+ git_dir = os.path.normpath(lines[0])
263
+ common_dir = os.path.normpath(lines[1])
264
+ if not os.path.isabs(git_dir) or not os.path.isabs(common_dir):
265
+ return None
266
+ real_git_dir = os.path.normcase(os.path.realpath(git_dir))
267
+ real_common_dir = os.path.normcase(os.path.realpath(common_dir))
268
+ if real_git_dir == real_common_dir:
269
+ return None # ordinary checkout or submodule, not a linked worktree
270
+ expected_admin_parent = os.path.normcase(
271
+ os.path.realpath(os.path.join(common_dir, "worktrees")))
272
+ if os.path.normcase(os.path.realpath(os.path.dirname(git_dir))) != expected_admin_parent:
273
+ return None
274
+ if os.path.basename(common_dir) != ".git" or not os.path.isdir(common_dir):
275
+ return None
276
+ main_root = os.path.dirname(common_dir)
277
+ if not os.path.isdir(main_root):
278
+ return None
279
+ try:
280
+ main_probe = subprocess.run(
281
+ [git_executable(), "-C", main_root, "rev-parse", "--path-format=absolute",
282
+ "--show-toplevel", "--git-common-dir"],
283
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
284
+ timeout=5, env=_root_bound_git_env(),
285
+ )
286
+ except Exception: # noqa: BLE001 - root selection must fail closed
287
+ return None
288
+ main_lines = [line.strip() for line in main_probe.stdout.splitlines() if line.strip()]
289
+ if main_probe.returncode != 0 or len(main_lines) != 2:
290
+ return None
291
+ main_toplevel = os.path.normpath(main_lines[0])
292
+ confirmed_common = os.path.normpath(main_lines[1])
293
+ if not os.path.isabs(main_toplevel) or not os.path.isabs(confirmed_common):
294
+ return None
295
+ if os.path.normcase(os.path.realpath(main_toplevel)) != os.path.normcase(
296
+ os.path.realpath(main_root)):
111
297
  return None
112
- if not pointer.startswith("gitdir: "):
298
+ if os.path.normcase(os.path.realpath(confirmed_common)) != real_common_dir:
113
299
  return None
114
- gitdir = pointer[len("gitdir: "):].strip().replace("\\", "/")
115
- marker = "/.git/worktrees/"
116
- idx = gitdir.find(marker)
117
- if idx == -1:
118
- return None # not a linked worktree (e.g. a submodule) — nothing to climb to
119
- main_git_dir = gitdir[:idx + len("/.git")]
120
- return os.path.dirname(main_git_dir) or None
300
+ if not _has_enabled_context(root) or not _has_enabled_context(main_root):
301
+ return None
302
+ return main_root.replace("\\", "/") if os.name == "nt" else main_root
121
303
 
122
304
 
123
305
  class Host:
@@ -130,6 +312,17 @@ class Host:
130
312
  """
131
313
 
132
314
  name = "claude"
315
+ adapter_name = "ca"
316
+ adapter_version = "2.17.0"
317
+
318
+ # Update-notifier descriptor. Each independently versioned host overrides
319
+ # these three values in its per-plugin _host.py. Keeping the target,
320
+ # release prefix, and remediation command together on the active Host
321
+ # prevents the shared notifier from comparing or instructing for a sibling
322
+ # product line (RA-02).
323
+ update_target = "ca"
324
+ update_tag_prefix = "v"
325
+ update_command = "/plugin marketplace update codearbiter"
133
326
 
134
327
  # Capability flags — what surfaces this host actually has. A hook that
135
328
  # heals/queries a statusline gates on has_statusline; a hook registered
@@ -220,14 +413,16 @@ class Host:
220
413
  return git_worktree_main_root(root) or root
221
414
 
222
415
  def plugin_root(self):
223
- """The plugin payload root: CLAUDE_PLUGIN_ROOT when set, else derived
224
- from this file's own location (<root>/hooks/hostapi.py -> <root>) —
225
- exactly the pre-seam per-entry-script derivation, which resolved
226
- relative to a file in the same hooks/ directory."""
227
- env = os.environ.get("CLAUDE_PLUGIN_ROOT")
228
- if env:
229
- return env
230
- return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
416
+ """The authenticated payload root derived from this executing module.
417
+
418
+ CLAUDE_PLUGIN_ROOT may corroborate that derived root, but can never
419
+ select a different adapter package.
420
+ """
421
+ return resolve_plugin_root(
422
+ __file__, adapter_name=self.adapter_name, adapter_version=self.adapter_version,
423
+ manifest_relpath=self.manifest_relpath(), anchor_relpath="hooks/hostapi.py",
424
+ signal_names=("CLAUDE_PLUGIN_ROOT",),
425
+ )
231
426
 
232
427
  def manifest_relpath(self):
233
428
  """The plugin manifest's path, relative to plugin_root() (#263,
@@ -402,6 +597,9 @@ class FailClosedHost(Host):
402
597
  silent (observability-002)."""
403
598
 
404
599
  name = "unknown"
600
+ update_target = None
601
+ update_tag_prefix = None
602
+ update_command = None
405
603
  has_statusline = False
406
604
  has_read_tool = False
407
605
  has_prunable_transcript = False
@@ -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
  }