@sabaiway/agent-workflow-kit 1.34.0 → 1.36.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 (42) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +1 -0
  3. package/SKILL.md +23 -13
  4. package/bin/install.mjs +15 -0
  5. package/bridges/antigravity-cli-bridge/SKILL.md +13 -1
  6. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +96 -1
  7. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +204 -1
  8. package/bridges/antigravity-cli-bridge/bin/agy.sh +94 -0
  9. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +245 -1
  10. package/bridges/antigravity-cli-bridge/capability.json +17 -1
  11. package/bridges/codex-cli-bridge/SKILL.md +15 -1
  12. package/bridges/codex-cli-bridge/bin/codex-exec.sh +113 -0
  13. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +288 -0
  14. package/bridges/codex-cli-bridge/bin/codex-review.sh +114 -1
  15. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +229 -1
  16. package/bridges/codex-cli-bridge/capability.json +29 -1
  17. package/capability.json +1 -1
  18. package/package.json +1 -1
  19. package/references/modes/bridge-settings.md +29 -0
  20. package/references/modes/review-ledger.md +28 -0
  21. package/references/modes/set-autonomy.md +29 -0
  22. package/references/modes/upgrade.md +8 -2
  23. package/references/modes/velocity.md +13 -0
  24. package/tools/atomic-write.mjs +59 -21
  25. package/tools/autonomy-config.mjs +306 -0
  26. package/tools/autonomy-write.mjs +27 -0
  27. package/tools/bridge-settings-read.mjs +221 -0
  28. package/tools/bridge-settings.mjs +288 -0
  29. package/tools/commands.mjs +21 -0
  30. package/tools/family-registry.mjs +12 -1
  31. package/tools/manifest/schema.md +31 -0
  32. package/tools/manifest/validate.mjs +85 -0
  33. package/tools/procedures.mjs +26 -4
  34. package/tools/recipes.mjs +16 -6
  35. package/tools/renderers.mjs +7 -0
  36. package/tools/review-ledger-write.mjs +257 -0
  37. package/tools/review-ledger.mjs +508 -0
  38. package/tools/seed-gates.mjs +45 -4
  39. package/tools/set-autonomy.mjs +195 -0
  40. package/tools/setup-backends.mjs +107 -9
  41. package/tools/velocity-profile.mjs +468 -5
  42. package/tools/view-model.mjs +7 -0
@@ -0,0 +1,288 @@
1
+ #!/usr/bin/env node
2
+ // bridge-settings.mjs — the reader + consent-gated WRITER for the host-level bridge settings file
3
+ // (bridges 2.3.0, D6). The four bridge wrappers READ ${XDG_CONFIG_HOME:-~/.config}/agent-workflow/
4
+ // bridge-settings.conf (KEY=VALUE lines, parsed never sourced; env > file > built-in default); this is
5
+ // the ONLY writer for it. It lives OUTSIDE every kit-managed tree, so a kit refresh never touches it
6
+ // (D2 — upgrade-survival is structural). The allowlist + typed validation come from the BUNDLED bridge
7
+ // manifests' `settings` blocks (manifest-as-source): the tool never invents a key or a value rule, and
8
+ // what it writes always passes the wrappers' own aw_settings_valid (the shared settingValueValid).
9
+ //
10
+ // Posture (D3, guarded): PREVIEW BY DEFAULT; --apply writes. It refuses an unknown key, an invalid /
11
+ // out-of-range value, and — loudly, naming the key — a file that already carries DUPLICATE keys (it
12
+ // never emits duplicates and never edits blindly around them). It touches only the KEY= line it owns,
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
16
+ // credit-rate caveat wherever it renders (from the manifest `effect`).
17
+ //
18
+ // 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.
23
+ //
24
+ // Dependency-free, Node >= 18. No side effects on import (the isDirectRun idiom).
25
+
26
+ import { pathToFileURL } from 'node:url';
27
+ import { settingValueValid } from './manifest/validate.mjs';
28
+ import { writeHostConfigFileAtomic } from './atomic-write.mjs';
29
+ import {
30
+ SETTINGS_SUBDIR, SETTINGS_FILENAME, fail, loadRegistry, allowedLabel, settingsDir, settingsPath,
31
+ joinLines, readFileState, parseSettings, duplicateKeys, effectiveOf, displayValue,
32
+ } from './bridge-settings-read.mjs';
33
+
34
+ // ── reader render ──────────────────────────────────────────────────────────────────
35
+
36
+ const renderReader = (registry, parsed, fileState, ctx, path) => {
37
+ const getenv = ctx.getenv ?? process.env;
38
+ const lines = [`bridge settings — ${path}`];
39
+ if (fileState.state === 'absent') lines.push(' (no settings file yet — every knob is at its built-in default)');
40
+ else if (fileState.state === 'unusable') lines.push(' ⚠ the settings file is a symlink / not a regular file / unreadable — the wrappers ignore it and fall back to built-in defaults');
41
+ lines.push('', ' effective knobs (env > file > built-in default):');
42
+ for (const entry of registry.values()) {
43
+ const eff = effectiveOf(entry, parsed, getenv);
44
+ const note = eff.note ? ` — ${eff.note}` : '';
45
+ lines.push(` ${entry.key} = ${displayValue(eff.value)} [${eff.source}]${note}`);
46
+ lines.push(` ${entry.bridge} · ${allowedLabel(entry)} · ${entry.effect}`);
47
+ }
48
+ const dups = duplicateKeys(parsed);
49
+ const unknown = [...parsed.byKey.keys()].filter((k) => !registry.has(k));
50
+ 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(', ')}`);
51
+ if (unknown.length) lines.push('', ` ⚠ unknown keys (ignored by every wrapper): ${unknown.join(', ')}`);
52
+ if (parsed.malformed.length) lines.push('', ` ⚠ malformed lines (ignored): ${parsed.malformed.map((m) => `line ${m.index + 1}`).join(', ')}`);
53
+ lines.push('', ` edit with: /agent-workflow-kit bridge-settings --set KEY=VALUE (preview; add --apply to write)`);
54
+ return lines.join('\n');
55
+ };
56
+
57
+ const buildReaderJson = (registry, parsed, fileState, ctx, path) => {
58
+ const getenv = ctx.getenv ?? process.env;
59
+ return {
60
+ path,
61
+ fileState: fileState.state,
62
+ knobs: [...registry.values()].map((entry) => {
63
+ const eff = effectiveOf(entry, parsed, getenv);
64
+ return { key: entry.key, bridge: entry.bridge, kind: entry.kind, effective: eff.value, source: eff.source, note: eff.note ?? null, default: entry.default };
65
+ }),
66
+ duplicateKeys: duplicateKeys(parsed),
67
+ unknownKeys: [...parsed.byKey.keys()].filter((k) => !registry.has(k)),
68
+ malformedLines: parsed.malformed.map((m) => m.index + 1),
69
+ };
70
+ };
71
+
72
+ // ── reconcile (the init/upgrade survival check — read-only, NEVER edits) ───────────────
73
+
74
+ // Classify every file key against the NEW bundled manifests (the union registry), for the init
75
+ // cascade + Mode: upgrade to run AFTER the bridge refresh. It NEVER writes: the file lives outside
76
+ // every kit tree (D2), so upgrade-survival is structural — a key the new manifests no longer declare
77
+ // (unknown / retired) is a loud flag, PRESERVED verbatim (the lens-region custom-edit posture). One
78
+ // tool-composed line per outcome, printed verbatim by the caller. Throws only on a corrupt bundle
79
+ // (cannot determine the allowlist) — the caller degrades that best-effort. Outcomes: absent · unusable
80
+ // · flagged (unknown/retired keys) · duplicates · ok.
81
+ export const reconcileSettings = (ctx = {}) => {
82
+ const registry = loadRegistry(ctx);
83
+ const path = settingsPath(ctx);
84
+ const fileState = readFileState(path, ctx);
85
+ if (fileState.state === 'absent') return { outcome: 'absent', lines: [` bridge-settings: no settings file — skipped (${path})`] };
86
+ if (fileState.state === 'unusable') return { outcome: 'unusable', lines: [` bridge-settings: settings file is a symlink / not a regular file — skipped, never edited (${path})`] };
87
+ const parsed = parseSettings(fileState.text);
88
+ const known = [...parsed.byKey.keys()].filter((k) => registry.has(k));
89
+ const unknown = [...parsed.byKey.keys()].filter((k) => !registry.has(k));
90
+ const dups = duplicateKeys(parsed);
91
+ const lines = [];
92
+ if (unknown.length) lines.push(` bridge-settings: ${unknown.length} unknown/retired key(s) preserved verbatim (never edited) — compare with the current knobs when convenient: ${unknown.join(', ')}`);
93
+ if (dups.length) lines.push(` bridge-settings: duplicate key(s) — the reader takes the last; fix by hand before the writer will edit: ${dups.join(', ')}`);
94
+ if (!unknown.length && !dups.length) lines.push(` bridge-settings: ${known.length} key(s) recognized, all current`);
95
+ return { outcome: unknown.length ? 'flagged' : dups.length ? 'duplicates' : 'ok', lines };
96
+ };
97
+
98
+ // ── writer ─────────────────────────────────────────────────────────────────────────
99
+
100
+ // argv → { ops, apply, dryRun, json }. `--set KEY=VALUE` / `--unset KEY` (inline `=` forms too). A
101
+ // duplicate op for the same key, an unknown flag, or --dry-run WITH --apply → usage error (2). Order-
102
+ // independent so a later flag never silently decides whether the tool mutates.
103
+ const parseArgs = (argv) => {
104
+ const ops = [];
105
+ const seen = new Set();
106
+ const flags = { apply: false, dryRun: false, json: false };
107
+ const takeOp = (kind, tok) => {
108
+ if (tok === undefined || tok.startsWith('-')) throw fail(2, `--${kind} requires KEY${kind === 'set' ? '=VALUE' : ''}`);
109
+ if (kind === 'set') {
110
+ const eq = tok.indexOf('=');
111
+ if (eq <= 0) throw fail(2, `--set needs KEY=VALUE (got ${JSON.stringify(tok)})`);
112
+ const key = tok.slice(0, eq);
113
+ const value = tok.slice(eq + 1);
114
+ if (seen.has(key)) throw fail(2, `duplicate op for "${key}" — name each key at most once`);
115
+ seen.add(key);
116
+ ops.push({ kind, key, value });
117
+ } else {
118
+ const key = tok;
119
+ if (seen.has(key)) throw fail(2, `duplicate op for "${key}" — name each key at most once`);
120
+ seen.add(key);
121
+ ops.push({ kind, key });
122
+ }
123
+ };
124
+ for (let i = 0; i < argv.length; i += 1) {
125
+ const a = argv[i];
126
+ if (a === '--apply') flags.apply = true;
127
+ else if (a === '--dry-run') flags.dryRun = true;
128
+ else if (a === '--json') flags.json = true;
129
+ else if (a === '--set') { takeOp('set', argv[i + 1]); i += 1; }
130
+ else if (a === '--unset') { takeOp('unset', argv[i + 1]); i += 1; }
131
+ else if (a.startsWith('--set=')) takeOp('set', a.slice('--set='.length));
132
+ else if (a.startsWith('--unset=')) takeOp('unset', a.slice('--unset='.length));
133
+ else if (a.startsWith('-')) throw fail(2, `unknown flag: ${a}`);
134
+ else throw fail(2, `unexpected argument: ${a}`);
135
+ }
136
+ if (flags.apply && flags.dryRun) throw fail(2, '--apply does not combine with --dry-run (default is preview)');
137
+ return { ops, ...flags };
138
+ };
139
+
140
+ // Validate ops against the registry: an unknown key or an out-of-range/invalid value is refused (2)
141
+ // BEFORE any file read, so a typo never reaches the settings file.
142
+ const validateOps = (ops, registry) => {
143
+ for (const op of ops) {
144
+ const entry = registry.get(op.key);
145
+ if (!entry) throw fail(2, `unknown key "${op.key}" — not a settings knob of any bundled bridge. Known keys: ${[...registry.keys()].join(', ')}`);
146
+ if (op.kind === 'set' && !settingValueValid(entry, op.value)) {
147
+ throw fail(2, `invalid value "${op.value}" for ${op.key} — allowed: ${allowedLabel(entry)}`);
148
+ }
149
+ }
150
+ };
151
+
152
+ // Apply one op to the line array: replace the single owned KEY= line, append it, or drop it. The caller
153
+ // guarantees the file carries no duplicate keys, so findIndex hits at most one line. `startsWith(KEY=)`
154
+ // is exact (the `=` anchors it: `FOO_BAR=` never matches key `FOO`).
155
+ const applyOp = (lines, op) => {
156
+ const idx = lines.findIndex((l) => l.startsWith(`${op.key}=`));
157
+ if (op.kind === 'set') {
158
+ const newLine = `${op.key}=${op.value}`;
159
+ return idx === -1 ? [...lines, newLine] : lines.map((l, i) => (i === idx ? newLine : l));
160
+ }
161
+ return idx === -1 ? lines : lines.filter((_, i) => i !== idx);
162
+ };
163
+
164
+ const changeLine = (c) => {
165
+ const b = c.before == null ? '(unset)' : c.before;
166
+ const a = c.kind === 'set' ? c.after : '(unset — built-in default applies)';
167
+ return ` ${c.key}: ${b} → ${a}`;
168
+ };
169
+
170
+ const renderWriter = ({ changes, wrote, path, willWrite, envShadows, caveats }) => {
171
+ const lines = [];
172
+ lines.push(wrote ? `wrote ${path}` : 'bridge-settings — preview (nothing written; re-run with --apply to write)');
173
+ for (const c of changes) lines.push(changeLine(c));
174
+ for (const cav of caveats) lines.push(` ↳ ${cav}`);
175
+ 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).`);
176
+ if (!wrote && willWrite) lines.push('', `re-run with --apply to write ${path}.`);
177
+ if (!wrote && !willWrite) lines.push(' no change — the file already reads this way.');
178
+ return lines.join('\n');
179
+ };
180
+
181
+ // ── main ───────────────────────────────────────────────────────────────────────────
182
+
183
+ const HELP = `bridge-settings — read / write the host-level bridge settings file.
184
+
185
+ ${'${XDG_CONFIG_HOME:-~/.config}'}/${SETTINGS_SUBDIR}/${SETTINGS_FILENAME} (KEY=VALUE lines; parsed, never sourced)
186
+
187
+ Usage:
188
+ node bridge-settings.mjs show every knob's effective value + source
189
+ node bridge-settings.mjs --set KEY=VALUE preview a change (writes nothing)
190
+ node bridge-settings.mjs --set KEY=VALUE --apply write the change (atomic; creates the file on first use)
191
+ node bridge-settings.mjs --unset KEY [--apply] return a knob to its built-in default
192
+ node bridge-settings.mjs --json machine-readable read-out
193
+ node bridge-settings.mjs --reconcile init/upgrade survival check: flag unknown/retired
194
+ keys (preserved verbatim), never writes
195
+
196
+ Precedence at run time: explicit env (even empty: KEY= disables) > this file > the wrapper's built-in
197
+ default. Allowed keys + value rules come from the bundled bridge manifests; model/effort are NOT
198
+ settable here (the wrappers' quality guard is untouched). This host file survives every kit upgrade —
199
+ a kit refresh never writes or clobbers it.
200
+
201
+ Exit codes: 0 success (read / preview / write); 2 usage (bad args, unknown key, invalid value);
202
+ 1 precondition STOP (duplicate-carrying / symlinked / unreadable file, or a corrupt bundle).`;
203
+
204
+ export const main = (argv = [], ctx = {}) => {
205
+ const getenv = ctx.getenv ?? process.env;
206
+ try {
207
+ if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
208
+ // Reconcile mode (init/upgrade survival check) — classify keys, never write, exit 0 on any outcome.
209
+ // It is a standalone mode: a write flag/op alongside it is a usage error, never silently ignored
210
+ // (a consent-gated writer never lets a stray flag make a caller think it wrote when it only classified).
211
+ if (argv.includes('--reconcile')) {
212
+ const extra = argv.filter((a) => a !== '--reconcile');
213
+ if (extra.length) throw fail(2, `--reconcile takes no other arguments (got ${extra.join(' ')}) — it only classifies keys, never writes`);
214
+ return { code: 0, stdout: reconcileSettings(ctx).lines.join('\n'), stderr: '' };
215
+ }
216
+ const { ops, apply, json } = parseArgs(argv);
217
+ const registry = loadRegistry(ctx);
218
+ const path = settingsPath(ctx);
219
+
220
+ // Reader (no ops): show effective settings. Best-effort — an unusable file warns but still exits 0.
221
+ if (ops.length === 0) {
222
+ const fileState = readFileState(path, ctx);
223
+ const parsed = parseSettings(fileState.text ?? '');
224
+ const stdout = json
225
+ ? JSON.stringify(buildReaderJson(registry, parsed, fileState, ctx, path), null, 2)
226
+ : renderReader(registry, parsed, fileState, ctx, path);
227
+ return { code: 0, stdout, stderr: '' };
228
+ }
229
+
230
+ // Writer. Validate the ops against the registry BEFORE the file is even read — a typo / unknown key /
231
+ // out-of-range value is refused (exit 2) without touching the file, exactly as the mode doc promises.
232
+ validateOps(ops, registry);
233
+ const fileState = readFileState(path, ctx);
234
+ if (fileState.state === 'unusable') {
235
+ throw fail(1, `the settings file is a symlink / not a regular file / unreadable — refusing to write through it: ${path}`);
236
+ }
237
+ const parsed = parseSettings(fileState.text ?? '');
238
+ const dups = duplicateKeys(parsed);
239
+ if (dups.length) {
240
+ throw fail(1, `the settings file already carries duplicate keys (${dups.join(', ')}) — refusing to edit it. Fix the duplicates by hand first: ${path}`);
241
+ }
242
+
243
+ const changes = ops.map((op) => {
244
+ const before = parsed.byKey.get(op.key)?.at(-1)?.value ?? null;
245
+ const after = op.kind === 'set' ? op.value : null;
246
+ return { key: op.key, kind: op.kind, before, after, entry: registry.get(op.key) };
247
+ });
248
+ const noop = changes.every((c) => (c.kind === 'set' ? c.before === c.after : c.before === null));
249
+ // The credit-rate / spend caveat rides with any knob whose manifest effect flags a cost (D4).
250
+ const caveats = changes
251
+ .filter((c) => c.kind === 'set' && /credit rate|SPEND KNOB/i.test(c.entry.effect))
252
+ .map((c) => `${c.key}: ${c.entry.effect}`);
253
+ const envShadows = changes
254
+ .filter((c) => Object.prototype.hasOwnProperty.call(getenv, c.key))
255
+ .map((c) => c.key);
256
+
257
+ const newLines = ops.reduce(applyOp, parsed.lines);
258
+ const body = joinLines(newLines);
259
+
260
+ // The machine surface carries the SAME spend/credit-rate caveats the human preview prints (D4) — a
261
+ // --json consumer must not miss the 2.5x-credit consent warning the human render shows.
262
+ const jsonBody = (wrote) => ({ path, wrote, noop, changes: changes.map((c) => ({ key: c.key, kind: c.kind, before: c.before, after: c.after })), envShadows, caveats });
263
+
264
+ if (!apply || noop) {
265
+ const stdout = json
266
+ ? JSON.stringify(jsonBody(false), null, 2)
267
+ : renderWriter({ changes, wrote: false, path, willWrite: !noop, envShadows, caveats });
268
+ return { code: 0, stdout, stderr: '' };
269
+ }
270
+
271
+ // --apply. The out-of-tree atomic writer creates the dir + file on first use; symlink/parent/TOCTOU-safe.
272
+ writeHostConfigFileAtomic(settingsDir(ctx), SETTINGS_FILENAME, body, ctx, { noun: 'the bridge settings file' });
273
+ const stdout = json
274
+ ? JSON.stringify(jsonBody(true), null, 2)
275
+ : renderWriter({ changes, wrote: true, path, willWrite: false, envShadows, caveats });
276
+ return { code: 0, stdout, stderr: '' };
277
+ } catch (err) {
278
+ return { code: err.exitCode ?? 1, stdout: '', stderr: `bridge-settings: ${err.message}` };
279
+ }
280
+ };
281
+
282
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
283
+ if (isDirectRun) {
284
+ const r = main(process.argv.slice(2));
285
+ if (r.stdout) console.log(r.stdout);
286
+ if (r.stderr) console.error(r.stderr);
287
+ process.exit(r.code);
288
+ }
@@ -126,6 +126,13 @@ const CATALOG = [
126
126
  kind: WRITER,
127
127
  oneLine: 'Auto-approve your own declared gate commands (docs/ai/gates.json) via a Claude Code hook — exact matches only, previews first (opt-in).',
128
128
  },
129
+ {
130
+ key: 'bridge-settings',
131
+ invocation: invocationOf('bridge-settings'),
132
+ group: 'Configure',
133
+ kind: GUARDED,
134
+ oneLine: 'Read or change the host-level bridge settings (e.g. the codex Fast tier) — a KEY=VALUE file that survives kit upgrades; previews first, and the Fast tier carries its extra-cost caveat.',
135
+ },
129
136
  {
130
137
  key: 'recipes',
131
138
  invocation: invocationOf('recipes'),
@@ -147,6 +154,13 @@ const CATALOG = [
147
154
  kind: WRITER,
148
155
  oneLine: 'Set the orchestration recipe for an activity from plain language — previews the change, then writes the config when you confirm.',
149
156
  },
157
+ {
158
+ key: 'set-autonomy',
159
+ invocation: invocationOf('set-autonomy'),
160
+ group: 'Orchestrate',
161
+ kind: WRITER,
162
+ oneLine: 'Set the per-project autonomy policy from plain language — which actions always ask (commit/push/publish/network) and how autonomously each activity runs; previews the change, then writes the policy when you confirm.',
163
+ },
150
164
  {
151
165
  key: 'review-state',
152
166
  invocation: invocationOf('review-state'),
@@ -161,6 +175,13 @@ const CATALOG = [
161
175
  kind: WRITER,
162
176
  oneLine: 'Assemble the verified-facts payload a grounded review runs against — the entry-point Hard Constraints plus a plan’s decision sections; prints it, or writes ONE scratch file with --out.',
163
177
  },
178
+ {
179
+ key: 'review-ledger',
180
+ invocation: invocationOf('review-ledger'),
181
+ group: 'Orchestrate',
182
+ kind: WRITER,
183
+ oneLine: 'Record each review round and read the computed crossover-stop for the plan-execution loop (converged / accepted-residual / triage-required); --check turns it into a gate exit code and forces a triage before over-running the review.',
184
+ },
164
185
  ];
165
186
 
166
187
  // Deep-freeze: freeze the array AND every entry, so the catalog is genuinely immutable at runtime
@@ -47,6 +47,9 @@ import {
47
47
  // The gate-hook writer's own wired-detection + placed path — reused by the settings survey (one
48
48
  // implementation; gate-hook imports only node builtins + velocity-profile, so no cycle).
49
49
  import { HOOK_FILE_REL as GATE_HOOK_FILE_REL, isHookWired } from './gate-hook.mjs';
50
+ // The host-level bridge-settings snapshot (fact-only, best-effort). The READ-ONLY core (never the
51
+ // writer, which pulls in the atomic-write core) so the status survey stays a pure reader.
52
+ import { settingsSnapshot } from './bridge-settings-read.mjs';
50
53
  import { GATES_REL, loadDeclaration } from './run-gates.mjs';
51
54
  // The cheap-agents writer's own bundle reader + placement planner — reused by the settings survey
52
55
  // (one implementation, never a drifting copy; cheap-agents imports only node builtins, no cycle).
@@ -513,12 +516,20 @@ export const surveySettings = (dir, deps = {}) => ({
513
516
  export const surveyBridges = (deps = {}) => {
514
517
  const { detection } = detectSafe(deps); // a detector failure → every readiness reads 'unknown' (honest)
515
518
  const probe = deps.findOnPath ?? findOnPath;
519
+ // Host-level bridge settings, read ONCE (best-effort — never throws; a corrupt bundle/fs error → error).
520
+ const snapshot = settingsSnapshot(deps);
516
521
  return FAMILY_MEMBERS.filter((m) => m.kind === 'execution-backend').map((m) => {
517
522
  const det = detection.find((d) => d.name === m.name);
518
523
  // Preserve findOnPath's THREE-state result (present | missing | unknown): an `unknown` (EACCES,
519
524
  // "cannot confirm") must stay distinct from a real `missing` — never flattened to a false boolean.
520
525
  const wrappers = m.wrapperCmds.map((cmd) => ({ cmd, state: probe(cmd, deps).state }));
521
- return { member: m.name, display: displayOf(m.name), readiness: det?.readiness ?? 'unknown', wrappers };
526
+ // Fact-only: the settings knobs ACTIVE (env/file, non-default) for THIS bridge. Model/effort are
527
+ // structurally absent from the registry, so this can NEVER carry a model claim (the survey's
528
+ // no-default-model-claim invariant). Localized-on-error like every other survey.
529
+ const settings = snapshot.error
530
+ ? { error: snapshot.error }
531
+ : { active: snapshot.active.filter((a) => a.bridge === m.name) };
532
+ return { member: m.name, display: displayOf(m.name), readiness: det?.readiness ?? 'unknown', wrappers, settings };
522
533
  });
523
534
  };
524
535
 
@@ -17,6 +17,7 @@ expansion), never via the shell. The validator is [`validate.mjs`](./validate.mj
17
17
  | `provides` | string[] | yes | subset of the **role vocabulary** |
18
18
  | `roles` | object | yes | keys ⊆ `provides`; see *Roles* |
19
19
  | `detect` | object | no | `installed` (skill on the machine) + `deployed` (substrate set up in cwd) |
20
+ | `settings` | array | no | the bridge's **settings-file surface** (typed; see *Settings*). Unlike `contract`, a malformed entry **fails** `--strict`. |
20
21
  | `install` / `uninstall` | object | no | `install.npm` is a package name, not a path |
21
22
  | `cost` / `quota` / `provenance` | misc | no | informational |
22
23
  | `available` | boolean | no | `false` = a declared-but-not-installed stub; skips fs/version checks |
@@ -54,6 +55,36 @@ Each `roles.<role>` is an object:
54
55
  `{ policy: "guarded", blocked: string[], probeRelaxed: string[] }`, matching the wrapper's
55
56
  real case-arm patterns (pinned by the source-level reverse-guard test).
56
57
 
58
+ ## Settings (bridges 2.3.0, D6 — manifest-as-source)
59
+
60
+ `settings` declares the bridge's host-level settings-file surface
61
+ (`${XDG_CONFIG_HOME:-~/.config}/agent-workflow/bridge-settings.conf`). It is an **array** of
62
+ typed entries — a JSON object would silently dedupe duplicate keys under `JSON.parse` — and,
63
+ unlike `contract`, it is validated by `validate.mjs` itself: a malformed entry **fails**
64
+ `--strict` (the kit writer, the status renderers, and the wrapper shell constants all consume
65
+ this block). Each entry:
66
+
67
+ - `key` (string, required) — UPPER_SNAKE_CASE env-var name; unique across the array.
68
+ - `kind` (string, required) — `enum | integer | duration | boolean`.
69
+ - `enum` → `values` (string[], non-empty, unique).
70
+ - `integer` → `min` / `max` (safe integers, `min <= max`); values are decimal strings.
71
+ - `duration` → the wrappers' shell duration grammar (`5m`, `30m`, `90s`): a unit suffix
72
+ (`s|m|h|d`) is **required** — a bare integer is invalid — and **zero durations are
73
+ rejected** (`timeout 0` would silently DISABLE a hard cap).
74
+ - `boolean` → the pinned wire format `"0" | "1"` (exactly what the wrappers' env vars accept).
75
+ - `default` (string|null, **required property** — a missing key fails validation) — `null` = no
76
+ file default, the wrapper built-ins apply (state them in `effect`); a non-null default must
77
+ itself pass the kind's validation.
78
+ - `appliesTo` (string[], required) — the wrapper `cmd` names (from `roles.*.cmd` of THIS
79
+ manifest) that APPLY the key. Every wrapper still RECOGNIZES the whole family registry (the
80
+ union of both bridges' `settings` keys) and skips other wrappers' keys silently.
81
+ - `effect` (string, required) — what the knob does, incl. built-in defaults and any spend/risk
82
+ caveat (the credit-rate caveat rides here for the tier knob).
83
+
84
+ Wrappers never parse JSON at run time: each carries its own shell registry/validation constants,
85
+ drift-guarded set-equal to this block by the bridge `bin/*.test.mjs` suites (help section keys,
86
+ `aw_settings_known` registry, `AW_SETTINGS_APPLIED` subset, `aw_settings_valid` typed arms).
87
+
57
88
  ## Path-field rules (Windows-safe, traversal-safe)
58
89
 
59
90
  - **No absolute paths**, **no `..` traversal**, **no unresolved placeholders** (`{{…}}` / `${…}`)
@@ -36,6 +36,30 @@ export const VALID = 'valid';
36
36
  export const UNSUPPORTED = 'unsupported';
37
37
  export const INVALID = 'invalid';
38
38
 
39
+ // Typed settings-value grammar (D6, bridges 2.3.0) — the ONE predicate the manifest validator (for a
40
+ // declared `default`), the kit-side bridge-settings writer (for a user-supplied value), and — mirrored
41
+ // in shell as aw_settings_valid — the wrappers all use, so a value the writer accepts is exactly a
42
+ // value a wrapper honors. A value is always compared as a STRING (the settings-file wire format).
43
+ export const SETTING_KINDS = new Set(['enum', 'integer', 'duration', 'boolean']);
44
+ // The wrappers' shell duration grammar: a unit suffix is REQUIRED (a bare integer is invalid), and
45
+ // zero durations are rejected — `timeout 0` DISABLES a hard cap, so a persistent settings line could
46
+ // otherwise silently remove the stall guard.
47
+ export const DURATION_RE = /^[0-9]+(\.[0-9]+)?[smhd]$/;
48
+ export const ZERO_DURATION_RE = /^0+(\.0+)?[smhd]$/;
49
+ export const settingValueValid = (entry, value) => {
50
+ switch (entry.kind) {
51
+ case 'enum': return Array.isArray(entry.values) && entry.values.includes(value);
52
+ case 'integer': {
53
+ if (!/^[0-9]+$/.test(value)) return false;
54
+ const n = Number(value);
55
+ return Number.isSafeInteger(n) && n >= entry.min && n <= entry.max;
56
+ }
57
+ case 'duration': return DURATION_RE.test(value) && !ZERO_DURATION_RE.test(value);
58
+ case 'boolean': return value === '0' || value === '1';
59
+ default: return false;
60
+ }
61
+ };
62
+
39
63
  const hasTraversal = (p) => p.split(/[\\/]/).includes('..');
40
64
  const isUnresolved = (s) => /\{\{|\}\}|\$\{/.test(s);
41
65
 
@@ -228,6 +252,67 @@ export const validateManifest = (skillDir) => {
228
252
  if (role.template != null) checkInSkillPath(`role "${key}".template`, role.template, !isStub);
229
253
  }
230
254
 
255
+ // Typed `settings` block (bridges 2.3.0, D6 manifest-as-source): the per-bridge settings-file
256
+ // surface — an ARRAY of typed entries (a JSON object would silently dedupe duplicate keys under
257
+ // JSON.parse). Unlike the AD-033 `contract` block (validator-tolerated, externally
258
+ // drift-guarded), a malformed `settings` entry FAILS validation: the kit writer, the status
259
+ // renderers, and the wrapper shell constants all consume this block, so a bad entry would
260
+ // corrupt a host-level config surface.
261
+ const settings = manifest.settings;
262
+ if (settings != null) {
263
+ if (!Array.isArray(settings)) {
264
+ errors.push('`settings` must be an array of setting entries');
265
+ } else {
266
+ const cmds = new Set(Object.values(roles)
267
+ .map((r) => (r && typeof r === 'object' && !Array.isArray(r) ? r.cmd : null))
268
+ .filter((c) => typeof c === 'string' && c));
269
+ const seenKeys = new Set();
270
+ settings.forEach((entry, i) => {
271
+ const at = `\`settings[${i}]\``;
272
+ if (entry == null || typeof entry !== 'object' || Array.isArray(entry)) {
273
+ errors.push(`${at} must be an object`);
274
+ return;
275
+ }
276
+ if (typeof entry.key !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(entry.key)) {
277
+ errors.push(`${at}.key must be an UPPER_SNAKE_CASE string`);
278
+ } else if (seenKeys.has(entry.key)) {
279
+ errors.push(`duplicate settings key "${entry.key}" (${at})`);
280
+ } else {
281
+ seenKeys.add(entry.key);
282
+ }
283
+ if (typeof entry.effect !== 'string' || !entry.effect) errors.push(`${at}.effect must be a non-empty string`);
284
+ if (!Array.isArray(entry.appliesTo) || entry.appliesTo.length === 0
285
+ || !entry.appliesTo.every((c) => typeof c === 'string' && c)) {
286
+ errors.push(`${at}.appliesTo must be a non-empty array of wrapper cmd names`);
287
+ } else {
288
+ for (const c of entry.appliesTo) {
289
+ if (!cmds.has(c)) errors.push(`${at}.appliesTo names "${c}" which is no roles.*.cmd of this manifest`);
290
+ }
291
+ }
292
+ if (!SETTING_KINDS.has(entry.kind)) {
293
+ errors.push(`${at}.kind must be one of enum|integer|duration|boolean`);
294
+ return; // the typed checks below are meaningless without a kind
295
+ }
296
+ if (entry.kind === 'enum'
297
+ && (!Array.isArray(entry.values) || entry.values.length === 0
298
+ || !entry.values.every((v) => typeof v === 'string' && v)
299
+ || new Set(entry.values).size !== entry.values.length)) {
300
+ errors.push(`${at}.values must be a non-empty array of unique non-empty strings (enum kind)`);
301
+ }
302
+ if (entry.kind === 'integer'
303
+ && (!Number.isSafeInteger(entry.min) || !Number.isSafeInteger(entry.max) || entry.min > entry.max)) {
304
+ errors.push(`${at}.min/.max must be integers with min <= max (integer kind)`);
305
+ }
306
+ if (!Object.hasOwn(entry, 'default')) {
307
+ errors.push(`${at}.default is required (null = the wrapper built-ins apply)`);
308
+ } else if (entry.default != null
309
+ && (typeof entry.default !== 'string' || !settingValueValid(entry, entry.default))) {
310
+ errors.push(`${at}.default must be null or a string value that passes the ${entry.kind} validation`);
311
+ }
312
+ });
313
+ }
314
+ }
315
+
231
316
  if (!isStub) {
232
317
  const auth = readAuthoritativeVersion(skillDir);
233
318
  if (auth.version == null) errors.push(`could not resolve an authoritative version (${auth.from})`);
@@ -21,6 +21,9 @@ import { homedir } from 'node:os';
21
21
  import { join, dirname } from 'node:path';
22
22
  import { pathToFileURL, fileURLToPath } from 'node:url';
23
23
  import { detectBackends, wrapperCmdFor, wrapperContractFor } from './detect-backends.mjs';
24
+ // The host-level bridge-settings registry (manifest-as-source) + its allowed-value labels. READ-ONLY
25
+ // core only — never the writer — so this read-only advisor never imports the atomic-write core.
26
+ import { loadRegistry, allowedLabel } from './bridge-settings-read.mjs';
24
27
  import { ACTIVITIES, resolveActivityRecipe, planRecipe } from './recipes.mjs';
25
28
  import { resolveEngineDir, readEngineFragment, PROCEDURES_FRAGMENT_REL } from './engine-source.mjs';
26
29
  // The plan-in-flight detector (AD-038) — imported from the READ-ONLY checker (review-state.mjs
@@ -130,8 +133,19 @@ export const extractSection = (text, activity) => {
130
133
 
131
134
  // ── resolution + rendering ─────────────────────────────────────────────────────────
132
135
 
133
- const resolveAllSlots = ({ activity, config, detection, overrides }) =>
134
- Object.keys(ACTIVITIES[activity].slots).map((slot) => {
136
+ const resolveAllSlots = ({ activity, config, detection, overrides }) => {
137
+ // The host-level settings knobs (manifest-as-source), best-effort: a corrupt bundle degrades to none
138
+ // and the advisor never crashes. Attached per wrapper cmd via each knob's `appliesTo`. Fact-only —
139
+ // model/effort are not knobs, so no model claim can ride here.
140
+ const registry = (() => {
141
+ try {
142
+ return loadRegistry({});
143
+ } catch {
144
+ return new Map();
145
+ }
146
+ })();
147
+ const knobsFor = (cmd) => [...registry.values()].filter((k) => (k.appliesTo ?? []).includes(cmd));
148
+ return Object.keys(ACTIVITIES[activity].slots).map((slot) => {
135
149
  const resolved = resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity, slot, override: overrides[slot] });
136
150
  // The concrete wrapper set this slot's EFFECTIVE recipe dispatches (empty for solo). Reuse
137
151
  // planRecipe's drift-guarded dispatch for WHICH backends, then resolve each (backend, role) to its
@@ -144,9 +158,11 @@ const resolveAllSlots = ({ activity, config, detection, overrides }) =>
144
158
  // is NEVER gated by REVIEW_RECIPES (that set gates only the review-loop economics block).
145
159
  const contracts = dispatch
146
160
  .map((d) => ({ backend: d.backend, role: d.role, cmd: wrapperCmdFor(d.backend, d.role), contract: wrapperContractFor(d.backend, d.role) }))
147
- .filter((c) => c.cmd && c.contract);
161
+ .filter((c) => c.cmd && c.contract)
162
+ .map((c) => ({ ...c, settings: knobsFor(c.cmd).map((k) => ({ key: k.key, allowed: allowedLabel(k) })) }));
148
163
  return { slot, ...resolved, backends, contracts };
149
164
  });
165
+ };
150
166
 
151
167
  // An unsatisfiable EXPLICIT override is the only "warning" (loud, flagged for the agent to relay). A
152
168
  // graceful config/default degradation is reported as a per-slot reason, not a warning.
@@ -250,7 +266,7 @@ const costLanesAdvice = () => [
250
266
  // roles[role].contract (drift-guarded), never re-derived or re-worded here. Rendered for EVERY
251
267
  // dispatched backend of EVERY slot (review AND execute=delegated); each wrapper's --help prints the
252
268
  // same contract, so the agent never needs to open the wrapper source.
253
- const contractLines = ({ cmd, contract }) => {
269
+ const contractLines = ({ cmd, contract, settings }) => {
254
270
  const lines = [` ${cmd} — driving contract (as bundled with this kit; copy-paste — \`${cmd} --help\` prints the same):`];
255
271
  for (const inv of contract.invocations) lines.push(` ${inv}`);
256
272
  for (const f of contract.flags ?? []) lines.push(` ${f}`);
@@ -263,6 +279,12 @@ const contractLines = ({ cmd, contract }) => {
263
279
  if (contract.passthrough) {
264
280
  lines.push(` passthrough after '--' is ${contract.passthrough.policy}: blocked always: ${contract.passthrough.blocked.join(' ')}; relaxed only under CODEX_PROBE=1: ${contract.passthrough.probeRelaxed.join(' ')}`);
265
281
  }
282
+ // Host-level settings knobs this wrapper honors (fact-only, from the bundled manifests; explicit
283
+ // branch — contractLines drops any contract key it does not name, so this must be enumerated here).
284
+ if ((settings ?? []).length) {
285
+ lines.push(' host settings (survive kit upgrades — set via /agent-workflow-kit bridge-settings):');
286
+ for (const s of settings) lines.push(` ${s.key} — ${s.allowed}`);
287
+ }
266
288
  return lines;
267
289
  };
268
290
 
package/tools/recipes.mjs CHANGED
@@ -15,6 +15,9 @@
15
15
  // a backend is advisory or delegated, never autonomous. Dependency-free, Node >= 18.
16
16
 
17
17
  import { pathToFileURL } from 'node:url';
18
+ // The host-level bridge-settings snapshot (fact-only, best-effort). READ-ONLY core only — never the
19
+ // writer — so this read-only advisor never pulls in the atomic-write core.
20
+ import { settingsSnapshot } from './bridge-settings-read.mjs';
18
21
  import {
19
22
  detectBackends,
20
23
  wrapperCmdFor,
@@ -293,12 +296,19 @@ export const resolveActivityRecipe = ({ config = {}, readiness = [], activity, s
293
296
  // DISPLAY_ALIASES — the ONE alias table the recommendation clause already uses; ordering is the
294
297
  // deterministic BACKEND_PRIORITY (codex before agy), independent of detection emission order.
295
298
  // Always exactly one line: no part may carry a newline (pinned by tests).
296
- export const composeStatusLine = (detection, recommendation) => {
299
+ export const composeStatusLine = (detection, recommendation, settings = null) => {
297
300
  const backends = [...detection]
298
301
  .sort((a, b) => priorityIndex(a.name) - priorityIndex(b.name))
299
302
  .map((b) => `${DISPLAY_ALIASES[b.name] ?? b.name} ${b.readiness === READY ? '✓' : '✗'} ${b.readiness}`)
300
303
  .join(' · ');
301
- return `backends: ${backends} — run /agent-workflow-kit backends · recipes: ${recommendation.clause} — see /agent-workflow-kit recipes`;
304
+ const base = `backends: ${backends} — run /agent-workflow-kit backends · recipes: ${recommendation.clause} — see /agent-workflow-kit recipes`;
305
+ // Fact-only suffix, ONLY when a bridge knob is actively set (env/file, non-default). Omitted otherwise,
306
+ // so the default line is byte-identical to before. A raw env value may (D3) carry newlines/control
307
+ // chars — collapse them to a single space so the "exactly one line" backend-status contract holds.
308
+ const oneLine = (s) => String(s).replace(/[\s]+/g, ' ').trim();
309
+ const active = settings?.active ?? [];
310
+ const suffix = active.length ? ` · settings: ${active.map((s) => `${oneLine(s.key)}=${oneLine(s.value)}`).join(' · ')}` : '';
311
+ return base + suffix;
302
312
  };
303
313
 
304
314
  // ── the one-line ACTIVE-recipe line (the discovery line — configured, never recommended) ───────────
@@ -340,7 +350,7 @@ export const composeActiveRecipeLine = ({ config, source } = {}, detection) => {
340
350
 
341
351
  // The structured report behind `--json` — the recipes, the recommendation, a plan per recipe, and
342
352
  // (additive) the pasteable one-line backend status composed from the same detection.
343
- export const buildReport = (detection) => {
353
+ export const buildReport = (detection, settings = null) => {
344
354
  const recommendation = recommendRecipe(detection);
345
355
  return {
346
356
  recipes: RECIPES.map(({ id, title, role, minBackends, degradesTo, summary }) => ({
@@ -353,7 +363,7 @@ export const buildReport = (detection) => {
353
363
  })),
354
364
  recommendation,
355
365
  plans: RECIPES.map((r) => planRecipe(r.id, detection)),
356
- statusLine: composeStatusLine(detection, recommendation),
366
+ statusLine: composeStatusLine(detection, recommendation, settings),
357
367
  };
358
368
  };
359
369
 
@@ -423,8 +433,8 @@ exclusive. Detection only — never writes, never commits, never runs a subscrip
423
433
  console.error(`[agent-workflow-kit] ${err.message}`);
424
434
  return err.exitCode ?? 1;
425
435
  }
426
- } else if (argv.includes('--status-line')) console.log(composeStatusLine(detection, recommendRecipe(detection)));
427
- else if (argv.includes('--json')) console.log(JSON.stringify(buildReport(detection), null, 2));
436
+ } else if (argv.includes('--status-line')) console.log(composeStatusLine(detection, recommendRecipe(detection), settingsSnapshot()));
437
+ else if (argv.includes('--json')) console.log(JSON.stringify(buildReport(detection, settingsSnapshot()), null, 2));
428
438
  else console.log(formatRecipes(detection));
429
439
  return 0;
430
440
  };
@@ -56,6 +56,13 @@ const renderBridges = (vm, { glyph, color }) => {
56
56
  for (const b of vm.bridges) {
57
57
  const wrappers = b.wrappers.map((w) => `${w.cmd} ${glyph[w.state] ?? glyph.unknown}`).join(', ') || '—';
58
58
  lines.push(` ${pad(b.display, MEMBER_COL)}${pad(b.readiness, READINESS_COL)}wrappers: ${wrappers}`);
59
+ // Fact-only host-level settings sub-line: the active knobs for this bridge, or a localized error.
60
+ // Absent when no knob is active, so the block stays byte-identical to before when nothing is set.
61
+ if (b.settings?.error) lines.push(` ${pad('', MEMBER_COL)}${glyph.note} couldn't read bridge settings (${b.settings.error})`);
62
+ else if (b.settings?.active?.length) {
63
+ const active = b.settings.active.map((s) => `${s.key}=${s.value} [${s.source}]`).join(' · ');
64
+ lines.push(` ${pad('', MEMBER_COL)}settings: ${active}`);
65
+ }
59
66
  }
60
67
  return lines;
61
68
  };