@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.
- package/CHANGELOG.md +83 -0
- package/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +32 -11
- package/bridges/antigravity-cli-bridge/bin/agy-envelope.mjs +160 -0
- package/bridges/antigravity-cli-bridge/bin/agy-envelope.test.mjs +235 -0
- package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +23 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +242 -38
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +482 -38
- package/bridges/antigravity-cli-bridge/capability.json +3 -2
- package/bridges/antigravity-cli-bridge/references/models-and-flags.md +45 -12
- package/bridges/antigravity-cli-bridge/references/review-prompt.md +6 -3
- package/bridges/antigravity-cli-bridge/setup/README.md +18 -5
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/hooks/state-block-guard.mjs +107 -45
- package/references/modes/bootstrap.md +6 -2
- package/references/modes/set-recipe.md +8 -5
- package/references/modes/state-block-guard.md +39 -31
- package/references/modes/upgrade.md +8 -5
- package/references/scripts/check-docs-size-cli.test.mjs +7 -6
- package/references/scripts/check-docs-size-ensure.test.mjs +332 -0
- package/references/scripts/check-docs-size.mjs +181 -30
- package/references/shared/composition-handoff.md +10 -0
- package/references/shared/report-footer.md +2 -2
- package/references/templates/agent_rules.md +1 -0
- package/tools/detect-backends.mjs +1 -0
- package/tools/doc-parity.mjs +5 -1
- package/tools/ensure-configs.mjs +37 -19
- package/tools/ensure-ops.mjs +79 -1
- package/tools/ensure-vocabulary.mjs +17 -3
- package/tools/known-footprint.mjs +10 -0
- package/tools/lens-region.mjs +13 -1
- package/tools/source-size-scope.mjs +3 -1
- 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.
|
|
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.
|
|
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`)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
-
|
|
253
|
-
|
|
254
|
-
**no `agy inspect`**:
|
|
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
|
|
264
|
-
|
|
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
|
-
'
|
|
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');
|