@sabaiway/agent-workflow-kit 5.9.0 → 5.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/README.md +2 -2
  3. package/SKILL.md +1 -1
  4. package/bridges/antigravity-cli-bridge/SKILL.md +32 -11
  5. package/bridges/antigravity-cli-bridge/bin/agy-envelope.mjs +160 -0
  6. package/bridges/antigravity-cli-bridge/bin/agy-envelope.test.mjs +235 -0
  7. package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +23 -1
  8. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +242 -38
  9. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +482 -38
  10. package/bridges/antigravity-cli-bridge/capability.json +3 -2
  11. package/bridges/antigravity-cli-bridge/references/models-and-flags.md +45 -12
  12. package/bridges/antigravity-cli-bridge/references/review-prompt.md +6 -3
  13. package/bridges/antigravity-cli-bridge/setup/README.md +18 -5
  14. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +1 -1
  15. package/capability.json +1 -1
  16. package/package.json +1 -1
  17. package/references/hooks/state-block-guard.mjs +107 -45
  18. package/references/modes/commit-guard.md +11 -8
  19. package/references/modes/core-evidence.md +1 -1
  20. package/references/modes/dispatch.md +32 -10
  21. package/references/modes/set-recipe.md +8 -5
  22. package/references/modes/state-block-guard.md +39 -31
  23. package/references/modes/worktrees.md +47 -3
  24. package/references/scripts/check-docs-size-cli.test.mjs +2 -2
  25. package/references/shared/report-footer.md +2 -2
  26. package/references/templates/agent_rules.md +1 -0
  27. package/tools/advisor-matrix.mjs +165 -0
  28. package/tools/commands.mjs +2 -2
  29. package/tools/commit-guard.mjs +74 -17
  30. package/tools/core-evidence.mjs +10 -0
  31. package/tools/detect-backends.mjs +1 -0
  32. package/tools/dispatch-advisor.mjs +323 -0
  33. package/tools/dispatch.mjs +174 -109
  34. package/tools/doc-parity.mjs +68 -14
  35. package/tools/ensure-configs.mjs +4 -4
  36. package/tools/flow-check-cores.mjs +35 -6
  37. package/tools/flow-check-rungs.mjs +20 -2
  38. package/tools/flow-check.mjs +20 -5
  39. package/tools/lens-region.mjs +13 -1
  40. package/tools/observation-builder.mjs +123 -0
  41. package/tools/satellite-locator.mjs +179 -0
  42. package/tools/source-size-scope.mjs +3 -1
  43. package/tools/worktree-handoff-return.mjs +369 -0
  44. package/tools/worktree-prompt.mjs +190 -0
  45. package/tools/worktrees-record.mjs +171 -0
  46. package/tools/worktrees.mjs +308 -297
@@ -18,18 +18,70 @@ const WRAPPER = join(HERE, 'agy-review.sh');
18
18
  // --add-dir escape — the staging dir's perms + the offloaded artifact's perms/contents WHILE they
19
19
  // still exist (agy-review's trap removes the staging dir on exit). Kept inline so the file is
20
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
+
21
36
  const FAKE_AGY = [
22
37
  '#!/usr/bin/env bash',
23
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',
24
54
  ': "${AGY_FAKE_ARGV:=/dev/null}"',
25
55
  ': "${AGY_FAKE_ENV:=/dev/null}"',
26
56
  ': "${AGY_FAKE_PROMPT:=/dev/null}"',
27
57
  ': "${AGY_FAKE_SENTINEL:=/dev/null}"',
28
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}"',
29
62
  '{ for a in "$@"; do printf "%s\\n" "$a"; done; } > "$AGY_FAKE_ARGV"',
30
63
  '{ echo "FOO_API_KEY=${FOO_API_KEY:-<unset>}"; echo "ANTIGRAVITY_API_KEY=${ANTIGRAVITY_API_KEY:-<unset>}"; } > "$AGY_FAKE_ENV"',
31
64
  'prompt=""',
32
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
+ '}',
33
85
  'prev=""; for a in "$@"; do',
34
86
  ' if [[ "$prev" == "--add-dir" ]]; then',
35
87
  ' printf "%s" "$a" > "${AGY_FAKE_ADDDIR:-/dev/null}"',
@@ -49,14 +101,6 @@ const FAKE_AGY = [
49
101
  ' printf "%s" "$prompt" > "${AGY_FAKE_PROMPT}.$turn"',
50
102
  ' { for a in "$@"; do printf "%s\\n" "$a"; done; } > "${AGY_FAKE_ARGV}.$turn"',
51
103
  'fi',
52
- // agy writes the conversation id into its --log-file; AGY_FAKE_BAD_CONV_LOG=1 writes a log the
53
- // wrapper cannot parse (the D9 degrade arm).
54
- 'prev=""; for a in "$@"; do',
55
- ' if [[ "$prev" == "--log-file" ]]; then',
56
- ' if [[ "${AGY_FAKE_BAD_CONV_LOG:-}" == "1" ]]; then printf "no conversation marker here\\n" > "$a"',
57
- ' else printf "Starting new conversation %s\\n" "${AGY_FAKE_CONV_ID:-11111111-2222-3333-4444-555555555555}" > "$a"; fi',
58
- ' fi; prev="$a"',
59
- 'done',
60
104
  'if [[ -n "${AGY_FAKE_FAIL_TURN:-}" && "$turn" == "${AGY_FAKE_FAIL_TURN}" ]]; then',
61
105
  ' printf "FAKE_TURN_FAILURE\\n" >&2; exit 3',
62
106
  'fi',
@@ -64,7 +108,7 @@ const FAKE_AGY = [
64
108
  // PREMATURE verdict — so the isolation invariant (feed output never reaches stdout or the parsed
65
109
  // capture) is proven against the worst case, not the polite one.
66
110
  'if [[ -z "${AGY_FAKE_OUTPUT+x}" && "$prompt" == *"--- BEGIN CHANGE-SET PART "* && "$prompt" != *"Requested addresses"* ]]; then',
67
- ' printf "PREMATURE_FEED_CHATTER\\n### Verdict\\nREWORK\\n"; exit 0',
111
+ ' aw_fake_emit "$(printf "PREMATURE_FEED_CHATTER\\n### Verdict\\nREWORK")"; exit 0',
68
112
  'fi',
69
113
  // The FINAL turn carries the delivery-proof request. The fake answers it the only honest way:
70
114
  // by reading the bodies it was actually fed, turn by turn — so a wrapper that never delivered a
@@ -93,20 +137,23 @@ const FAKE_AGY = [
93
137
  ' done',
94
138
  ' if [[ "${AGY_FAKE_PROOF_EXTRA:-}" == "1" ]]; then entries+=("part 99 line 1: an address nobody asked for"); fi',
95
139
  ' if [[ "${AGY_FAKE_PROOF_HUGE_EXTRA:-}" == "1" ]]; then entries+=("part 99999999999999999999 line 1: an invented giant address"); fi',
96
- ' if [[ "${AGY_FAKE_PROOF_LATE:-}" == "1" ]]; then printf "### Verdict\\nSHIP\\n"; fi',
97
- ' if [[ "${AGY_FAKE_PROOF_CASE:-}" == "1" ]]; then printf "### Delivery Proof\\n"; else printf "### Delivery proof\\n"; fi',
98
- ' if [[ "${AGY_FAKE_PROOF_OUTSIDE:-}" == "1" ]]; then',
99
- ' printf "(nothing here)\\n### Verdict\\nSHIP\\n"',
100
- ' if (( ${#entries[@]} > 0 )); then printf "%s\\n" "${entries[@]}"; fi',
101
- ' else',
102
- ' if (( ${#entries[@]} > 0 )); then printf "%s\\n" "${entries[@]}"; fi',
103
- ' printf "### Verdict\\nSHIP\\n"',
104
- ' 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"',
105
152
  ' exit 0',
106
153
  'fi',
107
154
  // Unset AGY_FAKE_OUTPUT → a verdict-carrying default (D4: a verdict-less run is a FAILURE, so
108
155
  // the success-path tests need one); an EXPLICIT empty value exercises the empty-output failure.
109
- 'if [[ -z "${AGY_FAKE_OUTPUT+x}" ]]; then printf "FAKE_AGY_REVIEW_OUTPUT\\n### Verdict\\nSHIP\\n"; else printf "%s\\n" "$AGY_FAKE_OUTPUT"; fi',
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',
110
157
  'exit "${AGY_FAKE_EXIT:-0}"',
111
158
  '',
112
159
  ].join('\n');
@@ -176,7 +223,7 @@ const makeSandbox = ({ clean = false } = {}) => {
176
223
  // Capture files are per-INVOCATION: a second run() on the same sandbox must not inherit the first
177
224
  // run's turn counter or per-turn prompt files (the fed lane reads them back by turn index).
178
225
  let runSeq = 0;
179
- const run = (sb, { args, env = {}, cwd } = {}) => {
226
+ const run = (sb, { args, env = {}, cwd, wrapper } = {}) => {
180
227
  const { home, bin, repo } = sb;
181
228
  const farm = farmFor(['agy', 'agy-run']);
182
229
  const tag = `cap-${++runSeq}`;
@@ -185,8 +232,9 @@ const run = (sb, { args, env = {}, cwd } = {}) => {
185
232
  sentinel: join(home, `${tag}-sentinel`), adddir: join(home, `${tag}-adddir`),
186
233
  adddirMode: join(home, `${tag}-adddir-mode`), artifactMode: join(home, `${tag}-artifact-mode`),
187
234
  artifactCopy: join(home, `${tag}-artifact-copy`), turns: join(home, `${tag}-turns`),
235
+ dispatchCwd: join(home, `${tag}-dispatch-cwd`),
188
236
  };
189
- const r = spawnSync('bash', [WRAPPER, ...args], {
237
+ const r = spawnSync('bash', [wrapper || WRAPPER, ...args], {
190
238
  cwd: cwd || repo,
191
239
  encoding: 'utf8',
192
240
  timeout: 30000,
@@ -199,7 +247,7 @@ const run = (sb, { args, env = {}, cwd } = {}) => {
199
247
  AGY_FAKE_ARGV: cap.argv, AGY_FAKE_ENV: cap.env, AGY_FAKE_PROMPT: cap.prompt,
200
248
  AGY_FAKE_SENTINEL: cap.sentinel, AGY_FAKE_ADDDIR: cap.adddir, AGY_FAKE_ADDDIR_MODE: cap.adddirMode,
201
249
  AGY_FAKE_ARTIFACT_MODE: cap.artifactMode, AGY_FAKE_ARTIFACT_COPY: cap.artifactCopy,
202
- AGY_FAKE_TURNS: cap.turns,
250
+ AGY_FAKE_TURNS: cap.turns, AGY_FAKE_CWD: cap.dispatchCwd,
203
251
  ...env,
204
252
  },
205
253
  });
@@ -218,7 +266,7 @@ const run = (sb, { args, env = {}, cwd } = {}) => {
218
266
  argv: readIf(cap.argv), capEnv: readIf(cap.env), prompt: readIf(cap.prompt),
219
267
  adddir: readIf(cap.adddir).trim(), adddirMode: readIf(cap.adddirMode).trim(),
220
268
  artifactMode: readIf(cap.artifactMode).trim(), artifactCopy: readIf(cap.artifactCopy),
221
- turns, prompts, argvs,
269
+ dispatchCwd: readIf(cap.dispatchCwd).trim(), turns, prompts, argvs,
222
270
  };
223
271
  };
224
272
 
@@ -298,6 +346,355 @@ describe('agy-review.sh — model policy advisory (1)', () => {
298
346
  });
299
347
  });
300
348
 
349
+ // ── the pre-spend capability door (AGY-1.1.13, Decision 2) ───────────────────────────────────────
350
+ // The wrapper drives agy in --output-format json and reads the returned envelope in node, so a host
351
+ // that cannot do that must refuse BEFORE a subscription turn is spent. The door probes CAPABILITY,
352
+ // never a version floor — the release that introduced the flag is unmeasurable from one installed
353
+ // build. Every case asserts ZERO paid dispatch: the fake answers --help/--version without touching
354
+ // the invocation sentinel, so a probe can never read as a spent run.
355
+ const FAKE_OLD_NODE = '#!/usr/bin/env bash\nprintf "v18.20.0\\n"\n';
356
+
357
+ describe('agy-review.sh — the pre-spend capability door (Decision 2)', () => {
358
+ for (const flag of ['--output-format', '--disable-slash-commands']) {
359
+ it(`a --help that does not advertise ${flag} refuses pre-spend and names it`, () => {
360
+ const sb = makeSandbox();
361
+ const r = run(sb, { args: ['code', '--facts', 'f'], env: { AGY_FAKE_HELP_OMIT: flag } });
362
+ const receipts = readReceipts(sb.repo);
363
+ rmSync(sb.home, { recursive: true, force: true });
364
+ assert.notEqual(r.status, 0, r.stderr);
365
+ assert.equal(r.invoked, false, 'the door refuses BEFORE any paid dispatch');
366
+ assert.match(r.stderr, /does not advertise the flag/);
367
+ assert.ok(r.stderr.includes(flag), `the refusal names the missing flag: ${r.stderr}`);
368
+ assert.equal(receipts.length, 0);
369
+ });
370
+ }
371
+
372
+ // Each required flag buys something DIFFERENT, so the refusal must name the cost of the flag that
373
+ // is actually missing: a build lacking --disable-slash-commands answers perfectly readably and
374
+ // corrupts the delivered BODY instead, which the envelope explanation would send an operator
375
+ // hunting the wrong bug.
376
+ it('the refusal names what the MISSING flag buys, not one blanket envelope explanation', () => {
377
+ const sb = makeSandbox();
378
+ const r = run(sb, { args: ['code', '--facts', 'f'], env: { AGY_FAKE_HELP_OMIT: '--disable-slash-commands' } });
379
+ rmSync(sb.home, { recursive: true, force: true });
380
+ assert.notEqual(r.status, 0, r.stderr);
381
+ assert.equal(r.invoked, false);
382
+ assert.match(r.stderr, /slash command/i, 'the stated cost is command expansion of the change-set body');
383
+ assert.doesNotMatch(r.stderr, /cannot read/, 'the unreadable-answer cost belongs to --output-format alone');
384
+ });
385
+
386
+ it('`agy --help` exiting non-zero refuses with a DISTINCT cause — never read as "capability present"', () => {
387
+ const sb = makeSandbox();
388
+ const r = run(sb, { args: ['code', '--facts', 'f'], env: { AGY_FAKE_HELP_EXIT: '3' } });
389
+ rmSync(sb.home, { recursive: true, force: true });
390
+ assert.notEqual(r.status, 0, r.stderr);
391
+ assert.equal(r.invoked, false, 'a failed probe never becomes a paid dispatch');
392
+ assert.match(r.stderr, /'agy --help' exited 3/, 'the failed probe is the stated cause');
393
+ assert.doesNotMatch(r.stderr, /does not advertise the flag/, 'a failed probe is not a missing-flag verdict');
394
+ });
395
+
396
+ it('node ABSENT refuses pre-spend, naming Node and the floor', () => {
397
+ const sb = makeSandbox();
398
+ const r = run(sb, {
399
+ args: ['code', '--facts', 'f'],
400
+ env: { PATH: `${sb.bin}:${farmFor(['agy', 'agy-run', 'node'])}` },
401
+ });
402
+ rmSync(sb.home, { recursive: true, force: true });
403
+ assert.notEqual(r.status, 0, r.stderr);
404
+ assert.equal(r.invoked, false);
405
+ assert.match(r.stderr, /'node' is not on PATH/);
406
+ assert.match(r.stderr, /Node >= 22/);
407
+ });
408
+
409
+ it('node PRESENT but BELOW the floor refuses pre-spend — the version comparison really runs', () => {
410
+ const sb = makeSandbox();
411
+ // Into $HOME/.local/bin, which the wrapper itself prepends — so the fake wins over the real node.
412
+ writeFileSync(join(sb.bin, 'node'), FAKE_OLD_NODE, { mode: 0o755 });
413
+ const r = run(sb, { args: ['code', '--facts', 'f'] });
414
+ rmSync(sb.home, { recursive: true, force: true });
415
+ assert.notEqual(r.status, 0, r.stderr);
416
+ assert.equal(r.invoked, false, 'an existence-only check would have dispatched here');
417
+ assert.match(r.stderr, /v18\.20\.0/, 'the refusal quotes the version it read');
418
+ assert.match(r.stderr, /below the family floor \(Node >= 22\)/);
419
+ });
420
+
421
+ // The door reads option DECLARATIONS, not substrings. A near-miss flag and a prose mention are the
422
+ // two ways a substring search opens the door to a build that cannot honour the flag — and the
423
+ // failure would then surface only AFTER a subscription turn was already spent.
424
+ for (const [name, extraHelpLine] of [
425
+ ['a NEAR-MISS flag name', ' --output-formatting a longer flag that is not the one we pass'],
426
+ ['a prose mention outside any declaration', 'Note: pass --output-format json to get structured output.'],
427
+ ['the flag named inside ANOTHER option`s description', ' --json-schema enforce structured output; see --output-format'],
428
+ ['another option whose description BEGINS with the flag name', ' --other --output-format is unsupported on this build'],
429
+ ]) {
430
+ it(`${name} does NOT open the door — an option declaration is required`, () => {
431
+ const sb = makeSandbox();
432
+ const r = run(sb, {
433
+ args: ['code', '--facts', 'f'],
434
+ env: { AGY_FAKE_HELP_OMIT: '--output-format', AGY_FAKE_HELP_EXTRA: extraHelpLine },
435
+ });
436
+ rmSync(sb.home, { recursive: true, force: true });
437
+ assert.notEqual(r.status, 0, r.stderr);
438
+ assert.equal(r.invoked, false, 'a false-positive door would have spent a subscription turn here');
439
+ assert.match(r.stderr, /does not advertise the flag/);
440
+ assert.ok(r.stderr.includes('--output-format '), `the refusal still names the flag really missing: ${r.stderr}`);
441
+ });
442
+ }
443
+
444
+ // The other direction: a declaration the parser fails to READ refuses a build that CAN honour the
445
+ // flag — the false refusal Decision 2 exists to avoid. Both common clustered renderings count.
446
+ for (const [name, extraHelpLine] of [
447
+ ['a comma-clustered alias', ' -o, --output-format the clustered rendering'],
448
+ ['a metavar rendering', ' -o FORMAT, --output-format FORMAT the metavar rendering'],
449
+ ['an =<value> rendering', ' --output-format=<fmt> the joined-value rendering'],
450
+ ['a declaration with no description at all', ' --output-format'],
451
+ ]) {
452
+ it(`${name} READS as declared — a capable build is never falsely refused`, () => {
453
+ const sb = makeSandbox();
454
+ const r = run(sb, {
455
+ args: ['code', '--facts', 'f'],
456
+ env: { AGY_FAKE_HELP_OMIT: '--output-format', AGY_FAKE_HELP_EXTRA: extraHelpLine },
457
+ });
458
+ rmSync(sb.home, { recursive: true, force: true });
459
+ assert.equal(r.status, 0, `${name} must read as a declaration: ${r.stderr}`);
460
+ assert.equal(r.invoked, true);
461
+ });
462
+ }
463
+
464
+ it('the agy-capability refusal carries the INSTALLED version and the upgrade command', () => {
465
+ const sb = makeSandbox();
466
+ const r = run(sb, {
467
+ args: ['code', '--facts', 'f'],
468
+ env: { AGY_FAKE_HELP_OMIT: '--output-format', AGY_FAKE_VERSION: '1.0.9' },
469
+ });
470
+ rmSync(sb.home, { recursive: true, force: true });
471
+ assert.notEqual(r.status, 0, r.stderr);
472
+ assert.match(r.stderr, /Installed agy version: 1\.0\.9/, 'the operator is told what they actually have');
473
+ assert.match(r.stderr, /agy update/, 'and the command that fixes it');
474
+ });
475
+
476
+ it('a capable host passes the door and still dispatches (the door is not a blanket refusal)', () => {
477
+ const sb = makeSandbox();
478
+ const r = run(sb, { args: ['code', '--facts', 'f'] });
479
+ rmSync(sb.home, { recursive: true, force: true });
480
+ assert.equal(r.status, 0, r.stderr);
481
+ assert.equal(r.invoked, true);
482
+ });
483
+ });
484
+
485
+ // ── characterize-first: what the operator sees TODAY, pinned before the transport moves ──────────
486
+ // Phase 4.1 of the envelope plan. The switch from raw stdout to the CLI's JSON envelope must not
487
+ // change one byte of this: stdout carries the model's answer and NOTHING else (the posture banner
488
+ // is stderr), and the receipt records the verdict parsed out of that same answer. Green before the
489
+ // switch, green after — anything the transport quietly rewrites fails here.
490
+ describe('agy-review.sh — the operator-visible answer, byte-for-byte (characterization)', () => {
491
+ it('stdout is EXACTLY the model answer, and the receipt records the verdict parsed from it', () => {
492
+ const sb = makeSandbox();
493
+ const r = run(sb, { args: ['code', '--facts', 'a tiny fact'], env: { AGY_FAKE_OUTPUT: VERDICT_OUTPUT } });
494
+ const receipts = readReceipts(sb.repo);
495
+ rmSync(sb.home, { recursive: true, force: true });
496
+ assert.equal(r.status, 0, r.stderr);
497
+ assert.equal(r.stdout, `${VERDICT_OUTPUT}\n`, 'the answer reaches the operator with nothing added and nothing dropped');
498
+ assert.equal(receipts[0].verdict, 'SHIP WITH NITS', 'and the receipt verdict comes from that same answer');
499
+ });
500
+
501
+ // The bytes a transport is most likely to mangle: blank lines, non-ASCII, quotes, a backslash, a
502
+ // tab. If the answer ever rides a JSON field, this is the test that catches a lossy round-trip.
503
+ it('blank lines, multibyte, quotes, backslashes and tabs all survive to stdout unchanged', () => {
504
+ const answer = [
505
+ '### Verdict',
506
+ 'SHIP — «clean», no nits.',
507
+ '',
508
+ '### Blocking',
509
+ 'none',
510
+ '',
511
+ '### Non-blocking',
512
+ '1. a "quoted" path C:\\tmp\\x and a\ttab',
513
+ '',
514
+ '### Questions',
515
+ 'none',
516
+ ].join('\n');
517
+ const sb = makeSandbox();
518
+ const r = run(sb, { args: ['code', '--facts', 'a tiny fact'], env: { AGY_FAKE_OUTPUT: answer } });
519
+ const receipts = readReceipts(sb.repo);
520
+ rmSync(sb.home, { recursive: true, force: true });
521
+ assert.equal(r.status, 0, r.stderr);
522
+ assert.equal(r.stdout, `${answer}\n`, 'the answer is transported verbatim');
523
+ assert.equal(receipts[0].verdict, 'SHIP');
524
+ });
525
+ });
526
+
527
+ // ── the dispatch rides agy's structured envelope (Phase 4.3) ─────────────────────────────────────
528
+ // Every lane runs `--output-format json` and the wrapper reads the envelope's `response` instead of
529
+ // raw stdout. The review CONTRACT does not move: the same prompt, the same mandated shape, the same
530
+ // D4 arm. What changes is that a fact the wrapper used to guess at now arrives named.
531
+ describe('agy-review.sh — the dispatch rides the JSON envelope (Phase 4.3)', () => {
532
+ // The transport is BOTH flags: the envelope carries the answer, and --disable-slash-commands keeps
533
+ // a change-set line that happens to begin with a slash command as BODY rather than an instruction
534
+ // the CLI expands. The pre-spend door requires both, so the dispatch must pass both — a door that
535
+ // demands a capability the wrapper never uses refuses working installs for nothing.
536
+ const argvCarriesTransport = (argv) => {
537
+ const tokens = argv.split('\n');
538
+ const at = tokens.indexOf('--output-format');
539
+ return at !== -1 && tokens[at + 1] === 'json' && tokens.includes('--disable-slash-commands');
540
+ };
541
+
542
+ it('all three lanes carry the transport flags (single, fed, resume)', () => {
543
+ const single = makeSandbox();
544
+ const one = run(single, { args: ['code', '--facts', 'f'] });
545
+ rmSync(single.home, { recursive: true, force: true });
546
+ assert.equal(one.status, 0, one.stderr);
547
+ assert.ok(argvCarriesTransport(one.argv), `single lane argv: ${one.argv}`);
548
+
549
+ const fedSb = makeSandbox();
550
+ seedFedChangeSet(fedSb);
551
+ const fed = fedRun(fedSb);
552
+ rmSync(fedSb.home, { recursive: true, force: true });
553
+ assert.equal(fed.status, 0, fed.stderr);
554
+ assert.ok(fed.argvs.length >= 3, 'the fixture really chunks');
555
+ for (const [i, argv] of fed.argvs.entries()) {
556
+ assert.ok(argvCarriesTransport(argv), `fed turn ${i + 1} argv: ${argv}`);
557
+ }
558
+
559
+ const resumeSb = makeSandbox();
560
+ const resumed = run(resumeSb, { args: ['--continue'] });
561
+ rmSync(resumeSb.home, { recursive: true, force: true });
562
+ assert.equal(resumed.status, 0, resumed.stderr);
563
+ assert.ok(argvCarriesTransport(resumed.argv), `resume lane argv: ${resumed.argv}`);
564
+ });
565
+
566
+ it('operator-visible stdout IS the envelope response — and a feed turn`s response is neither published nor parsed', () => {
567
+ const sb = makeSandbox();
568
+ seedFedChangeSet(sb);
569
+ const fed = fedRun(sb);
570
+ const receipts = readReceipts(sb.repo);
571
+ rmSync(sb.home, { recursive: true, force: true });
572
+ assert.equal(fed.status, 0, fed.stderr);
573
+ assert.ok(fed.stdout.startsWith('### Delivery proof\n'), `stdout must be the FINAL response: ${fed.stdout.slice(0, 80)}`);
574
+ assert.ok(fed.stdout.endsWith('### Verdict\nSHIP\n'), 'and nothing is appended after it');
575
+ assert.ok(!fed.stdout.includes('PREMATURE_FEED_CHATTER'), 'a feed turn never publishes');
576
+ assert.ok(!fed.stdout.includes('conversation_id'), 'the raw envelope never reaches the operator');
577
+ assert.equal(receipts[0].verdict, 'SHIP', 'only the FINAL response is parsed');
578
+ });
579
+
580
+ it('a non-JSON blob on a ZERO exit is an UNREADABLE review: distinct exit, NO receipt, the cause names the envelope', () => {
581
+ const sb = makeSandbox();
582
+ const r = run(sb, { args: ['code', '--facts', 'f'], env: { AGY_FAKE_RAW_STDOUT: 'jetski: not an envelope at all\n' } });
583
+ const receipts = readReceipts(sb.repo);
584
+ rmSync(sb.home, { recursive: true, force: true });
585
+ assert.equal(r.status, 5, `an unreadable envelope has its OWN code, not the D4 4: ${r.stderr}`);
586
+ assert.equal(receipts.length, 0);
587
+ assert.match(r.stderr, /agy-envelope: not-json/, 'the module names the cause');
588
+ assert.match(r.stderr, /not a readable agy JSON envelope/, 'and the wrapper says what that means');
589
+ });
590
+
591
+ it('a non-SUCCESS status is unreadable too — its own named cause, NO receipt', () => {
592
+ const sb = makeSandbox();
593
+ const r = run(sb, { args: ['code', '--facts', 'f'], env: { AGY_FAKE_STATUS: 'ERROR' } });
594
+ const receipts = readReceipts(sb.repo);
595
+ rmSync(sb.home, { recursive: true, force: true });
596
+ assert.equal(r.status, 5, r.stderr);
597
+ assert.equal(receipts.length, 0);
598
+ assert.match(r.stderr, /agy-envelope: status/);
599
+ });
600
+
601
+ // Decision 4: the CLI's own failure WINS. The envelope is parsed only on a zero exit, so a
602
+ // non-zero run keeps its code and its message and never has a parse error layered over it.
603
+ it('a non-zero CLI exit keeps its code and message on the SINGLE lane — no envelope error replaces them', () => {
604
+ const sb = makeSandbox();
605
+ const r = run(sb, {
606
+ args: ['code', '--facts', 'f'],
607
+ env: { AGY_FAKE_EXIT: '7', AGY_FAKE_RAW_STDOUT: 'partial plain text\n', AGY_FAKE_STDERR: 'AGY_CLI_OWN_FAILURE' },
608
+ });
609
+ const receipts = readReceipts(sb.repo);
610
+ rmSync(sb.home, { recursive: true, force: true });
611
+ assert.equal(r.status, 7, 'the CLI exit code survives verbatim');
612
+ assert.match(r.stderr, /AGY_CLI_OWN_FAILURE/, 'and so does its own message');
613
+ assert.doesNotMatch(r.stderr, /agy-envelope:/, 'the envelope is never parsed on a non-zero exit');
614
+ assert.match(r.stdout, /partial plain text/, 'whatever the CLI printed still reaches the operator');
615
+ assert.equal(receipts.length, 0);
616
+ });
617
+
618
+ it('a non-zero CLI exit keeps its code and message on the FED lane too', () => {
619
+ const sb = makeSandbox();
620
+ seedFedChangeSet(sb);
621
+ const fed = fedRun(sb, { AGY_FAKE_FAIL_TURN: '2' });
622
+ const receipts = readReceipts(sb.repo);
623
+ rmSync(sb.home, { recursive: true, force: true });
624
+ assert.equal(fed.status, 3, 'the failing turn`s own exit code survives');
625
+ assert.match(fed.stderr, /FAKE_TURN_FAILURE/);
626
+ assert.doesNotMatch(fed.stderr, /agy-envelope:/, 'no parse is attempted on a failed turn');
627
+ assert.equal(fed.turns, 2, 'no later turn is spent');
628
+ assert.equal(receipts.length, 0);
629
+ });
630
+
631
+ // The switch must not move WHICH arm fires: a readable envelope carrying a verdict-less answer is
632
+ // still the D4 failed review, exit 4 — not the transport failure.
633
+ it('a READABLE envelope with no verdict still exits 4 through the EXISTING D4 arm', () => {
634
+ const sb = makeSandbox();
635
+ const r = run(sb, { args: ['code', '--facts', 'f'], env: { AGY_FAKE_OUTPUT: 'prose without the mandated section' } });
636
+ const receipts = readReceipts(sb.repo);
637
+ rmSync(sb.home, { recursive: true, force: true });
638
+ assert.equal(r.status, 4, 'the D4 arm owns this, not the envelope arm');
639
+ assert.equal(receipts.length, 0);
640
+ assert.match(r.stderr, /no recognized '### Verdict' section/);
641
+ assert.doesNotMatch(r.stderr, /agy-envelope:/, 'the envelope was perfectly readable');
642
+ });
643
+
644
+ // The MANAGED INSTALL shape: `agy-review` on PATH is a SYMLINK into the placed skill
645
+ // (~/.local/bin/agy-review -> <placed>/bin/agy-review.sh). An unresolved BASH_SOURCE names the
646
+ // LINK's directory, so every sibling payload — the envelope reader above all — would be looked
647
+ // for beside the link and EVERY review would refuse pre-spend on a correct install.
648
+ it('a review launched through a managed SYMLINK resolves its own sibling payload and still attests', () => {
649
+ const sb = makeSandbox();
650
+ const linked = join(sb.bin, 'agy-review');
651
+ symlinkSync(WRAPPER, linked);
652
+ const r = run(sb, { args: ['code', '--facts', 'a tiny fact'], wrapper: linked });
653
+ const receipts = readReceipts(sb.repo);
654
+ rmSync(sb.home, { recursive: true, force: true });
655
+ assert.equal(r.status, 0, `a symlinked launch must behave identically: ${r.stderr}`);
656
+ assert.equal(r.invoked, true, 'the dispatch really happened');
657
+ assert.doesNotMatch(r.stderr, /envelope reader .* is missing/, 'the reader is found beside the SCRIPT, not beside the link');
658
+ assert.equal(receipts.length, 1, 'and the review still attests');
659
+ });
660
+
661
+ // The walk that resolves the wrapper's own directory must TERMINATE. A cycle cannot be reached
662
+ // through exec (the kernel resolves the path first, and would fail with ELOOP), so the reachable
663
+ // shape is an absurd CHAIN — and it is testable exactly because the kernel tolerates a far longer
664
+ // chain than this bound. An unbounded walk spins until the harness kills it and prints nothing,
665
+ // so the assertion is on the MESSAGE: an infinite loop can never produce it.
666
+ it('a symlink chain past the hop bound refuses LOUDLY and spends nothing', () => {
667
+ const sb = makeSandbox();
668
+ const chainDir = join(sb.home, 'link-chain');
669
+ mkdirSync(chainDir, { recursive: true });
670
+ const chainTip = Array.from({ length: 24 }).reduce((previous, _unused, i) => {
671
+ const link = join(chainDir, `hop-${i}`);
672
+ symlinkSync(previous, link);
673
+ return link;
674
+ }, WRAPPER);
675
+ const r = run(sb, { args: ['code', '--facts', 'a tiny fact'], wrapper: chainTip });
676
+ rmSync(sb.home, { recursive: true, force: true });
677
+ assert.equal(r.status, 127, `the walk must refuse, not spin: ${r.stderr}`);
678
+ assert.match(r.stderr, /exceeded 16 symlink hops/, 'the refusal names the bound it hit');
679
+ assert.match(r.stderr, /could not resolve its OWN directory/, 'and what that costs');
680
+ assert.equal(r.invoked, false, 'nothing is spent');
681
+ });
682
+
683
+ it('the resume lanes parse the envelope and mint the continuation receipt exactly as before', () => {
684
+ for (const args of [['--continue'], ['--conversation', 'conv-xyz']]) {
685
+ const sb = makeSandbox();
686
+ const r = run(sb, { args, env: { AGY_FAKE_OUTPUT: VERDICT_OUTPUT } });
687
+ const receipts = readReceipts(sb.repo);
688
+ rmSync(sb.home, { recursive: true, force: true });
689
+ assert.equal(r.status, 0, `${args[0]}: ${r.stderr}`);
690
+ assert.equal(r.stdout, `${VERDICT_OUTPUT}\n`, `${args[0]}: stdout is the envelope response`);
691
+ assert.equal(receipts.length, 1);
692
+ assert.equal(receipts[0].fresh, false, `${args[0]}: a continuation still cannot attest`);
693
+ assert.equal(receipts[0].verdict, 'SHIP WITH NITS');
694
+ }
695
+ });
696
+ });
697
+
301
698
  describe('agy-review.sh — guard + grounding (2, 3)', () => {
302
699
  it('the model/cutoff GUARD line is in the captured prompt', () => {
303
700
  const sb = makeSandbox();
@@ -573,7 +970,7 @@ const FED_CAP = 6000;
573
970
  const seedFedChangeSet = (sb, { lines = 400, multibyte = false } = {}) => {
574
971
  const body = Array.from({ length: lines }, (_, i) =>
575
972
  multibyte
576
- ? `строка ${String(i).padStart(4, '0')} — многобайтовый маркер ${'ю'.repeat(20)}`
973
+ ? `line ${String(i).padStart(4, '0')} — multibyte marker ${'\u044e'.repeat(20)}`
577
974
  : `unique change-set line ${String(i).padStart(4, '0')} — a distinctive body marker ${'x'.repeat(20)}`).join('\n');
578
975
  writeFileSync(join(sb.repo, 'oversized.txt'), `${body}\n`);
579
976
  };
@@ -943,29 +1340,77 @@ describe('agy-review.sh — the agy permission fact is surfaced, never applied',
943
1340
  });
944
1341
  });
945
1342
 
1343
+ // ── fed lane: the conversation id comes from the ENVELOPE (Phase 5.1) ────────────────────────────
1344
+ // The id used to be scraped out of agy's own run log — a format that is agy's to change, degrading
1345
+ // to `--continue` when it changed. It is now a NAMED envelope field the reader validates against the
1346
+ // UUID grammar, so the pin that used to rot SILENTLY now fails LOUDLY: there is no `--continue`
1347
+ // fallback left in this lane and no later turn is spent when turn 1 cannot name its conversation.
946
1348
  describe('agy-review.sh — fed lane: turn targeting (D9)', () => {
947
- it('every turn after the first pins the captured conversation id', () => {
1349
+ it('every turn after the first is ROUTED at the id turn 1`s envelope named', () => {
948
1350
  const sb = makeSandbox();
949
1351
  seedFedChangeSet(sb);
950
1352
  const fed = fedRun(sb, { AGY_FAKE_CONV_ID: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' });
951
1353
  rmSync(sb.home, { recursive: true, force: true });
952
1354
  assert.equal(fed.status, 0, fed.stderr);
953
- assert.match(fed.argvs[0], /(^|\n)--log-file(\n|$)/, 'turn 1 asks agy for its run log');
954
1355
  assert.ok(!fed.argvs[0].includes('--conversation'), 'turn 1 is fresh');
1356
+ for (const [i, argv] of fed.argvs.entries()) {
1357
+ assert.ok(!argv.includes('--log-file'), `turn ${i + 1} must not ask for a run log any more: ${argv}`);
1358
+ assert.ok(!argv.includes('--continue'), `turn ${i + 1} must never ride the guessing lane: ${argv}`);
1359
+ }
955
1360
  for (const argv of fed.argvs.slice(1)) {
956
1361
  assert.match(argv, /(^|\n)--conversation(\n|$)/);
957
1362
  assert.match(argv, /(^|\n)aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee(\n|$)/);
958
1363
  }
959
1364
  });
960
1365
 
961
- it('an unparseable log degrades to --continue with a stated notice, not silently', () => {
962
- const sb = makeSandbox();
963
- seedFedChangeSet(sb);
964
- const fed = fedRun(sb, { AGY_FAKE_BAD_CONV_LOG: '1' });
965
- rmSync(sb.home, { recursive: true, force: true });
966
- assert.equal(fed.status, 0, fed.stderr);
967
- assert.match(fed.stderr, /could not capture the conversation id/, 'the degrade is STATED');
968
- for (const argv of fed.argvs.slice(1)) assert.match(argv, /(^|\n)--continue(\n|$)/);
1366
+ // The three ways a NAMED field can still be unusable. Each one would otherwise route every later
1367
+ // turn at an arbitrary conversation, so each stops the run before turn 2 is spent.
1368
+ for (const [name, env] of [
1369
+ ['absent', { AGY_FAKE_CONV_SHAPE: 'missing' }],
1370
+ ['not a string', { AGY_FAKE_CONV_SHAPE: 'number' }],
1371
+ ['failing the UUID grammar', { AGY_FAKE_CONV_ID: 'not-a-conversation-id' }],
1372
+ ]) {
1373
+ it(`a turn-1 conversation id ${name} fails LOUDLY before turn 2, with NO receipt`, () => {
1374
+ const sb = makeSandbox();
1375
+ seedFedChangeSet(sb);
1376
+ const fed = fedRun(sb, env);
1377
+ const receipts = readReceipts(sb.repo);
1378
+ rmSync(sb.home, { recursive: true, force: true });
1379
+ assert.equal(fed.status, 5, `an unusable envelope has the transport code: ${fed.stderr}`);
1380
+ assert.equal(fed.turns, 1, 'no later turn is spent');
1381
+ assert.equal(receipts.length, 0);
1382
+ assert.match(fed.stderr, /agy-envelope: conversation-id/, 'the reader names the field');
1383
+ assert.doesNotMatch(fed.stderr, /--continue/, 'there is no degrade lane to fall back to');
1384
+ });
1385
+ }
1386
+
1387
+ // A LATER turn breaking is a different arm from turn 1 breaking: turn 1 already answered, so the
1388
+ // run has spent real subscription turns and must still stop without attesting anything.
1389
+ for (const [name, env, cause] of [
1390
+ ['whose payload is not an envelope', { AGY_FAKE_BAD_TURN: '2', AGY_FAKE_RAW_STDOUT: 'jetski: not an envelope\n' }, /agy-envelope: not-json/],
1391
+ ['reporting a non-SUCCESS status', { AGY_FAKE_BAD_TURN: '2', AGY_FAKE_STATUS: 'ERROR' }, /agy-envelope: status/],
1392
+ ]) {
1393
+ it(`a fed turn 2 ${name} stops the run: NO receipt, no turn 3`, () => {
1394
+ const sb = makeSandbox();
1395
+ seedFedChangeSet(sb);
1396
+ const fed = fedRun(sb, env);
1397
+ const receipts = readReceipts(sb.repo);
1398
+ rmSync(sb.home, { recursive: true, force: true });
1399
+ assert.equal(fed.status, 5, fed.stderr);
1400
+ assert.equal(fed.turns, 2, 'the failing turn is the last one spent');
1401
+ assert.equal(receipts.length, 0);
1402
+ assert.match(fed.stderr, cause, 'the reader names the cause');
1403
+ assert.match(fed.stderr, /feed turn 2 of/, 'and the wrapper names which turn');
1404
+ });
1405
+ }
1406
+
1407
+ // The doc must not describe BOTH routings at once: a residual recording the retired log scrape
1408
+ // beside the envelope statement leaves an operator unable to tell which lane their build runs.
1409
+ it('the fed-lane doc states envelope routing and keeps no log-scrape residual (doc contract)', () => {
1410
+ const prompt = readFileSync(resolve(HERE, '..', 'references', 'review-prompt.md'), 'utf8');
1411
+ assert.match(prompt, /Later turns are routed by a NAMED field/, 'the live routing is stated');
1412
+ assert.doesNotMatch(prompt, /conversation id is parsed from/i, 'the retired scrape is no longer described as live');
1413
+ assert.doesNotMatch(prompt, /degrades[^\n]*--continue/, 'and neither is its degrade lane');
969
1414
  });
970
1415
  });
971
1416
 
@@ -1289,9 +1734,8 @@ describe('agy-review.sh — size ceiling + gated --add-dir escape (6)', () => {
1289
1734
  const sb = makeSandbox();
1290
1735
  seedFedChangeSet(sb);
1291
1736
  const r = run(sb, { args: ['code', '--facts', 'f'], env: { AGY_MAX_PROMPT_BYTES: String(FED_CAP) } });
1292
- // Turn 1 hands agy `--log-file <staging>/turn1.log`, so the fed lane's own argv names the dir.
1293
- const logFile = r.argvs[0].split('\n')[r.argvs[0].split('\n').indexOf('--log-file') + 1];
1294
- const stagingPath = logFile ? dirname(logFile) : '';
1737
+ // The fed lane dispatches every turn FROM the staging dir, so the fake's own cwd names it.
1738
+ const stagingPath = r.dispatchCwd;
1295
1739
  const stillThere = stagingPath ? existsSync(stagingPath) : false;
1296
1740
  rmSync(sb.home, { recursive: true, force: true });
1297
1741
  assert.ok(stagingPath, 'a staging path was captured from the run');
@@ -1984,7 +2428,7 @@ describe('agy-review.sh — review receipts (AD-038)', () => {
1984
2428
  // The grammar ENUMERATES the ASCII set (no ranges): a locale-collated [A-Za-z] could admit a
1985
2429
  // non-ASCII letter the kit's JS reader then refuses, breaking correlation after a paid run.
1986
2430
  const utf8 = makeSandbox();
1987
- const r2 = run(utf8, { args: ['code', '--facts', 'a tiny fact'], env: { AW_REVIEW_NONCE: 'ré1', LC_ALL: 'en_US.UTF-8', LANG: 'en_US.UTF-8' } });
2431
+ const r2 = run(utf8, { args: ['code', '--facts', 'a tiny fact'], env: { AW_REVIEW_NONCE: 'r\u00e91', LC_ALL: 'en_US.UTF-8', LANG: 'en_US.UTF-8' } });
1988
2432
  rmSync(utf8.home, { recursive: true, force: true });
1989
2433
  assert.equal(r2.status, 2, 'a non-ASCII nonce letter refuses whatever the locale collation says');
1990
2434
  assert.match(r2.stderr, /safe nonce grammar/);