@skanl/brambo-environment 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/dist/doctor.js ADDED
@@ -0,0 +1,551 @@
1
+ import { access, constants, stat } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { dirname } from 'node:path';
4
+ import { BRAMBO_ERROR_CODES, REGISTRY_ENTRY_TYPES } from '@skanl/brambo-contracts';
5
+ import { noExecutorsDetected, projectCommandFor, runScope, scopeDirectory } from './init.js';
6
+ /**
7
+ * What `brambo init` WOULD do about each kind — and only ever what brambo can
8
+ * itself perform. A `Record` over the closed kind union, so a kind added without
9
+ * an answer here does not compile: "each finding carries what brambo would do
10
+ * about it" is a type error away from being false, rather than a promise.
11
+ */
12
+ export const RESOLUTION = {
13
+ edited: "brambo never overwrites an entry that changed since it wrote it; projecting again leaves your edit exactly as it is",
14
+ 'removed-by-user': 'brambo never re-adds an entry you deleted; projecting again leaves it absent',
15
+ 'foreign-collision': 'brambo never resolves a collision with content its ledger does not claim; projecting again leaves it untouched',
16
+ 'not-initialised': "`brambo init` (or `brambo project init`) creates brambo's state here; doctor creates nothing",
17
+ 'no-executor': 'brambo projects into configurations that already exist and creates none, so `brambo init` would write nothing here and exits 2',
18
+ 'registry-unreadable': 'brambo never replaces a registry document it cannot read; `brambo init` fails on it and projects nothing, so no entry is deleted from any vendor file',
19
+ 'registry-version-ahead': 'the document is not damaged: this build is simply older than the one that wrote it, and brambo refuses a format it does not speak rather than reading half of it. `brambo init` fails on it and projects nothing, so no entry is deleted from any vendor file and not one byte of the document is changed',
20
+ 'retired-type': 'brambo reads and lists the entry but hands it to no target, so projecting again neither writes nor removes anything for it; brambo never deletes a registry entry by itself, because removing one is a decision and a decision is yours',
21
+ 'ledger-damaged': 'brambo leaves the ledger exactly as it is and claims nothing it cannot read; until it is readable again brambo reports its own entries as foreign and touches none of them',
22
+ 'projection-warning': 'brambo surfaced this from the projection run and resolves none of it by itself; `brambo init` runs through the same condition',
23
+ 'out-of-date': 'projecting makes this location match the registry — for a skills root that can mean REMOVING a tree brambo wrote, not only writing one. Brambo checked the location is writable, which is weaker than a guarantee: an ACL, a mount option or another process holding it can still refuse the write, and on Windows that check sees only the read-only attribute',
24
+ 'not-writable': 'brambo cannot write here, so projecting fails on this location and changes nothing rather than half-applying it',
25
+ unprojectable: 'no target can express this entry, so projecting again changes nothing for it; it stays out of this configuration',
26
+ 'legacy-block': "`brambo remediate discard --executor <id>` removes exactly this block and leaves every other byte of the file alone; projecting again neither reads nor removes it. Where the detail says brambo will NOT take it, that is the reason, and brambo leaves the file untouched",
27
+ 'target-failed': 'projecting again fails the same way for this executor and leaves its file untouched; the other executors are unaffected',
28
+ 'worktree-leftover': 'projecting neither reads nor touches a worktree, so `brambo init` would do nothing about this; brambo never sweeps a leftover on its own, because removing a checkout on a run nobody asked to be destructive is exactly what a startup sweep would be',
29
+ };
30
+ /**
31
+ * The exit for every state brambo reports — TOTAL over {@link DiagnosisFindingKind},
32
+ * so a finding kind added without one does not compile.
33
+ *
34
+ * IT LIVES IN DOCTOR, beside the kinds, and not beside `remediate` — because the
35
+ * first version put it beside the capability, nothing outside the tests consumed
36
+ * it, and `brambo doctor` went on printing *"brambo never overwrites an entry that
37
+ * changed since it wrote it; projecting again leaves your edit exactly as it is"*
38
+ * for four of the five states this story gave an exit to. The trap was closed in
39
+ * the code and left open on the only surface a user reads. Every `resolution`
40
+ * below is now composed from this record, so the product cannot know an exit the
41
+ * report does not print.
42
+ */
43
+ export const FINDING_EXITS = {
44
+ edited: {
45
+ by: 'remediation',
46
+ remediations: ['adopt', 'release'],
47
+ detail: "`adopt` takes ownership of what is there now, after which `brambo init` replaces it with the registry's version — that is how you get brambo's version back, and it is a REPLACEMENT of your edit. `release` drops the claim and leaves your edit alone permanently",
48
+ },
49
+ 'removed-by-user': {
50
+ by: 'remediation',
51
+ remediations: ['release'],
52
+ detail: '`release` drops the claim, which makes the location free again, so the next `brambo init` writes the entry back. To keep it absent instead, the entry has to leave the registry, which is `brambo remove <type> <id>` (`brambo project remove <type> <id>` for a project-scope entry)',
53
+ },
54
+ 'foreign-collision': {
55
+ by: 'remediation',
56
+ remediations: ['adopt', 'release'],
57
+ detail: "`adopt` takes ownership of what occupies brambo's location, exactly as it is now — including brambo's OWN tree left unclaimed by a crash, and a tree that is only PARTLY there, which it claims as the subset that exists. It claims what is THERE and nothing else, so where the location holds nothing brambo can identify there is nothing to claim and `adopt` refuses rather than writing an empty claim. `release` is the exit where the collision comes from a claim brambo holds and cannot use. Where the detail says the VENDOR's document is ambiguous — a location declared twice, a container brambo cannot address — neither verb applies until that is fixed in the file itself, and brambo's own ledger is not involved",
58
+ },
59
+ 'ledger-damaged': {
60
+ by: 'remediation',
61
+ remediations: ['repair'],
62
+ detail: "`repair` rewrites brambo's own ledger to hold exactly the records it can read. It describes what it will drop before it drops it, and it touches no vendor file",
63
+ },
64
+ 'legacy-block': {
65
+ by: 'remediation',
66
+ remediations: ['discard'],
67
+ detail: "`discard` removes exactly the block a previous brambo build wrote and nothing else. Where the detail says brambo will NOT take it — markers it cannot bound, a key it cannot attribute — that is the reason, brambo leaves the file untouched, and the block has to be removed by hand",
68
+ },
69
+ 'not-initialised': {
70
+ by: 'command',
71
+ command: 'brambo init',
72
+ // ONLY WHAT THE OTHER HALF DID NOT SAY. `RESOLUTION['not-initialised']`
73
+ // already names the command and says doctor creates nothing; this said the
74
+ // same sentence again, and the user read both in one line.
75
+ detail: "it writes brambo's own state directory and registry document, and nothing into any executor's configuration",
76
+ },
77
+ 'out-of-date': {
78
+ by: 'command',
79
+ command: 'brambo init',
80
+ // THE SAME RULE `not-initialised` CARRIES ABOVE, applied to the sibling that
81
+ // missed it: only what the other half did not say. This used to end "that is
82
+ // `brambo init`", which the caller's scope override cannot reach — the
83
+ // override rewrites the "To leave this state" half and the detail is
84
+ // concatenated raw. Driven at project scope, one resolution said BOTH
85
+ // `brambo project init` and `brambo init`, and a user reading the tail runs
86
+ // the machine command for a project finding.
87
+ //
88
+ // It names no command now, because the half in front of it already prints
89
+ // the right one. What it adds instead is the thing `RESOLUTION` does not
90
+ // say: that this verdict cost the user nothing.
91
+ detail: 'doctor reached it by running the same merge projecting would and writing none of it, so the location is exactly as you left it',
92
+ },
93
+ 'no-executor': {
94
+ by: 'outside-brambo',
95
+ // The premise -- that brambo projects into configurations and creates none --
96
+ // belongs to `RESOLUTION` and was restated here for eleven words. This half
97
+ // carries the ACTION, which is the only part the other one cannot give.
98
+ detail: 'Run one of them at least once so a configuration exists to project into; nothing in brambo has to be fixed first',
99
+ },
100
+ 'registry-unreadable': {
101
+ by: 'outside-brambo',
102
+ // The refusal and its reason are `RESOLUTION`'s sentence; repeating them
103
+ // here cost eight words before the part a user acts on.
104
+ detail: "Repair or remove that document. Brambo's ownership ledger is a different file and is not involved, so nothing it already claims is at risk while you do",
105
+ },
106
+ // The ONE action, and it is the opposite of the sibling's above. The premise —
107
+ // the document is intact and brambo refuses it whole — is `RESOLUTION`'s
108
+ // sentence; this half carries only what the user does about it, which is the
109
+ // part that other one cannot give.
110
+ 'registry-version-ahead': {
111
+ by: 'outside-brambo',
112
+ detail: 'Install a brambo at least as new as the build that wrote it; the detail above names both versions. The document itself needs nothing done to it, and deleting it or editing it back into a shape this older build accepts is how the entries in it get lost',
113
+ },
114
+ 'not-writable': {
115
+ by: 'outside-brambo',
116
+ detail: 'brambo cannot grant itself permission; the location has to become writable',
117
+ },
118
+ // A COMMAND, and it has to be: retiring a word from the registry vocabulary
119
+ // while the entries written under it stay unreadable-or-unremovable is the
120
+ // dead end M4.C exists to abolish, reached this time by upgrading. `brambo
121
+ // remove` therefore accepts a retired type even though `brambo add` refuses
122
+ // one, and the finding's own detail names the exact spelling for this entry.
123
+ 'retired-type': {
124
+ by: 'command',
125
+ command: 'brambo remove <type> <id>',
126
+ detail: 'the entry is not damaged and the document is not corrupt — brambo simply no longer declares that word. `brambo remove` still accepts a retired type even though `brambo add` refuses one, which is how an entry written by an older build leaves without hand-editing the document',
127
+ },
128
+ // Reclassified OUT of `outside-brambo` by story M4.D, which is the SAFE
129
+ // direction: the M4.C ledger flagged reclassification INTO `outside-brambo` as
130
+ // the move that weakens the totality proof, because it lets a hard state be
131
+ // answered with a plausible sentence. This goes the other way — the sentence
132
+ // is replaced by a command the binary dispatches.
133
+ unprojectable: {
134
+ by: 'command',
135
+ command: 'brambo remove <type> <id>',
136
+ detail: 'this is informational and is never counted as a problem, so nothing has to be done about it. Nothing makes the entry PROJECTABLE — no target can express it — and what `brambo remove <type> <id>` changes is that it stops being reported, because the entry has left the registry. Use `brambo project remove <type> <id>` for an entry registered at a project scope',
137
+ },
138
+ 'target-failed': {
139
+ by: 'outside-brambo',
140
+ detail: 'the coded error on the finding names the cause; brambo leaves this executor untouched until it is addressed, and the others are unaffected',
141
+ },
142
+ 'projection-warning': {
143
+ by: 'outside-brambo',
144
+ detail: 'a condition the projection run surfaced with no more specific reading than its own code; brambo resolves none of it by itself',
145
+ },
146
+ // A COMMAND, and the same shape `retired-type` reached: the state is fully
147
+ // resolvable and the thing that resolves it is a verb the binary dispatches.
148
+ // Naming the removal here is also what keeps the report and the capability one
149
+ // answer -- the verb re-runs the identical removal the interrupted one was
150
+ // performing, so a leftover cannot be resolved by a second code path that
151
+ // reasons differently from the first.
152
+ 'worktree-leftover': {
153
+ by: 'command',
154
+ command: 'brambo workspace remove <id>',
155
+ detail: 'the removal is finished by running it again: the same checks, the same refusals, and the same retirement the interrupted one was performing. It removes only what brambo holds a record for, and it still refuses a tree with modified or untracked files or one whose commit no ref contains -- resuming an interrupted removal is not a licence to skip the checks. Run it with no id to resolve every leftover in the project at once',
156
+ },
157
+ };
158
+ /** Every kind this remediation is the named exit for. Derived, never listed. */
159
+ export function findingKindsFor(remediation) {
160
+ return Object.keys(FINDING_EXITS).filter((kind) => {
161
+ const exit = FINDING_EXITS[kind];
162
+ return exit.by === 'remediation' && exit.remediations.includes(remediation);
163
+ });
164
+ }
165
+ /**
166
+ * The exit, as the sentence a user reads. Composed from {@link FINDING_EXITS} so
167
+ * the command the product prints and the command the capability accepts are one
168
+ * string, and a remediation renamed upstream renames itself here.
169
+ */
170
+ function exitSentence(kind,
171
+ /**
172
+ * WHICH STATE THE EXIT HAS TO LEAVE. `FINDING_EXITS` is a
173
+ * `Record<DiagnosisFindingKind, FindingExit>` with ONE command per kind and no
174
+ * scope axis, so every exit was rendered in the machine grammar. Driven at
175
+ * project scope: `brambo project doctor` reported an `edited` finding and named
176
+ * `brambo remediate adopt`, which exits 1 with
177
+ * `BRAMBO_PROJECTION_REMEDIATION_REFUSED` — brambo never remediates a state it
178
+ * did not just report, and the machine scope reported none. The command that
179
+ * works was never printed.
180
+ *
181
+ * The verb is spelled twice rather than interpolated, because that is what
182
+ * makes the printed-command invariant see a real verb in each — the same shape
183
+ * `retired-type` arrived at 300 lines below, after it printed
184
+ * `brambo project remove` for a global entry.
185
+ */
186
+ scope, command) {
187
+ const exit = FINDING_EXITS[kind];
188
+ if (exit.by === 'remediation') {
189
+ return `To LEAVE this state, name it: ${exit.remediations
190
+ .map((remediation) => scope === 'machine' ? `\`brambo remediate ${remediation}\`` : `\`brambo project remediate ${remediation}\``)
191
+ .join(' or ')}. ${exit.detail}`;
192
+ }
193
+ // `command` is the SPELLING for this one finding, where the caller holds the
194
+ // concrete values. `FINDING_EXITS` can only declare the shape of the exit --
195
+ // `brambo remove <type> <id>` -- and printing a placeholder at a finding that
196
+ // already knows the type and the id makes the user translate a command brambo
197
+ // could have written out. `unprojectable` still prints the template, because
198
+ // its rows carry an entry id and no type; `retired-type` carries both.
199
+ if (exit.by === 'command')
200
+ return `To leave this state: \`${command ?? exit.command}\`. ${exit.detail}`;
201
+ return `Brambo cannot leave this state itself. ${exit.detail}`;
202
+ }
203
+ /**
204
+ * Which findings the exit code answers for. Total over the same union, for the
205
+ * same reason: a new kind has to be classified deliberately, not inherit
206
+ * "problem" from a default nobody chose.
207
+ */
208
+ const SEVERITY = {
209
+ edited: 'problem',
210
+ 'removed-by-user': 'problem',
211
+ 'foreign-collision': 'problem',
212
+ 'not-initialised': 'problem',
213
+ 'no-executor': 'problem',
214
+ 'registry-unreadable': 'problem',
215
+ // A PROBLEM, and the `unprojectable` test is what earns it: the light CAN be
216
+ // got back to green, by installing the build the document was already written
217
+ // for. It also names a condition OF THIS MACHINE — an older brambo in front of
218
+ // a newer document — rather than a standing architectural fact, so it fires on
219
+ // approximately no runs instead of on every one (spec M4.A's test, passed).
220
+ 'registry-version-ahead': 'problem',
221
+ // A PROBLEM rather than info, and the exit code is again the reason. The test
222
+ // is whether a user can get the light back to green: `unprojectable` is info
223
+ // because no target can express the entry and deleting one the user
224
+ // deliberately registered is not a fix. Here one command clears it
225
+ // for good, and the entry is one no current build can create — leaving it
226
+ // silent would hide the single visible consequence of an upgrade.
227
+ 'retired-type': 'problem',
228
+ 'ledger-damaged': 'problem',
229
+ 'projection-warning': 'problem',
230
+ 'out-of-date': 'problem',
231
+ 'not-writable': 'problem',
232
+ // The one INFO kind, and the exit code is the whole reason: no target can
233
+ // express the entry — a `skill` reaching a config target, a `skill` at project
234
+ // scope, an `mcp-server` with no command — so the only way exit 1 here could
235
+ // be got back to 0 is DELETING an entry the user deliberately registered,
236
+ // which is not a fix. Reported in full, never counted as diagnosed. (Story
237
+ // M4.D gave the kind a real exit, `brambo remove`; that changes how it is LEFT,
238
+ // not whether having it is wrong.)
239
+ unprojectable: 'info',
240
+ 'target-failed': 'problem',
241
+ // A PROBLEM, and the Codex case is why: a `# BEGIN brambo-managed` block puts
242
+ // foreign sub-keys inside `[tools]` and `[skills]`, so a documented
243
+ // `--strict-config` run fails to load the user's ENTIRE config.toml. It is
244
+ // also fully resolvable, which is what earns a non-zero exit — the exit code
245
+ // is a promise that the light can be got back to green.
246
+ 'legacy-block': 'problem',
247
+ // A PROBLEM: a half-removed worktree is a real state of brambo's own store,
248
+ // and one command clears it for good. The `unprojectable` test applies and
249
+ // passes — the light CAN be got back to green — so silence here would hide
250
+ // the one visible consequence of a run that was killed.
251
+ 'worktree-leftover': 'problem',
252
+ };
253
+ /**
254
+ * Every kind, derived from a record TypeScript proves total. Exported for the
255
+ * tests that partition the kinds by what a finding of that kind must name — a
256
+ * hand-written list there would fall behind the union silently.
257
+ */
258
+ export const DIAGNOSIS_FINDING_KINDS = Object.keys(RESOLUTION);
259
+ /**
260
+ * How a projection warning is read. Keyed on the warning's own CODE rather than
261
+ * assumed: the engine seeds warnings from the ledger today, and a second source
262
+ * added upstream would otherwise ship silently wearing the ledger's resolution
263
+ * text. An unmapped code says exactly that instead.
264
+ */
265
+ const WARNING_KIND = {
266
+ [BRAMBO_ERROR_CODES.projectionLedgerUnavailable]: 'ledger-damaged',
267
+ };
268
+ /**
269
+ * How a failed registry read is read, keyed on the store's own CODE — never on
270
+ * its message text (AD-7). Every code the store can raise that is NOT listed
271
+ * here is a document brambo could not read, which is what the fallback says.
272
+ */
273
+ const REGISTRY_ERROR_KIND = {
274
+ [BRAMBO_ERROR_CODES.registryStoreVersionMismatch]: 'registry-version-ahead',
275
+ };
276
+ /** True when at least one finding is something wrong — the non-zero condition. */
277
+ export function hasProblem(diagnosis) {
278
+ return diagnosis.findings.some((found) => found.severity === 'problem');
279
+ }
280
+ /**
281
+ * `finding`, bound to the scope being diagnosed.
282
+ *
283
+ * A CLOSURE rather than a parameter on all fourteen call sites, and that is the
284
+ * point: every one of them is inside `findingsFor`, which holds
285
+ * `diagnosis.scope`, so binding it once makes a scope-blind exit impossible to
286
+ * write here rather than possible-but-discouraged.
287
+ */
288
+ function findingIn(scope) {
289
+ return (kind, detail, about = {}, command) => buildFinding(scope, kind, detail, about, command);
290
+ }
291
+ function buildFinding(scope, kind, detail, about = {},
292
+ /** The concrete spelling of a `by: 'command'` exit; see {@link exitSentence}. */
293
+ command) {
294
+ // Two halves, always: what PROJECTING again would do (which for five kinds is
295
+ // "nothing, forever"), and how to LEAVE the state. Shipping only the first is
296
+ // what made this command tell users four remediable states were terminal.
297
+ return {
298
+ kind,
299
+ severity: SEVERITY[kind],
300
+ ...about,
301
+ detail,
302
+ resolution: `${RESOLUTION[kind]} — ${exitSentence(kind, scope, command)}`,
303
+ };
304
+ }
305
+ async function isFile(path) {
306
+ return await stat(path).then((stats) => stats.isFile(), () => false);
307
+ }
308
+ /**
309
+ * Whether `access(W_OK)` is granted at `path` or, when nothing is there yet, at
310
+ * its nearest EXISTING ancestor — three-valued, because "brambo could not
311
+ * determine" must not be reported as "brambo cannot write".
312
+ */
313
+ async function permitsWrite(path) {
314
+ let candidate = path;
315
+ for (;;) {
316
+ try {
317
+ await access(candidate, constants.W_OK);
318
+ return true;
319
+ }
320
+ catch (error) {
321
+ const code = error?.code;
322
+ // EPERM is the Windows spelling for a read-only file; EACCES the POSIX one.
323
+ if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS')
324
+ return false;
325
+ if (code !== 'ENOENT')
326
+ return undefined;
327
+ const parent = dirname(candidate);
328
+ if (parent === candidate)
329
+ return undefined;
330
+ candidate = parent;
331
+ }
332
+ }
333
+ }
334
+ /**
335
+ * Whether brambo could write at `path` — modelling the write brambo ACTUALLY
336
+ * performs, which is not `open(path, 'w')`. Every byte brambo lands goes through
337
+ * one atomic writer: a temp file created in the target's own directory, then
338
+ * renamed over the target.
339
+ *
340
+ * So the permission that decides the outcome is the DIRECTORY's, on both
341
+ * platforms — the temp file has to be created there, and rename() consults the
342
+ * containing directory, never the mode of the name it replaces. A 0444 target
343
+ * whose directory is writable is replaced without complaint on POSIX. Windows
344
+ * is the exception, and only Windows: rename over a file carrying the read-only
345
+ * attribute fails EPERM there, so that one check is guarded by the platform
346
+ * rather than applied to both. Both halves measured by execution (see the
347
+ * differential rows in `test/doctor.test.ts`).
348
+ *
349
+ * ponytail: `access(W_OK)` is advisory, not a guarantee — on Windows it sees the
350
+ * read-only attribute and not ACLs, and nothing survives another process taking
351
+ * the directory between the check and the write. That is why a positive answer
352
+ * only lets the `out-of-date` resolution say brambo CHECKED, never that it will
353
+ * succeed. Upgrade path: none worth having; a trial write is exactly the thing
354
+ * this command may not do.
355
+ *
356
+ * ponytail: a SYMLINKED target is probed at the link's own directory, not at the
357
+ * directory of the file `realpath` resolves to, which is where the writer lands
358
+ * it. Ceiling accepted deliberately: `realpath` is not one of the four fs verbs
359
+ * this package is allowed to import (`test/guard.test.ts`), and the answer is
360
+ * advisory either way. Upgrade path: the engine reports the resolved write
361
+ * target alongside the row, and this probes that.
362
+ */
363
+ async function writableLocation(path) {
364
+ if (process.platform === 'win32' && (await isFile(path))) {
365
+ const target = await permitsWrite(path);
366
+ if (target !== true)
367
+ return target;
368
+ }
369
+ return await permitsWrite(dirname(path));
370
+ }
371
+ /**
372
+ * Everything wrong with one scope, in the order a reader needs it: brambo's own
373
+ * state first (a machine with nothing initialised explains every other row),
374
+ * then per target in catalogue order.
375
+ */
376
+ async function findingsFor(diagnosis, registryError, retired, worktreeLeftovers) {
377
+ // Bound once, so no exit below can be rendered in the wrong grammar.
378
+ const finding = findingIn(diagnosis.scope);
379
+ const findings = [];
380
+ if (registryError !== undefined) {
381
+ findings.push(finding(REGISTRY_ERROR_KIND[registryError.code] ?? 'registry-unreadable', `${registryError.code}: ${registryError.message}`, { filePath: diagnosis.registryPath }));
382
+ }
383
+ else if (!(await isFile(diagnosis.registryPath))) {
384
+ // The REGISTRY DOCUMENT is the initialised signal, not brambo's directory:
385
+ // the ledger creates `<home>/.brambo` on its own first write, so one
386
+ // `brambo project init` anywhere would otherwise make the machine scope read
387
+ // as initialised forever — on the ordinary path, not an exotic one.
388
+ findings.push(finding('not-initialised', `brambo has no registry document at '${diagnosis.registryPath}'`, { filePath: diagnosis.registryPath },
389
+ // The override this parameter was built for. `FINDING_EXITS` holds
390
+ // `brambo init`, and at project scope that command exits 0 and leaves the
391
+ // project uninitialised — driven, then asserted by running what doctor
392
+ // printed and asking doctor again.
393
+ projectCommandFor(diagnosis.scope)));
394
+ }
395
+ for (const row of retired) {
396
+ // Both halves come from the ROW, never from the scope being diagnosed: the
397
+ // verb is the grammar that reaches the document the entry is actually in,
398
+ // and `filePath` is that document. `brambo project doctor` reads the global
399
+ // registry too, so deriving either from `diagnosis.scope` printed
400
+ // `brambo project remove <id>` for a global entry -- a command that exits 1,
401
+ // against a project document that does not hold it. The two spellings are
402
+ // separate literals so the printed-command invariant sees a real verb in
403
+ // each, and the concrete command goes to the EXIT sentence rather than into
404
+ // this detail, so the rendered line states the fact once and the command once.
405
+ const { entry } = row;
406
+ const removeCommand = row.scope === 'global'
407
+ ? `brambo remove ${entry.type} ${entry.id}`
408
+ : `brambo project remove ${entry.type} ${entry.id}`;
409
+ findings.push(finding('retired-type', `'${entry.id}' is a '${entry.type}' entry in the ${row.scope} registry, and '${entry.type}' is a type brambo no longer declares (it has ${REGISTRY_ENTRY_TYPES.join(', ')}); no target will ever take it`, { filePath: row.registryPath, entryId: entry.id }, removeCommand));
410
+ }
411
+ for (const leftover of worktreeLeftovers) {
412
+ // The command is SPELLED OUT with this leftover's own id rather than left as
413
+ // the `<id>` template the exit declares, for the reason `retired-type`'s
414
+ // block gives: a finding that already knows the id makes the user translate
415
+ // a command brambo could have written out.
416
+ findings.push(finding('worktree-leftover', leftover.detail, { filePath: leftover.path }, `brambo workspace remove ${leftover.id}`));
417
+ }
418
+ if (noExecutorsDetected(diagnosis)) {
419
+ // `brambo init` exits 2 on exactly this state, so a doctor that called it
420
+ // clean would certify an environment the very next command refuses.
421
+ findings.push(finding('no-executor', `no configuration was found for any executor brambo knows (${diagnosis.detected.map((detection) => detection.executorId).join(', ')})`));
422
+ }
423
+ for (const block of diagnosis.legacy) {
424
+ findings.push(finding('legacy-block', block.detail, {
425
+ executorId: block.executorId,
426
+ filePath: block.filePath,
427
+ }));
428
+ }
429
+ for (const warning of diagnosis.warnings) {
430
+ findings.push(finding(WARNING_KIND[warning.code] ?? 'projection-warning', `${warning.code}: ${warning.detail}`, {
431
+ ...(WARNING_KIND[warning.code] === 'ledger-damaged' ? { filePath: diagnosis.ledgerPath } : {}),
432
+ }));
433
+ }
434
+ // Brambo's own ledger is written for EVERY target a run produces a result for,
435
+ // changed or not, so an unwritable ledger fails a run that would otherwise be
436
+ // a no-op — and inspection cannot discover that by failing, because the write
437
+ // it would fail on is the one this mode skips.
438
+ if (diagnosis.targets.length + diagnosis.skills.length > 0 &&
439
+ (await writableLocation(diagnosis.ledgerPath)) === false) {
440
+ findings.push(finding('not-writable', `brambo's own ownership ledger '${diagnosis.ledgerPath}' is not writable, which fails every target of a run, not only the ones that would change`, { filePath: diagnosis.ledgerPath }));
441
+ }
442
+ // Config files first, then skills roots — the same catalogue order both
443
+ // arrays already carry, so one executor's two surfaces read together.
444
+ for (const target of [...diagnosis.targets, ...diagnosis.skills]) {
445
+ const tree = diagnosis.skills.includes(target);
446
+ const at = { executorId: target.executorId, filePath: target.filePath };
447
+ if (target.error !== undefined) {
448
+ findings.push(finding('target-failed', `${target.error.code}: ${target.error.message}`, at));
449
+ }
450
+ if (target.wouldWrite) {
451
+ // Reported as one or the other, never both: `out-of-date` promises a write
452
+ // brambo would perform, and at a location brambo cannot write that promise
453
+ // is false forever — which is the one thing this command may not say.
454
+ //
455
+ // A skills ROOT is probed as itself rather than through its parent: the
456
+ // writer creates the root when it is absent, so the nearest existing
457
+ // ancestor is what decides, and `permitsWrite` walks up to find it. A
458
+ // config file is probed through its DIRECTORY, because that is where the
459
+ // temp-file-then-rename actually lands.
460
+ const writable = tree ? await permitsWrite(target.filePath) : await writableLocation(target.filePath);
461
+ // ABSENT IS NOT DIFFERENT, and saying so is AD-5 applied to this command's
462
+ // own sentence. Driven before this: a project that had never been
463
+ // projected reported "the bytes in '<path>' differ from what projecting
464
+ // would produce" — a byte comparison brambo did not perform, about a file
465
+ // with no bytes — and the CONTROL, a file that really was there and really
466
+ // differed, produced a byte-identical sentence. Two states, one report,
467
+ // and the one brambo invented is the commoner of the two.
468
+ //
469
+ // `ProjectionResult` carries `written`/`byteDelta` and no presence, and
470
+ // threading one through would change a published contract for a sentence.
471
+ // A `stat` here instead: this branch already probes the filesystem for
472
+ // writability, so the answer costs one more syscall on a path it is
473
+ // holding anyway.
474
+ const present = await stat(target.filePath).then(() => true, () => false);
475
+ findings.push(writable === false
476
+ ? finding('not-writable', `brambo would rewrite '${target.filePath}' and the location is not writable`, at)
477
+ : finding('out-of-date', present
478
+ ? tree
479
+ ? `the skills brambo materialises under '${target.filePath}' differ from what projecting would produce`
480
+ : `the bytes in '${target.filePath}' differ from what projecting would produce`
481
+ : tree
482
+ ? `'${target.filePath}' does not exist yet, so nothing of what projecting would materialise is there`
483
+ : `'${target.filePath}' does not exist yet, so nothing of what projecting would write is there`, at,
484
+ // Same override, same reason as `not-initialised`: this exit is
485
+ // "project again", and at project scope `brambo init` is not that.
486
+ projectCommandFor(diagnosis.scope)));
487
+ }
488
+ for (const entry of target.drift) {
489
+ findings.push(finding(entry.kind, entry.detail, { ...at, location: entry.location, entryId: entry.entryId }));
490
+ }
491
+ for (const entry of target.unprojectable) {
492
+ findings.push(finding('unprojectable', entry.reason, { ...at, entryId: entry.entryId }));
493
+ }
494
+ }
495
+ return findings;
496
+ }
497
+ function toDiagnosisTarget(row) {
498
+ return {
499
+ executorId: row.executorId,
500
+ targetId: row.targetId,
501
+ filePath: row.filePath,
502
+ wouldWrite: row.changed,
503
+ drift: row.drift,
504
+ unprojectable: row.unprojectable,
505
+ ...(row.error === undefined ? {} : { error: row.error }),
506
+ };
507
+ }
508
+ /**
509
+ * Diagnoses one scope and writes nothing at all.
510
+ *
511
+ * A clean environment yields no findings; anything wrong yields at least one
512
+ * with `severity: 'problem'`, which is what lets a script branch on it.
513
+ * Reporting stops at the scope the caller named: doctor never goes looking for
514
+ * other projects brambo has bound.
515
+ */
516
+ export async function diagnose(options = {}) {
517
+ // Every field read ONCE, here, before the first await — the same TOCTOU rule
518
+ // `initMachine` follows, and it bites harder here: an accessor that answered
519
+ // with a temp directory now and the real home directory later would have the
520
+ // real one diagnosed under a promise that nothing would be touched.
521
+ const { homeDir = homedir(), projectDir, scope = 'machine', worktreeLeftovers = [] } = options;
522
+ const home = await scopeDirectory('the home directory', homeDir);
523
+ // Resolved and validated only when it is the scope being diagnosed. `brambo
524
+ // doctor` must not fail on a working directory it was never asked about — and
525
+ // `process.cwd()` THROWS when the process's directory has been deleted, which
526
+ // is exactly the kind of machine this command gets run on.
527
+ const root = scope === 'machine'
528
+ ? home
529
+ : await scopeDirectory('the project directory', projectDir ?? process.cwd());
530
+ // No log sink: nothing is invoked, so nothing is recorded as invoked.
531
+ const report = await runScope(scope, home, root, undefined, 'inspect');
532
+ const body = {
533
+ scope,
534
+ bramboDir: report.bramboDir,
535
+ registryPath: report.registryPath,
536
+ ledgerPath: report.ledgerPath,
537
+ entryCount: report.entryCount,
538
+ detected: report.detected,
539
+ // Named field by field rather than spread, so this payload's key order is
540
+ // authored and pinned instead of inherited from a rest object.
541
+ targets: report.targets.map(toDiagnosisTarget),
542
+ skills: report.skills.map(toDiagnosisTarget),
543
+ legacy: report.legacy,
544
+ skipped: report.skipped,
545
+ warnings: report.warnings,
546
+ };
547
+ return {
548
+ ...body,
549
+ findings: await findingsFor(body, report.registryError, report.retired, worktreeLeftovers),
550
+ };
551
+ }
@@ -0,0 +1,88 @@
1
+ import type { ProjectionConfigTarget, ProjectionMaterialiseTarget } from '@skanl/brambo-contracts';
2
+ import type { FileFormat, NativeMcpRead } from '@skanl/brambo-projection';
3
+ /**
4
+ * One filesystem location consulted for an executor, and what was found.
5
+ *
6
+ * `exists` is deliberately THREE-valued. Collapsing "brambo could not look" into
7
+ * "absent" makes the no-executor exit tell a user that nothing is installed
8
+ * when the truth is that a permission error, a dangling link or an ELOOP stopped
9
+ * the check — and it fails in the direction that hides a config brambo would
10
+ * otherwise have written to.
11
+ */
12
+ export interface EvidencePath {
13
+ readonly path: string;
14
+ /** true present · false definitively absent · undefined could not determine. */
15
+ readonly exists: boolean | undefined;
16
+ /** errno behind an `undefined` verdict; absent otherwise. */
17
+ readonly error?: string;
18
+ }
19
+ export interface ExecutorDetection {
20
+ readonly executorId: string;
21
+ /** The projection target this executor is served by, present or not. */
22
+ readonly targetId: string;
23
+ /** True only where an evidence path was OBSERVED to exist. Nothing else sets it. */
24
+ readonly present: boolean;
25
+ readonly evidence: readonly EvidencePath[];
26
+ }
27
+ export interface ExecutorProfile {
28
+ readonly executorId: string;
29
+ readonly targetId: string;
30
+ /** Consulted in order; any hit makes the executor present. */
31
+ readonly evidencePaths: (homeDir: string) => readonly string[];
32
+ readonly machineConfig: (homeDir: string) => string;
33
+ /** Absent when the vendor has no verified project-scope configuration. */
34
+ readonly projectConfig: ((projectDir: string) => string) | undefined;
35
+ readonly createTarget: (filePath: string) => ProjectionConfigTarget;
36
+ /**
37
+ * The READ direction of the same file `createTarget` writes into (M11.A D2).
38
+ *
39
+ * A closure over this executor's own trait record, exactly like `createTarget`
40
+ * above, rather than the trait record itself: `brambo ingest` needs the entries
41
+ * a vendor's document declares and nothing else about the format, and the
42
+ * document's format, container key and entry shape all stay where D1 put them.
43
+ * `undefined` from it means the file is absent, which is not an error (AD-5).
44
+ */
45
+ readonly readMcpEntries: (filePath: string) => Promise<NativeMcpRead | undefined>;
46
+ /**
47
+ * The skills root brambo has VERIFIED this executor reads, or `undefined`.
48
+ *
49
+ * Undefined is the honest answer, not a gap: an executor whose skills
50
+ * location brambo has not proven by running the real binary reports its skills
51
+ * unprojectable, exactly as before this story. There is no project-scope
52
+ * entry here at all for the same reason — materialising into a project scope
53
+ * is Ask-First in this story's Boundaries, so `brambo project init` reports
54
+ * skills unprojectable rather than inventing a second location.
55
+ */
56
+ readonly machineSkills: ((homeDir: string) => string) | undefined;
57
+ readonly skillsTargetId: string | undefined;
58
+ readonly createSkillsTarget: ((rootPath: string) => ProjectionMaterialiseTarget) | undefined;
59
+ /**
60
+ * Where a PREVIOUS brambo build wrote brambo's own vocabulary, and in which
61
+ * format — correction-01 C6.
62
+ *
63
+ * These are not locations brambo writes; they are locations brambo has to be
64
+ * able to CLEAN. Stories 2.2 and 2.3 put a reserved `$.brambo` key into the
65
+ * JSON family and a `# BEGIN brambo-managed` block into Codex's TOML, none of
66
+ * which any executor reads and the Codex one of which stops the user's whole
67
+ * `config.toml` from loading under `--strict-config`. A machine that ran one
68
+ * of those builds still has the litter, and the corrected build cannot produce
69
+ * it — so it is reported and removed rather than merged around.
70
+ *
71
+ * MACHINE SCOPE ONLY, and that is a measurement rather than an omission: the
72
+ * builds that wrote these had no project scope at all (`brambo project init`
73
+ * arrives in Story 2.7a, after correction-01), so there is no project-scope
74
+ * location that can hold one.
75
+ */
76
+ readonly legacyConfig: ((homeDir: string) => {
77
+ filePath: string;
78
+ fileFormat: FileFormat;
79
+ }) | undefined;
80
+ }
81
+ export declare const EXECUTOR_PROFILES: readonly ExecutorProfile[];
82
+ /**
83
+ * Every executor brambo knows about, whether it was found, and the exact paths
84
+ * consulted for each. Returns the FULL catalogue on purpose: a run that detects
85
+ * nothing has to be able to tell the user what was looked for and where, and a
86
+ * list that omitted the misses could not.
87
+ */
88
+ export declare function detectExecutors(homeDir: string): Promise<ExecutorDetection[]>;