@sabaiway/agent-workflow-kit 1.33.0 → 1.35.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 (45) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +2 -1
  3. package/SKILL.md +6 -2
  4. package/bin/install.mjs +37 -1
  5. package/bridges/antigravity-cli-bridge/SKILL.md +13 -1
  6. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +96 -1
  7. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +204 -1
  8. package/bridges/antigravity-cli-bridge/bin/agy.sh +94 -0
  9. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +245 -1
  10. package/bridges/antigravity-cli-bridge/capability.json +17 -1
  11. package/bridges/codex-cli-bridge/SKILL.md +15 -1
  12. package/bridges/codex-cli-bridge/bin/codex-exec.sh +113 -0
  13. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +288 -0
  14. package/bridges/codex-cli-bridge/bin/codex-review.sh +114 -1
  15. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +229 -1
  16. package/bridges/codex-cli-bridge/capability.json +29 -1
  17. package/capability.json +1 -1
  18. package/launchers/windsurf-workflow.md +3 -1
  19. package/package.json +1 -1
  20. package/references/contracts.md +3 -2
  21. package/references/modes/bootstrap.md +11 -6
  22. package/references/modes/bridge-settings.md +29 -0
  23. package/references/modes/gates.md +4 -2
  24. package/references/modes/review-state.md +1 -1
  25. package/references/modes/status.md +2 -0
  26. package/references/modes/upgrade.md +11 -5
  27. package/references/shared/deploy-tail.md +1 -1
  28. package/references/shared/report-footer.md +11 -4
  29. package/tools/atomic-write.mjs +144 -0
  30. package/tools/bridge-settings-read.mjs +221 -0
  31. package/tools/bridge-settings.mjs +288 -0
  32. package/tools/commands.mjs +22 -1
  33. package/tools/family-registry.mjs +45 -2
  34. package/tools/manifest/schema.md +31 -0
  35. package/tools/manifest/validate.mjs +85 -0
  36. package/tools/orchestration-write.mjs +8 -78
  37. package/tools/presentation.mjs +1 -0
  38. package/tools/procedures.mjs +26 -4
  39. package/tools/recipes.mjs +16 -6
  40. package/tools/renderers.mjs +17 -2
  41. package/tools/review-state.mjs +4 -2
  42. package/tools/seed-gates.mjs +413 -0
  43. package/tools/setup-backends.mjs +107 -9
  44. package/tools/velocity-profile.mjs +4 -0
  45. package/tools/view-model.mjs +20 -0
@@ -50,6 +50,10 @@ Usage:
50
50
  agy-run @path/to/prompt.md
51
51
  agy-run <prompt|-|@file> -- <extra agy flags...>
52
52
 
53
+ Settings file (KEY=VALUE, parsed never sourced; env wins over file, file wins over built-in default):
54
+ ${XDG_CONFIG_HOME:-~/.config}/agent-workflow/bridge-settings.conf
55
+ AGY_HARD_TIMEOUT — hard wall-clock cap, duration string like 5m/30m/90s (built-in default = AGY_TIMEOUT, 5m)
56
+
53
57
  Environment: AGY_MODEL (exact display string from `agy models`; empty ⇒ agy's settings.json), AGY_TIMEOUT / AGY_HARD_TIMEOUT (duration strings), AGY_MAX_PROMPT_BYTES (single-argv byte ceiling; the override only lowers it).
54
58
  Requires at run time: the agy CLI on PATH + a Google AI subscription login (--help needs neither).
55
59
  HELP
@@ -68,6 +72,96 @@ while IFS= read -r _api_key_var; do
68
72
  unset "$_api_key_var" 2>/dev/null || true
69
73
  done < <(compgen -v 2>/dev/null | grep '_API_KEY$' || true)
70
74
 
75
+ # This wrapper's applied settings-file subset (see the shared reader block below).
76
+ AW_SETTINGS_APPLIED="AGY_HARD_TIMEOUT"
77
+
78
+ # --- Bridge settings file (host-level, kit-independent) — byte-identical across the four wrappers ---
79
+ # ${XDG_CONFIG_HOME:-$HOME/.config}/agent-workflow/bridge-settings.conf holds KEY=VALUE lines,
80
+ # PARSED (grep/case), NEVER sourced — a file line can never execute code. Precedence: explicit
81
+ # env (even empty: KEY= disables the knob for one run) > file > built-in default. Each wrapper
82
+ # APPLIES only its own subset ($AW_SETTINGS_APPLIED, set above this block) but RECOGNIZES the
83
+ # whole registry: a key belonging to another wrapper or another bridge is skipped silently; only
84
+ # a key unknown to the entire registry warns (once per key), naming this file as the source.
85
+ # A malformed line warns and is ignored; a value failing the key's typed validation warns and
86
+ # falls back to the built-in default (never passed to the binary); duplicate key → the LAST
87
+ # occurrence wins; a missing file is silent; an existing-but-unreadable or non-regular file
88
+ # warns loudly and falls back to built-in defaults (a directory or FIFO is never opened).
89
+ # Diagnostics are emitted once per user-visible run: a delegating wrapper (agy-review →
90
+ # agy-run) exports AW_SETTINGS_NOTIFIED so the child never repeats the same file's warnings.
91
+ # The registry, per-wrapper subsets, and typed constants mirror
92
+ # the bridges' capability.json `settings` blocks (manifest-as-source, drift-guarded by tests).
93
+ aw_settings_file() {
94
+ printf '%s/agent-workflow/bridge-settings.conf' "${XDG_CONFIG_HOME:-$HOME/.config}"
95
+ }
96
+ aw_settings_known() {
97
+ case " CODEX_SERVICE_TIER CODEX_HARD_TIMEOUT CODEX_REVIEW_MAX_TOTAL_BYTES AGY_HARD_TIMEOUT AGY_REVIEW_ALLOW_ADDDIR " in
98
+ *" $1 "*) return 0 ;;
99
+ *) return 1 ;;
100
+ esac
101
+ }
102
+ aw_settings_valid() {
103
+ local k="$1" v="$2" int_re='^[0-9]+$' dur_re='^[0-9]+(\.[0-9]+)?[smhd]$' zero_re='^0+(\.0+)?[smhd]$'
104
+ case "$k" in
105
+ 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 )) ;;
108
+ AGY_HARD_TIMEOUT) [[ "$v" =~ $dur_re && ! "$v" =~ $zero_re ]] ;;
109
+ AGY_REVIEW_ALLOW_ADDDIR) [[ "$v" == "0" || "$v" == "1" ]] ;;
110
+ *) return 1 ;;
111
+ esac
112
+ }
113
+ aw_apply_settings() {
114
+ local file line key value warned notify
115
+ file="$(aw_settings_file)"
116
+ [[ -e "$file" ]] || return 0
117
+ notify=1
118
+ [[ -n "${AW_SETTINGS_NOTIFIED:-}" ]] && notify=0
119
+ export AW_SETTINGS_NOTIFIED=1
120
+ if [[ ! -f "$file" || ! -r "$file" ]]; then
121
+ if (( notify )); then
122
+ echo "warning: bridge settings file '$file' exists but is unreadable or not a regular file — using built-in defaults." >&2
123
+ fi
124
+ return 0
125
+ fi
126
+ if (( notify )); then
127
+ warned=" "
128
+ while IFS= read -r line || [[ -n "$line" ]]; do
129
+ [[ -z "${line//[[:space:]]/}" ]] && continue
130
+ case "${line#"${line%%[![:space:]]*}"}" in "#"*) continue ;; esac
131
+ if [[ ! "$line" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then
132
+ echo "warning: malformed line in bridge settings file '$file' (ignored): $line" >&2
133
+ continue
134
+ fi
135
+ key="${line%%=*}"
136
+ if ! aw_settings_known "$key"; then
137
+ case "$warned" in
138
+ *" $key "*) : ;;
139
+ *)
140
+ warned="$warned$key "
141
+ echo "warning: unknown key '$key' in bridge settings file '$file' (ignored)." >&2
142
+ ;;
143
+ esac
144
+ fi
145
+ done <"$file"
146
+ fi
147
+ for key in $AW_SETTINGS_APPLIED; do
148
+ if [[ -n "${!key+x}" ]]; then continue; fi
149
+ value="$(grep "^${key}=" "$file" 2>/dev/null || true)"
150
+ [[ -n "$value" ]] || continue
151
+ value="${value##*$'\n'}"
152
+ value="${value#*=}"
153
+ if ! aw_settings_valid "$key" "$value"; then
154
+ if (( notify )); then
155
+ echo "warning: invalid value '$value' for $key in bridge settings file '$file' — using the built-in default." >&2
156
+ fi
157
+ continue
158
+ fi
159
+ export "$key=$value"
160
+ done
161
+ return 0
162
+ }
163
+ aw_apply_settings
164
+
71
165
  if ! command -v agy >/dev/null 2>&1; then
72
166
  echo "error: 'agy' (Antigravity CLI) not found on PATH. Install it and run 'agy' once to sign in." >&2
73
167
  exit 127
@@ -1,6 +1,6 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync, existsSync, readdirSync, symlinkSync } from 'node:fs';
3
+ import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync, existsSync, readdirSync, symlinkSync, readFileSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join, dirname, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
@@ -208,3 +208,247 @@ describe('agy.sh — --help (pre-preflight, candidate C)', () => {
208
208
  assert.equal(invoked, true, 'the run must proceed to agy with the payload');
209
209
  });
210
210
  });
211
+
212
+ // ── bridge settings file (bridges 2.3.0) ─────────────────────────────────────────
213
+ // ${XDG_CONFIG_HOME:-$HOME/.config}/agent-workflow/bridge-settings.conf holds KEY=VALUE
214
+ // lines, PARSED (never sourced). Precedence: explicit env (even empty: KEY= disables the
215
+ // knob) > file > built-in default. agy-run APPLIES only AGY_HARD_TIMEOUT and RECOGNIZES
216
+ // the whole registry. HOME is the sandbox home, so the default path is hermetic per test.
217
+
218
+ const writeSettings = (home, text) => {
219
+ const dir = join(home, '.config', 'agent-workflow');
220
+ mkdirSync(dir, { recursive: true });
221
+ const file = join(dir, 'bridge-settings.conf');
222
+ writeFileSync(file, text);
223
+ return file;
224
+ };
225
+ const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
226
+
227
+ describe('agy.sh — bridge settings file (bridges 2.3.0)', () => {
228
+ it('a file-set AGY_HARD_TIMEOUT is effective (killed at the file cap)', () => {
229
+ const home = makeSandbox('#!/usr/bin/env bash\nsleep 5\n');
230
+ writeSettings(home, 'AGY_HARD_TIMEOUT=2s\n');
231
+ const r = runWrapper(home, { AGY_MODEL: '' });
232
+ rmSync(home, { recursive: true, force: true });
233
+ assert.notEqual(r.status, 0, 'the file cap must apply when the env is unset');
234
+ assert.match(r.stderr, /exceeded the hard cap AGY_HARD_TIMEOUT=2s/);
235
+ });
236
+
237
+ it('env overrides file: env=2s file=10m → killed at the env cap', () => {
238
+ const home = makeSandbox('#!/usr/bin/env bash\nsleep 5\n');
239
+ writeSettings(home, 'AGY_HARD_TIMEOUT=10m\n');
240
+ const r = runWrapper(home, { AGY_HARD_TIMEOUT: '2s', AGY_TIMEOUT: '2s', AGY_MODEL: '' });
241
+ rmSync(home, { recursive: true, force: true });
242
+ assert.notEqual(r.status, 0, 'the env cap (2s) must win over the file cap (10m)');
243
+ assert.match(r.stderr, /exceeded the hard cap AGY_HARD_TIMEOUT=2s/);
244
+ });
245
+
246
+ it('an EXPLICITLY EMPTY env (AGY_HARD_TIMEOUT=) disables the file knob for one run', () => {
247
+ const home = makeSandbox('#!/usr/bin/env bash\nsleep 3\necho "OK reply"\n');
248
+ writeSettings(home, 'AGY_HARD_TIMEOUT=2s\n');
249
+ const r = runWrapper(home, { AGY_HARD_TIMEOUT: '', AGY_MODEL: '' });
250
+ rmSync(home, { recursive: true, force: true });
251
+ assert.equal(r.status, 0, `built-in default must apply (not the 2s file cap): ${r.stderr}`);
252
+ assert.match(r.stdout, /OK reply/);
253
+ });
254
+
255
+ it('duplicate key → the LAST occurrence wins (10m then 2s → killed at 2s)', () => {
256
+ const home = makeSandbox('#!/usr/bin/env bash\nsleep 5\n');
257
+ writeSettings(home, 'AGY_HARD_TIMEOUT=10m\nAGY_HARD_TIMEOUT=2s\n');
258
+ const r = runWrapper(home, { AGY_MODEL: '' });
259
+ rmSync(home, { recursive: true, force: true });
260
+ assert.notEqual(r.status, 0);
261
+ assert.match(r.stderr, /exceeded the hard cap AGY_HARD_TIMEOUT=2s/);
262
+ });
263
+
264
+ it('an invalid duration warns and falls back to the built-in default', () => {
265
+ const home = makeSandbox(RECORDING_STUB);
266
+ writeSettings(home, 'AGY_HARD_TIMEOUT=abc\n');
267
+ const r = runWrapper(home, { AGY_MODEL: '' });
268
+ rmSync(home, { recursive: true, force: true });
269
+ assert.equal(r.status, 0, r.stderr);
270
+ assert.match(r.stderr, /invalid value 'abc'/);
271
+ assert.match(r.stdout, /OK reply/);
272
+ });
273
+
274
+ it('a bare-integer duration (no unit suffix) is invalid → warn + built-in default', () => {
275
+ const home = makeSandbox(RECORDING_STUB);
276
+ writeSettings(home, 'AGY_HARD_TIMEOUT=90\n');
277
+ const r = runWrapper(home, { AGY_MODEL: '' });
278
+ rmSync(home, { recursive: true, force: true });
279
+ assert.equal(r.status, 0, r.stderr);
280
+ assert.match(r.stderr, /invalid value '90'/, 'duration values require a unit suffix (5m/30m/90s)');
281
+ assert.match(r.stdout, /OK reply/);
282
+ });
283
+
284
+ it('a ZERO duration is invalid — timeout 0 would silently DISABLE the hard cap', () => {
285
+ const home = makeSandbox(RECORDING_STUB);
286
+ writeSettings(home, 'AGY_HARD_TIMEOUT=0s\n');
287
+ const r = runWrapper(home, { AGY_MODEL: '' });
288
+ rmSync(home, { recursive: true, force: true });
289
+ assert.equal(r.status, 0, r.stderr);
290
+ assert.match(r.stderr, /invalid value '0s'/, 'a persistent settings line must never remove the stall guard');
291
+ assert.match(r.stdout, /OK reply/);
292
+ });
293
+
294
+ it('a DIRECTORY at the settings path warns loudly and falls back to built-ins (no crash)', () => {
295
+ const home = makeSandbox(RECORDING_STUB);
296
+ mkdirSync(join(home, '.config', 'agent-workflow', 'bridge-settings.conf'), { recursive: true });
297
+ const r = runWrapper(home, { AGY_MODEL: '' });
298
+ rmSync(home, { recursive: true, force: true });
299
+ assert.equal(r.status, 0, `a directory must degrade honestly, not kill the run: ${r.stderr}`);
300
+ assert.match(r.stderr, /unreadable or not a regular file/);
301
+ assert.doesNotMatch(r.stderr, /Is a directory/, 'no raw bash error may leak');
302
+ assert.match(r.stdout, /OK reply/);
303
+ });
304
+
305
+ it("another bridge's valid key is skipped silently (and never applied)", () => {
306
+ const home = makeSandbox('#!/usr/bin/env bash\nsleep 3\necho "OK reply"\n');
307
+ writeSettings(home, 'CODEX_SERVICE_TIER=priority\nCODEX_HARD_TIMEOUT=2\n');
308
+ const r = runWrapper(home, { AGY_MODEL: '' });
309
+ rmSync(home, { recursive: true, force: true });
310
+ assert.equal(r.status, 0, `a codex key must not cap an agy run: ${r.stderr}`);
311
+ assert.doesNotMatch(r.stderr, /bridge settings/, 'a recognized non-applied key earns NO warning');
312
+ assert.match(r.stdout, /OK reply/);
313
+ });
314
+
315
+ it('a truly unknown key warns ONCE naming the file; the run is unaffected', () => {
316
+ const home = makeSandbox(RECORDING_STUB);
317
+ writeSettings(home, 'TOTALLY_UNKNOWN=1\nTOTALLY_UNKNOWN=2\n');
318
+ const r = runWrapper(home, { AGY_MODEL: '' });
319
+ rmSync(home, { recursive: true, force: true });
320
+ assert.equal(r.status, 0, r.stderr);
321
+ const warns = r.stderr.match(/unknown key 'TOTALLY_UNKNOWN'/g) ?? [];
322
+ assert.equal(warns.length, 1, `exactly one warning per unknown key, got ${warns.length}`);
323
+ assert.match(r.stderr, /bridge-settings\.conf/, 'the warning must name the settings file');
324
+ assert.match(r.stdout, /OK reply/);
325
+ });
326
+
327
+ it('malformed lines warn and are ignored; comments and blank lines are silent', () => {
328
+ const home = makeSandbox(RECORDING_STUB);
329
+ writeSettings(home, '# a comment\n\nNOT A KEY VALUE LINE\n');
330
+ const r = runWrapper(home, { AGY_MODEL: '' });
331
+ rmSync(home, { recursive: true, force: true });
332
+ assert.equal(r.status, 0, r.stderr);
333
+ const malformed = r.stderr.match(/malformed line/g) ?? [];
334
+ assert.equal(malformed.length, 1, 'comments/blank lines must NOT count as malformed');
335
+ assert.match(r.stdout, /OK reply/);
336
+ });
337
+
338
+ it('an existing-but-unreadable file warns loudly and falls back to built-ins', { skip: isRoot }, () => {
339
+ const home = makeSandbox(RECORDING_STUB);
340
+ const file = writeSettings(home, 'AGY_HARD_TIMEOUT=2s\n');
341
+ chmodSync(file, 0o000);
342
+ const r = runWrapper(home, { AGY_MODEL: '' });
343
+ rmSync(home, { recursive: true, force: true });
344
+ assert.equal(r.status, 0, r.stderr);
345
+ assert.match(r.stderr, /unreadable/);
346
+ assert.match(r.stdout, /OK reply/);
347
+ });
348
+
349
+ it('a settings line can NEVER execute code (command-substitution payload inert)', () => {
350
+ const home = makeSandbox(RECORDING_STUB);
351
+ const pwned = join(home, 'pwned');
352
+ writeSettings(home, `AGY_HARD_TIMEOUT=$(touch ${pwned})\nEVIL_KEY=\`touch ${pwned}2\`\n`);
353
+ const r = runWrapper(home, { AGY_MODEL: '' });
354
+ const executed = existsSync(pwned) || existsSync(`${pwned}2`);
355
+ rmSync(home, { recursive: true, force: true });
356
+ assert.equal(r.status, 0, r.stderr);
357
+ assert.equal(executed, false, 'file content must be parsed, never evaluated');
358
+ assert.match(r.stdout, /OK reply/);
359
+ });
360
+
361
+ it('no file → byte-identical behaviour to today (no settings chatter)', () => {
362
+ const home = makeSandbox(RECORDING_STUB);
363
+ const r = runWrapper(home, { AGY_MODEL: '' });
364
+ rmSync(home, { recursive: true, force: true });
365
+ assert.equal(r.status, 0, r.stderr);
366
+ assert.doesNotMatch(r.stderr, /bridge settings/);
367
+ assert.match(r.stdout, /OK reply/);
368
+ });
369
+ });
370
+
371
+ // ── settings surface ⟷ manifest (drift guard, D6) ────────────────────────────────
372
+ // agy-run's --help stays candidate-C (not contract-pinned), but its SETTINGS surface
373
+ // is manifest-pinned like the other three wrappers: the Settings help section and the
374
+ // shell constants (registry / applied subset / typed validation arms) stay set-equal
375
+ // to the bridges' capability.json `settings` blocks. The sibling manifest path
376
+ // resolves identically in the repo layout and in the kit's bridges/ mirror layout.
377
+ const SETTINGS_HEADER = 'Settings file (KEY=VALUE, parsed never sourced; env wins over file, file wins over built-in default):';
378
+ const MANIFEST = JSON.parse(readFileSync(join(HERE, '..', 'capability.json'), 'utf8'));
379
+ const SIBLING_MANIFEST = JSON.parse(readFileSync(join(HERE, '..', '..', 'codex-cli-bridge', 'capability.json'), 'utf8'));
380
+ const ALL_SETTINGS = [...(MANIFEST.settings ?? []), ...(SIBLING_MANIFEST.settings ?? [])];
381
+ const SETTINGS_CMD = 'agy-run';
382
+ const setEq = (got, want, msg) => assert.deepEqual([...got].sort(), [...want].sort(), msg);
383
+ const helpSection = (text, header) => {
384
+ const lines = text.split('\n');
385
+ const i = lines.findIndex((l) => l.trim() === header);
386
+ assert.notEqual(i, -1, `--help must carry a "${header}" section`);
387
+ const out = [];
388
+ for (let j = i + 1; j < lines.length; j += 1) {
389
+ if (lines[j].trim() === '') break;
390
+ out.push(lines[j].trim());
391
+ }
392
+ return out;
393
+ };
394
+
395
+ describe('agy.sh — settings surface ⟷ manifest (D6, manifest-pinned)', () => {
396
+ const runHelpText = () => {
397
+ const home = mkdtempSync(join(tmpdir(), 'agy-settings-help-'));
398
+ const r = spawnSync('bash', [WRAPPER, '--help'], {
399
+ env: { HOME: home, PATH: makePathWithout(home, ['agy', 'git']) },
400
+ encoding: 'utf8',
401
+ timeout: 15000,
402
+ });
403
+ rmSync(home, { recursive: true, force: true });
404
+ assert.equal(r.status, 0, r.stderr);
405
+ return r.stdout;
406
+ };
407
+
408
+ it('--help Settings section keys set-EQUAL the manifest appliesTo subset', () => {
409
+ const section = helpSection(runHelpText(), SETTINGS_HEADER);
410
+ const got = section.filter((l) => /^[A-Z][A-Z0-9_]+ —/.test(l)).map((l) => l.split(' ')[0]);
411
+ const want = (MANIFEST.settings ?? []).filter((s) => s.appliesTo.includes(SETTINGS_CMD)).map((s) => s.key);
412
+ assert.ok(want.length > 0, 'the manifest must declare settings for this wrapper');
413
+ setEq(got, want, 'help Settings keys ⟷ manifest settings.appliesTo');
414
+ assert.ok(section.some((l) => l.includes('agent-workflow/bridge-settings.conf')), 'the section names the settings file');
415
+ });
416
+
417
+ const source = readFileSync(WRAPPER, 'utf8');
418
+
419
+ it('aw_settings_known carries exactly the UNION of both bridges settings keys', () => {
420
+ const m = source.match(/aw_settings_known\(\) \{\n case " ([^"]+) " in/);
421
+ assert.ok(m, 'aw_settings_known registry case not found');
422
+ assert.ok(ALL_SETTINGS.length >= 5, 'both manifests must contribute settings');
423
+ setEq(m[1].trim().split(/\s+/), ALL_SETTINGS.map((s) => s.key), 'shell registry ⟷ manifest union');
424
+ });
425
+
426
+ it('AW_SETTINGS_APPLIED equals the manifest appliesTo subset for this wrapper', () => {
427
+ const m = source.match(/^AW_SETTINGS_APPLIED="([^"]*)"$/m);
428
+ assert.ok(m, 'AW_SETTINGS_APPLIED not found');
429
+ const want = ALL_SETTINGS.filter((s) => s.appliesTo.includes(SETTINGS_CMD)).map((s) => s.key);
430
+ assert.ok(want.length > 0);
431
+ setEq(m[1].trim().split(/\s+/), want, 'applied subset ⟷ manifest appliesTo');
432
+ });
433
+
434
+ it('aw_settings_valid arms carry the manifest typed constants per key', () => {
435
+ const body = source.match(/aw_settings_valid\(\) \{[\s\S]*?\n\}/);
436
+ assert.ok(body, 'aw_settings_valid not found');
437
+ const armKeys = [...body[0].matchAll(/^ ([A-Z][A-Z0-9_]*)\)/gm)].map((x) => x[1]);
438
+ setEq(armKeys, ALL_SETTINGS.map((s) => s.key), 'validation arms ⟷ manifest keys');
439
+ for (const s of ALL_SETTINGS) {
440
+ const arm = body[0].match(new RegExp(`^ ${s.key}\\) (.*) ;;$`, 'm'));
441
+ assert.ok(arm, `no validation arm for ${s.key}`);
442
+ if (s.kind === 'enum') for (const v of s.values) assert.ok(arm[1].includes(`"${v}"`), `${s.key}: enum value '${v}' not pinned`);
443
+ if (s.kind === 'integer') {
444
+ assert.match(arm[1], new RegExp(`>= ${s.min}\\b`), `${s.key}: min ${s.min} not pinned`);
445
+ assert.match(arm[1], new RegExp(`<= ${s.max}\\b`), `${s.key}: max ${s.max} not pinned`);
446
+ }
447
+ if (s.kind === 'boolean') assert.ok(arm[1].includes('"0"') && arm[1].includes('"1"'), `${s.key}: boolean 0/1 not pinned`);
448
+ if (s.kind === 'duration') {
449
+ assert.ok(arm[1].includes('$dur_re'), `${s.key}: duration grammar not pinned`);
450
+ assert.ok(arm[1].includes('$zero_re'), `${s.key}: zero-duration rejection not pinned (timeout 0 disables the cap)`);
451
+ }
452
+ }
453
+ });
454
+ });
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "antigravity-cli-bridge",
5
5
  "kind": "execution-backend",
6
- "version": "2.2.0",
6
+ "version": "2.3.0",
7
7
  "provides": ["review", "probe"],
8
8
  "roles": {
9
9
  "review": {
@@ -33,6 +33,22 @@
33
33
  },
34
34
  "probe": { "cmd": "agy-run", "source": "bin/agy.sh", "output": "advisory" }
35
35
  },
36
+ "settings": [
37
+ {
38
+ "key": "AGY_HARD_TIMEOUT",
39
+ "kind": "duration",
40
+ "default": null,
41
+ "appliesTo": ["agy-run", "agy-review"],
42
+ "effect": "hard wall-clock cap via timeout(1), duration string (e.g. 5m, 30m, 90s); built-in default: agy-run = AGY_TIMEOUT (5m), agy-review 30m."
43
+ },
44
+ {
45
+ "key": "AGY_REVIEW_ALLOW_ADDDIR",
46
+ "kind": "boolean",
47
+ "default": "0",
48
+ "appliesTo": ["agy-review"],
49
+ "effect": "1 ⇒ an oversized code review offloads the change set to a private --add-dir staging dir (re-enables the Issue-001 stall risk; the hard timeout bounds it). Default 0: an oversized prompt refuses instead."
50
+ }
51
+ ],
36
52
  "detect": {
37
53
  "installed": {
38
54
  "env": "ANTIGRAVITY_CLI_BRIDGE_DIR",
@@ -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: '2.2.0'
5
+ version: '2.3.0'
6
6
  ---
7
7
 
8
8
  # codex-cli-bridge
@@ -109,6 +109,7 @@ defeat a policy is guarded — see [§ Models](#models-quality-first-pinned).
109
109
  | `CODEX_MODEL` | `gpt-5.5` (pinned) | model; non-default REFUSED unless `CODEX_PROBE=1` |
110
110
  | `CODEX_EFFORT` | `xhigh` (pinned) | reasoning effort; non-default REFUSED unless `CODEX_PROBE=1` |
111
111
  | `CODEX_HARD_TIMEOUT` | `3600` (exec) / `1800` (review) | hard wall-clock cap (seconds) via `timeout`/`gtimeout`; exit 124/137 ⇒ "exceeded hard cap". No `timeout` binary ⇒ loud warning + uncapped (never silent). |
112
+ | `CODEX_SERVICE_TIER` | unset (standard tier) | **SPEND knob**: `priority` (catalog name "Fast") = ~1.5× token speed at a **2.5× credit rate** on gpt-5.5 — quality-neutral (same model). codex accepts any `-c service_tier` string silently (probe-pinned 2026-07-05), so the wrapper validates: an unsupported value warns and runs standard. Env or settings file. |
112
113
  | `CODEX_SESSION_FILE` | `./.codex-last-session` | where `codex-exec` records the session id and where `--resume-last` reads it |
113
114
  | `CODEX_REVIEW_MAX_TOTAL_BYTES` | `1500000` | `codex-review code`: above this the assembled diff goes via a git-dir temp file instead of inline — never truncated |
114
115
  | `CODEX_REVIEW_SCHEMA` | unset | `codex-review`: `=1` returns findings as a validated JSON object (`--output-schema`), with a raw-text fallback. Default off. |
@@ -117,6 +118,19 @@ defeat a policy is guarded — see [§ Models](#models-quality-first-pinned).
117
118
  The git-write shim, `--ignore-user-config`, and the `*_API_KEY` scrub are NOT env-tunable — they are
118
119
  fixed invariants.
119
120
 
121
+ ### Settings file (host-level, survives kit upgrades)
122
+
123
+ `${XDG_CONFIG_HOME:-~/.config}/agent-workflow/bridge-settings.conf` holds `KEY=VALUE` lines,
124
+ **parsed, never sourced** — a file line can never execute code. Precedence: explicit env (even
125
+ empty — `KEY=` disables a knob for one run) > file > built-in default. File-settable keys for this
126
+ bridge: `CODEX_SERVICE_TIER` (the Fast tier — **2.5× credit rate**; enabling it is a consented
127
+ per-host spend decision, never a default), `CODEX_HARD_TIMEOUT`, `CODEX_REVIEW_MAX_TOTAL_BYTES` —
128
+ exactly the manifest `settings` block (the single source; the wrapper constants and `--help` are
129
+ drift-guarded against it). Model/effort keys are **not** file-settable — the quality guard above
130
+ is untouched. The file lives **outside every kit-managed tree**, so a kit refresh/upgrade can
131
+ never wipe it; edit it by hand or via `/agent-workflow-kit bridge-settings` (preview-first,
132
+ consent-gated).
133
+
120
134
  ## Project context (how `codex` sees the repo)
121
135
 
122
136
  `codex` auto-**merges** `AGENTS.md` (root→cwd, plus a global `~/.codex/AGENTS.md`) straight into its
@@ -67,6 +67,11 @@ Guarded passthrough after '--':
67
67
  blocked always: -c* --config* -s* --sandbox* --dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust --full-auto --oss --local-provider* -p* --profile* -m* --model* -o* --output-last-message* --json* --color* --output-schema* --ephemeral*
68
68
  relaxed only under CODEX_PROBE=1: --add-dir* -C* --cd* --skip-git-repo-check --ignore-rules --enable* --disable*
69
69
 
70
+ Settings file (KEY=VALUE, parsed never sourced; env wins over file, file wins over built-in default):
71
+ ${XDG_CONFIG_HOME:-~/.config}/agent-workflow/bridge-settings.conf
72
+ CODEX_SERVICE_TIER — service tier: 'priority' (Fast — ~1.5x speed at a 2.5x credit rate on gpt-5.5); a consented SPEND knob, default off (standard tier)
73
+ CODEX_HARD_TIMEOUT — hard wall-clock cap, integer seconds 1..86400 (built-in default 3600)
74
+
70
75
  Environment: CODEX_HARD_TIMEOUT (seconds, default 3600), CODEX_PROBE=1 (throwaway probe only).
71
76
  Requires at run time: the codex CLI on PATH, a ChatGPT-subscription login, a git work tree with a root AGENTS.md (--help needs none of these).
72
77
  HELP
@@ -74,6 +79,96 @@ HELP
74
79
  ;;
75
80
  esac
76
81
 
82
+ # This wrapper's applied settings-file subset (see the shared reader block below).
83
+ AW_SETTINGS_APPLIED="CODEX_SERVICE_TIER CODEX_HARD_TIMEOUT"
84
+
85
+ # --- Bridge settings file (host-level, kit-independent) — byte-identical across the four wrappers ---
86
+ # ${XDG_CONFIG_HOME:-$HOME/.config}/agent-workflow/bridge-settings.conf holds KEY=VALUE lines,
87
+ # PARSED (grep/case), NEVER sourced — a file line can never execute code. Precedence: explicit
88
+ # env (even empty: KEY= disables the knob for one run) > file > built-in default. Each wrapper
89
+ # APPLIES only its own subset ($AW_SETTINGS_APPLIED, set above this block) but RECOGNIZES the
90
+ # whole registry: a key belonging to another wrapper or another bridge is skipped silently; only
91
+ # a key unknown to the entire registry warns (once per key), naming this file as the source.
92
+ # A malformed line warns and is ignored; a value failing the key's typed validation warns and
93
+ # falls back to the built-in default (never passed to the binary); duplicate key → the LAST
94
+ # occurrence wins; a missing file is silent; an existing-but-unreadable or non-regular file
95
+ # warns loudly and falls back to built-in defaults (a directory or FIFO is never opened).
96
+ # Diagnostics are emitted once per user-visible run: a delegating wrapper (agy-review →
97
+ # agy-run) exports AW_SETTINGS_NOTIFIED so the child never repeats the same file's warnings.
98
+ # The registry, per-wrapper subsets, and typed constants mirror
99
+ # the bridges' capability.json `settings` blocks (manifest-as-source, drift-guarded by tests).
100
+ aw_settings_file() {
101
+ printf '%s/agent-workflow/bridge-settings.conf' "${XDG_CONFIG_HOME:-$HOME/.config}"
102
+ }
103
+ aw_settings_known() {
104
+ case " CODEX_SERVICE_TIER CODEX_HARD_TIMEOUT CODEX_REVIEW_MAX_TOTAL_BYTES AGY_HARD_TIMEOUT AGY_REVIEW_ALLOW_ADDDIR " in
105
+ *" $1 "*) return 0 ;;
106
+ *) return 1 ;;
107
+ esac
108
+ }
109
+ aw_settings_valid() {
110
+ local k="$1" v="$2" int_re='^[0-9]+$' dur_re='^[0-9]+(\.[0-9]+)?[smhd]$' zero_re='^0+(\.0+)?[smhd]$'
111
+ case "$k" in
112
+ CODEX_SERVICE_TIER) [[ "$v" == "priority" ]] ;;
113
+ CODEX_HARD_TIMEOUT) [[ "$v" =~ $int_re ]] && (( 10#$v >= 1 && 10#$v <= 86400 )) ;;
114
+ CODEX_REVIEW_MAX_TOTAL_BYTES) [[ "$v" =~ $int_re ]] && (( 10#$v >= 1 && 10#$v <= 100000000 )) ;;
115
+ AGY_HARD_TIMEOUT) [[ "$v" =~ $dur_re && ! "$v" =~ $zero_re ]] ;;
116
+ AGY_REVIEW_ALLOW_ADDDIR) [[ "$v" == "0" || "$v" == "1" ]] ;;
117
+ *) return 1 ;;
118
+ esac
119
+ }
120
+ aw_apply_settings() {
121
+ local file line key value warned notify
122
+ file="$(aw_settings_file)"
123
+ [[ -e "$file" ]] || return 0
124
+ notify=1
125
+ [[ -n "${AW_SETTINGS_NOTIFIED:-}" ]] && notify=0
126
+ export AW_SETTINGS_NOTIFIED=1
127
+ if [[ ! -f "$file" || ! -r "$file" ]]; then
128
+ if (( notify )); then
129
+ echo "warning: bridge settings file '$file' exists but is unreadable or not a regular file — using built-in defaults." >&2
130
+ fi
131
+ return 0
132
+ fi
133
+ if (( notify )); then
134
+ warned=" "
135
+ while IFS= read -r line || [[ -n "$line" ]]; do
136
+ [[ -z "${line//[[:space:]]/}" ]] && continue
137
+ case "${line#"${line%%[![:space:]]*}"}" in "#"*) continue ;; esac
138
+ if [[ ! "$line" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then
139
+ echo "warning: malformed line in bridge settings file '$file' (ignored): $line" >&2
140
+ continue
141
+ fi
142
+ key="${line%%=*}"
143
+ if ! aw_settings_known "$key"; then
144
+ case "$warned" in
145
+ *" $key "*) : ;;
146
+ *)
147
+ warned="$warned$key "
148
+ echo "warning: unknown key '$key' in bridge settings file '$file' (ignored)." >&2
149
+ ;;
150
+ esac
151
+ fi
152
+ done <"$file"
153
+ fi
154
+ for key in $AW_SETTINGS_APPLIED; do
155
+ if [[ -n "${!key+x}" ]]; then continue; fi
156
+ value="$(grep "^${key}=" "$file" 2>/dev/null || true)"
157
+ [[ -n "$value" ]] || continue
158
+ value="${value##*$'\n'}"
159
+ value="${value#*=}"
160
+ if ! aw_settings_valid "$key" "$value"; then
161
+ if (( notify )); then
162
+ echo "warning: invalid value '$value' for $key in bridge settings file '$file' — using the built-in default." >&2
163
+ fi
164
+ continue
165
+ fi
166
+ export "$key=$value"
167
+ done
168
+ return 0
169
+ }
170
+ aw_apply_settings
171
+
77
172
  DEFAULT_CODEX_MODEL="gpt-5.5" # frontier coding model (verified locally) — pinned
78
173
  DEFAULT_CODEX_EFFORT="xhigh" # maximum reasoning effort — pinned
79
174
  CODEX_MODEL="${CODEX_MODEL:-$DEFAULT_CODEX_MODEL}"
@@ -82,6 +177,22 @@ CODEX_EFFORT="${CODEX_EFFORT:-$DEFAULT_CODEX_EFFORT}"
82
177
  # varies — a trivial reply was observed taking minutes). Raise for a known-healthy
83
178
  # long run; lowering it only risks killing real work.
84
179
  CODEX_HARD_TIMEOUT="${CODEX_HARD_TIMEOUT:-3600}"
180
+ # Codex service tier (quality-neutral speed knob; live-probed 2026-07-05): default EMPTY ⇒ no
181
+ # service_tier flag (standard tier) — enabling Fast is a consented per-host SPEND act, never a
182
+ # silent default. The only server-catalog tier id on this subscription is 'priority' (catalog
183
+ # display name "Fast": ~1.5x token speed at a 2.5x credit rate on gpt-5.5; quality-neutral —
184
+ # same model). codex itself accepts ANY -c service_tier string silently (probe-verified), so
185
+ # the wrapper validates the effective value: an unsupported one warns and runs on the standard
186
+ # tier — a typo can never silently masquerade as Fast.
187
+ CODEX_SERVICE_TIER="${CODEX_SERVICE_TIER:-}"
188
+ if [[ -n "$CODEX_SERVICE_TIER" ]] && ! aw_settings_valid CODEX_SERVICE_TIER "$CODEX_SERVICE_TIER"; then
189
+ echo "warning: CODEX_SERVICE_TIER='$CODEX_SERVICE_TIER' is not a supported service tier ('priority') — running on the standard tier." >&2
190
+ CODEX_SERVICE_TIER=""
191
+ fi
192
+ tier_flags=()
193
+ if [[ -n "$CODEX_SERVICE_TIER" ]]; then
194
+ tier_flags=(-c "service_tier=$CODEX_SERVICE_TIER")
195
+ fi
85
196
  CHATGPT_LOGIN_GUARD="Logged in using ChatGPT"
86
197
 
87
198
  # --- Quality-first guard: refuse a silent model/effort downgrade ---------------
@@ -371,6 +482,7 @@ if [[ -n "$resume_mode" ]]; then
371
482
  -c sandbox_workspace_write.network_access=false
372
483
  -c hide_agent_reasoning=true
373
484
  -c model_reasoning_summary=none
485
+ "${tier_flags[@]+"${tier_flags[@]}"}"
374
486
  -)
375
487
  full_prompt="$RESUME_REMINDER"$'\n\n'"$task"
376
488
  else
@@ -386,6 +498,7 @@ else
386
498
  -c model_reasoning_effort="$CODEX_EFFORT"
387
499
  -c hide_agent_reasoning=true
388
500
  -c model_reasoning_summary=none
501
+ "${tier_flags[@]+"${tier_flags[@]}"}"
389
502
  --color never
390
503
  -o "$out"
391
504
  --json