@sabaiway/agent-workflow-kit 3.0.0 → 3.2.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 (53) hide show
  1. package/CHANGELOG.md +82 -1
  2. package/README.md +1 -0
  3. package/SKILL.md +7 -3
  4. package/bin/install.mjs +10 -1
  5. package/bridges/antigravity-cli-bridge/SKILL.md +6 -4
  6. package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +20 -7
  7. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +84 -7
  8. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +189 -18
  9. package/bridges/antigravity-cli-bridge/bin/agy.sh +55 -6
  10. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +77 -41
  11. package/bridges/antigravity-cli-bridge/capability.json +4 -2
  12. package/bridges/antigravity-cli-bridge/references/driving-agy.md +5 -0
  13. package/bridges/codex-cli-bridge/SKILL.md +8 -4
  14. package/bridges/codex-cli-bridge/bin/codex-exec.sh +129 -11
  15. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +278 -23
  16. package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +20 -7
  17. package/bridges/codex-cli-bridge/bin/codex-review.sh +76 -13
  18. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +112 -17
  19. package/bridges/codex-cli-bridge/capability.json +19 -4
  20. package/bridges/codex-cli-bridge/references/driving-codex.md +11 -0
  21. package/capability.json +1 -1
  22. package/package.json +1 -1
  23. package/references/modes/bootstrap.md +3 -2
  24. package/references/modes/recommendations.md +1 -0
  25. package/references/modes/upgrade.md +9 -4
  26. package/references/modes/velocity.md +2 -0
  27. package/references/modes/worktrees.md +120 -0
  28. package/references/scripts/archive-decisions.mjs +2 -2
  29. package/references/scripts/archive-decisions.test.mjs +5 -5
  30. package/references/scripts/check-docs-size-cli.test.mjs +41 -0
  31. package/references/scripts/check-docs-size.mjs +46 -29
  32. package/references/shared/command-shapes.md +22 -0
  33. package/references/shared/composition-handoff.md +7 -2
  34. package/references/templates/agent_rules.md +10 -1
  35. package/tools/autonomy-doctor.mjs +1 -1
  36. package/tools/bridge-settings-read.mjs +25 -5
  37. package/tools/changed-surface.mjs +8 -8
  38. package/tools/commands.mjs +9 -0
  39. package/tools/delegation.mjs +2 -2
  40. package/tools/detect-backends.mjs +10 -0
  41. package/tools/grounding.mjs +13 -13
  42. package/tools/inject-methodology.mjs +71 -40
  43. package/tools/lens-region.mjs +113 -36
  44. package/tools/migrate-adr-store.mjs +3 -3
  45. package/tools/procedures.mjs +1 -1
  46. package/tools/recipes.mjs +2 -2
  47. package/tools/recommendations.mjs +34 -7
  48. package/tools/release-scan.mjs +12 -2
  49. package/tools/review-state.mjs +3 -3
  50. package/tools/sandbox-masks.mjs +13 -13
  51. package/tools/set-recipe.mjs +1 -1
  52. package/tools/velocity-profile.mjs +7 -7
  53. package/tools/worktrees.mjs +2292 -0
@@ -1,13 +1,13 @@
1
- import { describe, it } from 'node:test';
1
+ import { describe, it, after } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import {
4
4
  mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync, readFileSync,
5
- existsSync, readdirSync, symlinkSync,
5
+ existsSync, readdirSync, symlinkSync, cpSync,
6
6
  } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join, dirname, resolve } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
- import { spawnSync } from 'node:child_process';
10
+ import { spawnSync, execFile } from 'node:child_process';
11
11
 
12
12
  const HERE = dirname(fileURLToPath(import.meta.url));
13
13
  const WRAPPER = join(HERE, 'agy-review.sh');
@@ -65,15 +65,23 @@ const makePathWithout = (root, exclude = []) => {
65
65
  return dir;
66
66
  };
67
67
 
68
- // `clean: true` leaves a pristine committed tree (for the no-diff preflight); the default leaves one
69
- // untracked file so `code` mode has a diff to review.
70
- const makeSandbox = ({ clean = false } = {}) => {
71
- const home = mkdtempSync(join(tmpdir(), 'agy-review-test-'));
68
+ // The PATH farms and the sandbox base are READ-ONLY per invocation, so both are built ONCE and
69
+ // shared: a per-run farm rebuild (thousands of symlinks) plus a per-test `git init`+commit were
70
+ // the suite's dominant wall cost, not the wrapper under test.
71
+ const SHARED_ROOT = mkdtempSync(join(tmpdir(), 'agy-review-shared-'));
72
+ after(() => rmSync(SHARED_ROOT, { recursive: true, force: true }));
73
+ const farms = new Map();
74
+ const farmFor = (exclude) => {
75
+ const key = exclude.join('|');
76
+ if (!farms.has(key)) farms.set(key, makePathWithout(SHARED_ROOT, exclude));
77
+ return farms.get(key);
78
+ };
79
+
80
+ const TEMPLATE_HOME = (() => {
81
+ const home = join(SHARED_ROOT, 'template-home');
72
82
  const bin = join(home, '.local', 'bin');
73
83
  mkdirSync(bin, { recursive: true });
74
- const stub = join(bin, 'agy');
75
- writeFileSync(stub, FAKE_AGY, { mode: 0o755 });
76
- chmodSync(stub, 0o755);
84
+ writeFileSync(join(bin, 'agy'), FAKE_AGY, { mode: 0o755 });
77
85
  const repo = join(home, 'repo');
78
86
  mkdirSync(repo);
79
87
  const g = (...args) => spawnSync('git', args, { cwd: repo, encoding: 'utf8' });
@@ -83,13 +91,25 @@ const makeSandbox = ({ clean = false } = {}) => {
83
91
  writeFileSync(join(repo, 'base.txt'), 'committed base\n');
84
92
  g('add', '-A');
85
93
  g('commit', '-qm', 'base');
94
+ return home;
95
+ })();
96
+
97
+ // `clean: true` leaves a pristine committed tree (for the no-diff preflight); the default leaves one
98
+ // untracked file so `code` mode has a diff to review.
99
+ const makeSandbox = ({ clean = false } = {}) => {
100
+ const home = mkdtempSync(join(tmpdir(), 'agy-review-test-'));
101
+ cpSync(TEMPLATE_HOME, home, { recursive: true });
102
+ const bin = join(home, '.local', 'bin');
103
+ chmodSync(join(bin, 'agy'), 0o755);
104
+ const repo = join(home, 'repo');
105
+ const g = (...args) => spawnSync('git', args, { cwd: repo, encoding: 'utf8' });
86
106
  if (!clean) writeFileSync(join(repo, 'pending.txt'), 'PENDING_UNTRACKED_BODY\n');
87
107
  return { home, bin, repo, g };
88
108
  };
89
109
 
90
110
  const run = (sb, { args, env = {}, cwd } = {}) => {
91
111
  const { home, bin, repo } = sb;
92
- const farm = makePathWithout(home, ['agy', 'agy-run']);
112
+ const farm = farmFor(['agy', 'agy-run']);
93
113
  const cap = {
94
114
  argv: join(home, 'cap-argv'), env: join(home, 'cap-env'), prompt: join(home, 'cap-prompt'),
95
115
  sentinel: join(home, 'cap-sentinel'), adddir: join(home, 'cap-adddir'),
@@ -122,6 +142,44 @@ const run = (sb, { args, env = {}, cwd } = {}) => {
122
142
  };
123
143
  };
124
144
 
145
+ // Async twin of run() for the two sleep-bound timeout tests: spawnSync blocks the event loop
146
+ // for the whole deliberate wait, so a concurrent describe could not overlap them. Same spawn
147
+ // contract and captures.
148
+ const runAsync = (sb, { args, env = {}, cwd } = {}) =>
149
+ new Promise((done) => {
150
+ const { home, bin, repo } = sb;
151
+ const cap = {
152
+ argv: join(home, 'cap-argv'), env: join(home, 'cap-env'), prompt: join(home, 'cap-prompt'),
153
+ sentinel: join(home, 'cap-sentinel'), adddir: join(home, 'cap-adddir'),
154
+ adddirMode: join(home, 'cap-adddir-mode'), artifactMode: join(home, 'cap-artifact-mode'),
155
+ artifactCopy: join(home, 'cap-artifact-copy'),
156
+ };
157
+ const child = execFile('bash', [WRAPPER, ...args], {
158
+ cwd: cwd || repo,
159
+ encoding: 'utf8',
160
+ timeout: 30000,
161
+ env: {
162
+ HOME: home,
163
+ PATH: `${bin}:${farmFor(['agy', 'agy-run'])}`,
164
+ TMPDIR: process.env.TMPDIR ?? '/tmp',
165
+ AGY_FAKE_ARGV: cap.argv, AGY_FAKE_ENV: cap.env, AGY_FAKE_PROMPT: cap.prompt,
166
+ AGY_FAKE_SENTINEL: cap.sentinel, AGY_FAKE_ADDDIR: cap.adddir, AGY_FAKE_ADDDIR_MODE: cap.adddirMode,
167
+ AGY_FAKE_ARTIFACT_MODE: cap.artifactMode, AGY_FAKE_ARTIFACT_COPY: cap.artifactCopy,
168
+ ...env,
169
+ },
170
+ }, (error, stdout, stderr) => {
171
+ const readIf = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : '');
172
+ done({
173
+ status: error ? (error.code ?? 1) : 0, stdout, stderr,
174
+ invoked: existsSync(cap.sentinel),
175
+ argv: readIf(cap.argv), capEnv: readIf(cap.env), prompt: readIf(cap.prompt),
176
+ adddir: readIf(cap.adddir).trim(), adddirMode: readIf(cap.adddirMode).trim(),
177
+ artifactMode: readIf(cap.artifactMode).trim(), artifactCopy: readIf(cap.artifactCopy),
178
+ });
179
+ });
180
+ child.stdin.end();
181
+ });
182
+
125
183
  describe('agy-review.sh — model policy advisory (1)', () => {
126
184
  it('warns for a non-frontier model but still runs', () => {
127
185
  const sb = makeSandbox();
@@ -424,11 +482,11 @@ describe('agy-review.sh — resume / round-2 delta (7)', () => {
424
482
  });
425
483
  });
426
484
 
427
- describe('agy-review.sh — delegated guards inherited via agy-run (9, 10)', () => {
428
- it('hard timeout: a sleeping stub is killed at AGY_HARD_TIMEOUT', () => {
485
+ describe('agy-review.sh — delegated guards inherited via agy-run (9, 10)', { concurrency: true }, () => {
486
+ it('hard timeout: a sleeping stub is killed at AGY_HARD_TIMEOUT', async () => {
429
487
  const sb = makeSandbox();
430
488
  const started = Date.now();
431
- const r = run(sb, { args: ['code', '--facts', 'f'], env: { AGY_FAKE_SLEEP: '30', AGY_HARD_TIMEOUT: '2s', AGY_TIMEOUT: '2s' } });
489
+ const r = await runAsync(sb, { args: ['code', '--facts', 'f'], env: { AGY_FAKE_SLEEP: '30', AGY_HARD_TIMEOUT: '2s', AGY_TIMEOUT: '2s' } });
432
490
  const elapsed = Date.now() - started;
433
491
  rmSync(sb.home, { recursive: true, force: true });
434
492
  assert.ok(elapsed < 20000, `must return well under the kill-after window, took ${elapsed}ms`);
@@ -552,7 +610,7 @@ const runHelp = (arg) => {
552
610
  const root = mkdtempSync(join(tmpdir(), 'agy-review-help-'));
553
611
  const nongit = join(root, 'nongit');
554
612
  mkdirSync(nongit, { recursive: true });
555
- const path = makePathWithout(root, ['codex', 'agy', 'git']);
613
+ const path = farmFor(['codex', 'agy', 'git']);
556
614
  const r = spawnSync('bash', [WRAPPER, arg], {
557
615
  cwd: nongit, encoding: 'utf8', timeout: 15000, env: { HOME: root, PATH: path },
558
616
  });
@@ -1078,7 +1136,7 @@ const writeSettings = (sb, text) => {
1078
1136
  };
1079
1137
  const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
1080
1138
 
1081
- describe('agy-review.sh — bridge settings file (bridges 2.3.0)', () => {
1139
+ describe('agy-review.sh — bridge settings file (bridges 2.3.0)', { concurrency: true }, () => {
1082
1140
  it('a file-set AGY_REVIEW_ALLOW_ADDDIR=1 arms the oversized --add-dir escape', () => {
1083
1141
  const sb = makeSandbox();
1084
1142
  writeFileSync(join(sb.repo, 'unique.txt'), `OVERSIZE_UNIQUE_MARKER\n${'x'.repeat(8000)}\n`);
@@ -1123,10 +1181,10 @@ describe('agy-review.sh — bridge settings file (bridges 2.3.0)', () => {
1123
1181
  assert.equal(r.invoked, false);
1124
1182
  });
1125
1183
 
1126
- it('a file-set AGY_HARD_TIMEOUT flows through the agy-run delegation (killed at the file cap)', () => {
1184
+ it('a file-set AGY_HARD_TIMEOUT flows through the agy-run delegation (killed at the file cap)', async () => {
1127
1185
  const sb = makeSandbox();
1128
1186
  writeSettings(sb, 'AGY_HARD_TIMEOUT=2s\n');
1129
- const r = run(sb, { args: ['code', '--facts', 'f'], env: { AGY_FAKE_SLEEP: '5' } });
1187
+ const r = await runAsync(sb, { args: ['code', '--facts', 'f'], env: { AGY_FAKE_SLEEP: '5' } });
1130
1188
  rmSync(sb.home, { recursive: true, force: true });
1131
1189
  assert.notEqual(r.status, 0, 'the file cap must apply end-to-end (reader → agy-run → timeout)');
1132
1190
  assert.match(r.stderr, /exceeded the hard cap AGY_HARD_TIMEOUT=2s/);
@@ -1354,4 +1412,117 @@ describe('agy-review.sh — dispatch-posture labeling (D5)', () => {
1354
1412
  assert.equal(receipts.length, 0);
1355
1413
  assert.match(r.stderr, /control/i);
1356
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
+ });
1357
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