@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 +98 -0
- package/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +9 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +42 -11
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +176 -4
- package/bridges/antigravity-cli-bridge/bin/agy.sh +20 -2
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +79 -2
- package/bridges/antigravity-cli-bridge/capability.json +160 -2
- package/bridges/codex-cli-bridge/SKILL.md +8 -1
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +20 -2
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +136 -2
- package/bridges/codex-cli-bridge/bin/codex-review.sh +42 -11
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +168 -4
- package/bridges/codex-cli-bridge/capability.json +118 -2
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/review-ledger.md +1 -1
- package/references/modes/review-state.md +2 -2
- package/references/modes/setup.md +7 -2
- package/references/modes/upgrade.md +9 -9
- package/tools/detect-backends.mjs +2 -2
- package/tools/doc-parity.mjs +7 -0
- package/tools/fs-safe.mjs +28 -5
- package/tools/manifest/schema.md +62 -0
- package/tools/manifest/validate.mjs +327 -0
- package/tools/review-ledger-core.mjs +74 -0
- package/tools/review-ledger-write.mjs +19 -10
- package/tools/review-ledger.mjs +21 -12
- package/tools/review-state.mjs +89 -28
- package/tools/setup-backends.mjs +48 -3
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: '
|
|
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.
|
|
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);
|
|
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 ]] &&
|
|
140
|
-
CODEX_REVIEW_MAX_TOTAL_BYTES) [[ "$v" =~ $int_re ]] &&
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
998
|
-
|
|
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 ]] &&
|
|
107
|
-
CODEX_REVIEW_MAX_TOTAL_BYTES) [[ "$v" =~ $int_re ]] &&
|
|
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
|
-
|
|
447
|
-
|
|
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
|
+
});
|