@arbiterforge/ca-pi 0.14.7 → 0.14.9

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.
@@ -736,6 +736,110 @@ def last_tag_select_for_policy(tags, prefix, policy="semver", initial_version=No
736
736
  return best[1] if best else NONE_SENTINEL
737
737
 
738
738
 
739
+ _ANCESTRY_ZERO_TAG = "zero-tag"
740
+ _ANCESTRY_ANCESTOR = "ancestor"
741
+ _ANCESTRY_NOT_ANCESTOR = "not-ancestor"
742
+ _ANCESTRY_UNRESOLVABLE = "unresolvable"
743
+
744
+
745
+ def verify_tag_ancestor(tag, project_root, candidate="HEAD"):
746
+ """Classify whether `tag` resolves to a commit reachable from `candidate`
747
+ (issue #570, finding BODY-03).
748
+
749
+ `last_tag_select`/`last_tag_select_for_policy` choose a series' highest
750
+ SemVer (or `numeric-sequence`) tag by VALUE alone -- pure functions with
751
+ no git access, per this module's design invariants -- so neither can
752
+ know whether that tag's commit is even in `candidate`'s own commit-graph
753
+ history. A tag pushed once from a sibling branch that diverged from
754
+ `candidate` before the newest tag, an abandoned branch's stray push, or
755
+ an unrelated orphan history that happens to carry a same-prefixed tag
756
+ can still win the highest-SemVer scan even though `candidate` never
757
+ descends from it -- silently anchoring `$BASE_VERSION` (and everything
758
+ derived from it) to history this release does not actually contain.
759
+
760
+ Returns one of four labels, never raises:
761
+ `"zero-tag"` -- `tag` is `NONE_SENTINEL`. There is nothing to
762
+ verify; the zero-tag first-release path is a
763
+ DISTINCT code path from tag selection and stays
764
+ completely unaffected by this check (P5).
765
+ `"ancestor"` -- `git merge-base --is-ancestor <tag> <candidate>`
766
+ resolved both revisions and confirmed reachability
767
+ -- today's behavior for every genuine ancestor,
768
+ unchanged, including one reached only through a
769
+ merge commit and both annotated and lightweight
770
+ tag forms (git peels either transparently).
771
+ `"not-ancestor"` -- both revisions resolved, but `tag` is NOT an
772
+ ancestor of `candidate` -- the exact gap this
773
+ function exists to catch (a sibling/decoy/orphan
774
+ tag that numerically outranks every real release).
775
+ `"unresolvable"` -- `tag` or `candidate` does not resolve to a commit
776
+ at all, the probe could not run (`OSError`), or it
777
+ exceeded this module's standard `timeout=30`
778
+ (#627 precedent) -- distinct from `"not-ancestor"`
779
+ because no verdict about reachability was actually
780
+ reached.
781
+
782
+ Bounded with `timeout=30`, matching this module's other internal
783
+ Git-probe call sites. This function only classifies; it never chooses a
784
+ different (older) tag, never implements maintenance-branch allocation,
785
+ and never mutates anything -- refusal is the caller's job (P5).
786
+
787
+ Both `tag` and `candidate` are validated with the same
788
+ `startswith("-")` / `"\\0" in` predicate `_committed_changelog_text`
789
+ already applies to a revision reaching this module's own argv, before
790
+ either reaches `git` as a bare argv element -- a name beginning with
791
+ `-` could otherwise be parsed as an option by the downstream `git
792
+ merge-base` subcommand. An explicit end-of-options `--` is also placed
793
+ ahead of the two revisions as defense-in-depth, matching this module's
794
+ existing `--` precedent elsewhere.
795
+
796
+ **Caller contract -- repo-root binding (R-02, Scope-C security review
797
+ finding 2, MEDIUM).** `project_root` is trusted verbatim; this function
798
+ does not independently discover a project directory. Whatever OTHER
799
+ command a caller used to SELECT `tag` (this module's own
800
+ `last_tag_select`/`last_tag_select_for_policy`, or SKILL.md's `git tag
801
+ -l`) MUST resolve against the IDENTICAL root passed here -- never
802
+ merely "whatever the invoking shell's cwd happens to be" -- or a tag
803
+ selected from one checkout's history can be verified against a
804
+ completely different checkout's `HEAD`. A linked worktree, or any
805
+ other checkout whose actual shell cwd diverges from the harness-
806
+ designated project root, is a real, constructible instance of this,
807
+ not a hypothetical one. `main`'s `verify-tag-ancestor` CLI dispatch
808
+ keeps its legacy 1-argument env-first-else-cwd resolution
809
+ (`CLAUDE_PROJECT_DIR` first, falling back to `os.getcwd()` -- the
810
+ identical precedence `default_targets_path`/`default_backfill_root`
811
+ apply) for backward compatibility only; SKILL.md's Pre-flight
812
+ sequence instead resolves ONE `$PROJECT_ROOT` value up front and
813
+ threads it explicitly -- both as the optional SECOND CLI argument
814
+ here (so this function's own `project_root` is never re-derived by a
815
+ second, independent environment read) and via `-C` on its own bare
816
+ `git tag -l`/`git fetch --tags origin`/`git fetch origin
817
+ "$DEFAULT_BRANCH"`/`git branch --show-current` calls -- so every
818
+ consumer of "the project root" in that sequence reads the SAME
819
+ resolved value, never two reads that merely usually agree."""
820
+ if tag == NONE_SENTINEL:
821
+ return _ANCESTRY_ZERO_TAG
822
+ if (not isinstance(tag, str) or not tag
823
+ or tag.startswith("-") or "\0" in tag):
824
+ return _ANCESTRY_UNRESOLVABLE
825
+ if (not isinstance(candidate, str) or not candidate
826
+ or candidate.startswith("-") or "\0" in candidate):
827
+ return _ANCESTRY_UNRESOLVABLE
828
+ try:
829
+ probe = subprocess.run(
830
+ [git_executable(), "merge-base", "--is-ancestor", "--",
831
+ tag, candidate],
832
+ cwd=project_root, capture_output=True, text=True, timeout=30,
833
+ env=_sanitized_git_environment())
834
+ except (OSError, subprocess.TimeoutExpired):
835
+ return _ANCESTRY_UNRESOLVABLE
836
+ if probe.returncode == 0:
837
+ return _ANCESTRY_ANCESTOR
838
+ if probe.returncode == 1:
839
+ return _ANCESTRY_NOT_ANCESTOR
840
+ return _ANCESTRY_UNRESOLVABLE
841
+
842
+
739
843
  def notes_heading_matches(notes_text, tag, policy="semver", initial_version=None):
740
844
  """True iff the FIRST changelog heading names `tag` under `policy`.
741
845
 
@@ -1389,6 +1493,14 @@ def classify_window(commits, reconciliations=None, published_shas=None):
1389
1493
  list. ``reconciliations`` may supply exact-SHA changelog text only for
1390
1494
  SHAs independently present in ``published_shas``. It never changes the
1391
1495
  commit classification or bump.
1496
+
1497
+ Each row in `commits` carries `classify_commit`'s full per-commit
1498
+ verdict -- including `breaking` -- unchanged and in the input list's
1499
+ own order (never reordered or recomputed here). SKILL.md's Phase 1
1500
+ step 5 changelog composition and its `<summary>` headline rule rely on
1501
+ exactly that: reusing `breaking` to route a commit into its own
1502
+ `### Breaking` group, and scanning `commits` in this same order to find
1503
+ the first breaking entry for a major-bump title.
1392
1504
  """
1393
1505
  rows = []
1394
1506
  reconciliations = reconciliations if isinstance(reconciliations, dict) else {}
@@ -2004,16 +2116,32 @@ def governance_scratch_exclusions(row):
2004
2116
 
2005
2117
 
2006
2118
  def release_tree_status(row, project_root):
2007
- """Run the target-aware repository-wide clean-tree probe."""
2119
+ """Run the target-aware repository-wide clean-tree probe.
2120
+
2121
+ Bounded with `timeout=30`, matching every other internal Git-probe call
2122
+ site in this module (#627 Finding 1). A hang (index lock, network-mounted
2123
+ repo, credential-helper prompt) must not hang the release lane forever
2124
+ with no diagnostic: `subprocess.TimeoutExpired` is caught and converted
2125
+ into a synthetic failed `CompletedProcess`, which every existing caller
2126
+ already treats as a probe failure (`clean-tree-status` returns
2127
+ `probe.returncode` directly; `run-pre-tag`'s `_tree_state()` treats a
2128
+ nonzero returncode as "the probe itself failed", never as a detected
2129
+ mutation) — no caller-side change is required.
2130
+ """
2008
2131
  scratch_pathspecs = [
2009
2132
  f":(exclude,top){path}"
2010
2133
  for path in governance_scratch_exclusions(row)
2011
2134
  ]
2012
- return subprocess.run(
2013
- [git_executable(), "status", "--porcelain", "--", ":/",
2014
- *scratch_pathspecs],
2015
- capture_output=True, text=True, cwd=project_root,
2016
- env=_sanitized_git_environment())
2135
+ args = [git_executable(), "status", "--porcelain", "--", ":/",
2136
+ *scratch_pathspecs]
2137
+ try:
2138
+ return subprocess.run(
2139
+ args, capture_output=True, text=True, cwd=project_root,
2140
+ timeout=30, env=_sanitized_git_environment())
2141
+ except subprocess.TimeoutExpired:
2142
+ return subprocess.CompletedProcess(
2143
+ args, 1, "",
2144
+ "git status probe timed out after 30 seconds")
2017
2145
 
2018
2146
 
2019
2147
  def parse_release_targets(text):
@@ -2305,8 +2433,9 @@ def detect_candidate_target(manifest_candidates, changelog_candidates,
2305
2433
  (`scan_backfill_candidates` is the one filesystem reader, kept separate
2306
2434
  per this module's read-isolation convention). Returns a row dict shaped
2307
2435
  like one `load_targets` entry (`target`, `prefix`, `manifest`,
2308
- `changelog`, `payload`, `payload_exclude`, `latest_eligible`) ONLY when exactly one manifest
2309
- candidate and exactly one changelog candidate were found. Raises
2436
+ `changelog`, `changelog_reconciliations`, `payload`, `payload_exclude`,
2437
+ `latest_eligible`) ONLY when exactly one manifest candidate and exactly
2438
+ one changelog candidate were found. Raises
2310
2439
  `BackfillAmbiguousError` for every other case — zero or multiple of
2311
2440
  either — naming which side was ambiguous and what was found, so a caller
2312
2441
  surfacing the error has something concrete to show the user.
@@ -2356,6 +2485,13 @@ def detect_candidate_target(manifest_candidates, changelog_candidates,
2356
2485
  "prefix": prefix,
2357
2486
  "manifest": [manifest_candidates[0]],
2358
2487
  "changelog": changelog_candidates[0],
2488
+ # A first release may have real, already-published history from
2489
+ # before CHANGELOG footers were required. Back-fill must declare the
2490
+ # existing exact-SHA reconciliation mechanism up front so that the
2491
+ # adoption commit can land the operator-authored ledger on the
2492
+ # default branch before the release lane widens its first window.
2493
+ "changelog_reconciliations":
2494
+ ".codearbiter/release-changelog-reconciliations.json",
2359
2495
  "payload": ".",
2360
2496
  # Back-fill is the release-only adoption lane. Its own hooks create
2361
2497
  # governance scratch under .codearbiter/, so a root payload without
@@ -2374,7 +2510,8 @@ def format_release_targets_block(row):
2374
2510
  back-fill lane persists, so a caller never hand-assembles the delimiter
2375
2511
  grammar itself.
2376
2512
 
2377
- Emits a `latest-eligible` line when `row` declares one (HIGH-2,
2513
+ Emits a `changelog-reconciliations` line when `row` declares one, and a
2514
+ `latest-eligible` line when `row` declares one (HIGH-2,
2378
2515
  adversarial review 2026-07-31) — `detect_candidate_target` always does,
2379
2516
  since it can only ever propose a single-target row — rendered as the
2380
2517
  grammar's own `true`/`false` literal, never a bare Python truthiness
@@ -2385,6 +2522,10 @@ def format_release_targets_block(row):
2385
2522
  for manifest in row.get("manifest", []):
2386
2523
  lines.append(f"manifest: {manifest}")
2387
2524
  lines.append(f"changelog: {row['changelog']}")
2525
+ if row.get("changelog_reconciliations") is not None:
2526
+ lines.append(
2527
+ "changelog-reconciliations: "
2528
+ f"{row['changelog_reconciliations']}")
2388
2529
  lines.append(f"payload: {row['payload']}")
2389
2530
  for excluded in row.get("payload_exclude", []):
2390
2531
  lines.append(f"payload-exclude: {excluded}")
@@ -2577,6 +2718,45 @@ def main(argv):
2577
2718
  more than one).
2578
2719
  last-tag <prefix> stdin = tags (whitespace/newline separated)
2579
2720
  -> prints the selected tag or <none>.
2721
+ last-tag-for-policy <prefix> <policy> <initial_version>
2722
+ stdin = tags -> prints the selected tag or
2723
+ <none>, under a declared version policy
2724
+ (`semver` or `numeric-sequence`).
2725
+ verify-tag-ancestor <tag> [<project_root>]
2726
+ refuses an ambiguous release baseline
2727
+ before it is used for anything (#570
2728
+ finding BODY-03). `last-tag`/`last-tag-
2729
+ for-policy` select the highest tag by
2730
+ VALUE alone, with no git access; this
2731
+ confirms the SELECTED tag actually
2732
+ resolves to a commit reachable from HEAD.
2733
+ `<project_root>`, when given, is used
2734
+ VERBATIM and no environment read happens
2735
+ at all -- the caller's own single already-
2736
+ resolved root, threaded through explicitly
2737
+ rather than re-derived here a second time
2738
+ (R-02, Scope-C security review finding 2,
2739
+ MEDIUM: two independent env-first-else-cwd
2740
+ reads, taken at two different times, are
2741
+ "usually agree", never a guarantee).
2742
+ Omitted, this falls back to the prior
2743
+ 1-argument behavior -- `CLAUDE_PROJECT_DIR`
2744
+ first, else this process's own cwd --
2745
+ unchanged, for backward compatibility.
2746
+ exit 0 -- `<tag>` is `<none>` (the
2747
+ zero-tag first-release path, unaffected)
2748
+ or a confirmed ancestor of HEAD -- 1 --
2749
+ `<tag>` resolves but is NOT an ancestor of
2750
+ HEAD, printing the tag and the cause; the
2751
+ caller MUST stop, never silently fall
2752
+ back to an older tag, reuse the tag
2753
+ namespace, or move/delete the tag -- 2 --
2754
+ `<tag>` or HEAD could not be resolved at
2755
+ all (an unresolvable ref, a probe error,
2756
+ or a `timeout=30` expiry). Never chooses a
2757
+ different baseline and never implements
2758
+ maintenance-branch allocation policy; it
2759
+ only detects and refuses ambiguity (P5).
2580
2760
  notes-match <tag> <notes_file>
2581
2761
  exit 0 iff the notes file's first heading
2582
2762
  names the same version as `tag`.
@@ -2711,7 +2891,14 @@ def main(argv):
2711
2891
  prints the exact `release-targets.md`
2712
2892
  block text and exits 0; on zero or multiple
2713
2893
  of either, writes the ambiguity to stderr
2714
- and exits 1 — it never prints a guess.
2894
+ and exits 1 — it never prints a guess.
2895
+ validate-reconciliations <target> <ledger-file>
2896
+ validates a Back-fill ledger with the same
2897
+ strict parser the published release path
2898
+ uses. exit 0 valid - 4 missing, unreadable,
2899
+ non-UTF-8, oversized, or malformed. It does
2900
+ not claim the file is published; the later
2901
+ `classify-window` ancestry proof owns that.
2715
2902
  show-row <target> [--field NAME]
2716
2903
  prints every field the row declares, one
2717
2904
  `name: value` line each (multi-valued
@@ -2751,12 +2938,13 @@ def main(argv):
2751
2938
  if not argv:
2752
2939
  sys.stderr.write(
2753
2940
  "usage: _releaselib.py {tag-prefix|list-targets|show-row|"
2754
- "payload-pathspec|clean-tree-status|last-tag|last-tag-for-policy|notes-match|"
2941
+ "payload-pathspec|clean-tree-status|last-tag|last-tag-for-policy|"
2942
+ "verify-tag-ancestor|notes-match|"
2755
2943
  "changelog-section|dates-match|semver-greater|version-greater|"
2756
2944
  "apply-bump|derive-version|render-release-assets|"
2757
2945
  "verify-release-assets|classify|peel-tag|"
2758
2946
  "run-pre-tag|adoption-commit|classify-window|check-manifests|"
2759
- "backfill-detect} ...\n")
2947
+ "backfill-detect|validate-reconciliations} ...\n")
2760
2948
  return 2
2761
2949
 
2762
2950
  cmd, rest = argv[0], list(argv[1:])
@@ -2954,6 +3142,53 @@ def main(argv):
2954
3142
  sys.stdin.read().split(), prefix, policy, initial))
2955
3143
  return 0
2956
3144
 
3145
+ if cmd == "verify-tag-ancestor" and len(rest) in (1, 2):
3146
+ tag = rest[0]
3147
+ # R-02 (Scope-C security review finding 2, MEDIUM): an explicit
3148
+ # SECOND argument, when given, is the caller's own already-resolved
3149
+ # project root and wins outright -- no environment read happens at
3150
+ # all. Without this, this dispatch's own env-first-else-cwd
3151
+ # resolution and SKILL.md's shell-side `$PROJECT_ROOT` resolution
3152
+ # are two INDEPENDENT reads of "the project root", taken at two
3153
+ # different times (the shell's at bullet-time, this process's at
3154
+ # invocation-time) -- they agree only when `CLAUDE_PROJECT_DIR` is
3155
+ # set (trusted-harness input under Claude Code) and neither call's
3156
+ # own cwd has moved since. On a host with no such env var at all
3157
+ # (Codex, Pi) or in any degraded case, both legs independently fall
3158
+ # back to `os.getcwd()`/`$(pwd)`, which is "usually agree", not a
3159
+ # guarantee. Passing the value explicitly collapses this to ONE
3160
+ # resolution, computed once, consumed everywhere -- the 1-argument
3161
+ # form is kept for backward compatibility (any other caller of this
3162
+ # CLI, and this module's own test suite) and still resolves exactly
3163
+ # as before.
3164
+ if len(rest) == 2:
3165
+ project_root = rest[1]
3166
+ else:
3167
+ project_root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
3168
+ verdict = verify_tag_ancestor(tag, project_root)
3169
+ if verdict in (_ANCESTRY_ZERO_TAG, _ANCESTRY_ANCESTOR):
3170
+ return 0
3171
+ if verdict == _ANCESTRY_NOT_ANCESTOR:
3172
+ sys.stderr.write(
3173
+ f"verify-tag-ancestor: refusing {tag!r} as the release "
3174
+ "baseline -- it does not resolve to a commit reachable "
3175
+ "from HEAD. A sibling branch that diverged before this "
3176
+ "tag, an abandoned branch's stray push, or an unrelated "
3177
+ "orphan history sharing this series' prefix can still win "
3178
+ "the highest-SemVer scan even though this release's own "
3179
+ "history never contains it. This is never resolved by "
3180
+ "silently falling back to an older tag, reusing the tag "
3181
+ "namespace, or moving/deleting the tag -- remove the "
3182
+ "stray tag by hand (never retarget or delete a PUBLISHED "
3183
+ "one; see \"Recovering from a bad release\") or confirm "
3184
+ "the correct baseline before re-entering Pre-flight.\n")
3185
+ return 1
3186
+ sys.stderr.write(
3187
+ f"verify-tag-ancestor: could not resolve {tag!r} or HEAD to "
3188
+ "verify ancestry -- refusing rather than trusting an "
3189
+ "unverified baseline.\n")
3190
+ return 2
3191
+
2957
3192
  if cmd == "notes-match" and len(rest) in (2, 4):
2958
3193
  tag, notes_path = rest[:2]
2959
3194
  policy, initial = (rest[2], rest[3] or None) if len(rest) == 4 else (
@@ -3718,6 +3953,33 @@ def main(argv):
3718
3953
  sys.stdout.write(format_release_targets_block(row))
3719
3954
  return 0
3720
3955
 
3956
+ if cmd == "validate-reconciliations" and len(rest) == 2:
3957
+ target, ledger_path = rest
3958
+ try:
3959
+ if os.path.islink(ledger_path) or not os.path.isfile(ledger_path):
3960
+ raise ChangelogReconciliationError(
3961
+ "ledger must be one regular file")
3962
+ if os.path.getsize(ledger_path) > 1024 * 1024:
3963
+ raise ChangelogReconciliationError(
3964
+ "ledger exceeds the 1 MiB limit")
3965
+ with open(ledger_path, "rb") as handle:
3966
+ raw = handle.read(1024 * 1024 + 1)
3967
+ if len(raw) > 1024 * 1024:
3968
+ raise ChangelogReconciliationError(
3969
+ "ledger exceeds the 1 MiB limit")
3970
+ try:
3971
+ text = raw.decode("utf-8")
3972
+ except UnicodeError as exc:
3973
+ raise ChangelogReconciliationError(
3974
+ f"ledger is not UTF-8: {exc}") from None
3975
+ parse_changelog_reconciliations(text, target)
3976
+ except (OSError, ChangelogReconciliationError) as exc:
3977
+ sys.stderr.write(
3978
+ f"ChangelogReconciliationError: could not validate "
3979
+ f"{ledger_path!r}: {exc}\n")
3980
+ return 4
3981
+ return 0
3982
+
3721
3983
  sys.stderr.write(f"_releaselib.py: bad invocation: {' '.join(argv)}\n")
3722
3984
  return 2
3723
3985
 
@@ -313,7 +313,7 @@ class Host:
313
313
 
314
314
  name = "claude"
315
315
  adapter_name = "ca"
316
- adapter_version = "2.21.7"
316
+ adapter_version = "2.21.9"
317
317
 
318
318
  # Update-notifier descriptor. Each independently versioned host overrides
319
319
  # these three values in its per-plugin _host.py. Keeping the target,
@@ -40,11 +40,24 @@
40
40
  #
41
41
  # CLI:
42
42
  # releasehash.py digest <target> print the executable-command digest
43
- # releasehash.py check <target> exit 0 confirmed, 1 changed, 2 never
44
- # confirmed, 3/4 declared-file states
43
+ # releasehash.py check <target> print the confirmation state
45
44
  # releasehash.py record <target> mint the confirmation (the sanctioned
46
45
  # producer; run only after an operator has
47
46
  # actually READ the commands)
47
+ #
48
+ # Exit codes (T-05, spec P3 / AC-04, AC-05 -- disambiguated from a single
49
+ # overloaded 2 that used to mean three unrelated things):
50
+ # 64 malformed CLI usage (wrong arg count, or an unrecognised subcommand)
51
+ # -- never reaches target resolution, writes no marker
52
+ # 65 unknown release target (no declared row of that name) -- writes no
53
+ # marker
54
+ # 3/4 the declared-targets file itself could not be read/parsed for
55
+ # `<target>`; see `_releaselib._targets_error_exit_code` (3 genuinely
56
+ # absent, 4 every other declared-file error) -- unchanged by this split
57
+ # 0/1/2 `check`'s three valid-check outcomes, unchanged by this split:
58
+ # 0 confirmed or no-commands, 1 changed since last confirmation,
59
+ # 2 never confirmed -- NOW 2's sole remaining meaning
60
+ # 0 `digest`/`record` succeed once the target and declared file resolve
48
61
 
49
62
  from __future__ import annotations
50
63
 
@@ -182,7 +195,7 @@ def main(argv=None):
182
195
  argv = list(sys.argv[1:] if argv is None else argv)
183
196
  if len(argv) != 2 or argv[0] not in ("digest", "check", "record"):
184
197
  sys.stderr.write("usage: releasehash.py {digest|check|record} <target>\n")
185
- return 2
198
+ return 64
186
199
  command, target = argv
187
200
  root = os.path.dirname(os.path.dirname(_releaselib.default_targets_path()))
188
201
 
@@ -193,7 +206,7 @@ def main(argv=None):
193
206
  return _releaselib._targets_error_exit_code(error)
194
207
  if executable_inputs is None:
195
208
  sys.stderr.write(f"unknown release target: {target}\n")
196
- return 2
209
+ return 65
197
210
  commands, release_build = executable_inputs
198
211
 
199
212
  if command == "digest":
@@ -1,80 +1,49 @@
1
1
  # Redirect
2
2
 
3
- Canned messages for §6 **tiers 2 and 3** — when a direct off-channel instruction is *not* both
4
- unambiguous and non-destructive. Loaded only when needed.
3
+ Clarification support for §6 **tiers 2 and 3**, loaded only when the intended outcome, a material
4
+ fact, or required authority is unresolved. Tier 1 does not use this file: select the existing
5
+ owner and preserve its gates without asking the user to retype or approve an internal route.
5
6
 
6
- Tier 1 does not use this file at all. An unambiguous, non-destructive intent is routed directly into
7
- its command with a one-line statement of the route (ADR-0022): naming the command and then asking the
8
- user to type it back is ceremony, not governance.
7
+ ## Tier 2 — probable intent, or a required destructive confirmation
9
8
 
10
- Match the channel to the *phrasing*, not just the topic. An interrogative is a question, not a build
11
- request: "add a healthcheck endpoint" is tier 1 into `/ca-feature`, while "should we add a
12
- healthcheck?" is tier 1 into `/ca-btw`, and "do my ADRs conflict?" is tier 1 into
13
- `/ca-reconcile`. A question pulled into the heavy spec lane is a misroute.
9
+ Inspect available context before asking. A second plausible implementation method is not a missing
10
+ user decision. Ask about the missing outcome or fact, with a recommendation and its strongest
11
+ counter-consideration; do not ask the user to select a command that expresses an understood request.
14
12
 
15
- ## Tier 2 — probable intent, or an unambiguous but destructive one
13
+ For irreversible or gate-bypassing operations, obtain the existing confirmation even when intent
14
+ is clear: `/ca-override`, merge to the default branch, branch or worktree deletion, and release
15
+ and tag publication. Name the exact operation and target, state the consequence, then ask for its
16
+ confirmation. A generic approval of a route is not authority for an unspecified destructive action.
17
+ A deterministic mode-token flip is not in this set; its existing mode contract still applies.
16
18
 
17
- One question, naming the command. The user approves; they do not retype.
19
+ ## Tier 3 — genuinely unclear outcome
18
20
 
19
- ```
20
- That reads as <inferred intent> → <$ca- skill, prefilled with the user's own words>
21
+ Ask one focused question after checking the available evidence:
21
22
 
22
- Run it? Its own gates still apply.
23
- ```
23
+ > The missing decision is <outcome or constraint>. I recommend <option> because <reason>;
24
+ > <strongest counter-consideration> favors <alternative>. Which outcome is intended?
24
25
 
25
- Use this — not tier 1 — whenever the command is irreversible or gate-bypassing, however clear the
26
- intent: `/ca-override`, merge to the default branch, branch or worktree deletion, release and tag
27
- publication. There the confirmation is the gate, not friction. (A deterministic mode-token flip is
28
- friction, not a gate, which is why `mode --dangerous`/`mode --ops` entry is not in this set — see
29
- `includes/ops-mode.md` and `includes/dangerous-mode.md`.)
26
+ When even the intended task is unknown, ask what result the user wants. Do not repeat a command
27
+ catalog when the user restates the goal in ordinary language. Resolve the actual uncertainty and
28
+ then select the owner; lack of slash syntax is not itself uncertainty.
30
29
 
31
- ## Tier 3 — genuinely unclear
30
+ ## Questions and restricted requests
32
31
 
33
- ```
34
- codeArbiter routes work through commands, so every change clears its gates
35
- and lands on the audit trail. This one could go a few ways:
36
-
37
- <up to three prefilled $ca- skills, closest first>
38
-
39
- Or pick a channel:
40
- → Start a new project: /ca-decompose
41
- → Start a feature: /ca-feature "describe it"
42
- → Ask a question: /ca-btw "your question"
43
- → Fix a bug: /ca-fix "describe it"
44
- → Bypass with audit trail: /ca-override "reason"
45
- → See everything open: /ca-status
46
- → See all commands: /ca-commands
47
- ```
48
-
49
- When no intent is inferable at all, drop the candidate lines and lead with the channel list.
50
-
51
- ## Repeat — user insists off-channel after tier 3
52
-
53
- ```
54
- Still need a channel for this one. Closest matches first:
55
- <up to three prefilled $ca- skills>
56
-
57
- Full list:
58
- /ca-decompose /ca-create-context /ca-feature /ca-sprint /ca-fix /ca-refactor /ca-debug /ca-chore /ca-spike
59
- /ca-commit /ca-pr /ca-watch /ca-review /ca-checkpoint /ca-release /ca-add-dep
60
- /ca-threat-model /ca-adr /ca-adr-status /ca-reconcile /ca-conflict
61
- /ca-init /ca-status /ca-metrics /ca-audit /ca-preview /ca-doctor /ca-standup /ca-cleanup /ca-task
62
- /ca-new-skill /ca-btw /ca-commands
63
- Or /ca-override "reason" to proceed anyway with an audit entry.
64
- ```
32
+ Use the requested outcome, not punctuation alone. "Explain this commit" and "draft the message,
33
+ but do not commit" authorize no staging, commit, task-board write, or other persistent mutation.
34
+ "Can you commit these changes?" can request an action; it still needs its owning gate. Answer an
35
+ informational question directly using read-only evidence as needed. If a relevant procedure has a
36
+ writing terminal step, do not enter that step for a report-only request.
65
37
 
66
38
  ## Never
67
39
 
68
- A missing owner is a **routing gap**, not an override case. When no command owns the operation, say
69
- so and surface the gap — never steer the user toward `/ca-override` to get past a coverage hole.
70
- That substitution is exactly what issue #308 recorded: a routine post-merge cleanup routed first to
71
- `/ca-chore`, which does not accept it, and then to `/ca-override`, which exists to be rare.
40
+ A genuinely missing owner is a **routing gap**, not an override case. Surface the precise missing
41
+ capability; never steer the user toward `/ca-override` to get past a coverage hole. Do not invent
42
+ an ungoverned procedure, resolve a `[CONFIRM-NN]`, or broaden scope merely to avoid asking.
72
43
 
73
44
  ## Exception — local runtime work
74
45
 
75
- `npm run dev` and its kin have no owning command; in `arbiter` mode that is the routing gap above,
76
- surfaced in one line. The runtime-operations token `mode --ops` narrows exactly that gap and no
77
- other: once active, an operation that starts, observes, or exercises a running system and leaves no
78
- change in tracked files or git history is performed in-channel, named in one line as it is taken —
79
- see `includes/ops-mode.md`. Anything that mutates tracked files, the index, git history, or
80
- published state is still a routing gap, `ops` or not.
46
+ The existing `mode --ops` contract in `<plugin-root>/includes/ops-mode.md` owns local runtime
47
+ work that leaves tracked files and Git history unchanged. In `arbiter` mode, a runtime operation without an owner remains a routing gap;
48
+ this clarification card does not select or activate another mode. Tracked-file, index, Git-history,
49
+ and published-state mutations still require their ordinary governing workflow, `ops` or not.
@@ -1,10 +1,12 @@
1
1
  # Routing table
2
2
 
3
- Loaded on a scope-touch or `/command`, not every turn. This table is the authoritative trigger→route
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.
3
+ Loaded when a scope-touch or route needs it, not every turn. This table is the authoritative
4
+ intent-to-owner surface and destructive-operation registry. Invocation cues are examples, not
5
+ required user syntax. For the governing document on a scope-touch, use `reference-map.md`.
6
+ Select the existing owner and preserve its prerequisites, modes, and gates; a direct resource
7
+ reference does not discard behavior owned by its command wrapper. A gate blocks advancement until
8
+ its exit condition is satisfied; routing never authorizes a bypass. Informational requests use
9
+ read-only behavior, not a writing close. The orchestrator routes; a skill dispatches an agent.
8
10
  Routing to a skill means loading its body from `<plugin-root>/routines/<name>/SKILL.md` — a route
9
11
  cell names the skill; this path convention locates it. That resolution never depends on the host's
10
12
  skill registry: a chain-internal skill hidden from the registry (`disable-model-invocation`) is
@@ -4,7 +4,8 @@ dangerous, ops) at injection time; never injected alone. -->
4
4
  # Safety core
5
5
 
6
6
  **Precedence.** This file is prepended to every mode body and binds over it: no mode body may
7
- weaken, omit, or override a clause stated here.
7
+ weaken, omit, or override a clause stated here. Command names identify their governing procedure;
8
+ natural-language routing preserves that procedure and its attribution and confirmation requirements.
8
9
 
9
10
  **Section numbers are a public interface.** The enforcement hooks fire identically in every
10
11
  mode and their block messages cite a `§N` from this file directly, so the numbering below must
@@ -59,9 +60,12 @@ preflight to the exact fetched base and PR head. Preserve a required true merge
59
60
  `--match-head-commit`; green CI cannot substitute for source ancestry. Missing evidence blocks the
60
61
  offer. This check never authorizes the merge itself.
61
62
 
62
- A parameter is yours to decide only when it is reversible, has one sensible answer, and is
63
- recorded where the user will review it — an uncertain classification is a fork, and forks are
64
- asked.
63
+ Within already-authorized scope, choose reversible implementation parameters among reasonable
64
+ approaches and record material choices where the workflow requires. Multiple reasonable methods
65
+ alone do not require a user decision. This does not grant initial scope approval, resolve a
66
+ `[CONFIRM-NN]`, invent a product requirement, or expand provider, spending, data-disclosure, or
67
+ publication authority. When an unresolved choice changes the authorized outcome or requires
68
+ additional authority, ask. The irreversible-action confirmations above remain mandatory.
65
69
 
66
70
  ---
67
71
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arbiterforge/ca-pi",
3
- "version": "0.14.7",
3
+ "version": "0.14.9",
4
4
  "private": true,
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -17,7 +17,7 @@ Skill bodies load on routing only. This index is the surface scan; never bulk-re
17
17
  | [brainstorming](brainstorming/SKILL.md) | `/feature` (front), `/sprint` planning | Socratic idea→spec: split bundles, shape the approach, then decide parameters / ask forks in full; adversarial self-review before approval; writes new full-lane `specs/<slug>.html` through the installed engine while preserving existing Markdown authority. Its acceptance criteria become `tdd` obligations. Hard-gate: no code until the spec is approved. |
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 a same-format plan with bijective criterion↔task coverage (`.html` by default, `.md` for an existing Markdown spec). |
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
- | [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]`. |
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. A quality failure blocks acceptance; approved sprint recovery owns correction. Security CRITICAL and unresolved `[CONFIRM-NN]` remain stops. |
21
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. |