@skanl/brambo-projection 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,468 @@
1
+ import { readFile, realpath, stat } from 'node:fs/promises';
2
+ import { basename, dirname, join } from 'node:path';
3
+ import { parse as parseJsonc } from 'jsonc-parser';
4
+ import { BRAMBO_ERROR_CODES, BramboError, isRecord, projectionTargetLocation } from '@skanl/brambo-contracts';
5
+ import { atomicWriteText } from './atomic-write.js';
6
+ import { hasFileChangedSince, resolveProjectionMode } from './engine.js';
7
+ import { scanLegacyBramboBlock } from './formats.js';
8
+ import { LEDGER_REPAIR_AUTHORITY, isUnderRoot, resolveOwnedPath, sameOwnedPath, serialiseLedgerDocument, } from './ledger.js';
9
+ import { claimMaterialised } from './materialise.js';
10
+ function refusalOf(message, code = BRAMBO_ERROR_CODES.projectionRemediationRefused) {
11
+ return { code, message };
12
+ }
13
+ function refused(remediation, targetId, entryId, location, refusal) {
14
+ return { remediation, targetId, entryId, location, changes: [], applied: false, refusal };
15
+ }
16
+ function ledgerChange(action, ledgerPath, location, detail, byteDelta = 0) {
17
+ return { subject: 'ledger', action, path: ledgerPath, location, byteDelta, detail };
18
+ }
19
+ /** The records one target holds at one location — the unit a ledger write replaces. */
20
+ function scopeOf(target) {
21
+ return {
22
+ targetId: target.targetId,
23
+ filePath: resolveOwnedPath(projectionTargetLocation(target)),
24
+ };
25
+ }
26
+ /**
27
+ * The same predicate the engine uses to hand a target its own claims. Two
28
+ * spellings of "which records belong to this target at this location" is how a
29
+ * remediation comes to replace a set the projection would have computed
30
+ * differently.
31
+ */
32
+ function claimsIn(records, scope) {
33
+ return records.filter((record) => record.targetId === scope.targetId &&
34
+ sameOwnedPath(resolveOwnedPath(record.filePath), scope.filePath));
35
+ }
36
+ async function readIfPresent(path) {
37
+ try {
38
+ return await readFile(path, 'utf8');
39
+ }
40
+ catch (error) {
41
+ const code = error?.code;
42
+ if (code === 'ENOENT' || code === 'ENOTDIR')
43
+ return undefined;
44
+ throw new BramboError(BRAMBO_ERROR_CODES.projectionNativeUnclaimable, `native config file '${path}' cannot be read (${code ?? 'unknown error'})`, { cause: error });
45
+ }
46
+ }
47
+ /**
48
+ * Containment, applied to a record BEFORE it is written and again as the last
49
+ * thing before the write — the M4.B rule, unchanged, on the one path that
50
+ * creates a delete authority.
51
+ *
52
+ * A record's `ownedPaths` are what a later run takes to `rm`. `claimMaterialised`
53
+ * proves each of them inside the root while it builds them; this is the copy
54
+ * that runs next to `ledger.update`, so a record reaching here by any other
55
+ * route is still checked. Both halves are deliberate: the guard nobody can show
56
+ * firing is the guard that is not there.
57
+ */
58
+ function escapingPath(record, scope) {
59
+ if (!sameOwnedPath(resolveOwnedPath(record.filePath), scope.filePath))
60
+ return record.filePath;
61
+ return (record.ownedPaths ?? []).find((item) => !isUnderRoot(resolveOwnedPath(item.path), scope.filePath))
62
+ ?.path;
63
+ }
64
+ /** Whether the REGISTRY still holds this id — what decides overwrite vs. removal. */
65
+ function registered(entries, entryId) {
66
+ return Object.values(entries).some((kind) => kind.some((entry) => entry.id === entryId));
67
+ }
68
+ async function claimFor(options, claimed) {
69
+ const { target, entryId, entries } = options;
70
+ if (target.kind === 'materialise') {
71
+ return await claimMaterialised(target, entries, claimed, entryId);
72
+ }
73
+ if (target.claim === undefined) {
74
+ return {
75
+ location: entryId,
76
+ byteLength: 0,
77
+ refusal: `projection target '${target.targetId}' cannot say what occupies '${entryId}', so brambo will not claim it`,
78
+ };
79
+ }
80
+ const nativeText = (await readIfPresent(target.filePath)) ?? '';
81
+ // A config target answers about ONE file and knows nothing of the registry, so
82
+ // the consequence half is decided here. `removedNext` is what turns "the next
83
+ // run rewrites this" into "the next run deletes it", and a user has to be told
84
+ // which one before the claim is written, not after.
85
+ return { ...target.claim({ nativeText, entryId }), removedNext: !registered(entries, entryId) };
86
+ }
87
+ /**
88
+ * What taking this claim will let a LATER run do — the sentence the frozen
89
+ * Always clause requires and the first shipped version did not carry.
90
+ *
91
+ * Adoption writes no vendor byte, and the first wording said exactly that and
92
+ * stopped: *"no byte of that file is read again, written or removed"*. True of
93
+ * the ACT and false of its consequence, and it was inverted relative to risk —
94
+ * the branch where the occupant is a file the USER wrote got the reassuring
95
+ * sentence, while the branch where the file was brambo's to begin with got the
96
+ * warning.
97
+ *
98
+ * It takes no re-claim/fresh-claim argument on purpose: the two branches differ
99
+ * in what brambo is DOING (taking ownership versus re-taking it), which `adopt`
100
+ * says itself, and not in what ownership then permits. Making the consequence
101
+ * depend on which branch you are in is exactly how the wording came to reassure
102
+ * on the dangerous one.
103
+ */
104
+ function consequenceOf(claim) {
105
+ const paths = claim.ownedPaths ?? [];
106
+ const authority = paths.length === 0
107
+ ? "brambo gains no authority to delete any FILE: a config claim covers one region inside the file and can never remove the file itself"
108
+ : `brambo gains authority to overwrite AND to REMOVE exactly these path(s) on a later run: ${paths.join(', ')}`;
109
+ // BOTH SPELLINGS, because this is the one site of its class where the scope is
110
+ // genuinely absent. `RemediationBase` carries none, and it is FR-29 public SDK
111
+ // surface — threading a CLI-grammar concept into a third-party type to render
112
+ // one sentence is a worse trade than naming two commands, and `doctor.ts`
113
+ // already sanctions the shape ("`brambo init` (or `brambo project init`) creates
114
+ // brambo's state here"). Driven before this: a project claim was told the next
115
+ // `brambo init` would replace the entry, and the hand-edited byte survived that
116
+ // command and died to `brambo project init`.
117
+ const projecting = '`brambo init` (or `brambo project init` for a project claim)';
118
+ const next = claim.removedNext === true
119
+ ? `the registry does not hold this entry, so the next ${projecting} REMOVES what this claim covers`
120
+ : // The SAME sentence on both branches, and that is the correction. The
121
+ // first version reassured on the fresh-claim branch — where the occupant
122
+ // is a file the USER wrote and the stakes are highest — and warned only
123
+ // on the re-claim one, where the file was brambo's to begin with. The
124
+ // wording was inverted relative to risk.
125
+ `the next ${projecting} REPLACES what is there with what the registry says`;
126
+ return `${authority}. Then ${next}. To keep what is there and have brambo stop tracking it, use 'release' instead`;
127
+ }
128
+ /**
129
+ * Brambo claims what is at its own location, exactly as it is now.
130
+ *
131
+ * This is the ownership TRANSFER decision AD-6 always implied and that no story
132
+ * had taken: ownership is a durable record brambo writes, so transferring it is
133
+ * writing that record — never inferring one from a path, and never widening what
134
+ * brambo would have written anyway. The claim's paths come from the TARGET's own
135
+ * plan, so a file the user put beside brambo's is not swept in and cannot later
136
+ * be removed on that authority.
137
+ *
138
+ * NOT A WRITE INTO A VENDOR FILE. Afterwards the location is brambo's, and the
139
+ * ordinary `brambo init` converges it — which is why there is no fourth verb that
140
+ * renders one entry outside the merge.
141
+ */
142
+ async function adopt(options, apply) {
143
+ const { target, entryId, ledger } = options;
144
+ const scope = scopeOf(target);
145
+ const deny = (message, code) => refused('adopt', target.targetId, entryId, scope.filePath, refusalOf(message, code));
146
+ if (typeof entryId !== 'string' || entryId === '') {
147
+ return deny('an adoption names one registry entry, and brambo was given none');
148
+ }
149
+ const read = await ledger.read();
150
+ if (read.state === 'unreadable') {
151
+ return deny(`projection ledger '${ledger.filePath}' cannot be read, so brambo will not add a claim to it; repair the ledger first`, BRAMBO_ERROR_CODES.projectionLedgerUnavailable);
152
+ }
153
+ const claimed = claimsIn(read.records, scope);
154
+ const claim = await claimFor(options, claimed);
155
+ if (claim.refusal !== undefined)
156
+ return deny(claim.refusal);
157
+ const record = claim.record;
158
+ if (record === undefined) {
159
+ return deny(`nothing occupies '${claim.location}' at '${scope.filePath}', so there is nothing for brambo to claim`);
160
+ }
161
+ const escaping = escapingPath(record, scope);
162
+ if (escaping !== undefined) {
163
+ return deny(`claiming '${entryId}' would record '${escaping}', which is outside '${scope.filePath}'; brambo will not claim a path it cannot prove it owns`);
164
+ }
165
+ const existing = claimed.find((candidate) => candidate.entryId === entryId);
166
+ if (existing !== undefined && existing.contentHash === record.contentHash) {
167
+ return {
168
+ remediation: 'adopt',
169
+ targetId: target.targetId,
170
+ entryId,
171
+ location: claim.location,
172
+ changes: [],
173
+ applied: apply,
174
+ };
175
+ }
176
+ const changes = [
177
+ ledgerChange('claim', ledger.filePath, claim.location, `${existing === undefined
178
+ ? `brambo takes ownership of the ${claim.byteLength} byte(s) now at '${claim.location}' in '${scope.filePath}'`
179
+ : `brambo re-takes ownership of '${claim.location}' in '${scope.filePath}' at its CURRENT ${claim.byteLength} byte(s), replacing the hash it held`}. Nothing in that location is written by THIS command. ${consequenceOf(claim)}`),
180
+ ];
181
+ if (!apply) {
182
+ return { remediation: 'adopt', targetId: target.targetId, entryId, location: claim.location, changes, applied: false };
183
+ }
184
+ // Entry-granular, and re-read inside the ledger's own queue. Handing back a
185
+ // whole scope built from the read above would resurrect every claim another
186
+ // writer legitimately dropped in between — brambo would then claim a path it
187
+ // does not own, which on a materialisation root is an authority to delete it.
188
+ await ledger.updateEntry(scope, entryId, record);
189
+ return { remediation: 'adopt', targetId: target.targetId, entryId, location: claim.location, changes, applied: true };
190
+ }
191
+ /**
192
+ * Brambo stops claiming a location. The file is not read, not written, not
193
+ * looked at — this verb performs no filesystem operation except the ledger write
194
+ * itself, which is what makes it the safe exit from a state a user wants to keep
195
+ * exactly as they left it.
196
+ */
197
+ async function release(options, apply) {
198
+ const { target, entryId, ledger } = options;
199
+ const scope = scopeOf(target);
200
+ const deny = (message, code) => refused('release', target.targetId, entryId, scope.filePath, refusalOf(message, code));
201
+ if (typeof entryId !== 'string' || entryId === '') {
202
+ return deny('a release names one registry entry, and brambo was given none');
203
+ }
204
+ const read = await ledger.read();
205
+ if (read.state === 'unreadable') {
206
+ return deny(`projection ledger '${ledger.filePath}' cannot be read, so brambo cannot tell which claim to drop; repair the ledger first`, BRAMBO_ERROR_CODES.projectionLedgerUnavailable);
207
+ }
208
+ const claimed = claimsIn(read.records, scope);
209
+ const dropped = claimed.filter((record) => record.entryId === entryId);
210
+ if (dropped.length === 0) {
211
+ return deny(`brambo holds no claim for '${entryId}' at '${scope.filePath}', so there is nothing to release`);
212
+ }
213
+ const changes = dropped.map((record) => ledgerChange('unclaim', ledger.filePath, record.nativeLocation, `brambo stops claiming '${record.nativeLocation}' in '${scope.filePath}'; whatever is there stays exactly as it is, and brambo will treat it as foreign until it is adopted again. Nothing on disk is removed by this or by any later run while the claim is gone`));
214
+ if (!apply) {
215
+ return { remediation: 'release', targetId: target.targetId, entryId, location: scope.filePath, changes, applied: false };
216
+ }
217
+ // Entry-granular for the same reason `adopt` is: a whole-scope replace built
218
+ // from the read above resurrects claims another writer dropped in between.
219
+ await ledger.updateEntry(scope, entryId, undefined);
220
+ return { remediation: 'release', targetId: target.targetId, entryId, location: scope.filePath, changes, applied: true };
221
+ }
222
+ /**
223
+ * Brambo rewrites its OWN ledger document to hold exactly the records it can
224
+ * still read.
225
+ *
226
+ * The one write in this file that does not merge, and the only exit from a
227
+ * ledger brambo carries and never repairs. Two shapes reach here and the
228
+ * description distinguishes them, because their consequences are not comparable:
229
+ * a document brambo can read but whose individual records are malformed loses
230
+ * only those records, while a document brambo cannot read AT ALL is replaced with
231
+ * an empty one — after which brambo claims nothing, and every entry it ever wrote
232
+ * reports as a foreign collision that `adopt` reclaims one at a time. That
233
+ * sentence is in the preview, before anything happens.
234
+ */
235
+ async function repair(options, apply) {
236
+ const { ledger } = options;
237
+ const read = await ledger.read();
238
+ const location = ledger.filePath;
239
+ const base = { remediation: 'repair', targetId: '', entryId: '', location };
240
+ if (read.state === 'absent') {
241
+ return { ...base, changes: [], applied: apply };
242
+ }
243
+ if (read.state === 'readable' && read.warnings.length === 0) {
244
+ return { ...base, changes: [], applied: apply };
245
+ }
246
+ // SALVAGED, not just readable. Dropping the RECORD throws away far more than
247
+ // the broken FIELD: measured on the binary, a record whose only damage was its
248
+ // `contentHash` still carried all four identity fields, so brambo knew exactly
249
+ // which bytes it covered — and dropping it left `brambo doctor` reporting
250
+ // NOTHING, `brambo remediate adopt` refusing with exit 1, and `brambo remove` +
251
+ // `brambo init` leaving the entry in the user's config permanently. A salvaged
252
+ // record carries a hash brambo cannot vouch for, so the entry reads as `edited`
253
+ // — a state with an exit — instead of vanishing.
254
+ const kept = [...read.records, ...read.salvaged];
255
+ const lost = read.salvaged.length;
256
+ const current = await stat(ledger.filePath).then((stats) => stats.size, () => 0);
257
+ const next = Buffer.byteLength(serialiseLedgerDocument(kept), 'utf8');
258
+ // Built separately so the sentence below stays on ONE SOURCE LINE:
259
+ // `printed-commands.test.ts` scans line by line, and a printed sentence it
260
+ // cannot see is a printed sentence nothing checks.
261
+ const unvouched = lost === 0
262
+ ? ''
263
+ : `, and ${String(lost)} it can address but no longer vouch for, which report as edited until they are adopted or released`;
264
+ // THE OLD SENTENCE PROMISED SOMETHING BRAMBO DID NOT DELIVER. It said the
265
+ // records it drops leave entries that "report as foreign collisions until
266
+ // they are adopted". Driven on the binary, that was FALSE for config entries:
267
+ // after the drop the diagnosis reported NOTHING, the adopt remediation refused
268
+ // with exit 1, and removing the entry and re-initialising left it in the
269
+ // user's config permanently. The user consented to a rewrite on a promise
270
+ // brambo could not keep. (Command names are spelled out rather than quoted
271
+ // here: the printed-command scanner reads comments too, and a quoted one
272
+ // wrapped across two lines is exactly what it refuses.)
273
+ const detail = read.state === 'unreadable'
274
+ ? `brambo cannot read any of '${ledger.filePath}' and will REPLACE it with an empty ledger: brambo then claims nothing at all, every entry it has written anywhere reports as a foreign collision, and each one has to be adopted back deliberately`
275
+ : `brambo rewrites '${ledger.filePath}' holding ${String(kept.length)} record(s): ${String(read.records.length)} it can still vouch for${unvouched}. Any record with no readable identity left is dropped, and whatever it claimed becomes yours to remove by hand`;
276
+ const changes = [ledgerChange('rewrite', ledger.filePath, ledger.filePath, detail, Math.abs(next - current))];
277
+ if (!apply)
278
+ return { ...base, changes, applied: false };
279
+ // The read that DECIDES the write happens inside the ledger's own queue.
280
+ // Reading outside it and handing the result to the one write in the system
281
+ // that does not merge destroyed any claim written in between — deterministic,
282
+ // in-process, first try, and exactly the loss `ledger.ts`'s class header
283
+ // describes. `adopt` and `release` have the same shape and were saved by
284
+ // `update`'s merge; this method has no backstop, which is what made it the one
285
+ // that lost data.
286
+ //
287
+ // ponytail: this closes the IN-CALL window and not the cross-invocation one. A
288
+ // user who read a preview in one process and applied in another can still be
289
+ // told "drop 1 record" and get an empty ledger, because the two calls share no
290
+ // handle — the act reports what it really did, but only after doing it.
291
+ // Upgrade path: a receipt on the preview that the act must match, which is the
292
+ // same mechanism `adopt` needs for the same reason (deferred-work.md).
293
+ // `salvaged` too, and from the IN-QUEUE read rather than the one above: the
294
+ // write must be decided by the document as it is at the moment of writing.
295
+ await ledger.rewriteAll(LEDGER_REPAIR_AUTHORITY, (inQueue) => [
296
+ ...inQueue.records,
297
+ ...inQueue.salvaged,
298
+ ]);
299
+ return { ...base, changes, applied: true };
300
+ }
301
+ /**
302
+ * Brambo removes its OWN prior output from a vendor file — correction-01 C6.
303
+ *
304
+ * The one verb here that writes a file brambo did not author, and the bytes it
305
+ * takes are provably brambo's own vocabulary: a reserved `$.brambo` key or a
306
+ * `# BEGIN brambo-managed` block that no executor reads and that, in Codex's
307
+ * case, makes the user's whole `config.toml` fail to load under a documented
308
+ * flag. The region comes from `scanLegacyBramboBlock`, the SAME function
309
+ * `brambo doctor` reports it with, so the preview cannot name a region other
310
+ * than the one removed.
311
+ *
312
+ * In the JSON family the result is re-PARSED before it lands: a remediation that
313
+ * left a user's configuration unparseable would be a worse state than the one it
314
+ * repaired. TOML gets no such check and that is the existing rule rather than an
315
+ * omission — brambo never parses foreign TOML (see the header of `formats.ts`),
316
+ * and the removed region is bounded by brambo's own two marker lines, so nothing
317
+ * outside the block it wrote is inside the span.
318
+ */
319
+ async function discard(options, apply) {
320
+ const { legacy } = options;
321
+ const filePath = resolveOwnedPath(legacy.filePath);
322
+ const root = resolveOwnedPath(legacy.rootPath);
323
+ const base = { remediation: 'discard', targetId: legacy.targetId, entryId: '', location: filePath };
324
+ const deny = (message) => refused('discard', legacy.targetId, '', filePath, refusalOf(message));
325
+ // Containment first, before the file is even opened, and against the REAL
326
+ // path. `resolve()` is string arithmetic: with `~/.claude` a junction into a
327
+ // dotfiles repository the write lands outside the very scope this refusal
328
+ // promises it will not, which is the sixth attack — the one M4.B's five cases
329
+ // do not cover, and the reason its removal rule checks links at every depth.
330
+ const real = await realPathOf(filePath);
331
+ const realRoot = await realPathOf(root);
332
+ if (!isUnderRoot(real, realRoot)) {
333
+ return deny(`'${filePath}' resolves to '${real}', which is outside '${realRoot}'; brambo will not rewrite a file beyond the scope it was given, whatever a link in the way says`);
334
+ }
335
+ const text = await readIfPresent(filePath);
336
+ if (text === undefined)
337
+ return { ...base, changes: [], applied: apply };
338
+ const snapshot = await statSnapshot(filePath);
339
+ const scan = scanLegacyBramboBlock(text, legacy.fileFormat);
340
+ if (scan.refusal !== undefined)
341
+ return deny(`${scan.refusal} in '${filePath}'`);
342
+ if (scan.block === undefined)
343
+ return { ...base, changes: [], applied: apply };
344
+ const next = text.slice(0, scan.block.start) + text.slice(scan.block.end);
345
+ if (legacy.fileFormat === 'jsonc') {
346
+ // JSONC-aware, not `JSON.parse`. The first version used `JSON.parse` and so
347
+ // SELF-DISABLED on exactly the inputs at risk — a file with a comment, a
348
+ // trailing comma or a byte-order mark never "originally parsed", and those
349
+ // are the same inputs `jsonRemovalSpan`'s whitespace walk handles worst (a
350
+ // `// note` between the member and its comma leaves a dangling comma the
351
+ // original did not have). Judged the way the vendor judges it, the guard
352
+ // fires on precisely those files.
353
+ if (parsesAsJsonc(text) && !parsesAsJsonc(next)) {
354
+ return deny(`removing brambo's own block from '${filePath}' would leave it unparseable, so brambo left it alone; remove the block by hand`);
355
+ }
356
+ }
357
+ const changes = [
358
+ {
359
+ subject: 'native-file',
360
+ action: 'rewrite',
361
+ path: filePath,
362
+ location: scan.block.detail,
363
+ byteDelta: Math.abs(Buffer.byteLength(next, 'utf8') - Buffer.byteLength(text, 'utf8')),
364
+ detail: `brambo removes ${scan.block.detail} from '${filePath}'; every other byte of the file is left exactly as it is`,
365
+ },
366
+ ];
367
+ if (!apply)
368
+ return { ...base, changes, applied: false };
369
+ // The same read-write race the engine defends against, restored after the
370
+ // argument for omitting it was falsified. The claim was that the failing case
371
+ // could not be built because the read and the write are one call; it can — the
372
+ // atomic writer performs six further filesystem round-trips after the read, and
373
+ // a competing write anywhere in that window is clobbered wholesale. The scope
374
+ // half was wrong too: Claude Code writes `~/.claude/settings.json` itself.
375
+ if (await hasFileChangedSince(filePath, snapshot)) {
376
+ return deny(`'${filePath}' was modified while brambo was reading it, so brambo would have overwritten that change; nothing was written`);
377
+ }
378
+ // A REFUSAL, NOT A THROW — the contract this file states about itself at the
379
+ // top: "A refusal is RETURNED, coded, not thrown". `discard` is the one
380
+ // remediation of four that writes a user's file, so this was the single state
381
+ // in the whole surface that left by a different door.
382
+ //
383
+ // And it did not escape uncoded, which would have been the ordinary hole. It
384
+ // escaped FALSELY coded: `describe()` duck-types `.code`, a Node
385
+ // `ErrnoException` has one, so a libuv errno rendered in brambo's coded-error
386
+ // position and the user read `EPERM: EPERM: ... rename '<file>.<uuid>.tmp'`.
387
+ // Doubled, exit 2 where every sibling refusal exits 1, and leaking the
388
+ // temporary path brambo writes through.
389
+ //
390
+ // BOTH shapes are caught deliberately. `atomicWriteText` can also throw a
391
+ // CODED `BRAMBO_PROJECTION_NATIVE_UNCLAIMABLE` from its own containment check,
392
+ // and catching only errnos would leave a coded error still escaping as a throw
393
+ // out of a function whose refusals are values.
394
+ //
395
+ // Modelled on `config-write.ts`, not on `ledger.ts`: both code their boundary,
396
+ // but the ledger speaks ledger vocabulary about brambo's OWN document, and this
397
+ // writes a VENDOR file — the same distinction `config-write.ts` already made
398
+ // as the first non-engine caller to reach this rule.
399
+ try {
400
+ await atomicWriteText(filePath, next);
401
+ }
402
+ catch (error) {
403
+ const detail = error instanceof BramboError ? error.code : error?.code;
404
+ return deny(`'${filePath}' could not be replaced (${detail ?? String(error)}) and brambo wrote nothing. A file made read-only was made read-only on purpose.`);
405
+ }
406
+ return { ...base, changes, applied: true };
407
+ }
408
+ /** File identity for the race check; `undefined` when the file is not there. */
409
+ async function statSnapshot(path) {
410
+ return await stat(path).then((stats) => ({ mtimeMs: stats.mtimeMs, size: stats.size }), () => undefined);
411
+ }
412
+ /**
413
+ * The real path, with links resolved. Falls back to the nearest existing
414
+ * ancestor's real path when the target does not exist, so an absent file is
415
+ * still judged against where it WOULD be created.
416
+ */
417
+ async function realPathOf(path) {
418
+ try {
419
+ return await realpath(path);
420
+ }
421
+ catch {
422
+ const parent = dirname(path);
423
+ return parent === path ? path : join(await realPathOf(parent), basename(path));
424
+ }
425
+ }
426
+ /**
427
+ * Whether jsonc-parser can read this document — comments, trailing commas and a
428
+ * byte-order mark included, which is what the vendors themselves accept.
429
+ */
430
+ function parsesAsJsonc(text) {
431
+ const errors = [];
432
+ const parsed = parseJsonc(text.startsWith('\uFEFF') ? text.slice(1) : text, errors, {
433
+ allowTrailingComma: true,
434
+ });
435
+ return errors.length === 0 && isRecord(parsed);
436
+ }
437
+ /**
438
+ * Performs — or, under `'inspect'`, describes — exactly one remediation.
439
+ *
440
+ * The caller names the verb and its subject; nothing else is touched, and
441
+ * nothing runs by default. A state brambo will not leave is reported as a coded
442
+ * refusal in the result rather than thrown, because under inspection "brambo will
443
+ * not do this, and here is why" is part of the description.
444
+ */
445
+ export async function runRemediation(options) {
446
+ // Every caller-controlled field read ONCE, before the first await — the same
447
+ // TOCTOU rule `runProjection` follows. An options object whose `mode` getter
448
+ // answered `'inspect'` now and `'apply'` on a second read would write on a
449
+ // machine the caller was promised would not be touched.
450
+ // `=== undefined`, not `??`: `null` is a value a caller PASSED, not an
451
+ // omission, and coalescing it into a default is the silent accept the shared
452
+ // validator exists to remove. Only a genuine omission becomes `'inspect'`.
453
+ const apply = resolveProjectionMode(options.mode === undefined ? 'inspect' : options.mode) === 'apply';
454
+ switch (options.remediation) {
455
+ case 'adopt':
456
+ return await adopt(options, apply);
457
+ case 'release':
458
+ return await release(options, apply);
459
+ case 'repair':
460
+ return await repair(options, apply);
461
+ case 'discard':
462
+ return await discard(options, apply);
463
+ default:
464
+ // Unreachable through the typed surface; a plain object reaching a
465
+ // published API must fail coded rather than silently do nothing.
466
+ throw new BramboError(BRAMBO_ERROR_CODES.projectionRemediationRefused, `remediation ${JSON.stringify(options.remediation)} is not recognised`);
467
+ }
468
+ }
@@ -0,0 +1,6 @@
1
+ import type { ProjectionConfigTarget } from '@skanl/brambo-contracts';
2
+ import type { ProjectionTargetTraits, TraitTargetOptions } from '../formats.ts';
3
+ export declare const CLAUDE_MCP_TARGET_ID = "claude-mcp";
4
+ export declare const CLAUDE_MCP_TRAITS: ProjectionTargetTraits;
5
+ export type ClaudeMcpTargetOptions = TraitTargetOptions;
6
+ export declare function createClaudeMcpTarget(options?: ClaudeMcpTargetOptions): ProjectionConfigTarget;
@@ -0,0 +1,36 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { createProjectionTargetFromTraits, readNativeCommand } from '../formats.js';
4
+ // Claude Code MCP target.
5
+ //
6
+ // Verified against Claude Code itself: `settings.json` has NO `mcpServers` key
7
+ // — the reason the previous build's output was inert. User-scope MCP servers
8
+ // live in `~/.claude.json`; project scope is the SAME `{mcpServers: {...}}`
9
+ // shape in `<project>/.mcp.json`, so it is this trait record with an injected
10
+ // filePath, not a second target.
11
+ //
12
+ // `~/.claude.json` is Claude's own state file, so brambo touches nothing in it
13
+ // but the `mcpServers` key. It is strict JSON, expressed as trait data.
14
+ export const CLAUDE_MCP_TARGET_ID = 'claude-mcp';
15
+ export const CLAUDE_MCP_TRAITS = {
16
+ targetId: CLAUDE_MCP_TARGET_ID,
17
+ fileFormat: 'jsonc',
18
+ defaultPath: join(homedir(), '.claude.json'),
19
+ strictJson: true,
20
+ mcpContainerKey: 'mcpServers',
21
+ renderMcpEntry: (entry) => ({ type: 'stdio', command: entry.command, args: entry.args }),
22
+ // The inverse, beside the renderer it inverts. A `type` other than `stdio` is
23
+ // a server with no command at all — an HTTP or SSE entry carries a `url` —
24
+ // and brambo says so instead of inventing one. Which keys count as CONSUMED is
25
+ // not spelled here: the reader derives that from `renderMcpEntry` above, so a
26
+ // key added to the renderer cannot be reported to a user as dropped.
27
+ readMcpEntry: (native) => native['type'] !== undefined && native['type'] !== 'stdio'
28
+ ? {
29
+ ok: false,
30
+ detail: `its 'type' is ${JSON.stringify(native['type'])} rather than 'stdio', and brambo projects a command with arguments`,
31
+ }
32
+ : readNativeCommand(native),
33
+ };
34
+ export function createClaudeMcpTarget(options = {}) {
35
+ return createProjectionTargetFromTraits(CLAUDE_MCP_TRAITS, options);
36
+ }
@@ -0,0 +1,6 @@
1
+ import type { ProjectionConfigTarget } from '@skanl/brambo-contracts';
2
+ import type { ProjectionTargetTraits, TraitTargetOptions } from '../formats.ts';
3
+ export declare const CODEX_CONFIG_TARGET_ID = "codex-config";
4
+ export declare const CODEX_CONFIG_TRAITS: ProjectionTargetTraits;
5
+ export type CodexConfigTargetOptions = TraitTargetOptions;
6
+ export declare function createCodexConfigTarget(options?: CodexConfigTargetOptions): ProjectionConfigTarget;
@@ -0,0 +1,27 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { createProjectionTargetFromTraits, readNativeCommand } from '../formats.js';
4
+ // Codex config.toml target.
5
+ //
6
+ // The key is `mcp_servers` — snake_case, the name `ConfigToml` actually
7
+ // declares. The previous build wrote `[mcpServers.<id>]` plus foreign sub-keys
8
+ // into `[tools]` and `[skills]`, which are REAL fixed structs; under the
9
+ // documented `--strict-config` flag that made the user's entire config.toml
10
+ // fail to load. Brambo now emits only `command`/`args` inside a table Codex
11
+ // owns, so strict mode has nothing to reject.
12
+ export const CODEX_CONFIG_TARGET_ID = 'codex-config';
13
+ export const CODEX_CONFIG_TRAITS = {
14
+ targetId: CODEX_CONFIG_TARGET_ID,
15
+ fileFormat: 'toml',
16
+ defaultPath: join(homedir(), '.codex', 'config.toml'),
17
+ mcpContainerKey: 'mcp_servers',
18
+ renderMcpEntry: (entry) => ({ command: entry.command, args: entry.args }),
19
+ // The inverse, beside the renderer it inverts. Codex's table carries no
20
+ // discriminator, so the reading is the shared one; anything else a user put in
21
+ // the table (`env`, `startup_timeout_sec`) is reported as dropped rather than
22
+ // silently discarded, against the key set the renderer above emits.
23
+ readMcpEntry: (native) => readNativeCommand(native),
24
+ };
25
+ export function createCodexConfigTarget(options = {}) {
26
+ return createProjectionTargetFromTraits(CODEX_CONFIG_TRAITS, options);
27
+ }
@@ -0,0 +1,6 @@
1
+ import type { ProjectionConfigTarget } from '@skanl/brambo-contracts';
2
+ import type { ProjectionTargetTraits, TraitTargetOptions } from '../formats.ts';
3
+ export declare const OPENCODE_CONFIG_TARGET_ID = "opencode-config";
4
+ export declare const OPENCODE_CONFIG_TRAITS: ProjectionTargetTraits;
5
+ export type OpenCodeConfigTargetOptions = TraitTargetOptions;
6
+ export declare function createOpenCodeConfigTarget(options?: OpenCodeConfigTargetOptions): ProjectionConfigTarget;
@@ -0,0 +1,58 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { createProjectionTargetFromTraits } from '../formats.js';
4
+ // OpenCode config target.
5
+ //
6
+ // `mcp.<id>` in opencode.json, shape `{type:'local', command: string[]}`.
7
+ // OpenCode's `command` IS the argv — there is no `args` field — so the split
8
+ // brambo keeps internally is joined here and nowhere else. opencode.json is
9
+ // JSONC-tolerant (comments and trailing commas are legal), so it reuses the
10
+ // shared splice WITHOUT the strict-JSON guard.
11
+ export const OPENCODE_CONFIG_TARGET_ID = 'opencode-config';
12
+ export const OPENCODE_CONFIG_TRAITS = {
13
+ targetId: OPENCODE_CONFIG_TARGET_ID,
14
+ fileFormat: 'jsonc',
15
+ defaultPath: join(homedir(), '.config', 'opencode', 'opencode.json'),
16
+ mcpContainerKey: 'mcp',
17
+ renderMcpEntry: (entry) => ({ type: 'local', command: [entry.command, ...entry.args] }),
18
+ // The UN-JOIN, and it lives here because the join does: `readNativeCommand`
19
+ // is the other two vendors' shared reading and would be the wrong answer for
20
+ // this one. A `remote` server carries a url and no argv at all, and an argv
21
+ // whose first element is not an executable name is a server with nothing to
22
+ // run — both are reported and skipped rather than turned into an entry no
23
+ // executor could start.
24
+ readMcpEntry: (native) => {
25
+ if (native['type'] !== undefined && native['type'] !== 'local') {
26
+ return {
27
+ ok: false,
28
+ detail: `its 'type' is ${JSON.stringify(native['type'])} rather than 'local', and brambo projects a command with arguments`,
29
+ };
30
+ }
31
+ const argv = native['command'];
32
+ if (argv === undefined || typeof argv === 'string') {
33
+ return {
34
+ ok: false,
35
+ detail: argv === undefined
36
+ ? "it declares no 'command', so there is nothing for brambo to run"
37
+ : "'command' is a string, and OpenCode spells a local server's whole argv as an array",
38
+ };
39
+ }
40
+ const [command, ...args] = argv;
41
+ if (command === undefined || command === '') {
42
+ // Two different documents, one honest sentence each: `[]` has no first
43
+ // element at all, and `['', 'x']` has one that names no executable.
44
+ // Saying "empty array" for the second described a file the user does not
45
+ // have, which is the kind of detail that sends someone to the wrong line.
46
+ return {
47
+ ok: false,
48
+ detail: command === undefined
49
+ ? "'command' is an empty array, so there is no command to run"
50
+ : "the first element of 'command' is empty, so the argv names no executable to run",
51
+ };
52
+ }
53
+ return { ok: true, command, args };
54
+ },
55
+ };
56
+ export function createOpenCodeConfigTarget(options = {}) {
57
+ return createProjectionTargetFromTraits(OPENCODE_CONFIG_TRAITS, options);
58
+ }
@@ -0,0 +1,22 @@
1
+ import type { ProjectionMaterialiseTarget } from '@skanl/brambo-contracts';
2
+ /** The file name every one of the three executors requires. */
3
+ export declare const SKILL_ENTRY_FILE = "SKILL.md";
4
+ export interface SkillsTargetTraits {
5
+ readonly targetId: string;
6
+ /** Absolute machine-scope root, used when the caller injects none. */
7
+ readonly defaultRoot: string;
8
+ }
9
+ export interface SkillsTargetOptions {
10
+ /** Overrides the trait record's defaultRoot (default roots are injectable). */
11
+ readonly rootPath?: string;
12
+ }
13
+ export declare const CLAUDE_SKILLS_TARGET_ID = "claude-skills";
14
+ export declare const CODEX_SKILLS_TARGET_ID = "codex-skills";
15
+ export declare const OPENCODE_SKILLS_TARGET_ID = "opencode-skills";
16
+ export declare const CLAUDE_SKILLS_TRAITS: SkillsTargetTraits;
17
+ export declare const CODEX_SKILLS_TRAITS: SkillsTargetTraits;
18
+ export declare const OPENCODE_SKILLS_TRAITS: SkillsTargetTraits;
19
+ export declare function createSkillsTargetFromTraits(traits: SkillsTargetTraits, options?: SkillsTargetOptions): ProjectionMaterialiseTarget;
20
+ export declare function createClaudeSkillsTarget(options?: SkillsTargetOptions): ProjectionMaterialiseTarget;
21
+ export declare function createCodexSkillsTarget(options?: SkillsTargetOptions): ProjectionMaterialiseTarget;
22
+ export declare function createOpenCodeSkillsTarget(options?: SkillsTargetOptions): ProjectionMaterialiseTarget;