@sabaiway/agent-workflow-kit 14.20.0 → 14.22.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 (56) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +2 -2
  3. package/SKILL.md +1 -1
  4. package/bridges/antigravity-cli-bridge/SKILL.md +38 -50
  5. package/bridges/antigravity-cli-bridge/bin/agy-model-posture.test.mjs +257 -0
  6. package/bridges/antigravity-cli-bridge/bin/agy-review-harness.test.mjs +10 -3
  7. package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +20 -21
  8. package/bridges/antigravity-cli-bridge/bin/agy-review-model-screen.test.mjs +4 -4
  9. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +120 -48
  10. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +22 -62
  11. package/bridges/antigravity-cli-bridge/bin/agy.sh +109 -16
  12. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +5 -5
  13. package/bridges/antigravity-cli-bridge/capability.json +14 -7
  14. package/bridges/antigravity-cli-bridge/references/driving-agy.md +16 -20
  15. package/bridges/antigravity-cli-bridge/references/models-and-flags.md +10 -27
  16. package/bridges/codex-cli-bridge/SKILL.md +51 -49
  17. package/bridges/codex-cli-bridge/bin/codex-exec.sh +145 -46
  18. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +28 -55
  19. package/bridges/codex-cli-bridge/bin/codex-model-posture.test.mjs +371 -0
  20. package/bridges/codex-cli-bridge/bin/codex-review.sh +134 -36
  21. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +33 -45
  22. package/bridges/codex-cli-bridge/capability.json +22 -8
  23. package/bridges/codex-cli-bridge/references/driving-codex.md +18 -15
  24. package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +16 -12
  25. package/bridges/codex-cli-bridge/setup/README.md +3 -3
  26. package/capability.json +1 -1
  27. package/package.json +1 -1
  28. package/references/authoring/TASK_TEMPLATE.md +11 -16
  29. package/references/modes/agents.md +2 -2
  30. package/references/modes/bridge-settings.md +10 -7
  31. package/references/modes/procedures.md +7 -7
  32. package/references/modes/recipes.md +2 -2
  33. package/references/modes/review-state.md +1 -1
  34. package/references/modes/set-recipe.md +1 -1
  35. package/references/modes/tier.md +1 -1
  36. package/references/modes/velocity.md +1 -1
  37. package/references/reference-profile.json +1 -1
  38. package/references/templates/SPEC_TEMPLATE.md +2 -1
  39. package/references/templates/agent_rules.md +7 -7
  40. package/tools/bridge-catalog.mjs +114 -0
  41. package/tools/bridge-posture.mjs +11 -2
  42. package/tools/bridge-settings-read.mjs +41 -5
  43. package/tools/bridge-settings.mjs +111 -23
  44. package/tools/carriers.mjs +6 -4
  45. package/tools/cheap-agents.mjs +1 -1
  46. package/tools/detect-backends.mjs +6 -6
  47. package/tools/family-registry.mjs +3 -3
  48. package/tools/held-session.mjs +1 -33
  49. package/tools/manifest/schema.md +1 -1
  50. package/tools/manifest/validate.mjs +3 -2
  51. package/tools/procedures.mjs +86 -116
  52. package/tools/recipes.mjs +2 -2
  53. package/tools/review-state.mjs +1 -1
  54. package/tools/rules-regions.mjs +2 -1
  55. package/tools/tier-guide-facts.mjs +145 -159
  56. package/tools/tier-guide.mjs +8 -14
@@ -0,0 +1,114 @@
1
+ // bridge-catalog.mjs — reads the models each installed bridge CLI offers, exactly as the bridge
2
+ // wrappers read them before a run (spec bridge-model: Catalog check; spec bridge-settings): codex
3
+ // `codex debug models -c model_provider=openai`, the `visibility: list` slugs each with its efforts;
4
+ // agy `agy models`, the display string after the TAB of each line carrying one. Each read runs on the
5
+ // caller's PATH with every *_API_KEY (and, for codex, OPENAI_BASE_URL) removed, so the list is the
6
+ // subscription catalog the runs use. A read is bounded by the wrapper's own constant, then killed with
7
+ // its whole process group after the wrapper's kill grace. Read-only: it runs no model, writes nothing.
8
+ //
9
+ // Dependency-free, Node >= 22. No side effects on import.
10
+
11
+ import { spawn } from 'node:child_process';
12
+ import { accessSync, statSync, constants } from 'node:fs';
13
+ import { delimiter, isAbsolute, join } from 'node:path';
14
+
15
+ // The wrappers' constants: codex-exec.sh CODEX_CATALOG_TIMEOUT_S=15, agy.sh AGY_CATALOG_TIMEOUT=30s,
16
+ // both under `timeout --kill-after=5s`.
17
+ export const CATALOG_BOUNDS_MS = Object.freeze({ 'codex-cli-bridge': 15000, 'antigravity-cli-bridge': 30000 });
18
+ export const CATALOG_KILL_GRACE_MS = 5000;
19
+
20
+ const CONTROL = /[\u0000-\u001f\u007f]/u;
21
+ const unreadable = (cause) => ({ state: 'unreadable', cause, entries: [] });
22
+
23
+ export const parseCodexCatalog = (text) => {
24
+ let doc;
25
+ try { doc = JSON.parse(text); } catch { return unreadable('its output is not JSON'); }
26
+ if (doc === null || typeof doc !== 'object' || !Array.isArray(doc.models)) return unreadable('it carries no models[]');
27
+ const offered = doc.models.filter((m) => m !== null && typeof m === 'object' && m.visibility === 'list');
28
+ if (offered.some((m) => typeof m.slug !== 'string' || m.slug === '' || CONTROL.test(m.slug))) return unreadable('an offered entry carries no usable slug');
29
+ if (offered.length === 0) return unreadable('it offers no model');
30
+ const effortsOf = (m) => (Array.isArray(m.supported_reasoning_levels)
31
+ && m.supported_reasoning_levels.every((l) => l !== null && typeof l === 'object' && typeof l.effort === 'string' && l.effort !== '' && !CONTROL.test(l.effort))
32
+ ? m.supported_reasoning_levels.map((l) => l.effort) : null);
33
+ return { state: 'offered', cause: null, entries: offered.map((m) => ({ model: m.slug, efforts: effortsOf(m) })) };
34
+ };
35
+
36
+ export const parseAgyCatalog = (text) => {
37
+ const entries = text.split('\n')
38
+ .map((line) => line.replace(/\r$/u, ''))
39
+ .filter((line) => line.includes('\t'))
40
+ .map((line) => line.slice(line.indexOf('\t') + 1))
41
+ .filter((model) => model !== '' && !CONTROL.test(model))
42
+ .map((model) => ({ model }));
43
+ return entries.length ? { state: 'offered', cause: null, entries } : unreadable('it offers no model');
44
+ };
45
+
46
+ const CLIS = {
47
+ 'codex-cli-bridge': { cli: 'codex', args: ['debug', 'models', '-c', 'model_provider=openai'], parse: parseCodexCatalog, clears: ['OPENAI_BASE_URL'] },
48
+ 'antigravity-cli-bridge': { cli: 'agy', args: ['models'], parse: parseAgyCatalog, clears: [] },
49
+ };
50
+
51
+ // The environment a catalog read runs in: the caller's, minus every *_API_KEY and the bridge's own
52
+ // base-URL variable — the variables the wrapper's subscription guard clears before its own read.
53
+ export const catalogEnv = (name, getenv) => Object.fromEntries(Object.entries(getenv)
54
+ .filter(([key]) => !/_API_KEY$/u.test(key) && !(CLIS[name]?.clears ?? []).includes(key)));
55
+
56
+ const resolveOnPath = (cli, env) => {
57
+ for (const dir of String(env.PATH ?? '').split(delimiter)) {
58
+ if (!dir || !isAbsolute(dir)) continue;
59
+ const candidate = join(dir, cli);
60
+ try {
61
+ if (!statSync(candidate).isFile()) continue;
62
+ accessSync(candidate, constants.X_OK);
63
+ return candidate;
64
+ } catch { /* not this directory */ }
65
+ }
66
+ return null;
67
+ };
68
+
69
+ const killGroup = (child, signal) => {
70
+ try { process.kill(-child.pid, signal); } catch { try { child.kill(signal); } catch { /* already gone */ } }
71
+ };
72
+
73
+ const readOne = (name, ctx) => new Promise((done) => {
74
+ const spec = CLIS[name];
75
+ if (!spec) return done(unreadable(`no catalog reader for ${name}`));
76
+ const env = catalogEnv(name, ctx.getenv ?? process.env);
77
+ const bin = resolveOnPath(spec.cli, env);
78
+ if (bin === null) return done({ state: 'not-installed', cause: `${spec.cli} is not on PATH`, entries: [] });
79
+ const boundMs = ctx.catalogBoundsMs?.[name] ?? CATALOG_BOUNDS_MS[name];
80
+ const graceMs = ctx.catalogKillGraceMs ?? CATALOG_KILL_GRACE_MS;
81
+ const label = `${spec.cli} ${spec.args.slice(0, name === 'codex-cli-bridge' ? 2 : 1).join(' ')}`;
82
+ const chunks = [];
83
+ let timedOut = false;
84
+ let settled = false;
85
+ let bound;
86
+ let kill;
87
+ const finish = (result) => { if (!settled) { settled = true; clearTimeout(bound); clearTimeout(kill); done(result); } };
88
+ // spawn THROWS for an execve errno it does not emit (ETXTBSY: the binary is being replaced).
89
+ let child;
90
+ try {
91
+ child = spawn(bin, spec.args, { env, stdio: ['ignore', 'pipe', 'ignore'], detached: process.platform !== 'win32' });
92
+ } catch (err) {
93
+ return finish(unreadable(`${label} could not start (${err.code ?? err.message})`));
94
+ }
95
+ bound = setTimeout(() => {
96
+ timedOut = true;
97
+ killGroup(child, 'SIGTERM');
98
+ kill = setTimeout(() => killGroup(child, 'SIGKILL'), graceMs);
99
+ }, boundMs);
100
+ child.stdout?.on('data', (chunk) => chunks.push(chunk));
101
+ child.on('error', (err) => finish(unreadable(`${label} could not start (${err.code ?? err.message})`)));
102
+ child.on('close', (code, signal) => {
103
+ if (timedOut) return finish(unreadable(`it exceeded ${boundMs / 1000}s`));
104
+ if (code !== 0) return finish(unreadable(`${label} exited ${code ?? signal}`));
105
+ return finish(spec.parse(Buffer.concat(chunks).toString('utf8')));
106
+ });
107
+ });
108
+
109
+ // names: bridge names (the family-members `name`). Resolves to { [name]: { state, cause, entries } },
110
+ // state `offered` | `unreadable` | `not-installed`; the reads run concurrently and never reject.
111
+ export const readCatalogs = async (names, ctx = {}) => {
112
+ const results = await Promise.all(names.map((name) => readOne(name, ctx)));
113
+ return Object.fromEntries(names.map((name, i) => [name, results[i]]));
114
+ };
@@ -12,9 +12,18 @@ const validPosture = (posture) => posture !== null && typeof posture === 'object
12
12
  && (!Object.hasOwn(posture, 'tier') || posture.tier === null || (typeof posture.tier === 'string' && posture.tier.length > 0))
13
13
  && Object.keys(posture).every((key) => ['model', 'effort', 'tier'].includes(key));
14
14
 
15
+ // A posture key the settings file or the environment sets replaces the manifest default and names its
16
+ // source; an explicitly empty AGY_MODEL (value null) renders as no model.
17
+ const overlaid = (slot, fallback, backend, settings) => {
18
+ const row = (settings?.posture ?? []).find((r) => r.bridge === backend.name && r.source !== 'default'
19
+ && (r.key.endsWith('_EFFORT') ? 'effort' : 'model') === slot);
20
+ if (!row) return oneLine(fallback);
21
+ return `${row.value == null ? 'none' : oneLine(row.value)} (${row.source})`;
22
+ };
23
+
15
24
  const postureString = (posture, backend, settings) => {
16
- const parts = [`model=${oneLine(posture.model)}`];
17
- if (Object.hasOwn(posture, 'effort')) parts.push(`effort=${oneLine(posture.effort)}`);
25
+ const parts = [`model=${overlaid('model', posture.model, backend, settings)}`];
26
+ if (Object.hasOwn(posture, 'effort')) parts.push(`effort=${overlaid('effort', posture.effort, backend, settings)}`);
18
27
  if (Object.hasOwn(posture, 'tier')) {
19
28
  const knob = (settings?.active ?? []).find((row) => row.key === 'CODEX_SERVICE_TIER' && row.bridge === backend.name);
20
29
  parts.push(knob ? `tier=${oneLine(knob.value)} (bridge-settings)` : `tier=${posture.tier ?? 'standard'}`);
@@ -75,6 +75,7 @@ export const allowedLabel = (entry) => {
75
75
  case 'integer': return `integer ${entry.min}..${entry.max}`;
76
76
  case 'duration': return 'duration (e.g. 5m, 30m, 90s — a unit is required, nonzero)';
77
77
  case 'boolean': return '"0" | "1"';
78
+ case 'posture': return 'a value the installed CLI offers (checked when set)';
78
79
  default: return entry.kind;
79
80
  }
80
81
  };
@@ -172,8 +173,34 @@ const REFUSED = (key, v) => ({ value: null, source: 'default', configuredIn: 'en
172
173
  // built-in default (`source: 'default'`) while still being an explicit line the operator wrote — and
173
174
  // for a RETIRED knob that is exactly the line the retirement asks them to clear, so it must not
174
175
  // vanish from the surfaces (council fold: `AGY_REVIEW_ALLOW_ADDDIR=2` and `=` both disappeared).
176
+ // A posture key (the model and effort, spec bridge-model) follows the wrappers' aw_read_posture, not
177
+ // the applied-settings lane: an env key set at all wins, its empty value meaning the key's default
178
+ // (AGY_MODEL: no model, agy's own settings decide); else a non-empty, control-free file value; else the
179
+ // default. Its sources are the wrappers' banner words: default, setting, environment.
180
+ const EMPTY_ENV_DROPS_MODEL = new Set(['AGY_MODEL']);
181
+ const postureOf = (entry, parsed, getenv) => {
182
+ const key = entry.key;
183
+ if (Object.prototype.hasOwnProperty.call(getenv, key)) {
184
+ const v = getenv[key];
185
+ if (v === '' && EMPTY_ENV_DROPS_MODEL.has(key)) return { value: null, source: 'environment', configuredIn: 'env', note: `the env ${key}= drops the model — agy's own settings decide` };
186
+ if (v === '') return { value: entry.default, source: 'environment', configuredIn: 'env', note: `the env ${key}= selects the built-in default` };
187
+ if (hasControlByte(v)) return { value: null, source: 'environment', configuredIn: 'env', note: `env value "${safeShow(v)}" carries control bytes for ${key} — ${entry.bridge === 'antigravity-cli-bridge'
188
+ ? 'agy-review refuses the run pre-spend; agy-run has no control-byte screen and refuses it only through its catalog check (it runs it when the catalog is unreadable)'
189
+ : 'the codex wrappers refuse the run pre-spend'}` };
190
+ return { value: v, source: 'environment', configuredIn: 'env' };
191
+ }
192
+ const fileEntries = parsed.byKey.get(key);
193
+ if (fileEntries && fileEntries.length) {
194
+ const v = fileEntries[fileEntries.length - 1].value;
195
+ if (v !== '' && !hasControlByte(v)) return { value: v, source: 'setting', configuredIn: 'file' };
196
+ return { value: entry.default, source: 'default', configuredIn: 'file', note: `file value "${safeShow(v)}" is empty or carries a control byte — falls back to the built-in default` };
197
+ }
198
+ return { value: entry.default, source: 'default', configuredIn: null };
199
+ };
200
+
175
201
  export const effectiveOf = (entry, parsed, getenv) => {
176
202
  const key = entry.key;
203
+ if (entry.kind === 'posture') return postureOf(entry, parsed, getenv);
177
204
  if (Object.prototype.hasOwnProperty.call(getenv, key)) {
178
205
  const v = getenv[key];
179
206
  // An EXPLICITLY-EMPTY env (`KEY=`) suppresses the FILE override for this run (the wrapper skips a
@@ -222,21 +249,29 @@ export const displayValue = (v) => (v == null ? '(unset — wrapper built-in app
222
249
  // ── the fact-only snapshot the read-only status/advisor surfaces consume ────────────────
223
250
 
224
251
  // Best-effort, never throws: the ACTIVE knobs (a non-default value is in play — source env/file,
225
- // non-null) plus file presence + any unknown/duplicate keys. Model/effort are structurally excluded
226
- // from the registry, so `active` can NEVER carry a model claim (the fact-only guarantee). A corrupt
227
- // bundle or fs error degrades to `{ error }` — the caller renders that localized-on-error, never crashes.
252
+ // non-null), every posture key's effective value and source (`posture`), plus file presence + any
253
+ // unknown/duplicate keys. A posture key's sources are default/setting/environment, so it never enters
254
+ // `active`. A corrupt bundle or fs error degrades to `{ error }` — the caller renders that
255
+ // localized-on-error, never crashes.
256
+ const postureRows = (registry, parsed, getenv) => [...registry.values()]
257
+ .filter((entry) => entry.kind === 'posture')
258
+ .map((entry) => {
259
+ const eff = effectiveOf(entry, parsed, getenv);
260
+ return { key: entry.key, bridge: entry.bridge, value: eff.value, source: eff.source, retired: entry.retired ?? null };
261
+ });
262
+
228
263
  export const settingsSnapshot = (ctx = {}) => {
229
264
  try {
230
265
  const registry = loadRegistry(ctx);
231
266
  const path = settingsPath(ctx);
232
267
  const fileState = readFileState(path, ctx);
268
+ const getenv = ctx.getenv ?? process.env;
233
269
  // A symlink / non-regular / unreadable file is IGNORED by the wrappers (built-in defaults apply) —
234
270
  // surface that honestly instead of silently omitting it (the wrappers warn; the status must too).
235
271
  if (fileState.state === 'unusable') {
236
- return { path, fileState: 'unusable', active: [], unknown: [], duplicates: [], error: 'the settings file is a symlink / not a regular file — ignored, built-in defaults apply' };
272
+ return { path, fileState: 'unusable', active: [], posture: postureRows(registry, parseSettings(''), getenv), unknown: [], duplicates: [], error: 'the settings file is a symlink / not a regular file — ignored, built-in defaults apply' };
237
273
  }
238
274
  const parsed = parseSettings(fileState.text ?? '');
239
- const getenv = ctx.getenv ?? process.env;
240
275
  const active = [];
241
276
  for (const entry of registry.values()) {
242
277
  const eff = effectiveOf(entry, parsed, getenv);
@@ -262,6 +297,7 @@ export const settingsSnapshot = (ctx = {}) => {
262
297
  path,
263
298
  fileState: fileState.state,
264
299
  active,
300
+ posture: postureRows(registry, parsed, getenv),
265
301
  unknown: [...parsed.byKey.keys()].filter((k) => !registry.has(k)),
266
302
  duplicates: duplicateKeys(parsed),
267
303
  };
@@ -11,29 +11,97 @@
11
11
  // out-of-range value, and — loudly, naming the key — a file that already carries DUPLICATE keys (it
12
12
  // never emits duplicates and never edits blindly around them). It touches only the KEY= line it owns,
13
13
  // preserving every comment/blank/other line verbatim, and creates the dir + file on first apply via
14
- // the hardened out-of-tree atomic writer (symlink/parent/TOCTOU-safe). Model/effort are NOT in the
15
- // allowlist — the wrappers' quality-first guard is untouched (D4). CODEX_SERVICE_TIER carries the
14
+ // the hardened out-of-tree atomic writer (symlink/parent/TOCTOU-safe). The model keys (kind `posture`,
15
+ // spec bridge-settings) are settable: the read-out shows each bridge's host posture and the models its
16
+ // installed CLI offers, and a set naming a model key is judged against that bridge's catalog before
17
+ // any write. It runs only the catalog commands, never a model. CODEX_SERVICE_TIER carries the
16
18
  // credit-rate caveat wherever it renders (from the manifest `effect`).
17
19
  //
18
20
  // Output is ENGLISH/structured (repo-artifact Hard Constraint); the agent localizes when narrating.
19
- // Exit codes: 0 success (reader, preview, or apply); 2 usage (bad args / unknown key / invalid value);
20
- // 1 precondition STOP (duplicate-carrying / symlinked / unreadable file, a write STOP) or a corrupt
21
- // bundle (cannot determine the allowlist). main(argv, ctx) → { code, stdout, stderr }; getenv / home /
22
- // bundleRoot / fs are injectable for host-independent tests.
21
+ // Exit codes: 0 success (reader, preview, or apply); 2 usage (bad args / unknown key / invalid value /
22
+ // a model or effort the catalog does not offer); 1 precondition STOP (duplicate-carrying / symlinked /
23
+ // unreadable file, a write STOP) or a corrupt bundle (cannot determine the allowlist).
24
+ // main(argv, ctx) → Promise of { code, stdout, stderr }; getenv / home / bundleRoot / fs and the
25
+ // catalog bounds are injectable for host-independent tests.
23
26
  //
24
27
  // Dependency-free, Node >= 22. No side effects on import (the isDirectRun idiom).
25
28
 
26
29
  import { isDirectRun } from './direct-run.mjs';
27
30
  import { settingValueValid } from './manifest/validate.mjs';
28
31
  import { writeHostConfigFileAtomic } from './atomic-write.mjs';
32
+ import { readCatalogs } from './bridge-catalog.mjs';
29
33
  import {
30
34
  SETTINGS_SUBDIR, SETTINGS_FILENAME, fail, loadRegistry, allowedLabel, settingsDir, settingsPath,
31
35
  joinLines, readFileState, parseSettings, duplicateKeys, effectiveOf, displayValue,
32
36
  } from './bridge-settings-read.mjs';
33
37
 
38
+ // ── host posture (the model keys) ──────────────────────────────────────────────────────
39
+
40
+ const slotOf = (key) => (key.endsWith('_EFFORT') ? 'effort' : 'model');
41
+ const postureBridges = (registry) => [...new Set([...registry.values()].filter((e) => e.kind === 'posture').map((e) => e.bridge))];
42
+
43
+ // One bridge's posture as { model: {key, value, source, note}, effort?: … } under the given env.
44
+ const postureOfBridge = (registry, parsed, getenv, bridge) => Object.fromEntries([...registry.values()]
45
+ .filter((e) => e.kind === 'posture' && e.bridge === bridge)
46
+ .map((e) => {
47
+ const eff = effectiveOf(e, parsed, getenv);
48
+ return [slotOf(e.key), { key: e.key, value: eff.value, source: eff.source, note: eff.note ?? null, retired: e.retired ?? null }];
49
+ }));
50
+
51
+ const showModel = (v) => (v == null ? '(none)' : v);
52
+ const offeredLabel = (catalog) => catalog.entries
53
+ .map((e) => (e.efforts === undefined ? e.model : `${e.model} (${e.efforts === null ? 'efforts unknown' : e.efforts.join(', ')})`))
54
+ .join(', ');
55
+ const catalogWarning = (bridge, catalog) => (catalog.state === 'not-installed'
56
+ ? `⚠ ${bridge}: not installed (${catalog.cause}) — no offered list`
57
+ : `⚠ ${bridge}: the model catalog is unreadable (${catalog.cause}) — the offered list is unknown`);
58
+
59
+ const renderPosture = (registry, parsed, getenv, catalogs) => {
60
+ const lines = ['', ' host posture (the model each bridge runs; the offered models are read from the installed CLI, none is run):'];
61
+ for (const bridge of postureBridges(registry)) {
62
+ const p = postureOfBridge(registry, parsed, getenv, bridge);
63
+ const effort = p.effort ? ` · effort ${showModel(p.effort.value)} [${p.effort.source}]` : '';
64
+ const notes = [p.model.note, p.effort?.note].filter(Boolean).map((n) => ` — ${n}`).join('');
65
+ lines.push(` ${bridge}: model ${showModel(p.model.value)} [${p.model.source}]${effort}${notes}`);
66
+ const catalog = catalogs[bridge];
67
+ lines.push(catalog.state === 'offered' ? ` offered: ${offeredLabel(catalog)}` : ` ${catalogWarning(bridge, catalog)}`);
68
+ }
69
+ return lines;
70
+ };
71
+
72
+ const postureJson = (registry, parsed, getenv, catalogs) => postureBridges(registry).map((bridge) => ({
73
+ bridge, ...postureOfBridge(registry, parsed, getenv, bridge), catalog: catalogs[bridge],
74
+ }));
75
+
76
+ // The set judgment: the posture the file would give after the ops (the environment ignored), judged
77
+ // against each touched bridge's catalog. Refuses (exit 2) only a model the catalog does not offer or a
78
+ // codex effort that model does not offer; an unreadable catalog yields an "unchecked" statement.
79
+ const judgePosture = async (ops, registry, newLines, ctx) => {
80
+ const bridges = [...new Set(ops.map((op) => registry.get(op.key)).filter((e) => e.kind === 'posture').map((e) => e.bridge))];
81
+ if (!bridges.length) return [];
82
+ const after = parseSettings(joinLines(newLines));
83
+ const catalogs = await readCatalogs(bridges, ctx);
84
+ const unchecked = [];
85
+ for (const bridge of bridges) {
86
+ const { model, effort } = postureOfBridge(registry, after, {}, bridge);
87
+ const catalog = catalogs[bridge];
88
+ if (catalog.state !== 'offered') {
89
+ unchecked.push(`${bridge}: the value was not checked — ${catalog.state === 'not-installed' ? 'not installed' : 'the model catalog is unreadable'} (${catalog.cause}); the wrapper checks it at run time`);
90
+ continue;
91
+ }
92
+ const entry = catalog.entries.find((e) => e.model === model.value);
93
+ if (!entry) throw fail(2, `${model.key}=${model.value} is not a model the installed ${bridge} catalog offers — refusing before any write. Offered: ${catalog.entries.map((e) => e.model).join(', ')}`);
94
+ if (effort && entry.efforts === null) unchecked.push(`${bridge}: the effort was not checked — the catalog entry for ${model.value} lists no efforts; the wrapper checks it at run time`);
95
+ else if (effort && !entry.efforts.includes(effort.value)) {
96
+ throw fail(2, `${effort.key}=${effort.value} is not an effort the installed ${bridge} catalog offers for ${model.value} — refusing before any write. Offered: ${entry.efforts.join(', ')}`);
97
+ }
98
+ }
99
+ return unchecked;
100
+ };
101
+
34
102
  // ── reader render ──────────────────────────────────────────────────────────────────
35
103
 
36
- const renderReader = (registry, parsed, fileState, ctx, path) => {
104
+ const renderReader = (registry, parsed, fileState, ctx, path, catalogs) => {
37
105
  const getenv = ctx.getenv ?? process.env;
38
106
  const lines = [`bridge settings — ${path}`];
39
107
  if (fileState.state === 'absent') lines.push(' (no settings file yet — every knob is at its built-in default)');
@@ -43,10 +111,12 @@ const renderReader = (registry, parsed, fileState, ctx, path) => {
43
111
  const eff = effectiveOf(entry, parsed, getenv);
44
112
  const note = eff.note ? ` — ${eff.note}` : '';
45
113
  const retired = entry.retired ? ' ⚠ RETIRED' : '';
46
- lines.push(` ${entry.key} = ${displayValue(eff.value)} [${eff.source}]${retired}${note}`);
114
+ const shown = entry.kind === 'posture' ? showModel(eff.value) : displayValue(eff.value);
115
+ lines.push(` ${entry.key} = ${shown} [${eff.source}]${retired}${note}`);
47
116
  lines.push(` ${entry.bridge} · ${allowedLabel(entry)} · ${entry.effect}`);
48
117
  if (entry.retired) lines.push(` RETIRED — ${entry.retired}`);
49
118
  }
119
+ lines.push(...renderPosture(registry, parsed, getenv, catalogs));
50
120
  const dups = duplicateKeys(parsed);
51
121
  const unknown = [...parsed.byKey.keys()].filter((k) => !registry.has(k));
52
122
  if (dups.length) lines.push('', ` ⚠ duplicate keys in the file (last wins at read time; the writer refuses to edit until you fix these): ${dups.join(', ')}`);
@@ -56,7 +126,7 @@ const renderReader = (registry, parsed, fileState, ctx, path) => {
56
126
  return lines.join('\n');
57
127
  };
58
128
 
59
- const buildReaderJson = (registry, parsed, fileState, ctx, path) => {
129
+ const buildReaderJson = (registry, parsed, fileState, ctx, path, catalogs) => {
60
130
  const getenv = ctx.getenv ?? process.env;
61
131
  return {
62
132
  path,
@@ -65,6 +135,7 @@ const buildReaderJson = (registry, parsed, fileState, ctx, path) => {
65
135
  const eff = effectiveOf(entry, parsed, getenv);
66
136
  return { key: entry.key, bridge: entry.bridge, kind: entry.kind, effective: eff.value, source: eff.source, note: eff.note ?? null, default: entry.default, retired: entry.retired ?? null };
67
137
  }),
138
+ bridges: postureJson(registry, parsed, getenv, catalogs),
68
139
  duplicateKeys: duplicateKeys(parsed),
69
140
  unknownKeys: [...parsed.byKey.keys()].filter((k) => !registry.has(k)),
70
141
  malformedLines: parsed.malformed.map((m) => m.index + 1),
@@ -182,12 +253,22 @@ const changeLine = (c) => {
182
253
  return ` ${c.key}: ${b} → ${a}`;
183
254
  };
184
255
 
185
- const renderWriter = ({ changes, wrote, path, willWrite, envShadows, caveats }) => {
256
+ // A model key's env value off the host posture is a one-off the wrappers refuse (spec bridge-model),
257
+ // so its shadow line says that instead of "it wins".
258
+ const SHADOW_EFFECT = {
259
+ 'codex-cli-bridge': 'an environment value off the host posture is a one-off, which codex-exec and codex-review refuse unless CODEX_PROBE=1; unset it.',
260
+ 'antigravity-cli-bridge': 'an environment value off the host posture (an empty one included) is a one-off, which agy-review refuses unless AGY_PROBE=1 (agy-run, the probe role, runs it); unset it.',
261
+ };
262
+ const shadowLine = (entry) => ` ⚠ ${entry.key} is currently set in the environment — ${entry.kind === 'posture'
263
+ ? SHADOW_EFFECT[entry.bridge] : 'the env value overrides the file for this session (unset it, or it wins until you do).'}`;
264
+
265
+ const renderWriter = ({ registry, changes, wrote, path, willWrite, envShadows, caveats, unchecked }) => {
186
266
  const lines = [];
187
267
  lines.push(wrote ? `wrote ${path}` : 'bridge-settings — preview (nothing written; re-run with --apply to write)');
188
268
  for (const c of changes) lines.push(changeLine(c));
189
269
  for (const cav of caveats) lines.push(` ↳ ${cav}`);
190
- for (const key of envShadows) lines.push(` ⚠ ${key} is currently set in the environment — the env value overrides the file for this session (unset it, or it wins until you do).`);
270
+ for (const u of unchecked) lines.push(` ⚠ ${u}`);
271
+ for (const key of envShadows) lines.push(shadowLine(registry.get(key)));
191
272
  if (!wrote && willWrite) lines.push('', `re-run with --apply to write ${path}.`);
192
273
  if (!wrote && !willWrite) lines.push(' no change — the file already reads this way.');
193
274
  return lines.join('\n');
@@ -200,7 +281,9 @@ const HELP = `bridge-settings — read / write the host-level bridge settings fi
200
281
  ${'${XDG_CONFIG_HOME:-~/.config}'}/${SETTINGS_SUBDIR}/${SETTINGS_FILENAME} (KEY=VALUE lines; parsed, never sourced)
201
282
 
202
283
  Usage:
203
- node bridge-settings.mjs show every knob's effective value + source
284
+ node bridge-settings.mjs show every knob's effective value + source, each
285
+ bridge's model (codex: and effort) and the models
286
+ its installed CLI offers
204
287
  node bridge-settings.mjs --set KEY=VALUE preview a change (writes nothing)
205
288
  node bridge-settings.mjs --set KEY=VALUE --apply write the change (atomic; creates the file on first use)
206
289
  node bridge-settings.mjs --unset KEY [--apply] return a knob to its built-in default
@@ -209,14 +292,17 @@ Usage:
209
292
  keys (preserved verbatim), never writes
210
293
 
211
294
  Precedence at run time: explicit env (even empty: KEY= disables) > this file > the wrapper's built-in
212
- default. Allowed keys + value rules come from the bundled bridge manifests; model/effort are NOT
213
- settable here (the wrappers' quality guard is untouched). This host file survives every kit upgrade —
295
+ default. Allowed keys + value rules come from the bundled bridge manifests. CODEX_MODEL, CODEX_EFFORT
296
+ and AGY_MODEL set each bridge's host posture; a set of one is checked against that bridge's installed
297
+ CLI catalog before anything is written (an unreadable catalog leaves it unchecked, and says so). The
298
+ command runs only the catalog commands, never a model. This host file survives every kit upgrade —
214
299
  a kit refresh never writes or clobbers it.
215
300
 
216
- Exit codes: 0 success (read / preview / write); 2 usage (bad args, unknown key, invalid value);
217
- 1 precondition STOP (duplicate-carrying / symlinked / unreadable file, or a corrupt bundle).`;
301
+ Exit codes: 0 success (read / preview / write); 2 usage (bad args, unknown key, invalid value, a model
302
+ or effort the catalog does not offer); 1 precondition STOP (duplicate-carrying / symlinked /
303
+ unreadable file, or a corrupt bundle).`;
218
304
 
219
- export const main = (argv = [], ctx = {}) => {
305
+ export const main = async (argv = [], ctx = {}) => {
220
306
  const getenv = ctx.getenv ?? process.env;
221
307
  try {
222
308
  if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
@@ -236,9 +322,10 @@ export const main = (argv = [], ctx = {}) => {
236
322
  if (ops.length === 0) {
237
323
  const fileState = readFileState(path, ctx);
238
324
  const parsed = parseSettings(fileState.text ?? '');
325
+ const catalogs = await readCatalogs(postureBridges(registry), ctx);
239
326
  const stdout = json
240
- ? JSON.stringify(buildReaderJson(registry, parsed, fileState, ctx, path), null, 2)
241
- : renderReader(registry, parsed, fileState, ctx, path);
327
+ ? JSON.stringify(buildReaderJson(registry, parsed, fileState, ctx, path, catalogs), null, 2)
328
+ : renderReader(registry, parsed, fileState, ctx, path, catalogs);
242
329
  return { code: 0, stdout, stderr: '' };
243
330
  }
244
331
 
@@ -271,15 +358,16 @@ export const main = (argv = [], ctx = {}) => {
271
358
 
272
359
  const newLines = ops.reduce(applyOp, parsed.lines);
273
360
  const body = joinLines(newLines);
361
+ const unchecked = await judgePosture(ops, registry, newLines, ctx);
274
362
 
275
363
  // The machine surface carries the SAME spend/credit-rate caveats the human preview prints (D4) — a
276
364
  // --json consumer must not miss the spend consent warning the human render shows.
277
- const jsonBody = (wrote) => ({ path, wrote, noop, changes: changes.map((c) => ({ key: c.key, kind: c.kind, before: c.before, after: c.after })), envShadows, caveats });
365
+ const jsonBody = (wrote) => ({ path, wrote, noop, changes: changes.map((c) => ({ key: c.key, kind: c.kind, before: c.before, after: c.after })), envShadows, caveats, unchecked });
278
366
 
279
367
  if (!apply || noop) {
280
368
  const stdout = json
281
369
  ? JSON.stringify(jsonBody(false), null, 2)
282
- : renderWriter({ changes, wrote: false, path, willWrite: !noop, envShadows, caveats });
370
+ : renderWriter({ registry, changes, wrote: false, path, willWrite: !noop, envShadows, caveats, unchecked });
283
371
  return { code: 0, stdout, stderr: '' };
284
372
  }
285
373
 
@@ -287,7 +375,7 @@ export const main = (argv = [], ctx = {}) => {
287
375
  writeHostConfigFileAtomic(settingsDir(ctx), SETTINGS_FILENAME, body, ctx, { noun: 'the bridge settings file' });
288
376
  const stdout = json
289
377
  ? JSON.stringify(jsonBody(true), null, 2)
290
- : renderWriter({ changes, wrote: true, path, willWrite: false, envShadows, caveats });
378
+ : renderWriter({ registry, changes, wrote: true, path, willWrite: false, envShadows, caveats, unchecked });
291
379
  return { code: 0, stdout, stderr: '' };
292
380
  } catch (err) {
293
381
  return { code: err.exitCode ?? 1, stdout: '', stderr: `bridge-settings: ${err.message}` };
@@ -295,7 +383,7 @@ export const main = (argv = [], ctx = {}) => {
295
383
  };
296
384
 
297
385
  if (isDirectRun(import.meta.url)) {
298
- const r = main(process.argv.slice(2));
386
+ const r = await main(process.argv.slice(2));
299
387
  if (r.stdout) console.log(r.stdout);
300
388
  if (r.stderr) console.error(r.stderr);
301
389
  process.exit(r.code);
@@ -112,8 +112,8 @@ export const SLICE_BY_SLOT = {
112
112
  'plan-execution.execute': 'a slice is a set of file-disjoint ledger rows; wording is copied verbatim where wording is a red line',
113
113
  'routine.carrier': "a slice is a bounded mechanical task; a read-only one (a sweep, gate triage) rides its placed read-only vehicle, or is carried solo with a stated reason when that vehicle is absent; a write-capable one (a regeneration, a fixture build) rides the executor; the changelog stays the orchestrator's",
114
114
  'epic.author': 'a slice is a brief naming the intent, the value, the non-goals and the stories in order; the subagent drafts the epic file from it, and the orchestrator runs the shape check on the draft as its own',
115
- 'task.author': 'a slice is one ledger row of the plan with the contracts it reads; the subagent writes the task brief from it, and the orchestrator stamps and checks the brief itself',
116
- 'task.execute': 'a slice is one stamped brief; the subagent runs it inside the Files the brief names and reports the paths it changed, and the orchestrator runs the Acceptance commands itself',
115
+ 'task.author': "no slice the flow dispatches: the orchestrator writes each row's prompt itself, whatever this slot resolves to",
116
+ 'task.execute': "a slice is one prompt the orchestrator wrote from one ledger row; the subagent runs it inside the Files the prompt names and reports the paths it changed, and the orchestrator runs the row's tests itself",
117
117
  };
118
118
 
119
119
  export const VEHICLE_STATE_TOKEN = '<state>';
@@ -139,12 +139,14 @@ export const SUBAGENT_SLOT_TYPES = Object.entries(SLOT_RECIPES)
139
139
 
140
140
  // dispatchForm({ activity, slot, state }) → the lines a `subagent`-resolved slot renders: the
141
141
  // slot's slice sentence, then the four shared lines with the surveyed vehicle state filled in.
142
- // A slot whose type cannot hold `subagent` (a review slot) and an unknown activity render nothing.
142
+ // A slot type that cannot hold `subagent` (review) and an unknown activity render nothing; a slot
143
+ // whose slice the flow never dispatches renders its sentence alone.
144
+ const UNDISPATCHED_SLOTS = new Set(['task.author']);
143
145
  export const dispatchForm = ({ activity, slot, state } = {}) => {
144
146
  const slice = SLICE_BY_SLOT[`${activity}.${slot}`];
145
147
  const slotType = ACTIVITIES[activity]?.slots?.[slot];
146
148
  if (!slice || !SUBAGENT_SLOT_TYPES.includes(slotType)) return [];
147
- return [slice, ...DISPATCH_LINES.map((line) => line.replace(VEHICLE_STATE_TOKEN, state ?? MISSING))];
149
+ return UNDISPATCHED_SLOTS.has(`${activity}.${slot}`) ? [slice] : [slice, ...DISPATCH_LINES.map((line) => line.replace(VEHICLE_STATE_TOKEN, state ?? MISSING))];
148
150
  };
149
151
 
150
152
  // parallelLine({ value, carrier }) → the `routine` switch line. The concurrency claim is TRUE only
@@ -94,7 +94,7 @@ const EXIT_USAGE = 2;
94
94
  // the internal-attestation evaluation consumes this sentence — a lens set claiming a configured
95
95
  // backend's slot without a then-active down-mark REFUSES, quoting it (substitution is recorded,
96
96
  // never silent).
97
- export const FALLBACK_LENS_ADDITIONAL_ONLY = 'review-lens is an ADDITIONAL read-only review opinion, not a replacement for your configured review recipe.';
97
+ export const FALLBACK_LENS_ADDITIONAL_ONLY = 'review-lens is an ADDITIONAL read-only review opinion, not a replacement for your plan or diff review recipe.';
98
98
 
99
99
  const USAGE = `usage: cheap-agents [--dry-run | --apply] [--cwd <dir>] [--help]
100
100
 
@@ -75,7 +75,7 @@ const RAW_BACKENDS = [
75
75
  'codex-exec --resume-last [--nonce <n>] <plan-file|->',
76
76
  'codex-exec --resume <session-id> [--nonce <n>] <plan-file|->',
77
77
  ],
78
- receipt: "side effect — a NONCED run mints ONE exec receipt beside the delegation store: the dispatch nonce seam is the AW_DISPATCH_NONCE environment value or its plain-argument equivalent --nonce <n>, recognised ONLY before the prompt operand (after the operand or a literal '--' it is passthrough payload, never a flag), under the safe grammar [A-Za-z0-9._-]{1,64} — anything else, a duplicate, or a flag disagreeing with a non-empty env value refuses PRE-SPEND. The store directory resolves exactly as the kit's delegation store does: the dirname of an ABSOLUTE AW_DELEGATION_STORE (a relative one, or one ending in a path separator, refuses), else the git common dir. The artifact is agent-workflow-exec-receipt-<backendLength>-<backend>-<nonce>.json in two states: 'reserved' is written atomically and NO-CLOBBER immediately before the CLI runs — that write IS the nonce reservation, so a second dispatch on the same nonce, or an already-taken report name, refuses BEFORE any spend — and 'terminal' replaces it in place at exit. A nonced run also refuses pre-spend when no timeout/gtimeout binary can cap it (an accounted dispatch that cannot be capped can never honour the terminal-exit rule; a nonce-LESS run still warns and runs uncapped), when node is missing (the mint core), and when the prompt rides on stdin instead of a contract FILE — contractDigest is computed BY THIS WRAPPER from the dispatch file it was actually handed, so the kit can refuse a run that executed a different contract than the one it opened. That digest is taken from the SAME bytes already read as the prompt, never a second open of the path: two reads leave a window in which the file can be swapped, and the run would then execute one contract while its receipt claimed the digest of another. The header's own nonce must EQUAL the dispatch nonce — 'dispatch open' copies the nonce FROM the header, so a disagreeing --nonce could only reserve an identity no return would ever absorb, and it refuses pre-spend. A contract file edited BETWEEN 'dispatch open' and the run is caught at ABSORB by the contractDigest comparison, not pre-spend: the wrapper never reads the ledger, and that boundary is what the whole lane rests on. At exit the wrapper FIRST re-reads its reservation and verifies its own opaque owner token — a foreign owner refuses having published NOTHING, neither report nor receipt — THEN writes the delegate's final message atomically to agent-workflow-exec-report-<backendLength>-<backend>-<nonce>.txt, THEN re-verifies the owner and REPLACES the reservation with the terminal receipt {schema, kind, state, backend, nonce, owner, contractDigest, wrapperVersion, posture {model, effort, tier}, capS, killGraceS, sessionId, exitStatus, outcome, reportDigest, reportLength, timestamp}: the report is complete on disk before any artifact says the run arrived. capS and killGraceS are the cap the run ACTUALLY applied. outcome is the wrapper's own SUBSET of the ledger's vocabulary — exit 0 with a session id -> success, exit 0 without one -> missing-identity, ANY nonzero exit including the timeout codes 124 and 137 -> transport-failure; every orchestrator judgment is recorded at absorb time, never claimed here. The session id is captured BEFORE outcome branching, so a FAILED run records one too; in resume mode it is the validated resume id. FAIL-CLOSED, deliberately NOT the review lane's warn-only receipt: a publication that cannot complete exits nonzero with a DISTINCT status, and the message states only what the run can still prove. 70: the reservation could not be verified BEFORE any publication — NOTHING was published, not the report and not the receipt, and because the artifact found there belongs to another run it is never a '--no-receipt' source. 71: a publication stopped after that point — either the report write failed (nothing beyond the reservation was published; the '--no-receipt' absorb then records reportLength 0, ineligible by the name empty-report) or the report IS on disk and the terminal receipt was not completed (the absorb reads it, report-if-present). The post-report lane never claims the reservation still stands, because after that point its fate is no longer something this run observed. Every lane names the tree as partial/dirtied rather than untouched. A nonce-LESS invocation is byte-unchanged: no reservation, no receipt, no artifact, no node.",
78
+ receipt: "side effect — a NONCED run mints ONE exec receipt beside the delegation store: the dispatch nonce seam is the AW_DISPATCH_NONCE environment value or its plain-argument equivalent --nonce <n>, recognised ONLY before the prompt operand (after the operand or a literal '--' it is passthrough payload, never a flag), under the safe grammar [A-Za-z0-9._-]{1,64} — anything else, a duplicate, or a flag disagreeing with a non-empty env value refuses PRE-SPEND. The store directory resolves exactly as the kit's delegation store does: the dirname of an ABSOLUTE AW_DELEGATION_STORE (a relative one, or one ending in a path separator, refuses), else the git common dir. The artifact is agent-workflow-exec-receipt-<backendLength>-<backend>-<nonce>.json in two states: 'reserved' is written atomically and NO-CLOBBER immediately before the CLI runs — that write IS the nonce reservation, so a second dispatch on the same nonce, or an already-taken report name, refuses BEFORE any spend — and 'terminal' replaces it in place at exit. A nonced run also refuses pre-spend when no timeout/gtimeout binary can cap it (an accounted dispatch that cannot be capped can never honour the terminal-exit rule; a nonce-LESS run still warns and runs uncapped), when node is missing (the mint core), and when the prompt rides on stdin instead of a contract FILE — contractDigest is computed BY THIS WRAPPER from the dispatch file it was actually handed, so the kit can refuse a run that executed a different contract than the one it opened. That digest is taken from the SAME bytes already read as the prompt, never a second open of the path: two reads leave a window in which the file can be swapped, and the run would then execute one contract while its receipt claimed the digest of another. The header's own nonce must EQUAL the dispatch nonce — 'dispatch open' copies the nonce FROM the header, so a disagreeing --nonce could only reserve an identity no return would ever absorb, and it refuses pre-spend. A contract file edited BETWEEN 'dispatch open' and the run is caught at ABSORB by the contractDigest comparison, not pre-spend: the wrapper never reads the ledger, and that boundary is what the whole lane rests on. At exit the wrapper FIRST re-reads its reservation and verifies its own opaque owner token — a foreign owner refuses having published NOTHING, neither report nor receipt — THEN writes the delegate's final message atomically to agent-workflow-exec-report-<backendLength>-<backend>-<nonce>.txt, THEN re-verifies the owner and REPLACES the reservation with the terminal receipt {schema, kind, state, backend, nonce, owner, contractDigest, wrapperVersion, posture {model, effort, tier}, capS, killGraceS, sessionId, exitStatus, outcome, reportDigest, reportLength, timestamp}: the report is complete on disk before any artifact says the run arrived. capS and killGraceS are the cap the run ACTUALLY applied. outcome is the wrapper's own SUBSET of the ledger's vocabulary — exit 0 with a session id -> success, exit 0 without one -> missing-identity, ANY nonzero exit including the timeout codes 124 and 137 -> transport-failure; every orchestrator judgment is recorded at absorb time, never claimed here. The session id is captured BEFORE outcome branching, so a FAILED run records one too; in resume mode it is the validated resume id. FAIL-CLOSED, deliberately NOT the review lane's warn-only receipt: a publication that cannot complete exits nonzero with a DISTINCT status, and the message states only what the run can still prove. 70: the reservation could not be verified BEFORE any publication — NOTHING was published, not the report and not the receipt, and because the artifact found there belongs to another run it is never a '--no-receipt' source. 71: a publication stopped after that point — either the report write failed (nothing beyond the reservation was published; the '--no-receipt' absorb then records reportLength 0, ineligible by the name empty-report) or the report IS on disk and the terminal receipt was not completed (the absorb reads it, report-if-present). The post-report lane never claims the reservation still stands, because after that point its fate is no longer something this run observed. Every lane names the tree as partial/dirtied rather than untouched. A nonce-LESS invocation is byte-unchanged: no reservation, no receipt, no artifact, and it needs no node.",
79
79
  passthrough: {
80
80
  policy: 'guarded',
81
81
  blocked: ['-c*', '--config*', '-s*', '--sandbox*', '--dangerously-bypass-approvals-and-sandbox', '--dangerously-bypass-hook-trust', '--full-auto', '--oss', '--local-provider*', '-p*', '--profile*', '-m*', '--model*', '-o*', '--output-last-message*', '--json*', '--color*', '--output-schema*', '--ephemeral*'],
@@ -83,9 +83,9 @@ const RAW_BACKENDS = [
83
83
  },
84
84
  notes: [
85
85
  'nested-sandbox limit: codex-exec ships its OWN OS sandbox (bwrap workspace-write) and cannot run nested inside a harness sandbox (the FS turns read-only) — route it OUTSIDE the harness sandbox (excludedCommands / a per-run consented bypass) on the OBSERVED bwrap/EPERM failure, never a preemptive blanket',
86
- 'exec posture banner: ONE stderr line before dispatch states the ACTUAL run posture — exec posture: model=… effort=… tier=… sandbox=workspace-write session=fresh|resume:<id> timeout=… — from RESOLVED post-validation values; the resume id is validated pre-spend, and control bytes in any banner field refuse pre-spend',
86
+ 'exec posture banner: ONE stderr line before dispatch states the ACTUAL run posture — exec posture: model=… effort=… tier=… sandbox=workspace-write session=fresh|resume:<id> source=… timeout=… — from RESOLVED post-validation values; the resume id is validated pre-spend, and control bytes in any banner field refuse pre-spend',
87
87
  'threat model: the sidecar byte and grammar screens detect corrupted input under a trusted parent environment. A hostile parent environment — including exported shell functions or PATH substitution of core/backend commands — is outside the threat model and can substitute the backend itself. Targeted shadow-proof resolution protects banner/dispatch honesty from accidental shadowing; it is not an environment security boundary',
88
- 'the exec posture banner appends a banner-only timeout=<duration|uncapped> field — exactly the duration handed to timeout(1), uncapped when no timeout/gtimeout binary caps the run; INFORMATIONAL only: it is never persisted in a receipt or session sidecar',
88
+ 'the exec posture banner appends a banner-only source=model:<s>,effort:<s> field — where each value came from: default, setting or environment — then a banner-only timeout=<duration|uncapped> field — exactly the duration handed to timeout(1), uncapped when no timeout/gtimeout binary caps the run; INFORMATIONAL only: neither is persisted in a receipt or session sidecar',
89
89
  'quote the posture banner verbatim when labeling this dispatch — the banner is the machine-stated posture; a prose re-type drifts',
90
90
  'every-run nested-sandbox scan (DUAL policy, deliberately two different rules): the scan runs on EVERY completed run, not only a failed one, because a run that SURVIVES the nested-sandbox failure exits 0 with an ungrounded answer and nothing said so. Failed run (rc != 0): the existing loose whole-trace combination rule prints the recovery hint. Successful run (rc == 0): a warning fires ONLY on precise per-item evidence — both a sandbox-mechanism token AND a permission/read-only failure token inside the aggregated_output of ONE command_execution item whose failure is PROVEN (a nonzero exit_code, or the serialized status "failed"); a null exit_code is never failure by itself, tokens split across two items never fire, and a successful command\'s output never fires. The answer is printed FIRST on stdout, then the warning on stderr. HONEST RESIDUAL: the exit status does NOT change on that lane (a distinct nonzero exit would give a heuristic scan DENY polarity, refusing real work whenever the scan over-warns), so an orchestrator keying on exit status alone can still bank an ungrounded answer — the stderr warning is the signal',
91
91
  ],
@@ -99,7 +99,7 @@ const RAW_BACKENDS = [
99
99
  continue: [],
100
100
  receipt: 'side effect — a successful review appends one JSON receipt line to <git dir>/agent-workflow-review-receipts.jsonl (AW_REVIEW_RECEIPTS overrides): fingerprint = sha256 over the canonical uncommitted-state payload (staged diff + unstaged diff + untracked-not-ignored contents — the review-payload domain; never-committable untracked paths — character/block devices, FIFOs, sockets — are excluded from the domain entirely, untracked symlinks/directories ride as name-only notes) in code mode, the artifact-file sha256 in plan mode; durationS = integer wall-clock seconds from CLI start through verdict parsing and the wrapper prints review duration: <n>s; blocking = the count of [blocker] and [major] lines (schema mode: findings with either severity; a payload whose findings cannot be counted fails the run, no receipt); artifactPath = the normalized realpath on plan receipts only (repo-relative inside the work tree, absolute otherwise), while a double quote, backslash or control byte refuses pre-spend because the receipt encoder cannot carry it; verdict parsed from the mandated literal verdict line (schema mode: the verdict field); always fresh:true (one-shot) + grounded:true (native AGENTS.md auto-merge, factsHash null); probe = whether the run relaxed the quality guards (CODEX_PROBE=1), written on EVERY receipt so it self-declares — the kit\'s review-state gate rejects a probe-marked receipt (a probe review never attests) and equally rejects an unmarked one (silence is not a declaration); posture = the ACTUAL run posture {model, effort, tier} (tier null on the standard tier), written on EVERY receipt (D5) — the gate rejects a receipt with an absent/invalid posture (a pre-D5 wrapper minted it; re-run the review), one stderr banner line states the same posture, and a posture value carrying control bytes refuses pre-spend in every mode; a run whose final message carries NO recognized \'Verdict: <ship|revise|rethink>\' line — empty or missing output included — exits 4 with NO receipt (D4: a FAILED review to RE-RUN, never a fatal session error); when the dispatch nonce seam is supplied — the AW_REVIEW_NONCE environment value or its plain-argument equivalent --nonce <n> (one seam: the flag assigns the same value; supplying both with different values refuses pre-spend) — under the safe grammar [A-Za-z0-9._-]{1,64} (anything else refuses pre-spend), the wrapper first mints the finding MANIFEST {schema, backend, nonce, fingerprint, findings} beside the receipts file (agent-workflow-finding-manifest-<backend>-<nonce>.json; atomic, no-clobber — a byte-identical rewrite is an idempotent no-op, different bytes refuse loudly) ORDERED before the receipt append — a failed manifest write EXCLUDES the receipt append, so a nonce-supplied dispatch can never land a receipt without its readable manifest; a nonce-less invocation adds NO nonce field and mints NO finding manifest (the existing wrapperVersion field still changes with each bridge release); a write failure warns, never fails the review',
101
101
  notes: [
102
- 'the review posture banner appends a banner-only timeout=<duration> field — exactly the duration handed to timeout(1); the hard-timeout preflight fails CLOSED when no timeout/gtimeout binary exists (the wrapper refuses by name before any CLI run, so an uncapped review run can no longer happen), and the field never enters the receipt posture or the D5 banner↔receipt parity',
102
+ 'the review posture banner appends a banner-only source=model:<s>,effort:<s> field — where each value came from: default, setting or environment — then a banner-only timeout=<duration> field — exactly the duration handed to timeout(1); the hard-timeout preflight fails CLOSED when no timeout/gtimeout binary exists (the wrapper refuses by name before any CLI run, so an uncapped review run can no longer happen), and neither field enters the receipt posture or the D5 banner↔receipt parity',
103
103
  'quote the posture banner verbatim when labeling this dispatch — the banner is the machine-stated posture; a prose re-type drifts',
104
104
  ],
105
105
  },
@@ -139,11 +139,11 @@ const RAW_BACKENDS = [
139
139
  'agy-review --continue [--decided @f] [--focus "…"] [--nonce <n>]',
140
140
  'agy-review --conversation <id> [--decided @f] [--focus "…"] [--nonce <n>]',
141
141
  ],
142
- receipt: "side effect — a successful review appends one JSON receipt line to <git dir>/agent-workflow-review-receipts.jsonl (AW_REVIEW_RECEIPTS overrides; plan/diff outside a git tree: warn + skip unless overridden): fingerprint = sha256 over the canonical uncommitted-state payload (staged diff + unstaged diff + untracked-not-ignored contents — the review-payload domain; never-committable untracked paths — character/block devices, FIFOs, sockets — are excluded from the domain entirely, untracked symlinks/directories ride as name-only notes) in code mode, the artifact-file sha256 in plan/diff mode; durationS = integer wall-clock seconds from CLI start through verdict parsing and the wrapper prints review duration: <n>s; blocking = the count of numbered items in the first ### Blocking section; artifactPath = the normalized realpath on plan/diff receipts only (repo-relative inside the work tree, absolute otherwise), while a double quote, backslash or control byte refuses pre-spend because the receipt encoder cannot carry it; verdict recorded verbatim from the mandated '### Verdict' section (SHIP / SHIP WITH NITS / REWORK); grounded = whether a NON-EMPTY --facts payload was supplied (code mode refuses pre-spend without one — no run, no receipt — unless --ungrounded/AGY_PROBE=1; in plan/diff an empty payload records grounded:false — fail-closed, the state gate rejects it), factsHash = sha256 of the facts payload; a continuation receipt is fresh:false (informational-only — it cannot attest the folded tree); probe = whether the run relaxed the quality guards (AGY_PROBE=1), written on EVERY receipt so it self-declares — the kit's review-state gate rejects a probe-marked receipt (a probe review never attests) and equally rejects an unmarked one (silence is not a declaration); posture = the ACTUAL run posture {model} (agy has no tier), written on EVERY receipt (D5) — the gate rejects a receipt with an absent/invalid posture (a pre-D5 wrapper minted it; re-run the review), one stderr banner line states the same posture, an ATTESTING review with AGY_MODEL explicitly emptied refuses pre-spend, and a model string carrying control bytes refuses pre-spend in every mode; delivery = how the change set REACHED the model, currently emitted as 'inline' (the whole set rode one prompt — proven by construction) or 'fed' (a chunked feed whose per-part echo proof verified); REQUIRED on every agy code receipt and its ABSENCE is what stops a pre-fed-lane receipt attesting, while the gate accepts any well-formed declaration rather than a particular value; absent by construction on plan/diff/continuation receipts, which carry no change set; a run whose output carries NO recognized '### Verdict' section — empty output included — exits 4 with NO receipt (D4: a FAILED review to RE-RUN, never a fatal session error); when the dispatch nonce seam is supplied — the AW_REVIEW_NONCE environment value or its plain-argument equivalent --nonce <n> (one seam: the flag assigns the same value; supplying both with different values refuses pre-spend) — under the safe grammar [A-Za-z0-9._-]{1,64} (anything else refuses pre-spend), the wrapper first mints the finding MANIFEST {schema, backend, nonce, fingerprint, findings} beside the receipts file (agent-workflow-finding-manifest-<backend>-<nonce>.json; atomic, no-clobber — a byte-identical rewrite is an idempotent no-op, different bytes refuse loudly) ORDERED before the receipt append — a failed manifest write EXCLUDES the receipt append, so a nonce-supplied dispatch can never land a receipt without its readable manifest; a nonce-less invocation adds NO nonce field and mints NO finding manifest (the existing wrapperVersion field still changes with each bridge release); a write failure warns, never fails the review",
142
+ receipt: "side effect — a successful review appends one JSON receipt line to <git dir>/agent-workflow-review-receipts.jsonl (AW_REVIEW_RECEIPTS overrides; plan/diff outside a git tree: warn + skip unless overridden): fingerprint = sha256 over the canonical uncommitted-state payload (staged diff + unstaged diff + untracked-not-ignored contents — the review-payload domain; never-committable untracked paths — character/block devices, FIFOs, sockets — are excluded from the domain entirely, untracked symlinks/directories ride as name-only notes) in code mode, the artifact-file sha256 in plan/diff mode; durationS = integer wall-clock seconds from CLI start through verdict parsing and the wrapper prints review duration: <n>s; blocking = the count of numbered items in the first ### Blocking section; artifactPath = the normalized realpath on plan/diff receipts only (repo-relative inside the work tree, absolute otherwise), while a double quote, backslash or control byte refuses pre-spend because the receipt encoder cannot carry it; verdict recorded verbatim from the mandated '### Verdict' section (SHIP / SHIP WITH NITS / REWORK); grounded = whether a NON-EMPTY --facts payload was supplied (code mode refuses pre-spend without one — no run, no receipt — unless --ungrounded/AGY_PROBE=1; in plan/diff an empty payload records grounded:false — fail-closed, the state gate rejects it), factsHash = sha256 of the facts payload; a continuation receipt is fresh:false (informational-only — it cannot attest the folded tree); probe = whether the run relaxed the quality guards (AGY_PROBE=1), written on EVERY receipt so it self-declares — the kit's review-state gate rejects a probe-marked receipt (a probe review never attests) and equally rejects an unmarked one (silence is not a declaration); posture = the ACTUAL run posture {model} (agy has no tier), written on EVERY receipt (D5) — the gate rejects a receipt with an absent/invalid posture (a pre-D5 wrapper minted it; re-run the review), one stderr banner line states the same posture, a one-off model (an explicitly empty AGY_MODEL included) refuses pre-spend unless AGY_PROBE=1, and a model string carrying control bytes refuses pre-spend in every mode; delivery = how the change set REACHED the model, currently emitted as 'inline' (the whole set rode one prompt — proven by construction) or 'fed' (a chunked feed whose per-part echo proof verified); REQUIRED on every agy code receipt and its ABSENCE is what stops a pre-fed-lane receipt attesting, while the gate accepts any well-formed declaration rather than a particular value; absent by construction on plan/diff/continuation receipts, which carry no change set; a run whose output carries NO recognized '### Verdict' section — empty output included — exits 4 with NO receipt (D4: a FAILED review to RE-RUN, never a fatal session error); when the dispatch nonce seam is supplied — the AW_REVIEW_NONCE environment value or its plain-argument equivalent --nonce <n> (one seam: the flag assigns the same value; supplying both with different values refuses pre-spend) — under the safe grammar [A-Za-z0-9._-]{1,64} (anything else refuses pre-spend), the wrapper first mints the finding MANIFEST {schema, backend, nonce, fingerprint, findings} beside the receipts file (agent-workflow-finding-manifest-<backend>-<nonce>.json; atomic, no-clobber — a byte-identical rewrite is an idempotent no-op, different bytes refuse loudly) ORDERED before the receipt append — a failed manifest write EXCLUDES the receipt append, so a nonce-supplied dispatch can never land a receipt without its readable manifest; a nonce-less invocation adds NO nonce field and mints NO finding manifest (the existing wrapperVersion field still changes with each bridge release); a write failure warns, never fails the review",
143
143
  notes: [
144
144
  'transport: every review dispatch drives the CLI in --output-format json (plus --disable-slash-commands) and the returned envelope is parsed in node (bin/agy-envelope.mjs) — the operator-facing invocations and flags above do NOT change, and on a ZERO exit the wrapper still PRINTS the review text, never JSON. A missing or unreadable envelope on a zero exit is a loud failure with NO receipt, never a downgraded verdict and never a fallback to raw-stdout parsing; a non-zero CLI exit keeps its own code and message, and publishes the captured stdout unchanged from the SINGLE dispatch or the FINAL fed turn (which may therefore be a JSON or partial payload — the envelope is parsed only on a zero exit); an INTERMEDIATE feed turn is the exception, its output stays private (Invariant E) and its failure prints only a named error. Enforced by a PRE-SPEND capability probe, not a version floor: agy --help must advertise --output-format and --disable-slash-commands, node must be >= 22, and bin/agy-envelope.mjs must be present — otherwise the review refuses before any run is spent and names the missing capability',
145
145
  'pre-dispatch host-diff: before the FIRST dispatch of this bridge, diff its declared networkHosts against the live sandbox allow-list — a missing host is surfaced to the maintainer BEFORE dispatching, never fired into a known prompt',
146
- 'the review posture banner appends a banner-only timeout=<duration> field — exactly the duration agy-run hands to timeout(1); the hard-timeout preflight fails CLOSED when no timeout/gtimeout binary exists (the wrapper refuses by name before any CLI run, so an uncapped review run can no longer happen), and the field never enters the receipt posture or the D5 banner↔receipt parity',
146
+ 'the review posture banner appends a banner-only source=model:<s> field — where the model came from: default, setting or environment — then a banner-only timeout=<duration> field — exactly the duration agy-run hands to timeout(1); the hard-timeout preflight fails CLOSED when no timeout/gtimeout binary exists (the wrapper refuses by name before any CLI run, so an uncapped review run can no longer happen), and neither field enters the receipt posture or the D5 banner↔receipt parity',
147
147
  'quote the posture banner verbatim when labeling this dispatch — the banner is the machine-stated posture; a prose re-type drifts',
148
148
  ],
149
149
  },
@@ -672,9 +672,9 @@ export const surveyBridges = (deps = {}) => {
672
672
  // Preserve findOnPath's THREE-state result (present | missing | unknown): an `unknown` (EACCES,
673
673
  // "cannot confirm") must stay distinct from a real `missing` — never flattened to a false boolean.
674
674
  const wrappers = m.wrapperCmds.map((cmd) => ({ cmd, state: probe(cmd, deps).state }));
675
- // Fact-only: the settings knobs ACTIVE (env/file, non-default) for THIS bridge. Model/effort are
676
- // structurally absent from the registry, so this can NEVER carry a model claim (the survey's
677
- // no-default-model-claim invariant). Localized-on-error like every other survey.
675
+ // Fact-only: the settings knobs ACTIVE (env/file, non-default) for THIS bridge. The model keys are
676
+ // knobs too, but their rows live in the snapshot's `posture`, never `active`, so the survey still
677
+ // emits no model field (its no-default-model-claim invariant). Localized-on-error like every survey.
678
678
  const settings = snapshot.error
679
679
  ? { error: snapshot.error }
680
680
  : { active: snapshot.active.filter((a) => a.bridge === m.name) };