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