@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.
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/dist/atomic-write.d.ts +3 -0
- package/dist/atomic-write.js +90 -0
- package/dist/config-write.d.ts +49 -0
- package/dist/config-write.js +146 -0
- package/dist/document-fault.d.ts +59 -0
- package/dist/document-fault.js +78 -0
- package/dist/engine.d.ts +69 -0
- package/dist/engine.js +235 -0
- package/dist/formats.d.ts +132 -0
- package/dist/formats.js +1301 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +15 -0
- package/dist/ledger.d.ts +193 -0
- package/dist/ledger.js +552 -0
- package/dist/materialise.d.ts +55 -0
- package/dist/materialise.js +620 -0
- package/dist/remediate.d.ts +65 -0
- package/dist/remediate.js +468 -0
- package/dist/targets/claude-mcp.d.ts +6 -0
- package/dist/targets/claude-mcp.js +36 -0
- package/dist/targets/codex-config.d.ts +6 -0
- package/dist/targets/codex-config.js +27 -0
- package/dist/targets/opencode-config.d.ts +6 -0
- package/dist/targets/opencode-config.js +58 -0
- package/dist/targets/skills.d.ts +22 -0
- package/dist/targets/skills.js +165 -0
- package/package.json +57 -0
|
@@ -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;
|