@arbiterforge/ca-pi 0.8.1 → 0.10.2

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 (46) hide show
  1. package/README.md +29 -90
  2. package/package.json +1 -1
  3. package/plugins/ca-pi/CHANGELOG.md +89 -0
  4. package/plugins/ca-pi/COMMANDS.md +141 -64
  5. package/plugins/ca-pi/SKILLS.md +137 -28
  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/finding-triage.md +31 -14
  9. package/plugins/ca-pi/agents/verdict-aggregator.md +64 -0
  10. package/plugins/ca-pi/arbiter.md +12 -3
  11. package/plugins/ca-pi/extensions/codearbiter.js +137 -15
  12. package/plugins/ca-pi/generated/command-catalog.json +386 -186
  13. package/plugins/ca-pi/generated/roles.json +9 -0
  14. package/plugins/ca-pi/hooks/_bashguardlib.py +33 -16
  15. package/plugins/ca-pi/hooks/_gitexec.py +23 -0
  16. package/plugins/ca-pi/hooks/_githooks.py +50 -23
  17. package/plugins/ca-pi/hooks/_hooklib.py +94 -7
  18. package/plugins/ca-pi/hooks/_host.py +9 -1
  19. package/plugins/ca-pi/hooks/_modelib.py +173 -55
  20. package/plugins/ca-pi/hooks/_protectedlib.py +13 -4
  21. package/plugins/ca-pi/hooks/_releaselib.py +278 -48
  22. package/plugins/ca-pi/hooks/_updatelib.py +230 -50
  23. package/plugins/ca-pi/hooks/doctor.py +56 -8
  24. package/plugins/ca-pi/hooks/git-enforce.py +10 -3
  25. package/plugins/ca-pi/hooks/hostapi.py +220 -22
  26. package/plugins/ca-pi/hooks/session-start.py +8 -6
  27. package/plugins/ca-pi/hooks/statusline.py +1 -1
  28. package/plugins/ca-pi/hooks/wire-statusline.py +13 -8
  29. package/plugins/ca-pi/includes/command-compatibility.md +16 -0
  30. package/plugins/ca-pi/includes/routing-table.md +13 -5
  31. package/plugins/ca-pi/routines/INDEX.md +1 -1
  32. package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +54 -2
  33. package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +9 -1
  34. package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +4 -4
  35. package/plugins/ca-pi/routines/release/SKILL.md +1 -1
  36. package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +5 -4
  37. package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +6 -0
  38. package/plugins/ca-pi/skills/ca-context-check/SKILL.md +6 -0
  39. package/plugins/ca-pi/skills/ca-create-context/SKILL.md +6 -0
  40. package/plugins/ca-pi/skills/ca-decompose/SKILL.md +6 -0
  41. package/plugins/ca-pi/skills/ca-doctor/SKILL.md +4 -0
  42. package/plugins/ca-pi/skills/ca-init/SKILL.md +18 -1
  43. package/plugins/ca-pi/skills/ca-pr/SKILL.md +17 -1
  44. package/plugins/ca-pi/skills/ca-review/SKILL.md +3 -4
  45. package/plugins/ca-pi/skills/ca-status/SKILL.md +13 -1
  46. package/plugins/ca-pi/skills/ca-watch/SKILL.md +6 -0
@@ -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.1"
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
@@ -754,16 +754,18 @@ def clear_mode_marker(root, host_name=None, session_id=None, now=None):
754
754
  # "No opinion yet" is the ABSENCE of an entry, not the arbiter VALUE:
755
755
  # `current_mode` answers `arbiter` for both "never flipped" and
756
756
  # "deliberately flipped back", and only the first may be migrated over.
757
- # The raw state map is the only place that distinction survives.
758
- # A corrupt or unreadable state file returns `{}` WITH a diagnostic,
759
- # which reads as "no entry" and would migrate `dangerous` back on top
757
+ # `session_has_entry` is the only reader that keeps that distinction
758
+ # (it also consults the pre-#681 shared map, so a session that chose
759
+ # before the per-session split still counts as having chosen).
760
+ # A corrupt or unreadable entry answers False WITH a diagnostic, which
761
+ # would otherwise read as "no entry" and migrate `dangerous` back on top
760
762
  # of a user who had explicitly returned to `arbiter`. Absence is the
761
763
  # only clean "nothing to convert over"; anything we could not read is
762
764
  # not evidence of anything, and guessing gates-off from unreadable
763
765
  # state is the one direction ADR-0030 forbids.
764
- mode_state, state_diag = _modelib._read_mode_state(root)
766
+ has_entry, state_diag = _modelib.session_has_entry(session_id, root=root)
765
767
  readable = state_diag in (None, _modelib.MODE_DIAG_ABSENT)
766
- unconverted = readable and str(session_id) not in mode_state
768
+ unconverted = readable and not has_entry
767
769
  if marker_live and unconverted and _modelib.write_mode(session_id, "dangerous", root=root):
768
770
  try:
769
771
  os.remove(marker)
@@ -883,7 +885,7 @@ def update_notice_line(plugin):
883
885
  no network call itself."""
884
886
  try:
885
887
  state = _updatelib.read_state(_updatelib.state_path())
886
- latest = state.get("latest") if isinstance(state, dict) else None
888
+ latest = _updatelib.target_state(state).get("latest")
887
889
  installed = _updatelib.installed_version(plugin)
888
890
  return _updatelib.notice_line(installed, latest) or ""
889
891
  except Exception: # noqa: BLE001 — never crash session startup
@@ -431,7 +431,7 @@ def seg_update(plugin=None):
431
431
  try:
432
432
  plugin = plugin if plugin is not None else plugin_root_for_render()
433
433
  state = _updatelib.read_state(_updatelib.state_path())
434
- latest = state.get("latest") if isinstance(state, dict) else None
434
+ latest = _updatelib.target_state(state).get("latest")
435
435
  installed = _updatelib.installed_version(plugin)
436
436
  if not _updatelib.update_available(installed, latest):
437
437
  return None
@@ -68,14 +68,19 @@ ARBITER_SPINNER_VERBS = {
68
68
 
69
69
 
70
70
  def plugin_root(opt):
71
- if opt:
72
- return os.path.abspath(opt)
73
- # Host seam (ADR-0011): CLAUDE_PLUGIN_ROOT then this script's parent —
74
- # exactly the prior inline lookup (hostapi.py lives in the same hooks/ dir,
75
- # so its file-relative fallback names the same root). get_host() (#257),
76
- # not a direct hostapi.load_host(): resolves the SAME Host run(host)
77
- # injected instead of triggering a second disk load.
78
- return os.path.abspath(_hooklib.get_host().plugin_root())
71
+ # The host seam authenticates the package currently executing this hook.
72
+ # --plugin-root remains test/operator corroboration, never an authority
73
+ # able to select a different executable for settings.json.
74
+ authenticated = os.path.realpath(
75
+ os.path.abspath(_hooklib.get_host().plugin_root()))
76
+ if not opt:
77
+ return authenticated
78
+ requested = os.path.realpath(os.path.abspath(opt))
79
+ if requested != authenticated:
80
+ raise SystemExit(
81
+ "ERROR: --plugin-root must match the authenticated executing "
82
+ f"adapter root ({authenticated}); got {requested}.")
83
+ return authenticated
79
84
 
80
85
 
81
86
  def settings_path(opt):
@@ -0,0 +1,16 @@
1
+ # Command-route compatibility
2
+
3
+ A compatibility alias is an installed legacy route with a preferred canonical form. The legacy
4
+ route keeps its argument grammar, confirmation gates, side effects, durable outputs, and host
5
+ availability; its migration notice does not invoke or forward to another host command.
6
+
7
+ The registry permanently declares ca 2.17.0, ca-codex 0.9.0, and ca-pi 0.10.0 as the first-containing
8
+ candidates. Each payload's deprecation clock becomes effective only when GitHub's Release API confirms
9
+ an exact, non-draft Release for that candidate tag and the tag's commit contains both the matching
10
+ registry declaration and matching payload version.
11
+ A tag alone, a draft, unavailable API evidence, or any tag/Release/payload mismatch does not start a
12
+ clock. Published releases ca 2.16.0, ca-codex 0.8.0, and ca-pi 0.9.0 predate this registry and do not
13
+ contain the compatibility metadata. ca retains these routes through every 2.x release, with no removal
14
+ before a separately approved 3.0.0. ca-codex and ca-pi retain them through every later 0.x release,
15
+ with no removal before a separately approved 1.0.0. Passing a version floor never authorizes removal:
16
+ removal needs a new governed decision and fresh compatibility evidence.
@@ -1,14 +1,22 @@
1
1
  # Routing table
2
2
 
3
3
  Loaded on a scope-touch or `/command`, not every turn. This table is the authoritative trigger→route
4
- surface: it answers *what to invoke or route given a trigger*; for *what doc to read before touching a
5
- scope*, use `reference-map.md`. Follow the primary route; the gate is a hard stop, not a suggestion. A
6
- command is **invoked**; the orchestrator **routes** to a skill; a skill **dispatches** an agent.
4
+ surface and destructive-operation registry: it answers *what to invoke or route given a trigger* and
5
+ *which operations always require tier-2 confirmation*; for *what doc to read before touching a scope*,
6
+ use `reference-map.md`. Follow the primary route; the gate is a hard stop, not a suggestion. A command
7
+ is **invoked**; the orchestrator **routes** to a skill; a skill **dispatches** an agent.
7
8
  Routing to a skill means loading its body from `<plugin-root>/routines/<name>/SKILL.md` — a route
8
9
  cell names the skill; this path convention locates it. That resolution never depends on the host's
9
10
  skill registry: a chain-internal skill hidden from the registry (`disable-model-invocation`) is
10
11
  reached the same way.
11
12
 
13
+ ## Destructive operations (tier-2 regardless of cue)
14
+
15
+ - Logged bypass (`/override`)
16
+ - Merge to the default branch
17
+ - Branch or worktree deletion
18
+ - Release and tag publication
19
+
12
20
  | Invocation cue | Primary route | Also dispatch | Hard gate |
13
21
  |---|---|---|---|
14
22
  | New feature | `/feature` Step 0 triage → full lane `brainstorming` → `writing-plans` → `executing-plans` → `tdd`, or logged small lane straight to `tdd` | `backend-`/`frontend-`/`infra-author` | No spec, no code; no code before `tdd` Phase 1; small lane only on all triage criteria, logged to `triage.log` |
@@ -21,8 +29,8 @@ reached the same way.
21
29
  | Commit | `/commit` → `commit-gate` | — | No commit without all nine gates green |
22
30
  | Open a PR / finish a branch | `/pr` → `finishing-a-development-branch` | reviewer fleet per path; PR-body prose applies `anti-slop-design` (`core` + `medium-documents` §7.A.1) | PR only; no direct-to-default, no force-push |
23
31
  | Watch a PR's CI / babysit checks | `/watch` → detached `gh pr checks --watch` | on-red diagnose (propose\|branch) | Never auto-merges; green → notify + offer; merge-to-default routes through the hard gate; no poll loop |
24
- | Code review of the diff | `/review` → `dispatching-parallel-agents` | reviewer fleet → `finding-triage` → `checkpoint-aggregator` | BLOCK on any CRITICAL/HIGH |
25
- | Periodic sweep | `/checkpoint` → `dispatching-parallel-agents` | reviewer fleet → triage → aggregator | Surfaces a triaged report; not a promotion gate |
32
+ | Code review of the diff | `/review` → `dispatching-parallel-agents` | reviewer fleet → `finding-triage` → `verdict-aggregator` | BLOCK on any CRITICAL/HIGH |
33
+ | Periodic sweep | `/checkpoint` → `dispatching-parallel-agents` | reviewer fleet → finding-triage → read-only verdict; then explicit `checkpoint-aggregator` persistence | Surfaces and persists a triaged report; not a promotion gate |
26
34
  | Governance record for a window | `/audit` | — | Read-only; never overwrites a packet; audit lines quoted verbatim |
27
35
  | Release / version tag | `/release` → `release` skill | `commit-gate` (release commit); CHANGELOG prose applies `anti-slop-design` (`core` §3.A/§3.B) | No tag on a red suite; tag not pushed unbidden |
28
36
  | Code uses crypto / hashing / signing / TLS / random | `crypto-compliance` skill | `auth-crypto-reviewer` | BLOCK on any banned primitive |
@@ -18,7 +18,7 @@ Skill bodies load on routing only. This index is the surface scan; never bulk-re
18
18
  | [writing-plans](writing-plans/SKILL.md) | `/feature`, `/sprint` (after the spec) | Decomposes an approved spec into small tasks, each with a path + a verification that maps to a `tdd` obligation; writes `plans/<slug>.md` with bijective criterion↔task coverage. |
19
19
  | [executing-plans](executing-plans/SKILL.md) | `/feature` | Checkpoint coordinator — groups tasks into batches, delegates each to `subagent-driven-development` (fresh author agent per task, full review chain), stops for user acknowledgement between batches. |
20
20
  | [subagent-driven-development](subagent-driven-development/SKILL.md) | `/sprint` (engine), `executing-plans` (batch scope) | Fresh subagent per task → spec-compliance then quality review → fresh-run verification; accepts only on proof. Hard-stops on `tdd` BLOCK, security CRITICAL, `[CONFIRM-NN]`. |
21
- | [dispatching-parallel-agents](dispatching-parallel-agents/SKILL.md) | `subagent-driven-development`, `/sprint`, parallel `/review` | Reusable fan-out primitive: bound concurrency, collect, dedupe, funnel through `finding-triage`→`checkpoint-aggregator`. Results unused until the funnel runs. |
21
+ | [dispatching-parallel-agents](dispatching-parallel-agents/SKILL.md) | `subagent-driven-development`, `/sprint`, parallel `/review` | Reusable fan-out primitive: bound concurrency, collect, dedupe, funnel through `finding-triage`→`verdict-aggregator`. Results unused until the read-only funnel runs. |
22
22
  | [finishing-a-development-branch](finishing-a-development-branch/SKILL.md) | `/feature`, `/sprint` (terminal) | The terminal step after `commit-gate`: open-PR / merge-via-PR / discard. No direct-to-main, no force-push; `/sprint` auto-selects open-PR and never merges. |
23
23
  | [using-git-worktrees](using-git-worktrees/SKILL.md) | `subagent-driven-development`, `dispatching-parallel-agents` (opt-in) | OPTIONAL per-unit filesystem isolation for parallel work; integrates accepted units back onto the caller's working branch for its single `commit-gate` + finish. Never the default path. |
24
24
  | [secret-handling](secret-handling/SKILL.md) | changed code reads/writes/passes a secret | The secret-source gate: identify → source → sinks/persistence. Secrets only from the approved store in `security-controls.md`; never in source, log, error, telemetry, image, or LLM prompt. Dispatches `auth-crypto-reviewer`. |
@@ -57,9 +57,59 @@ rm -f "$(git rev-parse --show-toplevel)/.codearbiter/.markers/adr-authoring-acti
57
57
 
58
58
  Gate: the ADR file is written with a real `decided-by` user attribution, numbered without a gap, and its log entry is appended. An ADR with no user attribution, or authored as the disposition of a finding, does not pass — STOP.
59
59
 
60
+ ### Accepted/Planned binding
61
+
62
+ `accepted` means **Accepted/Planned**. It records the user's governance decision; it does not claim
63
+ that any obligation is Implemented or Verified. When the user explicitly authorizes acceptance:
64
+
65
+ 1. Change only the ADR's status fields to accepted. Derive stable, stem-scoped obligations from every
66
+ normative clause in its immutable record, bind each obligation to exact ADR
67
+ text, and obtain independent review that the sealed obligation set is complete.
68
+ 2. Route through `commit-gate` to commit the accepted ADR and decision-log append. Do not add the
69
+ acceptance binding to that commit: its `source_commit` cannot truthfully name a commit that does
70
+ not exist yet.
71
+ 3. From that exact commit, hash the committed Git blob bytes and the separately canonicalized
72
+ immutable record: strict UTF-8 with LF-normalized line endings, containing the complete ADR while
73
+ replacing only the recognized status value in the strictly parsed frontmatter `status:` field and
74
+ `## Status` section with fixed sentinels. The two values must agree. All remaining Status prose,
75
+ including approval attribution, stays bound alongside title, date, `decided-by`, supersession,
76
+ governed paths, H1, and every other section. Malformed or duplicate frontmatter, status, or
77
+ headings fail closed. Append one
78
+ `acceptance` event to
79
+ `<project-root>/.codearbiter/decisions/adr-lifecycle.jsonl`, then persist that acceptance binding
80
+ in a subsequent commit. The event uses schema `adr-lifecycle/v1` and records `adr` (full stem),
81
+ `recorded_at`, `source_commit`, `blob_sha256`, `body_sha256`, `obligations`,
82
+ `obligations_sha256`, and `obligations_sealed: true`. A second acceptance or baseline binding for
83
+ the same stem is invalid.
84
+
85
+ The lifecycle ledger is append-only. A legacy accepted ADR receives a `baseline` with no fabricated
86
+ acceptance commit, an `observed_commit` whose Git blob is rechecked as the migration snapshot, an
87
+ empty or incrementally mapped obligation list, and
88
+ `obligations_sealed: false`; it remains Accepted/Planned. Later delivery evidence appends records:
89
+ `implemented` binds one declared obligation to a source commit and relevant input digests;
90
+ `verified` additionally binds a unique event ID, explicit proof contract, repository-scoped claim,
91
+ producer, command/workflow identity, timezone-aware observation and expiry times, and the same current
92
+ inputs. Evidence paths and digests are recomputed from the named Git commit, never trusted from the
93
+ caller. A later uniquely identified event may renew expired or changed-input evidence; an append-only
94
+ invalidation event may withdraw a prior evidence event. Only a complete,
95
+ sealed obligation set with current implementation inputs and fresh verification inputs derives
96
+ Implemented or Verified. Changed inputs invalidate the derived state; history is never rewritten.
97
+
98
+ After acceptance, do not edit any bound ADR content. A later user-authorized stored status transition
99
+ may change only the recognized status value in the strictly parsed frontmatter `status:` and
100
+ `## Status`; approval prose remains immutable. Supersession remains a forward reference in the new
101
+ ADR. The acceptance commit retains the exact original blob while the immutable-record digest proves
102
+ every other byte-equivalent field did not change.
103
+
60
104
  ## Phase 3 — Status (/adr-status) · gate: BLOCK
61
105
 
62
- Read-only. For each ADR (or the `--adr N` target), report: stem, title, status, date, and supersession state — found by scanning forward for any later ADR whose `supersedes:` **resolves to** it.
106
+ Read-only. For each ADR (or the `--adr N` target), report: stem, title, stored governance status,
107
+ derived delivery state, date, and supersession state. Read `adr-lifecycle.jsonl` when present. Display
108
+ stored `accepted` as **Accepted/Planned**. Display Implemented or Verified only when every obligation
109
+ in a sealed binding has the required current, input-bound evidence; otherwise name the narrow reason
110
+ (unsealed, incomplete, stale, expired, or mismatched) and do not promote the ADR. Repository evidence
111
+ never implies live-host, publication, support, legal, or other external truth. Find supersession by
112
+ scanning forward for any later ADR whose `supersedes:` **resolves to** it.
63
113
 
64
114
  Resolve a `supersedes:` value like this, and never guess:
65
115
 
@@ -75,7 +125,7 @@ If a supersession candidate contradicts an `accepted` ADR with no clear directio
75
125
  ## ADR Status — YYYY-MM-DD
76
126
 
77
127
  ### Active
78
- - ADR-NNNN-<slug> — <title> — <status> (<date>)
128
+ - ADR-NNNN-<slug> — <title> — governance: <status>; delivery: <Accepted/Planned | Implemented | Verified> (<date>)
79
129
 
80
130
  ### Superseded
81
131
  - ADR-NNNN-<slug> — <title> — superseded by ADR-MMMM-<slug>
@@ -97,6 +147,8 @@ Gate: every indexed ADR appears with its current status and supersession state;
97
147
  - MUST NOT record a decision the user did not explicitly make. "Use your best judgment," "I trust you" are declined.
98
148
  - MUST NOT resolve a `[CONFIRM-NN]` placeholder by guessing. Surface it and stop.
99
149
  - MUST NOT advance an ADR's status without explicit user instruction.
150
+ - MUST NOT report accepted as Implemented or Verified without complete, sealed, current lifecycle evidence.
151
+ - MUST NOT rewrite or truncate a committed `adr-lifecycle.jsonl`, create a second binding, or fabricate legacy acceptance evidence.
100
152
  - MUST NOT edit a prior ADR or a prior decision-log entry to add a back-reference — supersession is a forward-only chain; append a new record whose `supersedes:` names the prior one.
101
153
  - **The never-edit rule protects decision CONTENT, not identifiers.** Rewriting what was decided corrupts the record; disambiguating *which document a pointer names* repairs it. Maintainer ruling, 2026-07-25: *"the never edit rule is meant to prevent this situation, not prevent this situation from being fixed."* So a correction that is provably identifier-only — a `supersedes:` value changed from a number to the stem it already meant — is permissible, and nothing else about the file is. Any such correction MUST be a single-line diff that alters not one word of any decision, MUST be visible in its own commit, and still requires the maintainer-armed `adr-authoring-active` marker. MUST NOT touch Context, Decision, Alternatives, Consequences, Risks, `status:`, `date:`, `decided-by:`, or `title:` under this allowance.
102
154
  - MUST NOT number an ADR with a gap, and MUST NOT reuse a number an existing stem already holds — a shared number makes every bare reference to it ambiguous.
@@ -55,8 +55,16 @@ governs: <optional, comma-separated path globs this decision constrains — e.g.
55
55
  mirrors it for human readers. Keep the two in agreement.
56
56
  - **Status lifecycle:** `proposed → accepted → superseded | rejected`. `decompose` authors Layer 4
57
57
  ADRs as **`status: draft`** during the interview and promotes each to `status: accepted` at its
58
- Phase 5 (a frontmatter `status:` edit only never a body rewrite). Status transitions otherwise
58
+ Phase 5 in one sanctioned status edit that changes both the frontmatter `status:` field and the `## Status` value,
59
+ without changing any other body content. Status transitions otherwise
59
60
  require explicit user instruction; never advance status on the skill's own judgment.
61
+ - **`accepted` means Accepted/Planned.** It records an approved governance decision and does not imply
62
+ implementation. Implemented and Verified are derived delivery states from the separate append-only
63
+ `adr-lifecycle.jsonl`; they are never written into ADR frontmatter. After acceptance, the decision
64
+ record is immutable except for the recognized, agreeing status value in the strictly parsed
65
+ frontmatter `status:` field and `## Status` section. Approval attribution and all other Status prose
66
+ remain bound with title, date, `decided-by`, supersession, governed paths, H1, and every other
67
+ section. A later explicit status transition changes only those recognized status values.
60
68
  - **`decided-by:`** names the user who made the decision — real attribution, never inferred.
61
69
  - **`supersedes:`** names the prior ADR's full filename stem — `supersedes:
62
70
  0014-githook-shim-dropin-fail-closed`, not `supersedes: 0014` — or `none`. A bare number is
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: dispatching-parallel-agents
3
- description: "The parallel fan-out primitive. Routed to by any skill or command that splits work across independent units and dispatches an agent per unit — subagent-driven-development, /sprint, parallel /review. It owns the dispatch/collect/funnel discipline: bound concurrency, isolate units, collect every result, dedupe overlap, and funnel through finding-triage then checkpoint-aggregator. Raw agent output is never consumed before the funnel runs; an agent that errors drops its unit without corrupting the batch."
3
+ description: "The parallel fan-out primitive. Routed to by any skill or command that splits work across independent units and dispatches an agent per unit — subagent-driven-development, /sprint, parallel /review. It owns the dispatch/collect/funnel discipline: bound concurrency, isolate units, collect every result, dedupe overlap, and funnel through finding-triage then the read-only verdict-aggregator. Raw agent output is never consumed before the funnel runs; an agent that errors records its unit as incomplete without corrupting the batch."
4
4
  disable-model-invocation: true
5
5
  ---
6
6
 
@@ -59,8 +59,8 @@ Gate: the result set is deduped, contradictions surfaced, and completion claims
59
59
 
60
60
  The batch is consumed only here, through the fixed funnel — never directly by the caller.
61
61
 
62
- 1. Dispatch `finding-triage` (`<plugin-root>/agents/finding-triage.md`) over the deduped result set: it classifies severity, marks out-of-scope items with an inline `[NEEDS-TRIAGE]` marker, and discards noise.
63
- 2. Hand the triaged set to `checkpoint-aggregator` (`<plugin-root>/agents/checkpoint-aggregator.md`): it aggregates into the single batch verdict the caller consumes — pass, or a blocking finding list.
62
+ 1. Dispatch `finding-triage` (`<plugin-root>/agents/finding-triage.md`) over the deduped result set and batch completion contract: it classifies severity and marks out-of-scope items with an inline `[NEEDS-TRIAGE]` marker. Every reviewer finding reaches the verdict; only non-finding transport metadata may be omitted.
63
+ 2. Hand the triaged set to `verdict-aggregator` (`<plugin-root>/agents/verdict-aggregator.md`): it composes the single read-only batch verdict the caller consumes — `PASS`, `BLOCKING_FINDINGS`, or `INCOMPLETE`.
64
64
 
65
65
  The errored and deferred units from Phase 3 ride through the funnel as findings — an `ERRORED` unit is a finding the caller must see, not a silent gap.
66
66
 
@@ -71,6 +71,6 @@ Gate: the caller receives only the aggregated verdict. Bypassing the funnel —
71
71
  - MUST NOT dispatch two units that mutate the same path in one batch; isolate via `using-git-worktrees` or serialize.
72
72
  - MUST NOT fan out unbounded; dispatch within the concurrency bound.
73
73
  - MUST NOT let one `ERRORED` unit discard or corrupt the rest of the batch.
74
- - MUST NOT consume agent output before the `finding-triage` → `checkpoint-aggregator` funnel runs.
74
+ - MUST NOT consume agent output before the `finding-triage` → `verdict-aggregator` funnel runs.
75
75
  - MUST NOT trust a subagent's self-reported completion; verify with a fresh proving command.
76
76
  - MUST NOT silently drop a unit — every unit terminates with a recorded state that rides through the funnel.
@@ -269,7 +269,7 @@ The tag and the GitHub Release publish together, and only after the user explici
269
269
  On authorization:
270
270
 
271
271
  1. Push the tag: `git push origin ${TAG_PREFIX}${VERSION}`.
272
- 2. **Resolve `<Phase-1 section file>` fresh in every Phase-3 invocation — never assume Phase 1's scratch file survived** (HIGH, blind exercise run 19). That file was created with `mktemp` outside the working tree and discarded once Phase 3 no longer needed it; on the `resume_publish` path (tag composed in a prior invocation, published now) it is normally already gone, and there was previously no stated way to get it back that did not read as the "re-derive or hand-write" the hard rules forbid. There is one sanctioned, mechanical way: `"$PY" "<plugin-root>/hooks/_releaselib.py" changelog-section $CHANGELOG ${VERSION}` prints the `## [${VERSION}] …` section back out of the COMMITTED `$CHANGELOG` verbatim — guaranteed present, because Phase 1 step 7 committed it before any tag existed. Redirect its stdout to a fresh local file and use that as `<Phase-1 section file>` for every step below; this is reading the exact text back from its one permanent home, not composing new notes. Exit 1 (no heading for `${VERSION}`) means `$CHANGELOG` and the tag have drifted STOP and investigate; never compose a substitute section by hand. On a same-session fresh publish the Phase 1 scratch file is still there and this reconstruction is redundant but harmless — run it anyway, so Phase 3 does not need to know which case it is in. **Guard the notes-file first:** assert its first heading matches the tag — `"$PY" "<plugin-root>/hooks/_releaselib.py" notes-match ${TAG_PREFIX}${VERSION} <Phase-1 section file>` (exit 0). A stale notes-file (`notes_heading_matches` False) would publish the wrong changelog section under the right tag — STOP on mismatch. Then create the GitHub Release from the **same changelog section composed in Phase 1** — reuse it as the notes, never re-derive or hand-write them. **`--latest` follows the declared row:** assert it only when `$TARGET`'s row declares `latest-eligible: true`, and only when this tag is also the newest release across every declared series (compare against `gh release list`; vacuously satisfied when only one series is declared); every other target passes `--latest=false`. GitHub has one repo-wide "Latest"; a declared file may name several series, so a target claiming it wrongly hides another's current release from every visitor. `gh release create ${TAG_PREFIX}${VERSION} --title "<title>" --notes-file <Phase-1 section file> --latest --verify-tag` when the row qualifies per the rule above, otherwise `gh release create ${TAG_PREFIX}${VERSION} --title "<title>" --notes-file <Phase-1 section file> --latest=false --verify-tag` — two distinct, individually runnable commands, never the bracket notation `--latest[=false]`, which is prose shorthand and not shell `gh` accepts. The title convention is `<$DISPLAY_NAME> ${VERSION}: <summary>` — `$DISPLAY_NAME` is the row's declared `display-name`, or `$TARGET` itself when the row declares none — with no em-dash separator. **`<summary>` is derived, not invented** (MEDIUM, adversarial review 2026-07-31, run 3: it appeared exactly once in this file and was never defined, so it was whatever the agent made up): take the single highest-precedence entry from the Phase 1 section — the first bullet under `### Added` if the window bumped minor, otherwise the first bullet under `### Fixed`, else the first bullet of the first non-empty group — and compress it to a noun phrase under ten words, in the entry's own words. If that yields nothing usable because the section has one group with one terse bullet, use that bullet verbatim. Never write a summary that names a change absent from the section.
272
+ 2. **Resolve `<Phase-1 section file>` fresh in every Phase-3 invocation — never assume Phase 1's scratch file survived** (HIGH, blind exercise run 19). That file was created with `mktemp` outside the working tree and discarded once Phase 3 no longer needed it; on the `resume_publish` path (tag composed in a prior invocation, published now) it is normally already gone, and there was previously no stated way to get it back that did not read as the "re-derive or hand-write" the hard rules forbid. There is one sanctioned, mechanical way: `"$PY" "<plugin-root>/hooks/_releaselib.py" changelog-section "<project-root>" "${TAG_PREFIX}${VERSION}" "$CHANGELOG" "$VERSION"` prints the `## [${VERSION}] …` section back out of the exact regular-file blob committed under the already-composed tag — guaranteed present, because Phase 1 step 7 committed it before the tag was created. The project root and every value are separate quoted arguments; the helper rejects a nested or unrelated root, an absolute or escaping changelog path, a non-regular Git tree entry, a malformed heading, `Unreleased` in a released position, and duplicate target sections. It resolves the tag to a commit hash before reading the blob, so a dirty/deleted working file, a changed current `HEAD`, or an unrelated current directory cannot substitute Release notes after the tag is composed. Redirect stdout to a fresh local file and use that as `<Phase-1 section file>` for every step below; this is reading the exact text back from its one permanent home, not composing new notes. Exit 1 (no heading for `${VERSION}`) means the committed changelog and tag version disagree; exit 3 means the root/revision/path/blob binding could not be proven; exit 4 means the changelog is malformed or ambiguous. Every one STOPs for investigation; never compose a substitute section by hand. On a same-session fresh publish the Phase 1 scratch file is still there and this reconstruction is redundant but harmless — run it anyway, so Phase 3 does not need to know which case it is in. **Guard the notes-file first:** assert its first heading matches the tag — `"$PY" "<plugin-root>/hooks/_releaselib.py" notes-match "${TAG_PREFIX}${VERSION}" <Phase-1 section file>` (exit 0). A stale notes-file (`notes_heading_matches` False) would publish the wrong changelog section under the right tag — STOP on mismatch. Then create the GitHub Release from the **same changelog section composed in Phase 1** — reuse it as the notes, never re-derive or hand-write them. **`--latest` follows the declared row:** assert it only when `$TARGET`'s row declares `latest-eligible: true`, and only when this tag is also the newest release across every declared series (compare against `gh release list`; vacuously satisfied when only one series is declared); every other target passes `--latest=false`. GitHub has one repo-wide "Latest"; a declared file may name several series, so a target claiming it wrongly hides another's current release from every visitor. `gh release create "${TAG_PREFIX}${VERSION}" --title "<title>" --notes-file <Phase-1 section file> --latest --verify-tag` when the row qualifies per the rule above, otherwise `gh release create "${TAG_PREFIX}${VERSION}" --title "<title>" --notes-file <Phase-1 section file> --latest=false --verify-tag` — two distinct, individually runnable commands, never the bracket notation `--latest[=false]`, which is prose shorthand and not shell `gh` accepts. The title convention is `<$DISPLAY_NAME> ${VERSION}: <summary>` — `$DISPLAY_NAME` is the row's declared `display-name`, or `$TARGET` itself when the row declares none — with no em-dash separator. **`<summary>` is derived, not invented** (MEDIUM, adversarial review 2026-07-31, run 3: it appeared exactly once in this file and was never defined, so it was whatever the agent made up): take the single highest-precedence entry from the Phase 1 section — the first bullet under `### Added` if the window bumped minor, otherwise the first bullet under `### Fixed`, else the first bullet of the first non-empty group — and compress it to a noun phrase under ten words, in the entry's own words. If that yields nothing usable because the section has one group with one terse bullet, use that bullet verbatim. Never write a summary that names a change absent from the section.
273
273
  3. Handle edge cases explicitly, never silently: if a Release for the tag already exists, report it and skip creation (the tag push may already have landed); if `gh` is missing, unauthenticated, or the call fails, STOP and print the exact `gh release create` command so publication can be finished by hand rather than left half-done.
274
274
  4. **Verify publication — never assume it.** Read the Release back: `gh release view ${TAG_PREFIX}${VERSION} --json url,isDraft,tagName`. STOP unless it returns a **non-draft** Release on the correct tag; `gh release create` can partially succeed (tag pushed, Release rejected for an empty notes-file or a permissions/`--verify-tag` race), and an unverified publish is not a published release. Report the Release URL only once the read-back confirms it.
275
275
  5. **Record the tag's provenance, when the row declares one.** A git tag is a mutable ref, and the commit a tag was *originally* published at is not recoverable from the API once it moves — a moved tag looks exactly like a tag that was always there. So, when `$TARGET`'s row declares `$PROVENANCE_MANIFEST`, write it down: add the new tag to that file under `tags`, as `{"object_sha": <the ref's sha>, "object_type": "tag", "commit_sha": <the commit it dereferences to>}`. Read both mechanically from the remote you just pushed to, never from local state: `git ls-remote --tags origin ${TAG_PREFIX}${VERSION}` for the ref sha and `git rev-parse ${TAG_PREFIX}${VERSION}^{commit}` for the commit. If this project runs an automated tag-drift check in CI against that file, an unrecorded tag is an unguarded tag there. **A row declaring no `$PROVENANCE_MANIFEST` skips this step — say so explicitly in the report** rather than silently doing nothing (a skipped step and a forgotten one must never look the same to the person reading the report). The entry rides in a normal commit through `commit-gate` on the release branch.
@@ -23,8 +23,8 @@ Periodic sweep of the entire codebase with the reviewer fleet, funneled to a sin
23
23
  | `architecture-drift-reviewer` | `decisions/`; drift between code and accepted ADRs |
24
24
 
25
25
  2. Route to `dispatching-parallel-agents` (`<plugin-root>/routines/dispatching-parallel-agents/SKILL.md`) with that unit list (read-only batch). It dedupes, then
26
- funnels through `finding-triage` → `checkpoint-aggregator`.
27
- 3. `checkpoint-aggregator` writes the dated report to
26
+ funnels through `finding-triage` → `verdict-aggregator` and returns the single read-only verdict.
27
+ 3. After the verdict returns, separately dispatch `checkpoint-aggregator` with that verdict. It writes the dated report to
28
28
  `<project-root>/.codearbiter/checkpoints/YYYY-MM-DD.md`: findings by severity with
29
29
  file:line, and out-of-scope items marked inline `[NEEDS-TRIAGE]`.
30
30
  4. Write the current override **count** to `<project-root>/.codearbiter/last-checkpoint` — the
@@ -38,8 +38,9 @@ Periodic sweep of the entire codebase with the reviewer fleet, funneled to a sin
38
38
  ## Hard gate
39
39
 
40
40
  Read-only except writing the checkpoint doc and `last-checkpoint` — MUST NOT modify code. MUST NOT
41
- consume raw reviewer output — only the `finding-triage` → `checkpoint-aggregator` verdict. MUST NOT
42
- resolve a `[CONFIRM-NN]` surfaced during the sweep by guessing. The report surfaces findings; it does
41
+ consume raw reviewer output — only the `finding-triage` → `verdict-aggregator` verdict. Checkpoint
42
+ persistence MUST remain the separate `checkpoint-aggregator` step and MUST NOT run for `/ca-review`
43
+ or another generic parallel batch. MUST NOT resolve a `[CONFIRM-NN]` surfaced during the sweep by guessing. The report surfaces findings; it does
43
44
  not block or sign off anything.
44
45
 
45
46
  ## When NOT to use