@sabaiway/agent-workflow-kit 10.1.0 → 10.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
4
4
  is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
5
5
  every `migrations/<version>-<slug>.md` newer than it, in semver order.
6
6
 
7
+ ## 10.2.0 — a review can no longer pass a change it called broken (AD-122)
8
+
9
+ Ships antigravity-cli-bridge **5.4.0** in the bundled bridge tree. A consumer recorded a clean
10
+ `SHIP WITH NITS` receipt over a review body that named a correctness bug and a security violation
11
+ under `### Blocking` — the wrapper read the verdict line and nothing read the section. Now a
12
+ ship-class verdict beside a numbered Blocking item is a verdict-body contradiction and takes the
13
+ existing failed-review arm: exit 4, no receipt, both halves named, re-run the review. `REWORK` is
14
+ never refused — a veto needs no corroboration, only a pass does — an absent Blocking section is not
15
+ a contradiction, and a repeated heading terminates the scan, so exactly one section is ever judged.
16
+
17
+ The guard was written to a contract first (`docs/ai/specs/bridges/agy-review-verdict.md`, eight
18
+ scenarios), and the bridge's test suite was split in the same change: the shared fixture surface
19
+ moved to `bin/agy-review-harness.test.mjs`, the wrapper suite shrank to 2893 lines with all 213
20
+ existing arms green, and the new contract got its own topic file. Both new files ship with the
21
+ bridge tree, so the tarball count pin moves 257 → 259.
22
+
7
23
  ## 10.1.0 — no work without a specification, and a ratchet that does not trust its own file (AD-121)
8
24
 
9
25
  A shipped tool that no LIVE contract claims is now a REFUSAL, not a preference. `tools/spec-coverage.mjs`
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-kit
3
3
  description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '10.1.0'
6
+ version: '10.2.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
@@ -2,7 +2,7 @@
2
2
  name: antigravity-cli-bridge
3
3
  description: Delegate work to Google's Antigravity CLI (`agy`) — the successor to Gemini CLI — to reach Gemini, Claude, and GPT-OSS models under a Google AI Pro/Ultra subscription from the terminal. Use when the user wants to run a headless `agy` prompt, hand a focused task or second-opinion review to `agy`, install or authenticate Antigravity CLI, check or economise its quota/models, bridge project context into `agy`, set up a second delegated-execution backend beside Codex, or troubleshoot `agy` flags, models, auth, conversations, or its headless behaviour.
4
4
  metadata:
5
- version: '5.3.0'
5
+ version: '5.4.0'
6
6
  ---
7
7
 
8
8
  # antigravity-cli-bridge
@@ -163,7 +163,9 @@ billed turn restating the model's own prose (16,585 → 33,446 tokens on a match
163
163
 
164
164
  **Honesty + posture (D4/D5):** a run with no recognized `### Verdict` section — empty output
165
165
  included — **exits 4 with NO receipt**: treat it as a *failed review to re-run*, never a fatal
166
- session error. One stderr banner states the actual posture (`review posture: model=… timeout=…`)
166
+ session error. The verdict vocabulary is closed (SHIP / SHIP WITH NITS / REWORK), and a
167
+ ship-class verdict arriving beside a numbered `### Blocking` finding is a verdict-body
168
+ contradiction: the same exit 4, NO receipt, both halves named. One stderr banner states the actual posture (`review posture: model=… timeout=…`)
167
169
  and the receipt records the same `posture {model}`; an attesting review with `AGY_MODEL` explicitly
168
170
  emptied refuses pre-spend (`AGY_PROBE=1` exempt), and control bytes in a model string refuse
169
171
  pre-spend in every mode. The `timeout=` field is **banner-only** (exactly the duration `agy-run`
@@ -0,0 +1,288 @@
1
+ // Shared harness for the agy-review wrapper suites. PORTING TRAP: agy takes the prompt as the
2
+ // `-p` ARGV value, NOT stdin — the fake captures the -p value from argv.
3
+ import assert from 'node:assert/strict';
4
+ import { after } from 'node:test';
5
+ import {
6
+ mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync, readFileSync,
7
+ existsSync, readdirSync, symlinkSync, cpSync,
8
+ } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join, dirname, resolve } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { spawnSync, execFile } from 'node:child_process';
13
+
14
+ const HERE = dirname(fileURLToPath(import.meta.url));
15
+ export const WRAPPER = join(HERE, 'agy-review.sh');
16
+
17
+ const FAKE_ENVELOPE_ENCODER = [
18
+ 'const text = require("node:fs").readFileSync(0, "utf8");',
19
+ 'const [cid, status, shape] = process.argv.slice(1);',
20
+ 'const envelope = { conversation_id: cid, status, response: text, duration_seconds: 1.5, num_turns: 1,',
21
+ ' usage: { input_tokens: 10, output_tokens: 5, thinking_tokens: 0, cache_read_tokens: 0, total_tokens: 15 } };',
22
+ 'if (shape === "missing") delete envelope.conversation_id;',
23
+ 'if (shape === "number") envelope.conversation_id = 42;',
24
+ 'process.stdout.write(`${JSON.stringify(envelope)}\\n`);',
25
+ ].join('\n');
26
+
27
+ export const FAKE_AGY = [
28
+ '#!/usr/bin/env bash',
29
+ 'set -u',
30
+ // --help / --version answer BEFORE any capture file is touched — a probe is never a paid dispatch.
31
+ 'case "${1:-}" in',
32
+ ' --help|-h)',
33
+ ' if [[ -n "${AGY_FAKE_HELP_EXIT:-}" ]]; then echo "fake agy: help unavailable" >&2; exit "$AGY_FAKE_HELP_EXIT"; fi',
34
+ ' for f in --output-format --json-schema --disable-slash-commands --effort --mode; do',
35
+ ' if [[ "$f" == "${AGY_FAKE_HELP_OMIT:-}" ]]; then continue; fi',
36
+ ' printf " %s fake capability line\\n" "$f"',
37
+ ' done',
38
+ ' if [[ -n "${AGY_FAKE_HELP_EXTRA:-}" ]]; then printf "%s\\n" "$AGY_FAKE_HELP_EXTRA"; fi',
39
+ ' exit 0 ;;',
40
+ ' --version) printf "%s\\n" "${AGY_FAKE_VERSION:-1.1.13}"; exit 0 ;;',
41
+ 'esac',
42
+ ': "${AGY_FAKE_ARGV:=/dev/null}"',
43
+ ': "${AGY_FAKE_ENV:=/dev/null}"',
44
+ ': "${AGY_FAKE_PROMPT:=/dev/null}"',
45
+ ': "${AGY_FAKE_SENTINEL:=/dev/null}"',
46
+ 'printf invoked > "$AGY_FAKE_SENTINEL"',
47
+ 'printf "%s" "$PWD" > "${AGY_FAKE_CWD:-/dev/null}"',
48
+ '{ for a in "$@"; do printf "%s\\n" "$a"; done; } > "$AGY_FAKE_ARGV"',
49
+ '{ echo "FOO_API_KEY=${FOO_API_KEY:-<unset>}"; echo "ANTIGRAVITY_API_KEY=${ANTIGRAVITY_API_KEY:-<unset>}"; } > "$AGY_FAKE_ENV"',
50
+ 'prompt=""',
51
+ 'prev=""; for a in "$@"; do if [[ "$prev" == "-p" ]]; then prompt="$a"; printf "%s" "$a" > "$AGY_FAKE_PROMPT"; fi; prev="$a"; done',
52
+ 'aw_fmt=""',
53
+ 'prev=""; for a in "$@"; do if [[ "$prev" == "--output-format" ]]; then aw_fmt="$a"; fi; prev="$a"; done',
54
+ // AGY_FAKE_BAD_TURN scopes the transport-breaking knobs to ONE turn; AGY_FAKE_RAW_STDOUT bypasses
55
+ // the envelope encoder entirely.
56
+ 'aw_fake_emit() {',
57
+ ' local body="$1" aw_status="${AGY_FAKE_STATUS:-SUCCESS}" aw_raw=""',
58
+ ' if [[ -n "${AGY_FAKE_RAW_STDOUT+x}" ]]; then aw_raw=1; fi',
59
+ ' if [[ -n "${AGY_FAKE_BAD_TURN:-}" && "$turn" != "${AGY_FAKE_BAD_TURN}" ]]; then aw_status="SUCCESS"; aw_raw=""; fi',
60
+ ' if [[ -n "${AGY_FAKE_STDERR:-}" ]]; then printf "%s\\n" "$AGY_FAKE_STDERR" >&2; fi',
61
+ ' if [[ -n "$aw_raw" ]]; then printf "%s" "$AGY_FAKE_RAW_STDOUT"; return 0; fi',
62
+ ' if [[ "$aw_fmt" != "json" ]]; then printf "%s\\n" "$body"; return 0; fi',
63
+ ` printf "%s\\n" "$body" | node -e '${FAKE_ENVELOPE_ENCODER}' \\`,
64
+ ' "${AGY_FAKE_CONV_ID:-11111111-2222-3333-4444-555555555555}" "$aw_status" "${AGY_FAKE_CONV_SHAPE:-ok}"',
65
+ '}',
66
+ 'prev=""; for a in "$@"; do',
67
+ ' if [[ "$prev" == "--add-dir" ]]; then',
68
+ ' printf "%s" "$a" > "${AGY_FAKE_ADDDIR:-/dev/null}"',
69
+ ' stat -c "%a" "$a" > "${AGY_FAKE_ADDDIR_MODE:-/dev/null}" 2>/dev/null || true',
70
+ ' art="$a/precomputed-change-set"',
71
+ ' if [[ -f "$art" ]]; then stat -c "%a" "$art" > "${AGY_FAKE_ARTIFACT_MODE:-/dev/null}" 2>/dev/null || true; cp "$art" "${AGY_FAKE_ARTIFACT_COPY:-/dev/null}" 2>/dev/null || true; fi',
72
+ ' fi; prev="$a"',
73
+ 'done',
74
+ 'if [[ -n "${AGY_FAKE_SLEEP:-}" ]]; then sleep "$AGY_FAKE_SLEEP"; fi',
75
+ // The fed lane needs a PER-TURN record: each invocation writes prompt/argv to "<file>.<turn>".
76
+ 'turn=1',
77
+ 'if [[ -n "${AGY_FAKE_TURNS:-}" ]]; then',
78
+ ' if [[ -s "$AGY_FAKE_TURNS" ]]; then turn=$(( $(cat "$AGY_FAKE_TURNS") + 1 )); fi',
79
+ ' printf "%s" "$turn" > "$AGY_FAKE_TURNS"',
80
+ ' printf "%s" "$prompt" > "${AGY_FAKE_PROMPT}.$turn"',
81
+ ' { for a in "$@"; do printf "%s\\n" "$a"; done; } > "${AGY_FAKE_ARGV}.$turn"',
82
+ 'fi',
83
+ 'if [[ -n "${AGY_FAKE_FAIL_TURN:-}" && "$turn" == "${AGY_FAKE_FAIL_TURN}" ]]; then',
84
+ ' printf "FAKE_TURN_FAILURE\\n" >&2; exit 3',
85
+ 'fi',
86
+ // A FEED turn deliberately misbehaves (a premature verdict) so the isolation invariant is proven
87
+ // against the worst case.
88
+ 'if [[ -z "${AGY_FAKE_OUTPUT+x}" && "$prompt" == *"--- BEGIN CHANGE-SET PART "* && "$prompt" != *"Requested addresses"* ]]; then',
89
+ ' aw_fake_emit "$(printf "PREMATURE_FEED_CHATTER\\n### Verdict\\nREWORK")"; exit 0',
90
+ 'fi',
91
+ // The FINAL turn answers the delivery-proof request honestly: by reading the bodies it was fed.
92
+ 'if [[ -z "${AGY_FAKE_OUTPUT+x}" && "$prompt" == *"Requested addresses"* ]]; then',
93
+ ' req="$(printf "%s" "$prompt" | awk "/^Requested addresses/{f=1; next} f && /^###/{exit} f{print}")"',
94
+ ' entries=()',
95
+ ' mapfile -t _items <<< "$req"',
96
+ ' for _it in "${_items[@]}"; do',
97
+ ' [[ -n "$_it" ]] || continue',
98
+ ' k="$(printf "%s" "$_it" | awk "{print \\$2}")"; l="$(printf "%s" "$_it" | awk "{print \\$4}")"',
99
+ ' src="$k"',
100
+ ' if [[ "${AGY_FAKE_PROOF_DUP:-}" == "1" ]]; then src=1; fi',
101
+ ' if [[ "${AGY_FAKE_PROOF_OMIT:-}" == "$k" ]]; then continue; fi',
102
+ ' body="$(awk -v want="$l" "f && /^--- END CHANGE-SET PART /{exit} f{c++; if (c==want) {print; exit}} /^--- BEGIN CHANGE-SET PART /{f=1}" "${AGY_FAKE_PROMPT}.$src")"',
103
+ ' if [[ "${AGY_FAKE_PROOF_CORRUPT:-}" == "$k" ]]; then body="${body}X"; fi',
104
+ ' entry="$(printf "part %s line %s: %s" "$k" "$l" "$body")"',
105
+ ' if [[ "${AGY_FAKE_PROOF_BULLET:-}" == "1" ]]; then entry="- $entry"; fi',
106
+ ' if [[ "${AGY_FAKE_PROOF_NESTED:-}" == "$k" ]]; then entry="note: I believe $entry"; fi',
107
+ ' if [[ "${AGY_FAKE_PROOF_PAD:-}" == "1" ]]; then entry="$(printf "part %02d line %04d: %s" "$k" "$l" "$body")"; fi',
108
+ ' if [[ "${AGY_FAKE_PROOF_CASE:-}" == "1" ]]; then entry="$(printf "Part %s Line %s: %s" "$k" "$l" "$body")"; fi',
109
+ ' if [[ "${AGY_FAKE_PROOF_HUGE:-}" == "$k" ]]; then entry="$(printf "part %s line 99999999999999999999: %s" "$k" "$body")"; fi',
110
+ ' entries+=("$entry")',
111
+ ' if [[ "${AGY_FAKE_PROOF_TWICE:-}" == "1" ]]; then entries+=("$entry"); fi',
112
+ ' done',
113
+ ' if [[ "${AGY_FAKE_PROOF_EXTRA:-}" == "1" ]]; then entries+=("part 99 line 1: an address nobody asked for"); fi',
114
+ ' if [[ "${AGY_FAKE_PROOF_HUGE_EXTRA:-}" == "1" ]]; then entries+=("part 99999999999999999999 line 1: an invented giant address"); fi',
115
+ ' out="$( {',
116
+ ' if [[ "${AGY_FAKE_PROOF_LATE:-}" == "1" ]]; then printf "### Verdict\\nSHIP\\n"; fi',
117
+ ' if [[ "${AGY_FAKE_PROOF_CASE:-}" == "1" ]]; then printf "### Delivery Proof\\n"; else printf "### Delivery proof\\n"; fi',
118
+ ' if [[ "${AGY_FAKE_PROOF_OUTSIDE:-}" == "1" ]]; then',
119
+ ' printf "(nothing here)\\n### Verdict\\nSHIP\\n"',
120
+ ' if (( ${#entries[@]} > 0 )); then printf "%s\\n" "${entries[@]}"; fi',
121
+ ' else',
122
+ ' if (( ${#entries[@]} > 0 )); then printf "%s\\n" "${entries[@]}"; fi',
123
+ ' printf "### Verdict\\nSHIP\\n"',
124
+ ' fi',
125
+ ' if [[ -n "${AGY_FAKE_FINAL_APPEND:-}" ]]; then printf "%s\\n" "$AGY_FAKE_FINAL_APPEND"; fi',
126
+ ' } )"',
127
+ ' aw_fake_emit "$out"',
128
+ ' exit 0',
129
+ 'fi',
130
+ 'if [[ -z "${AGY_FAKE_OUTPUT+x}" ]]; then aw_fake_emit "$(printf "FAKE_AGY_REVIEW_OUTPUT\\n### Verdict\\nSHIP")"; else aw_fake_emit "$AGY_FAKE_OUTPUT"; fi',
131
+ 'exit "${AGY_FAKE_EXIT:-0}"',
132
+ '',
133
+ ].join('\n');
134
+
135
+ // A PATH whose entries are symlinks to the real PATH binaries EXCEPT the excluded names.
136
+ const makePathWithout = (root, exclude = []) => {
137
+ const skip = new Set(exclude);
138
+ const dir = mkdtempSync(join(root, 'nobin-'));
139
+ for (const d of (process.env.PATH || '').split(':').filter(Boolean)) {
140
+ let names;
141
+ try { names = readdirSync(d); } catch { continue; }
142
+ for (const name of names) {
143
+ if (skip.has(name)) continue;
144
+ const link = join(dir, name);
145
+ if (existsSync(link)) continue;
146
+ try { symlinkSync(resolve(d, name), link); } catch { /* dup / race — ignore */ }
147
+ }
148
+ }
149
+ return dir;
150
+ };
151
+
152
+ // Farms and the sandbox template are read-only per invocation, so both are built ONCE and shared.
153
+ const SHARED_ROOT = mkdtempSync(join(tmpdir(), 'agy-review-shared-'));
154
+ after(() => rmSync(SHARED_ROOT, { recursive: true, force: true }));
155
+ const farms = new Map();
156
+ export const farmFor = (exclude) => {
157
+ const key = exclude.join('|');
158
+ if (!farms.has(key)) farms.set(key, makePathWithout(SHARED_ROOT, exclude));
159
+ return farms.get(key);
160
+ };
161
+
162
+ const TEMPLATE_HOME = (() => {
163
+ const home = join(SHARED_ROOT, 'template-home');
164
+ const bin = join(home, '.local', 'bin');
165
+ mkdirSync(bin, { recursive: true });
166
+ writeFileSync(join(bin, 'agy'), FAKE_AGY, { mode: 0o755 });
167
+ const repo = join(home, 'repo');
168
+ mkdirSync(repo);
169
+ const g = (...args) => spawnSync('git', args, { cwd: repo, encoding: 'utf8' });
170
+ g('init', '-q');
171
+ g('config', 'user.email', 'probe@example.com');
172
+ g('config', 'user.name', 'probe');
173
+ writeFileSync(join(repo, 'base.txt'), 'committed base\n');
174
+ g('add', '-A');
175
+ g('commit', '-qm', 'base');
176
+ return home;
177
+ })();
178
+
179
+ // `clean: true` leaves a pristine committed tree; the default leaves one untracked file so `code`
180
+ // mode has a diff to review.
181
+ export const makeSandbox = ({ clean = false } = {}) => {
182
+ const home = mkdtempSync(join(tmpdir(), 'agy-review-test-'));
183
+ cpSync(TEMPLATE_HOME, home, { recursive: true });
184
+ const bin = join(home, '.local', 'bin');
185
+ chmodSync(join(bin, 'agy'), 0o755);
186
+ const repo = join(home, 'repo');
187
+ const g = (...args) => spawnSync('git', args, { cwd: repo, encoding: 'utf8' });
188
+ if (!clean) writeFileSync(join(repo, 'pending.txt'), 'PENDING_UNTRACKED_BODY\n');
189
+ return { home, bin, repo, g };
190
+ };
191
+
192
+ let runSeq = 0;
193
+ // Asynchronous on purpose: a blocking spawnSync pinned the whole suite to one core.
194
+ export const run = (sb, { args, env = {}, cwd, wrapper } = {}) => new Promise((settle) => {
195
+ const { home, bin, repo } = sb;
196
+ const farm = farmFor(['agy', 'agy-run']);
197
+ const tag = `cap-${++runSeq}`;
198
+ const cap = {
199
+ argv: join(home, `${tag}-argv`), env: join(home, `${tag}-env`), prompt: join(home, `${tag}-prompt`),
200
+ sentinel: join(home, `${tag}-sentinel`), adddir: join(home, `${tag}-adddir`),
201
+ adddirMode: join(home, `${tag}-adddir-mode`), artifactMode: join(home, `${tag}-artifact-mode`),
202
+ artifactCopy: join(home, `${tag}-artifact-copy`), turns: join(home, `${tag}-turns`),
203
+ dispatchCwd: join(home, `${tag}-dispatch-cwd`),
204
+ };
205
+ const child = execFile('bash', [wrapper || WRAPPER, ...args], {
206
+ cwd: cwd || repo,
207
+ encoding: 'utf8',
208
+ timeout: 30000,
209
+ maxBuffer: 64 * 1024 * 1024,
210
+ env: {
211
+ HOME: home,
212
+ PATH: `${bin}:${farm}`,
213
+ TMPDIR: process.env.TMPDIR ?? '/tmp',
214
+ AGY_FAKE_ARGV: cap.argv, AGY_FAKE_ENV: cap.env, AGY_FAKE_PROMPT: cap.prompt,
215
+ AGY_FAKE_SENTINEL: cap.sentinel, AGY_FAKE_ADDDIR: cap.adddir, AGY_FAKE_ADDDIR_MODE: cap.adddirMode,
216
+ AGY_FAKE_ARTIFACT_MODE: cap.artifactMode, AGY_FAKE_ARTIFACT_COPY: cap.artifactCopy,
217
+ AGY_FAKE_TURNS: cap.turns, AGY_FAKE_CWD: cap.dispatchCwd,
218
+ ...env,
219
+ },
220
+ }, (error, stdout, stderr) => {
221
+ const readIf = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : '');
222
+ const turns = existsSync(cap.turns) ? Number(readFileSync(cap.turns, 'utf8')) : 0;
223
+ const prompts = [];
224
+ const argvs = [];
225
+ for (let i = 1; i <= turns; i += 1) {
226
+ prompts.push(readIf(`${cap.prompt}.${i}`));
227
+ argvs.push(readIf(`${cap.argv}.${i}`));
228
+ }
229
+ settle({
230
+ status: error ? (error.code ?? 1) : 0, signal: error?.signal ?? null, stdout, stderr,
231
+ invoked: existsSync(cap.sentinel),
232
+ argv: readIf(cap.argv), capEnv: readIf(cap.env), prompt: readIf(cap.prompt),
233
+ adddir: readIf(cap.adddir).trim(), adddirMode: readIf(cap.adddirMode).trim(),
234
+ artifactMode: readIf(cap.artifactMode).trim(), artifactCopy: readIf(cap.artifactCopy),
235
+ dispatchCwd: readIf(cap.dispatchCwd).trim(), turns, prompts, argvs,
236
+ });
237
+ });
238
+ // Only EPIPE is a refusal working — any other stdin write failure must reach the test.
239
+ child.stdin.on('error', (err) => { if (err.code !== 'EPIPE') throw err; });
240
+ child.stdin.end();
241
+ });
242
+
243
+ export const runAsync = run;
244
+
245
+ export const RECEIPTS_REL = join('.git', 'agent-workflow-review-receipts.jsonl');
246
+ export const readReceipts = (repo) => {
247
+ const p = join(repo, RECEIPTS_REL);
248
+ if (!existsSync(p)) return [];
249
+ return readFileSync(p, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
250
+ };
251
+
252
+ // The chunked-feed lane: an over-cap change set is DELIVERED over turns and its delivery is PROVEN
253
+ // by verbatim line echoes the final answer must reproduce.
254
+ export const ARTIFACT_HEADER = '## The change set under review (assembled working-tree diff — repo-complete)';
255
+ export const SHAPE_HEADER = '\n## Output — Markdown, this exact shape, nothing else';
256
+ export const FED_CAP = 6000;
257
+ export const MAX_COUNTABLE_PROOF_ADDRESS = 40;
258
+ export const FED_WIDE_CAP = 24000;
259
+ export const isAssemblerBanner = (line) =>
260
+ /^=== (repo file map|git status|staged diff|unstaged diff|untracked)/.test(line) && line.endsWith(' ===');
261
+
262
+ export const seedFedChangeSet = (sb, { lines = 400, multibyte = false } = {}) => {
263
+ const body = Array.from({ length: lines }, (_, i) =>
264
+ multibyte
265
+ ? `line ${String(i).padStart(4, '0')} — multibyte marker ${'\u044e'.repeat(20)}`
266
+ : `unique change-set line ${String(i).padStart(4, '0')} — a distinctive body marker ${'x'.repeat(20)}`).join('\n');
267
+ writeFileSync(join(sb.repo, 'oversized.txt'), `${body}\n`);
268
+ };
269
+
270
+ export const inlineArtifactOf = (prompt) => prompt.slice(prompt.indexOf(ARTIFACT_HEADER), prompt.indexOf(SHAPE_HEADER));
271
+ export const bodyOf = (turnPrompt) => {
272
+ const begin = turnPrompt.match(/--- BEGIN CHANGE-SET PART \d+ OF \d+ ---\n/);
273
+ if (!begin) return null;
274
+ const start = begin.index + begin[0].length;
275
+ return turnPrompt.slice(start, turnPrompt.indexOf('\n--- END CHANGE-SET PART ', start));
276
+ };
277
+ export const requestedBlockOf = (finalPrompt) => {
278
+ const start = finalPrompt.indexOf('Requested addresses');
279
+ assert.notEqual(start, -1, 'the final turn states which lines it requires');
280
+ const after2 = finalPrompt.slice(finalPrompt.indexOf('\n', start) + 1);
281
+ return after2.slice(0, after2.indexOf('\n###')).split('\n').filter(Boolean);
282
+ };
283
+ export const requestedOf = (finalPrompt) => requestedBlockOf(finalPrompt).map((item) => {
284
+ const [, part, line] = item.match(/^part (\d+) line (\d+)$/);
285
+ return { part: Number(part), line: Number(line) };
286
+ });
287
+ export const fedRun = (sb, extraEnv = {}) =>
288
+ run(sb, { args: ['code', '--facts', 'grounded fact'], env: { AGY_MAX_PROMPT_BYTES: String(FED_CAP), ...extraEnv } });
@@ -0,0 +1,109 @@
1
+ // Contract under test: docs/ai/specs/bridges/agy-review-verdict.md (verdict-body honesty).
2
+ import { describe, it } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { rmSync, readFileSync } from 'node:fs';
5
+ import { dirname, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { makeSandbox, run, readReceipts, seedFedChangeSet, fedRun } from './agy-review-harness.test.mjs';
8
+
9
+ const HERE = dirname(fileURLToPath(import.meta.url));
10
+
11
+ const CONTRADICTION_OUTPUT = '### Verdict\nSHIP WITH NITS — solid, two nits.\n### Blocking\n1. correctness: drops the last chunk\n2. security: path traversal in the staging dir\n### Non-blocking\nnone\n### Questions\nnone';
12
+
13
+ describe('agy-review.sh — a ship-class verdict cannot ride numbered Blocking findings', { concurrency: 2 }, () => {
14
+ // spec:agy-review-verdict/S1
15
+ it('a ship-class verdict beside a numbered Blocking item exits 4 with NO receipt — fresh and fed lanes alike', async () => {
16
+ const fresh = makeSandbox();
17
+ const r = await run(fresh, { args: ['code', '--facts', 'a tiny fact'], env: { AGY_FAKE_OUTPUT: CONTRADICTION_OUTPUT } });
18
+ const freshReceipts = readReceipts(fresh.repo);
19
+ rmSync(fresh.home, { recursive: true, force: true });
20
+ assert.equal(r.status, 4, r.stderr);
21
+ assert.equal(r.invoked, true, 'the contradiction is judged AFTER the run — a failed review, not a pre-spend refusal');
22
+ assert.equal(freshReceipts.length, 0, 'a contradictory run mints nothing');
23
+ const sb = makeSandbox();
24
+ seedFedChangeSet(sb);
25
+ const fed = await fedRun(sb, { AGY_FAKE_FINAL_APPEND: '### Blocking\n1. a numbered blocker the verdict ignores' });
26
+ const fedReceipts = readReceipts(sb.repo);
27
+ rmSync(sb.home, { recursive: true, force: true });
28
+ assert.equal(fed.status, 4, fed.stderr);
29
+ assert.equal(fedReceipts.length, 0, 'the fed lane refuses the same shape after a PROVEN delivery');
30
+ });
31
+
32
+ // spec:agy-review-verdict/S2
33
+ it('a Blocking body of none — any case, trailing punctuation — mints exactly as today', async () => {
34
+ for (const noneForm of ['none', 'None.', 'NONE']) {
35
+ const sb = makeSandbox();
36
+ const output = `### Verdict\nSHIP WITH NITS — fine.\n### Blocking\n${noneForm}\n### Non-blocking\nnone\n### Questions\nnone`;
37
+ const r = await run(sb, { args: ['code', '--facts', 'a tiny fact'], env: { AGY_FAKE_OUTPUT: output } });
38
+ const receipts = readReceipts(sb.repo);
39
+ rmSync(sb.home, { recursive: true, force: true });
40
+ assert.equal(r.status, 0, `"${noneForm}" must pass: ${r.stderr}`);
41
+ assert.equal(receipts.length, 1);
42
+ assert.equal(receipts[0].verdict, 'SHIP WITH NITS');
43
+ }
44
+ });
45
+
46
+ // spec:agy-review-verdict/S3
47
+ it('REWORK is never refused by the cross-check, numbered Blocking items or not', async () => {
48
+ const sb = makeSandbox();
49
+ const output = '### Verdict\nREWORK — two real blockers.\n### Blocking\n1. the guard fails open\n2. the receipt lies\n### Non-blocking\nnone\n### Questions\nnone';
50
+ const r = await run(sb, { args: ['code', '--facts', 'a tiny fact'], env: { AGY_FAKE_OUTPUT: output } });
51
+ const receipts = readReceipts(sb.repo);
52
+ rmSync(sb.home, { recursive: true, force: true });
53
+ assert.equal(r.status, 0, r.stderr);
54
+ assert.equal(receipts.length, 1, 'a veto needs no corroboration');
55
+ assert.equal(receipts[0].verdict, 'REWORK');
56
+ });
57
+
58
+ // spec:agy-review-verdict/S4
59
+ it('an absent Blocking section is not a contradiction — the verdict-only read is unchanged', async () => {
60
+ const sb = makeSandbox();
61
+ const r = await run(sb, { args: ['code', '--facts', 'a tiny fact'], env: { AGY_FAKE_OUTPUT: '### Verdict\nSHIP — clean, no sections beyond this one.' } });
62
+ const receipts = readReceipts(sb.repo);
63
+ rmSync(sb.home, { recursive: true, force: true });
64
+ assert.equal(r.status, 0, r.stderr);
65
+ assert.equal(receipts.length, 1, 'the shape mandate lives in the prompt; absence is not judged here');
66
+ assert.equal(receipts[0].verdict, 'SHIP');
67
+ });
68
+
69
+ // spec:agy-review-verdict/S5
70
+ it('the continuation lane refuses a contradictory verdict before its informational receipt', async () => {
71
+ const sb = makeSandbox();
72
+ const r = await run(sb, { args: ['--continue'], env: { AGY_FAKE_OUTPUT: CONTRADICTION_OUTPUT } });
73
+ const receipts = readReceipts(sb.repo);
74
+ rmSync(sb.home, { recursive: true, force: true });
75
+ assert.equal(r.status, 4, r.stderr);
76
+ assert.equal(receipts.length, 0, 'a fresh:false receipt must not encode a contradiction either');
77
+ });
78
+
79
+ // spec:agy-review-verdict/S6
80
+ it('the refusal names both halves and the verdict is never rewritten or downgraded', async () => {
81
+ const sb = makeSandbox();
82
+ const r = await run(sb, { args: ['code', '--facts', 'a tiny fact'], env: { AGY_FAKE_OUTPUT: CONTRADICTION_OUTPUT } });
83
+ const receipts = readReceipts(sb.repo);
84
+ rmSync(sb.home, { recursive: true, force: true });
85
+ assert.equal(r.status, 4);
86
+ assert.match(r.stderr, /SHIP WITH NITS — solid, two nits\./, 'the RAW verdict line is named, reason text included');
87
+ assert.match(r.stderr, /drops the last chunk/, 'the first numbered Blocking item is named');
88
+ assert.match(r.stderr, /[Rr]e-run the review/, 'the recovery is the same as every failed review');
89
+ assert.equal(receipts.length, 0, 'no rewritten or downgraded verdict is ever minted');
90
+ });
91
+
92
+ // spec:agy-review-verdict/S8
93
+ it('a second Blocking heading terminates the scan — only the first section is judged', async () => {
94
+ const sb = makeSandbox();
95
+ const output = '### Verdict\nSHIP — clean.\n### Blocking\nnone\n### Blocking\n1. a numbered item under a duplicate heading\n### Questions\nnone';
96
+ const r = await run(sb, { args: ['code', '--facts', 'a tiny fact'], env: { AGY_FAKE_OUTPUT: output } });
97
+ const receipts = readReceipts(sb.repo);
98
+ rmSync(sb.home, { recursive: true, force: true });
99
+ assert.equal(r.status, 0, r.stderr);
100
+ assert.equal(receipts.length, 1, 'the first section said none — a later heading cannot suppress the receipt');
101
+ assert.equal(receipts[0].verdict, 'SHIP');
102
+ });
103
+
104
+ // spec:agy-review-verdict/S7
105
+ it('the loadable skill text names the closed verdict vocabulary', async () => {
106
+ const skill = readFileSync(resolve(HERE, '..', 'SKILL.md'), 'utf8');
107
+ assert.match(skill, /SHIP \/ SHIP WITH NITS \/ REWORK/, 'the closed vocabulary is spelled in SKILL.md, not only in --help and failure stderr');
108
+ });
109
+ });
@@ -338,7 +338,7 @@ DEFAULT_AGY_REVIEW_MODEL="Gemini 3.7 Flash (High)"
338
338
  # Review-receipt identity (AD-038). AW_BRIDGE_VERSION mirrors this bridge's SKILL.md/capability.json
339
339
  # version (drift-guarded by agy-review.test.mjs against capability.json).
340
340
  AW_RECEIPT_BACKEND="agy"
341
- AW_BRIDGE_VERSION="5.3.0" # aw-version-anchor
341
+ AW_BRIDGE_VERSION="5.4.0" # aw-version-anchor
342
342
  # `-` not `:-` so an EXPLICIT empty AGY_MODEL= survives (drop --model, use settings.json — agy.sh:52).
343
343
  AGY_MODEL="${AGY_MODEL-$DEFAULT_AGY_REVIEW_MODEL}"
344
344
  # D5 control-byte screen — IMMEDIATELY after resolution, BEFORE the off-frontier advisory (or any
@@ -1070,6 +1070,14 @@ parse_agy_verdict() { # $1 = captured-output file
1070
1070
  fi
1071
1071
  }
1072
1072
 
1073
+ parse_agy_verdict_line() { # $1 = captured-output file
1074
+ awk '/^### Verdict[[:space:]]*$/{flag=1; next} flag && NF {print; exit}' "$1" 2>/dev/null
1075
+ }
1076
+
1077
+ parse_agy_blocking_first_numbered() { # $1 = captured-output file
1078
+ awk 'flag && /^### /{exit} /^### Blocking[[:space:]]*$/{flag=1; next} flag && /^[0-9]+[.)]/{print; exit}' "$1" 2>/dev/null
1079
+ }
1080
+
1073
1081
  # The repo file map is a FIXED cost that scales with REPO SIZE, not change size (measured 28,735
1074
1082
  # bytes in the home repo — 24% of agy's 120000-byte single-argv ceiling), so an unbounded map taxes
1075
1083
  # the change budget of every review. AW_REVIEW_MAP_BUDGET_BYTES bounds it; the value is set by each
@@ -2111,6 +2119,16 @@ if [[ $rc -eq 0 ]]; then
2111
2119
  echo " the review; if it recurs, inspect the captured output for what the model produced." >&2
2112
2120
  exit 4
2113
2121
  fi
2122
+ if [[ "$verdict" == "SHIP" || "$verdict" == "SHIP WITH NITS" ]]; then
2123
+ first_blocking_item="$(parse_agy_blocking_first_numbered "$review_out_file")"
2124
+ if [[ -n "$first_blocking_item" ]]; then
2125
+ verdict_line="$(parse_agy_verdict_line "$review_out_file")"
2126
+ echo "error: verdict-body contradiction — '### Verdict' reads '$verdict_line' while '### Blocking'" >&2
2127
+ echo " carries a numbered finding ($first_blocking_item). A ship-class verdict cannot" >&2
2128
+ echo " ride blocking findings; NO receipt was written. Re-run the review." >&2
2129
+ exit 4
2130
+ fi
2131
+ fi
2114
2132
  if [[ -n "$resume_mode" ]]; then
2115
2133
  # A continuation never re-embeds the current artifact (agy holds the ORIGINAL round server-side;
2116
2134
  # --facts is rejected above), so it cannot attest the folded tree: fresh:false, artifact /
@@ -10,283 +10,11 @@ import { fileURLToPath } from 'node:url';
10
10
  import { spawnSync, execFile } from 'node:child_process';
11
11
 
12
12
  const HERE = dirname(fileURLToPath(import.meta.url));
13
- const WRAPPER = join(HERE, 'agy-review.sh');
14
-
15
- // Hermetic fake `agy`. PORTING TRAP: agy takes the prompt as the `-p` ARGV value, NOT stdin — so the
16
- // fake captures the -p value from argv (a stdin capture would make every prompt assertion vacuous).
17
- // It also records full argv, a couple of env vars, an invocation sentinel, and — for the oversized
18
- // --add-dir escape — the staging dir's perms + the offloaded artifact's perms/contents WHILE they
19
- // still exist (agy-review's trap removes the staging dir on exit). Kept inline so the file is
20
- // standalone (the kit mirror is byte-equality; no shared helper grows that set).
21
- // The envelope `agy --output-format json` returns: ONE object whose `response` carries the model's
22
- // text VERBATIM. Encoded in node so an arbitrary body (quotes, backslashes, multibyte) round-trips
23
- // exactly — hand-rolled JSON quoting in bash is the defect farm this fake exists to avoid.
24
- const FAKE_ENVELOPE_ENCODER = [
25
- 'const text = require("node:fs").readFileSync(0, "utf8");',
26
- 'const [cid, status, shape] = process.argv.slice(1);',
27
- 'const envelope = { conversation_id: cid, status, response: text, duration_seconds: 1.5, num_turns: 1,',
28
- ' usage: { input_tokens: 10, output_tokens: 5, thinking_tokens: 0, cache_read_tokens: 0, total_tokens: 15 } };',
29
- // The fed lane ROUTES later turns at this field, so the two ways it can be present-but-unusable —
30
- // absent, and present with the wrong TYPE — are knobs, not just a bad-grammar string.
31
- 'if (shape === "missing") delete envelope.conversation_id;',
32
- 'if (shape === "number") envelope.conversation_id = 42;',
33
- 'process.stdout.write(`${JSON.stringify(envelope)}\\n`);',
34
- ].join('\n');
35
-
36
- const FAKE_AGY = [
37
- '#!/usr/bin/env bash',
38
- 'set -u',
39
- // A capability probe is NOT a paid dispatch: --help / --version answer BEFORE any capture file is
40
- // touched, so the wrapper's pre-spend door can never read as a spent run (the sentinel) nor shift
41
- // the fed lane's per-turn counter. Keyed on the FIRST argument like the real CLI, so a prompt that
42
- // merely CONTAINS "--help" is never intercepted.
43
- 'case "${1:-}" in',
44
- ' --help|-h)',
45
- ' if [[ -n "${AGY_FAKE_HELP_EXIT:-}" ]]; then echo "fake agy: help unavailable" >&2; exit "$AGY_FAKE_HELP_EXIT"; fi',
46
- ' for f in --output-format --json-schema --disable-slash-commands --effort --mode; do',
47
- ' if [[ "$f" == "${AGY_FAKE_HELP_OMIT:-}" ]]; then continue; fi',
48
- ' printf " %s fake capability line\\n" "$f"',
49
- ' done',
50
- ' if [[ -n "${AGY_FAKE_HELP_EXTRA:-}" ]]; then printf "%s\\n" "$AGY_FAKE_HELP_EXTRA"; fi',
51
- ' exit 0 ;;',
52
- ' --version) printf "%s\\n" "${AGY_FAKE_VERSION:-1.1.13}"; exit 0 ;;',
53
- 'esac',
54
- ': "${AGY_FAKE_ARGV:=/dev/null}"',
55
- ': "${AGY_FAKE_ENV:=/dev/null}"',
56
- ': "${AGY_FAKE_PROMPT:=/dev/null}"',
57
- ': "${AGY_FAKE_SENTINEL:=/dev/null}"',
58
- 'printf invoked > "$AGY_FAKE_SENTINEL"',
59
- // The fed lane dispatches from the private staging dir (deliberately — see the wrapper), so the
60
- // fake's own cwd is the only handle a test has on a directory the exit trap then removes.
61
- 'printf "%s" "$PWD" > "${AGY_FAKE_CWD:-/dev/null}"',
62
- '{ for a in "$@"; do printf "%s\\n" "$a"; done; } > "$AGY_FAKE_ARGV"',
63
- '{ echo "FOO_API_KEY=${FOO_API_KEY:-<unset>}"; echo "ANTIGRAVITY_API_KEY=${ANTIGRAVITY_API_KEY:-<unset>}"; } > "$AGY_FAKE_ENV"',
64
- 'prompt=""',
65
- 'prev=""; for a in "$@"; do if [[ "$prev" == "-p" ]]; then prompt="$a"; printf "%s" "$a" > "$AGY_FAKE_PROMPT"; fi; prev="$a"; done',
66
- // Every answer leaves through ONE emitter, so the transport is a property of the INVOCATION
67
- // (`--output-format json` ⇒ an envelope, otherwise text) and every existing output knob keeps
68
- // meaning exactly what it meant. AGY_FAKE_RAW_STDOUT bypasses the encoder entirely — the
69
- // unparseable-payload arm; AGY_FAKE_STDERR is the CLI's own diagnostic.
70
- 'aw_fmt=""',
71
- 'prev=""; for a in "$@"; do if [[ "$prev" == "--output-format" ]]; then aw_fmt="$a"; fi; prev="$a"; done',
72
- // AGY_FAKE_BAD_TURN scopes the two transport-breaking knobs to ONE turn: a fed run whose turn 1
73
- // answers normally and whose turn 2 does not is the only way to reach the "no LATER turn is spent"
74
- // arms — an unscoped knob breaks turn 1 and the run never gets there.
75
- 'aw_fake_emit() {',
76
- ' local body="$1" aw_status="${AGY_FAKE_STATUS:-SUCCESS}" aw_raw=""',
77
- ' if [[ -n "${AGY_FAKE_RAW_STDOUT+x}" ]]; then aw_raw=1; fi',
78
- ' if [[ -n "${AGY_FAKE_BAD_TURN:-}" && "$turn" != "${AGY_FAKE_BAD_TURN}" ]]; then aw_status="SUCCESS"; aw_raw=""; fi',
79
- ' if [[ -n "${AGY_FAKE_STDERR:-}" ]]; then printf "%s\\n" "$AGY_FAKE_STDERR" >&2; fi',
80
- ' if [[ -n "$aw_raw" ]]; then printf "%s" "$AGY_FAKE_RAW_STDOUT"; return 0; fi',
81
- ' if [[ "$aw_fmt" != "json" ]]; then printf "%s\\n" "$body"; return 0; fi',
82
- ` printf "%s\\n" "$body" | node -e '${FAKE_ENVELOPE_ENCODER}' \\`,
83
- ' "${AGY_FAKE_CONV_ID:-11111111-2222-3333-4444-555555555555}" "$aw_status" "${AGY_FAKE_CONV_SHAPE:-ok}"',
84
- '}',
85
- 'prev=""; for a in "$@"; do',
86
- ' if [[ "$prev" == "--add-dir" ]]; then',
87
- ' printf "%s" "$a" > "${AGY_FAKE_ADDDIR:-/dev/null}"',
88
- ' stat -c "%a" "$a" > "${AGY_FAKE_ADDDIR_MODE:-/dev/null}" 2>/dev/null || true',
89
- ' art="$a/precomputed-change-set"',
90
- ' if [[ -f "$art" ]]; then stat -c "%a" "$art" > "${AGY_FAKE_ARTIFACT_MODE:-/dev/null}" 2>/dev/null || true; cp "$art" "${AGY_FAKE_ARTIFACT_COPY:-/dev/null}" 2>/dev/null || true; fi',
91
- ' fi; prev="$a"',
92
- 'done',
93
- 'if [[ -n "${AGY_FAKE_SLEEP:-}" ]]; then sleep "$AGY_FAKE_SLEEP"; fi',
94
- // ── multi-turn support (the fed lane) ──────────────────────────────────────────────────────────
95
- // The single-file captures above record the LAST invocation; a chunked feed needs a PER-TURN
96
- // record, so each invocation also writes prompt/argv to "<file>.<turn>" and bumps a counter file.
97
- 'turn=1',
98
- 'if [[ -n "${AGY_FAKE_TURNS:-}" ]]; then',
99
- ' if [[ -s "$AGY_FAKE_TURNS" ]]; then turn=$(( $(cat "$AGY_FAKE_TURNS") + 1 )); fi',
100
- ' printf "%s" "$turn" > "$AGY_FAKE_TURNS"',
101
- ' printf "%s" "$prompt" > "${AGY_FAKE_PROMPT}.$turn"',
102
- ' { for a in "$@"; do printf "%s\\n" "$a"; done; } > "${AGY_FAKE_ARGV}.$turn"',
103
- 'fi',
104
- 'if [[ -n "${AGY_FAKE_FAIL_TURN:-}" && "$turn" == "${AGY_FAKE_FAIL_TURN}" ]]; then',
105
- ' printf "FAKE_TURN_FAILURE\\n" >&2; exit 3',
106
- 'fi',
107
- // A FEED turn: the model was told to reply OK only. This fake deliberately misbehaves — it emits a
108
- // PREMATURE verdict — so the isolation invariant (feed output never reaches stdout or the parsed
109
- // capture) is proven against the worst case, not the polite one.
110
- 'if [[ -z "${AGY_FAKE_OUTPUT+x}" && "$prompt" == *"--- BEGIN CHANGE-SET PART "* && "$prompt" != *"Requested addresses"* ]]; then',
111
- ' aw_fake_emit "$(printf "PREMATURE_FEED_CHATTER\\n### Verdict\\nREWORK")"; exit 0',
112
- 'fi',
113
- // The FINAL turn carries the delivery-proof request. The fake answers it the only honest way:
114
- // by reading the bodies it was actually fed, turn by turn — so a wrapper that never delivered a
115
- // part cannot be satisfied by this stub either.
116
- 'if [[ -z "${AGY_FAKE_OUTPUT+x}" && "$prompt" == *"Requested addresses"* ]]; then',
117
- ' req="$(printf "%s" "$prompt" | awk "/^Requested addresses/{f=1; next} f && /^###/{exit} f{print}")"',
118
- ' entries=()',
119
- ' mapfile -t _items <<< "$req"',
120
- ' for _it in "${_items[@]}"; do',
121
- ' [[ -n "$_it" ]] || continue',
122
- ' k="$(printf "%s" "$_it" | awk "{print \\$2}")"; l="$(printf "%s" "$_it" | awk "{print \\$4}")"',
123
- ' src="$k"',
124
- ' if [[ "${AGY_FAKE_PROOF_DUP:-}" == "1" ]]; then src=1; fi',
125
- ' if [[ "${AGY_FAKE_PROOF_OMIT:-}" == "$k" ]]; then continue; fi',
126
- ' body="$(awk -v want="$l" "f && /^--- END CHANGE-SET PART /{exit} f{c++; if (c==want) {print; exit}} /^--- BEGIN CHANGE-SET PART /{f=1}" "${AGY_FAKE_PROMPT}.$src")"',
127
- ' if [[ "${AGY_FAKE_PROOF_CORRUPT:-}" == "$k" ]]; then body="${body}X"; fi',
128
- ' entry="$(printf "part %s line %s: %s" "$k" "$l" "$body")"',
129
- // Shape knobs the grammar must survive (a bullet) or reject (everything else).
130
- ' if [[ "${AGY_FAKE_PROOF_BULLET:-}" == "1" ]]; then entry="- $entry"; fi',
131
- ' if [[ "${AGY_FAKE_PROOF_NESTED:-}" == "$k" ]]; then entry="note: I believe $entry"; fi',
132
- ' if [[ "${AGY_FAKE_PROOF_PAD:-}" == "1" ]]; then entry="$(printf "part %02d line %04d: %s" "$k" "$l" "$body")"; fi',
133
- ' if [[ "${AGY_FAKE_PROOF_CASE:-}" == "1" ]]; then entry="$(printf "Part %s Line %s: %s" "$k" "$l" "$body")"; fi',
134
- ' if [[ "${AGY_FAKE_PROOF_HUGE:-}" == "$k" ]]; then entry="$(printf "part %s line 99999999999999999999: %s" "$k" "$body")"; fi',
135
- ' entries+=("$entry")',
136
- ' if [[ "${AGY_FAKE_PROOF_TWICE:-}" == "1" ]]; then entries+=("$entry"); fi',
137
- ' done',
138
- ' if [[ "${AGY_FAKE_PROOF_EXTRA:-}" == "1" ]]; then entries+=("part 99 line 1: an address nobody asked for"); fi',
139
- ' if [[ "${AGY_FAKE_PROOF_HUGE_EXTRA:-}" == "1" ]]; then entries+=("part 99999999999999999999 line 1: an invented giant address"); fi',
140
- ' out="$( {',
141
- ' if [[ "${AGY_FAKE_PROOF_LATE:-}" == "1" ]]; then printf "### Verdict\\nSHIP\\n"; fi',
142
- ' if [[ "${AGY_FAKE_PROOF_CASE:-}" == "1" ]]; then printf "### Delivery Proof\\n"; else printf "### Delivery proof\\n"; fi',
143
- ' if [[ "${AGY_FAKE_PROOF_OUTSIDE:-}" == "1" ]]; then',
144
- ' printf "(nothing here)\\n### Verdict\\nSHIP\\n"',
145
- ' if (( ${#entries[@]} > 0 )); then printf "%s\\n" "${entries[@]}"; fi',
146
- ' else',
147
- ' if (( ${#entries[@]} > 0 )); then printf "%s\\n" "${entries[@]}"; fi',
148
- ' printf "### Verdict\\nSHIP\\n"',
149
- ' fi',
150
- ' } )"',
151
- ' aw_fake_emit "$out"',
152
- ' exit 0',
153
- 'fi',
154
- // Unset AGY_FAKE_OUTPUT → a verdict-carrying default (D4: a verdict-less run is a FAILURE, so
155
- // the success-path tests need one); an EXPLICIT empty value exercises the empty-output failure.
156
- 'if [[ -z "${AGY_FAKE_OUTPUT+x}" ]]; then aw_fake_emit "$(printf "FAKE_AGY_REVIEW_OUTPUT\\n### Verdict\\nSHIP")"; else aw_fake_emit "$AGY_FAKE_OUTPUT"; fi',
157
- 'exit "${AGY_FAKE_EXIT:-0}"',
158
- '',
159
- ].join('\n');
160
-
161
- // A PATH whose entries are symlinks to the real PATH binaries EXCEPT the excluded names. Excluding
162
- // `agy-run` forces agy-review onto its `$HERE/agy.sh` fallback (the repo's CURRENT agy.sh, not a
163
- // possibly-stale installed one), keeping the test hermetic; excluding `agy` ensures the only agy is
164
- // our fake (prepended via $HOME/.local/bin). Ported from codex-review.test.mjs.
165
- const makePathWithout = (root, exclude = []) => {
166
- const skip = new Set(exclude);
167
- const dir = mkdtempSync(join(root, 'nobin-'));
168
- for (const d of (process.env.PATH || '').split(':').filter(Boolean)) {
169
- let names;
170
- try { names = readdirSync(d); } catch { continue; }
171
- for (const name of names) {
172
- if (skip.has(name)) continue;
173
- const link = join(dir, name);
174
- if (existsSync(link)) continue;
175
- try { symlinkSync(resolve(d, name), link); } catch { /* dup / race — ignore */ }
176
- }
177
- }
178
- return dir;
179
- };
180
-
181
- // The PATH farms and the sandbox base are READ-ONLY per invocation, so both are built ONCE and
182
- // shared: a per-run farm rebuild (thousands of symlinks) plus a per-test `git init`+commit were
183
- // the suite's dominant wall cost, not the wrapper under test.
184
- const SHARED_ROOT = mkdtempSync(join(tmpdir(), 'agy-review-shared-'));
185
- after(() => rmSync(SHARED_ROOT, { recursive: true, force: true }));
186
- const farms = new Map();
187
- const farmFor = (exclude) => {
188
- const key = exclude.join('|');
189
- if (!farms.has(key)) farms.set(key, makePathWithout(SHARED_ROOT, exclude));
190
- return farms.get(key);
191
- };
192
-
193
- const TEMPLATE_HOME = (() => {
194
- const home = join(SHARED_ROOT, 'template-home');
195
- const bin = join(home, '.local', 'bin');
196
- mkdirSync(bin, { recursive: true });
197
- writeFileSync(join(bin, 'agy'), FAKE_AGY, { mode: 0o755 });
198
- const repo = join(home, 'repo');
199
- mkdirSync(repo);
200
- const g = (...args) => spawnSync('git', args, { cwd: repo, encoding: 'utf8' });
201
- g('init', '-q');
202
- g('config', 'user.email', 'probe@example.com');
203
- g('config', 'user.name', 'probe');
204
- writeFileSync(join(repo, 'base.txt'), 'committed base\n');
205
- g('add', '-A');
206
- g('commit', '-qm', 'base');
207
- return home;
208
- })();
209
-
210
- // `clean: true` leaves a pristine committed tree (for the no-diff preflight); the default leaves one
211
- // untracked file so `code` mode has a diff to review.
212
- const makeSandbox = ({ clean = false } = {}) => {
213
- const home = mkdtempSync(join(tmpdir(), 'agy-review-test-'));
214
- cpSync(TEMPLATE_HOME, home, { recursive: true });
215
- const bin = join(home, '.local', 'bin');
216
- chmodSync(join(bin, 'agy'), 0o755);
217
- const repo = join(home, 'repo');
218
- const g = (...args) => spawnSync('git', args, { cwd: repo, encoding: 'utf8' });
219
- if (!clean) writeFileSync(join(repo, 'pending.txt'), 'PENDING_UNTRACKED_BODY\n');
220
- return { home, bin, repo, g };
221
- };
222
-
223
- // Capture files are per-INVOCATION: a second run() on the same sandbox must not inherit the first
224
- // run's turn counter or per-turn prompt files (the fed lane reads them back by turn index).
225
- let runSeq = 0;
226
- // ASYNCHRONOUS on purpose. A blocking spawnSync here holds the event loop for the whole dispatch,
227
- // which pinned this file to ONE core: 208 points in a serial chain, 91.4s solo at 103% CPU while
228
- // the other seven cores idled. Awaiting the child instead lets a `{ concurrency }` describe
229
- // overlap its tests. The per-test environment still rides the CHILD's options — `process.env` is
230
- // never mutated, which is what keeps overlapping tests from reading each other's PATH.
231
- const run = (sb, { args, env = {}, cwd, wrapper } = {}) => new Promise((settle) => {
232
- const { home, bin, repo } = sb;
233
- const farm = farmFor(['agy', 'agy-run']);
234
- const tag = `cap-${++runSeq}`;
235
- const cap = {
236
- argv: join(home, `${tag}-argv`), env: join(home, `${tag}-env`), prompt: join(home, `${tag}-prompt`),
237
- sentinel: join(home, `${tag}-sentinel`), adddir: join(home, `${tag}-adddir`),
238
- adddirMode: join(home, `${tag}-adddir-mode`), artifactMode: join(home, `${tag}-artifact-mode`),
239
- artifactCopy: join(home, `${tag}-artifact-copy`), turns: join(home, `${tag}-turns`),
240
- dispatchCwd: join(home, `${tag}-dispatch-cwd`),
241
- };
242
- const child = execFile('bash', [wrapper || WRAPPER, ...args], {
243
- cwd: cwd || repo,
244
- encoding: 'utf8',
245
- timeout: 30000,
246
- maxBuffer: 64 * 1024 * 1024,
247
- env: {
248
- HOME: home,
249
- PATH: `${bin}:${farm}`,
250
- // Keep the wrapper's mktemp working when the suite runs inside an OS sandbox whose /tmp is
251
- // read-only (only $TMPDIR is writable there).
252
- TMPDIR: process.env.TMPDIR ?? '/tmp',
253
- AGY_FAKE_ARGV: cap.argv, AGY_FAKE_ENV: cap.env, AGY_FAKE_PROMPT: cap.prompt,
254
- AGY_FAKE_SENTINEL: cap.sentinel, AGY_FAKE_ADDDIR: cap.adddir, AGY_FAKE_ADDDIR_MODE: cap.adddirMode,
255
- AGY_FAKE_ARTIFACT_MODE: cap.artifactMode, AGY_FAKE_ARTIFACT_COPY: cap.artifactCopy,
256
- AGY_FAKE_TURNS: cap.turns, AGY_FAKE_CWD: cap.dispatchCwd,
257
- ...env,
258
- },
259
- }, (error, stdout, stderr) => {
260
- const readIf = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : '');
261
- // Per-turn captures are read EAGERLY: callers rmSync the sandbox before asserting.
262
- const turns = existsSync(cap.turns) ? Number(readFileSync(cap.turns, 'utf8')) : 0;
263
- const prompts = [];
264
- const argvs = [];
265
- for (let i = 1; i <= turns; i += 1) {
266
- prompts.push(readIf(`${cap.prompt}.${i}`));
267
- argvs.push(readIf(`${cap.argv}.${i}`));
268
- }
269
- settle({
270
- status: error ? (error.code ?? 1) : 0, signal: error?.signal ?? null, stdout, stderr,
271
- invoked: existsSync(cap.sentinel),
272
- argv: readIf(cap.argv), capEnv: readIf(cap.env), prompt: readIf(cap.prompt),
273
- adddir: readIf(cap.adddir).trim(), adddirMode: readIf(cap.adddirMode).trim(),
274
- artifactMode: readIf(cap.artifactMode).trim(), artifactCopy: readIf(cap.artifactCopy),
275
- dispatchCwd: readIf(cap.dispatchCwd).trim(), turns, prompts, argvs,
276
- });
277
- });
278
- // The wrapper refuses many inputs BEFORE it reads stdin, so the pipe can already be closed here.
279
- // The blocking spawn swallowed that; an async one throws EPIPE at the test. A closed pipe is the
280
- // refusal working — but ONLY EPIPE is: any other write failure is a real fault and must reach
281
- // the test instead of passing as a green.
282
- child.stdin.on('error', (err) => { if (err.code !== 'EPIPE') throw err; });
283
- child.stdin.end();
284
- });
285
-
286
- // runAsync was the async twin kept for the two sleep-bound timeout tests, back when run() blocked.
287
- // run() IS that twin now, with the fuller capture surface, so the twin is one name pointing at it
288
- // — two spawn paths could only drift.
289
- const runAsync = run;
13
+ import {
14
+ WRAPPER, farmFor, makeSandbox, run, runAsync, readReceipts, RECEIPTS_REL,
15
+ ARTIFACT_HEADER, SHAPE_HEADER, FED_CAP, FED_WIDE_CAP, MAX_COUNTABLE_PROOF_ADDRESS,
16
+ isAssemblerBanner, seedFedChangeSet, inlineArtifactOf, bodyOf, requestedBlockOf, requestedOf, fedRun,
17
+ } from './agy-review-harness.test.mjs';
290
18
 
291
19
  describe('agy-review.sh — model policy advisory (1)', { concurrency: 2 }, () => {
292
20
  it('warns for a non-frontier model but still runs', async () => {
@@ -935,59 +663,6 @@ describe('agy-review.sh — repo file map budget (Phase 2)', { concurrency: 2 },
935
663
  });
936
664
  });
937
665
 
938
- // ── the chunked-feed code review with PROVEN delivery (Phase 3) ──────────────────────────────────
939
- // agy takes its prompt as ONE argv, and this host AUTO-DENIES agy's native read_file tool — so an
940
- // over-cap change set can never be FETCHED by the model. It is DELIVERED instead: partitioned into
941
- // under-cap parts, fed over continuation turns, then reviewed in a final turn. Delivery is PROVEN,
942
- // never assumed: the wrapper picks a line from each part's body AFTER assembly and the final answer
943
- // must reproduce every picked line verbatim. Envelope and body are formally separate — only BODIES
944
- // concatenate, and they concatenate to the change set byte-for-byte.
945
- const ARTIFACT_HEADER = '## The change set under review (assembled working-tree diff — repo-complete)';
946
- const SHAPE_HEADER = '\n## Output — Markdown, this exact shape, nothing else';
947
- const FED_CAP = 6000;
948
- // The proof asks the model to COUNT to the address with no tool, so the address has to be reachable
949
- // that way. The walk used to start at each part's middle: measured 847..1024 on a 701464-byte change
950
- // set, which is where the three live false refusals sat. FED_WIDE_CAP gives the countability
951
- // regression parts wide enough that a midpoint address is unmistakably out of reach — under the
952
- // narrow FED_CAP the midpoint lands at 80, close enough to the bar that fixture drift could hide it.
953
- const MAX_COUNTABLE_PROOF_ADDRESS = 40;
954
- const FED_WIDE_CAP = 24000;
955
- // The assembler's OWN section banners — the vocabulary the proof selector refuses, because it emits
956
- // them for every change set and a model can rebuild the per-path forms from part 1's git-status
957
- // block. A change set's own `=== … ===` line is NOT in this set and stays admissible.
958
- const isAssemblerBanner = (line) =>
959
- /^=== (repo file map|git status|staged diff|unstaged diff|untracked)/.test(line) && line.endsWith(' ===');
960
-
961
- // A change set big enough to need several parts under FED_CAP.
962
- const seedFedChangeSet = (sb, { lines = 400, multibyte = false } = {}) => {
963
- const body = Array.from({ length: lines }, (_, i) =>
964
- multibyte
965
- ? `line ${String(i).padStart(4, '0')} — multibyte marker ${'\u044e'.repeat(20)}`
966
- : `unique change-set line ${String(i).padStart(4, '0')} — a distinctive body marker ${'x'.repeat(20)}`).join('\n');
967
- writeFileSync(join(sb.repo, 'oversized.txt'), `${body}\n`);
968
- };
969
-
970
- const inlineArtifactOf = (prompt) => prompt.slice(prompt.indexOf(ARTIFACT_HEADER), prompt.indexOf(SHAPE_HEADER));
971
- const bodyOf = (turnPrompt) => {
972
- const begin = turnPrompt.match(/--- BEGIN CHANGE-SET PART \d+ OF \d+ ---\n/);
973
- if (!begin) return null;
974
- const start = begin.index + begin[0].length;
975
- return turnPrompt.slice(start, turnPrompt.indexOf('\n--- END CHANGE-SET PART ', start));
976
- };
977
- // The addresses ride ONE PER LINE — that format is what makes a collision with a proof candidate
978
- // constructively impossible, so the parser reads lines, never a delimiter-joined field.
979
- const requestedBlockOf = (finalPrompt) => {
980
- const start = finalPrompt.indexOf('Requested addresses');
981
- assert.notEqual(start, -1, 'the final turn states which lines it requires');
982
- const after = finalPrompt.slice(finalPrompt.indexOf('\n', start) + 1);
983
- return after.slice(0, after.indexOf('\n###')).split('\n').filter(Boolean);
984
- };
985
- const requestedOf = (finalPrompt) => requestedBlockOf(finalPrompt).map((item) => {
986
- const [, part, line] = item.match(/^part (\d+) line (\d+)$/);
987
- return { part: Number(part), line: Number(line) };
988
- });
989
- const fedRun = (sb, extraEnv = {}) =>
990
- run(sb, { args: ['code', '--facts', 'grounded fact'], env: { AGY_MAX_PROMPT_BYTES: String(FED_CAP), ...extraEnv } });
991
666
 
992
667
  describe('agy-review.sh — chunked feed: the change set is DELIVERED (Phase 3)', { concurrency: 2 }, () => {
993
668
  it('an over-cap code review feeds every part and the concatenated BODIES reproduce the change set exactly', async () => {
@@ -2368,12 +2043,6 @@ describe('agy-review.sh — declared contract is really accepted (forward guard)
2368
2043
  const RECEIPT_FIXTURE = JSON.parse(
2369
2044
  '{"schema":1,"artifact":"code","fresh":true,"fingerprint":"<sha256hex>","backend":"codex","verdict":"revise","grounded":true,"factsHash":null,"wrapperVersion":"2.3.0","timestamp":"2026-07-03T12:00:00Z","probe":false,"posture":{"model":"<display>"},"delivery":"inline"}',
2370
2045
  );
2371
- const RECEIPTS_REL = join('.git', 'agent-workflow-review-receipts.jsonl');
2372
- const readReceipts = (repo) => {
2373
- const p = join(repo, RECEIPTS_REL);
2374
- if (!existsSync(p)) return [];
2375
- return readFileSync(p, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
2376
- };
2377
2046
  const sha256HexOf = async (buf) => {
2378
2047
  const { createHash } = await import('node:crypto');
2379
2048
  return createHash('sha256').update(buf).digest('hex');
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "antigravity-cli-bridge",
5
5
  "kind": "execution-backend",
6
- "version": "5.3.0",
6
+ "version": "5.4.0",
7
7
  "provides": ["review", "probe"],
8
8
  "posture": { "model": "Gemini 3.7 Flash (High)" },
9
9
  "roles": {
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "10.1.0",
6
+ "version": "10.2.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "10.1.0",
3
+ "version": "10.2.0",
4
4
  "description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
5
5
  "keywords": [
6
6
  "ai-agents",