@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SKANL
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # @skanl/brambo-projection
2
+
3
+ Projects Registry entries into executors' native configuration files, in each
4
+ executor's OWN vocabulary at the location that executor actually reads (port
5
+ defined in `@skanl/brambo-contracts`).
6
+
7
+ - `runProjection` — engine: reads the ownership ledger, then runs each target
8
+ SEQUENTIALLY with per-target failure containment (a failing target never
9
+ affects siblings, and failures surface as typed results — the call does not
10
+ throw). The native file lands before the ledger records it, so a crash
11
+ between the two under-claims rather than over-claims. Single-writer
12
+ assumption: concurrent `runProjection` calls over the same projected file are
13
+ unsupported in v1; a file modified externally between read and write fails
14
+ that target instead of landing stale content.
15
+ - `runRemediation` — the way OUT of a state projection reports and refuses to
16
+ resolve. Four verbs, one subject per call, and the SAME call describes and
17
+ performs (`mode: 'inspect' | 'apply'`, the engine's own switch), so a preview
18
+ cannot disagree with the act:
19
+ - `adopt` — brambo takes ownership of what is at its own location, exactly as
20
+ it is now. It writes only the ledger, and that is not the same as being
21
+ harmless: owning a location is what lets a LATER `runProjection` replace it,
22
+ and on a materialisation root REMOVE it. The description names every path
23
+ that becomes deletable and says which of the two will happen, before the
24
+ claim is written. A tree that is only partly there is claimed as the subset
25
+ that exists, so the ordinary run writes the rest back; an entry that has left
26
+ the registry is claimed from the ledger's own record, and the next run then
27
+ removes the tree. There is no verb that renders one entry outside the merge.
28
+ - `release` — brambo stops claiming a location. The file is not opened.
29
+ - `repair` — brambo rewrites its OWN ledger to hold exactly the records it can
30
+ read; the only write that does not merge, and the only exit from a ledger
31
+ carrying records it cannot use.
32
+ - `discard` — brambo removes its OWN prior output from a vendor file
33
+ (correction-01 C6): a reserved `$.brambo` key whose members are *all* brambo's
34
+ own vocabulary (`version`, `tools`, `mcpServers`, `skills`, `hooks`), or a
35
+ `# BEGIN brambo-managed` block whose sub-keys under `[tools]`/`[skills]` make
36
+ a Codex `config.toml` fail to load under `--strict-config`. A `brambo` key
37
+ holding anything else is somebody's own configuration: not reported, not
38
+ removed. A marker inside a multi-line TOML string is the user's bytes and is
39
+ invisible to the scan.
40
+
41
+ Containment is the materialisation rule unchanged: every path is resolved and
42
+ proven inside the location brambo owns, a link at any depth disqualifies it, and
43
+ a path another surviving claim holds is refused. `discard` checks the REAL
44
+ path, so a junctioned `~/.claude` cannot land the write outside the scope its
45
+ refusal promises. `adopt` builds its claim from the TARGET's plan of what brambo
46
+ would write — never from a directory listing — so a file a user put beside
47
+ brambo's is never swept into a record that could later authorise deleting it,
48
+ and both ledger verbs write ONE entry through `updateEntry` rather than
49
+ replacing a scope from a read they took earlier. A remediation brambo will not
50
+ perform is returned as a coded `BRAMBO_PROJECTION_REMEDIATION_REFUSED`, not
51
+ thrown, because under inspection the refusal is part of the description.
52
+
53
+ `runRemediation` defaults to `mode: 'inspect'` — the opposite of
54
+ `runProjection`, and the same default `brambo remediate` uses, so the
55
+ describe-before-act guarantee is true of the SDK surface and of the command.
56
+ - `ProjectionLedger` — brambo's durable record of what it wrote (target, file,
57
+ native location, content hash), in `~/.brambo/projection-ledger.json`. Brambo
58
+ never marks a vendor's file to prove ownership: a marker has nowhere to live
59
+ in some formats and is an unknown field in others. Writes MERGE one target's
60
+ claims into the on-disk document; an unreadable ledger is reported and left
61
+ alone, never overwritten.
62
+ - `createClaudeMcpTarget` — `mcpServers` in `~/.claude.json` (or `.mcp.json`
63
+ for project scope), `{type:'stdio', command, args}`.
64
+ - `createOpenCodeConfigTarget` — `mcp.<id>` in `opencode.json`,
65
+ `{type:'local', command: argv}`.
66
+ - `createCodexConfigTarget` — `[mcp_servers.<id>]` in `config.toml`, `command`
67
+ and `args` only, so `--strict-config` has nothing to reject.
68
+
69
+ Ledger records, native-entry and drift vocabulary live in `@skanl/brambo-contracts`
70
+ (`src/projection.ts`). Drift is a ledger-versus-disk comparison — `edited`,
71
+ `removed-by-user`, `foreign-collision` — and is always reported, never resolved
72
+ by writing.
73
+
74
+ - `createClaudeSkillsTarget` / `createCodexSkillsTarget` /
75
+ `createOpenCodeSkillsTarget` — materialisation targets: they own a ROOT and
76
+ copy each registry skill's `entryPath` to `<root>/<id>/SKILL.md`. Brambo
77
+ copies; it never authors skill content.
78
+
79
+ ## Where skills land, and what each executor really reads
80
+
81
+ Verified by EXECUTION against each installed binary under an injected home
82
+ (`test/skills-discovery.live.test.ts`), not by reading a document.
83
+
84
+ | Executor | Brambo writes | How it was verified |
85
+ | --- | --- | --- |
86
+ | claude-code | `~/.claude/skills/<id>/SKILL.md` | the request claude sends lists it in its own available-skills block |
87
+ | codex | `~/.codex/skills/<id>/SKILL.md` | `codex debug prompt-input` names the file by absolute path |
88
+ | opencode | `~/.config/opencode/skills/<id>/SKILL.md` | `opencode debug skill` reports that exact `location` |
89
+
90
+ **OpenCode reads four roots, and brambo does not write into the one that wins.**
91
+ Measured: `~/.config/opencode/skills`, `~/.config/opencode/skill`,
92
+ `~/.opencode/skills` and `~/.opencode/skill` are all scanned, and with the same
93
+ skill id present in two of them `~/.opencode/skills` silently takes precedence
94
+ while `brambo init` and `brambo doctor` still report brambo's own write as done.
95
+ Brambo writes one root per executor and claims nothing about precedence between
96
+ them. OpenCode additionally scans `~/.claude/skills` and `~/.agents/skills`, so
97
+ a skill brambo materialises for Claude Code is visible to opencode too.
98
+
99
+ **Brambo's registry id is not necessarily the name the executor shows.** Claude
100
+ Code takes the skill's name from the DIRECTORY, while codex and opencode take
101
+ it from the source's frontmatter `name:`. A registry entry whose id differs
102
+ from its source's frontmatter therefore appears under two different names, and
103
+ brambo's ledger, `init` and `doctor` all name the registry id.
104
+
105
+ Entries no target can express are still reported rather than approximated
106
+ (`skippedEntryIds`, plus the target's own reason in `skipped`): a `skill` is not
107
+ expressible by a CONFIG target, and an `mcp-server` with no command has nothing
108
+ to render. A RETIRED type never reaches a target at all — `groupByKind` has no
109
+ bucket for one — so it is neither projected nor reported here; `brambo doctor`
110
+ reports it against the registry document instead. Skills at PROJECT scope are
111
+ reported the same way — materialising into a project is a decision no story has
112
+ taken, so brambo invents no location.
@@ -0,0 +1,3 @@
1
+ export declare function atomicWriteText(path: string, contents: string): Promise<void>;
2
+ /** The same discipline for a file brambo COPIES rather than renders. */
3
+ export declare function atomicWriteBytes(path: string, contents: Uint8Array): Promise<void>;
@@ -0,0 +1,90 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { chmod, lstat, mkdir, realpath, rename, stat, unlink, writeFile } from 'node:fs/promises';
3
+ import { basename, dirname, join } from 'node:path';
4
+ import { BRAMBO_ERROR_CODES, BramboError } from '@skanl/brambo-contracts';
5
+ // Atomic persistence for target files: temp file in the same directory, then
6
+ // rename over the target. Atomicity here means reader visibility (readers only
7
+ // ever observe a complete document) and crash-safe replacement — NOT
8
+ // durability: like the Registry store, there is a power-loss window after
9
+ // rename with no fsync; that deferral is tracked as deferred work.
10
+ //
11
+ // The previous file's permissions are copied onto the temp file before the
12
+ // rename so a projection never widens or narrows the target's mode.
13
+ //
14
+ // SYMLINKS ARE FOLLOWED, NEVER REPLACED. `~/.claude.json -> ~/dotfiles/claude.json`
15
+ // is the ordinary way people keep these files in a repo, and rename() over a
16
+ // symlink destroys the link and orphans the source: every later edit in the
17
+ // dotfiles repo goes nowhere, `git status` there shows nothing, and brambo exits
18
+ // 0. So the link is resolved first and the rename lands on the real file. A link
19
+ // that cannot be resolved — dangling, a cycle — is a coded refusal, because the
20
+ // only alternative is to materialise a regular file where the user put a link.
21
+ async function priorMode(path) {
22
+ try {
23
+ return (await stat(path)).mode;
24
+ }
25
+ catch (error) {
26
+ if (error?.code === 'ENOENT')
27
+ return undefined;
28
+ throw error;
29
+ }
30
+ }
31
+ /**
32
+ * The real file the write must land on. Identity for a regular file or an
33
+ * absent one; the link's destination for a symlink.
34
+ */
35
+ async function writeTargetOf(path) {
36
+ let link;
37
+ try {
38
+ link = (await lstat(path)).isSymbolicLink();
39
+ }
40
+ catch (error) {
41
+ if (error?.code === 'ENOENT')
42
+ return path;
43
+ throw error;
44
+ }
45
+ if (!link)
46
+ return path;
47
+ try {
48
+ return await realpath(path);
49
+ }
50
+ catch (error) {
51
+ const detail = error?.code ?? String(error);
52
+ throw new BramboError(BRAMBO_ERROR_CODES.projectionNativeUnclaimable, `native config file '${path}' is a symlink brambo cannot resolve (${detail}); refusing to replace the link with a regular file`, { cause: error });
53
+ }
54
+ }
55
+ async function atomicWrite(path, contents) {
56
+ const target = await writeTargetOf(path);
57
+ const dir = dirname(target);
58
+ await mkdir(dir, { recursive: true });
59
+ const mode = await priorMode(target);
60
+ const tempPath = join(dir, `${basename(target)}.${randomUUID()}.tmp`);
61
+ try {
62
+ // `utf8` applies to the string form alone; a Uint8Array is written verbatim,
63
+ // which is what a materialised file needs — brambo copies bytes it did not
64
+ // author and must not re-encode them.
65
+ await writeFile(tempPath, contents, typeof contents === 'string' ? 'utf8' : undefined);
66
+ if (mode !== undefined)
67
+ await chmod(tempPath, mode);
68
+ await rename(tempPath, target);
69
+ }
70
+ catch (error) {
71
+ await unlink(tempPath).catch(() => { });
72
+ // Rethrown RAW, deliberately. `toTargetFailure` in `engine.ts` wraps a raw
73
+ // error as `BRAMBO_PROJECTION_TARGET_FAILED` and passes a `BramboError`
74
+ // through unchanged, so every projection caller already receives a coded
75
+ // failure and `doctor` classifies this state from that code. Coding it here
76
+ // was tried and reverted: it changed the code doctor sees, and the "bare
77
+ // errno reaches a caller" defect it was meant to fix does not exist for any
78
+ // caller that goes through the engine. A caller that does NOT — brambo's own
79
+ // config writer — codes it at its own boundary, where the right vocabulary
80
+ // is a configuration one rather than a projection one.
81
+ throw error;
82
+ }
83
+ }
84
+ export async function atomicWriteText(path, contents) {
85
+ await atomicWrite(path, contents);
86
+ }
87
+ /** The same discipline for a file brambo COPIES rather than renders. */
88
+ export async function atomicWriteBytes(path, contents) {
89
+ await atomicWrite(path, contents);
90
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The keys brambo will persist — an ALLOWLIST, not a suggestion.
3
+ *
4
+ * The two literals are spelled here rather than imported: this package cannot
5
+ * reach '@skanl/brambo-adapter-cli' (which owns EXECUTOR_CONFIG_KEY) under AD-2, and
6
+ * importing '@skanl/brambo-contracts' for METHOD_CONFIG_KEY alone would make one of the
7
+ * pair look canonical while the other stayed a literal. Both are duplicated, and
8
+ * this comment is why.
9
+ *
10
+ * Key-agnostic is not unconstrained. A key brambo does not read is a value
11
+ * written once and ignored forever, which is the same defect as a registry type
12
+ * nothing projects: M4.E's rule, applied to configuration. `method` arrived
13
+ * with M5.D, in the same change that taught brambo to read and mount one.
14
+ */
15
+ export declare const WRITABLE_CONFIG_KEYS: readonly ['executor', 'method'];
16
+ export type WritableConfigKey = (typeof WRITABLE_CONFIG_KEYS)[number];
17
+ export interface ConfigWriteOptions {
18
+ /** `machine` writes the `global` layer's document; `project` writes the project's. */
19
+ readonly scope: 'machine' | 'project';
20
+ readonly homeDir: string;
21
+ /** Required for the project scope; ignored for the machine one. */
22
+ readonly projectDir?: string;
23
+ readonly key: WritableConfigKey;
24
+ readonly value: string;
25
+ }
26
+ export interface ConfigWriteResult {
27
+ readonly filePath: string;
28
+ /** What the key said before, so a caller can tell a change from a no-op. */
29
+ readonly previous: string | undefined;
30
+ /** True when the document did not exist and this call created it. */
31
+ readonly created: boolean;
32
+ }
33
+ /** `<root>/.brambo/config.json` for the scope this call names. */
34
+ export declare function configPathFor(options: Pick<ConfigWriteOptions, 'scope' | 'homeDir' | 'projectDir'>): string;
35
+ /**
36
+ * Sets ONE allowlisted key in brambo's own configuration document.
37
+ *
38
+ * Every other key is carried through untouched: these documents hold the
39
+ * workspace root beside the executor selection, and a writer that serialises
40
+ * only what it was handed silently deletes the rest.
41
+ *
42
+ * The write goes through `@skanl/brambo-projection`'s `atomicWriteText` rather than a
43
+ * local temp-then-rename, because this exact file is the one dotfile managers
44
+ * materialise as a symlink and it is the only writer in this repository that
45
+ * resolves the link instead of replacing it. The cost is that a refusal arrives
46
+ * as a `BRAMBO_PROJECTION_*` code out of a configuration verb; that is recorded
47
+ * in `deferred-work.md` and it is cheaper than a second copy of the symlink rule.
48
+ */
49
+ export declare function setConfigValue(options: ConfigWriteOptions): Promise<ConfigWriteResult>;
@@ -0,0 +1,146 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { BRAMBO_ERROR_CODES, BramboError } from '@skanl/brambo-contracts';
4
+ import { atomicWriteText } from './atomic-write.js';
5
+ import { strictFaultLocation } from './document-fault.js';
6
+ // The write half of brambo's OWN configuration. `brambo run --help` has always
7
+ // told the user where the executor selection comes from and never how a value
8
+ // gets there, because nothing in the product wrote one: `packages/session`
9
+ // reads these documents four times and writes them zero. The answer brambo gave
10
+ // a user who wanted a different default was "edit this JSON", which is the one
11
+ // answer the product exists to remove.
12
+ //
13
+ // KEY-AGNOSTIC ON PURPOSE. This is "set one key in brambo's own configuration
14
+ // document", not "set the executor". Story 5.4 persists a `method` selection
15
+ // into the same documents with the same layer semantics and the same symlink
16
+ // hazard, and a second writer is a second place to get the symlink rule wrong.
17
+ // `<homeDir>/.brambo/config.json` is the `global` layer and
18
+ // `<projectDir>/.brambo/config.json` is the `project` layer, resolved by
19
+ // `readExecutorConfigLayers` in `@skanl/brambo-session`.
20
+ //
21
+ // ponytail: `.brambo/config.json` is spelled here rather than imported from
22
+ // `@skanl/brambo-session`, which spells it too and carries the same note. AD-2 forbids
23
+ // the edge, and it would exist only to share two string literals.
24
+ const BRAMBO_STATE_DIR = '.brambo';
25
+ const CONFIG_FILE = 'config.json';
26
+ /**
27
+ * The keys brambo will persist — an ALLOWLIST, not a suggestion.
28
+ *
29
+ * The two literals are spelled here rather than imported: this package cannot
30
+ * reach '@skanl/brambo-adapter-cli' (which owns EXECUTOR_CONFIG_KEY) under AD-2, and
31
+ * importing '@skanl/brambo-contracts' for METHOD_CONFIG_KEY alone would make one of the
32
+ * pair look canonical while the other stayed a literal. Both are duplicated, and
33
+ * this comment is why.
34
+ *
35
+ * Key-agnostic is not unconstrained. A key brambo does not read is a value
36
+ * written once and ignored forever, which is the same defect as a registry type
37
+ * nothing projects: M4.E's rule, applied to configuration. `method` arrived
38
+ * with M5.D, in the same change that taught brambo to read and mount one.
39
+ */
40
+ export const WRITABLE_CONFIG_KEYS = ['executor', 'method'];
41
+ function unusable(filePath, detail, cause) {
42
+ return new BramboError(BRAMBO_ERROR_CODES.configurationUnusable, `brambo will not write '${filePath}' because ${detail}`, cause === undefined ? undefined : { cause });
43
+ }
44
+ function isRecord(value) {
45
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
46
+ }
47
+ /**
48
+ * The document as it is on disk, or `undefined` when there is none.
49
+ *
50
+ * AD-5: absent and unusable are DIFFERENT answers and this is the only place
51
+ * that can tell them apart. Absent means create; unusable means refuse, and
52
+ * refuse without writing — the alternative is destroying a document brambo could
53
+ * not understand, including one a user is halfway through editing.
54
+ */
55
+ async function readDocument(filePath) {
56
+ let text;
57
+ try {
58
+ text = await readFile(filePath, 'utf8');
59
+ }
60
+ catch (error) {
61
+ const code = error?.code;
62
+ // ENOENT on a DANGLING symlink too — `readFile` follows links. That case is
63
+ // not treated as absent here: `atomicWriteText` resolves the link itself and
64
+ // refuses a dangling one coded, which is the answer that keeps brambo from
65
+ // materialising a regular file where the user put a link.
66
+ if (code === 'ENOENT')
67
+ return undefined;
68
+ throw unusable(filePath, `it could not be read (${code ?? 'unknown error'})`, error);
69
+ }
70
+ let parsed;
71
+ try {
72
+ parsed = JSON.parse(text);
73
+ }
74
+ catch {
75
+ // LOCATED, never quoted (`document-fault.ts`), and no `cause`: passing the
76
+ // error here put its message on every stack that printed this refusal.
77
+ throw unusable(filePath, `it is not valid JSON, and brambo does not overwrite a document it cannot read: ${strictFaultLocation(text)}`);
78
+ }
79
+ if (!isRecord(parsed)) {
80
+ throw unusable(filePath, 'it must hold a JSON object, and brambo does not replace one that does not');
81
+ }
82
+ return parsed;
83
+ }
84
+ /** `<root>/.brambo/config.json` for the scope this call names. */
85
+ export function configPathFor(options) {
86
+ const root = options.scope === 'machine' ? options.homeDir : options.projectDir;
87
+ if (root === undefined || root.trim().length === 0) {
88
+ throw new BramboError(BRAMBO_ERROR_CODES.environmentScopeUnavailable, "a 'project' scope needs the directory of the project whose configuration is being written");
89
+ }
90
+ return join(root, BRAMBO_STATE_DIR, CONFIG_FILE);
91
+ }
92
+ /**
93
+ * Sets ONE allowlisted key in brambo's own configuration document.
94
+ *
95
+ * Every other key is carried through untouched: these documents hold the
96
+ * workspace root beside the executor selection, and a writer that serialises
97
+ * only what it was handed silently deletes the rest.
98
+ *
99
+ * The write goes through `@skanl/brambo-projection`'s `atomicWriteText` rather than a
100
+ * local temp-then-rename, because this exact file is the one dotfile managers
101
+ * materialise as a symlink and it is the only writer in this repository that
102
+ * resolves the link instead of replacing it. The cost is that a refusal arrives
103
+ * as a `BRAMBO_PROJECTION_*` code out of a configuration verb; that is recorded
104
+ * in `deferred-work.md` and it is cheaper than a second copy of the symlink rule.
105
+ */
106
+ export async function setConfigValue(options) {
107
+ const { key, value } = options;
108
+ if (!WRITABLE_CONFIG_KEYS.includes(key)) {
109
+ // The list is built BEFORE the message rather than interpolated into it: the
110
+ // printed-command scanner reads backtick strings out of shipped source, and
111
+ // a nested template literal reached it as raw `${...}` source text.
112
+ const known = WRITABLE_CONFIG_KEYS.map((writable) => `'${writable}'`).join(', ');
113
+ throw new BramboError(BRAMBO_ERROR_CODES.configurationUnusable, `brambo does not persist a '${key}' setting; it writes ${known}`);
114
+ }
115
+ const filePath = configPathFor(options);
116
+ const existing = await readDocument(filePath);
117
+ const previous = existing?.[key];
118
+ try {
119
+ await atomicWriteText(filePath, `${JSON.stringify({ ...existing, [key]: value }, undefined, 2)}\n`);
120
+ }
121
+ catch (error) {
122
+ // Coded HERE rather than inside `atomicWriteText` (AD-7). Every OTHER caller
123
+ // of that writer goes through the projection engine, whose `toTargetFailure`
124
+ // already wraps a raw error as `BRAMBO_PROJECTION_TARGET_FAILED` and which
125
+ // `doctor` classifies from — coding it upstream was tried and it changed the
126
+ // code doctor sees. This caller does not go through the engine, so it codes
127
+ // its own failure, in configuration vocabulary rather than projection's.
128
+ //
129
+ // The commonest cause is a read-only document, and on Windows it is `rename`
130
+ // that refuses (measured: a 0o444 target gives `EPERM: operation not
131
+ // permitted, rename` while the temp write succeeds). The mode is NOT relaxed
132
+ // to get the write through: a file the user made read-only was made
133
+ // read-only on purpose.
134
+ const detail = error?.code;
135
+ // Worded to read after `unusable`'s own "brambo will not write '<path>'
136
+ // because" prefix, which is also why it does not start with the word brambo:
137
+ // a printed string that does is treated as a COMMAND by
138
+ // `packages/cli/test/printed-commands.test.ts` and has to be declared prose.
139
+ throw unusable(filePath, `it could not be replaced (${detail ?? String(error)}), so it is not writable`, error);
140
+ }
141
+ return {
142
+ filePath,
143
+ previous: typeof previous === 'string' ? previous : undefined,
144
+ created: existing === undefined,
145
+ };
146
+ }
@@ -0,0 +1,59 @@
1
+ import type { ParseError } from 'jsonc-parser';
2
+ /**
3
+ * ONE rule, in one place, for every document this package parses (Spec M17.A):
4
+ * **no error brambo raises about a document quotes that document's content.**
5
+ *
6
+ * The hazard is not hypothetical and not confined to vendor files. V8's
7
+ * `JSON.parse` puts a window of the SOURCE TEXT in its message for the shapes it
8
+ * cannot give a position for — a stray comma before an array element and a `NaN`
9
+ * literal — and the documents brambo parses are where MCP server arguments live,
10
+ * which is where an API token lives. Measured through the shipped binary at
11
+ * `4232e9c`: a credential planted next to the fault reached stdout from
12
+ * `~/.claude.json` AND from `.brambo/registry.json`.
13
+ *
14
+ * The message cannot be TRIMMED to its location, because the shapes that quote
15
+ * the document are exactly the ones carrying no position. So it is dropped
16
+ * whole, the `cause` goes with it — a cause is reachable from any printed stack,
17
+ * so keeping it would move the leak rather than close it — and brambo derives its
18
+ * own location from `jsonc-parser`'s offsets.
19
+ *
20
+ * ponytail: `@skanl/brambo-registry` carries its own copy of this, because AD-2 forbids
21
+ * the edge that would let it import this one and `@skanl/brambo-contracts` must stay
22
+ * dependency-free for the third-party promise. Ceiling: two copies to keep in
23
+ * step. Upgrade path: a shared dependency-free leaf package, worth it the first
24
+ * time a third package needs it.
25
+ */
26
+ /** What brambo says when it cannot derive a location — never the parser's text. */
27
+ export declare const FAULT_UNLOCATED = "the fault could not be located";
28
+ /**
29
+ * A byte offset as the 1-based `line:column` a user's editor shows.
30
+ *
31
+ * Offset 0 needs no special case and had one until it was measured: there
32
+ * `lastIndexOf('\n', -1)` is -1, the +1 makes `lineStart` 0, and `''.split('\n')`
33
+ * has length 1 — so the general form already answers `line 1, column 1`.
34
+ */
35
+ export declare function positionOf(text: string, offset: number): string;
36
+ /**
37
+ * The parser's own CODE plus brambo's `line:column`, from a collected offset.
38
+ *
39
+ * The code (`InvalidSymbol`, `PropertyNameExpected`) is the parser's, not prose
40
+ * brambo invents: it is terser than a sentence, and it is the same word the
41
+ * user's editor and every other jsonc-parser consumer already shows them. It
42
+ * names the FAULT; it never carries a byte of the document.
43
+ */
44
+ export declare function faultDetail(body: string, error: ParseError | undefined): string;
45
+ /**
46
+ * Re-locates a fault `JSON.parse` refused, using `jsonc-parser`'s OFFSETS — not
47
+ * a second parser, and not a regex over V8's message.
48
+ *
49
+ * The options are strict-JSON semantics exactly: trailing commas are errors (the
50
+ * default) and so are comments, so nearly every document V8 rejects collects an
51
+ * offset here. Where none is collected, the caller still refuses; it just cannot
52
+ * say where.
53
+ *
54
+ * `parseTree` RECURSES, and it was measured throwing `RangeError` on a document
55
+ * nested past ~5000 levels that V8 also rejects — so the throw is reachable from
56
+ * here. It is caught rather than propagated, because losing the location is the
57
+ * documented outcome and losing the CODED error is not.
58
+ */
59
+ export declare function strictFaultLocation(body: string): string;
@@ -0,0 +1,78 @@
1
+ import { parseTree, printParseErrorCode } from 'jsonc-parser';
2
+ /**
3
+ * ONE rule, in one place, for every document this package parses (Spec M17.A):
4
+ * **no error brambo raises about a document quotes that document's content.**
5
+ *
6
+ * The hazard is not hypothetical and not confined to vendor files. V8's
7
+ * `JSON.parse` puts a window of the SOURCE TEXT in its message for the shapes it
8
+ * cannot give a position for — a stray comma before an array element and a `NaN`
9
+ * literal — and the documents brambo parses are where MCP server arguments live,
10
+ * which is where an API token lives. Measured through the shipped binary at
11
+ * `4232e9c`: a credential planted next to the fault reached stdout from
12
+ * `~/.claude.json` AND from `.brambo/registry.json`.
13
+ *
14
+ * The message cannot be TRIMMED to its location, because the shapes that quote
15
+ * the document are exactly the ones carrying no position. So it is dropped
16
+ * whole, the `cause` goes with it — a cause is reachable from any printed stack,
17
+ * so keeping it would move the leak rather than close it — and brambo derives its
18
+ * own location from `jsonc-parser`'s offsets.
19
+ *
20
+ * ponytail: `@skanl/brambo-registry` carries its own copy of this, because AD-2 forbids
21
+ * the edge that would let it import this one and `@skanl/brambo-contracts` must stay
22
+ * dependency-free for the third-party promise. Ceiling: two copies to keep in
23
+ * step. Upgrade path: a shared dependency-free leaf package, worth it the first
24
+ * time a third package needs it.
25
+ */
26
+ /** What brambo says when it cannot derive a location — never the parser's text. */
27
+ export const FAULT_UNLOCATED = 'the fault could not be located';
28
+ /**
29
+ * A byte offset as the 1-based `line:column` a user's editor shows.
30
+ *
31
+ * Offset 0 needs no special case and had one until it was measured: there
32
+ * `lastIndexOf('\n', -1)` is -1, the +1 makes `lineStart` 0, and `''.split('\n')`
33
+ * has length 1 — so the general form already answers `line 1, column 1`.
34
+ */
35
+ export function positionOf(text, offset) {
36
+ const bounded = Math.max(0, Math.min(offset, text.length));
37
+ const lineStart = text.lastIndexOf('\n', bounded - 1) + 1;
38
+ return `line ${text.slice(0, lineStart).split('\n').length}, column ${bounded - lineStart + 1}`;
39
+ }
40
+ /**
41
+ * The parser's own CODE plus brambo's `line:column`, from a collected offset.
42
+ *
43
+ * The code (`InvalidSymbol`, `PropertyNameExpected`) is the parser's, not prose
44
+ * brambo invents: it is terser than a sentence, and it is the same word the
45
+ * user's editor and every other jsonc-parser consumer already shows them. It
46
+ * names the FAULT; it never carries a byte of the document.
47
+ */
48
+ export function faultDetail(body, error) {
49
+ return error === undefined
50
+ ? FAULT_UNLOCATED
51
+ : `${printParseErrorCode(error.error)} at ${positionOf(body, error.offset)}`;
52
+ }
53
+ /**
54
+ * Re-locates a fault `JSON.parse` refused, using `jsonc-parser`'s OFFSETS — not
55
+ * a second parser, and not a regex over V8's message.
56
+ *
57
+ * The options are strict-JSON semantics exactly: trailing commas are errors (the
58
+ * default) and so are comments, so nearly every document V8 rejects collects an
59
+ * offset here. Where none is collected, the caller still refuses; it just cannot
60
+ * say where.
61
+ *
62
+ * `parseTree` RECURSES, and it was measured throwing `RangeError` on a document
63
+ * nested past ~5000 levels that V8 also rejects — so the throw is reachable from
64
+ * here. It is caught rather than propagated, because losing the location is the
65
+ * documented outcome and losing the CODED error is not.
66
+ */
67
+ export function strictFaultLocation(body) {
68
+ const errors = [];
69
+ try {
70
+ parseTree(body, errors, { disallowComments: true });
71
+ }
72
+ catch {
73
+ return FAULT_UNLOCATED;
74
+ }
75
+ // The FIRST only. A recovering parser cascades — an unquoted key reports four
76
+ // — and the rest are that one's shadow.
77
+ return faultDetail(body, errors[0]);
78
+ }
@@ -0,0 +1,69 @@
1
+ import type { ProjectionFailure, ProjectionResult, ProjectionTarget, ProjectionWarning, RegistryEntriesByKind, RegistryEntry } from '@skanl/brambo-contracts';
2
+ import type { ProjectionLedger } from './ledger.ts';
3
+ /**
4
+ * The registry as the projection engine reads it: one bucket per DECLARED entry
5
+ * type.
6
+ *
7
+ * Derived from `REGISTRY_ENTRY_TYPES` rather than listed — a second table here
8
+ * is how the engine comes to project a word the contract no longer has, or to
9
+ * miss one it just gained. It is also the boundary that keeps a RETIRED type out
10
+ * of projection: an entry whose type is not declared has no bucket, so it is
11
+ * dropped here and no target is ever asked to express it. The store still reads
12
+ * it, `brambo list` still shows it and `brambo remove` still takes it out.
13
+ */
14
+ export declare function groupByKind(entries: readonly RegistryEntry[]): RegistryEntriesByKind;
15
+ /**
16
+ * Whether the run is allowed to LAND what it computes.
17
+ *
18
+ * `'inspect'` is the whole of `brambo doctor`: the identical merge, the identical
19
+ * drift classification, the identical ledger read — and neither of the two
20
+ * writes a run performs. A diagnosis computed by a second code path can disagree
21
+ * with what applying would do, and it would disagree exactly when a user is
22
+ * trying to fix something.
23
+ */
24
+ export type ProjectionMode = 'apply' | 'inspect';
25
+ /**
26
+ * FAIL CLOSED. Not `mode !== 'inspect'`: that writes for `'Inspect'`,
27
+ * `'inspect '`, `'dry-run'` and `null`, and the one thing this field decides is
28
+ * whether brambo writes into files it does not own. A no-op run is visible in its
29
+ * own output; a write into a user's config on the say-so of a typo is not. So
30
+ * both failures are loud. `=== undefined`, not `??`: `null` is a value a caller
31
+ * PASSED, not an omission, and coalescing it into the writing default is the
32
+ * same silent accept this guard exists to remove.
33
+ *
34
+ * Shared by `runProjection` and `runRemediation` so the two commands that can
35
+ * write cannot disagree about what "do not touch this machine" means.
36
+ */
37
+ export declare function resolveProjectionMode(mode: ProjectionMode | undefined): ProjectionMode;
38
+ export interface RunProjectionOptions {
39
+ readonly entries: RegistryEntriesByKind;
40
+ readonly targets: readonly ProjectionTarget[];
41
+ /** Required: without a ledger brambo cannot know which entries are its own. */
42
+ readonly ledger: ProjectionLedger;
43
+ /**
44
+ * Defaults to `'apply'`. Under `'inspect'` NOTHING is written — not the vendor
45
+ * file, not the ledger — and `ProjectionResult.written` reads as "these bytes
46
+ * WOULD have changed", which is the one field whose sentence the mode alters.
47
+ */
48
+ readonly mode?: ProjectionMode;
49
+ }
50
+ export interface ProjectionRun {
51
+ readonly results: ProjectionResult[];
52
+ readonly failures: ProjectionFailure[];
53
+ readonly warnings: ProjectionWarning[];
54
+ }
55
+ /** File identity snapshot taken when the native text is read. */
56
+ export interface NativeFileSnapshot {
57
+ readonly mtimeMs: number;
58
+ readonly size: number;
59
+ }
60
+ /**
61
+ * Defense against a read-write race: if the native file changed on disk between
62
+ * the read and the write, the projection is stale and MUST NOT land. An ABSENT
63
+ * snapshot is not "nothing to compare": the merge was computed against an empty
64
+ * document, so the file appearing in the meantime — a vendor CLI creating
65
+ * `~/.claude.json` — is exactly the case where landing would overwrite it
66
+ * wholesale.
67
+ */
68
+ export declare function hasFileChangedSince(filePath: string, snapshot: NativeFileSnapshot | undefined): Promise<boolean>;
69
+ export declare function runProjection(options: RunProjectionOptions): Promise<ProjectionRun>;