@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.
- package/CHANGELOG.md +38 -0
- package/README.md +1 -0
- package/SKILL.md +6 -2
- package/bin/install.mjs +15 -0
- package/bridges/antigravity-cli-bridge/SKILL.md +13 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +96 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +204 -1
- package/bridges/antigravity-cli-bridge/bin/agy.sh +94 -0
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +245 -1
- package/bridges/antigravity-cli-bridge/capability.json +17 -1
- package/bridges/codex-cli-bridge/SKILL.md +15 -1
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +113 -0
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +288 -0
- package/bridges/codex-cli-bridge/bin/codex-review.sh +114 -1
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +229 -1
- package/bridges/codex-cli-bridge/capability.json +29 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/bridge-settings.md +29 -0
- package/references/modes/upgrade.md +8 -2
- package/tools/atomic-write.mjs +59 -21
- package/tools/bridge-settings-read.mjs +221 -0
- package/tools/bridge-settings.mjs +288 -0
- package/tools/commands.mjs +7 -0
- package/tools/family-registry.mjs +12 -1
- package/tools/manifest/schema.md +31 -0
- package/tools/manifest/validate.mjs +85 -0
- package/tools/procedures.mjs +26 -4
- package/tools/recipes.mjs +16 -6
- package/tools/renderers.mjs +7 -0
- package/tools/setup-backends.mjs +107 -9
- package/tools/view-model.mjs +7 -0
|
@@ -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
|
+
}
|
package/tools/commands.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/tools/manifest/schema.md
CHANGED
|
@@ -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** (`{{…}}` / `${…}`)
|