@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/init.js ADDED
@@ -0,0 +1,641 @@
1
+ import { mkdir, stat } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+ import { BRAMBO_ERROR_CODES, BramboError, isRetiredEntryType, projectionTargetLocation, } from '@skanl/brambo-contracts';
5
+ import { createMemoryLogSink } from '@skanl/brambo-kernel';
6
+ import { ProjectionLedger, groupByKind, runProjection, runRemediation } from '@skanl/brambo-projection';
7
+ import { RegistryStore } from '@skanl/brambo-registry';
8
+ import { EXECUTOR_PROFILES, detectExecutors } from './executors.js';
9
+ // The first composed path brambo has: registry -> projection -> a real
10
+ // executor's real configuration file. Everything here is COMPOSITION. The
11
+ // projection engine, its targets and its ownership ledger are Story 2.8's and
12
+ // are used exactly as they ship: this package decides WHICH targets run and
13
+ // reports what happened, and it is the ledger — never this file — that decides
14
+ // what brambo is allowed to modify.
15
+ //
16
+ // Nothing in this package writes into a vendor's file. The only filesystem write
17
+ // it performs itself is `mkdir` of brambo's OWN directory; `test/guard.test.ts`
18
+ // pins that by asserting the whole of `src/` reaches the filesystem module for
19
+ // nothing beyond `mkdir` and `stat`.
20
+ //
21
+ // On the kernel: a projection write is not an executor action, so AD-10's
22
+ // interception pipeline is not forced in here. What IS taken from the kernel is
23
+ // the Story 1.6 record sink (NFR-4), because "what did brambo write into whose
24
+ // configuration" is the kind of thing that has to be reconstructable afterwards.
25
+ // The record shape is closed and has no free-form slot, so the sink carries THAT
26
+ // each target was projected and whether it succeeded, and the durable ledger
27
+ // carries what was written and where. Both halves are needed and neither is a
28
+ // substitute for the other.
29
+ //
30
+ // ponytail: one record per TARGET attempt, not per entry. The closed record
31
+ // shape has room for an event and a bounded subject and nothing else, so
32
+ // per-entry granularity has nowhere to go without a kernel record-shape change
33
+ // (LOG_RECORD_VERSION exists to carry one). Recorded in deferred-work.md.
34
+ /**
35
+ * Subject PREFIX every projection record is written under. The subject is
36
+ * `${PROJECTION_ACTION_ID}#${targetId}` — bounded by brambo's own constants, so
37
+ * it can never be rejected by the sink's identifier rules the way a file path
38
+ * (unbounded length, arbitrary characters) could be.
39
+ *
40
+ * Exported because a reader of the record stream needs the same string brambo
41
+ * wrote; match with `subject.startsWith(PROJECTION_ACTION_ID + '#')`.
42
+ */
43
+ export const PROJECTION_ACTION_ID = 'environment.projection';
44
+ /**
45
+ * True when no executor was found — the caller's non-zero-exit condition.
46
+ *
47
+ * Takes the DETECTION, not an `InitResult`: `brambo doctor` has to answer the
48
+ * same question about the same evidence, and two spellings of "did brambo find
49
+ * anything" is how the two commands come to disagree about one machine.
50
+ */
51
+ export function noExecutorsDetected(result) {
52
+ return result.detected.every((detection) => !detection.present);
53
+ }
54
+ /**
55
+ * Registry identity is `type:id`, but `ProjectionResult.skippedEntryIds` carries
56
+ * BARE ids, so one id can arrive matching several entries — a `tool` named `x`
57
+ * and an `mcp-server` named `x` are two different entries and only the first is
58
+ * skipped. Reporting both reasons would tell the user that an mcp-server which
59
+ * was projected successfully in this very run declares no command, and that
60
+ * reason is the one field a user acts on.
61
+ *
62
+ * So the candidates are narrowed by the property that actually makes an entry
63
+ * skippable, which is the same rule `collectMcpEntries` applies: any kind other
64
+ * than mcp-server, or an mcp-server with no command. Nothing else can be behind
65
+ * a skipped id.
66
+ *
67
+ * A target that KNOWS why says so itself, through `ProjectionResult.skipped`,
68
+ * and its reason wins wherever it is present — the skills target is the first
69
+ * one with reasons of its own ("this skill names a source brambo cannot read"),
70
+ * which no derivation from the registry entry could ever have produced.
71
+ *
72
+ * `skillsHandled` is the other half of the same correction. Once an executor has
73
+ * a verified skills root, "this executor has no native representation for a
74
+ * skill" is FALSE for that executor, so skill entries stop being candidates
75
+ * here; an id left with no candidate at all is dropped from the config row
76
+ * entirely rather than explained by a sentence that is no longer true.
77
+ */
78
+ function reasonUnprojectable(entries, executorId, skillsHandled) {
79
+ const candidates = entries.filter((entry) => (entry.type !== 'mcp-server' || entry.command === undefined) &&
80
+ !(skillsHandled && entry.type === 'skill'));
81
+ if (candidates.length === 0) {
82
+ // Only when this executor materialises skills can an id legitimately have no
83
+ // candidate left, and then the id belongs to the skills row, not this one.
84
+ if (skillsHandled && entries.length > 0)
85
+ return undefined;
86
+ // Otherwise no entry brambo handed over can explain this id: said plainly
87
+ // rather than guessed, because a reason brambo cannot establish is still a fact.
88
+ return `'${executorId}' reported this entry as unprojectable and the registry holds no entry that explains it`;
89
+ }
90
+ return candidates
91
+ .map((entry) => entry.type === 'mcp-server'
92
+ ? `the mcp-server entry declares no command, so there is nothing to render into '${executorId}'`
93
+ : // NO SPEC CITATION IN A SENTENCE A USER READS. This carried
94
+ // `(correction-01 C5)` and doctor printed it verbatim: an internal
95
+ // document name is a fact about brambo's own history, not about the
96
+ // reader's machine, and it is the one part of this line nobody can act
97
+ // on. The citation belongs where the RULE lives, which is the doc
98
+ // comment above and the `unprojectable` finding kind's own.
99
+ `'${executorId}' has no native representation for a ${entry.type} entry`)
100
+ .join('; ');
101
+ }
102
+ /**
103
+ * The per-entry reasons a target row carries: the target's own words where it
104
+ * had any, and the registry-derived sentence everywhere else.
105
+ */
106
+ function unprojectableFor(result, byId, executorId, skillsHandled) {
107
+ const stated = new Map((result?.skipped ?? []).map((skip) => [skip.entryId, skip.reason]));
108
+ const rows = [];
109
+ for (const entryId of result?.skippedEntryIds ?? []) {
110
+ const reason = stated.get(entryId) ?? reasonUnprojectable(byId.get(entryId) ?? [], executorId, skillsHandled);
111
+ if (reason !== undefined)
112
+ rows.push({ entryId, reason });
113
+ }
114
+ return rows;
115
+ }
116
+ function scopeUnavailable(detail, cause) {
117
+ return new BramboError(BRAMBO_ERROR_CODES.environmentScopeUnavailable, detail, { cause });
118
+ }
119
+ /**
120
+ * The trust boundary. `homeDir` and `projectDir` are caller-supplied paths that
121
+ * decide where brambo creates directories and which vendor files it writes, so
122
+ * every one of them is resolved ONCE here and rejected unless it already names a
123
+ * directory.
124
+ *
125
+ * Three failures this closes, all of them observed: `homeDir: ''` — which is
126
+ * exactly `process.env.HOME ?? ''` in a consumer — resolves to the CWD and
127
+ * relocates the machine scope into whatever directory the process happens to be
128
+ * in; `brambo project init ~/typo` built the whole missing tree and wrote a
129
+ * vendor config into it; and `brambo project init ~/repo/.git` would have done
130
+ * the same inside a git directory. Brambo BINDS a project, it does not create one.
131
+ */
132
+ export async function scopeDirectory(label, value) {
133
+ if (typeof value !== 'string' || value.trim() === '') {
134
+ throw scopeUnavailable(`${label} must be a non-empty path, but brambo was given ${JSON.stringify(value)}`);
135
+ }
136
+ const resolved = resolve(value);
137
+ let isDirectory;
138
+ try {
139
+ isDirectory = (await stat(resolved)).isDirectory();
140
+ }
141
+ catch (error) {
142
+ throw scopeUnavailable(`${label} '${resolved}' cannot be used (${error?.code ?? 'unknown error'}); brambo binds an existing directory and never creates one`, error);
143
+ }
144
+ if (!isDirectory) {
145
+ throw scopeUnavailable(`${label} '${resolved}' is not a directory`);
146
+ }
147
+ return resolved;
148
+ }
149
+ /**
150
+ * MACHINE SCOPE, AND `'inspect'` HARD-CODED. Two separate deliberate choices:
151
+ *
152
+ * Machine scope, because the builds that wrote these locations had no project
153
+ * scope at all — `brambo project init` arrives in Story 2.7a, after correction-01
154
+ * — so there is no project-scope file that can hold one.
155
+ *
156
+ * `'inspect'` written here rather than threaded from `runScope`'s own `mode`,
157
+ * because `brambo init` must never remove a legacy block: removal is a decision,
158
+ * and this story's whole rule is that a decision is a user's, named one at a
159
+ * time. Passing the caller's mode through would make `brambo init` silently
160
+ * rewrite a vendor file — pinned by a test in `test/remediate.test.ts`.
161
+ */
162
+ async function legacyFor(detected, homeDir) {
163
+ const present = new Set(detected.filter((detection) => detection.present).map((detection) => detection.executorId));
164
+ const rows = [];
165
+ for (const profile of EXECUTOR_PROFILES) {
166
+ if (!present.has(profile.executorId) || profile.legacyConfig === undefined)
167
+ continue;
168
+ const location = profile.legacyConfig(homeDir);
169
+ let outcome;
170
+ try {
171
+ outcome = await runRemediation({
172
+ remediation: 'discard',
173
+ legacy: { targetId: profile.targetId, rootPath: homeDir, ...location },
174
+ mode: 'inspect',
175
+ });
176
+ }
177
+ catch {
178
+ // A file brambo cannot READ is not evidence that litter is in it, and this
179
+ // finding's only resolution is a removal brambo would then be unable to
180
+ // perform — which is the false promise `brambo doctor`'s own Never clause
181
+ // forbids. Silence here, and the two of these three files that a target
182
+ // owns already report the read failure as `target-failed`.
183
+ continue;
184
+ }
185
+ const change = outcome.changes[0];
186
+ if (outcome.refusal !== undefined) {
187
+ rows.push({
188
+ executorId: profile.executorId,
189
+ targetId: profile.targetId,
190
+ filePath: location.filePath,
191
+ detail: outcome.refusal.message,
192
+ byteDelta: 0,
193
+ refusal: outcome.refusal,
194
+ });
195
+ continue;
196
+ }
197
+ if (change === undefined)
198
+ continue;
199
+ rows.push({
200
+ executorId: profile.executorId,
201
+ targetId: profile.targetId,
202
+ filePath: location.filePath,
203
+ detail: change.detail,
204
+ byteDelta: change.byteDelta,
205
+ });
206
+ }
207
+ return rows;
208
+ }
209
+ export function targetsFor(scope, detected, homeDir, projectDir) {
210
+ const planned = [];
211
+ const skills = [];
212
+ const skipped = [];
213
+ const present = new Set(detected.filter((detection) => detection.present).map((detection) => detection.executorId));
214
+ for (const profile of EXECUTOR_PROFILES) {
215
+ if (!present.has(profile.executorId))
216
+ continue;
217
+ const rootPath = scope === 'machine' ? profile.machineSkills?.(homeDir) : undefined;
218
+ if (rootPath !== undefined && profile.createSkillsTarget !== undefined) {
219
+ skills.push({ profile, target: profile.createSkillsTarget(rootPath) });
220
+ }
221
+ const filePath = scope === 'machine' ? profile.machineConfig(homeDir) : profile.projectConfig?.(projectDir);
222
+ if (filePath === undefined) {
223
+ skipped.push({
224
+ executorId: profile.executorId,
225
+ reason: `'${profile.executorId}' has no project-scope configuration file; brambo will not invent a location it does not read`,
226
+ });
227
+ continue;
228
+ }
229
+ planned.push({ profile, target: profile.createTarget(filePath) });
230
+ }
231
+ return { planned, skills, skipped };
232
+ }
233
+ /**
234
+ * Records without letting a broken sink break the run it is describing — the
235
+ * same containment rule the kernel applies to its own call sites. Brambo's
236
+ * subjects are bounded by construction, so the only way this throws is a hostile
237
+ * sink; the sink's own `dropped` counter remains the loss signal.
238
+ */
239
+ function recordProjection(log, event, targetId) {
240
+ // No sink means there is no action to record: `diagnose` passes none, because
241
+ // an `action.invoked` for a projection that deliberately never ran would put a
242
+ // projection brambo did not perform into the record stream NFR-4 exists to make
243
+ // reconstructable. `initMachine`/`initProject` always pass one.
244
+ if (log === undefined)
245
+ return;
246
+ try {
247
+ log.record({ event, subject: `${PROJECTION_ACTION_ID}#${targetId}` });
248
+ }
249
+ catch {
250
+ // Contained by contract; a diagnostic never aborts what it describes.
251
+ }
252
+ }
253
+ /**
254
+ * A thrown error flattened to the two fields a caller acts on. A live `Error`
255
+ * serialises to `{}` for every caller that prints the result.
256
+ */
257
+ function toTargetFailure(error) {
258
+ const code = error?.code;
259
+ return {
260
+ // Duck-typed on `code`, like the CLI's own `describe()`: the registry throws
261
+ // `BramboError`, but a code that arrived some other way is still the fact.
262
+ code: typeof code === 'string' && code.length > 0
263
+ ? code
264
+ : BRAMBO_ERROR_CODES.registryStoreUnavailable,
265
+ message: error instanceof Error ? error.message : String(error),
266
+ };
267
+ }
268
+ /** Brambo's own state directory for a scope root. One spelling, two callers. */
269
+ function bramboDirOf(root) {
270
+ return join(root, '.brambo');
271
+ }
272
+ export function storeFor(scope, homeDir, projectDir) {
273
+ return new RegistryStore(scope === 'machine' ? { homeDir } : { homeDir, projectDir });
274
+ }
275
+ /**
276
+ * The command that projects one scope. One spelling, and `doctor.ts` is now a
277
+ * caller too: it named `brambo init` as the exit from a PROJECT's
278
+ * `not-initialised` and `out-of-date`, and that command exits 0 leaving the
279
+ * project exactly as it was.
280
+ */
281
+ export function projectCommandFor(scope) {
282
+ return scope === 'machine' ? 'brambo init' : 'brambo project init';
283
+ }
284
+ async function takenBy(entry, detected, scope, homeDir, projectDir) {
285
+ const { planned, skills } = targetsFor(scope, detected, homeDir, projectDir);
286
+ const byKind = groupByKind([entry]);
287
+ const executorIds = [];
288
+ // DEDUPED, and that was measured rather than tidiness. The derived sentence
289
+ // names the EXECUTOR, while targets are per-executor-PLUS-surface: with a
290
+ // `.claude.json` and a `.claude/` skills root both present, the same executor
291
+ // is asked twice and a naive collection printed the identical sentence twice.
292
+ // Doctor's rows are per TARGET and can carry two; `EntryDelivery.reasons` is a
293
+ // flat per-executor list and cannot.
294
+ const reasons = new Set();
295
+ // The executors whose skills root brambo VERIFIED at this scope, which is what
296
+ // makes "this executor has no native representation for a skill" false for
297
+ // them -- the same input `unprojectableFor` gives `reasonUnprojectable`.
298
+ const skillsHandled = new Set(skills.map(({ profile }) => profile.executorId));
299
+ /**
300
+ * A target skipped this entry and said nothing. Derive the reason the way
301
+ * doctor already does, from the ONE producer, rather than reporting an absence
302
+ * brambo never measured.
303
+ *
304
+ * `no target said why` at `registry-commands.ts` used to fire here for every
305
+ * mcp-server with no command -- while `brambo doctor`, on the same fixture,
306
+ * printed the exact sentence. The verb that CREATED the state sent the user to
307
+ * a second command for an answer that lives in this module.
308
+ */
309
+ const explain = (executorId) => {
310
+ const derived = reasonUnprojectable([entry], executorId, skillsHandled.has(executorId));
311
+ // `undefined` is the skills-handled case: the id belongs to another row and
312
+ // this one has nothing true to say. Left unsaid, so the caller's fallback
313
+ // still fires -- an absence brambo DID measure.
314
+ if (derived !== undefined)
315
+ reasons.add(derived);
316
+ };
317
+ for (const { profile, target } of [...planned, ...skills]) {
318
+ if (target.kind === 'materialise') {
319
+ const plan = await target.plan({ entries: byKind, records: [], rootPath: target.rootPath });
320
+ if (plan.entries.some((row) => row.entryId === entry.id)) {
321
+ executorIds.push(profile.executorId);
322
+ continue;
323
+ }
324
+ // The target's own words win where it had any -- it knows things no
325
+ // derivation from a registry entry could produce, like a skill whose
326
+ // source cannot be read. The prefix stays HERE and only here, because that
327
+ // text was authored by the target and may not name the executor; the
328
+ // derived sentence already does.
329
+ const own = (plan.skipped ?? []).filter((skip) => skip.entryId === entry.id);
330
+ if (own.length > 0) {
331
+ for (const skip of own)
332
+ reasons.add(`${profile.executorId}: ${skip.reason}`);
333
+ continue;
334
+ }
335
+ // AND THIS BRANCH IS THE ONE THE FIRST DIAGNOSIS MISSED. A skills target
336
+ // iterates `entries.skill` alone, so an mcp-server is dropped by it without
337
+ // ever producing a `ProjectionSkip`: for that entry BOTH target kinds were
338
+ // silent, not just merge.
339
+ explain(profile.executorId);
340
+ continue;
341
+ }
342
+ const outcome = await target.merge({ entries: byKind, records: [], nativeText: '' });
343
+ if (!(outcome.skippedEntryIds ?? []).includes(entry.id)) {
344
+ executorIds.push(profile.executorId);
345
+ continue;
346
+ }
347
+ // A merge outcome carries ids alone (`ProjectionMergeOutcome.skippedEntryIds`),
348
+ // which `contracts/src/projection.ts` names as the defect that forces this
349
+ // derivation. It is EXACT rather than approximate today: `collectMcpEntries`
350
+ // skips exactly the two shapes `reasonUnprojectable` derives, and anything
351
+ // else falls through to its honest "the registry holds no entry that
352
+ // explains it".
353
+ explain(profile.executorId);
354
+ }
355
+ return { executorIds, reasons: [...reasons] };
356
+ }
357
+ /**
358
+ * {@link EntryDelivery} for one scope, with the OTHER scope answered too
359
+ * whenever this one takes the entry nowhere.
360
+ *
361
+ * Contained: a target that throws while being asked leaves `undetermined` set
362
+ * rather than propagating, because the caller has already registered the entry
363
+ * and a message is not worth losing a completed write over.
364
+ */
365
+ export async function deliveryFor(entry, scope, homeDir, projectDir) {
366
+ const command = projectCommandFor(scope);
367
+ let detected;
368
+ try {
369
+ detected = await detectExecutors(homeDir);
370
+ const here = await takenBy(entry, detected, scope, homeDir, projectDir);
371
+ if (here.executorIds.length > 0) {
372
+ return { scope, command, executorIds: here.executorIds, reasons: here.reasons };
373
+ }
374
+ const otherScope = scope === 'machine' ? 'project' : 'machine';
375
+ const there = await takenBy(entry, detected, otherScope, homeDir, projectDir);
376
+ return {
377
+ scope,
378
+ command,
379
+ executorIds: [],
380
+ reasons: here.reasons,
381
+ ...(there.executorIds.length === 0
382
+ ? {}
383
+ : {
384
+ elsewhere: {
385
+ scope: otherScope,
386
+ command: projectCommandFor(otherScope),
387
+ executorIds: there.executorIds,
388
+ reasons: there.reasons,
389
+ },
390
+ }),
391
+ };
392
+ }
393
+ catch (error) {
394
+ const failure = toTargetFailure(error);
395
+ return {
396
+ scope,
397
+ command,
398
+ executorIds: [],
399
+ reasons: [],
400
+ undetermined: `${failure.code}: ${failure.message}`,
401
+ };
402
+ }
403
+ }
404
+ /**
405
+ * The ONLY two writes into brambo's own state that `brambo init` performs: its
406
+ * directory, and the registry store document. They live here — OUTSIDE
407
+ * `runScope`, which `init` and `diagnose` share — so the read-only caller cannot
408
+ * reach them by construction rather than by remembering not to.
409
+ * `test/doctor.test.ts` proves the consequence at byte level.
410
+ */
411
+ async function prepareScope(scope, root, homeDir, projectDir) {
412
+ const bramboDir = bramboDirOf(root);
413
+ // Brambo's own directory, created by brambo. `recursive` here means "tolerate an
414
+ // existing directory", not "build a tree": the parent was validated as an
415
+ // existing directory above, so the only thing this can still meet is `.brambo`
416
+ // occupied by a FILE, which arrives as a bare doubled EEXIST naming nothing.
417
+ try {
418
+ await mkdir(bramboDir, { recursive: true });
419
+ }
420
+ catch (error) {
421
+ throw scopeUnavailable(`brambo's own state directory '${bramboDir}' cannot be created (${error?.code ?? 'unknown error'})`, error);
422
+ }
423
+ const store = storeFor(scope, homeDir, projectDir);
424
+ try {
425
+ // Materialised through the store itself, so the document's version and shape
426
+ // stay the registry's to define. A corrupt store fails coded here rather than
427
+ // being silently replaced.
428
+ return await store.ensure(scope === 'machine' ? 'global' : 'project');
429
+ }
430
+ finally {
431
+ await store.dispose();
432
+ }
433
+ }
434
+ /**
435
+ * Registry -> detection -> projection engine, for one scope. `brambo init` and
436
+ * `brambo doctor` are THIS function under the two projection modes and nothing
437
+ * else: same entries, same detection, same targets, same engine call, same drift
438
+ * classification. Two code paths could disagree about what applying would do,
439
+ * and they would disagree exactly when a user is trying to fix something.
440
+ *
441
+ * Every line below either reads, or writes through the projection engine — which
442
+ * under `'inspect'` writes nothing at all.
443
+ */
444
+ export async function runScope(scope, homeDir, projectDir, log, mode) {
445
+ const store = storeFor(scope, homeDir, projectDir);
446
+ const registryPath = store.storePath(scope === 'machine' ? 'global' : 'project');
447
+ let entries = [];
448
+ const retired = [];
449
+ let registryError;
450
+ try {
451
+ entries = await store.list();
452
+ // Read PER SCOPE as well, rather than filtered out of the merged view above:
453
+ // the merge keeps one row per `type:id` and DROPS the scope that produced
454
+ // it, which is exactly the fact every message about a retired entry needs.
455
+ // Same scopes, in the same order, that `brambo list` walks under each
456
+ // grammar — so `brambo project doctor` reports a global entry against the
457
+ // global document, with the global verb.
458
+ const retiredScopes = scope === 'machine' ? ['global'] : ['global', 'project'];
459
+ for (const candidateScope of retiredScopes) {
460
+ for (const entry of await store.list(candidateScope)) {
461
+ if (!isRetiredEntryType(entry.type))
462
+ continue;
463
+ retired.push({ entry, scope: candidateScope, registryPath: store.storePath(candidateScope) });
464
+ }
465
+ }
466
+ }
467
+ catch (error) {
468
+ // Brambo's OWN two state files, classified the same way. A corrupt ledger is
469
+ // already a reported finding; a corrupt registry throwing out of the command
470
+ // whose job is diagnosing brambo's state would be the opposite treatment for
471
+ // the same class of fault. `'apply'` still throws: `brambo init` must not
472
+ // project against a registry it cannot read.
473
+ if (mode === 'apply')
474
+ throw error;
475
+ registryError = toTargetFailure(error);
476
+ }
477
+ finally {
478
+ await store.dispose();
479
+ }
480
+ const detected = await detectExecutors(homeDir);
481
+ const { planned, skills, skipped } = targetsFor(scope, detected, homeDir, projectDir);
482
+ const ledger = new ProjectionLedger({ homeDir });
483
+ if (registryError !== undefined) {
484
+ // No engine call at all: every per-target verdict is derived from the
485
+ // registry, so reporting rows computed against an empty one would tell the
486
+ // user brambo is about to delete entries it simply could not read.
487
+ return {
488
+ bramboDir: bramboDirOf(scope === 'machine' ? homeDir : projectDir),
489
+ registryPath,
490
+ ledgerPath: ledger.filePath,
491
+ entryCount: 0,
492
+ detected,
493
+ targets: [],
494
+ skills: [],
495
+ skipped,
496
+ legacy: scope === 'machine' && mode === 'inspect' ? await legacyFor(detected, homeDir) : [],
497
+ warnings: [],
498
+ registryError,
499
+ retired: [],
500
+ };
501
+ }
502
+ const everyTarget = [...planned, ...skills];
503
+ for (const { target } of everyTarget)
504
+ recordProjection(log, 'action.invoked', target.targetId);
505
+ const run = await runProjection({
506
+ entries: groupByKind(entries),
507
+ targets: everyTarget.map((plan) => plan.target),
508
+ ledger,
509
+ mode,
510
+ });
511
+ // Built from the entries the engine was actually GIVEN: a retired entry is
512
+ // never handed to a target, so it can never be the explanation for an id a
513
+ // target skipped — and an id spelled the same in both vocabularies would
514
+ // otherwise be explained by the entry nobody projected, naming a type brambo no
515
+ // longer declares. `test/init.test.ts` forces exactly that collision.
516
+ const byId = new Map();
517
+ for (const entry of entries) {
518
+ if (isRetiredEntryType(entry.type))
519
+ continue;
520
+ byId.set(entry.id, [...(byId.get(entry.id) ?? []), entry]);
521
+ }
522
+ const results = new Map(run.results.map((result) => [result.targetId, result]));
523
+ const failures = new Map(run.failures.map((failure) => [failure.targetId, failure]));
524
+ // Walked over `planned`, which is catalogue order, so one executor failing can
525
+ // never reshuffle the report — and so a target that BOTH wrote and then failed
526
+ // its ledger update yields ONE row carrying both facts. Two rows, or a row
527
+ // hardcoding `changed: false` for a failure, is how brambo came to report
528
+ // `written: false` for bytes it had already landed.
529
+ const materialising = new Set(skills.map(({ profile }) => profile.executorId));
530
+ const rowFor = ({ profile, target }) => {
531
+ const result = results.get(target.targetId);
532
+ const failure = failures.get(target.targetId);
533
+ recordProjection(log, failure === undefined ? 'action.completed' : 'action.failed', target.targetId);
534
+ return {
535
+ executorId: profile.executorId,
536
+ targetId: target.targetId,
537
+ filePath: projectionTargetLocation(target),
538
+ changed: result?.written ?? false,
539
+ drift: result?.drift ?? [],
540
+ // A config row stops claiming an executor cannot express a SKILL once that
541
+ // executor has a verified skills root: the skills row below is the
542
+ // authority for those ids, and two rows answering for one entry is how a
543
+ // user is told a skill was both materialised and impossible.
544
+ //
545
+ // KNOWN LOSS OF GRANULARITY, and it is deliberate. When the skills target
546
+ // FAILS outright, its row carries the coded error and an empty
547
+ // `unprojectable` list, while this row has already dropped those ids — so
548
+ // no row names the individual skills. The alternative is worse: the config
549
+ // row's only sentence is "this executor has no native representation for a
550
+ // skill", which is false for an executor that has a verified root and
551
+ // merely could not be written to this run. A per-target failure is loud in
552
+ // its own right (`error` on the row, `target-failed` in doctor, non-zero
553
+ // exit), so what is lost is which entries it covered, not the failure.
554
+ unprojectable: unprojectableFor(result, byId, profile.executorId, target.kind !== 'materialise' && materialising.has(profile.executorId)),
555
+ ...(failure === undefined
556
+ ? {}
557
+ : { error: { code: failure.error.code, message: failure.error.message } }),
558
+ };
559
+ };
560
+ const targets = planned.map(rowFor);
561
+ const skillRows = skills.map(rowFor);
562
+ return {
563
+ bramboDir: bramboDirOf(scope === 'machine' ? homeDir : projectDir),
564
+ registryPath,
565
+ ledgerPath: ledger.filePath,
566
+ entryCount: entries.length,
567
+ detected,
568
+ targets,
569
+ skills: skillRows,
570
+ skipped,
571
+ legacy: scope === 'machine' && mode === 'inspect' ? await legacyFor(detected, homeDir) : [],
572
+ warnings: run.warnings,
573
+ retired,
574
+ };
575
+ }
576
+ function toTargetProjection(row) {
577
+ // Named field by field, NOT spread. A spread rebuilds the row in the spread's
578
+ // order and silently moved `written` from index 3 to last, after `error`, with
579
+ // both suites green — and this is a documented payload a caller prints.
580
+ // `test/init.test.ts` pins the order.
581
+ return {
582
+ executorId: row.executorId,
583
+ targetId: row.targetId,
584
+ filePath: row.filePath,
585
+ written: row.changed,
586
+ drift: row.drift,
587
+ unprojectable: row.unprojectable,
588
+ // A row carrying no `error` key keeps carrying none: an `error: undefined`
589
+ // in the payload reads to every JSON consumer as a field brambo decided to
590
+ // say nothing about.
591
+ ...(row.error === undefined ? {} : { error: row.error }),
592
+ };
593
+ }
594
+ function toInitResult(scope, registryPath, report) {
595
+ return {
596
+ scope,
597
+ bramboDir: report.bramboDir,
598
+ registryPath,
599
+ ledgerPath: report.ledgerPath,
600
+ entryCount: report.entryCount,
601
+ detected: report.detected,
602
+ targets: report.targets.map(toTargetProjection),
603
+ skills: report.skills.map(toTargetProjection),
604
+ skipped: report.skipped,
605
+ warnings: report.warnings,
606
+ };
607
+ }
608
+ /**
609
+ * Prepares this machine: brambo's own directory and registry store exist
610
+ * afterwards, and the global registry is projected into every detected
611
+ * executor's own machine-scope configuration.
612
+ *
613
+ * Idempotent. A second run over an unchanged registry writes no vendor byte and
614
+ * reports every target as unchanged.
615
+ */
616
+ export async function initMachine(options = {}) {
617
+ // Every field read ONCE, here, before the first await. A later read of a
618
+ // caller-controlled object is a TOCTOU hole: an accessor that answers with a
619
+ // temp directory now and the real home directory later would get the real one
620
+ // projected into.
621
+ const { homeDir = homedir(), log } = options;
622
+ const home = await scopeDirectory('the home directory', homeDir);
623
+ const registryPath = await prepareScope('machine', home, home, home);
624
+ const report = await runScope('machine', home, home, log ?? createMemoryLogSink(), 'apply');
625
+ return toInitResult('machine', registryPath, report);
626
+ }
627
+ /**
628
+ * Binds a project: brambo's own directory and project registry store exist under
629
+ * it afterwards, and the registry it can see — the project's entries over the
630
+ * machine's — is projected into every detected executor that has a project-scope
631
+ * configuration. An executor without one is reported as skipped, never written
632
+ * to somewhere it does not read.
633
+ */
634
+ export async function initProject(options = {}) {
635
+ const { homeDir = homedir(), projectDir = process.cwd(), log } = options;
636
+ const home = await scopeDirectory('the home directory', homeDir);
637
+ const projectRoot = await scopeDirectory('the project directory', projectDir);
638
+ const registryPath = await prepareScope('project', projectRoot, home, projectRoot);
639
+ const report = await runScope('project', home, projectRoot, log ?? createMemoryLogSink(), 'apply');
640
+ return toInitResult('project', registryPath, report);
641
+ }