@sabaiway/agent-workflow-kit 1.34.0 → 1.35.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.
@@ -0,0 +1,221 @@
1
+ // bridge-settings-read.mjs — the READ-ONLY core of the host-level bridge settings surface (bridges
2
+ // 2.3.0, D6). Split out from bridge-settings.mjs so the read-only status/advisor consumers
3
+ // (family-registry, recipes --status-line, procedures) can surface settings facts WITHOUT importing
4
+ // the writer (which pulls in the atomic-write core — forbidden in a read-only module, pinned by the
5
+ // procedures import guard). It reads: the settings-file path, the file's state, the parsed KEY=VALUE
6
+ // lines, the manifest-as-source registry (the union of the bundled bridges' `settings` blocks), and
7
+ // the effective value of each knob (env > file > built-in default). It NEVER writes.
8
+ //
9
+ // Dependency-free, Node >= 18. No side effects on import.
10
+
11
+ import { readFileSync, statSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { dirname, join, resolve } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { FAMILY_MEMBERS } from './family-members.mjs';
16
+ import { resolveDir } from './detect-backends.mjs';
17
+ import { settingValueValid, SETTING_KINDS } from './manifest/validate.mjs';
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ // bridges/ ships beside tools/ in both the repo and the installed kit, so this resolves in both.
21
+ export const DEFAULT_BUNDLE_ROOT = resolve(__dirname, '..', 'bridges');
22
+ export const SETTINGS_XDG = { env: 'XDG_CONFIG_HOME', default: '~/.config' };
23
+ export const SETTINGS_SUBDIR = 'agent-workflow';
24
+ export const SETTINGS_FILENAME = 'bridge-settings.conf';
25
+ // A valid settings line: an UPPER/lower_snake KEY (the wrappers' own `^[A-Za-z_][A-Za-z0-9_]*=`) then
26
+ // everything after the first `=` as the value. Anchored at column 0, exactly like the wrappers' grep.
27
+ export const KEY_LINE_RE = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
28
+
29
+ // A typed refusal we surface. `exitCode` lets a CLI map it to the process code without matching text.
30
+ export const fail = (exitCode, message) =>
31
+ Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'BridgeSettingsError', exitCode });
32
+
33
+ // ── the registry (manifest-as-source) ───────────────────────────────────────────────
34
+
35
+ // The bundled execution-backend bridge dirs (derived, never hardcoded — a future bridge joins here by
36
+ // its family-members row, not an edit).
37
+ const bridgeDirs = (ctx) =>
38
+ FAMILY_MEMBERS
39
+ .filter((m) => m.kind === 'execution-backend')
40
+ .map((m) => ({ name: m.name, dir: join(ctx.bundleRoot ?? DEFAULT_BUNDLE_ROOT, m.name) }));
41
+
42
+ // Load the union of both bridges' `settings` blocks → a Map keyed by settings key, each entry carrying
43
+ // its typed rule + the owning bridge. A missing/corrupt bundle is a loud STOP (a writer cannot be
44
+ // honored without a trustworthy allowlist; a reader/snapshot caller catches it to degrade). The
45
+ // registry is validated at build time (manifest validate --strict + the mirror drift-guard); here we
46
+ // only guard fs/JSON errors and the block's basic shape.
47
+ export const loadRegistry = (ctx) => {
48
+ const read = ctx.readFile ?? readFileSync;
49
+ const byKey = new Map();
50
+ for (const { name, dir } of bridgeDirs(ctx)) {
51
+ const manifestPath = join(dir, 'capability.json');
52
+ const manifest = (() => {
53
+ try {
54
+ return JSON.parse(String(read(manifestPath, 'utf8')));
55
+ } catch (err) {
56
+ throw fail(1, `cannot read the bundled bridge manifest ${manifestPath} (${err.code ?? err.message}) — corrupt kit install?`);
57
+ }
58
+ })();
59
+ const settings = manifest.settings ?? [];
60
+ if (!Array.isArray(settings)) throw fail(1, `bundled ${name} manifest \`settings\` is not an array — corrupt kit install?`);
61
+ for (const entry of settings) {
62
+ if (!entry || typeof entry.key !== 'string' || !SETTING_KINDS.has(entry.kind)) {
63
+ throw fail(1, `bundled ${name} manifest carries a malformed settings entry — corrupt kit install?`);
64
+ }
65
+ byKey.set(entry.key, { ...entry, bridge: name });
66
+ }
67
+ }
68
+ return byKey;
69
+ };
70
+
71
+ // A one-line, fact-only description of a knob's allowed values (from its typed rule) for help/preview.
72
+ export const allowedLabel = (entry) => {
73
+ switch (entry.kind) {
74
+ case 'enum': return entry.values.map((v) => `"${v}"`).join(' | ');
75
+ case 'integer': return `integer ${entry.min}..${entry.max}`;
76
+ case 'duration': return 'duration (e.g. 5m, 30m, 90s — a unit is required, nonzero)';
77
+ case 'boolean': return '"0" | "1"';
78
+ default: return entry.kind;
79
+ }
80
+ };
81
+
82
+ // ── the settings file ────────────────────────────────────────────────────────────────
83
+
84
+ export const settingsDir = (ctx) => join(resolveDir(SETTINGS_XDG, ctx.getenv ?? process.env, ctx.home ?? homedir()), SETTINGS_SUBDIR);
85
+ export const settingsPath = (ctx) => join(settingsDir(ctx), SETTINGS_FILENAME);
86
+
87
+ // Lines with a single trailing newline normalized away, so edits operate on an exact line array and
88
+ // re-serialize with one trailing newline (comments/blank lines in the middle survive verbatim).
89
+ export const splitLines = (text) => {
90
+ if (text === '') return [];
91
+ const parts = text.split('\n');
92
+ if (parts[parts.length - 1] === '') parts.pop();
93
+ return parts;
94
+ };
95
+ export const joinLines = (lines) => (lines.length ? `${lines.join('\n')}\n` : '');
96
+
97
+ // Read the file's STATE the SAME way the wrappers do — `stat` (FOLLOW symlinks), matching their
98
+ // `-e`/`-f`/`-r` guard EXACTLY so the reader/status/reconcile reflect what the wrappers actually use:
99
+ // 'absent' (missing OR a dangling symlink — silent, defaults apply, like the wrapper's `-e`), 'unusable'
100
+ // (a directory / FIFO / unreadable target — the wrapper's `-f`/`-r` false → warn + defaults), or
101
+ // 'present' with its text (a regular file OR a symlink-to-regular — the wrapper reads it). The WRITER
102
+ // stays secure regardless: the atomic core refuses a symlinked leaf (a write would clobber the target),
103
+ // so a symlinked config is READ (matching the wrappers) but never written THROUGH.
104
+ export const readFileState = (path, ctx) => {
105
+ const stat = ctx.stat ?? statSync;
106
+ const read = ctx.readFile ?? readFileSync;
107
+ const st = (() => {
108
+ try {
109
+ return stat(path);
110
+ } catch (err) {
111
+ if (err && err.code === 'ENOENT') return null;
112
+ return 'error';
113
+ }
114
+ })();
115
+ if (st === null) return { state: 'absent' };
116
+ if (st === 'error' || !st.isFile()) return { state: 'unusable' };
117
+ try {
118
+ return { state: 'present', text: String(read(path, 'utf8')) };
119
+ } catch {
120
+ return { state: 'unusable' };
121
+ }
122
+ };
123
+
124
+ // Parse a settings file body → valid KEY= entries (in order), malformed lines, and a key→entries map.
125
+ export const parseSettings = (text) => {
126
+ const lines = splitLines(text);
127
+ const entries = [];
128
+ const malformed = [];
129
+ lines.forEach((line, index) => {
130
+ const noLeadWs = line.replace(/^[\s]+/, '');
131
+ if (noLeadWs === '' || noLeadWs.startsWith('#')) return; // blank / comment (indented ok)
132
+ const m = line.match(KEY_LINE_RE);
133
+ if (!m) {
134
+ malformed.push({ index, text: line });
135
+ return;
136
+ }
137
+ entries.push({ key: m[1], value: m[2], index });
138
+ });
139
+ const byKey = new Map();
140
+ for (const e of entries) {
141
+ if (!byKey.has(e.key)) byKey.set(e.key, []);
142
+ byKey.get(e.key).push(e);
143
+ }
144
+ return { lines, entries, malformed, byKey };
145
+ };
146
+
147
+ export const duplicateKeys = (parsed) => [...parsed.byKey.entries()].filter(([, es]) => es.length > 1).map(([k]) => k);
148
+
149
+ // ── effective-value resolution (env > file > built-in default) ────────────────────────
150
+
151
+ // The effective value of one knob + where it comes from. Fact-only; never a model claim.
152
+ export const effectiveOf = (entry, parsed, getenv) => {
153
+ const key = entry.key;
154
+ if (Object.prototype.hasOwnProperty.call(getenv, key)) {
155
+ const v = getenv[key];
156
+ // An EXPLICITLY-EMPTY env (`KEY=`) suppresses the FILE override for this run (the wrapper skips a
157
+ // key already present in env, `${!key+x}`), so the effective value falls to the WRAPPER BUILT-IN —
158
+ // NOT "flag absent" (only the tier's built-in default happens to be "no flag"; the timeout / bytes /
159
+ // add-dir knobs fall to their real built-in default). Report the manifest default (null ⇒ "wrapper
160
+ // built-in applies"), not a misleading null-for-everything.
161
+ if (v === '') return { value: entry.default, source: 'default', note: 'the env KEY= suppresses the file override — the wrapper built-in applies' };
162
+ // Mirror the wrapper: an ENUM knob (the service tier) validates its ENV value too — codex accepts
163
+ // any `-c service_tier` string silently, so the wrapper drops an unsupported one to the built-in
164
+ // default (codex-exec.sh:188). A non-enum env value is the operator's documented RAW override (e.g.
165
+ // a timeout(1) duration like `2h` the wrapper passes straight through) — shown as-is (D3 scopes
166
+ // typed validation to FILE lines; the Phase-1 council refuted validating non-enum env usage).
167
+ if (entry.kind === 'enum' && !settingValueValid(entry, v)) {
168
+ return { value: entry.default, source: 'default', note: `env value "${v}" is not a supported ${key} — the wrapper runs the built-in default` };
169
+ }
170
+ return { value: v, source: 'env' };
171
+ }
172
+ const fileEntries = parsed.byKey.get(key);
173
+ if (fileEntries && fileEntries.length) {
174
+ const v = fileEntries[fileEntries.length - 1].value; // last occurrence wins
175
+ if (settingValueValid(entry, v)) return { value: v, source: 'file' };
176
+ return { value: entry.default, source: 'default', note: `file value "${v}" is invalid — falls back to the built-in default` };
177
+ }
178
+ return { value: entry.default, source: 'default' };
179
+ };
180
+
181
+ export const displayValue = (v) => (v == null ? '(unset — wrapper built-in applies)' : v);
182
+
183
+ // ── the fact-only snapshot the read-only status/advisor surfaces consume ────────────────
184
+
185
+ // Best-effort, never throws: the ACTIVE knobs (a non-default value is in play — source env/file,
186
+ // non-null) plus file presence + any unknown/duplicate keys. Model/effort are structurally excluded
187
+ // from the registry, so `active` can NEVER carry a model claim (the fact-only guarantee). A corrupt
188
+ // bundle or fs error degrades to `{ error }` — the caller renders that localized-on-error, never crashes.
189
+ export const settingsSnapshot = (ctx = {}) => {
190
+ try {
191
+ const registry = loadRegistry(ctx);
192
+ const path = settingsPath(ctx);
193
+ const fileState = readFileState(path, ctx);
194
+ // A symlink / non-regular / unreadable file is IGNORED by the wrappers (built-in defaults apply) —
195
+ // surface that honestly instead of silently omitting it (the wrappers warn; the status must too).
196
+ if (fileState.state === 'unusable') {
197
+ return { path, fileState: 'unusable', active: [], unknown: [], duplicates: [], error: 'the settings file is a symlink / not a regular file — ignored, built-in defaults apply' };
198
+ }
199
+ const parsed = parseSettings(fileState.text ?? '');
200
+ const getenv = ctx.getenv ?? process.env;
201
+ const active = [];
202
+ for (const entry of registry.values()) {
203
+ const eff = effectiveOf(entry, parsed, getenv);
204
+ // ACTIVE = a non-default value is genuinely in effect (source env/file AND differs from the
205
+ // built-in default). A value that merely equals the default keeps every surface byte-identical to
206
+ // "nothing set" — the "status line unchanged unless a knob is active" contract.
207
+ if ((eff.source === 'env' || eff.source === 'file') && eff.value != null && eff.value !== entry.default) {
208
+ active.push({ key: entry.key, value: eff.value, source: eff.source, bridge: entry.bridge });
209
+ }
210
+ }
211
+ return {
212
+ path,
213
+ fileState: fileState.state,
214
+ active,
215
+ unknown: [...parsed.byKey.keys()].filter((k) => !registry.has(k)),
216
+ duplicates: duplicateKeys(parsed),
217
+ };
218
+ } catch (err) {
219
+ return { error: err && err.message ? err.message : String(err), active: [] };
220
+ }
221
+ };
@@ -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'),
@@ -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** (`{{…}}` / `${…}`)