@arbiterforge/ca-pi 0.14.6 → 0.14.8

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.6"
316
+ adapter_version = "2.21.8"
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,
@@ -67,6 +67,7 @@ import hostapi # noqa: E402 — host seam (ADR-0011)
67
67
  import _approvallib # noqa: E402 — host-owned artifact approval capture
68
68
  import _hooklib # noqa: E402
69
69
  import _modelib # noqa: E402 — mode plane core (T-06..T-16, Lane A)
70
+ import _prerequisitelib # noqa: E402 — host-owned prerequisite capture
70
71
  import _readinjectlib # noqa: E402 — marker_path(prefix=) (T-30)
71
72
 
72
73
 
@@ -456,13 +457,26 @@ def _handle_user_prompt_submit(payload, host):
456
457
  token = _modelib.match_mode_token(prompt)
457
458
  approval_context = ""
458
459
  if token is None:
459
- approval_context = _approvallib.consume_from_hook(
460
+ contexts = []
461
+ approval = _approvallib.consume_from_hook(
460
462
  root=root,
461
463
  plugin_root=host.plugin_root(),
462
464
  prompt=prompt,
463
465
  host=host.name,
464
466
  session_id=session_id,
465
467
  )
468
+ if approval:
469
+ contexts.append(approval)
470
+ prerequisite = _prerequisitelib.consume_from_hook(
471
+ root=root,
472
+ plugin_root=host.plugin_root(),
473
+ prompt=prompt,
474
+ host=host.name,
475
+ session_id=session_id,
476
+ )
477
+ if prerequisite:
478
+ contexts.append(prerequisite)
479
+ approval_context = "\n".join(contexts)
466
480
  if host.name == "codex":
467
481
  return _handle_codex(
468
482
  payload, host, state_root, session_id, token, approval_context
@@ -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":
@@ -109,6 +109,50 @@ delivery receipts. They are not authentication secrets and do not claim
109
109
  resistance to an unrestricted same-user filesystem writer. A digest without its
110
110
  corresponding engine-written receipt is rejected.
111
111
 
112
+ Before task selection, inspect the plan's exact prerequisite records. If one is
113
+ unsatisfied, collect and present the facts needed to judge its requirement; a
114
+ verification runner may supply facts but cannot confer prerequisite authority.
115
+ On Claude Code or Codex, arm a genuine user-workflow decision for the exact
116
+ current approved plan and prerequisite:
117
+
118
+ ```sh
119
+ python "<plugin-root>/hooks/_prerequisitelib.py" arm --root "<project-root>" --artifact-id <artifact-id> --prerequisite-id <prerequisite-id>
120
+ ```
121
+
122
+ Show the returned title, requirement, and review-packet digest, then require the
123
+ returned `satisfy-prerequisite ...` reply verbatim. The `UserPromptSubmit` hook
124
+ durably records that observed decision before publishing its authority source,
125
+ captures a prerequisite receipt, and applies the existing prerequisite CAS
126
+ operation. Its final nonce is non-secret freshness/correlation data, not a
127
+ credential: possession alone confers no authority, and only the exact prompt
128
+ observed at the trusted host boundary while the reviewed plan remains current
129
+ can satisfy the prerequisite. Wrong nonces or records, stale plan content or
130
+ approval, and expired requests confer no authority. A retry after confirmation reuses the same event,
131
+ receipt, operation ID, and mutation request; it never reconstructs a decision.
132
+ Only an unconfirmed request can be cancelled:
133
+
134
+ ```sh
135
+ python "<plugin-root>/hooks/_prerequisitelib.py" cancel --root "<project-root>" --artifact-id <artifact-id> --prerequisite-id <prerequisite-id>
136
+ ```
137
+
138
+ If the plan changes after confirmation, recover the obsolete request instead of
139
+ deleting its marker. `supersede` first proves the current identity differs. For
140
+ a captured request it also retries the exact operation ID: a committed replay is
141
+ accepted, while only an engine `REVISION_CONFLICT` proves the obsolete mutation
142
+ did not commit and permits the marker to clear. Published source and receipt
143
+ files remain as evidence:
144
+
145
+ ```sh
146
+ python "<plugin-root>/hooks/_prerequisitelib.py" supersede --root "<project-root>" --artifact-id <artifact-id> --prerequisite-id <prerequisite-id>
147
+ ```
148
+
149
+ The adapter fixes `authority_kind: user_workflow` and `verdict: satisfied`;
150
+ there is no caller-selected authority or bulk-satisfaction path. SMARTS remains
151
+ unsupported until a real policy-owned SMARTS decision producer exists. Pi has no
152
+ qualified pre-model prompt seam and must remain blocked. Do not infer eligibility from one satisfied prerequisite:
153
+ rerun `eligible`, and resolve each remaining record independently against the
154
+ new current plan identity.
155
+
112
156
  `eligible` is authoritative for task selection on the HTML path.
113
157
  `task-start` checks current spec/plan approval, binding, prerequisites, source
114
158
  snapshot, dependency evidence and the complete context ticket. Within a checkpoint,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arbiterforge/ca-pi",
3
- "version": "0.14.6",
3
+ "version": "0.14.8",
4
4
  "private": true,
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",