@sabaiway/agent-workflow-kit 2.1.0 → 3.1.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.
- package/CHANGELOG.md +94 -1
- package/README.md +7 -5
- package/SKILL.md +16 -8
- package/bin/install.mjs +11 -2
- package/bridges/antigravity-cli-bridge/SKILL.md +8 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +163 -0
- package/bridges/antigravity-cli-bridge/bin/agy-review-model-screen.test.mjs +76 -0
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +80 -15
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +171 -22
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +59 -41
- package/bridges/antigravity-cli-bridge/capability.json +3 -2
- package/bridges/codex-cli-bridge/SKILL.md +8 -1
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +67 -22
- package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +156 -0
- package/bridges/codex-cli-bridge/bin/codex-review.sh +71 -10
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +158 -27
- package/bridges/codex-cli-bridge/capability.json +3 -2
- package/capability.json +1 -1
- package/migrations/3.0.0-hardened-core-loop.md +44 -0
- package/migrations/README.md +1 -1
- package/package.json +2 -2
- package/references/hooks/gate-approve.mjs +1 -1
- package/references/modes/bootstrap.md +3 -3
- package/references/modes/commit-guard.md +19 -0
- package/references/modes/core-evidence.md +15 -0
- package/references/modes/coverage-check.md +12 -0
- package/references/modes/doc-parity.md +7 -6
- package/references/modes/gates.md +7 -6
- package/references/modes/grounding.md +1 -2
- package/references/modes/hook.md +1 -1
- package/references/modes/recommendations.md +1 -0
- package/references/modes/review-state.md +3 -3
- package/references/modes/upgrade.md +6 -6
- package/references/modes/velocity.md +2 -2
- package/references/modes/worktrees.md +120 -0
- package/references/scripts/archive-decisions.mjs +3 -3
- package/references/scripts/archive-decisions.test.mjs +5 -5
- package/references/scripts/check-docs-size-cli.test.mjs +41 -0
- package/references/scripts/check-docs-size.mjs +46 -29
- package/references/scripts/install-git-hooks-repo-exec.test.mjs +82 -0
- package/references/scripts/install-git-hooks.mjs +90 -18
- package/references/scripts/install-git-hooks.test.mjs +102 -0
- package/references/scripts/migrate-gates-branches.test.mjs +157 -0
- package/references/scripts/migrate-gates.mjs +395 -0
- package/references/scripts/migrate-gates.test.mjs +284 -0
- package/references/templates/agent_rules.md +2 -2
- package/tools/ack-write.mjs +1 -1
- package/tools/atomic-write.mjs +2 -2
- package/tools/autonomy-config.mjs +1 -1
- package/tools/autonomy-doctor.mjs +2 -2
- package/tools/autonomy-write.mjs +1 -1
- package/tools/bridge-settings-read.mjs +1 -1
- package/tools/bridge-settings.mjs +1 -1
- package/tools/changed-surface.mjs +16 -77
- package/tools/cheap-agents.mjs +2 -2
- package/tools/commands.mjs +26 -10
- package/tools/commit-guard.mjs +167 -0
- package/tools/core-evidence.mjs +914 -0
- package/tools/coverage-check.mjs +260 -0
- package/tools/delegation.mjs +1 -1
- package/tools/detect-backends.mjs +3 -3
- package/tools/doc-parity.mjs +11 -27
- package/tools/engine-source.mjs +1 -1
- package/tools/family-members.mjs +1 -1
- package/tools/family-registry.mjs +1 -1
- package/tools/fs-safe.mjs +1 -1
- package/tools/gate-hook.mjs +1 -1
- package/tools/{seed-gates.mjs → gates-init.mjs} +58 -52
- package/tools/grounding.mjs +19 -96
- package/tools/hide-footprint.mjs +1 -1
- package/tools/inject-methodology.mjs +72 -41
- package/tools/known-footprint.mjs +1 -1
- package/tools/labels.mjs +1 -1
- package/tools/lcov.mjs +6 -10
- package/tools/lens-region.mjs +1 -1
- package/tools/manifest/validate.mjs +19 -0
- package/tools/migrate-adr-store.mjs +5 -5
- package/tools/orchestration-config.mjs +1 -1
- package/tools/orchestration-write.mjs +1 -1
- package/tools/presentation.mjs +1 -1
- package/tools/procedures.mjs +3 -3
- package/tools/recipes.mjs +62 -11
- package/tools/recommendations.mjs +97 -14
- package/tools/release-scan.mjs +10 -3
- package/tools/renderers.mjs +1 -1
- package/tools/review-state.mjs +291 -343
- package/tools/run-gates.mjs +216 -92
- package/tools/sandbox-masks.mjs +14 -14
- package/tools/semver-lite.mjs +1 -1
- package/tools/set-autonomy.mjs +1 -1
- package/tools/set-recipe.mjs +2 -2
- package/tools/setup-backends.mjs +1 -1
- package/tools/surface.mjs +1 -1
- package/tools/uninstall.mjs +1 -1
- package/tools/velocity-profile.mjs +9 -9
- package/tools/view-model.mjs +1 -1
- package/tools/worktrees.mjs +2292 -0
- package/references/modes/fold-completeness.md +0 -30
- package/references/modes/review-ledger.md +0 -34
- package/references/templates/verification-profile.json +0 -10
- package/tools/fold-completeness-run.mjs +0 -1120
- package/tools/fold-completeness.mjs +0 -672
- package/tools/review-ledger-core.mjs +0 -428
- package/tools/review-ledger-write.mjs +0 -647
- package/tools/review-ledger.mjs +0 -630
- package/tools/sarif.mjs +0 -52
- package/tools/verification-profile.mjs +0 -219
|
@@ -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
|
-
|
|
35
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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:
|
|
206
|
+
env: { HOME: home, PATH: FARM },
|
|
189
207
|
encoding: 'utf8',
|
|
190
208
|
timeout: 15000,
|
|
191
209
|
});
|
|
@@ -226,77 +244,77 @@ 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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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('a DIRECTORY at the settings path warns loudly and falls back to built-ins (no crash)', async () => {
|
|
297
315
|
const home = makeSandbox(RECORDING_STUB);
|
|
298
316
|
mkdirSync(join(home, '.config', 'agent-workflow', 'bridge-settings.conf'), { recursive: true });
|
|
299
|
-
const r =
|
|
317
|
+
const r = await runWrapperAsync(home, { AGY_MODEL: '' });
|
|
300
318
|
rmSync(home, { recursive: true, force: true });
|
|
301
319
|
assert.equal(r.status, 0, `a directory must degrade honestly, not kill the run: ${r.stderr}`);
|
|
302
320
|
assert.match(r.stderr, /unreadable or not a regular file/);
|
|
@@ -304,20 +322,20 @@ describe('agy.sh — bridge settings file (bridges 2.3.0)', () => {
|
|
|
304
322
|
assert.match(r.stdout, /OK reply/);
|
|
305
323
|
});
|
|
306
324
|
|
|
307
|
-
it("another bridge's valid key is skipped silently (and never applied)", () => {
|
|
325
|
+
it("another bridge's valid key is skipped silently (and never applied)", async () => {
|
|
308
326
|
const home = makeSandbox('#!/usr/bin/env bash\nsleep 3\necho "OK reply"\n');
|
|
309
327
|
writeSettings(home, 'CODEX_SERVICE_TIER=priority\nCODEX_HARD_TIMEOUT=2\n');
|
|
310
|
-
const r =
|
|
328
|
+
const r = await runWrapperAsync(home, { AGY_MODEL: '' });
|
|
311
329
|
rmSync(home, { recursive: true, force: true });
|
|
312
330
|
assert.equal(r.status, 0, `a codex key must not cap an agy run: ${r.stderr}`);
|
|
313
331
|
assert.doesNotMatch(r.stderr, /bridge settings/, 'a recognized non-applied key earns NO warning');
|
|
314
332
|
assert.match(r.stdout, /OK reply/);
|
|
315
333
|
});
|
|
316
334
|
|
|
317
|
-
it('a truly unknown key warns ONCE naming the file; the run is unaffected', () => {
|
|
335
|
+
it('a truly unknown key warns ONCE naming the file; the run is unaffected', async () => {
|
|
318
336
|
const home = makeSandbox(RECORDING_STUB);
|
|
319
337
|
writeSettings(home, 'TOTALLY_UNKNOWN=1\nTOTALLY_UNKNOWN=2\n');
|
|
320
|
-
const r =
|
|
338
|
+
const r = await runWrapperAsync(home, { AGY_MODEL: '' });
|
|
321
339
|
rmSync(home, { recursive: true, force: true });
|
|
322
340
|
assert.equal(r.status, 0, r.stderr);
|
|
323
341
|
const warns = r.stderr.match(/unknown key 'TOTALLY_UNKNOWN'/g) ?? [];
|
|
@@ -326,10 +344,10 @@ describe('agy.sh — bridge settings file (bridges 2.3.0)', () => {
|
|
|
326
344
|
assert.match(r.stdout, /OK reply/);
|
|
327
345
|
});
|
|
328
346
|
|
|
329
|
-
it('malformed lines warn and are ignored; comments and blank lines are silent', () => {
|
|
347
|
+
it('malformed lines warn and are ignored; comments and blank lines are silent', async () => {
|
|
330
348
|
const home = makeSandbox(RECORDING_STUB);
|
|
331
349
|
writeSettings(home, '# a comment\n\nNOT A KEY VALUE LINE\n');
|
|
332
|
-
const r =
|
|
350
|
+
const r = await runWrapperAsync(home, { AGY_MODEL: '' });
|
|
333
351
|
rmSync(home, { recursive: true, force: true });
|
|
334
352
|
assert.equal(r.status, 0, r.stderr);
|
|
335
353
|
const malformed = r.stderr.match(/malformed line/g) ?? [];
|
|
@@ -337,22 +355,22 @@ describe('agy.sh — bridge settings file (bridges 2.3.0)', () => {
|
|
|
337
355
|
assert.match(r.stdout, /OK reply/);
|
|
338
356
|
});
|
|
339
357
|
|
|
340
|
-
it('an existing-but-unreadable file warns loudly and falls back to built-ins', { skip: isRoot }, () => {
|
|
358
|
+
it('an existing-but-unreadable file warns loudly and falls back to built-ins', { skip: isRoot }, async () => {
|
|
341
359
|
const home = makeSandbox(RECORDING_STUB);
|
|
342
360
|
const file = writeSettings(home, 'AGY_HARD_TIMEOUT=2s\n');
|
|
343
361
|
chmodSync(file, 0o000);
|
|
344
|
-
const r =
|
|
362
|
+
const r = await runWrapperAsync(home, { AGY_MODEL: '' });
|
|
345
363
|
rmSync(home, { recursive: true, force: true });
|
|
346
364
|
assert.equal(r.status, 0, r.stderr);
|
|
347
365
|
assert.match(r.stderr, /unreadable/);
|
|
348
366
|
assert.match(r.stdout, /OK reply/);
|
|
349
367
|
});
|
|
350
368
|
|
|
351
|
-
it('a settings line can NEVER execute code (command-substitution payload inert)', () => {
|
|
369
|
+
it('a settings line can NEVER execute code (command-substitution payload inert)', async () => {
|
|
352
370
|
const home = makeSandbox(RECORDING_STUB);
|
|
353
371
|
const pwned = join(home, 'pwned');
|
|
354
372
|
writeSettings(home, `AGY_HARD_TIMEOUT=$(touch ${pwned})\nEVIL_KEY=\`touch ${pwned}2\`\n`);
|
|
355
|
-
const r =
|
|
373
|
+
const r = await runWrapperAsync(home, { AGY_MODEL: '' });
|
|
356
374
|
const executed = existsSync(pwned) || existsSync(`${pwned}2`);
|
|
357
375
|
rmSync(home, { recursive: true, force: true });
|
|
358
376
|
assert.equal(r.status, 0, r.stderr);
|
|
@@ -360,9 +378,9 @@ describe('agy.sh — bridge settings file (bridges 2.3.0)', () => {
|
|
|
360
378
|
assert.match(r.stdout, /OK reply/);
|
|
361
379
|
});
|
|
362
380
|
|
|
363
|
-
it('no file → byte-identical behaviour to today (no settings chatter)', () => {
|
|
381
|
+
it('no file → byte-identical behaviour to today (no settings chatter)', async () => {
|
|
364
382
|
const home = makeSandbox(RECORDING_STUB);
|
|
365
|
-
const r =
|
|
383
|
+
const r = await runWrapperAsync(home, { AGY_MODEL: '' });
|
|
366
384
|
rmSync(home, { recursive: true, force: true });
|
|
367
385
|
assert.equal(r.status, 0, r.stderr);
|
|
368
386
|
assert.doesNotMatch(r.stderr, /bridge settings/);
|
|
@@ -398,7 +416,7 @@ describe('agy.sh — settings surface ⟷ manifest (D6, manifest-pinned)', () =>
|
|
|
398
416
|
const runHelpText = () => {
|
|
399
417
|
const home = mkdtempSync(join(tmpdir(), 'agy-settings-help-'));
|
|
400
418
|
const r = spawnSync('bash', [WRAPPER, '--help'], {
|
|
401
|
-
env: { HOME: home, PATH:
|
|
419
|
+
env: { HOME: home, PATH: FARM },
|
|
402
420
|
encoding: 'utf8',
|
|
403
421
|
timeout: 15000,
|
|
404
422
|
});
|
|
@@ -475,7 +493,7 @@ describe('agy.sh — mode catalog ⟷ wrapper reality (the contract-free raw mod
|
|
|
475
493
|
const helpText = () => {
|
|
476
494
|
const home = mkdtempSync(join(tmpdir(), 'agy-catalog-help-'));
|
|
477
495
|
const r = spawnSync('bash', [WRAPPER, '--help'], {
|
|
478
|
-
env: { HOME: home, PATH:
|
|
496
|
+
env: { HOME: home, PATH: FARM },
|
|
479
497
|
encoding: 'utf8',
|
|
480
498
|
timeout: 15000,
|
|
481
499
|
});
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
"schema": 1,
|
|
4
4
|
"name": "antigravity-cli-bridge",
|
|
5
5
|
"kind": "execution-backend",
|
|
6
|
-
"version": "
|
|
6
|
+
"version": "4.0.0",
|
|
7
7
|
"provides": ["review", "probe"],
|
|
8
|
+
"posture": { "model": "Gemini 3.1 Pro (High)" },
|
|
8
9
|
"roles": {
|
|
9
10
|
"review": {
|
|
10
11
|
"cmd": "agy-review",
|
|
@@ -29,7 +30,7 @@
|
|
|
29
30
|
"agy-review --continue [--decided @f] [--focus \"…\"]",
|
|
30
31
|
"agy-review --conversation <id> [--decided @f] [--focus \"…\"]"
|
|
31
32
|
],
|
|
32
|
-
"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); a write failure warns, never fails the review",
|
|
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",
|
|
33
34
|
"notes": [
|
|
34
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
36
|
]
|
|
@@ -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: '
|
|
5
|
+
version: '3.0.0'
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# codex-cli-bridge
|
|
@@ -96,6 +96,13 @@ codex-review code # review the current working-tr
|
|
|
96
96
|
codex-review code "focus on the new reducer" # review with extra focus
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
+
**Honesty + posture (D4/D5):** a run whose final message has no recognized
|
|
100
|
+
`Verdict: <ship|revise|rethink>` line — empty or missing output included — **exits 4 with NO
|
|
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.
|
|
105
|
+
|
|
99
106
|
`codex exec` is headless: there is **no TTY**, so `approval_policy=never` — anything needing
|
|
100
107
|
escalation is refused and reported, never interactively approved. The wrappers capture only codex's
|
|
101
108
|
**final message** (`-o`; the JSON event stream + reasoning go to a discarded trace), so output is
|
|
@@ -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');
|
|
@@ -55,13 +55,16 @@ const FAKE_CODEX = [
|
|
|
55
55
|
'',
|
|
56
56
|
].join('\n');
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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}:${
|
|
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}:${
|
|
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 =
|
|
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}:${
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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/);
|