@sabaiway/agent-workflow-kit 1.33.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +2 -1
  3. package/SKILL.md +6 -2
  4. package/bin/install.mjs +37 -1
  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/launchers/windsurf-workflow.md +3 -1
  19. package/package.json +1 -1
  20. package/references/contracts.md +3 -2
  21. package/references/modes/bootstrap.md +11 -6
  22. package/references/modes/bridge-settings.md +29 -0
  23. package/references/modes/gates.md +4 -2
  24. package/references/modes/review-state.md +1 -1
  25. package/references/modes/status.md +2 -0
  26. package/references/modes/upgrade.md +11 -5
  27. package/references/shared/deploy-tail.md +1 -1
  28. package/references/shared/report-footer.md +11 -4
  29. package/tools/atomic-write.mjs +144 -0
  30. package/tools/bridge-settings-read.mjs +221 -0
  31. package/tools/bridge-settings.mjs +288 -0
  32. package/tools/commands.mjs +22 -1
  33. package/tools/family-registry.mjs +45 -2
  34. package/tools/manifest/schema.md +31 -0
  35. package/tools/manifest/validate.mjs +85 -0
  36. package/tools/orchestration-write.mjs +8 -78
  37. package/tools/presentation.mjs +1 -0
  38. package/tools/procedures.mjs +26 -4
  39. package/tools/recipes.mjs +16 -6
  40. package/tools/renderers.mjs +17 -2
  41. package/tools/review-state.mjs +4 -2
  42. package/tools/seed-gates.mjs +413 -0
  43. package/tools/setup-backends.mjs +107 -9
  44. package/tools/velocity-profile.mjs +4 -0
  45. package/tools/view-model.mjs +20 -0
@@ -0,0 +1,144 @@
1
+ // atomic-write.mjs — the family's ONE hardened atomic-write core for kit writers. Extracted from
2
+ // orchestration-write.mjs (the only full implementation of the discipline, AD-042) and parameterized
3
+ // by (containment ROOT, absolute target, body, stop identity) so every consumer runs the same guarded
4
+ // flow with zero drift:
5
+ // • writeDocsAiFileAtomic — a file under a project's docs/ai/ (deployment-gated to cwd):
6
+ // orchestration-write.mjs → docs/ai/orchestration.json; seed-gates.mjs → docs/ai/gates.json.
7
+ // • writeHostConfigFileAtomic — a file under a host config dir OUTSIDE any project tree
8
+ // (bridges 2.3.0, D6): ${XDG_CONFIG_HOME:-~/.config}/agent-workflow/bridge-settings.conf. The
9
+ // host dir is CREATED if absent (a host config SHOULD materialize), unlike the docs/ai gate
10
+ // which REFUSES an absent deployment.
11
+ //
12
+ // The discipline (verbatim from the source implementation) — writeContainedFileAtomic(root, dst, …):
13
+ // - a per-consumer GATE runs first (deployment gate for docs/ai; create+verify for the host dir).
14
+ // - refuse a SYMLINKED leaf — a rename would silently replace the link target.
15
+ // - guard the dst + the tmp sibling with assertContainedRealPath (fs-safe) — refuses a symlinked
16
+ // PARENT component inside `root`, not just the leaf, and refuses any escape outside `root`.
17
+ // - atomic: write a UNIQUE *.<rand>.tmp opened EXCLUSIVE-CREATE (wx), then rename over the dst.
18
+ // - RE-CHECK the parent chain + the leaf immediately before the rename (TOCTOU).
19
+ // - tmp cleaned up on any failure after its creation.
20
+ // - LAST-WRITER-WINS: local, single-user; no cross-process lock (documented, not silently assumed).
21
+ //
22
+ // Dependency-free, Node >= 18. Every fs primitive is injectable (deps.*) so the guards are
23
+ // unit-testable. NEVER imported by a read-only module (procedures.mjs — pinned by an import guard).
24
+
25
+ import { lstatSync, writeFileSync, renameSync, rmSync, mkdirSync } from 'node:fs';
26
+ import { randomBytes } from 'node:crypto';
27
+ import { join } from 'node:path';
28
+ import { assertContainedRealPath } from './fs-safe.mjs';
29
+
30
+ export const ATOMIC_WRITE_STOP = 'ATOMIC_WRITE_STOP';
31
+ const defaultStop = (message) =>
32
+ Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'AtomicWriteStop', code: ATOMIC_WRITE_STOP });
33
+
34
+ // lstat without following symlinks; null when absent. A non-ENOENT error propagates (never fail open).
35
+ export const lstatNoFollow = (path, lstat = lstatSync) => {
36
+ try {
37
+ return lstat(path);
38
+ } catch (err) {
39
+ if (err && err.code === 'ENOENT') return null;
40
+ throw err;
41
+ }
42
+ };
43
+
44
+ // Deployment gate — never scatter a file into a non-deployed repo. lstat, NOT existsSync:
45
+ // existsSync FOLLOWS a symlink, so a DANGLING `docs/ai` symlink would read as "absent" and
46
+ // mislabel a broken/symlinked deployment as "no deployment". lstat the leaf instead: a true
47
+ // ENOENT → no deployment (STOP, run init); a symlink or non-directory → STOP loud.
48
+ // `noun` names what the caller writes ("a config" / "a gate declaration") so each consumer's STOP
49
+ // message stays exactly as its own tests pinned it.
50
+ export const assertDocsAiDeployment = (cwd, deps = {}, opts = {}) => {
51
+ const lstat = deps.lstat ?? lstatSync;
52
+ const stop = opts.stop ?? defaultStop;
53
+ const noun = opts.noun ?? 'a file';
54
+ const rel = opts.rel ?? 'under docs/ai';
55
+ const docsAi = join(cwd, 'docs', 'ai');
56
+ const docsAiStat = lstatNoFollow(docsAi, lstat);
57
+ if (docsAiStat === null) {
58
+ throw stop(`no agent-workflow deployment here (docs/ai is absent) — run init/bootstrap before writing ${rel}`);
59
+ }
60
+ if (docsAiStat.isSymbolicLink()) {
61
+ throw stop(`docs/ai is a symlink — refusing to write ${noun} through it (run init/bootstrap in a real deployment)`);
62
+ }
63
+ if (!docsAiStat.isDirectory()) {
64
+ throw stop(`docs/ai exists but is not a directory — refusing to write ${rel}`);
65
+ }
66
+ };
67
+
68
+ // Host config dir gate — CREATE the dir if absent (a host config SHOULD materialize on first write,
69
+ // the opposite of the docs/ai deployment gate), then refuse a symlinked / non-directory dir we would
70
+ // write THROUGH (a rename into a symlinked dir would land outside where the user thinks). `noun` names
71
+ // what the caller writes so each consumer's STOP message stays exactly as its own tests pinned it.
72
+ export const assertHostConfigDirSafe = (dir, deps = {}, opts = {}) => {
73
+ const lstat = deps.lstat ?? lstatSync;
74
+ const mkdir = deps.mkdir ?? ((p) => mkdirSync(p, { recursive: true }));
75
+ const stop = opts.stop ?? defaultStop;
76
+ const noun = opts.noun ?? 'a host config file';
77
+ mkdir(dir);
78
+ const st = lstatNoFollow(dir, lstat);
79
+ if (st === null) throw stop(`could not create the host config dir: ${dir}`);
80
+ if (st.isSymbolicLink()) throw stop(`${dir} is a symlink — refusing to write ${noun} through it`);
81
+ if (!st.isDirectory()) throw stop(`${dir} exists but is not a directory — refusing to write ${noun}`);
82
+ };
83
+
84
+ // The hardened atomic flow, parameterized by containment ROOT + an already-passed gate. `dst` is the
85
+ // ABSOLUTE target under `root`; `opts.label` names it in a symlink-refusal message. Returns
86
+ // { writtenPath: dst }. THROWS the caller's typed STOP (opts.stop) or a native fs error.
87
+ export const writeContainedFileAtomic = (root, dst, body, deps = {}, opts = {}) => {
88
+ const lstat = deps.lstat ?? lstatSync;
89
+ const writeFile = deps.writeFile ?? writeFileSync;
90
+ const rename = deps.rename ?? renameSync;
91
+ const rm = deps.rm ?? ((p) => rmSync(p, { force: true }));
92
+ const rand = deps.rand ?? (() => randomBytes(6).toString('hex'));
93
+ const stop = opts.stop ?? defaultStop;
94
+ const label = opts.label ?? dst;
95
+ const guard = (target) => assertContainedRealPath(root, target, { lstat });
96
+
97
+ // Refuse a symlinked leaf with a CLEAR message before the generic traversal guard fires (a rename
98
+ // would silently replace the link rather than the file the user thinks they are editing).
99
+ const leaf = lstatNoFollow(dst, lstat);
100
+ if (leaf && leaf.isSymbolicLink()) {
101
+ throw stop(`${label} is a symlink — refusing to replace it (a write would clobber the link target)`);
102
+ }
103
+ // Guard the dst + a unique tmp SIBLING: refuses a symlinked parent component inside root, and any escape.
104
+ guard(dst);
105
+ const tmp = `${dst}.${rand()}.tmp`;
106
+ guard(tmp);
107
+
108
+ // Exclusive-create (wx): never clobber a leftover tmp (a stray collision is surfaced, not silently
109
+ // overwritten). The random suffix makes a collision effectively impossible; wx makes it impossible-loud.
110
+ writeFile(tmp, body, { encoding: 'utf8', flag: 'wx' });
111
+ try {
112
+ // TOCTOU re-check: the parent chain + the leaf may have changed since the pre-checks above.
113
+ guard(dst);
114
+ const leafAgain = lstatNoFollow(dst, lstat);
115
+ if (leafAgain && leafAgain.isSymbolicLink()) {
116
+ throw stop(`${label} became a symlink — refusing to replace it`);
117
+ }
118
+ rename(tmp, dst);
119
+ } catch (err) {
120
+ rm(tmp); // never leave a temp file behind on failure
121
+ throw err;
122
+ }
123
+ return { writtenPath: dst };
124
+ };
125
+
126
+ // writeDocsAiFileAtomic(cwd, rel, body, deps, opts) → { writtenPath: rel } on success; THROWS the
127
+ // caller's typed STOP (via opts.stop) or a native fs error otherwise. `body` arrives pre-serialized
128
+ // (each consumer owns its canonical serialization). Thin wrapper over the core: gate = deployment
129
+ // gate, root = cwd, target = cwd/rel; the label + return stay `rel` so its public API is unchanged.
130
+ export const writeDocsAiFileAtomic = (cwd, rel, body, deps = {}, opts = {}) => {
131
+ assertDocsAiDeployment(cwd, deps, { ...opts, rel });
132
+ const dst = join(cwd, rel);
133
+ writeContainedFileAtomic(cwd, dst, body, deps, { ...opts, label: rel });
134
+ return { writtenPath: rel };
135
+ };
136
+
137
+ // writeHostConfigFileAtomic(dir, filename, body, deps, opts) → { writtenPath: dir/filename }. Gate =
138
+ // create+verify the host dir; root = that dir; target = dir/filename. For the out-of-tree host config
139
+ // surface (bridge-settings.conf) that no project deployment owns.
140
+ export const writeHostConfigFileAtomic = (dir, filename, body, deps = {}, opts = {}) => {
141
+ assertHostConfigDirSafe(dir, deps, opts);
142
+ const dst = join(dir, filename);
143
+ return writeContainedFileAtomic(dir, dst, body, deps, { ...opts, label: opts.label ?? dst });
144
+ };
@@ -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
+ }