@spinabot/brigade 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,730 @@
1
+ /**
2
+ * Brigade super-config.
3
+ *
4
+ * Single source of truth at `<BRIGADE_DIR>/brigade.json`. Replaces the legacy
5
+ * three-file layout (`auth.json`, `config.json`, `settings.json`) with a
6
+ * versioned, schema-validated document plus rotating backups and atomic
7
+ * writes. Boot must never fail on a corrupt file — recovery walks the
8
+ * backup chain and ultimately falls through to a fresh empty config.
9
+ *
10
+ * v2 schema (current). The block names mirror the reference super-config
11
+ * shape so future Phase-2/3 features (auth profiles for non-key providers,
12
+ * multi-agent rosters, plugins, skills, channels, gateway control UI) just
13
+ * add nested fields rather than reshaping the document. The migration from
14
+ * v1 → v2 reorganises three things:
15
+ *
16
+ * 1. settings.defaultProvider + settings.defaultModelId
17
+ * → agents.defaults.model.primary (composed as "<provider>/<modelId>")
18
+ * 2. settings.fallbackProvider + settings.fallbackModelId
19
+ * → agents.defaults.model.fallbacks[0]
20
+ * 3. settings.installedAt
21
+ * → meta.installedAt
22
+ *
23
+ * settings.compaction and settings.thinkingLevel stay under `settings` —
24
+ * Brigade-private namespace until later primitives give them a real home.
25
+ */
26
+ import { chmodSync, closeSync, copyFileSync, fsyncSync, openSync, renameSync, writeSync } from "node:fs";
27
+ import * as fs from "node:fs/promises";
28
+ import * as path from "node:path";
29
+ import { Type } from "typebox";
30
+ import { Check, Errors } from "typebox/value";
31
+ import { BRIGADE_CLI_VERSION } from "./version.js";
32
+ export const BRIGADE_CONFIG_SCHEMA_VERSION = 2;
33
+ export const BRIGADE_CONFIG_FILENAME = "brigade.json";
34
+ export const BACKUP_DEPTH = 4;
35
+ const FILE_MODE = 0o600;
36
+ /* ───────────────────────────── v2 schema ──────────────────────────────── */
37
+ const TOOL_FILTER_SCHEMA = Type.Object({
38
+ alsoAllow: Type.Optional(Type.Array(Type.String())),
39
+ deny: Type.Optional(Type.Array(Type.String())),
40
+ });
41
+ const IDENTITY_SCHEMA = Type.Object({
42
+ name: Type.Optional(Type.String()),
43
+ emoji: Type.Optional(Type.String()),
44
+ });
45
+ // Profile METADATA only — never the secret value. Key material lives in
46
+ // Pi's auth.json (or in the env block as the canonical state-isolation home).
47
+ const AUTH_PROFILE_SCHEMA = Type.Object({
48
+ provider: Type.String(),
49
+ mode: Type.Union([Type.Literal("api_key"), Type.Literal("oauth"), Type.Literal("token")]),
50
+ email: Type.Optional(Type.String()),
51
+ displayName: Type.Optional(Type.String()),
52
+ });
53
+ const AUTH_SCHEMA = Type.Object({
54
+ profiles: Type.Optional(Type.Record(Type.String(), AUTH_PROFILE_SCHEMA)),
55
+ });
56
+ // Object form chosen so adding `fallbacks` later is additive (not a reshape).
57
+ // `primary` is "<provider>/<modelId>" — first-slash-split on read.
58
+ const AGENT_MODEL_SCHEMA = Type.Object({
59
+ primary: Type.Optional(Type.String()),
60
+ fallbacks: Type.Optional(Type.Array(Type.String())),
61
+ });
62
+ const AGENT_DEFAULTS_SCHEMA = Type.Object({
63
+ model: Type.Optional(AGENT_MODEL_SCHEMA),
64
+ subagents: Type.Optional(Type.Object({
65
+ allowAgents: Type.Optional(Type.Array(Type.String())),
66
+ })),
67
+ });
68
+ // `name` is OPTIONAL by design — it mirrors the virtual-default-agent pattern
69
+ // in mature personal-AI-crew agents, where the entry is keyed by `id` (the
70
+ // routing key, e.g. "main") and the human-readable name is derived later from
71
+ // the persona layer (BOOTSTRAP / identity command writes `identity.name`, or
72
+ // the user sets a top-level `name` explicitly). Requiring `name` upfront would
73
+ // force callers to invent a placeholder string at scaffold time, which is
74
+ // exactly the back-door the v1→v2 migration was leaking the product brand
75
+ // through.
76
+ const AGENT_ENTRY_SCHEMA = Type.Object({
77
+ id: Type.String(),
78
+ name: Type.Optional(Type.String()),
79
+ identity: Type.Optional(IDENTITY_SCHEMA),
80
+ workspace: Type.Optional(Type.String()),
81
+ agentDir: Type.Optional(Type.String()),
82
+ tools: Type.Optional(TOOL_FILTER_SCHEMA),
83
+ model: Type.Optional(AGENT_MODEL_SCHEMA),
84
+ });
85
+ const AGENTS_SCHEMA = Type.Object({
86
+ defaults: Type.Optional(AGENT_DEFAULTS_SCHEMA),
87
+ list: Type.Optional(Type.Array(AGENT_ENTRY_SCHEMA)),
88
+ });
89
+ const PLUGIN_ENTRY_SCHEMA = Type.Object({
90
+ enabled: Type.Optional(Type.Boolean()),
91
+ config: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
92
+ });
93
+ const PLUGINS_SCHEMA = Type.Object({
94
+ enabled: Type.Optional(Type.Boolean()),
95
+ allow: Type.Optional(Type.Array(Type.String())),
96
+ deny: Type.Optional(Type.Array(Type.String())),
97
+ entries: Type.Optional(Type.Record(Type.String(), PLUGIN_ENTRY_SCHEMA)),
98
+ });
99
+ const SKILL_ENTRY_SCHEMA = Type.Object({
100
+ enabled: Type.Optional(Type.Boolean()),
101
+ config: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
102
+ });
103
+ const SKILLS_SCHEMA = Type.Object({
104
+ entries: Type.Optional(Type.Record(Type.String(), SKILL_ENTRY_SCHEMA)),
105
+ });
106
+ const CHANNELS_SCHEMA = Type.Record(Type.String(), Type.Record(Type.String(), Type.Unknown()));
107
+ const GATEWAY_AUTH_SCHEMA = Type.Object({
108
+ mode: Type.Optional(Type.Union([Type.Literal("none"), Type.Literal("token"), Type.Literal("password")])),
109
+ token: Type.Optional(Type.String()),
110
+ password: Type.Optional(Type.String()),
111
+ });
112
+ const GATEWAY_HTTP_SCHEMA = Type.Object({
113
+ endpoints: Type.Optional(Type.Record(Type.String(), Type.Object({
114
+ enabled: Type.Optional(Type.Boolean()),
115
+ }))),
116
+ });
117
+ const GATEWAY_CONTROL_UI_SCHEMA = Type.Object({
118
+ allowedOrigins: Type.Optional(Type.Array(Type.String())),
119
+ });
120
+ const GATEWAY_RELOAD_SCHEMA = Type.Object({
121
+ mode: Type.Optional(Type.Union([Type.Literal("off"), Type.Literal("hot"), Type.Literal("manual")])),
122
+ });
123
+ const GATEWAY_SCHEMA = Type.Object({
124
+ mode: Type.Optional(Type.Union([Type.Literal("local"), Type.Literal("remote")])),
125
+ port: Type.Optional(Type.Number()),
126
+ auth: Type.Optional(GATEWAY_AUTH_SCHEMA),
127
+ http: Type.Optional(GATEWAY_HTTP_SCHEMA),
128
+ controlUi: Type.Optional(GATEWAY_CONTROL_UI_SCHEMA),
129
+ reload: Type.Optional(GATEWAY_RELOAD_SCHEMA),
130
+ });
131
+ const WIZARD_SCHEMA = Type.Object({
132
+ lastRunAt: Type.Optional(Type.String()),
133
+ lastRunVersion: Type.Optional(Type.String()),
134
+ });
135
+ const META_SCHEMA = Type.Object({
136
+ lastTouchedVersion: Type.Optional(Type.String()),
137
+ lastTouchedAt: Type.Optional(Type.String()),
138
+ installedAt: Type.Optional(Type.String()),
139
+ });
140
+ // Brigade-private namespace for knobs that have no v2 home in the reference
141
+ // shape yet. compaction/thinkingLevel stay here until the relevant primitive
142
+ // (Primitive #2/#4 etc.) gives them a real home in agents.* or similar.
143
+ //
144
+ // fallbackProvider/fallbackModelId/installedAt are DROPPED at v2 — they're
145
+ // moved to agents.defaults.model.fallbacks and meta.installedAt. The
146
+ // migration in `migrateBrigadeConfigV1toV2` performs the lift; in-flight
147
+ // readers fall back here transparently.
148
+ //
149
+ // defaultProvider/defaultModelId are PARTIALLY dropped: when both are
150
+ // present, they're consolidated into agents.defaults.model.primary. They
151
+ // remain accepted in the v2 schema solely as transient in-flight scratch
152
+ // for partial-write CLI flows (`brigade config set defaultProvider X`
153
+ // without a subsequent `set defaultModelId Y`), since
154
+ // agents.defaults.model.primary requires both halves to form a valid
155
+ // "<provider>/<modelId>" ref. saveConfig MUST clear them once primary is
156
+ // composed.
157
+ const SETTINGS_SCHEMA = Type.Object({
158
+ compaction: Type.Optional(Type.Object({ enabled: Type.Optional(Type.Boolean()) })),
159
+ thinkingLevel: Type.Optional(Type.String()),
160
+ defaultProvider: Type.Optional(Type.String()),
161
+ defaultModelId: Type.Optional(Type.String()),
162
+ });
163
+ export const BrigadeConfigSchema = Type.Object({
164
+ $schema: Type.Optional(Type.String()),
165
+ version: Type.Literal(2),
166
+ meta: Type.Optional(META_SCHEMA),
167
+ wizard: Type.Optional(WIZARD_SCHEMA),
168
+ env: Type.Optional(Type.Record(Type.String(), Type.String())),
169
+ auth: Type.Optional(AUTH_SCHEMA),
170
+ agents: Type.Optional(AGENTS_SCHEMA),
171
+ plugins: Type.Optional(PLUGINS_SCHEMA),
172
+ skills: Type.Optional(SKILLS_SCHEMA),
173
+ channels: Type.Optional(CHANNELS_SCHEMA),
174
+ gateway: Type.Optional(GATEWAY_SCHEMA),
175
+ settings: Type.Optional(SETTINGS_SCHEMA),
176
+ });
177
+ /* ────────────────────────── v1 schema (legacy) ────────────────────────── */
178
+ /**
179
+ * v1 schema kept around solely for migration validation. Never written; only
180
+ * matched against on disk to detect "this is a pre-v2 file we should lift."
181
+ */
182
+ const BRIGADE_CONFIG_V1_SCHEMA = Type.Object({
183
+ version: Type.Literal(1),
184
+ env: Type.Optional(Type.Record(Type.String(), Type.String())),
185
+ settings: Type.Optional(Type.Object({
186
+ defaultProvider: Type.Optional(Type.String()),
187
+ defaultModelId: Type.Optional(Type.String()),
188
+ fallbackProvider: Type.Optional(Type.String()),
189
+ fallbackModelId: Type.Optional(Type.String()),
190
+ installedAt: Type.Optional(Type.String()),
191
+ thinkingLevel: Type.Optional(Type.String()),
192
+ compaction: Type.Optional(Type.Object({ enabled: Type.Optional(Type.Boolean()) })),
193
+ })),
194
+ });
195
+ export class BrigadeConfigValidationError extends Error {
196
+ errors;
197
+ constructor(message, errors) {
198
+ super(message);
199
+ this.name = "BrigadeConfigValidationError";
200
+ this.errors = errors;
201
+ }
202
+ }
203
+ function configPath(brigadeDir) {
204
+ return path.join(brigadeDir, BRIGADE_CONFIG_FILENAME);
205
+ }
206
+ function backupPath(brigadeDir, slot) {
207
+ const base = `${configPath(brigadeDir)}.bak`;
208
+ return slot === 0 ? base : `${base}.${slot}`;
209
+ }
210
+ function backupChain(brigadeDir) {
211
+ const chain = [];
212
+ for (let slot = 0; slot <= BACKUP_DEPTH; slot++) {
213
+ chain.push(backupPath(brigadeDir, slot));
214
+ }
215
+ return chain;
216
+ }
217
+ function emptyConfig() {
218
+ return { version: BRIGADE_CONFIG_SCHEMA_VERSION };
219
+ }
220
+ function isoTimestampForFilename() {
221
+ return new Date().toISOString().replace(/[:]/g, "-");
222
+ }
223
+ function collectErrors(value) {
224
+ const issues = [];
225
+ for (const err of Errors(BrigadeConfigSchema, value)) {
226
+ issues.push({
227
+ path: typeof err.instancePath === "string" ? err.instancePath : "",
228
+ message: typeof err.message === "string" ? err.message : "validation error",
229
+ });
230
+ }
231
+ return issues;
232
+ }
233
+ async function tryReadJson(p) {
234
+ const buf = await fs.readFile(p, "utf8");
235
+ return JSON.parse(buf);
236
+ }
237
+ async function safeRename(from, to) {
238
+ try {
239
+ await fs.rename(from, to);
240
+ }
241
+ catch (err) {
242
+ const code = err?.code;
243
+ if (code === "ENOENT")
244
+ return;
245
+ throw err;
246
+ }
247
+ }
248
+ async function safeRemove(p) {
249
+ try {
250
+ await fs.rm(p, { force: true });
251
+ }
252
+ catch {
253
+ /* ignore */
254
+ }
255
+ }
256
+ async function exists(p) {
257
+ try {
258
+ await fs.stat(p);
259
+ return true;
260
+ }
261
+ catch {
262
+ return false;
263
+ }
264
+ }
265
+ async function rotateBackups(brigadeDir) {
266
+ // Rotate before write so a crash mid-write leaves bak as the last known good.
267
+ const current = configPath(brigadeDir);
268
+ if (!(await exists(current)))
269
+ return;
270
+ for (let slot = BACKUP_DEPTH; slot >= 1; slot--) {
271
+ const from = backupPath(brigadeDir, slot - 1);
272
+ const to = backupPath(brigadeDir, slot);
273
+ if (!(await exists(from)))
274
+ continue;
275
+ await safeRemove(to);
276
+ await safeRename(from, to);
277
+ }
278
+ await safeRemove(backupPath(brigadeDir, 0));
279
+ await safeRename(current, backupPath(brigadeDir, 0));
280
+ }
281
+ function atomicWriteSync(filePath, data) {
282
+ const tmp = `${filePath}.tmp`;
283
+ const fd = openSync(tmp, "w", FILE_MODE);
284
+ try {
285
+ writeSync(fd, data);
286
+ fsyncSync(fd);
287
+ }
288
+ finally {
289
+ closeSync(fd);
290
+ }
291
+ renameSync(tmp, filePath);
292
+ try {
293
+ chmodSync(filePath, FILE_MODE);
294
+ }
295
+ catch {
296
+ /* ignore — Windows / restricted FS */
297
+ }
298
+ }
299
+ function snapshotCorrupted(brigadeDir) {
300
+ const src = configPath(brigadeDir);
301
+ const target = `${src}.clobbered.${isoTimestampForFilename()}`;
302
+ try {
303
+ renameSync(src, target);
304
+ return target;
305
+ }
306
+ catch {
307
+ return undefined;
308
+ }
309
+ }
310
+ /**
311
+ * Scrub the legacy `agents.list[i].name = "Brigade"` value that pre-v0.1.0
312
+ * onboarding wrote to disk. The product brand was being injected into the
313
+ * persona slot through a back door — once the seed entry exists with that
314
+ * name, every downstream display surface that reads `agents.list[i].name`
315
+ * surfaces "Brigade" as the agent's own name, which is exactly the leak the
316
+ * `name`-removal fix was meant to prevent.
317
+ *
318
+ * Strategy: if any list entry has `name === "Brigade"` (the literal product
319
+ * brand), drop the `name` field entirely. Schema marks `name` optional so a
320
+ * scrubbed entry remains valid; downstream code falls back to whatever the
321
+ * user picks via the BOOTSTRAP / identity command (which writes IDENTITY.md).
322
+ *
323
+ * Returns true when at least one entry was modified, so the caller can
324
+ * persist the cleaned config back to disk + emit a one-line audit message.
325
+ * Idempotent: a second pass over an already-clean config does nothing.
326
+ */
327
+ function scrubLegacyAgentName(cfg) {
328
+ const list = cfg.agents?.list;
329
+ if (!list || list.length === 0)
330
+ return false;
331
+ let changed = false;
332
+ for (const entry of list) {
333
+ if (entry && entry.name === "Brigade") {
334
+ delete entry.name;
335
+ changed = true;
336
+ }
337
+ }
338
+ return changed;
339
+ }
340
+ export async function loadBrigadeConfig(brigadeDir) {
341
+ const main = configPath(brigadeDir);
342
+ if (await exists(main)) {
343
+ try {
344
+ const raw = await tryReadJson(main);
345
+ if (Check(BrigadeConfigSchema, raw)) {
346
+ const cfg = raw;
347
+ // Migrate-on-read scrub: drop legacy product-brand seed in
348
+ // agents.list[i].name. See `scrubLegacyAgentName` for rationale.
349
+ // Persist the cleaned config back so the next read finds nothing
350
+ // to scrub (idempotent), and so other readers + the brigade.json
351
+ // file on disk both reflect the same cleaned state.
352
+ if (scrubLegacyAgentName(cfg)) {
353
+ try {
354
+ await writeBrigadeConfig(brigadeDir, cfg);
355
+ process.stderr.write("brigade: scrubbed legacy agents.list[i].name='Brigade' from brigade.json (auto-cleanup of pre-v0.1.0 leak)\n");
356
+ }
357
+ catch {
358
+ // Don't block boot if the write fails — the in-memory
359
+ // config is already clean for this process; the next
360
+ // successful write from any code path will persist it.
361
+ }
362
+ }
363
+ return cfg;
364
+ }
365
+ // Parsed but failed schema — treat as corrupt: snapshot, walk backups.
366
+ snapshotCorrupted(brigadeDir);
367
+ }
368
+ catch {
369
+ snapshotCorrupted(brigadeDir);
370
+ }
371
+ }
372
+ for (let slot = 0; slot <= BACKUP_DEPTH; slot++) {
373
+ const candidate = backupPath(brigadeDir, slot);
374
+ if (!(await exists(candidate)))
375
+ continue;
376
+ try {
377
+ const raw = await tryReadJson(candidate);
378
+ if (Check(BrigadeConfigSchema, raw)) {
379
+ const cfg = raw;
380
+ // Same scrub on the recovered backup. Don't try to write here —
381
+ // recovery already implies the main file was bad; we'd rather
382
+ // hand back a clean in-memory copy and let the next normal
383
+ // write path persist the scrub.
384
+ scrubLegacyAgentName(cfg);
385
+ return cfg;
386
+ }
387
+ }
388
+ catch {
389
+ /* try the next slot */
390
+ }
391
+ }
392
+ process.stderr.write("brigade: warning: brigade.json corrupt, all backups exhausted, starting fresh\n");
393
+ return emptyConfig();
394
+ }
395
+ /**
396
+ * Strip leaf properties whose value is `undefined`, recursively. JSON.stringify
397
+ * already drops them at the top level, but the migration builds nested objects
398
+ * (`{ agents: { defaults: { model: { primary: undefined } } } }`) where the
399
+ * `undefined` would mean the inner objects survive serialization as empty `{}`
400
+ * — noisy and hostile to grep. Clean both empty objects AND undefined leaves.
401
+ */
402
+ function stripUndefined(value) {
403
+ if (Array.isArray(value)) {
404
+ return value
405
+ .map((v) => stripUndefined(v))
406
+ .filter((v) => v !== undefined);
407
+ }
408
+ if (value && typeof value === "object") {
409
+ const out = {};
410
+ for (const [k, v] of Object.entries(value)) {
411
+ if (v === undefined)
412
+ continue;
413
+ const cleaned = stripUndefined(v);
414
+ // Drop empty plain objects so we don't litter the file with `{}` leaves.
415
+ // Arrays may legitimately be empty (explicit "I want no fallbacks") so
416
+ // they're preserved — only object-shaped emptiness gets pruned.
417
+ if (cleaned !== null &&
418
+ typeof cleaned === "object" &&
419
+ !Array.isArray(cleaned) &&
420
+ Object.keys(cleaned).length === 0) {
421
+ continue;
422
+ }
423
+ out[k] = cleaned;
424
+ }
425
+ return out;
426
+ }
427
+ return value;
428
+ }
429
+ /**
430
+ * Stamp meta.lastTouchedVersion + lastTouchedAt unconditionally on every write
431
+ * so `~/.brigade/brigade.json` always reflects which Brigade build last
432
+ * mutated it. Preserves existing meta fields (notably installedAt) by merging.
433
+ */
434
+ function applyMetaTouch(cfg) {
435
+ const meta = { ...(cfg.meta ?? {}) };
436
+ meta.lastTouchedVersion = BRIGADE_CLI_VERSION;
437
+ meta.lastTouchedAt = new Date().toISOString();
438
+ return { ...cfg, meta };
439
+ }
440
+ export async function writeBrigadeConfig(brigadeDir, cfg) {
441
+ const stamped = applyMetaTouch(cfg);
442
+ if (!Check(BrigadeConfigSchema, stamped)) {
443
+ const issues = collectErrors(stamped);
444
+ throw new BrigadeConfigValidationError(`brigade.json failed schema validation (${issues.length} issue${issues.length === 1 ? "" : "s"})`, issues);
445
+ }
446
+ await fs.mkdir(brigadeDir, { recursive: true });
447
+ await rotateBackups(brigadeDir);
448
+ const serialized = `${JSON.stringify(stamped, null, 2)}\n`;
449
+ atomicWriteSync(configPath(brigadeDir), serialized);
450
+ }
451
+ /**
452
+ * Compose a "<provider>/<modelId>" model ref string. Avoid double-prefixing
453
+ * when the modelId already starts with "<provider>/" (e.g. OpenRouter ids
454
+ * like "openrouter/auto" — joining naively would give "openrouter/openrouter/auto"
455
+ * which is technically what the user typed, but the cleaner stored form is
456
+ * "openrouter/auto"). First-slash-split on read still recovers the right
457
+ * provider either way, but skip the redundant prefix when we can.
458
+ */
459
+ function composeModelRef(p, m) {
460
+ if (!p || !m)
461
+ return undefined;
462
+ return m.startsWith(`${p}/`) ? m : `${p}/${m}`;
463
+ }
464
+ function composeFallbacks(p, m) {
465
+ const ref = composeModelRef(p, m);
466
+ return ref ? [ref] : undefined;
467
+ }
468
+ /**
469
+ * Migrate a v1 Brigade super-config to the v2 reference-aligned shape.
470
+ * Idempotent — already-v2 configs short-circuit. Side effect: writes a
471
+ * `<configPath>.migrated-v1-<ISO-ts>.bak` snapshot before write so the
472
+ * migration is auditable + reversible.
473
+ *
474
+ * Behaviour:
475
+ * - missing brigade.json → no-op (returns {migrated:false})
476
+ * - version === 2 → no-op (already migrated)
477
+ * - version > 2 → no-op + stderr warn (don't trash a future config)
478
+ * - version === 1 (or absent) → snapshot + lift + write v2
479
+ * - corrupt v1 (schema-fails) → no-op + stderr warn (don't trash bad data)
480
+ */
481
+ export async function migrateBrigadeConfigV1toV2(brigadeDir) {
482
+ const cfgPath = configPath(brigadeDir);
483
+ if (!(await exists(cfgPath)))
484
+ return { migrated: false };
485
+ let raw;
486
+ try {
487
+ raw = await tryReadJson(cfgPath);
488
+ }
489
+ catch {
490
+ // Corrupt JSON — leave it alone. loadBrigadeConfig's recovery path
491
+ // will deal with it on next read; we don't want to mask the corruption
492
+ // or destroy a file the user might still be able to hand-recover.
493
+ process.stderr.write("brigade: warning: brigade.json is unparseable; v1→v2 migration skipped\n");
494
+ return { migrated: false };
495
+ }
496
+ const versionField = (raw && typeof raw === "object")
497
+ ? raw.version
498
+ : undefined;
499
+ if (versionField === 2)
500
+ return { migrated: false };
501
+ if (typeof versionField === "number" && versionField > 2) {
502
+ process.stderr.write(`brigade: warning: brigade.json is version ${versionField} (newer than this build); skipping migration\n`);
503
+ return { migrated: false };
504
+ }
505
+ // Treat absent version as v1 best-effort. Validate against the v1 schema
506
+ // to confirm the shape is recognisable; if it isn't, leave the file alone.
507
+ let v1;
508
+ if (versionField === undefined) {
509
+ // Synthesise a version field so the v1 schema check passes; we already
510
+ // know there's no version, so it's v0/v1-shaped at best.
511
+ const candidate = { ...raw, version: 1 };
512
+ if (!Check(BRIGADE_CONFIG_V1_SCHEMA, candidate)) {
513
+ process.stderr.write("brigade: warning: brigade.json has no version and doesn't match the v1 shape; migration skipped\n");
514
+ return { migrated: false };
515
+ }
516
+ v1 = candidate;
517
+ }
518
+ else if (versionField === 1) {
519
+ if (!Check(BRIGADE_CONFIG_V1_SCHEMA, raw)) {
520
+ process.stderr.write("brigade: warning: brigade.json claims version 1 but failed schema validation; migration skipped\n");
521
+ return { migrated: false };
522
+ }
523
+ v1 = raw;
524
+ }
525
+ else {
526
+ // Some other version literal (0, negative, non-number). Skip — too risky
527
+ // to lift heuristically.
528
+ process.stderr.write(`brigade: warning: brigade.json has unexpected version ${String(versionField)}; migration skipped\n`);
529
+ return { migrated: false };
530
+ }
531
+ // Snapshot before write so the migration is reversible. Use copyFile (not
532
+ // rename) because writeBrigadeConfig will overwrite the original — we want
533
+ // to keep both the new v2 file AND the original v1 backup side-by-side.
534
+ const snapshotPath = `${cfgPath}.migrated-v1-${isoTimestampForFilename()}.bak`;
535
+ try {
536
+ copyFileSync(cfgPath, snapshotPath);
537
+ }
538
+ catch (err) {
539
+ const msg = err instanceof Error ? err.message : String(err);
540
+ process.stderr.write(`brigade: warning: couldn't snapshot v1 config before migration (${msg}); aborting migration\n`);
541
+ return { migrated: false };
542
+ }
543
+ const v2 = stripUndefined({
544
+ version: 2,
545
+ env: v1.env,
546
+ meta: {
547
+ installedAt: v1.settings?.installedAt,
548
+ // lastTouchedVersion + lastTouchedAt stamped by writeBrigadeConfig
549
+ },
550
+ agents: {
551
+ defaults: {
552
+ model: {
553
+ primary: composeModelRef(v1.settings?.defaultProvider, v1.settings?.defaultModelId),
554
+ fallbacks: composeFallbacks(v1.settings?.fallbackProvider, v1.settings?.fallbackModelId),
555
+ },
556
+ },
557
+ // Deliberately DO NOT seed `agents.list` here. This matches the
558
+ // virtual-default-agent pattern used in mature personal-AI-crew
559
+ // agents: when `agents.list` is absent, the runtime synthesizes a
560
+ // virtual entry keyed by the well-known "main" id. Writing a stub
561
+ // like `{ id: "main", name: "<product>" }` would leak the product
562
+ // brand into the persona slot through a back door — the `name`
563
+ // field is reserved for whatever the user picks via the BOOTSTRAP
564
+ // conversation (which writes IDENTITY.md) or an explicit identity
565
+ // command. Leaving the list undefined keeps that slot clean.
566
+ },
567
+ settings: {
568
+ compaction: v1.settings?.compaction,
569
+ thinkingLevel: v1.settings?.thinkingLevel,
570
+ },
571
+ });
572
+ await writeBrigadeConfig(brigadeDir, v2);
573
+ return { migrated: true, snapshotPath };
574
+ }
575
+ async function readLegacyJson(p) {
576
+ if (!(await exists(p)))
577
+ return undefined;
578
+ try {
579
+ const raw = await fs.readFile(p, "utf8");
580
+ const parsed = JSON.parse(raw);
581
+ if (parsed && typeof parsed === "object")
582
+ return parsed;
583
+ return undefined;
584
+ }
585
+ catch {
586
+ return undefined;
587
+ }
588
+ }
589
+ export async function migrateLegacyConfig(brigadeDir) {
590
+ // Idempotent guard — a valid super-config means the migration already ran.
591
+ const main = configPath(brigadeDir);
592
+ if (await exists(main)) {
593
+ try {
594
+ const existing = await tryReadJson(main);
595
+ if (Check(BrigadeConfigSchema, existing)) {
596
+ return { migrated: false, movedFiles: [] };
597
+ }
598
+ }
599
+ catch {
600
+ /* corrupted — fall through to attempt migration */
601
+ }
602
+ }
603
+ const authPath = path.join(brigadeDir, "auth.json");
604
+ const configFile = path.join(brigadeDir, "config.json");
605
+ const settingsPath = path.join(brigadeDir, "settings.json");
606
+ const legacyAuth = await readLegacyJson(authPath);
607
+ const legacyConfig = await readLegacyJson(configFile);
608
+ const legacySettings = await readLegacyJson(settingsPath);
609
+ if (!legacyAuth && !legacyConfig && !legacySettings) {
610
+ return { migrated: false, movedFiles: [] };
611
+ }
612
+ // Build directly into v2 shape — skip the intermediate v1 hop. Otherwise
613
+ // every fresh install would write v1 first, then immediately migrate to v2
614
+ // on the next call, doubling the .bak chain churn for no benefit.
615
+ const merged = stripUndefined({
616
+ version: BRIGADE_CONFIG_SCHEMA_VERSION,
617
+ env: legacyAuth && Object.keys(legacyAuth).length > 0
618
+ ? Object.fromEntries(Object.entries(legacyAuth).filter(([, v]) => typeof v === "string"))
619
+ : undefined,
620
+ meta: {
621
+ installedAt: typeof legacyConfig?.installedAt === "string"
622
+ ? legacyConfig.installedAt
623
+ : undefined,
624
+ },
625
+ agents: legacyConfig
626
+ ? {
627
+ defaults: {
628
+ model: {
629
+ primary: composeModelRef(legacyConfig.defaultProvider, legacyConfig.defaultModelId),
630
+ fallbacks: composeFallbacks(legacyConfig.fallbackProvider, legacyConfig.fallbackModelId),
631
+ },
632
+ },
633
+ // `agents.list` deliberately omitted — runtime synthesizes the
634
+ // virtual default agent keyed by "main" when the list is
635
+ // absent (see migrateBrigadeConfigV1toV2 for the same
636
+ // rationale). Avoids leaking the product name into the
637
+ // persona slot.
638
+ }
639
+ : undefined,
640
+ settings: {
641
+ compaction: legacySettings?.compaction && typeof legacySettings.compaction === "object"
642
+ ? typeof legacySettings.compaction.enabled === "boolean"
643
+ ? { enabled: legacySettings.compaction.enabled }
644
+ : undefined
645
+ : undefined,
646
+ thinkingLevel: typeof legacyConfig?.thinkingLevel === "string"
647
+ ? legacyConfig.thinkingLevel
648
+ : undefined,
649
+ },
650
+ });
651
+ await writeBrigadeConfig(brigadeDir, merged);
652
+ const moved = [];
653
+ const ts = isoTimestampForFilename();
654
+ for (const legacyPath of [authPath, configFile, settingsPath]) {
655
+ if (!(await exists(legacyPath)))
656
+ continue;
657
+ const target = `${legacyPath}.migrated-${ts}`;
658
+ try {
659
+ await fs.rename(legacyPath, target);
660
+ moved.push(target);
661
+ }
662
+ catch {
663
+ /* leave the legacy file in place if rename fails */
664
+ }
665
+ }
666
+ return { migrated: true, movedFiles: moved };
667
+ }
668
+ /**
669
+ * Module-level snapshot of which env-block keys were ACTUALLY written into
670
+ * `process.env` by the most recent `loadEnvIntoProcess` call. Auth-source
671
+ * resolution reads this so it can distinguish "value came from brigade.json"
672
+ * from "value happens to match a shell env var" — without this set, value-
673
+ * equality would falsely report "file" when the shell pre-populated the
674
+ * variable, and `rm -rf ~/.brigade` would NOT actually wipe the auth.
675
+ *
676
+ * Reset on every `loadEnvIntoProcess` invocation, including the empty-config
677
+ * path, so stale entries from a previous boot can't bleed into a new one.
678
+ */
679
+ let _appliedFromFile = new Set();
680
+ export function getAppliedEnvKeys() {
681
+ return _appliedFromFile;
682
+ }
683
+ export function loadEnvIntoProcess(cfg) {
684
+ const result = { applied: [], skipped: [] };
685
+ const env = cfg.env;
686
+ if (!env) {
687
+ _appliedFromFile = new Set();
688
+ return result;
689
+ }
690
+ for (const [key, value] of Object.entries(env)) {
691
+ // Shell-supplied env wins so users aren't surprised by config overriding it.
692
+ if (Object.prototype.hasOwnProperty.call(process.env, key)) {
693
+ result.skipped.push(key);
694
+ continue;
695
+ }
696
+ process.env[key] = value;
697
+ result.applied.push(key);
698
+ }
699
+ _appliedFromFile = new Set(result.applied);
700
+ return result;
701
+ }
702
+ /* ──────────────────────── helpers exported for callers ────────────────── */
703
+ /**
704
+ * First-slash-split a "<provider>/<modelId>" model ref into its component
705
+ * parts. Used by config readers that need to recover provider+modelId from
706
+ * `agents.defaults.model.primary` (or `.fallbacks[i]`). Documented semantics:
707
+ * the FIRST `/` is the provider/modelId separator, so multi-slash modelIds
708
+ * (e.g. `openrouter/openrouter/auto` → provider="openrouter",
709
+ * modelId="openrouter/auto") survive intact.
710
+ */
711
+ export function parseModelRef(ref) {
712
+ if (!ref || ref.length === 0)
713
+ return undefined;
714
+ const slash = ref.indexOf("/");
715
+ if (slash <= 0 || slash === ref.length - 1)
716
+ return undefined;
717
+ return {
718
+ provider: ref.slice(0, slash),
719
+ modelId: ref.slice(slash + 1),
720
+ };
721
+ }
722
+ /**
723
+ * Inverse of `parseModelRef`. Exposed so callers (config-cmd, onboarding,
724
+ * server, chat) can compose a model ref consistently and avoid double-
725
+ * prefixing OpenRouter-style ids that already include the provider segment.
726
+ */
727
+ export function composeModelRefExternal(provider, modelId) {
728
+ return composeModelRef(provider, modelId);
729
+ }
730
+ //# sourceMappingURL=brigade-config.js.map