@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,10 +1,10 @@
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 { 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';
7
- import { spawnSync } from 'node:child_process';
7
+ import { spawnSync, execFile } from 'node:child_process';
8
8
 
9
9
  const HERE = dirname(fileURLToPath(import.meta.url));
10
10
  const WRAPPER = join(HERE, 'agy.sh');
@@ -31,11 +31,23 @@ const runWrapper = (home, env, prompt = 'hello') =>
31
31
  timeout: 20000,
32
32
  });
33
33
 
34
- describe('agy.sh hard wall-clock cap (timeout(1))', () => {
35
- it('kills a hung agy at AGY_HARD_TIMEOUT and reports it (non-zero + actionable guidance)', () => {
34
+ // Async twin of runWrapper for the sleep-bound suites below: spawnSync BLOCKS the event loop
35
+ // and would serialize the deliberate timeout waits; the async form lets a concurrent describe
36
+ // overlap them (the waiting children are idle, not CPU-bound). Same spawn contract.
37
+ const runWrapperAsync = (home, env, prompt = 'hello') =>
38
+ new Promise((done) => {
39
+ execFile('bash', [WRAPPER, prompt], {
40
+ env: { HOME: home, PATH: `${join(home, '.local', 'bin')}:${process.env.PATH}`, TMPDIR: process.env.TMPDIR ?? '/tmp', ...env },
41
+ encoding: 'utf8',
42
+ timeout: 20000,
43
+ }, (error, stdout, stderr) => done({ status: error ? (error.code ?? 1) : 0, stdout, stderr }));
44
+ });
45
+
46
+ describe('agy.sh — hard wall-clock cap (timeout(1))', { concurrency: true }, () => {
47
+ it('kills a hung agy at AGY_HARD_TIMEOUT and reports it (non-zero + actionable guidance)', async () => {
36
48
  const home = makeSandbox('#!/usr/bin/env bash\nsleep 30\n');
37
49
  const started = Date.now();
38
- const r = runWrapper(home, { AGY_HARD_TIMEOUT: '2s', AGY_TIMEOUT: '2s', AGY_MODEL: '' });
50
+ const r = await runWrapperAsync(home, { AGY_HARD_TIMEOUT: '2s', AGY_TIMEOUT: '2s', AGY_MODEL: '' });
39
51
  const elapsed = Date.now() - started;
40
52
  rmSync(home, { recursive: true, force: true });
41
53
  assert.ok(elapsed < 13000, `wrapper must return well under the kill-after window, took ${elapsed}ms`);
@@ -43,17 +55,17 @@ describe('agy.sh — hard wall-clock cap (timeout(1))', () => {
43
55
  assert.match(r.stderr, /exceeded the hard cap/, 'must explain the hard-cap kill');
44
56
  });
45
57
 
46
- it('passes a fast agy run through unchanged (exit 0, stdout preserved)', () => {
58
+ it('passes a fast agy run through unchanged (exit 0, stdout preserved)', async () => {
47
59
  const home = makeSandbox('#!/usr/bin/env bash\necho "OK reply"\nexit 0\n');
48
- const r = runWrapper(home, { AGY_HARD_TIMEOUT: '10s', AGY_TIMEOUT: '10s', AGY_MODEL: '' });
60
+ const r = await runWrapperAsync(home, { AGY_HARD_TIMEOUT: '10s', AGY_TIMEOUT: '10s', AGY_MODEL: '' });
49
61
  rmSync(home, { recursive: true, force: true });
50
62
  assert.equal(r.status, 0, `expected clean exit, got ${r.status}; stderr=${r.stderr}`);
51
63
  assert.match(r.stdout, /OK reply/);
52
64
  });
53
65
 
54
- it('propagates a non-timeout agy failure code verbatim (no false hard-cap message)', () => {
66
+ it('propagates a non-timeout agy failure code verbatim (no false hard-cap message)', async () => {
55
67
  const home = makeSandbox('#!/usr/bin/env bash\necho "boom" >&2\nexit 3\n');
56
- const r = runWrapper(home, { AGY_HARD_TIMEOUT: '10s', AGY_TIMEOUT: '10s', AGY_MODEL: '' });
68
+ const r = await runWrapperAsync(home, { AGY_HARD_TIMEOUT: '10s', AGY_TIMEOUT: '10s', AGY_MODEL: '' });
57
69
  rmSync(home, { recursive: true, force: true });
58
70
  assert.equal(r.status, 3, 'a genuine agy failure code must pass through');
59
71
  assert.doesNotMatch(r.stderr, /exceeded the hard cap/, 'must not mislabel a non-timeout failure');
@@ -178,6 +190,12 @@ const makePathWithout = (root, exclude = []) => {
178
190
  return dir;
179
191
  };
180
192
 
193
+ // The farm is READ-ONLY per invocation — built ONCE and shared (a per-call rebuild is thousands
194
+ // of symlinks).
195
+ const FARM_ROOT = mkdtempSync(join(tmpdir(), 'agy-farm-'));
196
+ after(() => rmSync(FARM_ROOT, { recursive: true, force: true }));
197
+ const FARM = makePathWithout(FARM_ROOT, ['agy', 'git']);
198
+
181
199
  describe('agy.sh — --help (pre-preflight, candidate C)', () => {
182
200
  it('--help and -h exit 0 with NO agy on PATH and name the documented usage', () => {
183
201
  for (const arg of ['--help', '-h']) {
@@ -185,7 +203,7 @@ describe('agy.sh — --help (pre-preflight, candidate C)', () => {
185
203
  // the help must not need the CLI even when the host has a real agy installed.
186
204
  const home = mkdtempSync(join(tmpdir(), 'agy-help-'));
187
205
  const r = spawnSync('bash', [WRAPPER, arg], {
188
- env: { HOME: home, PATH: makePathWithout(home, ['agy', 'git']) },
206
+ env: { HOME: home, PATH: FARM },
189
207
  encoding: 'utf8',
190
208
  timeout: 15000,
191
209
  });
@@ -226,77 +244,95 @@ const writeSettings = (home, text) => {
226
244
  };
227
245
  const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
228
246
 
229
- describe('agy.sh — bridge settings file (bridges 2.3.0)', () => {
230
- it('a file-set AGY_HARD_TIMEOUT is effective (killed at the file cap)', () => {
247
+ describe('agy.sh — bridge settings file (bridges 2.3.0)', { concurrency: true }, () => {
248
+ it('a file-set AGY_HARD_TIMEOUT is effective (killed at the file cap)', async () => {
231
249
  const home = makeSandbox('#!/usr/bin/env bash\nsleep 5\n');
232
250
  writeSettings(home, 'AGY_HARD_TIMEOUT=2s\n');
233
- const r = runWrapper(home, { AGY_MODEL: '' });
251
+ const r = await runWrapperAsync(home, { AGY_MODEL: '' });
234
252
  rmSync(home, { recursive: true, force: true });
235
253
  assert.notEqual(r.status, 0, 'the file cap must apply when the env is unset');
236
254
  assert.match(r.stderr, /exceeded the hard cap AGY_HARD_TIMEOUT=2s/);
237
255
  });
238
256
 
239
- it('env overrides file: env=2s file=10m → killed at the env cap', () => {
257
+ it('env overrides file: env=2s file=10m → killed at the env cap', async () => {
240
258
  const home = makeSandbox('#!/usr/bin/env bash\nsleep 5\n');
241
259
  writeSettings(home, 'AGY_HARD_TIMEOUT=10m\n');
242
- const r = runWrapper(home, { AGY_HARD_TIMEOUT: '2s', AGY_TIMEOUT: '2s', AGY_MODEL: '' });
260
+ const r = await runWrapperAsync(home, { AGY_HARD_TIMEOUT: '2s', AGY_TIMEOUT: '2s', AGY_MODEL: '' });
243
261
  rmSync(home, { recursive: true, force: true });
244
262
  assert.notEqual(r.status, 0, 'the env cap (2s) must win over the file cap (10m)');
245
263
  assert.match(r.stderr, /exceeded the hard cap AGY_HARD_TIMEOUT=2s/);
246
264
  });
247
265
 
248
- it('an EXPLICITLY EMPTY env (AGY_HARD_TIMEOUT=) disables the file knob for one run', () => {
266
+ it('an EXPLICITLY EMPTY env (AGY_HARD_TIMEOUT=) disables the file knob for one run', async () => {
249
267
  const home = makeSandbox('#!/usr/bin/env bash\nsleep 3\necho "OK reply"\n');
250
268
  writeSettings(home, 'AGY_HARD_TIMEOUT=2s\n');
251
- const r = runWrapper(home, { AGY_HARD_TIMEOUT: '', AGY_MODEL: '' });
269
+ const r = await runWrapperAsync(home, { AGY_HARD_TIMEOUT: '', AGY_MODEL: '' });
252
270
  rmSync(home, { recursive: true, force: true });
253
271
  assert.equal(r.status, 0, `built-in default must apply (not the 2s file cap): ${r.stderr}`);
254
272
  assert.match(r.stdout, /OK reply/);
255
273
  });
256
274
 
257
- it('duplicate key → the LAST occurrence wins (10m then 2s → killed at 2s)', () => {
275
+ it('duplicate key → the LAST occurrence wins (10m then 2s → killed at 2s)', async () => {
258
276
  const home = makeSandbox('#!/usr/bin/env bash\nsleep 5\n');
259
277
  writeSettings(home, 'AGY_HARD_TIMEOUT=10m\nAGY_HARD_TIMEOUT=2s\n');
260
- const r = runWrapper(home, { AGY_MODEL: '' });
278
+ const r = await runWrapperAsync(home, { AGY_MODEL: '' });
261
279
  rmSync(home, { recursive: true, force: true });
262
280
  assert.notEqual(r.status, 0);
263
281
  assert.match(r.stderr, /exceeded the hard cap AGY_HARD_TIMEOUT=2s/);
264
282
  });
265
283
 
266
- it('an invalid duration warns and falls back to the built-in default', () => {
284
+ it('an invalid duration warns and falls back to the built-in default', async () => {
267
285
  const home = makeSandbox(RECORDING_STUB);
268
286
  writeSettings(home, 'AGY_HARD_TIMEOUT=abc\n');
269
- const r = runWrapper(home, { AGY_MODEL: '' });
287
+ const r = await runWrapperAsync(home, { AGY_MODEL: '' });
270
288
  rmSync(home, { recursive: true, force: true });
271
289
  assert.equal(r.status, 0, r.stderr);
272
290
  assert.match(r.stderr, /invalid value 'abc'/);
273
291
  assert.match(r.stdout, /OK reply/);
274
292
  });
275
293
 
276
- it('a bare-integer duration (no unit suffix) is invalid → warn + built-in default', () => {
294
+ it('a bare-integer duration (no unit suffix) is invalid → warn + built-in default', async () => {
277
295
  const home = makeSandbox(RECORDING_STUB);
278
296
  writeSettings(home, 'AGY_HARD_TIMEOUT=90\n');
279
- const r = runWrapper(home, { AGY_MODEL: '' });
297
+ const r = await runWrapperAsync(home, { AGY_MODEL: '' });
280
298
  rmSync(home, { recursive: true, force: true });
281
299
  assert.equal(r.status, 0, r.stderr);
282
300
  assert.match(r.stderr, /invalid value '90'/, 'duration values require a unit suffix (5m/30m/90s)');
283
301
  assert.match(r.stdout, /OK reply/);
284
302
  });
285
303
 
286
- it('a ZERO duration is invalid — timeout 0 would silently DISABLE the hard cap', () => {
304
+ it('a ZERO duration is invalid — timeout 0 would silently DISABLE the hard cap', async () => {
287
305
  const home = makeSandbox(RECORDING_STUB);
288
306
  writeSettings(home, 'AGY_HARD_TIMEOUT=0s\n');
289
- const r = runWrapper(home, { AGY_MODEL: '' });
307
+ const r = await runWrapperAsync(home, { AGY_MODEL: '' });
290
308
  rmSync(home, { recursive: true, force: true });
291
309
  assert.equal(r.status, 0, r.stderr);
292
310
  assert.match(r.stderr, /invalid value '0s'/, 'a persistent settings line must never remove the stall guard');
293
311
  assert.match(r.stdout, /OK reply/);
294
312
  });
295
313
 
296
- it('a DIRECTORY at the settings path warns loudly and falls back to built-ins (no crash)', () => {
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
+
332
+ it('a DIRECTORY at the settings path warns loudly and falls back to built-ins (no crash)', async () => {
297
333
  const home = makeSandbox(RECORDING_STUB);
298
334
  mkdirSync(join(home, '.config', 'agent-workflow', 'bridge-settings.conf'), { recursive: true });
299
- const r = runWrapper(home, { AGY_MODEL: '' });
335
+ const r = await runWrapperAsync(home, { AGY_MODEL: '' });
300
336
  rmSync(home, { recursive: true, force: true });
301
337
  assert.equal(r.status, 0, `a directory must degrade honestly, not kill the run: ${r.stderr}`);
302
338
  assert.match(r.stderr, /unreadable or not a regular file/);
@@ -304,20 +340,20 @@ describe('agy.sh — bridge settings file (bridges 2.3.0)', () => {
304
340
  assert.match(r.stdout, /OK reply/);
305
341
  });
306
342
 
307
- it("another bridge's valid key is skipped silently (and never applied)", () => {
343
+ it("another bridge's valid key is skipped silently (and never applied)", async () => {
308
344
  const home = makeSandbox('#!/usr/bin/env bash\nsleep 3\necho "OK reply"\n');
309
345
  writeSettings(home, 'CODEX_SERVICE_TIER=priority\nCODEX_HARD_TIMEOUT=2\n');
310
- const r = runWrapper(home, { AGY_MODEL: '' });
346
+ const r = await runWrapperAsync(home, { AGY_MODEL: '' });
311
347
  rmSync(home, { recursive: true, force: true });
312
348
  assert.equal(r.status, 0, `a codex key must not cap an agy run: ${r.stderr}`);
313
349
  assert.doesNotMatch(r.stderr, /bridge settings/, 'a recognized non-applied key earns NO warning');
314
350
  assert.match(r.stdout, /OK reply/);
315
351
  });
316
352
 
317
- it('a truly unknown key warns ONCE naming the file; the run is unaffected', () => {
353
+ it('a truly unknown key warns ONCE naming the file; the run is unaffected', async () => {
318
354
  const home = makeSandbox(RECORDING_STUB);
319
355
  writeSettings(home, 'TOTALLY_UNKNOWN=1\nTOTALLY_UNKNOWN=2\n');
320
- const r = runWrapper(home, { AGY_MODEL: '' });
356
+ const r = await runWrapperAsync(home, { AGY_MODEL: '' });
321
357
  rmSync(home, { recursive: true, force: true });
322
358
  assert.equal(r.status, 0, r.stderr);
323
359
  const warns = r.stderr.match(/unknown key 'TOTALLY_UNKNOWN'/g) ?? [];
@@ -326,10 +362,10 @@ describe('agy.sh — bridge settings file (bridges 2.3.0)', () => {
326
362
  assert.match(r.stdout, /OK reply/);
327
363
  });
328
364
 
329
- it('malformed lines warn and are ignored; comments and blank lines are silent', () => {
365
+ it('malformed lines warn and are ignored; comments and blank lines are silent', async () => {
330
366
  const home = makeSandbox(RECORDING_STUB);
331
367
  writeSettings(home, '# a comment\n\nNOT A KEY VALUE LINE\n');
332
- const r = runWrapper(home, { AGY_MODEL: '' });
368
+ const r = await runWrapperAsync(home, { AGY_MODEL: '' });
333
369
  rmSync(home, { recursive: true, force: true });
334
370
  assert.equal(r.status, 0, r.stderr);
335
371
  const malformed = r.stderr.match(/malformed line/g) ?? [];
@@ -337,22 +373,22 @@ describe('agy.sh — bridge settings file (bridges 2.3.0)', () => {
337
373
  assert.match(r.stdout, /OK reply/);
338
374
  });
339
375
 
340
- it('an existing-but-unreadable file warns loudly and falls back to built-ins', { skip: isRoot }, () => {
376
+ it('an existing-but-unreadable file warns loudly and falls back to built-ins', { skip: isRoot }, async () => {
341
377
  const home = makeSandbox(RECORDING_STUB);
342
378
  const file = writeSettings(home, 'AGY_HARD_TIMEOUT=2s\n');
343
379
  chmodSync(file, 0o000);
344
- const r = runWrapper(home, { AGY_MODEL: '' });
380
+ const r = await runWrapperAsync(home, { AGY_MODEL: '' });
345
381
  rmSync(home, { recursive: true, force: true });
346
382
  assert.equal(r.status, 0, r.stderr);
347
383
  assert.match(r.stderr, /unreadable/);
348
384
  assert.match(r.stdout, /OK reply/);
349
385
  });
350
386
 
351
- it('a settings line can NEVER execute code (command-substitution payload inert)', () => {
387
+ it('a settings line can NEVER execute code (command-substitution payload inert)', async () => {
352
388
  const home = makeSandbox(RECORDING_STUB);
353
389
  const pwned = join(home, 'pwned');
354
390
  writeSettings(home, `AGY_HARD_TIMEOUT=$(touch ${pwned})\nEVIL_KEY=\`touch ${pwned}2\`\n`);
355
- const r = runWrapper(home, { AGY_MODEL: '' });
391
+ const r = await runWrapperAsync(home, { AGY_MODEL: '' });
356
392
  const executed = existsSync(pwned) || existsSync(`${pwned}2`);
357
393
  rmSync(home, { recursive: true, force: true });
358
394
  assert.equal(r.status, 0, r.stderr);
@@ -360,9 +396,9 @@ describe('agy.sh — bridge settings file (bridges 2.3.0)', () => {
360
396
  assert.match(r.stdout, /OK reply/);
361
397
  });
362
398
 
363
- it('no file → byte-identical behaviour to today (no settings chatter)', () => {
399
+ it('no file → byte-identical behaviour to today (no settings chatter)', async () => {
364
400
  const home = makeSandbox(RECORDING_STUB);
365
- const r = runWrapper(home, { AGY_MODEL: '' });
401
+ const r = await runWrapperAsync(home, { AGY_MODEL: '' });
366
402
  rmSync(home, { recursive: true, force: true });
367
403
  assert.equal(r.status, 0, r.stderr);
368
404
  assert.doesNotMatch(r.stderr, /bridge settings/);
@@ -398,7 +434,7 @@ describe('agy.sh — settings surface ⟷ manifest (D6, manifest-pinned)', () =>
398
434
  const runHelpText = () => {
399
435
  const home = mkdtempSync(join(tmpdir(), 'agy-settings-help-'));
400
436
  const r = spawnSync('bash', [WRAPPER, '--help'], {
401
- env: { HOME: home, PATH: makePathWithout(home, ['agy', 'git']) },
437
+ env: { HOME: home, PATH: FARM },
402
438
  encoding: 'utf8',
403
439
  timeout: 15000,
404
440
  });
@@ -475,7 +511,7 @@ describe('agy.sh — mode catalog ⟷ wrapper reality (the contract-free raw mod
475
511
  const helpText = () => {
476
512
  const home = mkdtempSync(join(tmpdir(), 'agy-catalog-help-'));
477
513
  const r = spawnSync('bash', [WRAPPER, '--help'], {
478
- env: { HOME: home, PATH: makePathWithout(home, ['agy', 'git']) },
514
+ env: { HOME: home, PATH: FARM },
479
515
  encoding: 'utf8',
480
516
  timeout: 15000,
481
517
  });
@@ -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
@@ -36,7 +36,7 @@
36
36
  # codex-exec <file|-> -- <extra codex flags...> # passthrough codex flags
37
37
  # codex-exec --resume-last <file|-> # continue the last session (iterate, no re-send)
38
38
  # codex-exec --resume <session-id> <file|-> # continue a specific session
39
- # CODEX_HARD_TIMEOUT=2h codex-exec <file> # raise the hard wall-clock cap
39
+ # CODEX_HARD_TIMEOUT=7200 codex-exec <file> # raise the hard wall-clock cap (integer seconds)
40
40
  # CODEX_PROBE=1 CODEX_MODEL=<slug> codex-exec <file> # throwaway probe (relaxes the guard)
41
41
  set -euo pipefail
42
42
 
@@ -72,6 +72,20 @@ Notes:
72
72
  nested inside a harness sandbox (the FS turns read-only) — route it OUTSIDE the harness sandbox
73
73
  (excludedCommands / a per-run consented bypass) on the OBSERVED bwrap/EPERM failure, never a
74
74
  preemptive blanket
75
+ exec posture banner: ONE stderr line before dispatch states the ACTUAL run posture —
76
+ exec posture: model=… effort=… tier=… sandbox=workspace-write session=fresh|resume:<id> timeout=… —
77
+ from RESOLVED post-validation values; the resume id is validated pre-spend, and control bytes in
78
+ any banner field refuse pre-spend
79
+ threat model: the sidecar byte and grammar screens detect corrupted input under a trusted parent
80
+ environment. A hostile parent environment — including exported shell functions or PATH
81
+ substitution of core/backend commands — is outside the threat model and can substitute the
82
+ backend itself. Targeted shadow-proof resolution protects banner/dispatch honesty from accidental
83
+ shadowing; it is not an environment security boundary
84
+ the exec posture banner appends a banner-only timeout=<duration|uncapped> field — exactly the
85
+ duration handed to timeout(1), uncapped when no timeout/gtimeout binary caps the run;
86
+ INFORMATIONAL only: it is never persisted in a receipt or session sidecar
87
+ quote the posture banner verbatim when labeling this dispatch — the banner is the machine-stated
88
+ posture; a prose re-type drifts
75
89
 
76
90
  Settings file (KEY=VALUE, parsed never sourced; env wins over file, file wins over built-in default):
77
91
  ${XDG_CONFIG_HOME:-~/.config}/agent-workflow/bridge-settings.conf
@@ -193,6 +207,56 @@ aw_apply_settings() {
193
207
  }
194
208
  aw_apply_settings
195
209
 
210
+ # --- Effective-timeout resolver (D5 banner honesty; AD-061) --------------------
211
+ # ONE rule, both bridges: the posture banner prints EXACTLY the duration handed to timeout(1) —
212
+ # an integer-seconds value rendered with the `s` suffix, a duration string verbatim — and
213
+ # `timeout=uncapped` when no timeout/gtimeout binary can cap the run; never a fabricated number.
214
+ # The EFFECTIVE value (env included — closing the aw_settings_valid env bypass) is validated by
215
+ # the same per-key rule as the settings file, plus a 7-digit integer-part bound (overflow); an
216
+ # invalid value warns + falls back to the built-in default — a typo never silently masquerades
217
+ # as a cap. AGY_TIMEOUT shares AGY_HARD_TIMEOUT's duration rule (it has no settings-file arm).
218
+ aw_effective_timeout() {
219
+ local key="$1" default="$2" value="${!1:-}" rule="$1" intpart
220
+ [[ "$rule" == "AGY_TIMEOUT" ]] && rule="AGY_HARD_TIMEOUT"
221
+ [[ -n "$value" ]] || { printf '%s' "$default"; return 0; }
222
+ intpart="${value%%[!0-9]*}"
223
+ if ! aw_settings_valid "$rule" "$value" || (( ${#intpart} > 7 )); then
224
+ # %q escapes the raw value so a control byte in it can never forge an extra diagnostic line
225
+ # (the direct agy-run lane has no pre-spend screen — the warning itself must be injection-proof).
226
+ printf "warning: invalid value '%q' for %s — using the built-in default %s.\n" "$value" "$key" "$default" >&2
227
+ printf '%s' "$default"
228
+ return 0
229
+ fi
230
+ printf '%s' "$value"
231
+ }
232
+ aw_timeout_label() {
233
+ local bin="$1" value="$2"
234
+ [[ -n "$bin" ]] || { printf 'uncapped'; return 0; }
235
+ case "$value" in
236
+ *[!0-9]*) printf '%s' "$value" ;;
237
+ *) printf '%ss' "$value" ;;
238
+ esac
239
+ }
240
+ aw_resolve_timeout_bin() {
241
+ local bin dir base
242
+ bin="$(builtin type -P timeout 2>/dev/null || true)"
243
+ [[ -n "$bin" ]] || bin="$(builtin type -P gtimeout 2>/dev/null || true)"
244
+ [[ -n "$bin" ]] || { printf ''; return 0; }
245
+ case "$bin" in
246
+ /*) ;;
247
+ *)
248
+ case "$bin" in
249
+ */*) dir="${bin%/*}"; base="${bin##*/}" ;;
250
+ *) dir="."; base="$bin" ;;
251
+ esac
252
+ dir="$(builtin cd -- "$dir" 2>/dev/null && builtin pwd -P)" || { printf ''; return 0; }
253
+ bin="$dir/$base"
254
+ ;;
255
+ esac
256
+ [[ -f "$bin" && -x "$bin" ]] || { printf ''; return 0; }
257
+ printf '%s' "$bin"
258
+ }
259
+
196
260
  DEFAULT_CODEX_MODEL="gpt-5.6-sol" # frontier coding model (verified locally) — pinned
197
261
  DEFAULT_CODEX_EFFORT="xhigh" # maximum reasoning effort — pinned
198
262
  CODEX_MODEL="${CODEX_MODEL:-$DEFAULT_CODEX_MODEL}"
@@ -209,6 +273,15 @@ CODEX_HARD_TIMEOUT="${CODEX_HARD_TIMEOUT:-3600}"
209
273
  # the wrapper validates the effective value: an unsupported one warns and runs on the standard
210
274
  # tier — a typo can never silently masquerade as Fast.
211
275
  CODEX_SERVICE_TIER="${CODEX_SERVICE_TIER:-}"
276
+ # D5 pre-spend control-byte screen — BEFORE tier validation (the codex-review.sh order: a
277
+ # malformed value is not a policy question). Screens EVERY exec-banner field.
278
+ for _posture_pair in "CODEX_MODEL=$CODEX_MODEL" "CODEX_EFFORT=$CODEX_EFFORT" "CODEX_SERVICE_TIER=$CODEX_SERVICE_TIER" "CODEX_HARD_TIMEOUT=$CODEX_HARD_TIMEOUT"; do
279
+ if [[ "${_posture_pair#*=}" == *[$'\x01'-$'\x1f'$'\x7f']* ]]; then
280
+ echo "error: ${_posture_pair%%=*} contains control bytes — fix the setting (env or bridge-settings.conf) and re-run." >&2
281
+ exit 2
282
+ fi
283
+ done
284
+ CODEX_HARD_TIMEOUT="$(aw_effective_timeout CODEX_HARD_TIMEOUT 3600)"
212
285
  if [[ -n "$CODEX_SERVICE_TIER" ]] && ! aw_settings_valid CODEX_SERVICE_TIER "$CODEX_SERVICE_TIER"; then
213
286
  echo "warning: CODEX_SERVICE_TIER='$CODEX_SERVICE_TIER' is not a supported service tier ('priority') — running on the standard tier." >&2
214
287
  CODEX_SERVICE_TIER=""
@@ -257,14 +330,27 @@ fi
257
330
  # Resolve the real git to an ABSOLUTE executable, ignoring shell functions/aliases
258
331
  # (`type -P` forces a PATH lookup). The shim embeds this path so codex cannot recurse
259
332
  # into the shim or delegate to the wrong binary.
260
- real_git="$(type -P git 2>/dev/null || true)"
333
+ real_git="$(builtin type -P git 2>/dev/null || true)"
261
334
  if [[ -z "$real_git" ]]; then
262
335
  echo "error: 'git' not found on PATH (needed for the work tree and the git-write boundary shim)." >&2
263
336
  exit 127
264
337
  fi
338
+ # Normalize a relative-PATH hit to an ABSOLUTE path with the same shadow-proof discipline as
339
+ # aw_resolve_timeout_bin: parameter-expansion split (no external dirname/basename an exported
340
+ # function could shadow), builtin cd/pwd -P, fail-closed to empty (the -x check below STOPs).
265
341
  case "$real_git" in
266
342
  /*) ;;
267
- *) real_git="$(cd "$(dirname "$real_git")" 2>/dev/null && pwd)/$(basename "$real_git")" ;;
343
+ *)
344
+ case "$real_git" in
345
+ */*) _git_dir="${real_git%/*}"; _git_base="${real_git##*/}" ;;
346
+ *) _git_dir="."; _git_base="$real_git" ;;
347
+ esac
348
+ if _git_dir="$(builtin cd -- "$_git_dir" 2>/dev/null && builtin pwd -P)"; then
349
+ real_git="$_git_dir/$_git_base"
350
+ else
351
+ real_git=""
352
+ fi
353
+ ;;
268
354
  esac
269
355
  if [[ ! -x "$real_git" ]]; then
270
356
  echo "error: resolved git path '$real_git' is not an executable." >&2
@@ -363,12 +449,37 @@ if [[ -n "$resume_mode" ]]; then
363
449
  echo " Run a normal 'codex-exec' once (it records the session id there) before resuming." >&2
364
450
  exit 2
365
451
  fi
366
- resume_id="$(head -n1 -- "$sidecar" | tr -d '[:space:]')"
367
- if [[ -z "$resume_id" ]]; then
452
+ # RAW-byte NUL screen BEFORE the shell variable: bash command substitution silently DROPS
453
+ # NUL bytes, so a hostile `sess-\0target` would otherwise be repaired into a valid id.
454
+ nul_count="$(head -n1 -- "$sidecar" | LC_ALL=C tr -cd '\000' | wc -c)"
455
+ if (( nul_count != 0 )); then
456
+ echo "error: the session sidecar '$sidecar' carries NUL bytes — refusing before any spend; delete it or pass --resume <session-id>." >&2
457
+ exit 2
458
+ fi
459
+ # Read the first line WITHOUT content mutation — only a CRLF terminator is stripped. A
460
+ # whitespace-ONLY line is an empty sidecar; inner whitespace beside real content hits the
461
+ # session-id grammar below and REFUSES — it is never silently stripped into a different
462
+ # (accidentally valid) id.
463
+ resume_id="$(head -n1 -- "$sidecar")"
464
+ resume_id="${resume_id%$'\r'}"
465
+ if [[ -z "${resume_id//[[:space:]]/}" ]]; then
368
466
  echo "error: the session sidecar '$sidecar' is empty — no session id to resume." >&2
369
467
  exit 2
370
468
  fi
371
469
  fi
470
+ # Resume-id grammar (AD-061, derived from live codex ids — UUID-like and sess-* forms alike):
471
+ # a safe charset + length bound, FIRST char alphanumeric (a leading dash would reach
472
+ # `codex exec resume` as an OPTION, bypassing the guarded passthrough). A hostile/malformed
473
+ # id — explicit OR sidecar-read — refuses BEFORE any spend and before the id can reach the
474
+ # codex argv; the raw value is never echoed (it could carry control bytes).
475
+ aw_session_id_re='^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$'
476
+ if [[ ! "$resume_id" =~ $aw_session_id_re ]]; then
477
+ echo "error: the resolved session id is not a valid codex session id (letters, digits, dot," >&2
478
+ echo " underscore, hyphen; must START with a letter or digit; 1-128 chars) — refusing" >&2
479
+ echo " before any spend. If it came from the sidecar, the file may be corrupted —" >&2
480
+ echo " delete it or pass --resume <session-id>." >&2
481
+ exit 2
482
+ fi
372
483
  else
373
484
  # Normal mode: split off passthrough codex flags after a literal `--`. Extra args
374
485
  # WITHOUT the `--` separator are a mistake — fail loudly rather than drop them.
@@ -541,17 +652,24 @@ run_env=(env "PATH=$shim_dir:$PATH")
541
652
  # after the initial TERM if codex ignores it (a live probe confirmed plain
542
653
  # `timeout` reaps the whole codex child tree — no --foreground needed). If neither
543
654
  # binary exists we warn loudly and run uncapped rather than fail silently.
544
- timeout_bin=""
545
- if command -v timeout >/dev/null 2>&1; then
546
- timeout_bin="timeout"
547
- elif command -v gtimeout >/dev/null 2>&1; then
548
- timeout_bin="gtimeout"
549
- fi
655
+ # aw_resolve_timeout_bin: builtin type -P (an exported function can shadow neither `timeout` nor
656
+ # `type` itself), normalized to an ABSOLUTE path fail-closed the dispatch invokes the same
657
+ # absolute path the banner rendered from (banner and run never make independent conclusions).
658
+ timeout_bin="$(aw_resolve_timeout_bin)"
550
659
  if [[ -z "$timeout_bin" ]]; then
551
660
  echo "warning: no 'timeout'/'gtimeout' on PATH — running codex WITHOUT a hard wall-clock cap" >&2
552
661
  echo " (install coreutils to enable CODEX_HARD_TIMEOUT=$CODEX_HARD_TIMEOUT)." >&2
553
662
  fi
554
663
 
664
+ # --- D5 exec banner (one line, the ACTUAL run posture; AD-061) -----------------
665
+ # Emitted from RESOLVED post-validation values, AFTER the resume id is resolved and validated,
666
+ # BEFORE the dispatch. The timeout field is banner-only (never a receipt/sidecar field): it
667
+ # prints exactly the duration handed to timeout(1), or `uncapped` without a capping binary.
668
+ aw_timeout_banner="$(aw_timeout_label "$timeout_bin" "$CODEX_HARD_TIMEOUT")"
669
+ aw_session_label="fresh"
670
+ [[ -n "$resume_mode" ]] && aw_session_label="resume:$resume_id"
671
+ echo "exec posture: model=$CODEX_MODEL effort=$CODEX_EFFORT tier=${CODEX_SERVICE_TIER:-standard} sandbox=workspace-write session=$aw_session_label timeout=$aw_timeout_banner" >&2
672
+
555
673
  # Normal mode: -o writes $out, the JSON stream + logs go to $trace. Resume mode: the
556
674
  # final message is codex's stdout → $out, logs → $trace. Either way the final lands
557
675
  # in $out and diagnostics in $trace, so the post-processing below is shared.