@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
@@ -0,0 +1,235 @@
1
+ // agy-envelope.test.mjs — the envelope parse and the CLI boundary, both driven IN-PROCESS: no
2
+ // subprocess, ever. The parse suites are pure; the CLI suite writes real temporary files under one
3
+ // mkdtemp root, because its failure arms ARE filesystem failures (an unreadable payload, an
4
+ // unwritable destination) and stubbing them would test the stub.
5
+ // Fixtures are INLINE (the packed tarball bans fixtures/ directories) and REDACTED per the plan's
6
+ // Decision 7: no local absolute path, no real conversation id. The payload below is the RECORDED
7
+ // bytes of a live `agy --output-format json` run with only its id replaced, so the test really
8
+ // parses what the CLI printed rather than something this file serialized for itself.
9
+
10
+ import { describe, it, after } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { parseAgyEnvelope, runEnvelopeCli } from './agy-envelope.mjs';
16
+
17
+ const REDACTED_ID = '00000000-1111-2222-3333-444444444444';
18
+
19
+ const RECORDED_RESPONSE = [
20
+ '### Delivery proof',
21
+ 'part 1 line 7: alpha-bravo-charlie-delta',
22
+ 'Requested addresses, one per line:',
23
+ 'part 1 line 7',
24
+ '### Verdict',
25
+ 'SHIP — probe run, nothing reviewed.',
26
+ '### Blocking',
27
+ 'none',
28
+ '### Non-blocking',
29
+ 'none',
30
+ '### Questions',
31
+ 'none',
32
+ '',
33
+ ].join('\n');
34
+
35
+ const RECORDED_PAYLOAD = `{"conversation_id":"${REDACTED_ID}","status":"SUCCESS","response":"### Delivery proof\\npart 1 line 7: alpha-bravo-charlie-delta\\nRequested addresses, one per line:\\npart 1 line 7\\n### Verdict\\nSHIP — probe run, nothing reviewed.\\n### Blocking\\nnone\\n### Non-blocking\\nnone\\n### Questions\\nnone\\n","duration_seconds":3.640368748,"num_turns":1,"usage":{"input_tokens":16398,"output_tokens":187,"thinking_tokens":118,"cache_read_tokens":0,"total_tokens":16585}}`;
36
+
37
+ const withFields = (fields) => JSON.stringify({ conversation_id: REDACTED_ID, status: 'SUCCESS', response: 'body', ...fields });
38
+
39
+ describe('agy-envelope — a recorded envelope parses', () => {
40
+ it('yields the response text byte-for-byte and the conversation id', () => {
41
+ const result = parseAgyEnvelope(RECORDED_PAYLOAD, { requireConversationId: true });
42
+ assert.equal(result.ok, true, result.sentence);
43
+ assert.equal(result.response, RECORDED_RESPONSE, 'the model Markdown survives the envelope VERBATIM');
44
+ assert.equal(result.conversationId, REDACTED_ID);
45
+ });
46
+
47
+ it('an EMPTY response is a real answer, not an unreadable envelope (the verdict-less arm owns it)', () => {
48
+ const result = parseAgyEnvelope(withFields({ response: '' }));
49
+ assert.equal(result.ok, true, result.sentence);
50
+ assert.equal(result.response, '');
51
+ });
52
+ });
53
+
54
+ describe('agy-envelope — every failure carries its OWN named cause', () => {
55
+ for (const [name, payload, cause] of [
56
+ ['empty input', '', 'empty-payload'],
57
+ ['a non-JSON blob', 'jetski: no output produced\n', 'not-json'],
58
+ ['JSON that is not one object', '[{"status":"SUCCESS","response":"body"}]', 'not-an-envelope'],
59
+ // `null` is a LEGAL JSON document and the obvious failure sentinel at once — it must report
60
+ // what it actually is, or the operator hunts a transport bug that is really a shape bug.
61
+ ['the literal JSON document `null`', 'null', 'not-an-envelope'],
62
+ ['a bare JSON string', '"just a string"', 'not-an-envelope'],
63
+ ['a bare JSON number', '42', 'not-an-envelope'],
64
+ ['valid JSON with no response field', '{"conversation_id":"x","status":"SUCCESS"}', 'response'],
65
+ ['a non-string response', withFields({ response: 42 }), 'response'],
66
+ ['a non-SUCCESS status', withFields({ status: 'ERROR' }), 'status'],
67
+ ['an absent status', '{"response":"body"}', 'status'],
68
+ ]) {
69
+ it(`${name} → cause "${cause}"`, () => {
70
+ const result = parseAgyEnvelope(payload);
71
+ assert.equal(result.ok, false, `${name} must not parse`);
72
+ assert.equal(result.cause, cause);
73
+ assert.ok(result.sentence.length > 0, 'a cause without a sentence tells the operator nothing');
74
+ });
75
+ }
76
+ });
77
+
78
+ // The id is required ONLY when the caller declares it will route a later turn at this conversation.
79
+ // Failing a single-turn review over a field it never uses would be a refusal with no defect behind it.
80
+ describe('agy-envelope — the conversation id is validated exactly when it is needed', () => {
81
+ for (const [name, payload] of [
82
+ ['an absent id', '{"status":"SUCCESS","response":"body"}'],
83
+ ['a non-string id', withFields({ conversation_id: 12345 })],
84
+ ['an id failing the UUID grammar', withFields({ conversation_id: 'not-a-uuid' })],
85
+ ['a TRUNCATED uuid', withFields({ conversation_id: '00000000-1111-2222-3333-4444444444' })],
86
+ ]) {
87
+ it(`${name} fails with cause "conversation-id" when routing is required`, () => {
88
+ const result = parseAgyEnvelope(payload, { requireConversationId: true });
89
+ assert.equal(result.ok, false);
90
+ assert.equal(result.cause, 'conversation-id');
91
+ });
92
+
93
+ it(`${name} parses fine when routing is NOT required`, () => {
94
+ const result = parseAgyEnvelope(payload);
95
+ assert.equal(result.ok, true, result.sentence);
96
+ assert.equal(result.response, 'body');
97
+ assert.equal(result.conversationId, '', 'an unrequested id is never handed back as if it were validated');
98
+ });
99
+ }
100
+ });
101
+
102
+ // ── evidence: the plan's REJECTED and DEFERRED decisions stay checkable ──────────────────────────
103
+ // STRUCTURAL EXCERPTS of two recorded probes (redacted). Nothing here is claimed byte-complete —
104
+ // only the fields the Appendix really recorded are asserted.
105
+
106
+ // Probe C: probe A's prompt with a schema ON — the matched control for the schema-cost claim.
107
+ const SCHEMA_RUN_RESULT = {
108
+ conversation_id: REDACTED_ID,
109
+ status: 'SUCCESS',
110
+ response: 'the full prose review, with the structured JSON appended as trailing text',
111
+ duration_seconds: 6.718974944,
112
+ num_turns: 2,
113
+ usage: { input_tokens: 33165, output_tokens: 281, thinking_tokens: 185, cache_read_tokens: 0, total_tokens: 33446 },
114
+ };
115
+
116
+ // The stream-json `init` event of the same run — the ONLY place the ACTUALLY resolved model appears.
117
+ const STREAM_INIT_EVENT = {
118
+ event: 'init',
119
+ conversation_id: REDACTED_ID,
120
+ init: { model: 'Gemini 3.7 Flash (High)', cwd: '<redacted local path>', permission_mode: 'request-review' },
121
+ };
122
+
123
+ // ── the CLI boundary, driven IN-PROCESS ──────────────────────────────────────────────────────────
124
+ // Every CLI arm RETURNS its outcome, so each one is exercised here rather than through a spawn.
125
+ // That is not a convenience: coverage is collected in the test process, so an arm reachable only
126
+ // through a subprocess is an arm nothing can prove was ever run. Real temp files, no subprocess.
127
+ const CLI_ROOT = mkdtempSync(join(tmpdir(), 'agy-envelope-cli-'));
128
+ after(() => rmSync(CLI_ROOT, { recursive: true, force: true }));
129
+
130
+ const cliCase = (name, payload) => {
131
+ const dir = join(CLI_ROOT, name);
132
+ mkdirSync(dir, { recursive: true });
133
+ const envelope = join(dir, 'envelope.json');
134
+ writeFileSync(envelope, payload);
135
+ return { envelope, responseOut: join(dir, 'response.txt'), conversationIdOut: join(dir, 'conv.txt'), dir };
136
+ };
137
+
138
+ describe('agy-envelope — the CLI boundary returns an outcome for every arm', () => {
139
+ it('a complete invocation writes the response VERBATIM and the conversation id, exit 0', () => {
140
+ const c = cliCase('ok', RECORDED_PAYLOAD);
141
+ const outcome = runEnvelopeCli(['--envelope', c.envelope, '--response-out', c.responseOut, '--conversation-id-out', c.conversationIdOut]);
142
+ assert.equal(outcome.code, 0, outcome.message);
143
+ assert.equal(readFileSync(c.responseOut, 'utf8'), RECORDED_RESPONSE);
144
+ assert.equal(readFileSync(c.conversationIdOut, 'utf8'), REDACTED_ID);
145
+ });
146
+
147
+ it('without --conversation-id-out no id file is written and the id is never required', () => {
148
+ const c = cliCase('no-id', withFields({ conversation_id: 'not-a-uuid' }));
149
+ const outcome = runEnvelopeCli(['--envelope', c.envelope, '--response-out', c.responseOut]);
150
+ assert.equal(outcome.code, 0, outcome.message);
151
+ assert.equal(readFileSync(c.responseOut, 'utf8'), 'body');
152
+ });
153
+
154
+ for (const [name, argv, fragment] of [
155
+ ['an unknown argument', ['--bogus', 'x'], "unknown argument '--bogus'"],
156
+ ['a flag with no value', ['--envelope'], '--envelope needs a value'],
157
+ ['a flag swallowing the next flag', ['--envelope', '--response-out'], '--envelope needs a value'],
158
+ ['a missing --envelope', ['--response-out', '/dev/null'], '--envelope is required'],
159
+ ['a missing --response-out', ['--envelope', '/dev/null'], '--response-out is required'],
160
+ ]) {
161
+ it(`${name} is a USAGE refusal (exit 2) carrying the usage text`, () => {
162
+ const outcome = runEnvelopeCli(argv);
163
+ assert.equal(outcome.code, 2, `${name}: ${outcome.message}`);
164
+ assert.ok(outcome.message.includes(fragment), `${name}: ${outcome.message}`);
165
+ assert.ok(outcome.message.includes('Usage:'), 'a usage refusal prints the usage');
166
+ });
167
+ }
168
+
169
+ it('an unreadable payload (a DIRECTORY at the path) is the named unreadable-payload cause', () => {
170
+ const c = cliCase('unreadable', '{}');
171
+ const outcome = runEnvelopeCli(['--envelope', c.dir, '--response-out', c.responseOut]);
172
+ assert.equal(outcome.code, 1, outcome.message);
173
+ assert.ok(outcome.message.includes('unreadable-payload'), outcome.message);
174
+ });
175
+
176
+ it('a payload that is not valid UTF-8 is the named not-utf8 cause', () => {
177
+ const dir = join(CLI_ROOT, 'not-utf8');
178
+ mkdirSync(dir, { recursive: true });
179
+ const envelope = join(dir, 'envelope.json');
180
+ writeFileSync(envelope, Buffer.from([0x7b, 0xff, 0xfe, 0x7d]));
181
+ const outcome = runEnvelopeCli(['--envelope', envelope, '--response-out', join(dir, 'r.txt')]);
182
+ assert.equal(outcome.code, 1, outcome.message);
183
+ assert.ok(outcome.message.includes('not-utf8'), outcome.message);
184
+ });
185
+
186
+ it('a parse failure rides out as its own named cause (exit 1), not as usage', () => {
187
+ const c = cliCase('bad-json', 'not json at all');
188
+ const outcome = runEnvelopeCli(['--envelope', c.envelope, '--response-out', c.responseOut]);
189
+ assert.equal(outcome.code, 1, outcome.message);
190
+ assert.ok(outcome.message.includes('not-json'), outcome.message);
191
+ });
192
+
193
+ it('an unwritable response destination is the named write-failed cause', () => {
194
+ const c = cliCase('unwritable-response', RECORDED_PAYLOAD);
195
+ const outcome = runEnvelopeCli(['--envelope', c.envelope, '--response-out', join(c.dir, 'no-such-dir', 'r.txt')]);
196
+ assert.equal(outcome.code, 1, outcome.message);
197
+ assert.ok(outcome.message.includes('write-failed'), outcome.message);
198
+ assert.ok(outcome.message.includes('response'), 'the failing field is named');
199
+ });
200
+
201
+ it('an unwritable conversation-id destination is named too — the response write already succeeded', () => {
202
+ const c = cliCase('unwritable-id', RECORDED_PAYLOAD);
203
+ const outcome = runEnvelopeCli([
204
+ '--envelope', c.envelope, '--response-out', c.responseOut,
205
+ '--conversation-id-out', join(c.dir, 'no-such-dir', 'conv.txt'),
206
+ ]);
207
+ assert.equal(outcome.code, 1, outcome.message);
208
+ assert.ok(outcome.message.includes('conversation_id'), outcome.message);
209
+ assert.equal(readFileSync(c.responseOut, 'utf8'), RECORDED_RESPONSE, 'the response was already written');
210
+ });
211
+ });
212
+
213
+ describe('agy-envelope — recorded evidence for the rejected and deferred decisions', () => {
214
+ it('--json-schema is REJECTED: it buys a SECOND billed turn, not a constrained decode', () => {
215
+ const plain = JSON.parse(RECORDED_PAYLOAD);
216
+ assert.equal(plain.num_turns, 1, 'the plain envelope answers in ONE turn');
217
+ assert.equal(SCHEMA_RUN_RESULT.num_turns, 2, 'the schema run answers in TWO — the model restates its own prose');
218
+ assert.ok(
219
+ SCHEMA_RUN_RESULT.usage.total_tokens > 2 * plain.usage.total_tokens,
220
+ `matched control: ${plain.usage.total_tokens} tokens without a schema against ${SCHEMA_RUN_RESULT.usage.total_tokens} with one`,
221
+ );
222
+ });
223
+
224
+ it('stream-json is DEFERRED for a real gain: the RESOLVED model rides `init` and nothing else', () => {
225
+ assert.equal(typeof STREAM_INIT_EVENT.init.model, 'string', 'the init event names the model that actually answered');
226
+ assert.ok(!Object.hasOwn(JSON.parse(RECORDED_PAYLOAD), 'model'), 'the plain envelope carries no resolved model');
227
+ assert.ok(!Object.hasOwn(SCHEMA_RUN_RESULT, 'model'), 'and neither does the stream-json result event');
228
+ });
229
+
230
+ it('the parse ignores the envelope fields it does not need — an added CLI field is not a breakage', () => {
231
+ const result = parseAgyEnvelope(JSON.stringify({ ...SCHEMA_RUN_RESULT, structured_output: { verdict: 'REWORK' } }));
232
+ assert.equal(result.ok, true, result.sentence);
233
+ assert.equal(result.response, SCHEMA_RUN_RESULT.response);
234
+ });
235
+ });
@@ -16,11 +16,33 @@ import { spawnSync } from 'node:child_process';
16
16
  const HERE = dirname(fileURLToPath(import.meta.url));
17
17
  const WRAPPER = join(HERE, 'agy-review.sh');
18
18
 
19
+ // Same shape as the sibling spec's fake, minimally: --help / --version answer the wrapper's
20
+ // pre-spend capability door WITHOUT touching the invocation sentinel (a probe is not a paid
21
+ // dispatch), and the answer leaves as an `--output-format json` envelope when the dispatch asked
22
+ // for one — the model's text rides `response` VERBATIM.
23
+ const FAKE_ENVELOPE_ENCODER = [
24
+ 'const text = require("node:fs").readFileSync(0, "utf8");',
25
+ 'const envelope = { conversation_id: "11111111-2222-3333-4444-555555555555", status: "SUCCESS",',
26
+ ' response: text, duration_seconds: 1.5, num_turns: 1,',
27
+ ' usage: { input_tokens: 10, output_tokens: 5, thinking_tokens: 0, cache_read_tokens: 0, total_tokens: 15 } };',
28
+ 'process.stdout.write(`${JSON.stringify(envelope)}\\n`);',
29
+ ].join('\n');
30
+
19
31
  const FAKE_AGY = [
20
32
  '#!/usr/bin/env bash',
21
33
  'set -u',
34
+ 'case "${1:-}" in',
35
+ ' --help|-h) printf " --output-format\\n --disable-slash-commands\\n"; exit 0 ;;',
36
+ ' --version) printf "1.1.13\\n"; exit 0 ;;',
37
+ 'esac',
22
38
  'printf invoked > "${AGY_FAKE_SENTINEL:-/dev/null}"',
23
- 'printf "%s\\n" "${AGY_FAKE_OUTPUT:-### Verdict}"',
39
+ 'aw_fmt=""',
40
+ 'prev=""; for a in "$@"; do if [[ "$prev" == "--output-format" ]]; then aw_fmt="$a"; fi; prev="$a"; done',
41
+ 'if [[ "$aw_fmt" == "json" ]]; then',
42
+ ` printf "%s\\n" "\${AGY_FAKE_OUTPUT:-### Verdict}" | node -e '${FAKE_ENVELOPE_ENCODER}'`,
43
+ 'else',
44
+ ' printf "%s\\n" "${AGY_FAKE_OUTPUT:-### Verdict}"',
45
+ 'fi',
24
46
  'exit 0',
25
47
  '',
26
48
  ].join('\n');
@@ -80,6 +80,18 @@ Grounding:
80
80
  AGY_PROBE=1); plan/diff proceed with a loud warning
81
81
 
82
82
  Notes:
83
+ transport: every review dispatch drives the CLI in --output-format json (plus
84
+ --disable-slash-commands) and the returned envelope is parsed in node (bin/agy-envelope.mjs) — the
85
+ operator-facing invocations and flags above do NOT change, and on a ZERO exit the wrapper still
86
+ PRINTS the review text, never JSON. A missing or unreadable envelope on a zero exit is a loud
87
+ failure with NO receipt, never a downgraded verdict and never a fallback to raw-stdout parsing; a
88
+ non-zero CLI exit keeps its own code and message, and publishes the captured stdout unchanged from
89
+ the SINGLE dispatch or the FINAL fed turn (which may therefore be a JSON or partial payload — the
90
+ envelope is parsed only on a zero exit); an INTERMEDIATE feed turn is the exception, its output
91
+ stays private (Invariant E) and its failure prints only a named error. Enforced by a PRE-SPEND
92
+ capability probe, not a version floor: agy --help must advertise --output-format and
93
+ --disable-slash-commands, node must be >= 22, and bin/agy-envelope.mjs must be present — otherwise
94
+ the review refuses before any run is spent and names the missing capability
83
95
  pre-dispatch host-diff: before the FIRST dispatch of this bridge, diff its declared networkHosts
84
96
  against the live sandbox allow-list — a missing host is surfaced to the maintainer BEFORE
85
97
  dispatching, never fired into a known prompt
@@ -326,7 +338,7 @@ DEFAULT_AGY_REVIEW_MODEL="Gemini 3.7 Flash (High)"
326
338
  # Review-receipt identity (AD-038). AW_BRIDGE_VERSION mirrors this bridge's SKILL.md/capability.json
327
339
  # version (drift-guarded by agy-review.test.mjs against capability.json).
328
340
  AW_RECEIPT_BACKEND="agy"
329
- AW_BRIDGE_VERSION="5.2.0" # aw-version-anchor
341
+ AW_BRIDGE_VERSION="5.3.0" # aw-version-anchor
330
342
  # `-` not `:-` so an EXPLICIT empty AGY_MODEL= survives (drop --model, use settings.json — agy.sh:52).
331
343
  AGY_MODEL="${AGY_MODEL-$DEFAULT_AGY_REVIEW_MODEL}"
332
344
  # D5 control-byte screen — IMMEDIATELY after resolution, BEFORE the off-frontier advisory (or any
@@ -455,7 +467,68 @@ FED_ECHO_MAX_BYTES=200
455
467
  # address nobody requested and FAILS the proof, rather than vanishing from the check entirely.
456
468
  FED_PROOF_ADDRESS_MAX=1000000000
457
469
 
458
- HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
470
+ # This wrapper's REAL directory, symlinks RESOLVED. The managed install puts a symlink on PATH
471
+ # (~/.local/bin/agy-review -> <placed skill>/bin/agy-review.sh), so an unresolved BASH_SOURCE names
472
+ # the LINK's directory — and every sibling payload below, the envelope reader most of all, would be
473
+ # looked for beside the link instead of beside the script. `readlink -f` is GNU-only (macOS ships
474
+ # BSD readlink), so the chain is walked by hand; `cd -P` resolves a symlinked directory too.
475
+ # The walk is HOP-BOUNDED with a loud refusal, never an unbounded loop. A symlink CYCLE cannot be
476
+ # reached by construction — the kernel already resolved this path to exec the script and would have
477
+ # failed with ELOOP — but it becomes reachable if a link is rewritten in the window between exec and
478
+ # this walk, and an unbounded `while` would then spin until something killed it. Past the bound the
479
+ # wrapper refuses BY NAME rather than guessing at a directory: a wrong answer here sends every
480
+ # sibling lookup somewhere arbitrary. 16 is far above any managed install (which uses ONE hop) and
481
+ # below the kernel's own chain limit, so it never truncates a real chain.
482
+ AW_SCRIPT_LINK_MAX_HOPS=16
483
+ aw_script_dir() {
484
+ local src="${BASH_SOURCE[0]}" dir hops=0
485
+ while [[ -L "$src" ]]; do
486
+ if (( hops >= AW_SCRIPT_LINK_MAX_HOPS )); then
487
+ echo "error: resolving this wrapper's own path exceeded ${AW_SCRIPT_LINK_MAX_HOPS} symlink hops at" >&2
488
+ echo " '$src' — a symlink cycle, or an absurd chain." >&2
489
+ return 1
490
+ fi
491
+ hops=$(( hops + 1 ))
492
+ dir="$(cd -P "$(dirname "$src")" && pwd)" || return 1
493
+ src="$(readlink "$src")" || return 1
494
+ case "$src" in /*) ;; *) src="$dir/$src" ;; esac
495
+ done
496
+ ( cd -P "$(dirname "$src")" && pwd )
497
+ }
498
+ if ! HERE="$(aw_script_dir)"; then
499
+ echo "error: this wrapper could not resolve its OWN directory, so the sibling payload every review" >&2
500
+ echo " depends on cannot be located. Refusing before anything is spent — repair the link, or" >&2
501
+ echo " invoke the real script path." >&2
502
+ exit 127
503
+ fi
504
+
505
+ # --- The dispatch transport (AGY-1.1.13, Decision 1) ---------------------------
506
+ # EVERY review dispatch runs `--output-format json`: the CLI then answers with ONE envelope whose
507
+ # `response` carries the model's Markdown VERBATIM, so the wrapper reads a NAMED FIELD instead of
508
+ # guessing at raw stdout. The review CONTRACT is untouched — the model is asked for exactly what it
509
+ # was asked for before; only the wrapper's reading of the answer moved.
510
+ # `--disable-slash-commands` rides the same array: a change set is delivered as prompt TEXT, and a
511
+ # body line that happens to begin with a slash command is body — never an instruction for the CLI to
512
+ # expand. It is also what makes the pre-spend door below honest: that door refuses an install whose
513
+ # --help does not advertise this flag, and a door may never require a capability the wrapper leaves
514
+ # unused.
515
+ AGY_TRANSPORT_FLAGS=(--output-format json --disable-slash-commands)
516
+ AGY_ENVELOPE_READER="$HERE/agy-envelope.mjs"
517
+ # A captured answer that is not a readable envelope on a ZERO exit (Decision 3). Its own code, not
518
+ # the D4 verdict-less 4: those are different failures and an operator must be able to tell them
519
+ # apart — D4 means the model answered without a verdict, this means the CLI's answer never arrived
520
+ # in the shape this wrapper reads.
521
+ AGY_ENVELOPE_EXIT=5
522
+
523
+ # Read ONE captured envelope through the sibling module. This PRINTS NOTHING on success — the caller
524
+ # decides what reaches stdout, which is exactly what keeps Invariant E intact when a feed turn's
525
+ # envelope has to be validated without ever becoming operator-visible. A failure is loud and carries
526
+ # the module's own named cause on stderr.
527
+ read_agy_envelope() { # $1 = captured payload, $2 = response destination, $3 = "" | conversation-id destination
528
+ local args=(--envelope "$1" --response-out "$2")
529
+ if [[ -n "${3:-}" ]]; then args+=(--conversation-id-out "$3"); fi
530
+ node "$AGY_ENVELOPE_READER" "${args[@]}"
531
+ }
459
532
 
460
533
  # --- Subscription invariant (reuse agy.sh's security pattern verbatim) --------
461
534
  export PATH="$HOME/.local/bin:$PATH"
@@ -499,6 +572,106 @@ if ! grep -q "AGY_REQUIRE_TIMEOUT_BIN" "$aw_child_path" 2>/dev/null; then
499
572
  exit 127
500
573
  fi
501
574
 
575
+ # --- Pre-spend capability door (AGY-1.1.13, Decision 2) ------------------------
576
+ # This wrapper drives the CLI in `--output-format json`, passes --disable-slash-commands, and reads
577
+ # the returned envelope in node. A host that cannot do all three must refuse BEFORE a subscription
578
+ # turn is spent — never after a paid run returns something unreadable.
579
+ # A version FLOOR is deliberately NOT the door: the release that introduced --output-format is not
580
+ # measurable from one installed build, so a guessed floor would refuse working installs. The door
581
+ # probes the CAPABILITY the dispatch actually uses instead — the flags `agy --help` advertises, plus
582
+ # a usable node. A probe that FAILS is never read as "capability present" (fail closed).
583
+ AW_REQUIRED_AGY_FLAGS=(--output-format --disable-slash-commands)
584
+ AW_MIN_NODE_MAJOR=22
585
+ aw_agy_version() {
586
+ local v
587
+ v="$(agy --version 2>/dev/null | LC_ALL=C awk 'NR == 1 { print }' || true)"
588
+ printf '%s' "${v:-unknown}"
589
+ }
590
+ set +e
591
+ aw_help_probe="$(agy --help 2>&1)"
592
+ aw_help_rc=$?
593
+ set -e
594
+ if (( aw_help_rc != 0 )); then
595
+ echo "error: could not read the agy capability list — 'agy --help' exited ${aw_help_rc}. A failed probe is" >&2
596
+ echo " NEVER read as 'capability present', so this review refuses BEFORE any run is spent." >&2
597
+ echo " Installed agy version: $(aw_agy_version). Repair the install, then re-run; to update: agy update" >&2
598
+ exit 127
599
+ fi
600
+ # The DECLARED option tokens of the probed help, never a substring search: `--output-formatting`, or
601
+ # the flag merely NAMED in prose, would both open the door to a build that cannot honour the flag.
602
+ # A line contributes only when its first non-space character is a dash, and only its DECLARATION
603
+ # SEGMENT counts — the part before the description column, which help renderers separate by a run of
604
+ # two or more spaces or a tab. Inside that segment EVERY dash-token declares (so `-o FORMAT,
605
+ # --output-format FORMAT` declares both and a metavar declares nothing), and `--flag=<value>`
606
+ # declares `--flag`.
607
+ # Stated residual: a renderer that separates its columns by a SINGLE space has no machine-readable
608
+ # boundary at all, and a description beginning with a flag name would read as a declaration there.
609
+ # No such rendering is known; the guarantee below is the column convention, not a parse of English.
610
+ aw_declared_flags="$(printf '%s\n' "$aw_help_probe" | LC_ALL=C awk '
611
+ {
612
+ line = $0
613
+ sub(/^[ \t]+/, "", line)
614
+ if (substr(line, 1, 1) != "-") next
615
+ if (match(line, / +|\t/)) line = substr(line, 1, RSTART - 1)
616
+ n = split(line, token, /[ \t,]+/)
617
+ for (i = 1; i <= n; i++) {
618
+ t = token[i]
619
+ sub(/=.*$/, "", t)
620
+ if (substr(t, 1, 1) == "-") print t
621
+ }
622
+ }')"
623
+ # What each required capability BUYS — the refusal names the cost of the flag that is actually
624
+ # missing. One blanket explanation would be false for half of them: a build without
625
+ # --disable-slash-commands answers perfectly readably and corrupts the delivered BODY instead, and an
626
+ # operator sent hunting an unreadable envelope would be hunting the wrong bug.
627
+ # The subject is a NAMED local, never "$1": the source-level reverse guard reads every `case "$1"`
628
+ # as an argument-parser arm, and this table is not one.
629
+ aw_agy_flag_cost() { # $1 = required flag
630
+ local flag="$1"
631
+ case "$flag" in
632
+ --output-format) printf '%s' 'the answer arrives as ONE JSON envelope this wrapper reads; without it a spent turn answers in a shape the wrapper cannot read' ;;
633
+ --disable-slash-commands) printf '%s' 'a change-set line beginning with a slash command stays BODY; without it the CLI expands it and the model reviews something other than the delivered bytes' ;;
634
+ *) printf '%s' 'this dispatch passes the flag and the installed build does not declare it' ;;
635
+ esac
636
+ }
637
+ aw_missing_flags=()
638
+ for _flag in "${AW_REQUIRED_AGY_FLAGS[@]}"; do
639
+ case $'\n'"$aw_declared_flags"$'\n' in
640
+ *$'\n'"$_flag"$'\n'*) ;;
641
+ *) aw_missing_flags+=("$_flag") ;;
642
+ esac
643
+ done
644
+ if (( ${#aw_missing_flags[@]} > 0 )); then
645
+ echo "error: the installed agy CLI does not advertise the flag(s) this review dispatch passes: ${aw_missing_flags[*]}" >&2
646
+ echo " Refusing BEFORE any run is spent — what each missing capability costs:" >&2
647
+ for _flag in "${aw_missing_flags[@]}"; do
648
+ echo " ${_flag} — $(aw_agy_flag_cost "$_flag")" >&2
649
+ done
650
+ echo " Installed agy version: $(aw_agy_version). Update the CLI: agy update" >&2
651
+ exit 127
652
+ fi
653
+ if ! command -v node >/dev/null 2>&1; then
654
+ echo "error: 'node' is not on PATH, and this review parses agy's JSON envelope in node (Node >= ${AW_MIN_NODE_MAJOR})." >&2
655
+ echo " Refusing BEFORE any run is spent. Install Node >= ${AW_MIN_NODE_MAJOR}, then re-run." >&2
656
+ exit 127
657
+ fi
658
+ aw_node_version="$(node --version 2>/dev/null || true)"
659
+ aw_node_major="${aw_node_version#v}"
660
+ aw_node_major="${aw_node_major%%.*}"
661
+ if [[ ! "$aw_node_major" =~ ^[0-9]+$ ]] || (( aw_node_major < AW_MIN_NODE_MAJOR )); then
662
+ echo "error: node '${aw_node_version:-<unreadable>}' is below the family floor (Node >= ${AW_MIN_NODE_MAJOR}) this review needs" >&2
663
+ echo " to parse agy's JSON envelope. Refusing BEFORE any run is spent." >&2
664
+ echo " Upgrade Node to >= ${AW_MIN_NODE_MAJOR}, then re-run." >&2
665
+ exit 127
666
+ fi
667
+ if [[ ! -f "$AGY_ENVELOPE_READER" ]]; then
668
+ echo "error: the envelope reader '$AGY_ENVELOPE_READER' is missing — every review reads agy's JSON" >&2
669
+ echo " envelope through it, so a bridge install without it could only fail AFTER a paid run." >&2
670
+ echo " Refusing BEFORE any run is spent. Refresh the placed bridges:" >&2
671
+ echo " /agent-workflow-kit setup --refresh-placed" >&2
672
+ exit 127
673
+ fi
674
+
502
675
  # --- Model policy (advisory, NOT a gate) -------------------------------------
503
676
  is_frontier=0
504
677
  for _m in "${FRONTIER_SET[@]}"; do
@@ -1026,20 +1199,6 @@ fixed_occurrences() {
1026
1199
  printf '%s' "$(( n ))"
1027
1200
  }
1028
1201
 
1029
- # The conversation id agy writes into its own run log (D9, Arm A). The format is agy's own to change,
1030
- # which is exactly why an unparseable log DEGRADES LOUDLY to --continue instead of failing: the
1031
- # correctness guarantee is the D1 echo proof, which fails closed if the wrong conversation answers.
1032
- capture_conversation_id() { # $1 = run log → the id, or empty
1033
- local id
1034
- [[ -f "$1" ]] || { printf ''; return 0; }
1035
- id="$(LC_ALL=C awk '/onversation/ && match($0, /[0-9a-fA-F]+-[0-9a-fA-F]+-[0-9a-fA-F]+-[0-9a-fA-F]+-[0-9a-fA-F]+/) { print substr($0, RSTART, RLENGTH); exit }' "$1")"
1036
- if [[ "$id" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then
1037
- printf '%s' "$id"
1038
- else
1039
- printf ''
1040
- fi
1041
- }
1042
-
1043
1202
  # A validated duration string → integer seconds (floor 1). The fed lane needs arithmetic on the cap
1044
1203
  # because ONE wall-clock budget has to cover N+1 turns; the banner keeps printing the duration
1045
1204
  # verbatim, so this conversion never changes what the user is told about the cap itself.
@@ -1186,7 +1345,8 @@ verify_delivery_proof() { # $1 = captured final-turn output
1186
1345
  # produced (an OK, or a premature verdict) reaches this wrapper's stdout or the parsed capture, and
1187
1346
  # the FIRST non-zero feed turn stops the run so no later turn is spent.
1188
1347
  run_fed_review() {
1189
- local k conv_id="" log_file="$staging/turn1.log" feed_out="$staging/feed-turn-out" turn_rc turn_pass=()
1348
+ local k conv_id="" conv_id_file="$staging/turn1-conversation-id" feed_out="$staging/feed-turn-out" turn_rc turn_pass=()
1349
+ local final_envelope="$staging/final-envelope"
1190
1350
  # ONE wall-clock budget for the WHOLE fed review, not one per turn. Handing every call the full
1191
1351
  # AGY_HARD_TIMEOUT multiplied the stated cap by the turn count — a 30m cap silently became up to
1192
1352
  # 30m × (N+1). Each turn now gets only what is LEFT of the shared deadline, and a review that runs
@@ -1214,9 +1374,8 @@ run_fed_review() {
1214
1374
  budget=$(( remaining - AGY_TURN_KILL_GRACE_S ))
1215
1375
  turn_hard="${budget}s"
1216
1376
  turn_soft="$(( soft_budget < budget ? soft_budget : budget ))s"
1217
- if (( k == 1 )); then turn_pass=(--log-file "$log_file")
1218
- elif [[ -n "$conv_id" ]]; then turn_pass=(--conversation "$conv_id")
1219
- else turn_pass=(--continue)
1377
+ if (( k == 1 )); then turn_pass=()
1378
+ else turn_pass=(--conversation "$conv_id")
1220
1379
  fi
1221
1380
  set +e
1222
1381
  # Dispatched from the STAGING dir, never the work tree: agy surfaces the cwd's context file
@@ -1225,7 +1384,7 @@ run_fed_review() {
1225
1384
  # byte the model can read, so the fed lane removes the uncontrolled input instead of guessing
1226
1385
  # at it. Nothing is lost: the grounded facts already carry what the review must know.
1227
1386
  ( cd "$staging" && AGY_MODEL="$AGY_MODEL" AGY_TIMEOUT="$turn_soft" AGY_HARD_TIMEOUT="$turn_hard" \
1228
- "$AGY_RUN" "@$staging/turn-$k" -- "${turn_pass[@]}" ) > "$feed_out"
1387
+ "$AGY_RUN" "@$staging/turn-$k" -- "${AGY_TRANSPORT_FLAGS[@]}" "${turn_pass[@]}" ) > "$feed_out"
1229
1388
  turn_rc=$?
1230
1389
  set -e
1231
1390
  if (( turn_rc != 0 )); then
@@ -1233,16 +1392,34 @@ run_fed_review() {
1233
1392
  echo " delivered, so no later turn is spent and NO receipt is written. Re-run the review." >&2
1234
1393
  return "$turn_rc"
1235
1394
  fi
1395
+ # Invariant E: a feed turn's envelope is validated PRIVATELY. Its response — an OK, or the
1396
+ # premature verdict a misbehaving turn might emit — reaches neither stdout nor the parsed
1397
+ # capture; only its READABILITY is consumed here. A turn that exited 0 without answering in the
1398
+ # shape this wrapper reads stops the run before any later turn is spent.
1399
+ # Turn 1 ALSO takes the conversation id, from the envelope's NAMED field, validated against the
1400
+ # UUID grammar by the reader. Every later turn is then routed at THIS conversation — there is no
1401
+ # --continue lane left to guess with, so an id that is absent, wrong-typed or malformed is the
1402
+ # same class of loud stop as an unreadable envelope, before turn 2 is spent.
1403
+ set +e
1236
1404
  if (( k == 1 )); then
1237
- conv_id="$(capture_conversation_id "$log_file")"
1238
- if [[ -z "$conv_id" ]]; then
1239
- echo "notice: could not capture the conversation id from agy's run log — the remaining turns ride" >&2
1240
- echo " --continue instead. Stated, never silent; the delivery proof still fails closed if" >&2
1241
- echo " another conversation answers." >&2
1242
- fi
1405
+ read_agy_envelope "$feed_out" "$staging/feed-turn-response" "$conv_id_file"
1406
+ else
1407
+ read_agy_envelope "$feed_out" "$staging/feed-turn-response"
1243
1408
  fi
1409
+ turn_rc=$?
1410
+ set -e
1411
+ if (( turn_rc != 0 )); then
1412
+ echo "error: feed turn ${k} of ${FED_PART_COUNT} exited 0 but its answer is not a usable agy JSON" >&2
1413
+ echo " envelope — unreadable, or (turn 1) carrying no routable conversation id; cause above." >&2
1414
+ echo " Delivery cannot be confirmed, so no later turn is spent and NO receipt is written." >&2
1415
+ echo " Re-run the review." >&2
1416
+ return "$AGY_ENVELOPE_EXIT"
1417
+ fi
1418
+ if (( k == 1 )); then conv_id="$(cat "$conv_id_file")"; fi
1244
1419
  done
1245
- if [[ -n "$conv_id" ]]; then turn_pass=(--conversation "$conv_id"); else turn_pass=(--continue); fi
1420
+ # The partitioner refuses a zero-part change set, so this loop ran at least once: either turn 1 set
1421
+ # a grammar-valid conv_id or the run already returned above.
1422
+ turn_pass=(--conversation "$conv_id")
1246
1423
  remaining=$(( deadline - $(date +%s) ))
1247
1424
  if (( remaining <= AGY_TURN_KILL_GRACE_S )); then
1248
1425
  echo "error: the fed review exhausted its hard wall-clock cap (AGY_HARD_TIMEOUT=${AGY_HARD_TIMEOUT}) before the" >&2
@@ -1256,8 +1433,20 @@ run_fed_review() {
1256
1433
  turn_soft="$(( soft_budget < budget ? soft_budget : budget ))s"
1257
1434
  set +e
1258
1435
  ( cd "$staging" && AGY_MODEL="$AGY_MODEL" AGY_TIMEOUT="$turn_soft" AGY_HARD_TIMEOUT="$turn_hard" \
1259
- "$AGY_RUN" "@$staging/turn-final" -- "${turn_pass[@]}" ) | tee "$review_out_file"
1260
- turn_rc=${PIPESTATUS[0]}
1436
+ "$AGY_RUN" "@$staging/turn-final" -- "${AGY_TRANSPORT_FLAGS[@]}" "${turn_pass[@]}" ) > "$final_envelope"
1437
+ turn_rc=$?
1438
+ if (( turn_rc == 0 )); then
1439
+ if ! read_agy_envelope "$final_envelope" "$review_out_file"; then
1440
+ set -e
1441
+ echo "error: the final review turn exited 0 but its answer is not a readable agy JSON envelope" >&2
1442
+ echo " (cause above). The review is UNREADABLE, not merely verdict-less: NO receipt was" >&2
1443
+ echo " written. Re-run the review." >&2
1444
+ return "$AGY_ENVELOPE_EXIT"
1445
+ fi
1446
+ cat "$review_out_file"
1447
+ else
1448
+ cat "$final_envelope"
1449
+ fi
1261
1450
  set -e
1262
1451
  return "$turn_rc"
1263
1452
  }
@@ -1807,21 +1996,36 @@ aw_timeout_banner="$(aw_timeout_label "$aw_review_timeout_bin" "$AGY_HARD_TIMEOU
1807
1996
  echo "review posture: model=${AGY_MODEL:-<agy settings default>} timeout=$aw_timeout_banner" >&2
1808
1997
 
1809
1998
  # --- Execute via agy-run (single home of timeout + subscription + byte ceiling) ---
1810
- # The output is teed into the private staging dir so the mandated '### Verdict' section can be
1811
- # parsed into the review receipt — the user-facing stream is unchanged.
1999
+ # The dispatch is captured PRIVATELY as an envelope and its `response` becomes both the
2000
+ # operator-visible stream and the parsed captureone source, so what the reader sees and what the
2001
+ # receipt attests can never diverge.
2002
+ # Error priority (Decision 4): the CLI's OWN failure wins. The envelope is parsed only on a ZERO
2003
+ # exit; on a non-zero one the captured stdout is republished untouched and the CLI's exit code and
2004
+ # its stderr survive, with no envelope-parse error layered over them.
1812
2005
  review_out_file="$staging/review-output"
2006
+ envelope_file="$staging/review-envelope"
2007
+ dispatch_flags=("${AGY_TRANSPORT_FLAGS[@]}")
2008
+ if (( ${#run_passthrough[@]} > 0 )); then dispatch_flags+=("${run_passthrough[@]}"); fi
1813
2009
  set +e
1814
2010
  if (( FED_MODE == 1 )); then
1815
2011
  run_fed_review
1816
2012
  rc=$?
1817
- elif (( ${#run_passthrough[@]} > 0 )); then
1818
- AGY_MODEL="$AGY_MODEL" AGY_TIMEOUT="$AGY_TIMEOUT" AGY_HARD_TIMEOUT="$AGY_HARD_TIMEOUT" \
1819
- "$AGY_RUN" "@$prompt_file" -- "${run_passthrough[@]}" | tee "$review_out_file"
1820
- rc=${PIPESTATUS[0]}
1821
2013
  else
1822
2014
  AGY_MODEL="$AGY_MODEL" AGY_TIMEOUT="$AGY_TIMEOUT" AGY_HARD_TIMEOUT="$AGY_HARD_TIMEOUT" \
1823
- "$AGY_RUN" "@$prompt_file" | tee "$review_out_file"
1824
- rc=${PIPESTATUS[0]}
2015
+ "$AGY_RUN" "@$prompt_file" -- "${dispatch_flags[@]}" > "$envelope_file"
2016
+ rc=$?
2017
+ if (( rc == 0 )); then
2018
+ if ! read_agy_envelope "$envelope_file" "$review_out_file"; then
2019
+ set -e
2020
+ echo "error: the dispatch exited 0 but its answer is not a readable agy JSON envelope (cause above)." >&2
2021
+ echo " The review is UNREADABLE, not merely verdict-less: NO receipt was written. Re-run the" >&2
2022
+ echo " review; if it recurs, the installed CLI is not honouring --output-format json." >&2
2023
+ exit "$AGY_ENVELOPE_EXIT"
2024
+ fi
2025
+ cat "$review_out_file"
2026
+ else
2027
+ cat "$envelope_file"
2028
+ fi
1825
2029
  fi
1826
2030
  set -e
1827
2031