@sabaiway/agent-workflow-kit 1.48.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,104 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
4
4
  is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
5
5
  every `migrations/<version>-<slug>.md` newer than it, in semver order.
6
6
 
7
+ ## 2.0.0 — Bridge mode catalog (manifest-as-source) + a review receipt that SELF-DECLARES (AD-057)
8
+
9
+ > ### ⚠ BREAKING — a review receipt written before this release no longer attests a tree
10
+ >
11
+ > The kit now **rejects an unmarked receipt**: a receipt must self-declare whether it came from a
12
+ > probe run, and silence is not a declaration. Every receipt on disk from kit ≤ 1.49.0 / bridges
13
+ > ≤ 2.7.1 / ≤ 2.6.1 predates the marker, so `review-state --check` and `review-ledger --check` will
14
+ > RED on it where they previously passed.
15
+ >
16
+ > **How to upgrade:** `npx @sabaiway/agent-workflow-kit@latest init`, which also ATTEMPTS to refresh
17
+ > the placed bridges — then read its per-bridge outcome. **`skipped-readonly` or `could not refresh`
18
+ > means a compatible writer is not guaranteed:** the new reader may still be paired with an old bridge
19
+ > writer, whose reviews write unmarked receipts this gate now rejects. Re-run the refresh from a
20
+ > writable environment (using the recovery command if one was printed), then **re-run the review** on
21
+ > the tree you are working on. That is the whole migration; no project file changes, and the
22
+ > deployment-lineage stamp is untouched.
23
+ >
24
+ > **Why it is not opt-in.** The pre-marker wrappers already honoured `CODEX_PROBE` / `AGY_PROBE` and
25
+ > wrote no marker, so an unmarked receipt is **indistinguishable from a probe receipt** — a review
26
+ > that ran with the frontier-model/max-effort guard switched off. Any transitional window that keeps
27
+ > accepting unmarked receipts keeps the hole fully open for exactly the receipts it targets: it would
28
+ > not defer the break, it would cancel the fix.
29
+
30
+ A **breaking + feature** release (kit MAJOR carrying codex-cli-bridge **2.8.0** + antigravity-cli-bridge
31
+ **2.7.0** MINOR in-tarball — the bridges only ADD a field to the receipt they write, which is additive;
32
+ the incompatibility is created by the kit READER that now refuses the old form. engine 1.17.0 / memory
33
+ 2.3.0 unchanged; the deployment-lineage head is a separate axis and stays `2.0.0`). Two independent
34
+ contracts, one theme: **what a bridge offers, and what a receipt claims, must both be readable off the
35
+ artifact itself — never inferred from source or from silence.**
36
+
37
+ - **`modeCatalog` — a machine-readable answer to "what modes does this bridge offer, and why?"** A new
38
+ **top-level, additive-optional** manifest block (schema stays 1), typed-validated exactly like
39
+ `settings`: absent → valid (a bridge predating it keeps validating), present-but-malformed → invalid.
40
+ Both bridges now declare their real mode set — codex `{exec, exec.resume-last, exec.resume,
41
+ review.plan, review.code, CODEX_PROBE}`, agy `{review.code/plan/diff, review.continue,
42
+ review.conversation, run, AGY_PROBE}` — each entry carrying a closed taxonomy (`primary` /
43
+ `continuation` / `env-hook`, where an env-hook names `parents[]` rather than faking a role), a
44
+ required one-line `purpose` + `whenToUse`, and — declared only where they apply — `whenNotTo`, typed
45
+ `operands[]`, structured `guardrails` `{value, enforcement, condition?, source}` and `customHooks[]`
46
+ (`exec.resume` carries no `guardrails`; a mode with no operands omits `operands`). Invocation forms compose **by
47
+ reference** (`invocationRefs[]`) into the existing AD-033 driving contract — the catalog is the
48
+ user-facing DISCOVERY layer and never shadows it.
49
+ - **Descriptor honesty is enforced, not promised.** Declared operand slots must set-EQUAL the
50
+ placeholders the referenced forms really carry, in **both** directions over the deduplicated union of
51
+ an entry's forms: an undeclared placeholder is as dishonest as an invented slot, since either way the
52
+ render shows a form as ready-to-run that the reader cannot actually fill. `enforced` is claimable only
53
+ for an OS- or code-enforced fact; a runtime bound rides in `condition`; anything a prompt merely asks
54
+ for renders `advisory`. Catalog `submode` values are drift-guarded against the wrappers' real parser
55
+ arms, and every declared env-hook must be a real EXECUTABLE condition (heredocs and comments excluded,
56
+ so a name-grep cannot stay green after the logic is deleted).
57
+ - **A probe review can no longer attest a tree — and the receipt says so itself.** Both wrappers wrote
58
+ receipts unconditionally, so a `CODEX_PROBE=1` / `AGY_PROBE=1` review — which runs with the
59
+ frontier-model/max-effort guard **off** — minted a `fresh:true`/`grounded:true` receipt the
60
+ review-state gate accepted. Both wrappers now write `probe` on **every** successful review, `true` or
61
+ `false`, through the shared byte-identical `write_review_receipt` block: a receipt self-declares and
62
+ nothing has to infer it. The kit rejects a probe-marked receipt (a probe never attests) and **equally
63
+ rejects an unmarked one — silence is not a declaration**.
64
+ - **One attesting predicate, three consumers.** `classifyReviewReceiptForTree` /
65
+ `summarizeReviewReceiptsForTree` / `describeMissingReviewAttestation` live in the neutral
66
+ `review-ledger-core.mjs` and are read by `review-state.mjs`, `receiptCrossCheck` and the round writer
67
+ — two gates disagreeing about what attests is precisely the class AD-050 closed, and a second copy
68
+ would re-open it. The shared summary also fixes a latent hole: the ledger took `own[own.length - 1]`,
69
+ so a probe landing **after** a real review became the authoritative verdict — a probe SHIP could bury
70
+ a real REWORK and let both gates report convergence. The summary now returns the latest **attesting**
71
+ receipt, never the last line.
72
+ - **Scope of the claim.** What a marker carries is UNTRUSTWORTHINESS, never provenance — receipts are
73
+ not authenticated, and a forger could write `probe:false` as easily as any other field. This is
74
+ self-discipline made legible, not a security boundary. See the BREAKING callout above for the upgrade
75
+ path; full record, including the mid-execution amendment of the original design and the stated
76
+ residuals, in AD-057.
77
+
78
+ ## 1.49.0 — Honesty/robustness bundle: refresh EROFS stated skip · settings integer-overflow parity (AD-056)
79
+
80
+ A small **honesty/robustness** release (kit MINOR carrying the two bridge PATCH bumps in-tarball —
81
+ codex-cli-bridge 2.7.1 + antigravity-cli-bridge 2.6.1; engine 1.17.0 / memory 2.3.0 unchanged; lineage
82
+ head stays `2.0.0`). Two kit/bridge fixes ship here (a third, repo-only dispatcher fix, rides the same
83
+ commit); all three share one theme: **a blocked environment must produce a STATED degrade, never a
84
+ false red — and a real failure stays loud.**
85
+
86
+ - **Refresh under a read-only skills dir is a stated skip, not a false failure.** Under the harness
87
+ session sandbox `~/.claude/skills` is read-only, yet `--refresh-placed` re-syncs even at the current
88
+ version (repair-on-rerun). That write EROFSed into the generic catch → *"could not refresh — … recover
89
+ with setup"*, though both versions were already current AND `setup` hits the same read-only dir. It now
90
+ reports a new **`skipped-readonly`** outcome (exit 0): it names the current version, states the re-sync
91
+ was skipped/incomplete, and names the read-only cause — never claiming a re-sync ran or file integrity
92
+ (a partial copy may precede the block). Only a read-only-class write failure at the copy-write boundary,
93
+ at an equal version, degrades; a read-side / source-side / `linkWrappers` / real-I/O failure, or a
94
+ version-**behind** refresh, stays a loud *could not refresh* (its recovery pointing at a writable
95
+ rerun). The opt-in `setup` placement lane keeps its loud failure. Any drift persists until a writable
96
+ rerun (converge-on-re-run).
97
+ - **Settings integer validation is now shell↔JS exact (Issue-012, Resolved).** The four bridge wrappers'
98
+ shared `aw_settings_valid` integer arms did `(( 10#$v … ))`, which wraps modulo 2^64 on a 19+ digit
99
+ string — the shell **accepted** `18446744073709551916` (2^64+300) while the kit's `settingValueValid`
100
+ (safe-integer) **rejected** it. A shared overflow-safe `aw_int_in_range` helper (byte-identical across
101
+ all four wrappers) strips leading zeros then rejects on a digit count exceeding the max's — never
102
+ running arithmetic on a huge string. A leading-zero **in-range** value (e.g. `000…086400`) still
103
+ passes on both sides; the value is pinned by a new behavioral shell↔JS parity test.
104
+
7
105
  ## 1.48.0 — Family-owned neutral ack store + read-prompt-economy hook lane (AD-055, the CLAUDE-CODE-HARNESS-FRICTION cluster)
8
106
 
9
107
  A **feature** release (kit-only — engine 1.17.0, memory 2.3.0, bridges 2.7.0/2.6.0 unchanged) that
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-kit
3
3
  description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '1.48.0'
6
+ version: '2.0.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
@@ -2,7 +2,7 @@
2
2
  name: antigravity-cli-bridge
3
3
  description: Delegate work to Google's Antigravity CLI (`agy`) — the successor to Gemini CLI — to reach Gemini, Claude, and GPT-OSS models under a Google AI Pro/Ultra subscription from the terminal. Use when the user wants to run a headless `agy` prompt, hand a focused task or second-opinion review to `agy`, install or authenticate Antigravity CLI, check or economise its quota/models, bridge project context into `agy`, set up a second delegated-execution backend beside Codex, or troubleshoot `agy` flags, models, auth, conversations, or its no-JSON headless behaviour.
4
4
  metadata:
5
- version: '2.6.0'
5
+ version: '2.7.0'
6
6
  ---
7
7
 
8
8
  # antigravity-cli-bridge
@@ -69,6 +69,14 @@ list wins.
69
69
 
70
70
  ## Usage
71
71
 
72
+ > **The machine-readable mode catalog lives in [`capability.json`](capability.json) `modeCatalog`** —
73
+ > every documented mode (`agy-run` raw prompts, the three `agy-review` modes, their continuations,
74
+ > the `AGY_PROBE` escape) with its purpose, when to use it (and when not), the exact invocation form
75
+ > with its operand slots, and the guardrails that really apply. The catalog tracks **the documented
76
+ > wrapper mode set** (never "the CLI's modes"): an upstream Antigravity CLI change reaches it through
77
+ > a bridge release, where the source-level drift tests fail loudly until the catalog is updated.
78
+ > Nothing probes a live CLI. The prose below stays the human tour.
79
+
72
80
  Drive `agy` only through the wrapper [`bin/agy.sh`](bin/agy.sh) (installed on `PATH` as `agy-run`):
73
81
 
74
82
  ```bash
@@ -91,7 +91,10 @@ Receipt:
91
91
  grounded = whether a NON-EMPTY --facts payload was supplied (an empty payload records
92
92
  grounded:false — fail-closed, the state gate rejects it), factsHash = sha256 of the facts
93
93
  payload; a continuation receipt is fresh:false (informational-only — it cannot attest the
94
- folded tree); a write failure warns, never fails the review
94
+ folded tree); probe = whether the run relaxed the quality guards (AGY_PROBE=1), written on EVERY
95
+ receipt so it self-declares — the kit's review-state gate rejects a probe-marked receipt (a probe
96
+ review never attests) and equally rejects an unmarked one (silence is not a declaration); a write
97
+ failure warns, never fails the review
95
98
 
96
99
  Settings file (KEY=VALUE, parsed never sourced; env wins over file, file wins over built-in default):
97
100
  ${XDG_CONFIG_HOME:-~/.config}/agent-workflow/bridge-settings.conf
@@ -132,12 +135,22 @@ aw_settings_known() {
132
135
  *) return 1 ;;
133
136
  esac
134
137
  }
138
+ aw_int_in_range() {
139
+ # All-digits $1 vs [min,max] WITHOUT 64-bit wrap (Issue-012): strip leading zeros, then a longer
140
+ # digit count than max's is unconditionally out of range — never do the arithmetic on a huge string.
141
+ # A leading-zero in-range value still passes (its stripped count is small); all-zeros collapses to
142
+ # "0" (below min>=1). Mirrors the JS settingValueValid safe-integer bound, verified by parity test.
143
+ local n="${1#"${1%%[!0]*}"}" min="$2" max="$3"
144
+ n="${n:-0}"
145
+ (( ${#n} > ${#max} )) && return 1
146
+ (( n >= min && n <= max ))
147
+ }
135
148
  aw_settings_valid() {
136
149
  local k="$1" v="$2" int_re='^[0-9]+$' dur_re='^[0-9]+(\.[0-9]+)?[smhd]$' zero_re='^0+(\.0+)?[smhd]$'
137
150
  case "$k" in
138
151
  CODEX_SERVICE_TIER) [[ "$v" == "priority" ]] ;;
139
- CODEX_HARD_TIMEOUT) [[ "$v" =~ $int_re ]] && (( 10#$v >= 1 && 10#$v <= 86400 )) ;;
140
- CODEX_REVIEW_MAX_TOTAL_BYTES) [[ "$v" =~ $int_re ]] && (( 10#$v >= 1 && 10#$v <= 100000000 )) ;;
152
+ CODEX_HARD_TIMEOUT) [[ "$v" =~ $int_re ]] && aw_int_in_range "$v" 1 86400 ;;
153
+ CODEX_REVIEW_MAX_TOTAL_BYTES) [[ "$v" =~ $int_re ]] && aw_int_in_range "$v" 1 100000000 ;;
141
154
  AGY_HARD_TIMEOUT) [[ "$v" =~ $dur_re && ! "$v" =~ $zero_re ]] ;;
142
155
  AGY_REVIEW_ALLOW_ADDDIR) [[ "$v" == "0" || "$v" == "1" ]] ;;
143
156
  *) return 1 ;;
@@ -189,6 +202,14 @@ aw_apply_settings() {
189
202
  fi
190
203
  continue
191
204
  fi
205
+ # Normalize an all-digit (integer) value to DECIMAL before export: a leading-zero value the integer
206
+ # arms legitimately accept (000…086400 == 86400) would otherwise read as OCTAL in downstream Bash
207
+ # arithmetic ("value too great for base"). Strip leading zeros, floor "0"; enum/duration (non-digit)
208
+ # and boolean 0/1 are unaffected.
209
+ if [[ "$value" =~ ^[0-9]+$ ]]; then
210
+ value="${value#"${value%%[!0]*}"}"
211
+ value="${value:-0}"
212
+ fi
192
213
  export "$key=$value"
193
214
  done
194
215
  return 0
@@ -199,7 +220,7 @@ DEFAULT_AGY_REVIEW_MODEL="Gemini 3.1 Pro (High)"
199
220
  # Review-receipt identity (AD-038). AW_BRIDGE_VERSION mirrors this bridge's SKILL.md/capability.json
200
221
  # version (drift-guarded by agy-review.test.mjs against capability.json).
201
222
  AW_RECEIPT_BACKEND="agy"
202
- AW_BRIDGE_VERSION="2.6.0"
223
+ AW_BRIDGE_VERSION="2.7.0"
203
224
  # `-` not `:-` so an EXPLICIT empty AGY_MODEL= survives (drop --model, use settings.json — agy.sh:52).
204
225
  AGY_MODEL="${AGY_MODEL-$DEFAULT_AGY_REVIEW_MODEL}"
205
226
  # Frontier review models. ANY model is allowed; a sub-frontier one only earns a soft, silenceable warning.
@@ -210,6 +231,10 @@ FRONTIER_SET=("Gemini 3.1 Pro (High)" "Claude Opus 4.6 (Thinking)" "Claude Sonne
210
231
  AGY_HARD_TIMEOUT="${AGY_HARD_TIMEOUT:-30m}"
211
232
  AGY_TIMEOUT="${AGY_TIMEOUT:-$AGY_HARD_TIMEOUT}"
212
233
  AGY_PROBE="${AGY_PROBE:-0}"
234
+ # A probe run is RECORDED, not just silently allowed: the receipt carries probe:true so the kit's
235
+ # review-state gate rejects it — a guards-relaxed review must never attest a tree.
236
+ REVIEW_PROBE=false
237
+ if [[ "$AGY_PROBE" == "1" ]]; then REVIEW_PROBE=true; fi
213
238
  AGY_REVIEW_ALLOW_ADDDIR="${AGY_REVIEW_ALLOW_ADDDIR:-0}"
214
239
  AGY_MAX_PROMPT_BYTES="${AGY_MAX_PROMPT_BYTES:-120000}"
215
240
  if [[ ! "$AGY_MAX_PROMPT_BYTES" =~ ^[0-9]+$ ]]; then
@@ -414,13 +439,18 @@ receipt_json_scalar() {
414
439
  if [[ -z "${1:-}" ]]; then printf 'null'; else printf '"%s"' "$1"; fi
415
440
  }
416
441
 
417
- # write_review_receipt <artifact|""> <fresh: true|false> <fingerprint|""> <verdict> <grounded: true|false> <factsHash|"">
442
+ # write_review_receipt <artifact|""> <fresh: true|false> <fingerprint|""> <verdict> <grounded: true|false> <factsHash|""> [probe: true|false]
418
443
  # Appends ONE receipt line (the AD-038 fixture shape) as a side effect of a SUCCESSFUL review —
419
444
  # to $AW_REVIEW_RECEIPTS when set, else <git dir>/agent-workflow-review-receipts.jsonl (inside the
420
445
  # git dir by construction, so it is never committable). Fail-safe: every failure here warns loudly
421
446
  # and returns 0 — a missing receipt fails the kit's review-state CHECKER, never the review run.
447
+ # The optional 7th argument marks a PROBE run (CODEX_PROBE=1 / AGY_PROBE=1 — the quality guards
448
+ # relaxed), which the kit's review-state gate rejects: a probe review must never attest a tree. The
449
+ # marker is written ALWAYS, true or false: the receipt SELF-DECLARES, so the gate reads the fact
450
+ # itself instead of inferring it from this wrapper's version (which bumps in a different release
451
+ # phase). Silence is not a declaration — an unmarked receipt is untrustworthy and the gate rejects it.
422
452
  write_review_receipt() {
423
- local artifact="$1" fresh="$2" fingerprint="$3" verdict="$4" grounded="$5" facts_hash="$6"
453
+ local artifact="$1" fresh="$2" fingerprint="$3" verdict="$4" grounded="$5" facts_hash="$6" probe="${7:-false}"
424
454
  local receipts="${AW_REVIEW_RECEIPTS:-}"
425
455
  if [[ -z "$receipts" ]]; then
426
456
  local receipt_git_dir
@@ -430,11 +460,12 @@ write_review_receipt() {
430
460
  fi
431
461
  receipts="$receipt_git_dir/agent-workflow-review-receipts.jsonl"
432
462
  fi
433
- local line
434
- line="$(printf '{"schema":1,"artifact":%s,"fresh":%s,"fingerprint":%s,"backend":"%s","verdict":"%s","grounded":%s,"factsHash":%s,"wrapperVersion":"%s","timestamp":"%s"}' \
463
+ local line probe_field=',"probe":false'
464
+ if [[ "$probe" == "true" ]]; then probe_field=',"probe":true'; fi
465
+ line="$(printf '{"schema":1,"artifact":%s,"fresh":%s,"fingerprint":%s,"backend":"%s","verdict":"%s","grounded":%s,"factsHash":%s,"wrapperVersion":"%s","timestamp":"%s"%s}' \
435
466
  "$(receipt_json_scalar "$artifact")" "$fresh" "$(receipt_json_scalar "$fingerprint")" \
436
467
  "$AW_RECEIPT_BACKEND" "$verdict" "$grounded" "$(receipt_json_scalar "$facts_hash")" \
437
- "$AW_BRIDGE_VERSION" "$(date -u +%Y-%m-%dT%H:%M:%SZ)")"
468
+ "$AW_BRIDGE_VERSION" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$probe_field")"
438
469
  if ! printf '%s\n' "$line" >>"$receipts" 2>/dev/null; then
439
470
  echo "warning: could not append the review receipt to $receipts — the review itself succeeded;" >&2
440
471
  echo " the review-state gate will read the current tree as un-receipted." >&2
@@ -777,7 +808,7 @@ if [[ $rc -eq 0 ]]; then
777
808
  # A continuation never re-embeds the current artifact (agy holds the ORIGINAL round server-side;
778
809
  # --facts is rejected above), so it cannot attest the folded tree: fresh:false, artifact /
779
810
  # fingerprint / factsHash null, grounded false — informational-only, ignored by the state gate.
780
- write_review_receipt "" false "" "$verdict" false ""
811
+ write_review_receipt "" false "" "$verdict" false "" "$REVIEW_PROBE"
781
812
  echo "notice: a continuation receipt is fresh:false (informational-only) — only a fresh grounded run" >&2
782
813
  echo " (agy-review code --facts @f) mints a receipt that satisfies the review-state gate." >&2
783
814
  else
@@ -787,7 +818,7 @@ if [[ $rc -eq 0 ]]; then
787
818
  grounded=true
788
819
  facts_hash="$(printf '%s' "$FACTS_CONTENT" | sha256_stdin || true)"
789
820
  fi
790
- write_review_receipt "$REVIEW_ARTIFACT" true "$REVIEW_FINGERPRINT" "$verdict" "$grounded" "$facts_hash"
821
+ write_review_receipt "$REVIEW_ARTIFACT" true "$REVIEW_FINGERPRINT" "$verdict" "$grounded" "$facts_hash" "$REVIEW_PROBE"
791
822
  fi
792
823
  fi
793
824
  exit $rc
@@ -521,6 +521,32 @@ const extractArgCaseArms = (source) => {
521
521
  };
522
522
  const splitArms = (labels) => (labels ?? []).flatMap((l) => l.split('|'));
523
523
 
524
+ // The source lines that really EXECUTE: a heredoc body (the --help text) and a comment both carry
525
+ // names without carrying logic, so a bare name-grep over the whole source stays green after the
526
+ // logic is deleted. Reuses the same heredoc discipline as extractArgCaseArms.
527
+ const executableLines = (source) => {
528
+ const out = [];
529
+ let heredoc = null;
530
+ for (const raw of source.split('\n')) {
531
+ if (heredoc) {
532
+ if (raw.trim() === heredoc) heredoc = null;
533
+ continue;
534
+ }
535
+ const hd = raw.match(/<<-?\s*['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?/);
536
+ if (hd) { heredoc = hd[1]; continue; }
537
+ if (raw.trimStart().startsWith('#')) continue;
538
+ out.push(raw);
539
+ }
540
+ return out;
541
+ };
542
+ // An env var is really CONSULTED when an executable test compares it: [[ "$NAME" == … ]].
543
+ const consultsEnv = (source, name) =>
544
+ executableLines(source).some((l) => new RegExp(`\\[\\[[^\\]]*\\$\\{?${name}\\b[^\\]]*(==|!=)`).test(l));
545
+ // The operand slots a rendered invocation form really carries: <angle> and [bracket] placeholders.
546
+ // The optional `@` prefix rides WITH the slot (`@<facts-file>` is one operand, not a bare
547
+ // `<facts-file>` behind a stray character) — the catalog declares the whole token a user types.
548
+ const SLOT_RE = /@?<[^<>]+>|\[[^[\]]*\]/g;
549
+
524
550
  describe('agy-review.sh — --help contract (manifest-pinned)', () => {
525
551
  it('--help and -h exit 0 pre-preflight (no agy, no git)', () => {
526
552
  for (const arg of ['--help', '-h']) {
@@ -597,6 +623,116 @@ describe('agy-review.sh — source-level reverse guard (parser arms ⟷ manifest
597
623
  });
598
624
  });
599
625
 
626
+ // ── mode catalog ⟷ wrapper reality (BRIDGE-MODES-CATALOG) ─────────────────────────
627
+ // The kit validator owns the catalog's INTERNAL shape; these arms pin the half only this wrapper's
628
+ // source can settle — the cataloged review modes ARE the real parser arms, every declared contract
629
+ // invocation is cataloged, and the env-hook the catalog aims at review is a real env var.
630
+ describe('agy-review.sh — mode catalog ⟷ wrapper reality (manifest-pinned)', () => {
631
+ const source = readFileSync(WRAPPER, 'utf8');
632
+ const arms = extractArgCaseArms(source);
633
+ const catalog = MANIFEST.modeCatalog ?? [];
634
+ const reviewEntries = catalog.filter((e) => e.role === 'review');
635
+ const reviewPrimaries = reviewEntries.filter((e) => e.kind === 'primary');
636
+
637
+ it('the catalog submodes ARE the wrapper\'s real parser mode arms (both directions)', () => {
638
+ const modes = splitArms(arms.get('"$mode"')).filter((a) => a !== '*');
639
+ assert.ok(reviewPrimaries.length > 0, 'the manifest must catalog its review modes');
640
+ setEq(new Set(reviewPrimaries.map((e) => e.submode)), new Set(modes), 'catalog submodes ⟷ real parser mode arms');
641
+ });
642
+
643
+ it('every review entry composes BY REFERENCE and every reference resolves', () => {
644
+ for (const entry of reviewEntries) {
645
+ assert.ok(
646
+ Array.isArray(entry.invocationRefs) && entry.invocationRefs.length > 0,
647
+ `${entry.key}: a contract-backed entry references at least one contract descriptor`,
648
+ );
649
+ assert.ok(!Object.hasOwn(entry, 'descriptor'), `${entry.key}: a contract-backed entry never restates a literal descriptor`);
650
+ for (const ref of entry.invocationRefs) {
651
+ assert.equal(
652
+ typeof REVIEW_CONTRACT[ref.contractField]?.[ref.index], 'string',
653
+ `${entry.key}: ref ${ref.contractField}[${ref.index}] does not resolve into the manifest contract`,
654
+ );
655
+ }
656
+ }
657
+ });
658
+
659
+ it('every review contract invocation is claimed by exactly ONE catalog entry (no uncataloged mode)', () => {
660
+ const claims = reviewEntries.flatMap((e) => e.invocationRefs.map((r) => `${r.contractField}[${r.index}]`));
661
+ assert.equal(new Set(claims).size, claims.length, 'a contract invocation is claimed at most once');
662
+ const declared = [
663
+ ...REVIEW_CONTRACT.invocations.map((_, i) => `invocations[${i}]`),
664
+ ...REVIEW_CONTRACT.continue.map((_, i) => `continue[${i}]`),
665
+ ];
666
+ setEq(new Set(claims), declared, 'catalog claims ⟷ declared contract invocations');
667
+ });
668
+
669
+ it('every env-hook the catalog aims at a review mode is a real EXECUTABLE guard, not a mention', () => {
670
+ const hooks = catalog.filter((e) => e.kind === 'env-hook' && e.parents.some((p) => reviewPrimaries.some((r) => r.key === p)));
671
+ assert.ok(hooks.length > 0, 'AGY_PROBE must be cataloged as an env-hook over the review modes');
672
+ for (const hook of hooks) {
673
+ assert.ok(
674
+ consultsEnv(source, hook.key),
675
+ `env-hook ${hook.key} is named in the source but never TESTED in an executable condition — a help/comment mention would keep a name-grep green after the logic is deleted`,
676
+ );
677
+ }
678
+ });
679
+
680
+ it('the catalog operand slots set-EQUAL the slots its rendered forms really carry (both directions)', () => {
681
+ for (const entry of reviewEntries) {
682
+ const forms = entry.invocationRefs.map((r) => REVIEW_CONTRACT[r.contractField][r.index]);
683
+ // The DEDUPLICATED UNION over every resolved form: a plural-ref entry legitimately spreads its
684
+ // slots across forms, so per-form equality would false-fail a correct catalog.
685
+ const realSlots = new Set(forms.flatMap((f) => f.match(SLOT_RE) ?? []));
686
+ setEq(new Set((entry.operands ?? []).map((o) => o.slot)), realSlots, `${entry.key}: catalog operands ⟷ the slots its forms really carry`);
687
+ }
688
+ });
689
+
690
+ it('an entry rendering a LITERAL descriptor is slot-checked too (env-hooks have no role to filter on)', () => {
691
+ // The contract-backed arm above filters by role — and an env-hook HAS no role, so its descriptor
692
+ // was never slot-checked at all. That is exactly how a hardcoded dead path can reach the
693
+ // discovery surface looking ready-to-run. Every literal-descriptor kind is covered here:
694
+ // env-hooks and contract-free primaries.
695
+ const literalEntries = catalog.filter((e) => typeof e.descriptor === 'string');
696
+ assert.ok(literalEntries.length > 0, 'AGY_PROBE must be cataloged with a literal descriptor');
697
+ for (const entry of literalEntries) {
698
+ const realSlots = new Set(entry.descriptor.match(SLOT_RE) ?? []);
699
+ setEq(new Set((entry.operands ?? []).map((o) => o.slot)), realSlots, `${entry.key}: catalog operands ⟷ the slots its descriptor really carries`);
700
+ }
701
+ });
702
+
703
+ it('AGY_PROBE really silences the advisory on EVERY review parent the catalog claims (behavioural)', () => {
704
+ // The catalog CLAIMS these modes are modified by the hook; prove it per parent rather than
705
+ // trusting a source scan: the off-frontier advisory fires without it, is silent with it.
706
+ const hook = catalog.find((e) => e.key === 'AGY_PROBE');
707
+ const drive = {
708
+ 'review.code': () => ['code', '--facts', 'f'],
709
+ 'review.plan': (sb) => { writeFileSync(join(sb.repo, 'p.md'), '# p\n'); return ['plan', 'p.md', '--facts', 'f']; },
710
+ 'review.diff': (sb) => { writeFileSync(join(sb.repo, 'c.diff'), 'diff body\n'); return ['diff', 'c.diff', '--facts', 'f']; },
711
+ 'review.continue': () => ['--continue'],
712
+ 'review.conversation': () => ['--conversation', 'conv-1'],
713
+ };
714
+ assert.ok(hook.parents.length > 0, 'AGY_PROBE must claim at least one parent');
715
+ for (const parent of hook.parents) {
716
+ assert.ok(drive[parent], `no behavioural drive for claimed parent "${parent}" — add one`);
717
+ // Both runs must really REACH agy: asserting the diagnostic text alone would let an early
718
+ // failure that never dispatched pass the probe-on branch (its stderr simply lacks the string).
719
+ const noisy = makeSandbox();
720
+ const off = run(noisy, { args: drive[parent](noisy), env: { AGY_MODEL: 'Some Weak Model' } });
721
+ rmSync(noisy.home, { recursive: true, force: true });
722
+ assert.equal(off.status, 0, `${parent}: ${off.stderr}`);
723
+ assert.equal(off.invoked, true, `${parent}: the control run must reach agy`);
724
+ assert.match(off.stderr, /non-frontier model/, `${parent}: the advisory must fire without the hook`);
725
+
726
+ const quiet = makeSandbox();
727
+ const on = run(quiet, { args: drive[parent](quiet), env: { AGY_MODEL: 'Some Weak Model', AGY_PROBE: '1' } });
728
+ rmSync(quiet.home, { recursive: true, force: true });
729
+ assert.equal(on.status, 0, `${parent}: ${on.stderr}`);
730
+ assert.equal(on.invoked, true, `${parent}: AGY_PROBE=1 must still reach agy — silence must come from the hook, not from an early exit`);
731
+ assert.doesNotMatch(on.stderr, /non-frontier model/, `${parent}: AGY_PROBE=1 must really silence it — the catalog claims it does`);
732
+ }
733
+ });
734
+ });
735
+
600
736
  describe('agy-review.sh — declared contract is really accepted (forward guard)', () => {
601
737
  it('every manifest mode runs green', () => {
602
738
  const drive = {
@@ -643,10 +779,10 @@ describe('agy-review.sh — declared contract is really accepted (forward guard)
643
779
  });
644
780
 
645
781
  // ── review receipts (AD-038) ─────────────────────────────────────────────────────
646
- // The normative fixture (docs: the AD-038 plan Decisions copied verbatim; backend/verdict here
782
+ // The normative fixture: the AD-038 shape + the D3 self-declaring probe marker (backend/verdict here
647
783
  // carry this bridge's vocabulary; dynamic values are asserted by shape):
648
784
  const RECEIPT_FIXTURE = JSON.parse(
649
- '{"schema":1,"artifact":"code","fresh":true,"fingerprint":"<sha256hex>","backend":"codex","verdict":"revise","grounded":true,"factsHash":null,"wrapperVersion":"2.3.0","timestamp":"2026-07-03T12:00:00Z"}',
785
+ '{"schema":1,"artifact":"code","fresh":true,"fingerprint":"<sha256hex>","backend":"codex","verdict":"revise","grounded":true,"factsHash":null,"wrapperVersion":"2.3.0","timestamp":"2026-07-03T12:00:00Z","probe":false}',
650
786
  );
651
787
  const RECEIPTS_REL = join('.git', 'agent-workflow-review-receipts.jsonl');
652
788
  const readReceipts = (repo) => {
@@ -682,6 +818,41 @@ describe('agy-review.sh — review receipts (AD-038)', () => {
682
818
  assert.match(receipt.timestamp, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
683
819
  });
684
820
 
821
+ // The probe marker (BRIDGE-MODES-CATALOG, D3) — the twin of the sibling bridge's arm: an
822
+ // AGY_PROBE=1 review runs with the frontier-model advisory silenced, so its receipt is marked and
823
+ // the kit's review-state gate rejects it. EVERY receipt carries the marker (true or false): it
824
+ // self-declares, so the gate reads the fact rather than inferring it from a version string that
825
+ // bumps in a different release phase. Silence is not a declaration.
826
+ it('AGY_PROBE=1 stamps probe:true — a throwaway probe can never attest a tree (D3)', () => {
827
+ const sb = makeSandbox();
828
+ const r = run(sb, { args: ['code', '--facts', 'a tiny fact'], env: { AGY_PROBE: '1', AGY_FAKE_OUTPUT: VERDICT_OUTPUT } });
829
+ const receipts = readReceipts(sb.repo);
830
+ rmSync(sb.home, { recursive: true, force: true });
831
+ assert.equal(r.status, 0, r.stderr);
832
+ assert.equal(receipts[0].probe, true, 'a probe-relaxed run marks its own receipt');
833
+ assert.deepEqual(Object.keys(receipts[0]), Object.keys(RECEIPT_FIXTURE), 'fixture key set + order');
834
+ });
835
+
836
+ // Every receipt SELF-DECLARES: the kit's gate reads the marker, never the wrapper version — so
837
+ // the marker must not depend on a version bump landing in the same release phase.
838
+ it('a normal review self-declares probe:false — the receipt states the fact, not a version', () => {
839
+ const sb = makeSandbox();
840
+ run(sb, { args: ['code', '--facts', 'a tiny fact'], env: { AGY_FAKE_OUTPUT: VERDICT_OUTPUT } });
841
+ const receipts = readReceipts(sb.repo);
842
+ rmSync(sb.home, { recursive: true, force: true });
843
+ assert.equal(receipts[0].probe, false, 'silence is not a declaration — the gate rejects an unmarked receipt');
844
+ });
845
+
846
+ it('a probe CONTINUATION is marked too (it is doubly unable to attest — fresh:false AND probe)', () => {
847
+ const sb = makeSandbox();
848
+ const r = run(sb, { args: ['--continue'], env: { AGY_PROBE: '1', AGY_FAKE_OUTPUT: VERDICT_OUTPUT } });
849
+ const receipts = readReceipts(sb.repo);
850
+ rmSync(sb.home, { recursive: true, force: true });
851
+ assert.equal(r.status, 0, r.stderr);
852
+ assert.equal(receipts[0].fresh, false);
853
+ assert.equal(receipts[0].probe, true, 'both write paths carry the marker — no unmarked probe lane');
854
+ });
855
+
685
856
  it('an ungrounded fresh run records grounded:false + factsHash null (the vacuous-grounding hole stays visible)', () => {
686
857
  const sb = makeSandbox();
687
858
  const r = run(sb, { args: ['code'], env: { AGY_FAKE_OUTPUT: VERDICT_OUTPUT } });
@@ -994,8 +1165,9 @@ describe('agy-review.sh — settings surface ⟷ manifest (D6, manifest-pinned)'
994
1165
  assert.ok(arm, `no validation arm for ${s.key}`);
995
1166
  if (s.kind === 'enum') for (const v of s.values) assert.ok(arm[1].includes(`"${v}"`), `${s.key}: enum value '${v}' not pinned`);
996
1167
  if (s.kind === 'integer') {
997
- assert.match(arm[1], new RegExp(`>= ${s.min}\\b`), `${s.key}: min ${s.min} not pinned`);
998
- assert.match(arm[1], new RegExp(`<= ${s.max}\\b`), `${s.key}: max ${s.max} not pinned`);
1168
+ // Issue-012 refactor: min/max are pinned as the aw_int_in_range helper's positional bounds
1169
+ // (`aw_int_in_range "$v" <min> <max>`) the overflow-safe range check replaced raw arithmetic.
1170
+ assert.match(arm[1], new RegExp(`aw_int_in_range "\\$v" ${s.min} ${s.max}\\b`), `${s.key}: min/max ${s.min}/${s.max} not pinned as the aw_int_in_range bounds`);
999
1171
  }
1000
1172
  if (s.kind === 'boolean') assert.ok(arm[1].includes('"0"') && arm[1].includes('"1"'), `${s.key}: boolean 0/1 not pinned`);
1001
1173
  if (s.kind === 'duration') {
@@ -99,12 +99,22 @@ aw_settings_known() {
99
99
  *) return 1 ;;
100
100
  esac
101
101
  }
102
+ aw_int_in_range() {
103
+ # All-digits $1 vs [min,max] WITHOUT 64-bit wrap (Issue-012): strip leading zeros, then a longer
104
+ # digit count than max's is unconditionally out of range — never do the arithmetic on a huge string.
105
+ # A leading-zero in-range value still passes (its stripped count is small); all-zeros collapses to
106
+ # "0" (below min>=1). Mirrors the JS settingValueValid safe-integer bound, verified by parity test.
107
+ local n="${1#"${1%%[!0]*}"}" min="$2" max="$3"
108
+ n="${n:-0}"
109
+ (( ${#n} > ${#max} )) && return 1
110
+ (( n >= min && n <= max ))
111
+ }
102
112
  aw_settings_valid() {
103
113
  local k="$1" v="$2" int_re='^[0-9]+$' dur_re='^[0-9]+(\.[0-9]+)?[smhd]$' zero_re='^0+(\.0+)?[smhd]$'
104
114
  case "$k" in
105
115
  CODEX_SERVICE_TIER) [[ "$v" == "priority" ]] ;;
106
- CODEX_HARD_TIMEOUT) [[ "$v" =~ $int_re ]] && (( 10#$v >= 1 && 10#$v <= 86400 )) ;;
107
- CODEX_REVIEW_MAX_TOTAL_BYTES) [[ "$v" =~ $int_re ]] && (( 10#$v >= 1 && 10#$v <= 100000000 )) ;;
116
+ CODEX_HARD_TIMEOUT) [[ "$v" =~ $int_re ]] && aw_int_in_range "$v" 1 86400 ;;
117
+ CODEX_REVIEW_MAX_TOTAL_BYTES) [[ "$v" =~ $int_re ]] && aw_int_in_range "$v" 1 100000000 ;;
108
118
  AGY_HARD_TIMEOUT) [[ "$v" =~ $dur_re && ! "$v" =~ $zero_re ]] ;;
109
119
  AGY_REVIEW_ALLOW_ADDDIR) [[ "$v" == "0" || "$v" == "1" ]] ;;
110
120
  *) return 1 ;;
@@ -156,6 +166,14 @@ aw_apply_settings() {
156
166
  fi
157
167
  continue
158
168
  fi
169
+ # Normalize an all-digit (integer) value to DECIMAL before export: a leading-zero value the integer
170
+ # arms legitimately accept (000…086400 == 86400) would otherwise read as OCTAL in downstream Bash
171
+ # arithmetic ("value too great for base"). Strip leading zeros, floor "0"; enum/duration (non-digit)
172
+ # and boolean 0/1 are unaffected.
173
+ if [[ "$value" =~ ^[0-9]+$ ]]; then
174
+ value="${value#"${value%%[!0]*}"}"
175
+ value="${value:-0}"
176
+ fi
159
177
  export "$key=$value"
160
178
  done
161
179
  return 0
@@ -443,8 +443,9 @@ describe('agy.sh — settings surface ⟷ manifest (D6, manifest-pinned)', () =>
443
443
  assert.ok(arm, `no validation arm for ${s.key}`);
444
444
  if (s.kind === 'enum') for (const v of s.values) assert.ok(arm[1].includes(`"${v}"`), `${s.key}: enum value '${v}' not pinned`);
445
445
  if (s.kind === 'integer') {
446
- assert.match(arm[1], new RegExp(`>= ${s.min}\\b`), `${s.key}: min ${s.min} not pinned`);
447
- assert.match(arm[1], new RegExp(`<= ${s.max}\\b`), `${s.key}: max ${s.max} not pinned`);
446
+ // Issue-012 refactor: min/max are pinned as the aw_int_in_range helper's positional bounds
447
+ // (`aw_int_in_range "$v" <min> <max>`) the overflow-safe range check replaced raw arithmetic.
448
+ assert.match(arm[1], new RegExp(`aw_int_in_range "\\$v" ${s.min} ${s.max}\\b`), `${s.key}: min/max ${s.min}/${s.max} not pinned as the aw_int_in_range bounds`);
448
449
  }
449
450
  if (s.kind === 'boolean') assert.ok(arm[1].includes('"0"') && arm[1].includes('"1"'), `${s.key}: boolean 0/1 not pinned`);
450
451
  if (s.kind === 'duration') {
@@ -454,3 +455,79 @@ describe('agy.sh — settings surface ⟷ manifest (D6, manifest-pinned)', () =>
454
455
  }
455
456
  });
456
457
  });
458
+
459
+ // ── mode catalog: the contract-free raw mode ⟷ this wrapper (BRIDGE-MODES-CATALOG) ─────
460
+ // agy-run is the probe role: it is dispatched by no activity slot and so carries NO manifest
461
+ // driving contract (`wrapperContractFor(_, 'probe')` stays null). Its catalog entry therefore
462
+ // carries a LITERAL descriptor — the stated exception to no-duplication, because for a
463
+ // contract-free mode the catalog IS canonical. That makes the catalog the only thing that can
464
+ // drift from this wrapper, so these arms pin it in BOTH directions and then really drive it.
465
+ const USAGE_HEADER = 'Usage:';
466
+ const PROBE_CMD = 'agy-run';
467
+ // The operand slots a rendered invocation form really carries: <angle> and [bracket] placeholders.
468
+ // The optional `@` prefix rides WITH the slot — the catalog declares the whole token a user types.
469
+ const SLOT_RE = /@?<[^<>]+>|\[[^[\]]*\]/g;
470
+
471
+ describe('agy.sh — mode catalog ⟷ wrapper reality (the contract-free raw mode)', () => {
472
+ const catalog = MANIFEST.modeCatalog ?? [];
473
+ const probeEntries = catalog.filter((e) => e.role === 'probe');
474
+
475
+ const helpText = () => {
476
+ const home = mkdtempSync(join(tmpdir(), 'agy-catalog-help-'));
477
+ const r = spawnSync('bash', [WRAPPER, '--help'], {
478
+ env: { HOME: home, PATH: makePathWithout(home, ['agy', 'git']) },
479
+ encoding: 'utf8',
480
+ timeout: 15000,
481
+ });
482
+ rmSync(home, { recursive: true, force: true });
483
+ assert.equal(r.status, 0, r.stderr);
484
+ return r.stdout;
485
+ };
486
+
487
+ it('the probe role is cataloged as exactly ONE contract-free primary carrying a literal descriptor', () => {
488
+ assert.equal(probeEntries.length, 1, 'agy-run is one mode — a raw prompt');
489
+ const [entry] = probeEntries;
490
+ assert.equal(entry.kind, 'primary');
491
+ assert.equal(MANIFEST.roles.probe.contract, undefined, 'the probe role carries no driving contract — the catalog must not invent one');
492
+ assert.ok(!Object.hasOwn(entry, 'invocationRefs'), 'a contract-free entry has nothing to reference');
493
+ assert.equal(typeof entry.descriptor, 'string', 'a contract-free entry is canonical for its own form');
494
+ });
495
+
496
+ it('the catalog descriptor IS a real --help Usage line (catalog → wrapper: no invented form)', () => {
497
+ const usage = helpSection(helpText(), USAGE_HEADER);
498
+ assert.ok(usage.includes(probeEntries[0].descriptor), `the cataloged form is in no Usage line: ${probeEntries[0].descriptor}`);
499
+ });
500
+
501
+ it('every --help Usage line belongs to the cataloged mode (wrapper → catalog: no uncataloged form)', () => {
502
+ const usage = helpSection(helpText(), USAGE_HEADER);
503
+ assert.ok(usage.length > 0, 'the wrapper must document its usage');
504
+ for (const line of usage) {
505
+ assert.ok(line.includes(PROBE_CMD), `Usage line "${line}" invokes something other than the one cataloged mode`);
506
+ }
507
+ });
508
+
509
+ it('the catalog operand slots set-EQUAL the slots the cataloged form really carries (both directions)', () => {
510
+ const [entry] = probeEntries;
511
+ const realSlots = new Set(entry.descriptor.match(SLOT_RE) ?? []);
512
+ setEq(new Set((entry.operands ?? []).map((o) => o.slot)), realSlots, 'catalog operands ⟷ the slots the descriptor really carries');
513
+ });
514
+
515
+ it('the required operand really accepts every form the catalog says it takes (forward drive)', () => {
516
+ // <prompt|-|@file> is ONE slot with three real spellings — drive all three against the stub.
517
+ const home = makeSandbox('#!/usr/bin/env bash\nprintf "%s" "$*" >"$HOME/argv"\necho FAKE_REPLY\n');
518
+ const promptFile = join(home, 'p.md');
519
+ writeFileSync(promptFile, 'from a file\n');
520
+ const env = { HOME: home, PATH: `${join(home, '.local', 'bin')}:${process.env.PATH}`, TMPDIR: process.env.TMPDIR ?? '/tmp' };
521
+ const drives = [
522
+ { label: 'literal prompt', args: ['inline prompt'], stdin: undefined },
523
+ { label: 'stdin (-)', args: ['-'], stdin: 'from stdin\n' },
524
+ { label: '@file', args: [`@${promptFile}`], stdin: undefined },
525
+ ];
526
+ for (const drive of drives) {
527
+ const r = spawnSync('bash', [WRAPPER, ...drive.args], { env, encoding: 'utf8', timeout: 20000, input: drive.stdin });
528
+ assert.equal(r.status, 0, `${drive.label}: ${r.stderr}`);
529
+ assert.match(r.stdout, /FAKE_REPLY/, `${drive.label} never reached agy`);
530
+ }
531
+ rmSync(home, { recursive: true, force: true });
532
+ });
533
+ });