@sabaiway/agent-workflow-kit 1.34.0 → 1.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +1 -0
  3. package/SKILL.md +23 -13
  4. package/bin/install.mjs +15 -0
  5. package/bridges/antigravity-cli-bridge/SKILL.md +13 -1
  6. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +96 -1
  7. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +204 -1
  8. package/bridges/antigravity-cli-bridge/bin/agy.sh +94 -0
  9. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +245 -1
  10. package/bridges/antigravity-cli-bridge/capability.json +17 -1
  11. package/bridges/codex-cli-bridge/SKILL.md +15 -1
  12. package/bridges/codex-cli-bridge/bin/codex-exec.sh +113 -0
  13. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +288 -0
  14. package/bridges/codex-cli-bridge/bin/codex-review.sh +114 -1
  15. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +229 -1
  16. package/bridges/codex-cli-bridge/capability.json +29 -1
  17. package/capability.json +1 -1
  18. package/package.json +1 -1
  19. package/references/modes/bridge-settings.md +29 -0
  20. package/references/modes/review-ledger.md +28 -0
  21. package/references/modes/set-autonomy.md +29 -0
  22. package/references/modes/upgrade.md +8 -2
  23. package/references/modes/velocity.md +13 -0
  24. package/tools/atomic-write.mjs +59 -21
  25. package/tools/autonomy-config.mjs +306 -0
  26. package/tools/autonomy-write.mjs +27 -0
  27. package/tools/bridge-settings-read.mjs +221 -0
  28. package/tools/bridge-settings.mjs +288 -0
  29. package/tools/commands.mjs +21 -0
  30. package/tools/family-registry.mjs +12 -1
  31. package/tools/manifest/schema.md +31 -0
  32. package/tools/manifest/validate.mjs +85 -0
  33. package/tools/procedures.mjs +26 -4
  34. package/tools/recipes.mjs +16 -6
  35. package/tools/renderers.mjs +7 -0
  36. package/tools/review-ledger-write.mjs +257 -0
  37. package/tools/review-ledger.mjs +508 -0
  38. package/tools/seed-gates.mjs +45 -4
  39. package/tools/set-autonomy.mjs +195 -0
  40. package/tools/setup-backends.mjs +107 -9
  41. package/tools/velocity-profile.mjs +468 -5
  42. package/tools/view-model.mjs +7 -0
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env node
2
+ // autonomy-config.mjs — the schema / read / pure-transform core for the per-project autonomy policy
3
+ // (docs/ai/autonomy.json). It is the SINGLE source of the policy contract, mirroring
4
+ // orchestration-config.mjs structurally (AD-044):
5
+ //
6
+ // loadAutonomy / validateAutonomy / AUTONOMY_REL — the strict-JSON reader the read-only surfaces
7
+ // share (the Claude render imports loadAutonomy).
8
+ // parseAutonomyOp / assertAutonomyAssignment — the TYPED op parser + the ONE section/key/value
9
+ // validity table the set-autonomy writer reuses
10
+ // (drift-guarded: one accept/reject grammar).
11
+ // applyAutonomyOps / serializeAutonomy — the PURE merge + the canonical (2-space,
12
+ // _README-first) serializer the writer commits.
13
+ // resolveAutonomy — the pure computed-defaults resolver (sparse →
14
+ // effective policy); the render + any future read
15
+ // surface share this ONE resolver.
16
+ //
17
+ // This module performs NO filesystem WRITES — only reads (loadAutonomy). The single fs-writer lives in
18
+ // autonomy-write.mjs, which no read-only module imports, so the read surface stays fs-write-free.
19
+ // Pure-where-possible (fs injectable), dependency-free, Node >= 18. No side effects on import.
20
+
21
+ import { readFileSync, lstatSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { ACTIVITIES } from './recipes.mjs';
24
+
25
+ // The hand-editable / agent-writable, per-project policy (strict JSON). cwd-relative — the error prefix
26
+ // uses this rel path so a user sees a path they can open, never an absolute temp/host path.
27
+ export const AUTONOMY_REL = 'docs/ai/autonomy.json';
28
+
29
+ // A tagged failure: a plain Error carrying the intended process exit code (2 usage / 1 config). Avoids
30
+ // a class (project rule) while letting a CLI main() map a throw to the right code in one place. Shared
31
+ // so set-autonomy.mjs raises identically-typed errors (mirrors orchestration-config.mjs `fail`).
32
+ export const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
33
+
34
+ // ── the grammar tables (Decision 5) — the ONE shared accept/reject data ──────────────
35
+ // Both validateAutonomy and parseAutonomyOp read THESE tables (never a second hand-copy), so the
36
+ // accept/reject decision can never drift between config-validation and the writer's op parser (pinned
37
+ // by the full-matrix drift test, the orchestration recipeValidForSlot precedent).
38
+
39
+ // The six red-lines split into command red-lines (commit/push/publish) and non-command red-lines
40
+ // (network/credentials/fs_outside_repo). Both take `ask` or `deny`; only the Decision-4 DEFAULT differs.
41
+ export const COMMAND_REDLINES = Object.freeze(['commit', 'push', 'publish']);
42
+ export const NONCOMMAND_REDLINES = Object.freeze(['network', 'credentials', 'fs_outside_repo']);
43
+ export const REDLINE_KEYS = Object.freeze([...COMMAND_REDLINES, ...NONCOMMAND_REDLINES]);
44
+ export const REDLINE_VALUES = Object.freeze(['ask', 'deny']);
45
+ // Decision 4 defaults: command red-lines default to `ask` (commit stays the human checkpoint), the
46
+ // non-command red-lines default to `deny` (network/credentials/fs escape are the conservative floor).
47
+ export const REDLINE_DEFAULTS = Object.freeze({
48
+ commit: 'ask', push: 'ask', publish: 'ask',
49
+ network: 'deny', credentials: 'deny', fs_outside_repo: 'deny',
50
+ });
51
+
52
+ // Per-activity autonomy level (Decision 2): `sandbox` ⇒ auto-allow + acceptEdits; `prompt` ⇒
53
+ // conservative prompting (sandbox still confines). An absent activity floors at `prompt` (Decision 5).
54
+ export const AUTONOMY_LEVELS = Object.freeze(['sandbox', 'prompt']);
55
+ export const DEFAULT_ACTIVITY_AUTONOMY = 'prompt';
56
+ export const ACTIVITY_KEY = 'autonomy';
57
+ export const REDLINES_SECTION = 'redlines';
58
+
59
+ const KNOWN_ACTIVITIES = () => Object.keys(ACTIVITIES).join(', ');
60
+ const KNOWN_SECTIONS = () => `${REDLINES_SECTION}, ${KNOWN_ACTIVITIES()}`;
61
+ const isJsonObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
62
+
63
+ // ── the ONE section/key/value validity grammar (shared accept/reject) ────────────────
64
+ // assignmentValid is the PURE predicate (mirror recipeValidForSlot) the full-matrix drift test pins;
65
+ // assertAutonomySlot / assertAutonomyAssignment are the LOUD assertions the op parser uses. All read
66
+ // the same tables above, so parseAutonomyOp and validateAutonomy can never disagree.
67
+
68
+ // True iff (section, key) is a known slot of the policy (redlines.<redline> | <activity>.autonomy).
69
+ export const slotValid = (section, key) => {
70
+ if (section === REDLINES_SECTION) return REDLINE_KEYS.includes(key);
71
+ if (ACTIVITIES[section]) return key === ACTIVITY_KEY;
72
+ return false;
73
+ };
74
+
75
+ // Accepted values for a KNOWN slot: red-lines take ask|deny; an activity's autonomy takes sandbox|prompt.
76
+ export const acceptedValuesFor = (section) => (section === REDLINES_SECTION ? REDLINE_VALUES : AUTONOMY_LEVELS);
77
+
78
+ // True iff (section, key, value) is a fully-valid assignment. Pure predicate; throws nothing.
79
+ export const assignmentValid = (section, key, value) =>
80
+ slotValid(section, key) && acceptedValuesFor(section).includes(value);
81
+
82
+ // Assert (section, key) is a known slot; return its kind ('redline' | 'activity'). Loud (exit 2 by
83
+ // default) with the shared "unknown section" / "unknown slot" message the op parser emits.
84
+ export const assertAutonomySlot = (section, key, exitCode = 2) => {
85
+ if (section === REDLINES_SECTION) {
86
+ if (!REDLINE_KEYS.includes(key)) {
87
+ throw fail(exitCode, `unknown red-line "${key}" (known: ${REDLINE_KEYS.join(', ')})`);
88
+ }
89
+ return 'redline';
90
+ }
91
+ if (ACTIVITIES[section]) {
92
+ if (key !== ACTIVITY_KEY) {
93
+ throw fail(exitCode, `unknown key "${key}" for activity "${section}" (only: ${ACTIVITY_KEY})`);
94
+ }
95
+ return 'activity';
96
+ }
97
+ throw fail(exitCode, `unknown section "${section}" (known: ${KNOWN_SECTIONS()})`);
98
+ };
99
+
100
+ // Assert (section, key, value) is a fully-valid assignment — unknown section/key OR bad value → loud.
101
+ // Used by the set-autonomy op parser AND (with exitCode 1) by validateAutonomy, so they accept/reject
102
+ // in lockstep on ONE grammar. Returns the slot kind.
103
+ export const assertAutonomyAssignment = (section, key, value, exitCode = 2) => {
104
+ const kind = assertAutonomySlot(section, key, exitCode);
105
+ const accepted = acceptedValuesFor(section);
106
+ if (typeof value !== 'string' || !accepted.includes(value)) {
107
+ throw fail(exitCode, `invalid value "${value}" for ${section}.${key} (accepts: ${accepted.join(', ')})`);
108
+ }
109
+ return kind;
110
+ };
111
+
112
+ // ── the typed op parser (usage errors → exit 2) ─────────────────────────────────────
113
+ // The grammar is ALWAYS fully-qualified `<section>.<key>` — the writer never guesses a section. A bare
114
+ // `commit=ask` is rejected (name the section). No `all`-magic; the agent expands plain language into
115
+ // explicit per-key ops (asking if scope is unclear) — the set-recipe division of labor.
116
+
117
+ const parseQualified = (lhs, flag) => {
118
+ const dot = lhs.indexOf('.');
119
+ if (dot <= 0 || dot === lhs.length - 1) {
120
+ throw fail(
121
+ 2,
122
+ `${flag} must be fully-qualified <section>.<key> (got "${lhs}") — name the section, e.g. redlines.commit / plan-execution.autonomy`,
123
+ );
124
+ }
125
+ return { section: lhs.slice(0, dot), key: lhs.slice(dot + 1) };
126
+ };
127
+
128
+ // parseAutonomyOp(kind, token) → a typed record:
129
+ // kind 'set' + token `<section>.<key>=<value>` → { kind:'set', section, key, value }
130
+ // kind 'unset' + token `<section>.<key>` → { kind:'unset', section, key }
131
+ // Every malformed token is a USAGE error (exit 2): a bare key (no section), an unknown section/key, a
132
+ // bad value, a missing value on --set, or a stray value on --unset.
133
+ export const parseAutonomyOp = (kind, token) => {
134
+ if (kind === 'set') {
135
+ const eq = token.indexOf('=');
136
+ if (eq <= 0) throw fail(2, `--set must be <section>.<key>=<value> (got "${token}")`);
137
+ const value = token.slice(eq + 1);
138
+ if (!value) throw fail(2, `--set must be <section>.<key>=<value> (got "${token}")`);
139
+ const { section, key } = parseQualified(token.slice(0, eq), '--set');
140
+ assertAutonomyAssignment(section, key, value);
141
+ return { kind: 'set', section, key, value };
142
+ }
143
+ if (token.includes('=')) throw fail(2, `--unset takes <section>.<key> without a value (got "${token}")`);
144
+ const { section, key } = parseQualified(token, '--unset');
145
+ assertAutonomySlot(section, key);
146
+ return { kind: 'unset', section, key };
147
+ };
148
+
149
+ // ── policy validation (config errors → exit 1) ──────────────────────────────────────
150
+
151
+ // Validate a parsed autonomy.json object against the Decision-5 grammar. Strict: an unknown top-level
152
+ // key, an unknown red-line, an unknown activity/key, or a bad value is an error. All keys optional
153
+ // (sparse). An optional "_README" string key is allowed + ignored. Never a silent fallback — every
154
+ // rejection is a loud `path: reason` (exit 1). Returns the config on success.
155
+ export const validateAutonomy = (config) => {
156
+ if (!isJsonObject(config)) {
157
+ throw fail(1, `${AUTONOMY_REL}: must be a JSON object (red-lines + per-activity autonomy)`);
158
+ }
159
+ for (const [key, val] of Object.entries(config)) {
160
+ if (key === '_README') {
161
+ if (typeof val !== 'string') throw fail(1, `${AUTONOMY_REL}: "_README" must be a string`);
162
+ continue;
163
+ }
164
+ if (key === REDLINES_SECTION) {
165
+ if (!isJsonObject(val)) throw fail(1, `${AUTONOMY_REL}: "${REDLINES_SECTION}" must be a JSON object of red-line → ask|deny`);
166
+ for (const [rk, rv] of Object.entries(val)) {
167
+ try {
168
+ assertAutonomyAssignment(REDLINES_SECTION, rk, rv, 1);
169
+ } catch (err) {
170
+ throw fail(1, `${AUTONOMY_REL}: ${err.message}`);
171
+ }
172
+ }
173
+ continue;
174
+ }
175
+ if (ACTIVITIES[key]) {
176
+ if (!isJsonObject(val)) throw fail(1, `${AUTONOMY_REL}: activity "${key}" must be a JSON object of { ${ACTIVITY_KEY}: sandbox|prompt }`);
177
+ for (const [ak, av] of Object.entries(val)) {
178
+ try {
179
+ assertAutonomyAssignment(key, ak, av, 1);
180
+ } catch (err) {
181
+ throw fail(1, `${AUTONOMY_REL}: ${err.message}`);
182
+ }
183
+ }
184
+ continue;
185
+ }
186
+ throw fail(1, `${AUTONOMY_REL}: unknown top-level key "${key}" (known: _README, ${KNOWN_SECTIONS()})`);
187
+ }
188
+ return config;
189
+ };
190
+
191
+ // ── policy IO (config errors → exit 1) ──────────────────────────────────────────────
192
+
193
+ // Load + validate the policy from <cwd>/docs/ai/autonomy.json. Absent FILE → computed defaults (NOT an
194
+ // error): { config: null, source: 'none' }. Malformed JSON / schema-invalid / unreadable → loud
195
+ // `path: reason` (exit 1). lstat (NOT existsSync) so a DANGLING SYMLINK reads as present and its later
196
+ // read failure surfaces loudly — never silently treated as absent (no-silent-failures Hard Constraint).
197
+ export const loadAutonomy = (cwd, readFile = readFileSync, lstat = lstatSync) => {
198
+ const full = join(cwd, AUTONOMY_REL);
199
+ try {
200
+ lstat(full);
201
+ } catch (err) {
202
+ if (err && err.code === 'ENOENT') return { config: null, source: 'none' };
203
+ throw fail(1, `${AUTONOMY_REL}: unreadable (${(err && err.code) || (err && err.message) || err})`);
204
+ }
205
+ let raw;
206
+ try {
207
+ raw = readFile(full, 'utf8');
208
+ } catch (err) {
209
+ throw fail(1, `${AUTONOMY_REL}: unreadable (${(err && err.code) || (err && err.message) || err})`);
210
+ }
211
+ let parsed;
212
+ try {
213
+ parsed = JSON.parse(raw);
214
+ } catch (err) {
215
+ throw fail(1, `${AUTONOMY_REL}: malformed JSON (${err.message})`);
216
+ }
217
+ return { config: validateAutonomy(parsed), source: AUTONOMY_REL };
218
+ };
219
+
220
+ // ── the pure computed-defaults resolver ─────────────────────────────────────────────
221
+
222
+ // resolveAutonomy(config) → the effective policy: every red-line resolved to its config value or its
223
+ // Decision-4 default; every ACTIVITIES entry resolved to its config autonomy or `prompt`. The render +
224
+ // any future read surface share THIS resolver, so a sparse policy always yields one full effective
225
+ // policy (no key ever undefined). Pure; accepts null (absent file → all defaults).
226
+ export const resolveAutonomy = (config) => {
227
+ const cfg = isJsonObject(config) ? config : {};
228
+ const rl = isJsonObject(cfg[REDLINES_SECTION]) ? cfg[REDLINES_SECTION] : {};
229
+ const redlines = {};
230
+ for (const k of REDLINE_KEYS) redlines[k] = rl[k] ?? REDLINE_DEFAULTS[k];
231
+ const activities = {};
232
+ for (const a of Object.keys(ACTIVITIES)) {
233
+ const entry = isJsonObject(cfg[a]) ? cfg[a] : {};
234
+ activities[a] = { [ACTIVITY_KEY]: entry[ACTIVITY_KEY] ?? DEFAULT_ACTIVITY_AUTONOMY };
235
+ }
236
+ return { redlines, activities };
237
+ };
238
+
239
+ // ── pure merge + canonical serialization ────────────────────────────────────────────
240
+
241
+ // A pure deep-equal over the JSON-ish policy shape (plain objects + string values). Used only for the
242
+ // "did anything actually change?" decision (no-op detection + seed-on-change), never for output.
243
+ const deepEqual = (a, b) => {
244
+ if (a === b) return true;
245
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
246
+ const ak = Object.keys(a);
247
+ const bk = Object.keys(b);
248
+ if (ak.length !== bk.length) return false;
249
+ return ak.every((k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]));
250
+ };
251
+
252
+ // applyAutonomyOps(current, ops, { seedReadme }) → the merged policy. PURE: deep-clones `current` (or
253
+ // {}), applies each set/unset, preserves `_README` + every untouched section/key, drops a section that
254
+ // an unset empties (sparse), then re-runs validateAutonomy (loud on invalid — defensive; the op parser
255
+ // pre-validates). When (and ONLY when) the merge CHANGES the policy, `_README` is absent, and
256
+ // `seedReadme` is supplied, the canonical note is seeded — so a no-op set never spuriously seeds it.
257
+ export const applyAutonomyOps = (current, ops, { seedReadme = null } = {}) => {
258
+ const base = current == null ? {} : structuredClone(current);
259
+ const next = structuredClone(base);
260
+ for (const op of ops) {
261
+ if (op.kind === 'set') {
262
+ next[op.section] = { ...(next[op.section] ?? {}) };
263
+ next[op.section][op.key] = op.value;
264
+ } else if (next[op.section] && op.key in next[op.section]) {
265
+ const rest = { ...next[op.section] };
266
+ delete rest[op.key];
267
+ if (Object.keys(rest).length === 0) delete next[op.section];
268
+ else next[op.section] = rest;
269
+ }
270
+ }
271
+ validateAutonomy(next);
272
+ const changed = !deepEqual(next, base);
273
+ if (changed && seedReadme != null && next._README === undefined) next._README = seedReadme;
274
+ return next;
275
+ };
276
+
277
+ // serializeAutonomy(config) → strict JSON, 2-space, trailing newline, `_README` FIRST (explicitly, so
278
+ // the onboarding note never sinks below the policy). This is the canonical on-disk form: a touched
279
+ // write normalizes to it (content-preserving, NOT byte-preserving of arbitrary hand-formatting).
280
+ export const serializeAutonomy = (config) => {
281
+ const ordered = {};
282
+ if (config._README !== undefined) ordered._README = config._README;
283
+ for (const [k, v] of Object.entries(config)) {
284
+ if (k !== '_README') ordered[k] = v;
285
+ }
286
+ return `${JSON.stringify(ordered, null, 2)}\n`;
287
+ };
288
+
289
+ // ── the canonical seed (Decision 5) ──────────────────────────────────────────────────
290
+ // AUTONOMY_README is the onboarding note; SEED_AUTONOMY is the Decision-5 fixture the set-autonomy
291
+ // writer seeds and the config tests copy/validate verbatim. Plan 1 ships NO references/templates/
292
+ // autonomy.json and NO template-parity test (both Plan 4) — SEED_AUTONOMY's Plan-1 consumers are the
293
+ // autonomy-config tests and the render's "seed one first" guidance.
294
+ export const AUTONOMY_README =
295
+ 'Per-project autonomy policy: red-lines (always) + per-activity autonomy level. Hand-editable; or ' +
296
+ 'use the set-autonomy writer (previews, then writes valid JSON). Strict JSON — no comments.';
297
+
298
+ export const SEED_AUTONOMY = {
299
+ _README: AUTONOMY_README,
300
+ redlines: {
301
+ commit: 'ask', push: 'ask', publish: 'ask',
302
+ network: 'deny', credentials: 'deny', fs_outside_repo: 'deny',
303
+ },
304
+ 'plan-authoring': { autonomy: 'sandbox' },
305
+ 'plan-execution': { autonomy: 'sandbox' },
306
+ };
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ // autonomy-write.mjs — the ONLY filesystem WRITER for docs/ai/autonomy.json. It is imported by the
3
+ // set-autonomy writer alone; no read-only module (autonomy-config.mjs, velocity-profile.mjs's read
4
+ // path) imports it, so "a read-only surface can never reach a writer" is a STRUCTURAL invariant (an
5
+ // import-split test pins it), not just an assertion. Splitting the writer out of the schema/read module
6
+ // keeps the read surface fs-write-free — the orchestration-write.mjs precedent (AD-044).
7
+ //
8
+ // The hardened write flow (deployment gate → symlink STOPs → containment guard → exclusive-create tmp
9
+ // + rename with a TOCTOU re-check → tmp cleanup; LAST-WRITER-WINS) lives in the shared atomic-write
10
+ // core (tools/atomic-write.mjs, AD-042) and is consumed here with this module's own STOP identity, so
11
+ // the public API + error contract are its own (this file's tests pin them).
12
+ //
13
+ // Dependency-free, Node >= 18. Every fs primitive is injectable (deps.*) so the guards are unit-testable.
14
+
15
+ import { AUTONOMY_REL, serializeAutonomy } from './autonomy-config.mjs';
16
+ import { writeDocsAiFileAtomic } from './atomic-write.mjs';
17
+
18
+ // A typed STOP — a deliberate refusal we surface (deployment gate / symlinked leaf), distinct from a
19
+ // native fs error. `Object.assign(new Error(), { code })`, the codebase's typed-error idiom (no classes).
20
+ export const AUTONOMY_WRITE_STOP = 'AUTONOMY_WRITE_STOP';
21
+ const stop = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'AutonomyWriteStop', code: AUTONOMY_WRITE_STOP });
22
+
23
+ // writeAutonomy(cwd, config, deps) → { writtenPath } on success; THROWS a typed STOP (no deployment /
24
+ // symlinked leaf) or a native fs error otherwise. The tmp is cleaned up on any failure after its
25
+ // creation. config is serialized canonically (serializeAutonomy: 2-space, _README-first, trailing NL).
26
+ export const writeAutonomy = (cwd, config, deps = {}) =>
27
+ writeDocsAiFileAtomic(cwd, AUTONOMY_REL, serializeAutonomy(config), deps, { stop, noun: 'a policy' });
@@ -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
+ };