@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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +36 -0
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_prerequisitelib.py +971 -0
- package/plugins/ca-pi/hooks/_releaselib.py +274 -12
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/hooks/prompt-submit.py +15 -1
- package/plugins/ca-pi/hooks/releasehash.py +17 -4
- package/plugins/ca-pi/includes/artifacts.md +44 -0
- package/plugins/ca-pi/package.json +1 -1
- package/plugins/ca-pi/routines/release/SKILL.md +89 -26
|
@@ -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
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
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`,
|
|
2309
|
-
|
|
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 `
|
|
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
|
-
|
|
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|
|
|
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.
|
|
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
|
-
|
|
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>
|
|
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
|
|
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
|
|
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,
|