@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, 'codex-exec.sh');
@@ -28,7 +28,7 @@ const FAKE_CODEX = [
28
28
  '{ for a in "$@"; do echo "$a"; done; } >"$CODEX_FAKE_ARGV"',
29
29
  '{ echo "HOME=${HOME:-}"; echo "CODEX_HOME=${CODEX_HOME:-}"; echo "XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-}"; echo "OPENAI_API_KEY=${OPENAI_API_KEY:-<unset>}"; echo "OPENAI_BASE_URL=${OPENAI_BASE_URL:-<unset>}"; echo "FOO_API_KEY=${FOO_API_KEY:-<unset>}"; echo "CODEX_REAL_GIT=${CODEX_REAL_GIT:-<unset>}"; } >"$CODEX_FAKE_ENV"',
30
30
  'cat >"$CODEX_FAKE_STDIN"',
31
- 'if [[ "${CODEX_FAKE_GIT_PROBE:-}" == "1" ]]; then { echo "realgit_env=${CODEX_REAL_GIT:-unset}"; echo "status=$(git status --short >/dev/null 2>&1; echo $?)"; echo "diff=$(git --no-pager diff >/dev/null 2>&1; echo $?)"; echo "dashC_read=$(git -C . status --short >/dev/null 2>&1; echo $?)"; echo "dashc_read=$(git -c core.pager=cat status --short >/dev/null 2>&1; echo $?)"; echo "bare=$(git >/dev/null 2>&1; echo $?)"; echo "commit=$(git commit -m x >/dev/null 2>&1; echo $?)"; echo "add=$(git add -A >/dev/null 2>&1; echo $?)"; echo "checkout=$(git checkout -- . >/dev/null 2>&1; echo $?)"; echo "unknown=$(git frobnicate >/dev/null 2>&1; echo $?)"; echo "config_read=$(git config user.name >/dev/null 2>&1; echo $?)"; echo "config_list=$(git config --list >/dev/null 2>&1; echo $?)"; echo "config_bare=$(git config >/dev/null 2>&1; echo $?)"; echo "config_write=$(git config user.name HACKED >/dev/null 2>&1; echo $?)"; echo "config_bypass=$(git config --get --add a.b v >/dev/null 2>&1; echo $?)"; echo "symref_write=$(git symbolic-ref HEAD refs/heads/x >/dev/null 2>&1; echo $?)"; echo "reflog_write=$(git reflog expire --all >/dev/null 2>&1; echo $?)"; } > "${CODEX_FAKE_GIT_RESULT:-/dev/null}" 2>&1; fi',
31
+ 'if [[ "${CODEX_FAKE_GIT_PROBE:-}" == "1" ]]; then { echo "realgit_env=${CODEX_REAL_GIT:-unset}"; echo "status=$(git status --short >/dev/null 2>&1; echo $?)"; echo "diff=$(git --no-pager diff >/dev/null 2>&1; echo $?)"; echo "dashC_read=$(git -C . status --short >/dev/null 2>&1; echo $?)"; echo "dashc_read=$(git -c core.pager=cat status --short >/dev/null 2>&1; echo $?)"; echo "bare=$(git >/dev/null 2>&1; echo $?)"; echo "commit=$(git commit -m x >/dev/null 2>&1; echo $?)"; echo "add=$(git add -A >/dev/null 2>&1; echo $?)"; echo "checkout=$(git checkout -- . >/dev/null 2>&1; echo $?)"; echo "unknown=$(git frobnicate >/dev/null 2>&1; echo $?)"; echo "config_read=$(git config user.name >/dev/null 2>&1; echo $?)"; echo "config_list=$(git config --list >/dev/null 2>&1; echo $?)"; echo "config_bare=$(git config >/dev/null 2>&1; echo $?)"; echo "config_write=$(git config user.name HACKED >/dev/null 2>&1; echo $?)"; echo "config_bypass=$(git config --get --add a.b v >/dev/null 2>&1; echo $?)"; echo "symref_write=$(git symbolic-ref HEAD refs/heads/x >/dev/null 2>&1; echo $?)"; echo "reflog_write=$(git reflog expire --all >/dev/null 2>&1; echo $?)"; echo "cdaway=$(cd / && git --version >/dev/null 2>&1; echo $?)"; } > "${CODEX_FAKE_GIT_RESULT:-/dev/null}" 2>&1; fi',
32
32
  'if [[ -n "${CODEX_FAKE_SLEEP:-}" ]]; then sleep "${CODEX_FAKE_SLEEP}"; fi',
33
33
  'out=""',
34
34
  'prev=""',
@@ -55,13 +55,16 @@ const FAKE_CODEX = [
55
55
  '',
56
56
  ].join('\n');
57
57
 
58
- const makeSandbox = () => {
59
- const root = mkdtempSync(join(tmpdir(), 'codex-exec-test-'));
58
+ // The sandbox base and the PATH farms are READ-ONLY per invocation, so both are built ONCE and
59
+ // shared (a per-test `git init`+commit and a per-call farm rebuild dominate the wall otherwise).
60
+ const SHARED_ROOT = mkdtempSync(join(tmpdir(), 'codex-exec-shared-'));
61
+ after(() => rmSync(SHARED_ROOT, { recursive: true, force: true }));
62
+
63
+ const TEMPLATE_ROOT = (() => {
64
+ const root = join(SHARED_ROOT, 'template-root');
60
65
  const bin = join(root, 'bin');
61
66
  mkdirSync(bin, { recursive: true });
62
- const stub = join(bin, 'codex');
63
- writeFileSync(stub, FAKE_CODEX, { mode: 0o755 });
64
- chmodSync(stub, 0o755);
67
+ writeFileSync(join(bin, 'codex'), FAKE_CODEX, { mode: 0o755 });
65
68
  // A git work tree with a root AGENTS.md — the wrapper preflights both.
66
69
  const repo = join(root, 'repo');
67
70
  mkdirSync(repo);
@@ -72,7 +75,15 @@ const makeSandbox = () => {
72
75
  writeFileSync(join(repo, 'AGENTS.md'), '# AGENTS\n\nHard Constraints: none (test fixture).\n');
73
76
  g('add', '-A');
74
77
  g('commit', '-qm', 'base');
75
- return { root, bin, repo };
78
+ return root;
79
+ })();
80
+
81
+ const makeSandbox = () => {
82
+ const root = mkdtempSync(join(tmpdir(), 'codex-exec-test-'));
83
+ cpSync(TEMPLATE_ROOT, root, { recursive: true });
84
+ const bin = join(root, 'bin');
85
+ chmodSync(join(bin, 'codex'), 0o755);
86
+ return { root, bin, repo: join(root, 'repo') };
76
87
  };
77
88
 
78
89
  // A PATH dir mirroring the real one MINUS the named binaries, to exercise the
@@ -96,6 +107,13 @@ const makePathWithout = (root, exclude = []) => {
96
107
  return dir;
97
108
  };
98
109
 
110
+ const farms = new Map();
111
+ const farmFor = (exclude) => {
112
+ const key = exclude.join('|');
113
+ if (!farms.has(key)) farms.set(key, makePathWithout(SHARED_ROOT, exclude));
114
+ return farms.get(key);
115
+ };
116
+
99
117
  const run = ({ repo, bin }, { args = ['-'], input = 'do the thing', env = {}, path, cwd } = {}) => {
100
118
  const argvFile = join(repo, '.cap-argv');
101
119
  const envFile = join(repo, '.cap-env');
@@ -121,6 +139,33 @@ const run = ({ repo, bin }, { args = ['-'], input = 'do the thing', env = {}, pa
121
139
  return { ...r, argv: readIf(argvFile), capEnv: readIf(envFile), capStdin: readIf(stdinFile) };
122
140
  };
123
141
 
142
+ // Async twin of run() for the sleep-bound timeout tests: spawnSync blocks the event loop for
143
+ // the whole deliberate wait, so a concurrent describe could not overlap them. Same contract.
144
+ const runAsync = ({ repo, bin }, { args = ['-'], input = 'do the thing', env = {}, path, cwd } = {}) =>
145
+ new Promise((done) => {
146
+ const argvFile = join(repo, '.cap-argv');
147
+ const envFile = join(repo, '.cap-env');
148
+ const stdinFile = join(repo, '.cap-stdin');
149
+ const child = execFile('bash', [WRAPPER, ...args], {
150
+ cwd: cwd || repo,
151
+ encoding: 'utf8',
152
+ timeout: 30000,
153
+ env: {
154
+ PATH: path || `${bin}:${process.env.PATH}`,
155
+ HOME: repo,
156
+ TMPDIR: process.env.TMPDIR ?? '/tmp',
157
+ CODEX_FAKE_ARGV: argvFile,
158
+ CODEX_FAKE_ENV: envFile,
159
+ CODEX_FAKE_STDIN: stdinFile,
160
+ ...env,
161
+ },
162
+ }, (error, stdout, stderr) => {
163
+ const readIf = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : '');
164
+ done({ status: error ? (error.code ?? 1) : 0, stdout, stderr, argv: readIf(argvFile), capEnv: readIf(envFile), capStdin: readIf(stdinFile) });
165
+ });
166
+ child.stdin.end(input);
167
+ });
168
+
124
169
  describe('codex-exec.sh — quality-first model/effort guard (1.1)', () => {
125
170
  it('refuses a non-default CODEX_MODEL and never spends a run', () => {
126
171
  const sb = makeSandbox();
@@ -344,11 +389,11 @@ describe('codex-exec.sh — leaner prompt (1.4)', () => {
344
389
  });
345
390
  });
346
391
 
347
- describe('codex-exec.sh — hard timeout (1.3)', () => {
348
- it('kills a hung codex at CODEX_HARD_TIMEOUT and reports it', () => {
392
+ describe('codex-exec.sh — hard timeout (1.3)', { concurrency: true }, () => {
393
+ it('kills a hung codex at CODEX_HARD_TIMEOUT and reports it', async () => {
349
394
  const sb = makeSandbox();
350
395
  const started = Date.now();
351
- const r = run(sb, { env: { CODEX_FAKE_SLEEP: '30', CODEX_HARD_TIMEOUT: '2' } });
396
+ const r = await runAsync(sb, { env: { CODEX_FAKE_SLEEP: '30', CODEX_HARD_TIMEOUT: '2' } });
352
397
  const elapsed = Date.now() - started;
353
398
  rmSync(sb.root, { recursive: true, force: true });
354
399
  assert.ok(elapsed < 18000, `must return well under the kill-after window, took ${elapsed}ms`);
@@ -358,7 +403,7 @@ describe('codex-exec.sh — hard timeout (1.3)', () => {
358
403
 
359
404
  it('warns and runs uncapped when neither timeout nor gtimeout is on PATH', () => {
360
405
  const sb = makeSandbox();
361
- const path = `${sb.bin}:${makePathWithout(sb.root, ['timeout', 'gtimeout'])}`;
406
+ const path = `${sb.bin}:${farmFor(['timeout', 'gtimeout'])}`;
362
407
  const r = run(sb, { path });
363
408
  rmSync(sb.root, { recursive: true, force: true });
364
409
  assert.equal(r.status, 0, r.stderr);
@@ -368,7 +413,7 @@ describe('codex-exec.sh — hard timeout (1.3)', () => {
368
413
 
369
414
  it('resume runs uncapped (and warns) when no timeout binary is on PATH', () => {
370
415
  const sb = makeSandbox();
371
- const path = `${sb.bin}:${makePathWithout(sb.root, ['timeout', 'gtimeout'])}`;
416
+ const path = `${sb.bin}:${farmFor(['timeout', 'gtimeout'])}`;
372
417
  const r = run(sb, { args: ['--resume', 'sess-1', '-'], input: 'go', path });
373
418
  rmSync(sb.root, { recursive: true, force: true });
374
419
  assert.equal(r.status, 0, r.stderr);
@@ -545,7 +590,7 @@ describe('codex-exec.sh — environment preflight (fail fast, before a run)', ()
545
590
  it('STOPs with 127 when codex is not on PATH', () => {
546
591
  const sb = makeSandbox();
547
592
  // PATH WITHOUT the fake codex bin and without any real codex.
548
- const path = makePathWithout(sb.root, ['codex']);
593
+ const path = farmFor(['codex']);
549
594
  const r = run(sb, { path });
550
595
  rmSync(sb.root, { recursive: true, force: true });
551
596
  assert.equal(r.status, 127);
@@ -556,7 +601,7 @@ describe('codex-exec.sh — environment preflight (fail fast, before a run)', ()
556
601
  it('STOPs with 127 when git is not on PATH', () => {
557
602
  const sb = makeSandbox();
558
603
  // codex present (sb.bin) but git stripped — exercises the type -P git guard.
559
- const path = `${sb.bin}:${makePathWithout(sb.root, ['git'])}`;
604
+ const path = `${sb.bin}:${farmFor(['git'])}`;
560
605
  const r = run(sb, { path });
561
606
  rmSync(sb.root, { recursive: true, force: true });
562
607
  assert.equal(r.status, 127);
@@ -681,7 +726,7 @@ const runHelp = (arg) => {
681
726
  const root = mkdtempSync(join(tmpdir(), 'codex-exec-help-'));
682
727
  const nongit = join(root, 'nongit');
683
728
  mkdirSync(nongit, { recursive: true });
684
- const path = makePathWithout(root, ['codex', 'agy', 'git']);
729
+ const path = farmFor(['codex', 'agy', 'git']);
685
730
  const r = spawnSync('bash', [WRAPPER, arg], {
686
731
  cwd: nongit, encoding: 'utf8', timeout: 15000, env: { HOME: root, PATH: path },
687
732
  });
@@ -1027,20 +1072,20 @@ describe('codex-exec.sh — service tier knob (bridges 2.3.0)', () => {
1027
1072
 
1028
1073
  });
1029
1074
 
1030
- describe('codex-exec.sh — bridge settings file semantics (bridges 2.3.0)', () => {
1031
- it('env overrides file: CODEX_HARD_TIMEOUT env=2 file=9999 → killed at the env cap', () => {
1075
+ describe('codex-exec.sh — bridge settings file semantics (bridges 2.3.0)', { concurrency: true }, () => {
1076
+ it('env overrides file: CODEX_HARD_TIMEOUT env=2 file=9999 → killed at the env cap', async () => {
1032
1077
  const sb = makeSandbox();
1033
1078
  writeSettings(sb, 'CODEX_HARD_TIMEOUT=9999\n');
1034
- const r = run(sb, { env: { CODEX_FAKE_SLEEP: '5', CODEX_HARD_TIMEOUT: '2' } });
1079
+ const r = await runAsync(sb, { env: { CODEX_FAKE_SLEEP: '5', CODEX_HARD_TIMEOUT: '2' } });
1035
1080
  rmSync(sb.root, { recursive: true, force: true });
1036
1081
  assert.notEqual(r.status, 0, 'the env cap (2s) must win over the file cap (9999s)');
1037
1082
  assert.match(r.stderr, /exceeded the hard cap CODEX_HARD_TIMEOUT=2s/);
1038
1083
  });
1039
1084
 
1040
- it('a file-set CODEX_HARD_TIMEOUT is effective (killed at the file cap)', () => {
1085
+ it('a file-set CODEX_HARD_TIMEOUT is effective (killed at the file cap)', async () => {
1041
1086
  const sb = makeSandbox();
1042
1087
  writeSettings(sb, 'CODEX_HARD_TIMEOUT=2\n');
1043
- const r = run(sb, { env: { CODEX_FAKE_SLEEP: '5' } });
1088
+ const r = await runAsync(sb, { env: { CODEX_FAKE_SLEEP: '5' } });
1044
1089
  rmSync(sb.root, { recursive: true, force: true });
1045
1090
  assert.notEqual(r.status, 0, 'the file cap must apply when the env is unset');
1046
1091
  assert.match(r.stderr, /exceeded the hard cap CODEX_HARD_TIMEOUT=2s/);
@@ -1225,3 +1270,213 @@ describe('codex-exec.sh — settings surface ⟷ manifest (D6, manifest-pinned)'
1225
1270
  }
1226
1271
  });
1227
1272
  });
1273
+
1274
+ describe('codex-exec.sh — dispatch-posture labeling (D5, AD-061)', () => {
1275
+ const banners = (stderr) => stderr.split('\n').filter((l) => l.startsWith('exec posture: '));
1276
+
1277
+ it('ONE banner line carries the ACTUAL {model, effort, tier, sandbox, session, timeout} on a fresh run', () => {
1278
+ const sb = makeSandbox();
1279
+ const r = run(sb, {});
1280
+ rmSync(sb.root, { recursive: true, force: true });
1281
+ assert.equal(r.status, 0, r.stderr);
1282
+ const lines = banners(r.stderr);
1283
+ assert.equal(lines.length, 1, 'EXACTLY ONE banner line per run');
1284
+ assert.equal(lines[0],
1285
+ 'exec posture: model=gpt-5.6-sol effort=xhigh tier=standard sandbox=workspace-write session=fresh timeout=3600s');
1286
+ });
1287
+
1288
+ it('an ARMED Fast tier rides the banner (tier=priority)', () => {
1289
+ const sb = makeSandbox();
1290
+ const r = run(sb, { env: { CODEX_SERVICE_TIER: 'priority' } });
1291
+ rmSync(sb.root, { recursive: true, force: true });
1292
+ assert.equal(r.status, 0, r.stderr);
1293
+ assert.match(r.stderr, /^exec posture: .* tier=priority .*$/m);
1294
+ });
1295
+
1296
+ it('a resume banner carries the RESOLVED session id (explicit --resume)', () => {
1297
+ const sb = makeSandbox();
1298
+ const r = run(sb, { args: ['--resume', 'sess-xyz', '-'] });
1299
+ rmSync(sb.root, { recursive: true, force: true });
1300
+ assert.equal(r.status, 0, r.stderr);
1301
+ const lines = banners(r.stderr);
1302
+ assert.equal(lines.length, 1, 'EXACTLY ONE banner line on resume too');
1303
+ assert.match(lines[0], / session=resume:sess-xyz /, 'the banner names the resolved id');
1304
+ });
1305
+
1306
+ it('--resume-last resolves the sidecar id into the banner', () => {
1307
+ const sb = makeSandbox();
1308
+ writeFileSync(join(sb.repo, '.codex-last-session'), 'sess-from-sidecar\n');
1309
+ const r = run(sb, { args: ['--resume-last', '-'] });
1310
+ rmSync(sb.root, { recursive: true, force: true });
1311
+ assert.equal(r.status, 0, r.stderr);
1312
+ assert.match(r.stderr, / session=resume:sess-from-sidecar /);
1313
+ });
1314
+
1315
+ it('a HOSTILE/malformed EXPLICIT session id refuses pre-spend (no codex invocation)', () => {
1316
+ for (const hostile of ['evil;rm -rf /', 'a b', `x${String.fromCharCode(1)}y`]) {
1317
+ const sb = makeSandbox();
1318
+ const r = run(sb, { args: ['--resume', hostile, '-'] });
1319
+ rmSync(sb.root, { recursive: true, force: true });
1320
+ assert.notEqual(r.status, 0, `must refuse: ${JSON.stringify(hostile)}`);
1321
+ assert.equal(r.capStdin, '', 'codex is never invoked');
1322
+ assert.match(r.stderr, /session id/i, 'named as the session-id class');
1323
+ }
1324
+ });
1325
+
1326
+ it('a HOSTILE SIDECAR-READ session id refuses pre-spend the same way', () => {
1327
+ const sb = makeSandbox();
1328
+ writeFileSync(join(sb.repo, '.codex-last-session'), 'evil$(touch pwned)\n');
1329
+ const r = run(sb, { args: ['--resume-last', '-'] });
1330
+ rmSync(sb.root, { recursive: true, force: true });
1331
+ assert.notEqual(r.status, 0);
1332
+ assert.equal(r.capStdin, '', 'codex is never invoked');
1333
+ assert.match(r.stderr, /session id/i);
1334
+ });
1335
+
1336
+ it('a FLAG-SHAPED sidecar id (leading dash) refuses at the grammar — never reaches codex as an option', () => {
1337
+ for (const bad of ['--last\n', '-x\n']) {
1338
+ const sb = makeSandbox();
1339
+ writeFileSync(join(sb.repo, '.codex-last-session'), bad);
1340
+ const r = run(sb, { args: ['--resume-last', '-'] });
1341
+ rmSync(sb.root, { recursive: true, force: true });
1342
+ assert.notEqual(r.status, 0, `must refuse: ${JSON.stringify(bad)}`);
1343
+ assert.equal(r.capStdin, '', 'codex is never invoked');
1344
+ assert.match(r.stderr, /session id/i, 'refused at the grammar, never parsed as a codex option');
1345
+ }
1346
+ });
1347
+
1348
+ it('a sidecar carrying a NUL byte refuses pre-spend — bash would silently repair it into a valid id', () => {
1349
+ const sb = makeSandbox();
1350
+ writeFileSync(join(sb.repo, '.codex-last-session'), Buffer.from('sess-\0target\n', 'binary'));
1351
+ const r = run(sb, { args: ['--resume-last', '-'] });
1352
+ rmSync(sb.root, { recursive: true, force: true });
1353
+ assert.notEqual(r.status, 0);
1354
+ assert.equal(r.capStdin, '', 'codex is never invoked');
1355
+ assert.match(r.stderr, /NUL/i, 'named as the NUL class — the raw bytes are checked before the shell variable');
1356
+ });
1357
+
1358
+ it('a valid id containing the ASCII digit 0 gets no false NUL refusal', () => {
1359
+ const sb = makeSandbox();
1360
+ writeFileSync(join(sb.repo, '.codex-last-session'), 'sess-01\n');
1361
+ const r = run(sb, { args: ['--resume-last', '-'] });
1362
+ rmSync(sb.root, { recursive: true, force: true });
1363
+ assert.equal(r.status, 0, r.stderr);
1364
+ assert.match(r.stderr, / session=resume:sess-01 /);
1365
+ });
1366
+
1367
+ it('a sidecar id with inner WHITESPACE refuses — never silently repaired into a different id', () => {
1368
+ for (const bad of ['sess bad\n', 'sess\tbad\n']) {
1369
+ const sb = makeSandbox();
1370
+ writeFileSync(join(sb.repo, '.codex-last-session'), bad);
1371
+ const r = run(sb, { args: ['--resume-last', '-'] });
1372
+ rmSync(sb.root, { recursive: true, force: true });
1373
+ assert.notEqual(r.status, 0, `must refuse: ${JSON.stringify(bad)}`);
1374
+ assert.equal(r.capStdin, '', 'codex is never invoked');
1375
+ assert.match(r.stderr, /session id/i, 'the grammar refusal fires — the id is never whitespace-stripped into validity');
1376
+ }
1377
+ });
1378
+
1379
+ it('a banner field carrying CONTROL BYTES refuses pre-spend (model / effort / tier / timeout / DEL)', () => {
1380
+ const cases = [
1381
+ { CODEX_MODEL: `gpt-5.6-sol${String.fromCharCode(1)}` },
1382
+ { CODEX_EFFORT: `xhigh${String.fromCharCode(2)}` },
1383
+ { CODEX_SERVICE_TIER: `priority${String.fromCharCode(3)}` },
1384
+ { CODEX_HARD_TIMEOUT: `3600${String.fromCharCode(4)}` },
1385
+ { CODEX_MODEL: `gpt-5.6-sol${String.fromCharCode(127)}` },
1386
+ ];
1387
+ for (const env of cases) {
1388
+ const sb = makeSandbox();
1389
+ const r = run(sb, { env });
1390
+ rmSync(sb.root, { recursive: true, force: true });
1391
+ assert.notEqual(r.status, 0, `must refuse: ${JSON.stringify(env)}`);
1392
+ assert.equal(r.capStdin, '', 'codex is never invoked');
1393
+ assert.match(r.stderr, /control/i, 'named as the control-byte class');
1394
+ }
1395
+ });
1396
+
1397
+ it('timeout honesty: no timeout/gtimeout on PATH → timeout=uncapped, never a fabricated number', () => {
1398
+ const sb = makeSandbox();
1399
+ const r = run(sb, { path: `${sb.bin}:${farmFor(['timeout', 'gtimeout'])}` });
1400
+ rmSync(sb.root, { recursive: true, force: true });
1401
+ assert.equal(r.status, 0, r.stderr);
1402
+ assert.match(r.stderr, /^exec posture: .* timeout=uncapped$/m);
1403
+ });
1404
+
1405
+ it('an EXPORTED shell function shadowing timeout never fools the banner (type -P discipline)', () => {
1406
+ const sb = makeSandbox();
1407
+ const r = run(sb, {
1408
+ path: `${sb.bin}:${farmFor(['timeout', 'gtimeout'])}`,
1409
+ env: { 'BASH_FUNC_timeout%%': '() { return 0; }' },
1410
+ });
1411
+ rmSync(sb.root, { recursive: true, force: true });
1412
+ assert.equal(r.status, 0, r.stderr);
1413
+ assert.match(r.stderr, /^exec posture: .* timeout=uncapped$/m, 'a shell function is not a capping binary');
1414
+ });
1415
+
1416
+ it('an EXPORTED `type` function faking a path never fools the resolver (builtin type discipline)', () => {
1417
+ const sb = makeSandbox();
1418
+ const r = run(sb, {
1419
+ path: `${sb.bin}:${farmFor(['timeout', 'gtimeout'])}`,
1420
+ env: { 'BASH_FUNC_type%%': '() { echo /fake/timeout; }' },
1421
+ });
1422
+ rmSync(sb.root, { recursive: true, force: true });
1423
+ assert.equal(r.status, 0, r.stderr);
1424
+ assert.match(r.stderr, /^exec posture: .* timeout=uncapped$/m, 'builtin type bypasses an exported type function');
1425
+ });
1426
+
1427
+ it('a RELATIVE first PATH git entry + shadowed dirname/basename still bakes an ABSOLUTE real git into the shim', () => {
1428
+ const sb = makeSandbox();
1429
+ const realGit = (process.env.PATH || '').split(':').filter(Boolean).map((d) => join(d, 'git')).find((p) => existsSync(p));
1430
+ assert.ok(realGit, 'a real git exists on PATH');
1431
+ mkdirSync(join(sb.repo, 'relgit'), { recursive: true });
1432
+ writeFileSync(join(sb.repo, 'relgit', 'git'), `#!/usr/bin/env bash\nexec ${realGit} "$@"\n`, { mode: 0o755 });
1433
+ const gitResult = join(sb.repo, '.cap-git');
1434
+ const r = run(sb, {
1435
+ path: `relgit:${sb.bin}:${process.env.PATH}`,
1436
+ env: {
1437
+ CODEX_FAKE_GIT_PROBE: '1',
1438
+ CODEX_FAKE_GIT_RESULT: gitResult,
1439
+ 'BASH_FUNC_dirname%%': '() { echo /shadowed; }',
1440
+ 'BASH_FUNC_basename%%': '() { echo shadowed; }',
1441
+ },
1442
+ });
1443
+ const probe = existsSync(gitResult) ? readFileSync(gitResult, 'utf8') : '';
1444
+ rmSync(sb.root, { recursive: true, force: true });
1445
+ assert.equal(r.status, 0, r.stderr);
1446
+ assert.match(probe, /cdaway=0/, 'the shim git works from a different cwd — the embedded path is absolute, shadow-proof');
1447
+ });
1448
+
1449
+ it('a RELATIVE PATH entry still yields an ABSOLUTE capping binary (the stub sees an absolute $0)', () => {
1450
+ const sb = makeSandbox();
1451
+ mkdirSync(join(sb.repo, 'relbin'), { recursive: true });
1452
+ const cap = join(sb.repo, '.stub-argv0');
1453
+ writeFileSync(join(sb.repo, 'relbin', 'timeout'), [
1454
+ '#!/usr/bin/env bash',
1455
+ 'echo "$0" >"$TIMEOUT_STUB_CAP"',
1456
+ 'while [[ "$1" == --* ]]; do shift; done',
1457
+ 'shift',
1458
+ 'exec "$@"',
1459
+ '',
1460
+ ].join('\n'), { mode: 0o755 });
1461
+ const r = run(sb, {
1462
+ path: `relbin:${sb.bin}:${farmFor(['timeout', 'gtimeout'])}`,
1463
+ env: { TIMEOUT_STUB_CAP: cap },
1464
+ });
1465
+ const argv0 = existsSync(cap) ? readFileSync(cap, 'utf8').trim() : '';
1466
+ rmSync(sb.root, { recursive: true, force: true });
1467
+ assert.equal(r.status, 0, r.stderr);
1468
+ assert.match(r.stderr, /^exec posture: .* timeout=3600s$/m, 'the relative-PATH binary still caps the run');
1469
+ assert.ok(argv0.startsWith('/'), `the stub must be invoked by ABSOLUTE path, got: ${JSON.stringify(argv0)}`);
1470
+ });
1471
+
1472
+ it('an INVALID effective CODEX_HARD_TIMEOUT (env — the closed aw_settings_valid bypass) warns and falls back to the default', () => {
1473
+ for (const bad of ['abc', '0', '999999999']) {
1474
+ const sb = makeSandbox();
1475
+ const r = run(sb, { env: { CODEX_HARD_TIMEOUT: bad } });
1476
+ rmSync(sb.root, { recursive: true, force: true });
1477
+ assert.equal(r.status, 0, r.stderr);
1478
+ assert.match(r.stderr, new RegExp(`invalid value '${bad}' for CODEX_HARD_TIMEOUT`), 'the fallback is loud');
1479
+ assert.match(r.stderr, /^exec posture: .* timeout=3600s$/m, 'the banner prints the built-in default, never the bad value');
1480
+ }
1481
+ });
1482
+ });
@@ -5,9 +5,9 @@
5
5
  // CODEX_SERVICE_TIER BEFORE tier validation (no multiline warning echo, no silent standard-tier
6
6
  // run). Colocated separately — codex-review.test.mjs is red-proof-frozen; standalone harness.
7
7
 
8
- import { describe, it } from 'node:test';
8
+ import { describe, it, after } from 'node:test';
9
9
  import assert from 'node:assert/strict';
10
- import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, chmodSync, existsSync, readdirSync, symlinkSync } from 'node:fs';
10
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, chmodSync, existsSync, readdirSync, symlinkSync, cpSync } from 'node:fs';
11
11
  import { tmpdir } from 'node:os';
12
12
  import { join, dirname, resolve } from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
@@ -46,12 +46,17 @@ const makePathWithout = (root, exclude = []) => {
46
46
  return dir;
47
47
  };
48
48
 
49
- const makeSandbox = () => {
50
- const root = mkdtempSync(join(tmpdir(), 'codex-honesty-'));
49
+ // Farm + sandbox base are READ-ONLY per invocation — built ONCE, shared (a per-run farm rebuild
50
+ // plus a per-test `git init`+commit dominate the wall otherwise).
51
+ const SHARED_ROOT = mkdtempSync(join(tmpdir(), 'codex-honesty-shared-'));
52
+ after(() => rmSync(SHARED_ROOT, { recursive: true, force: true }));
53
+ const FARM = makePathWithout(SHARED_ROOT, ['codex']);
54
+
55
+ const TEMPLATE_ROOT = (() => {
56
+ const root = join(SHARED_ROOT, 'template-root');
51
57
  const bin = join(root, 'bin');
52
58
  mkdirSync(bin, { recursive: true });
53
59
  writeFileSync(join(bin, 'codex'), FAKE_CODEX, { mode: 0o755 });
54
- chmodSync(join(bin, 'codex'), 0o755);
55
60
  const repo = join(root, 'repo');
56
61
  mkdirSync(repo);
57
62
  const g = (...args) => spawnSync('git', args, { cwd: repo, encoding: 'utf8' });
@@ -62,7 +67,15 @@ const makeSandbox = () => {
62
67
  g('add', '-A');
63
68
  g('commit', '-qm', 'base');
64
69
  writeFileSync(join(repo, 'pending.txt'), 'PENDING\n');
65
- return { root, bin, repo };
70
+ return root;
71
+ })();
72
+
73
+ const makeSandbox = () => {
74
+ const root = mkdtempSync(join(tmpdir(), 'codex-honesty-'));
75
+ cpSync(TEMPLATE_ROOT, root, { recursive: true });
76
+ const bin = join(root, 'bin');
77
+ chmodSync(join(bin, 'codex'), 0o755);
78
+ return { root, bin, repo: join(root, 'repo') };
66
79
  };
67
80
 
68
81
  const run = (sb, { args = ['code'], env = {} } = {}) => {
@@ -73,7 +86,7 @@ const run = (sb, { args = ['code'], env = {} } = {}) => {
73
86
  timeout: 30000,
74
87
  env: {
75
88
  HOME: sb.repo,
76
- PATH: `${sb.bin}:${makePathWithout(sb.root, ['codex'])}`,
89
+ PATH: `${sb.bin}:${FARM}`,
77
90
  TMPDIR: process.env.TMPDIR ?? '/tmp',
78
91
  CODEX_HOME: join(sb.root, 'codex-home'),
79
92
  CODEX_FAKE_SENTINEL: sentinel,
@@ -79,12 +79,21 @@ Settings file (KEY=VALUE, parsed never sourced; env wins over file, file wins ov
79
79
  CODEX_HARD_TIMEOUT — hard wall-clock cap, integer seconds 1..86400 (built-in default 1800)
80
80
  CODEX_REVIEW_MAX_TOTAL_BYTES — inline-payload cap, integer bytes 1..100000000 (default 1500000); above it the diff rides via a git-dir temp file
81
81
 
82
+ Notes:
83
+ the review posture banner appends a banner-only timeout=<duration|uncapped> field — exactly the
84
+ duration handed to timeout(1), uncapped when no timeout/gtimeout binary caps the run;
85
+ INFORMATIONAL only: it never enters the receipt posture or the D5 banner↔receipt parity
86
+ quote the posture banner verbatim when labeling this dispatch — the banner is the machine-stated
87
+ posture; a prose re-type drifts
88
+
82
89
  Honesty + posture (D4/D5):
83
90
  a run whose final message carries NO recognized 'Verdict: <ship|revise|rethink>' line — empty or
84
91
  missing output included — exits 4 with NO receipt: a FAILED review to RE-RUN, never a fatal
85
92
  session error. One stderr banner line states the ACTUAL run posture (review posture: model=…
86
- effort=… tier=…) and the receipt records the same posture {model, effort, tier} (tier null on
87
- the standard tier). A posture value carrying control bytes refuses pre-spend in every mode.
93
+ effort=… tier=… timeout=…) and the receipt records the same posture {model, effort, tier} (tier
94
+ null on the standard tier; the timeout field is banner-only never a receipt field). Quote the
95
+ posture banner verbatim when labeling this dispatch. A posture value carrying control bytes
96
+ refuses pre-spend in every mode.
88
97
 
89
98
  Environment: CODEX_REVIEW_SCHEMA=1 (structured JSON findings), CODEX_HARD_TIMEOUT (seconds, default 1800), CODEX_PROBE=1 (throwaway probe only), AW_REVIEW_RECEIPTS (receipt file override).
90
99
  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).
@@ -201,12 +210,62 @@ aw_apply_settings() {
201
210
  }
202
211
  aw_apply_settings
203
212
 
213
+ # --- Effective-timeout resolver (D5 banner honesty; AD-061) --------------------
214
+ # ONE rule, both bridges: the posture banner prints EXACTLY the duration handed to timeout(1) —
215
+ # an integer-seconds value rendered with the `s` suffix, a duration string verbatim — and
216
+ # `timeout=uncapped` when no timeout/gtimeout binary can cap the run; never a fabricated number.
217
+ # The EFFECTIVE value (env included — closing the aw_settings_valid env bypass) is validated by
218
+ # the same per-key rule as the settings file, plus a 7-digit integer-part bound (overflow); an
219
+ # invalid value warns + falls back to the built-in default — a typo never silently masquerades
220
+ # as a cap. AGY_TIMEOUT shares AGY_HARD_TIMEOUT's duration rule (it has no settings-file arm).
221
+ aw_effective_timeout() {
222
+ local key="$1" default="$2" value="${!1:-}" rule="$1" intpart
223
+ [[ "$rule" == "AGY_TIMEOUT" ]] && rule="AGY_HARD_TIMEOUT"
224
+ [[ -n "$value" ]] || { printf '%s' "$default"; return 0; }
225
+ intpart="${value%%[!0-9]*}"
226
+ if ! aw_settings_valid "$rule" "$value" || (( ${#intpart} > 7 )); then
227
+ # %q escapes the raw value so a control byte in it can never forge an extra diagnostic line
228
+ # (the direct agy-run lane has no pre-spend screen — the warning itself must be injection-proof).
229
+ printf "warning: invalid value '%q' for %s — using the built-in default %s.\n" "$value" "$key" "$default" >&2
230
+ printf '%s' "$default"
231
+ return 0
232
+ fi
233
+ printf '%s' "$value"
234
+ }
235
+ aw_timeout_label() {
236
+ local bin="$1" value="$2"
237
+ [[ -n "$bin" ]] || { printf 'uncapped'; return 0; }
238
+ case "$value" in
239
+ *[!0-9]*) printf '%s' "$value" ;;
240
+ *) printf '%ss' "$value" ;;
241
+ esac
242
+ }
243
+ aw_resolve_timeout_bin() {
244
+ local bin dir base
245
+ bin="$(builtin type -P timeout 2>/dev/null || true)"
246
+ [[ -n "$bin" ]] || bin="$(builtin type -P gtimeout 2>/dev/null || true)"
247
+ [[ -n "$bin" ]] || { printf ''; return 0; }
248
+ case "$bin" in
249
+ /*) ;;
250
+ *)
251
+ case "$bin" in
252
+ */*) dir="${bin%/*}"; base="${bin##*/}" ;;
253
+ *) dir="."; base="$bin" ;;
254
+ esac
255
+ dir="$(builtin cd -- "$dir" 2>/dev/null && builtin pwd -P)" || { printf ''; return 0; }
256
+ bin="$dir/$base"
257
+ ;;
258
+ esac
259
+ [[ -f "$bin" && -x "$bin" ]] || { printf ''; return 0; }
260
+ printf '%s' "$bin"
261
+ }
262
+
204
263
  DEFAULT_CODEX_MODEL="gpt-5.6-sol"
205
264
  DEFAULT_CODEX_EFFORT="xhigh"
206
265
  # Review-receipt identity (AD-038). AW_BRIDGE_VERSION mirrors this bridge's SKILL.md/capability.json
207
266
  # version (drift-guarded by codex-review.test.mjs against capability.json).
208
267
  AW_RECEIPT_BACKEND="codex"
209
- AW_BRIDGE_VERSION="3.0.0"
268
+ AW_BRIDGE_VERSION="3.1.0"
210
269
  CODEX_MODEL="${CODEX_MODEL:-$DEFAULT_CODEX_MODEL}"
211
270
  CODEX_EFFORT="${CODEX_EFFORT:-$DEFAULT_CODEX_EFFORT}"
212
271
  # Generous hard cap for a slow xhigh review (subscription latency varies).
@@ -225,12 +284,13 @@ CODEX_SERVICE_TIER="${CODEX_SERVICE_TIER:-}"
225
284
  # D5 pre-spend control-byte screen — BEFORE tier validation (a malformed value is not a policy
226
285
  # question; validating first would echo the hostile value into a multiline warning and then run
227
286
  # on the standard tier, defeating the stated refusal). Screens the RAW model/effort/tier.
228
- for _posture_pair in "CODEX_MODEL=$CODEX_MODEL" "CODEX_EFFORT=$CODEX_EFFORT" "CODEX_SERVICE_TIER=$CODEX_SERVICE_TIER"; do
229
- if [[ "${_posture_pair#*=}" == *[$'\x01'-$'\x1f']* ]]; then
287
+ 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
288
+ if [[ "${_posture_pair#*=}" == *[$'\x01'-$'\x1f'$'\x7f']* ]]; then
230
289
  echo "error: ${_posture_pair%%=*} contains control bytes — fix the setting (env or bridge-settings.conf) and re-run." >&2
231
290
  exit 2
232
291
  fi
233
292
  done
293
+ CODEX_HARD_TIMEOUT="$(aw_effective_timeout CODEX_HARD_TIMEOUT 1800)"
234
294
  if [[ -n "$CODEX_SERVICE_TIER" ]] && ! aw_settings_valid CODEX_SERVICE_TIER "$CODEX_SERVICE_TIER"; then
235
295
  echo "warning: CODEX_SERVICE_TIER='$CODEX_SERVICE_TIER' is not a supported service tier ('priority') — running on the standard tier." >&2
236
296
  CODEX_SERVICE_TIER=""
@@ -243,8 +303,15 @@ CHATGPT_LOGIN_GUARD="Logged in using ChatGPT"
243
303
 
244
304
  # --- D5 banner (one line, the ACTUAL run posture) -------------------------------------------
245
305
  # The control-byte screen already ran above (BEFORE tier validation); the banner states the
246
- # EFFECTIVE posture (post tier validation) and the receipt records the same fields.
247
- echo "review posture: model=$CODEX_MODEL effort=$CODEX_EFFORT tier=${CODEX_SERVICE_TIER:-standard}" >&2
306
+ # EFFECTIVE posture (post tier validation) and the receipt records the same fields — except
307
+ # `timeout`, a BANNER-ONLY informational field (AD-061): it prints exactly the duration handed
308
+ # to timeout(1), or `uncapped` without a capping binary, and never enters the receipt.
309
+ # aw_resolve_timeout_bin: builtin type -P (an exported function can shadow neither `timeout` nor
310
+ # `type` itself), normalized to an ABSOLUTE path fail-closed; the dispatch below reuses this SAME
311
+ # resolved path (banner and run never make independent conclusions).
312
+ timeout_bin="$(aw_resolve_timeout_bin)"
313
+ aw_timeout_banner="$(aw_timeout_label "$timeout_bin" "$CODEX_HARD_TIMEOUT")"
314
+ echo "review posture: model=$CODEX_MODEL effort=$CODEX_EFFORT tier=${CODEX_SERVICE_TIER:-standard} timeout=$aw_timeout_banner" >&2
248
315
 
249
316
  # --- Quality-first guard: refuse a silent model/effort downgrade ---------------
250
317
  # A relaxed run is RECORDED, not just warned about: the receipt carries probe:true so the kit's
@@ -663,12 +730,8 @@ fence_env=(env
663
730
  CODEX_HOME="$real_codex_home")
664
731
 
665
732
  # --- Hard wall-clock cap via timeout(1) (gtimeout on macOS) -------------------
666
- timeout_bin=""
667
- if command -v timeout >/dev/null 2>&1; then
668
- timeout_bin="timeout"
669
- elif command -v gtimeout >/dev/null 2>&1; then
670
- timeout_bin="gtimeout"
671
- fi
733
+ # $timeout_bin was resolved ONCE (absolute path, type -P) at the banner emit above — the run
734
+ # reuses the exact binary the banner reported.
672
735
  if [[ -z "$timeout_bin" ]]; then
673
736
  echo "warning: no 'timeout'/'gtimeout' on PATH — running codex WITHOUT a hard wall-clock cap" >&2
674
737
  echo " (install coreutils to enable CODEX_HARD_TIMEOUT=$CODEX_HARD_TIMEOUT)." >&2