@sabaiway/agent-workflow-kit 5.8.0 → 5.10.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 (35) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/SKILL.md +1 -1
  3. package/bridges/antigravity-cli-bridge/SKILL.md +32 -11
  4. package/bridges/antigravity-cli-bridge/bin/agy-envelope.mjs +160 -0
  5. package/bridges/antigravity-cli-bridge/bin/agy-envelope.test.mjs +235 -0
  6. package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +23 -1
  7. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +242 -38
  8. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +482 -38
  9. package/bridges/antigravity-cli-bridge/capability.json +3 -2
  10. package/bridges/antigravity-cli-bridge/references/models-and-flags.md +45 -12
  11. package/bridges/antigravity-cli-bridge/references/review-prompt.md +6 -3
  12. package/bridges/antigravity-cli-bridge/setup/README.md +18 -5
  13. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +1 -1
  14. package/capability.json +1 -1
  15. package/package.json +1 -1
  16. package/references/hooks/state-block-guard.mjs +107 -45
  17. package/references/modes/bootstrap.md +6 -2
  18. package/references/modes/set-recipe.md +8 -5
  19. package/references/modes/state-block-guard.md +39 -31
  20. package/references/modes/upgrade.md +8 -5
  21. package/references/scripts/check-docs-size-cli.test.mjs +7 -6
  22. package/references/scripts/check-docs-size-ensure.test.mjs +332 -0
  23. package/references/scripts/check-docs-size.mjs +181 -30
  24. package/references/shared/composition-handoff.md +10 -0
  25. package/references/shared/report-footer.md +2 -2
  26. package/references/templates/agent_rules.md +1 -0
  27. package/tools/detect-backends.mjs +1 -0
  28. package/tools/doc-parity.mjs +5 -1
  29. package/tools/ensure-configs.mjs +37 -19
  30. package/tools/ensure-ops.mjs +79 -1
  31. package/tools/ensure-vocabulary.mjs +17 -3
  32. package/tools/known-footprint.mjs +10 -0
  33. package/tools/lens-region.mjs +13 -1
  34. package/tools/source-size-scope.mjs +3 -1
  35. package/tools/upgrade-runlist.mjs +1 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,89 @@ 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
+ ## 5.10.0 — the bundled agy review reads the CLI's own envelope, and refuses a host that cannot honour the dispatch (AD-098; antigravity-cli-bridge 5.3.0, memory 4.5.0)
8
+
9
+ **A review used to read whatever the CLI happened to print, and recover the conversation id by
10
+ scraping the CLI's run log with a pattern its own comment called "agy's own to change".** Antigravity
11
+ CLI 1.1.13 answers `--output-format json` with one object whose `response` carries the model's
12
+ Markdown verbatim and whose `conversation_id` is a first-class field. The bundled bridge now drives
13
+ every dispatch that way and reads named fields instead of guessing — and the guess that used to rot
14
+ silently is deleted rather than hardened.
15
+
16
+ - **The envelope is transport, not contract.** The model is asked for exactly what it was asked for
17
+ before, so the delivery-proof echo, the mandated section shape, the receipt and the "no recognized
18
+ verdict" arm keep their semantics. On a successful run the wrapper still PRINTS the review text,
19
+ never JSON. An unreadable envelope on a zero exit is its own loud failure (exit 5) with no receipt,
20
+ distinct from a verdict-less answer (exit 4); a non-zero CLI exit keeps the CLI's own code and
21
+ message, because the envelope is parsed only on a zero exit.
22
+ - **A pre-spend capability door, not a version floor.** Before anything is spent, the wrapper probes
23
+ the DECLARED option tokens of `agy --help` for `--output-format` and `--disable-slash-commands`,
24
+ requires `node >= 22` and the reader module, and refuses with the cost of the capability that is
25
+ actually missing, the installed `agy` version and the recovery command. A `--help` that itself
26
+ fails is never read as "capability present". A guessed version floor would have refused working
27
+ installs — the release that introduced the flag is not measurable from one build.
28
+ - **The conversation id comes from the envelope; the log scrape and its `--continue` fallback are
29
+ gone.** The UUID grammar the scrape validated is kept and applied to the field, so a missing,
30
+ wrong-typed or malformed id stops the run before the next turn is spent instead of routing it at
31
+ an arbitrary conversation.
32
+ - **`--disable-slash-commands` on every review dispatch.** A change-set line that begins with a slash
33
+ command stays BODY, so the model reviews the delivered bytes rather than an expansion of them.
34
+ - **`--json-schema` was measured and REFUSED, and the reference says why.** It is not a constrained
35
+ decode: the model answers in prose and the CLI spends a second turn asking it to restate that
36
+ answer in schema shape. Matched control, same prompt and model, schema off vs on: 16,585 vs 33,446
37
+ total tokens, with the structured reason coming back reworded rather than quoted. `stream-json`
38
+ stays unadopted, with its two real gains named in the reference.
39
+ - **Node is now required for the review role** (the JSON parse lives in `bin/agy-envelope.mjs`, not
40
+ in bash); `SKILL.md`, `setup/README.md` and the flags reference carry the requirement and the
41
+ probed capability list in place of the old "1.1.13 or newer" line.
42
+ - **The state-block guard ships ONE vocabulary — English — and enumerates no other language.** A
43
+ shipped phrase list is a guess about somebody else's dialogue and it never stops growing; a
44
+ deployment whose dialogue language differs extends its own placed copy, and the mode contract
45
+ states that silence instead of hiding it. The always-loaded rules template gains the closing
46
+ state-block rule in the same shape: the slot LABELS stay English, because they are what lets a
47
+ checker find the block at all, while everything written into a slot is in the project's dialogue
48
+ language.
49
+ - **Two smaller consequences of that sweep, both load-bearing.** `lens-region.mjs` now recognizes the
50
+ intermediate communications canon that shipped between the plain-language bullet and the
51
+ state-block rule, so a deployment sitting on it is still detected as canon rather than flagged as a
52
+ custom edit; and the index reader behind the size practice carries each entry's object id, so a
53
+ consumer can read what the index HOLDS instead of what the worktree happens to show.
54
+
55
+ ## 5.9.0 — the always-loaded navigator becomes something every deploy path actually creates (AD-096; memory 4.4.0)
56
+
57
+ **A fresh deployment's entry point declared `docs/ai/index.md` always-loaded, and no step ever
58
+ created it.** The navigator is generated, not templated, and nothing in bootstrap ran the generator:
59
+ a Node project was born with a pre-commit hook failing its own index check, and a project without
60
+ Node stayed quietly broken, its entry point pointing at a file nothing writes. The kit's own
61
+ fallback bootstrap carried the same gap. Every deploy and upgrade path now finishes by materializing
62
+ it, and two new guards make "the entry point only points at things a deploy creates" a test rather
63
+ than an intention.
64
+
65
+ - **A fifth project-configuration ensure: `index`.** `ensure-configs --reconcile` now regenerates
66
+ `docs/ai/index.md` whenever it is missing or stale, so an existing deployment gains its navigator
67
+ without a lineage bump — exactly like the config seeds. It never skips a No-Node project: the
68
+ generator runs from the kit's own bundled copy, not the project's `scripts/`.
69
+ - **The LATE run is the authoritative one.** The agent-rules lens rewrites `docs/ai` after the
70
+ step-3 reconcile, so `upgrade.md` documents a late `--reconcile --only index` rung at BOTH exits —
71
+ after the lens block on the equal-head exit, at the end of step 7 on the migrated one — and the
72
+ step-4/8 reports relay THAT line.
73
+ - **`ensure-configs --only <op>`** runs exactly one ensure. An unknown, missing or repeated
74
+ selector is a usage error with zero writes: a run that cannot be narrowed as asked must not widen.
75
+ - **Failure says how far it got.** The index op classifies `generator-unlaunchable` (nothing ran),
76
+ `generator-failed`, `index-probe-failed` and `index-stale-after-write`; every cause that can only
77
+ arise after the generator ran DISCLOSES that a write may already have landed, and a claimed
78
+ regeneration is verified by a re-probe that must ANSWER — an exit code alone would turn a failed
79
+ probe into a false "still stale". Every relayed cause is now doc-parity-bound into `upgrade.md`.
80
+ - **The bundled generator's write is contained and atomic** (mirrored from the memory canon): a
81
+ symlink at any level of `docs/ai/index.md` refuses, the body is published through an
82
+ exclusive-create temp + rename, and the containment guard runs BEFORE the freshness read — a
83
+ symlink whose target happens to hold current bytes is refused, not reported "already current".
84
+ - **Hidden mode now names the whole payload.** `KIT_OWN_PATHS` listed 14 of the 21 files a deploy
85
+ copies into `scripts/`, so six shipped scripts besides the new one stayed visible in a hidden
86
+ project's `git status`. The registry is complete (20 → 27) and a new test derives the expected set
87
+ from the deploy payload itself. Known residual: a registered path is still hidden by NAME rather
88
+ than proven ownership (`known_issues.md` → Issue-018).
89
+
7
90
  ## 5.8.0 — the bundled agy bridge speaks the catalog the installed CLI actually serves, and grounding stops needing a shell (AD-095; antigravity-cli-bridge 5.2.0)
8
91
 
9
92
  **A model display string is matched exactly, so a stale one is not a documentation bug — it is a
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: '5.8.0'
6
+ version: '5.10.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.2.0'
5
+ version: '5.3.0'
6
6
  ---
7
7
 
8
8
  # antigravity-cli-bridge
@@ -95,11 +95,11 @@ AGY_HARD_TIMEOUT=8m agy-run "..." # hard wall-clock cap via timeout(
95
95
  agy-run "..." -- --add-dir . # passthrough agy flags (never a permission widener)
96
96
  ```
97
97
 
98
- `agy` is **headless-only** here (`-p`/`--print`); v1.1.13 adds `--output-format json|stream-json`,
99
- and text stays the wrapper default — JSON can ride passthrough (`-- --output-format json`) but with
100
- no first-class parsing/validation (adoption is a backlog item). If you need structure, ask
101
- for Markdown with explicit headings and validate it
102
- yourself. Wrapper inputs: first argument is the prompt (`text`, `-` for stdin, or `@file`);
98
+ `agy` is **headless-only** here (`-p`/`--print`). `agy-run` stays **text**: `--output-format json`
99
+ can ride passthrough (`-- --output-format json`) but this wrapper adds no parsing/validation, so if
100
+ you need structure, ask for Markdown with explicit headings and validate it yourself. (The `review`
101
+ role is the one that adopted the JSON envelope — see below.) Wrapper inputs: first argument is the
102
+ prompt (`text`, `-` for stdin, or `@file`);
103
103
  `AGY_MODEL` (default `Gemini 3.7 Flash (High)`); `AGY_TIMEOUT` → `--print-timeout` (default `5m`);
104
104
  `AGY_HARD_TIMEOUT` → hard `timeout(1)` wall-clock cap (default = `AGY_TIMEOUT`); extra `agy` flags
105
105
  after `--`. Full detail: [`references/models-and-flags.md`](references/models-and-flags.md).
@@ -142,6 +142,25 @@ agy-review diff <diff-file> [--facts @f] … # review a supplied diff
142
142
  agy-review --continue --decided @round1.md --focus "still-open items" # round-2 delta, no re-assembly
143
143
  ```
144
144
 
145
+ **Transport + host requirements (probed, never a version floor).** Every review dispatch runs
146
+ `--output-format json` and `--disable-slash-commands`, and the wrapper reads the returned envelope
147
+ in node ([`bin/agy-envelope.mjs`](bin/agy-envelope.mjs)) instead of guessing at raw stdout — a
148
+ change-set line beginning with a slash command therefore stays BODY, and the answer TEXT plus the
149
+ conversation id arrive as **named fields**. The **review contract stays prose-shaped**: the verdict
150
+ is still parsed from that text's `### Verdict` section, unchanged. **On a zero exit** what
151
+ `agy-review` prints is the review text, never JSON; when the **single dispatch or the FINAL fed
152
+ turn** exits non-zero the captured stdout is published as-is (the CLI's own failure wins and the
153
+ envelope is parsed only on a zero exit), so a failing run may print a JSON or partial payload — an
154
+ **intermediate** feed turn is the exception, its output stays private (Invariant E) and its failure
155
+ prints only a named error. So it **probes before
156
+ spending anything**: `agy --help` must advertise both flags, **Node ≥ 22** must be on `PATH`, and
157
+ `bin/agy-envelope.mjs` must be present — each refusal names the missing capability and its recovery.
158
+ A version floor is deliberately not used (the release that introduced a flag is not measurable from
159
+ one installed build, so a guessed floor would refuse working installs), and a failed probe is never
160
+ read as "capability present". `--json-schema` is **rejected on measurement** — it costs a second
161
+ billed turn restating the model's own prose (16,585 → 33,446 tokens on a matched control); see
162
+ [`references/models-and-flags.md`](references/models-and-flags.md).
163
+
145
164
  **Honesty + posture (D4/D5):** a run with no recognized `### Verdict` section — empty output
146
165
  included — **exits 4 with NO receipt**: treat it as a *failed review to re-run*, never a fatal
147
166
  session error. One stderr banner states the actual posture (`review posture: model=… timeout=…`)
@@ -249,9 +268,10 @@ checklist, prompt templates, output handling). Essentials:
249
268
  - Subdirectory `CLAUDE.md` files are **not** auto-loaded by `agy` (only the cwd context file +
250
269
  `.agents/skills/`). Put cross-cutting rules in the root context file, or include local rules in the
251
270
  prompt when they matter.
252
- - **Text is the wrapper default** — the CLI's 1.1.x `--output-format json|stream-json` can ride
253
- passthrough but with no first-class parsing/validation (adoption is backlog) and there is
254
- **no `agy inspect`**: no machine-readable introspection.
271
+ - **`agy-run` leaves JSON unparsed** — `--output-format json|stream-json` can ride its passthrough
272
+ with no first-class parsing/validation. (`agy-review` is the exception: it **adopted `json`** and
273
+ parses the envelope; only `stream-json` stays deferred there.) And there is **no `agy inspect`**:
274
+ no machine-readable introspection.
255
275
  - Model names must match the `agy models` display strings **exactly**.
256
276
  - **Quota is finite.** Heavy use of Pro/Claude models can exhaust the subscription; prefer Flash for
257
277
  cheap work.
@@ -260,5 +280,6 @@ checklist, prompt templates, output handling). Essentials:
260
280
  kill (a run was seen surviving 32 min past a 10m `--print-timeout`). A heavy `--add-dir` agentic
261
281
  prompt on a slow model (e.g. `Gemini 3.1 Pro (High)`) can run unbounded — prefer a faster model or
262
282
  a **self-contained prompt** (no `--add-dir`); an "exceeded the hard cap" error is the guard firing.
263
- - `agy` output is plain text and may be incomplete or out of date — treat it as advisory until the
264
- main agent verifies it.
283
+ - `agy` output may be incomplete or out of date — treat it as advisory until the main agent verifies
284
+ it. (Its FORMAT is not one thing: see the transport section for which wrapper prints text, when,
285
+ and what a failing run prints instead.)
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ // agy-envelope.mjs — read ONE captured `agy --output-format json` payload and hand the review
3
+ // wrapper the fields it needs, or fail with a DISTINGUISHABLE cause.
4
+ //
5
+ // Why a module and not bash: the CLI answers with ONE JSON object whose `response` carries the
6
+ // model's Markdown VERBATIM, and JSON parsing in bash is a defect farm. The trade — node becomes a
7
+ // hard runtime requirement for every review — is paid deliberately and bought back by the wrapper's
8
+ // pre-spend capability door, which refuses BEFORE a subscription turn is spent rather than after
9
+ // one is wasted.
10
+ //
11
+ // Every failure is LOUD: a named cause, a non-zero exit. There is no degraded verdict and no
12
+ // fallback to parsing raw stdout — a review whose answer cannot be read has not happened, and
13
+ // saying otherwise would attest a tree nobody reviewed.
14
+ //
15
+ // The parse is a PURE function so it can be tested without a subprocess; the CLI below is a thin
16
+ // file-I/O shell around it, exercised end-to-end by the wrapper's own acceptance tests.
17
+
18
+ import { readFileSync, writeFileSync } from 'node:fs';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { resolve } from 'node:path';
21
+
22
+ const EXIT_NAMED_CAUSE = 1;
23
+ const EXIT_USAGE = 2;
24
+ const SUCCESS_STATUS = 'SUCCESS';
25
+ // The SAME grammar the retired `capture_conversation_id` log scrape validated. A named envelope
26
+ // field can still carry a malformed or wrong-typed value, and an unvalidated one would route every
27
+ // later feed turn at an arbitrary conversation.
28
+ const CONVERSATION_ID_GRAMMAR = /^[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}$/;
29
+
30
+ const failed = (cause, sentence) => ({ ok: false, cause, sentence });
31
+
32
+ // Read one captured payload. `requireConversationId` is the caller's declaration that it will ROUTE
33
+ // a later turn at this conversation: only then is the field required and validated, so a lane that
34
+ // never routes one cannot fail over a value it does not use.
35
+ export const parseAgyEnvelope = (text, { requireConversationId = false } = {}) => {
36
+ if (text.length === 0) {
37
+ return failed('empty-payload', 'the captured payload is empty — the CLI printed nothing at all.');
38
+ }
39
+ // Parsed-ness is carried SEPARATELY from the parsed value: `null` is both a legal JSON document
40
+ // and the obvious failure sentinel, and conflating them reported valid JSON `null` as "not JSON".
41
+ const parsed = (() => {
42
+ try {
43
+ return { parsed: true, value: JSON.parse(text) };
44
+ } catch {
45
+ return { parsed: false, value: null };
46
+ }
47
+ })();
48
+ if (!parsed.parsed) {
49
+ return failed('not-json', 'the captured payload is not JSON — the dispatch did not return an envelope.');
50
+ }
51
+ const envelope = parsed.value;
52
+ if (envelope === null || typeof envelope !== 'object' || Array.isArray(envelope)) {
53
+ return failed('not-an-envelope', 'the captured payload is JSON but not a single object.');
54
+ }
55
+ if (envelope.status !== SUCCESS_STATUS) {
56
+ return failed('status', `the envelope reports status ${JSON.stringify(envelope.status ?? null)}, not "${SUCCESS_STATUS}".`);
57
+ }
58
+ // An EMPTY response is a real (if useless) answer and rides through to the wrapper's verdict-less
59
+ // arm; only an ABSENT or wrong-typed field means the answer is not in the envelope at all.
60
+ if (typeof envelope.response !== 'string') {
61
+ return failed('response', 'the envelope carries no string `response` field — the model\'s answer is not in it.');
62
+ }
63
+ if (!requireConversationId) {
64
+ return { ok: true, response: envelope.response, conversationId: '' };
65
+ }
66
+ if (typeof envelope.conversation_id !== 'string') {
67
+ return failed('conversation-id', 'the envelope carries no string `conversation_id` field, so a later turn could not be routed at this conversation.');
68
+ }
69
+ if (!CONVERSATION_ID_GRAMMAR.test(envelope.conversation_id)) {
70
+ return failed('conversation-id', 'the envelope\'s `conversation_id` does not match the UUID grammar — routing a later turn at it would target an arbitrary conversation.');
71
+ }
72
+ return { ok: true, response: envelope.response, conversationId: envelope.conversation_id };
73
+ };
74
+
75
+ const FLAG_KEYS = {
76
+ '--envelope': 'envelope',
77
+ '--response-out': 'responseOut',
78
+ '--conversation-id-out': 'conversationIdOut',
79
+ };
80
+
81
+ const USAGE = [
82
+ 'agy-envelope — read ONE captured `agy --output-format json` payload, or fail with a named cause.',
83
+ '',
84
+ 'Usage:',
85
+ ' node agy-envelope.mjs --envelope <path> --response-out <path> [--conversation-id-out <path>]',
86
+ '',
87
+ ' --envelope <path> the captured stdout of ONE `agy --output-format json` dispatch',
88
+ ' --response-out <path> write the envelope\'s `response` VERBATIM here',
89
+ ' --conversation-id-out <path> ALSO require `conversation_id`, validate it against the UUID',
90
+ ' grammar, and write it here',
91
+ '',
92
+ 'Exit: 0 parsed; 1 a named failure cause on stderr; 2 usage.',
93
+ ].join('\n');
94
+
95
+ const usageOutcome = (message) => ({ code: EXIT_USAGE, message: `agy-envelope: ${message}\n\n${USAGE}\n` });
96
+ const causeOutcome = (cause, sentence) => ({ code: EXIT_NAMED_CAUSE, message: `agy-envelope: ${cause} — ${sentence}\n` });
97
+ const isOutcome = (value) => value !== null && typeof value === 'object' && typeof value.code === 'number';
98
+
99
+ const parseArguments = (argv) =>
100
+ argv.reduce((options, token, index) => {
101
+ if (isOutcome(options)) return options;
102
+ if (!Object.hasOwn(FLAG_KEYS, token)) {
103
+ if (index > 0 && Object.hasOwn(FLAG_KEYS, argv[index - 1])) return options;
104
+ return usageOutcome(`unknown argument '${token}'`);
105
+ }
106
+ const value = argv[index + 1];
107
+ if (value === undefined || value.startsWith('--')) return usageOutcome(`${token} needs a value`);
108
+ return { ...options, [FLAG_KEYS[token]]: value };
109
+ }, { envelope: '', responseOut: '', conversationIdOut: '' });
110
+
111
+ const readPayloadText = (path) => {
112
+ const bytes = (() => {
113
+ try {
114
+ return readFileSync(path);
115
+ } catch {
116
+ return null;
117
+ }
118
+ })();
119
+ if (bytes === null) return causeOutcome('unreadable-payload', `the captured payload '${path}' could not be read.`);
120
+ try {
121
+ // A BOM is stripped rather than fed to JSON.parse; any other invalid byte refuses loudly.
122
+ return { text: new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(bytes) };
123
+ } catch {
124
+ return causeOutcome('not-utf8', `the captured payload '${path}' is not valid UTF-8.`);
125
+ }
126
+ };
127
+
128
+ const writeField = (path, value, field) => {
129
+ try {
130
+ writeFileSync(path, value, 'utf8');
131
+ return null;
132
+ } catch {
133
+ return causeOutcome('write-failed', `the envelope's ${field} could not be written to '${path}'.`);
134
+ }
135
+ };
136
+
137
+ // The CLI as a PURE-ish function: every arm RETURNS its outcome instead of exiting, so each one can
138
+ // be driven in-process by a test. Writing and exiting happen once, at the entry point below — the
139
+ // single impure boundary. (Subprocess tests could not close this: coverage is collected in the test
140
+ // process, so an arm only reachable through a spawn is an arm nothing can prove was exercised.)
141
+ export const runEnvelopeCli = (argv) => {
142
+ const options = parseArguments(argv);
143
+ if (isOutcome(options)) return options;
144
+ if (!options.envelope) return usageOutcome('--envelope is required.');
145
+ if (!options.responseOut) return usageOutcome('--response-out is required.');
146
+ const payload = readPayloadText(options.envelope);
147
+ if (isOutcome(payload)) return payload;
148
+ const result = parseAgyEnvelope(payload.text, { requireConversationId: Boolean(options.conversationIdOut) });
149
+ if (!result.ok) return causeOutcome(result.cause, result.sentence);
150
+ const wroteResponse = writeField(options.responseOut, result.response, 'response');
151
+ if (wroteResponse !== null) return wroteResponse;
152
+ if (!options.conversationIdOut) return { code: 0 };
153
+ const wroteId = writeField(options.conversationIdOut, result.conversationId, 'conversation_id');
154
+ return wroteId === null ? { code: 0 } : wroteId;
155
+ };
156
+
157
+ // Entry-point guard (not import.meta.main — that landed after the family's Node >= 22 floor). ONE
158
+ // statement so the line is executed on import and carries no unreachable-by-test body.
159
+ const entryOutcome = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url) ? runEnvelopeCli(process.argv.slice(2)) : null;
160
+ if (entryOutcome !== null) { if (entryOutcome.message) process.stderr.write(entryOutcome.message); process.exit(entryOutcome.code); }
@@ -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');