@sabaiway/agent-workflow-kit 3.1.0 → 3.3.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/SKILL.md +3 -3
  3. package/bridges/antigravity-cli-bridge/SKILL.md +6 -4
  4. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +84 -7
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +113 -0
  6. package/bridges/antigravity-cli-bridge/bin/agy.sh +55 -6
  7. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +18 -0
  8. package/bridges/antigravity-cli-bridge/capability.json +4 -2
  9. package/bridges/antigravity-cli-bridge/references/driving-agy.md +5 -0
  10. package/bridges/codex-cli-bridge/SKILL.md +8 -4
  11. package/bridges/codex-cli-bridge/bin/codex-exec.sh +129 -11
  12. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +211 -1
  13. package/bridges/codex-cli-bridge/bin/codex-review.sh +76 -13
  14. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +47 -0
  15. package/bridges/codex-cli-bridge/capability.json +19 -4
  16. package/bridges/codex-cli-bridge/references/driving-codex.md +11 -0
  17. package/capability.json +1 -1
  18. package/package.json +1 -1
  19. package/references/modes/bootstrap.md +3 -2
  20. package/references/modes/upgrade.md +9 -4
  21. package/references/modes/velocity.md +8 -2
  22. package/references/shared/command-shapes.md +22 -0
  23. package/references/shared/composition-handoff.md +7 -2
  24. package/references/templates/agent_rules.md +10 -1
  25. package/tools/bridge-settings-read.mjs +25 -5
  26. package/tools/delegation.mjs +2 -2
  27. package/tools/detect-backends.mjs +10 -0
  28. package/tools/lens-region.mjs +113 -36
  29. package/tools/recipes.mjs +5 -2
  30. package/tools/release-scan.mjs +147 -13
  31. package/tools/run-gates.mjs +43 -2
  32. package/tools/velocity-profile.mjs +229 -20
package/CHANGELOG.md CHANGED
@@ -4,6 +4,77 @@ 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
+ ## 3.3.0 — version-pin honesty: the profile stops claiming a limit it never observed (AD-062)
8
+
9
+ `velocity --autonomy` told every user their credentials could not be protected by the sandbox —
10
+ naming a harness version frozen in the source, 30 patch releases stale, while a comment three lines
11
+ above recorded when the capability had actually arrived. A user who declared
12
+ `redlines.credentials=deny` got no `sandbox.credentials` key and a confident explanation that the
13
+ platform could not enforce it. Not a silent failure: a false claim about a security control.
14
+
15
+ Nobody can guarantee a vendor's version format, install layout or settings schema stays put, so the
16
+ fix is not "read the right version" — it is **never state what was not observed**. A pin goes stale
17
+ silently; a probe goes stale loudly, and that direction is the whole point.
18
+
19
+ - **`probeHarnessVersion`** reads the INSTALLED harness at render time — read-only PATH resolution,
20
+ never a spawn. Two layouts matched exactly (`claude/versions/<version>` and the
21
+ `@anthropic-ai/claude-code` package.json); a third-party wrapper whose name merely contains
22
+ "claude", a decorated version segment, and a prerelease all resolve to a STATED unknown. A
23
+ programming defect inside the walk is rethrown rather than reported as an unknown layout, and an
24
+ unreadable package.json surfaces instead of folding into "not found".
25
+ - **A supported build now gets the protection.** `sandbox.credentials` is rendered, MERGED into
26
+ settings (hand-declared `files` and foreign entries survive), drift-checked and local-mask-checked
27
+ — the render owns exactly the entries for the env vars it protects, nothing more.
28
+ - **Every honest limit is a loud degrade, not a note.** Coverage is stated PARTIAL (env vars only;
29
+ file credentials are NOT rendered, because that entry shape was never verified against an
30
+ installed build). `credentials=ask` degrades rather than being quietly upgraded to deny. An older
31
+ build, or one whose version cannot be RESOLVED from a recognised layout, degrades naming what was
32
+ observed or stating that nothing was — and a PASSING `--check` prints the degrades too, because
33
+ "in sync" only means the file matches the render. An UNREADABLE install (a permission error) or a
34
+ defect inside the probe is a different outcome on purpose: it throws loudly rather than degrading,
35
+ because "cannot confirm" must never read as "confirmed absent".
36
+ - **New `version-pin` rung in `release-scan`.** A harness version literal under `tools/` fails the
37
+ scan unless a runtime probe sits beside it in the same JS file. The harness series is discovered
38
+ from the scanned file, never hardcoded — a scanner carrying its own pin would be an instance of the
39
+ class it catches, and a self-refusal test enforces that. Prose earns nothing: a `#` comment in a
40
+ shell script or a line of Markdown cannot hold a probe, so those files stay scanned.
41
+ **Stated residual, argued in the source:** the rung proves a probe is PRESENT, not that its result
42
+ is compared with the literal. Proving the latter is JS lexing; an approximation of it was tried and
43
+ withdrawn, because each increment of precision opened a new hole without buying the guarantee.
44
+
45
+ ## 3.2.0 — plain language + posture as code: the friction cluster shipped as mechanism (AD-061)
46
+
47
+ Carrier release for a friction cluster (kit 3.2.0 bundles the bridges + shared contract + the
48
+ plain-language template bar; memory 3.1.0, codex bridge 3.1.0, agy bridge 4.1.0 ride alongside;
49
+ engine unbumped). The packaged fixes below ship as MECHANISM for every user, never as one agent's
50
+ discipline.
51
+
52
+ - **Shared command-shapes contract.** New `references/shared/command-shapes.md` states the
53
+ promptless bar for improvised reads/probes — the host's file-read tool, else ONE plain
54
+ undecorated command (no compounds, redirects, pipes, or command substitution; improvised writes
55
+ use the host's file-edit tools). Bound via the existing `Requires:` mechanism to exactly the
56
+ probe-instructing modes (bootstrap / upgrade / velocity, closed-world pinned) + a one-sentence
57
+ inline bar on the SKILL.md router stamp-read. The honest residual is stated (a decorated form is
58
+ host/config-dependent) — never a false "always prompts" guarantee.
59
+ - **Plain-language §2.5 Communication bar** in BOTH deployed `agent_rules` templates,
60
+ byte-identically (the kit fallback deploy path communicates under the same bar): user-facing
61
+ narration is short and plain in the dialogue language; transliterated jargon is banned; an
62
+ English term survives only as the NAME of a thing (flag / command / file / test), glossed.
63
+ `lens-region.mjs` now reconciles a SECOND region (Communication, canon from the kit's own bundled
64
+ template) so EXISTING deployments gain the bar on `upgrade`, not only new bootstraps.
65
+ - **Posture as code on the codex-exec dispatch and both review dispatches** (bridges bundled here).
66
+ `codex-exec` gains a D5 exec posture banner (fresh + resume, resolved post-validation values, a
67
+ validated resume-id grammar + raw-byte NUL screen, control-byte refusal on every field). Both
68
+ review banners gain a BANNER-ONLY `timeout` field — outside D5 banner↔receipt parity — from ONE
69
+ shared effective-timeout resolver (byte-identical across the four wrappers) that closes the old
70
+ env-validation bypass and resolves the timeout binary shadow-proof; no capping binary →
71
+ `timeout=uncapped`, never fabricated. (The raw `agy-run` probe lane keeps no posture banner —
72
+ out of scope this release.) The quote-the-banner-verbatim duty rides both bridges' driving
73
+ contracts, capability notes, and the kit registry mirror, drift-guarded by a new cross-file test;
74
+ the threat-model boundary is stated on the codex execute capability and `codex-exec --help`
75
+ surfaces, including the kit mirror. The read-only `bridge-settings` advisor mirrors the wrapper's
76
+ control-byte refusal semantics with escaped output.
77
+
7
78
  ## 3.1.0 — parallel feature worktrees v1: provision · list · land --prepare · cleanup (AD-060)
8
79
 
9
80
  New `worktrees` mode + `tools/worktrees.mjs` — several features implemented simultaneously in
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: '3.1.0'
6
+ version: '3.3.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
@@ -68,7 +68,7 @@ Pick the mode from the user's invocation — the mapping is pinned by `tools/com
68
68
 
69
69
  **Safe-routing rule (which mode did the user invoke?).** Map the invocation token with `tools/commands.mjs` `routeInvocation`: a **known** subcommand → its mode; the **bare/empty** invocation → `bootstrap` — the one writer reachable without a token, and only on an undeployed project (if `docs/ai/` already exists, **ask upgrade-vs-bootstrap**, never overwrite); **any unrecognized/ambiguous** token → `help`, which is **read-only**. The invariant: **no unrecognized/garbage invocation ever triggers a write** (only an explicit known token or the acknowledged bare-bootstrap exception can).
70
70
 
71
- Before acting, read `docs/ai/.workflow-version` (the project's stamp) to decide the route — an **internal** routing decision, **not** a line you print (version display: *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`). Route:
71
+ Before acting, read `docs/ai/.workflow-version` (the project's stamp) — via the host's file-read tool, else ONE plain undecorated command (no compounds/redirects/pipes) — to decide the route — an **internal** routing decision, **not** a line you print (version display: *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`). Route:
72
72
 
73
73
  - **absent** → bootstrap (a fresh deployment).
74
74
  - **stamp < `3.0.0`** (the deployment-lineage head) → `upgrade`.
@@ -195,7 +195,7 @@ writer — read `${CLAUDE_SKILL_DIR}/references/modes/worktrees.md` before actin
195
195
 
196
196
  ## References
197
197
 
198
- - [`references/modes/`](${CLAUDE_SKILL_DIR}/references/modes/) — one file per mode; [`references/shared/`](${CLAUDE_SKILL_DIR}/references/shared/) — the shared point-of-use contracts (report footer · composition hand-off · deploy tail).
198
+ - [`references/modes/`](${CLAUDE_SKILL_DIR}/references/modes/) — one file per mode; [`references/shared/`](${CLAUDE_SKILL_DIR}/references/shared/) — the shared point-of-use contracts (report footer · composition hand-off · deploy tail · command shapes).
199
199
  - [`references/contracts.md`](${CLAUDE_SKILL_DIR}/references/contracts.md) — the three setup contracts.
200
200
  - [`references/templates/`](${CLAUDE_SKILL_DIR}/references/templates/) — the `AGENTS.md` + `docs/ai/` templates + seeded configs; [`references/scripts/`](${CLAUDE_SKILL_DIR}/references/scripts/) — the Node enforcement scripts; [`migrations/`](${CLAUDE_SKILL_DIR}/migrations/) — per-version upgrade steps; [`launchers/`](${CLAUDE_SKILL_DIR}/launchers/) — non-Claude agent launchers.
201
201
  - [`tools/`](${CLAUDE_SKILL_DIR}/tools/) — the family-wide tooling; each tool's contract lives with its mode file and its own header; bridge mirrors under [`bridges/`](${CLAUDE_SKILL_DIR}/bridges/).
@@ -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: '4.0.0'
5
+ version: '4.1.0'
6
6
  ---
7
7
 
8
8
  # antigravity-cli-bridge
@@ -134,10 +134,12 @@ agy-review --continue --decided @round1.md --focus "still-open items" # round-
134
134
 
135
135
  **Honesty + posture (D4/D5):** a run with no recognized `### Verdict` section — empty output
136
136
  included — **exits 4 with NO receipt**: treat it as a *failed review to re-run*, never a fatal
137
- session error. One stderr banner states the actual posture (`review posture: model=…`) and the
138
- receipt records the same `posture {model}`; an attesting review with `AGY_MODEL` explicitly
137
+ session error. One stderr banner states the actual posture (`review posture: model=… timeout=…`)
138
+ and the receipt records the same `posture {model}`; an attesting review with `AGY_MODEL` explicitly
139
139
  emptied refuses pre-spend (`AGY_PROBE=1` exempt), and control bytes in a model string refuse
140
- pre-spend in every mode.
140
+ pre-spend in every mode. The `timeout=` field is **banner-only** (exactly the duration `agy-run`
141
+ hands to `timeout(1)`, or `uncapped`) — informational, never a receipt field. **Quote the posture
142
+ banner verbatim** when labeling a dispatch.
141
143
 
142
144
  Frontier default `Gemini 3.1 Pro (High)`; **any** model is allowed (a sub-frontier one earns a
143
145
  silenceable `AGY_PROBE=1` advisory). An oversized `code` review trips the byte ceiling with trim/split
@@ -77,6 +77,11 @@ Notes:
77
77
  pre-dispatch host-diff: before the FIRST dispatch of this bridge, diff its declared networkHosts
78
78
  against the live sandbox allow-list — a missing host is surfaced to the maintainer BEFORE
79
79
  dispatching, never fired into a known prompt
80
+ the review posture banner appends a banner-only timeout=<duration|uncapped> field — exactly the
81
+ duration agy-run hands to timeout(1), uncapped when no timeout/gtimeout binary caps the run;
82
+ INFORMATIONAL only: it never enters the receipt posture or the D5 banner↔receipt parity
83
+ quote the posture banner verbatim when labeling this dispatch — the banner is the machine-stated
84
+ posture; a prose re-type drifts
80
85
 
81
86
  Round-2 / resume:
82
87
  agy-review --continue [--decided @f] [--focus "…"]
@@ -115,10 +120,12 @@ Settings file (KEY=VALUE, parsed never sourced; env wins over file, file wins ov
115
120
  Honesty + posture (D4/D5):
116
121
  a run whose output carries NO recognized '### Verdict' section — empty output included — exits 4
117
122
  with NO receipt: a FAILED review to RE-RUN, never a fatal session error. One stderr banner line
118
- states the ACTUAL run posture (review posture: model=…) and the receipt records the same
119
- posture {model} (agy has no tier). An ATTESTING review with AGY_MODEL explicitly emptied
120
- refuses pre-spend (the actual model would be unknowable; AGY_PROBE=1 is exempt), and a model
121
- string carrying control bytes refuses pre-spend in every mode.
123
+ states the ACTUAL run posture (review posture: model=… timeout=…) and the receipt records the
124
+ same posture {model} (agy has no tier; the timeout field is banner-only never a receipt
125
+ field). Quote the posture banner verbatim when labeling this dispatch. An ATTESTING review with
126
+ AGY_MODEL explicitly emptied refuses pre-spend (the actual model would be unknowable;
127
+ AGY_PROBE=1 is exempt), and a model string carrying control bytes refuses pre-spend in every
128
+ mode.
122
129
 
123
130
  Closed grammar: unknown flags are rejected; no '--' passthrough (the flag escape is --ungrounded; the env escapes are AGY_PROBE=1 and AGY_REVIEW_ALLOW_ADDDIR=1).
124
131
  Requires at run time: the agy CLI on PATH + a Google AI subscription login (--help needs neither).
@@ -235,16 +242,66 @@ aw_apply_settings() {
235
242
  }
236
243
  aw_apply_settings
237
244
 
245
+ # --- Effective-timeout resolver (D5 banner honesty; AD-061) --------------------
246
+ # ONE rule, both bridges: the posture banner prints EXACTLY the duration handed to timeout(1) —
247
+ # an integer-seconds value rendered with the `s` suffix, a duration string verbatim — and
248
+ # `timeout=uncapped` when no timeout/gtimeout binary can cap the run; never a fabricated number.
249
+ # The EFFECTIVE value (env included — closing the aw_settings_valid env bypass) is validated by
250
+ # the same per-key rule as the settings file, plus a 7-digit integer-part bound (overflow); an
251
+ # invalid value warns + falls back to the built-in default — a typo never silently masquerades
252
+ # as a cap. AGY_TIMEOUT shares AGY_HARD_TIMEOUT's duration rule (it has no settings-file arm).
253
+ aw_effective_timeout() {
254
+ local key="$1" default="$2" value="${!1:-}" rule="$1" intpart
255
+ [[ "$rule" == "AGY_TIMEOUT" ]] && rule="AGY_HARD_TIMEOUT"
256
+ [[ -n "$value" ]] || { printf '%s' "$default"; return 0; }
257
+ intpart="${value%%[!0-9]*}"
258
+ if ! aw_settings_valid "$rule" "$value" || (( ${#intpart} > 7 )); then
259
+ # %q escapes the raw value so a control byte in it can never forge an extra diagnostic line
260
+ # (the direct agy-run lane has no pre-spend screen — the warning itself must be injection-proof).
261
+ printf "warning: invalid value '%q' for %s — using the built-in default %s.\n" "$value" "$key" "$default" >&2
262
+ printf '%s' "$default"
263
+ return 0
264
+ fi
265
+ printf '%s' "$value"
266
+ }
267
+ aw_timeout_label() {
268
+ local bin="$1" value="$2"
269
+ [[ -n "$bin" ]] || { printf 'uncapped'; return 0; }
270
+ case "$value" in
271
+ *[!0-9]*) printf '%s' "$value" ;;
272
+ *) printf '%ss' "$value" ;;
273
+ esac
274
+ }
275
+ aw_resolve_timeout_bin() {
276
+ local bin dir base
277
+ bin="$(builtin type -P timeout 2>/dev/null || true)"
278
+ [[ -n "$bin" ]] || bin="$(builtin type -P gtimeout 2>/dev/null || true)"
279
+ [[ -n "$bin" ]] || { printf ''; return 0; }
280
+ case "$bin" in
281
+ /*) ;;
282
+ *)
283
+ case "$bin" in
284
+ */*) dir="${bin%/*}"; base="${bin##*/}" ;;
285
+ *) dir="."; base="$bin" ;;
286
+ esac
287
+ dir="$(builtin cd -- "$dir" 2>/dev/null && builtin pwd -P)" || { printf ''; return 0; }
288
+ bin="$dir/$base"
289
+ ;;
290
+ esac
291
+ [[ -f "$bin" && -x "$bin" ]] || { printf ''; return 0; }
292
+ printf '%s' "$bin"
293
+ }
294
+
238
295
  DEFAULT_AGY_REVIEW_MODEL="Gemini 3.1 Pro (High)"
239
296
  # Review-receipt identity (AD-038). AW_BRIDGE_VERSION mirrors this bridge's SKILL.md/capability.json
240
297
  # version (drift-guarded by agy-review.test.mjs against capability.json).
241
298
  AW_RECEIPT_BACKEND="agy"
242
- AW_BRIDGE_VERSION="4.0.0"
299
+ AW_BRIDGE_VERSION="4.1.0"
243
300
  # `-` not `:-` so an EXPLICIT empty AGY_MODEL= survives (drop --model, use settings.json — agy.sh:52).
244
301
  AGY_MODEL="${AGY_MODEL-$DEFAULT_AGY_REVIEW_MODEL}"
245
302
  # D5 control-byte screen — IMMEDIATELY after resolution, BEFORE the off-frontier advisory (or any
246
303
  # other interpolation) can echo raw newline/ESC bytes into stderr/the terminal (round-2 fold).
247
- if [[ "$AGY_MODEL" == *[$'\x01'-$'\x1f']* ]]; then
304
+ if [[ "$AGY_MODEL" == *[$'\x01'-$'\x1f'$'\x7f']* ]]; then
248
305
  echo "error: AGY_MODEL contains control bytes — fix the setting (env or bridge-settings.conf) and re-run." >&2
249
306
  exit 2
250
307
  fi
@@ -254,7 +311,20 @@ FRONTIER_SET=("Gemini 3.1 Pro (High)" "Claude Opus 4.6 (Thinking)" "Claude Sonne
254
311
  # Duration-string timeouts (NOT codex's bare seconds): agy-run forwards a duration to --print-timeout,
255
312
  # and the timeout(1) hard cap is a duration too — never numerically compared, so 30m vs 2h is fine.
256
313
  AGY_HARD_TIMEOUT="${AGY_HARD_TIMEOUT:-30m}"
314
+ # D5 banner-field control-byte screen (AD-061) — like AGY_MODEL above, BEFORE any interpolation.
315
+ if [[ "$AGY_HARD_TIMEOUT" == *[$'\x01'-$'\x1f'$'\x7f']* ]]; then
316
+ echo "error: AGY_HARD_TIMEOUT contains control bytes — fix the setting (env or bridge-settings.conf) and re-run." >&2
317
+ exit 2
318
+ fi
319
+ AGY_HARD_TIMEOUT="$(aw_effective_timeout AGY_HARD_TIMEOUT 30m)"
257
320
  AGY_TIMEOUT="${AGY_TIMEOUT:-$AGY_HARD_TIMEOUT}"
321
+ # D5 banner-field control-byte screen for AGY_TIMEOUT (AD-061): agy-review forwards it to the
322
+ # child agy-run's --print-timeout, so a control byte must refuse HERE, pre-spawn — the child's
323
+ # own diagnostic would otherwise echo it. (agy-run screens its own direct-lane env separately.)
324
+ if [[ "$AGY_TIMEOUT" == *[$'\x01'-$'\x1f'$'\x7f']* ]]; then
325
+ echo "error: AGY_TIMEOUT contains control bytes — fix the setting (env or bridge-settings.conf) and re-run." >&2
326
+ exit 2
327
+ fi
258
328
  AGY_PROBE="${AGY_PROBE:-0}"
259
329
  # A probe run is RECORDED, not just silently allowed: the receipt carries probe:true so the kit's
260
330
  # review-state gate rejects it — a guards-relaxed review must never attest a tree.
@@ -882,7 +952,14 @@ if [[ -z "$AGY_MODEL" && -z "$resume_mode" && "$REVIEW_PROBE" != "true" && "$REV
882
952
  echo " AGY_MODEL (wrapper default), or set the real model display string; AGY_PROBE=1 is exempt." >&2
883
953
  exit 2
884
954
  fi
885
- echo "review posture: model=${AGY_MODEL:-<agy settings default>}" >&2
955
+ # The timeout field is BANNER-ONLY (AD-061): it prints exactly the duration agy-run hands to
956
+ # timeout(1), or `uncapped` without a capping binary, and never enters the receipt posture.
957
+ # aw_resolve_timeout_bin: builtin type -P (an exported function can shadow neither `timeout` nor
958
+ # `type` itself), normalized to an absolute path fail-closed — the banner can never claim a cap
959
+ # the agy-run child would not have.
960
+ aw_banner_timeout_bin="$(aw_resolve_timeout_bin)"
961
+ aw_timeout_banner="$(aw_timeout_label "$aw_banner_timeout_bin" "$AGY_HARD_TIMEOUT")"
962
+ echo "review posture: model=${AGY_MODEL:-<agy settings default>} timeout=$aw_timeout_banner" >&2
886
963
 
887
964
  # --- Execute via agy-run (single home of timeout + subscription + byte ceiling) ---
888
965
  # The output is teed into the private staging dir so the mandated '### Verdict' section can be
@@ -1412,4 +1412,117 @@ describe('agy-review.sh — dispatch-posture labeling (D5)', () => {
1412
1412
  assert.equal(receipts.length, 0);
1413
1413
  assert.match(r.stderr, /control/i);
1414
1414
  });
1415
+
1416
+ it('the banner appends the RESOLVED hard timeout verbatim — banner-only, never in the receipt (AD-061)', () => {
1417
+ const sb = makeSandbox();
1418
+ const r = run(sb, { args: ['code', '--facts', 'a tiny fact'] });
1419
+ const receipts = readReceipts(sb.repo);
1420
+ rmSync(sb.home, { recursive: true, force: true });
1421
+ assert.equal(r.status, 0, r.stderr);
1422
+ assert.match(r.stderr, /^review posture: model=Gemini 3\.1 Pro \(High\) timeout=30m$/m);
1423
+ assert.deepEqual(Object.keys(receipts[0].posture), ['model'], 'timeout never enters the receipt posture');
1424
+ });
1425
+
1426
+ it('the banner prints the EFFECTIVE hard cap: an env override and a fractional duration ride verbatim', () => {
1427
+ for (const [envValue, want] of [['90s', '90s'], ['1.5m', '1\\.5m']]) {
1428
+ const sb = makeSandbox();
1429
+ const r = run(sb, { args: ['code', '--facts', 'a tiny fact'], env: { AGY_HARD_TIMEOUT: envValue } });
1430
+ rmSync(sb.home, { recursive: true, force: true });
1431
+ assert.equal(r.status, 0, r.stderr);
1432
+ assert.match(r.stderr, new RegExp(`^review posture: .* timeout=${want}$`, 'm'));
1433
+ }
1434
+ });
1435
+
1436
+ it('AGY_TIMEOUT (soft print-timeout) alone never moves the banner — the hard cap governs (precedence pin)', () => {
1437
+ const sb = makeSandbox();
1438
+ const r = run(sb, { args: ['code', '--facts', 'a tiny fact'], env: { AGY_TIMEOUT: '5m' } });
1439
+ rmSync(sb.home, { recursive: true, force: true });
1440
+ assert.equal(r.status, 0, r.stderr);
1441
+ assert.match(r.stderr, /^review posture: .* timeout=30m$/m, 'the soft print-timeout is not the banner value');
1442
+ });
1443
+
1444
+ it('an INVALID / EMPTY / OVERFLOW effective AGY_HARD_TIMEOUT falls back to the built-in default (loud on invalid)', () => {
1445
+ for (const [bad, wantWarn] of [['10x', true], ['', false], ['99999999m', true]]) {
1446
+ const sb = makeSandbox();
1447
+ const r = run(sb, { args: ['code', '--facts', 'a tiny fact'], env: { AGY_HARD_TIMEOUT: bad } });
1448
+ rmSync(sb.home, { recursive: true, force: true });
1449
+ assert.equal(r.status, 0, r.stderr);
1450
+ assert.match(r.stderr, /^review posture: .* timeout=30m$/m, `default must stand for ${JSON.stringify(bad)}`);
1451
+ if (wantWarn) assert.match(r.stderr, new RegExp(`invalid value '${bad}' for AGY_HARD_TIMEOUT`));
1452
+ }
1453
+ });
1454
+
1455
+ it('a timeout value carrying CONTROL BYTES refuses pre-spend (the banner-field screen)', () => {
1456
+ const sb = makeSandbox();
1457
+ const r = run(sb, { args: ['code', '--facts', 'a tiny fact'], env: { AGY_HARD_TIMEOUT: `30m${String.fromCharCode(1)}` } });
1458
+ const receipts = readReceipts(sb.repo);
1459
+ rmSync(sb.home, { recursive: true, force: true });
1460
+ assert.notEqual(r.status, 0);
1461
+ assert.equal(r.invoked, false);
1462
+ assert.equal(receipts.length, 0);
1463
+ assert.match(r.stderr, /control/i);
1464
+ });
1465
+
1466
+ it('timeout honesty: no timeout/gtimeout on PATH → timeout=uncapped, never a fabricated number', () => {
1467
+ const sb = makeSandbox();
1468
+ const r = run(sb, {
1469
+ args: ['code', '--facts', 'a tiny fact'],
1470
+ env: { PATH: `${sb.bin}:${farmFor(['agy', 'agy-run', 'timeout', 'gtimeout'])}` },
1471
+ });
1472
+ rmSync(sb.home, { recursive: true, force: true });
1473
+ assert.equal(r.status, 0, r.stderr);
1474
+ assert.match(r.stderr, /^review posture: .* timeout=uncapped$/m);
1475
+ });
1476
+
1477
+ it('an EXPORTED shell function shadowing timeout never fools the banner (type -P discipline)', () => {
1478
+ const sb = makeSandbox();
1479
+ const r = run(sb, {
1480
+ args: ['code', '--facts', 'a tiny fact'],
1481
+ env: {
1482
+ PATH: `${sb.bin}:${farmFor(['agy', 'agy-run', 'timeout', 'gtimeout'])}`,
1483
+ 'BASH_FUNC_timeout%%': '() { return 0; }',
1484
+ },
1485
+ });
1486
+ rmSync(sb.home, { recursive: true, force: true });
1487
+ assert.equal(r.status, 0, r.stderr);
1488
+ assert.match(r.stderr, /^review posture: .* timeout=uncapped$/m, 'a shell function is not a capping binary');
1489
+ });
1490
+
1491
+ it('an EXPORTED `type` function faking a path never fools the resolver (builtin type discipline)', () => {
1492
+ const sb = makeSandbox();
1493
+ const r = run(sb, {
1494
+ args: ['code', '--facts', 'a tiny fact'],
1495
+ env: {
1496
+ PATH: `${sb.bin}:${farmFor(['agy', 'agy-run', 'timeout', 'gtimeout'])}`,
1497
+ 'BASH_FUNC_type%%': '() { echo /fake/timeout; }',
1498
+ },
1499
+ });
1500
+ rmSync(sb.home, { recursive: true, force: true });
1501
+ assert.equal(r.status, 0, r.stderr);
1502
+ assert.match(r.stderr, /^review posture: .* timeout=uncapped$/m, 'builtin type bypasses an exported type function');
1503
+ });
1504
+
1505
+ it('a DEL (0x7f) byte in a banner field refuses pre-spend like the C0 range', () => {
1506
+ const sb = makeSandbox();
1507
+ const r = run(sb, { args: ['code', '--facts', 'a tiny fact'], env: { AGY_MODEL: `bad${String.fromCharCode(127)}model` } });
1508
+ const receipts = readReceipts(sb.repo);
1509
+ rmSync(sb.home, { recursive: true, force: true });
1510
+ assert.notEqual(r.status, 0);
1511
+ assert.equal(r.invoked, false);
1512
+ assert.equal(receipts.length, 0);
1513
+ assert.match(r.stderr, /control/i);
1514
+ });
1515
+
1516
+ it('a control byte in AGY_TIMEOUT refuses pre-spawn — agy-review forwards it to the child agy-run', () => {
1517
+ for (const c of [1, 127]) {
1518
+ const sb = makeSandbox();
1519
+ const r = run(sb, { args: ['code', '--facts', 'a tiny fact'], env: { AGY_TIMEOUT: `30m${String.fromCharCode(c)}` } });
1520
+ const receipts = readReceipts(sb.repo);
1521
+ rmSync(sb.home, { recursive: true, force: true });
1522
+ assert.notEqual(r.status, 0, `must refuse control byte ${c}`);
1523
+ assert.equal(r.invoked, false, 'refused BEFORE spawning agy-run');
1524
+ assert.equal(receipts.length, 0);
1525
+ assert.match(r.stderr, /AGY_TIMEOUT/, 'names the offending knob');
1526
+ }
1527
+ });
1415
1528
  });
@@ -180,6 +180,56 @@ aw_apply_settings() {
180
180
  }
181
181
  aw_apply_settings
182
182
 
183
+ # --- Effective-timeout resolver (D5 banner honesty; AD-061) --------------------
184
+ # ONE rule, both bridges: the posture banner prints EXACTLY the duration handed to timeout(1) —
185
+ # an integer-seconds value rendered with the `s` suffix, a duration string verbatim — and
186
+ # `timeout=uncapped` when no timeout/gtimeout binary can cap the run; never a fabricated number.
187
+ # The EFFECTIVE value (env included — closing the aw_settings_valid env bypass) is validated by
188
+ # the same per-key rule as the settings file, plus a 7-digit integer-part bound (overflow); an
189
+ # invalid value warns + falls back to the built-in default — a typo never silently masquerades
190
+ # as a cap. AGY_TIMEOUT shares AGY_HARD_TIMEOUT's duration rule (it has no settings-file arm).
191
+ aw_effective_timeout() {
192
+ local key="$1" default="$2" value="${!1:-}" rule="$1" intpart
193
+ [[ "$rule" == "AGY_TIMEOUT" ]] && rule="AGY_HARD_TIMEOUT"
194
+ [[ -n "$value" ]] || { printf '%s' "$default"; return 0; }
195
+ intpart="${value%%[!0-9]*}"
196
+ if ! aw_settings_valid "$rule" "$value" || (( ${#intpart} > 7 )); then
197
+ # %q escapes the raw value so a control byte in it can never forge an extra diagnostic line
198
+ # (the direct agy-run lane has no pre-spend screen — the warning itself must be injection-proof).
199
+ printf "warning: invalid value '%q' for %s — using the built-in default %s.\n" "$value" "$key" "$default" >&2
200
+ printf '%s' "$default"
201
+ return 0
202
+ fi
203
+ printf '%s' "$value"
204
+ }
205
+ aw_timeout_label() {
206
+ local bin="$1" value="$2"
207
+ [[ -n "$bin" ]] || { printf 'uncapped'; return 0; }
208
+ case "$value" in
209
+ *[!0-9]*) printf '%s' "$value" ;;
210
+ *) printf '%ss' "$value" ;;
211
+ esac
212
+ }
213
+ aw_resolve_timeout_bin() {
214
+ local bin dir base
215
+ bin="$(builtin type -P timeout 2>/dev/null || true)"
216
+ [[ -n "$bin" ]] || bin="$(builtin type -P gtimeout 2>/dev/null || true)"
217
+ [[ -n "$bin" ]] || { printf ''; return 0; }
218
+ case "$bin" in
219
+ /*) ;;
220
+ *)
221
+ case "$bin" in
222
+ */*) dir="${bin%/*}"; base="${bin##*/}" ;;
223
+ *) dir="."; base="$bin" ;;
224
+ esac
225
+ dir="$(builtin cd -- "$dir" 2>/dev/null && builtin pwd -P)" || { printf ''; return 0; }
226
+ bin="$dir/$base"
227
+ ;;
228
+ esac
229
+ [[ -f "$bin" && -x "$bin" ]] || { printf ''; return 0; }
230
+ printf '%s' "$bin"
231
+ }
232
+
183
233
  if ! command -v agy >/dev/null 2>&1; then
184
234
  echo "error: 'agy' (Antigravity CLI) not found on PATH. Install it and run 'agy' once to sign in." >&2
185
235
  exit 127
@@ -188,11 +238,13 @@ fi
188
238
  # `-` (empty) => skip --model and let agy use settings.json; default to Pro.
189
239
  AGY_MODEL="${AGY_MODEL-Gemini 3.1 Pro (High)}"
190
240
  AGY_TIMEOUT="${AGY_TIMEOUT:-5m}"
241
+ AGY_TIMEOUT="$(aw_effective_timeout AGY_TIMEOUT 5m)"
191
242
  # Hard wall-clock cap (defaults to AGY_TIMEOUT). agy's own --print-timeout is NOT a reliable
192
243
  # wall-clock kill — a run was observed surviving 32 min past a 10m --print-timeout — so we also wrap
193
244
  # agy in timeout(1). A heavy `--add-dir` agentic prompt on the slowest model can otherwise run
194
245
  # unbounded, and once a caller backgrounds it nothing kills it. Raise only for a known-healthy run.
195
246
  AGY_HARD_TIMEOUT="${AGY_HARD_TIMEOUT:-$AGY_TIMEOUT}"
247
+ AGY_HARD_TIMEOUT="$(aw_effective_timeout AGY_HARD_TIMEOUT "$AGY_TIMEOUT")"
196
248
 
197
249
  if [[ $# -lt 1 ]]; then
198
250
  echo "usage: $0 <prompt | - | @file> [-- extra agy flags...]" >&2
@@ -273,12 +325,9 @@ agy_cmd=(agy "${model_flag[@]}" --print-timeout "$AGY_TIMEOUT" "${passthrough[@]
273
325
 
274
326
  # Hard wall-clock cap via timeout(1) (GNU `timeout` on Linux, `gtimeout` from coreutils on macOS).
275
327
  # This is the real guard — a backgrounded, hung agy survives its own --print-timeout otherwise.
276
- timeout_bin=""
277
- if command -v timeout >/dev/null 2>&1; then
278
- timeout_bin="timeout"
279
- elif command -v gtimeout >/dev/null 2>&1; then
280
- timeout_bin="gtimeout"
281
- fi
328
+ # aw_resolve_timeout_bin: builtin type -P (an exported function can shadow neither `timeout` nor
329
+ # `type` itself), normalized to an ABSOLUTE path fail-closed nothing can masquerade as the cap.
330
+ timeout_bin="$(aw_resolve_timeout_bin)"
282
331
 
283
332
  if [[ -z "$timeout_bin" ]]; then
284
333
  echo "warning: no 'timeout'/'gtimeout' on PATH — running agy WITHOUT a hard wall-clock cap" >&2
@@ -311,6 +311,24 @@ describe('agy.sh — bridge settings file (bridges 2.3.0)', { concurrency: true
311
311
  assert.match(r.stdout, /OK reply/);
312
312
  });
313
313
 
314
+ it('an INVALID ENV duration warns and falls back too (the closed aw_settings_valid env bypass, AD-061)', async () => {
315
+ const home = makeSandbox(RECORDING_STUB);
316
+ const r = await runWrapperAsync(home, { AGY_HARD_TIMEOUT: 'abc', AGY_MODEL: '' });
317
+ rmSync(home, { recursive: true, force: true });
318
+ assert.equal(r.status, 0, r.stderr);
319
+ assert.match(r.stderr, /invalid value 'abc' for AGY_HARD_TIMEOUT/, 'the delegated agy-run lane validates the EFFECTIVE value');
320
+ assert.match(r.stdout, /OK reply/);
321
+ });
322
+
323
+ it('the invalid-value warning ESCAPES the raw value — a newline can never forge a second diagnostic line (AD-061)', async () => {
324
+ const home = makeSandbox(RECORDING_STUB);
325
+ const r = await runWrapperAsync(home, { AGY_HARD_TIMEOUT: 'x\nreview posture: model=FORGED', AGY_MODEL: '' });
326
+ rmSync(home, { recursive: true, force: true });
327
+ assert.equal(r.status, 0, r.stderr);
328
+ assert.doesNotMatch(r.stderr, /^review posture: model=FORGED/m, 'the newline is escaped by %q — no forged line');
329
+ assert.match(r.stdout, /OK reply/, 'the run still proceeds on the built-in default');
330
+ });
331
+
314
332
  it('a DIRECTORY at the settings path warns loudly and falls back to built-ins (no crash)', async () => {
315
333
  const home = makeSandbox(RECORDING_STUB);
316
334
  mkdirSync(join(home, '.config', 'agent-workflow', 'bridge-settings.conf'), { recursive: true });
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "antigravity-cli-bridge",
5
5
  "kind": "execution-backend",
6
- "version": "4.0.0",
6
+ "version": "4.1.0",
7
7
  "provides": ["review", "probe"],
8
8
  "posture": { "model": "Gemini 3.1 Pro (High)" },
9
9
  "roles": {
@@ -32,7 +32,9 @@
32
32
  ],
33
33
  "receipt": "side effect — a successful review appends one JSON receipt line to <git dir>/agent-workflow-review-receipts.jsonl (AW_REVIEW_RECEIPTS overrides; plan/diff outside a git tree: warn + skip unless overridden): fingerprint = sha256 over the canonical uncommitted-state payload (staged diff + unstaged diff + untracked-not-ignored contents — the review-payload domain; never-committable untracked paths — character/block devices, FIFOs, sockets — are excluded from the domain entirely, untracked symlinks/directories ride as name-only notes) in code mode, the artifact-file sha256 in plan/diff mode; verdict recorded verbatim from the mandated '### Verdict' section (SHIP / SHIP WITH NITS / REWORK); grounded = whether a NON-EMPTY --facts payload was supplied (code mode refuses pre-spend without one — no run, no receipt — unless --ungrounded/AGY_PROBE=1; in plan/diff an empty payload records grounded:false — fail-closed, the state gate rejects it), factsHash = sha256 of the facts payload; a continuation receipt is fresh:false (informational-only — it cannot attest the folded tree); probe = whether the run relaxed the quality guards (AGY_PROBE=1), written on EVERY receipt so it self-declares — the kit's review-state gate rejects a probe-marked receipt (a probe review never attests) and equally rejects an unmarked one (silence is not a declaration); posture = the ACTUAL run posture {model} (agy has no tier), written on EVERY receipt (D5) — the gate rejects a receipt with an absent/invalid posture (a pre-D5 wrapper minted it; re-run the review), one stderr banner line states the same posture, an ATTESTING review with AGY_MODEL explicitly emptied refuses pre-spend, and a model string carrying control bytes refuses pre-spend in every mode; a run whose output carries NO recognized '### Verdict' section — empty output included — exits 4 with NO receipt (D4: a FAILED review to RE-RUN, never a fatal session error); a write failure warns, never fails the review",
34
34
  "notes": [
35
- "pre-dispatch host-diff: before the FIRST dispatch of this bridge, diff its declared networkHosts against the live sandbox allow-list — a missing host is surfaced to the maintainer BEFORE dispatching, never fired into a known prompt"
35
+ "pre-dispatch host-diff: before the FIRST dispatch of this bridge, diff its declared networkHosts against the live sandbox allow-list — a missing host is surfaced to the maintainer BEFORE dispatching, never fired into a known prompt",
36
+ "the review posture banner appends a banner-only timeout=<duration|uncapped> field — exactly the duration agy-run hands to timeout(1), uncapped when no timeout/gtimeout binary caps the run; INFORMATIONAL only: it never enters the receipt posture or the D5 banner↔receipt parity",
37
+ "quote the posture banner verbatim when labeling this dispatch — the banner is the machine-stated posture; a prose re-type drifts"
36
38
  ]
37
39
  }
38
40
  },
@@ -89,6 +89,11 @@ What it does for you, and what YOU must supply:
89
89
  - **Model:** frontier default `Gemini 3.1 Pro (High)`; any model is allowed (a sub-frontier one earns a
90
90
  silenceable `AGY_PROBE=1` advisory). The service can still **stall on large/substantive prompts**
91
91
  (Issue-001) — keep reviews **focused**; the hard timeout is the guard.
92
+ - **Posture banner — quote it verbatim.** Every review states its ACTUAL posture on ONE stderr line
93
+ (`review posture: model=… timeout=…`). When you label a dispatch for a user or a record, **quote
94
+ the posture banner verbatim** — the banner is the machine-stated posture; a prose re-type drifts.
95
+ The `timeout=` field is **banner-only** (exactly the duration `agy-run` hands to `timeout(1)`, or
96
+ `uncapped` when no capping binary is on PATH) — informational, never part of the receipt posture.
92
97
 
93
98
  ## Escalation policy (edits, network, git)
94
99
 
@@ -2,7 +2,7 @@
2
2
  name: codex-cli-bridge
3
3
  description: Delegate work to the OpenAI Codex CLI (`codex`) under a ChatGPT subscription — run plan/instruction EXECUTION in a sandboxed workspace, or get a read-only ADVISORY review of a plan or working-tree diff — as a second delegated-execution backend beside Antigravity. Use when the user wants to hand a bounded coding task or plan to `codex exec`, get a second-opinion review from codex, install or authenticate Codex CLI, understand its sandbox/network/approval policy, drive codex efficiently from the main agent (exec vs review, resume, the commit boundary), bridge project context (`AGENTS.md`) into codex, or troubleshoot codex flags, models, auth, or its no-TTY headless behaviour.
4
4
  metadata:
5
- version: '3.0.0'
5
+ version: '3.1.0'
6
6
  ---
7
7
 
8
8
  # codex-cli-bridge
@@ -99,9 +99,13 @@ codex-review code "focus on the new reducer" # review with extra focus
99
99
  **Honesty + posture (D4/D5):** a run whose final message has no recognized
100
100
  `Verdict: <ship|revise|rethink>` line — empty or missing output included — **exits 4 with NO
101
101
  receipt**: treat it as a *failed review to re-run*, never a fatal session error. One stderr banner
102
- states the actual posture (`review posture: model=… effort=… tier=…`) and the receipt records the
103
- same `posture {model, effort, tier}` (tier `null` on the standard tier); control bytes in a
104
- posture value refuse pre-spend in every mode.
102
+ states the actual posture (`review posture: model=… effort=… tier=… timeout=…`) and the receipt
103
+ records the same `posture {model, effort, tier}` (tier `null` on the standard tier); control bytes
104
+ in a posture value refuse pre-spend in every mode. `codex-exec` states its posture the same way —
105
+ ONE `exec posture: model=… effort=… tier=… sandbox=workspace-write session=fresh|resume:<id>
106
+ timeout=…` stderr line before dispatch (the resume id validated pre-spend). The `timeout=` field
107
+ is **banner-only** (exactly the duration handed to `timeout(1)`, or `uncapped`) — informational,
108
+ never a receipt field. **Quote the posture banner verbatim** when labeling a dispatch.
105
109
 
106
110
  `codex exec` is headless: there is **no TTY**, so `approval_policy=never` — anything needing
107
111
  escalation is refused and reported, never interactively approved. The wrappers capture only codex's