@skanl/brambo-session 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/dist/executors.d.ts +146 -0
- package/dist/executors.js +342 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +73 -0
- package/dist/methods.d.ts +137 -0
- package/dist/methods.js +264 -0
- package/dist/remote-mcp.d.ts +37 -0
- package/dist/remote-mcp.js +76 -0
- package/dist/run-session.d.ts +300 -0
- package/dist/run-session.js +523 -0
- package/dist/tool-executor.d.ts +4 -0
- package/dist/tool-executor.js +159 -0
- package/dist/usage.d.ts +30 -0
- package/dist/usage.js +110 -0
- package/dist/workspaces.d.ts +63 -0
- package/dist/workspaces.js +126 -0
- package/package.json +59 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { lstat, readFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { DEFAULT_EXECUTOR_ID, EXECUTOR_CATALOGUE, EXECUTOR_CONFIG_KEY, availableExecutorIds, createExecutorAdapter, unknownExecutor, } from '@skanl/brambo-adapter-cli';
|
|
5
|
+
import { METHOD_CONFIG_KEY, BRAMBO_ERROR_CODES, BramboError, isRecord } from '@skanl/brambo-contracts';
|
|
6
|
+
import { createLayeredConfig, deepMerge } from '@skanl/brambo-kernel';
|
|
7
|
+
// Executor SELECTION: which shipped adapter this run uses, decided through the
|
|
8
|
+
// layered configuration brambo already owns.
|
|
9
|
+
//
|
|
10
|
+
// The catalogue itself moved to `@skanl/brambo-adapter-cli` with Story M3.B — the
|
|
11
|
+
// package that ships the three adapters is the one whose kernel plugin has to
|
|
12
|
+
// turn a configured id into one. It is re-exported here unchanged, because
|
|
13
|
+
// `@skanl/brambo-session` is the FR-29 surface: a consumer that installed only this
|
|
14
|
+
// package still gets the whole selection vocabulary from one import.
|
|
15
|
+
export { DEFAULT_EXECUTOR_ID, EXECUTOR_CATALOGUE,
|
|
16
|
+
// Re-exported, never re-declared. A second `const EXECUTOR_CONFIG_KEY` lived
|
|
17
|
+
// here and a third literal lived in `run-session.ts`, so the REPORTED
|
|
18
|
+
// selection and the MOUNTED adapter were derived independently from the same
|
|
19
|
+
// document: renaming one produced `executor: codex (selected by the 'project'
|
|
20
|
+
// layer)` on stderr while `claude` was spawned, exit 0. This package's whole
|
|
21
|
+
// catalogue design exists because a second spelling drifted from the thing it
|
|
22
|
+
// named; the key gets the same treatment.
|
|
23
|
+
EXECUTOR_CONFIG_KEY, availableExecutorIds, createExecutorAdapter, };
|
|
24
|
+
// ponytail: `.brambo/config.json` is spelled here rather than imported from
|
|
25
|
+
// `@skanl/brambo-environment`, which owns the same `<scope>/.brambo` convention. That
|
|
26
|
+
// package is CONSUMER tier and so is this one, and `packages/session/test/
|
|
27
|
+
// guard.test.ts` pins @skanl/brambo-session's dependency set to exactly four packages —
|
|
28
|
+
// so reaching for it would be an AD-2 violation the gate rejects, not a reuse.
|
|
29
|
+
// Upgrade path: move the scope-directory convention down into `@skanl/brambo-contracts`
|
|
30
|
+
// (shared tier) and have both consumers read it from there. Recorded in the
|
|
31
|
+
// spec's Spec Change Log.
|
|
32
|
+
const BRAMBO_STATE_DIR = '.brambo';
|
|
33
|
+
const CONFIG_FILE = 'config.json';
|
|
34
|
+
// The two errnos that mean "there is no such document", including a parent that
|
|
35
|
+
// is not a directory (win32 reports that as ENOENT, POSIX as ENOTDIR). Every
|
|
36
|
+
// other errno means something IS there and brambo could not read it, which is an
|
|
37
|
+
// error rather than an absent layer.
|
|
38
|
+
const ABSENT_ERRNOS = new Set(['ENOENT', 'ENOTDIR']);
|
|
39
|
+
// A UTF-8 byte order mark, written as an escape. `readFile(path, 'utf8')` does
|
|
40
|
+
// not strip one and `JSON.parse` rejects it, so three invisible bytes brick a
|
|
41
|
+
// document whose visible contents are correct — and PowerShell 5.1's `>` and
|
|
42
|
+
// `Set-Content`, Notepad, and VS Code's "UTF-8 with BOM" all emit one by default
|
|
43
|
+
// on the platform this repo is developed on. `packages/adapter-cli/src/traits.ts`
|
|
44
|
+
// strips the same mark off executor stdout for the same reason.
|
|
45
|
+
const BYTE_ORDER_MARK = '\uFEFF';
|
|
46
|
+
/** Brambo's own configuration document for a scope root. */
|
|
47
|
+
export function executorConfigPath(scopeDir) {
|
|
48
|
+
return join(scopeDir, BRAMBO_STATE_DIR, CONFIG_FILE);
|
|
49
|
+
}
|
|
50
|
+
function unusable(filePath, detail, cause) {
|
|
51
|
+
return new BramboError(BRAMBO_ERROR_CODES.configurationUnusable, `brambo's configuration at '${filePath}' cannot be used: ${detail}`, cause === undefined ? undefined : { cause });
|
|
52
|
+
}
|
|
53
|
+
function blankExecutor() {
|
|
54
|
+
return new BramboError(BRAMBO_ERROR_CODES.executorNotFound, `an executor id must name one of: ${availableExecutorIds().join(', ')}, but it is blank`);
|
|
55
|
+
}
|
|
56
|
+
function describeError(error) {
|
|
57
|
+
return error instanceof Error ? error.message : String(error);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A caller-supplied scope root, absolute and non-empty.
|
|
61
|
+
*
|
|
62
|
+
* `homeDir: ''` — which is exactly `process.env.HOME ?? ''` in a consumer, and
|
|
63
|
+
* the shape Story 2.7a was bitten by — makes `join('', '.brambo', …)` RELATIVE,
|
|
64
|
+
* so the machine scope silently relocates into the working directory and the
|
|
65
|
+
* PROJECT's own document is then reported as the `global` layer. That is a false
|
|
66
|
+
* claim on the one output this story exists to make trustworthy, so it is
|
|
67
|
+
* refused with the same code `@skanl/brambo-environment` refuses it with.
|
|
68
|
+
*/
|
|
69
|
+
function scopeRoot(label, value) {
|
|
70
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
71
|
+
throw new BramboError(BRAMBO_ERROR_CODES.environmentScopeUnavailable, `${label} must be a non-empty path, but brambo was given ${JSON.stringify(value)}`);
|
|
72
|
+
}
|
|
73
|
+
return resolve(value);
|
|
74
|
+
}
|
|
75
|
+
/** True when SOMETHING is at this path, target reachable or not. */
|
|
76
|
+
async function entryExists(filePath) {
|
|
77
|
+
try {
|
|
78
|
+
await lstat(filePath);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** What a JSON value IS, for a message that tells the user what to fix. */
|
|
86
|
+
function describeJson(value) {
|
|
87
|
+
if (value === null)
|
|
88
|
+
return 'null';
|
|
89
|
+
if (Array.isArray(value))
|
|
90
|
+
return 'an array';
|
|
91
|
+
return `a ${typeof value}`;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* One configuration document, or `undefined` when there is none.
|
|
95
|
+
*
|
|
96
|
+
* The distinction this draws is the whole feature: a MISSING document is an
|
|
97
|
+
* absent layer, and a document that exists but cannot be used is a coded error.
|
|
98
|
+
* Reading a corrupt file and shrugging back to the default runs a different
|
|
99
|
+
* agent than the user configured, silently — which is the failure executor
|
|
100
|
+
* selection exists to remove, not a robustness feature.
|
|
101
|
+
*
|
|
102
|
+
* The `executor` key is type-checked here rather than after composition because
|
|
103
|
+
* the layer that CARRIES the bad value is the one whose path the user has to be
|
|
104
|
+
* told; once merged, the offending document is no longer identifiable.
|
|
105
|
+
*/
|
|
106
|
+
async function readConfigDocument(filePath) {
|
|
107
|
+
let text;
|
|
108
|
+
try {
|
|
109
|
+
text = await readFile(filePath, 'utf8');
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
const code = error?.code;
|
|
113
|
+
if (code !== undefined && ABSENT_ERRNOS.has(code)) {
|
|
114
|
+
// `readFile` FOLLOWS symlinks, so a DANGLING link reports ENOENT exactly
|
|
115
|
+
// like a file that was never there — and every dotfile manager (stow,
|
|
116
|
+
// chezmoi, dotbot) materialises brambo's config as a symlink, whose
|
|
117
|
+
// canonical failure is a broken target. `lstat` looks at the ENTRY rather
|
|
118
|
+
// than at the target, which is the one thing that tells the two apart.
|
|
119
|
+
// This is the only present-but-unusable state that would otherwise fall
|
|
120
|
+
// back to a different agent in silence.
|
|
121
|
+
if (await entryExists(filePath)) {
|
|
122
|
+
throw unusable(filePath, 'it exists but its target cannot be read (a dangling symbolic link, or it was removed mid-read)', error);
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
throw unusable(filePath, `it could not be read (${code ?? 'unknown error'})`, error);
|
|
127
|
+
}
|
|
128
|
+
let parsed;
|
|
129
|
+
try {
|
|
130
|
+
parsed = JSON.parse(text.startsWith(BYTE_ORDER_MARK) ? text.slice(BYTE_ORDER_MARK.length) : text);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
throw unusable(filePath, 'it is not valid JSON', error);
|
|
134
|
+
}
|
|
135
|
+
if (!isRecord(parsed)) {
|
|
136
|
+
throw unusable(filePath, `it must hold a JSON object, but it holds ${describeJson(parsed)}`);
|
|
137
|
+
}
|
|
138
|
+
const selected = parsed[EXECUTOR_CONFIG_KEY];
|
|
139
|
+
if (selected !== undefined && typeof selected !== 'string') {
|
|
140
|
+
throw unusable(filePath, `'${EXECUTOR_CONFIG_KEY}' must be a string naming one of: ${availableExecutorIds().join(', ')}, but it is ${describeJson(selected)}`);
|
|
141
|
+
}
|
|
142
|
+
if (typeof selected === 'string') {
|
|
143
|
+
// Normalised here so a value an editor appended a newline to still works,
|
|
144
|
+
// and so a blank one is named for what it is instead of reaching the
|
|
145
|
+
// catalogue as `' '` and being reported as a missing ADAPTER — which sends
|
|
146
|
+
// the user looking for an installation rather than for a typo.
|
|
147
|
+
const trimmed = selected.trim();
|
|
148
|
+
if (trimmed.length === 0) {
|
|
149
|
+
throw unusable(filePath, `'${EXECUTOR_CONFIG_KEY}' is blank; it must name one of: ${availableExecutorIds().join(', ')}`);
|
|
150
|
+
}
|
|
151
|
+
parsed[EXECUTOR_CONFIG_KEY] = trimmed;
|
|
152
|
+
}
|
|
153
|
+
return parsed;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Marks a document `readExecutorConfigLayers` actually read off disk.
|
|
157
|
+
*
|
|
158
|
+
* Module-private and unforgeable from outside this file. The layer a selection
|
|
159
|
+
* is reported under is the one printed on stderr — "a swap you cannot see is not
|
|
160
|
+
* one you can trust" — and without this brand a caller could hand `runSession` a
|
|
161
|
+
* document it invented, name it `project`, and have `brambo run` print
|
|
162
|
+
* `selected by the 'project' layer` for a file that does not exist. A supplied
|
|
163
|
+
* document is composed into the `agent` layer instead: still narrower than the
|
|
164
|
+
* project document, still reported honestly as coming from the running host.
|
|
165
|
+
*/
|
|
166
|
+
const READ_FROM_DISK = Symbol('brambo.executor-config.read-from-disk');
|
|
167
|
+
function readDocument(filePath, document) {
|
|
168
|
+
return { filePath, document, [READ_FROM_DISK]: true };
|
|
169
|
+
}
|
|
170
|
+
function wasReadFromDisk(entry) {
|
|
171
|
+
return entry[READ_FROM_DISK] === true;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Reads brambo's own documents into layer snapshots. The ONLY filesystem access
|
|
175
|
+
* in executor selection.
|
|
176
|
+
*
|
|
177
|
+
* A MISSING document is an absent layer. A document that exists and cannot be
|
|
178
|
+
* used is a coded error — reading a corrupt file and shrugging back to the
|
|
179
|
+
* default runs a different agent than the user configured, silently, which is
|
|
180
|
+
* the failure this selection exists to remove.
|
|
181
|
+
*/
|
|
182
|
+
export async function readExecutorConfigLayers(options = {}) {
|
|
183
|
+
// Every field read ONCE, before the first await, for the same reason
|
|
184
|
+
// `runSession` does it: a live read after control has returned to the caller's
|
|
185
|
+
// event loop lets an accessor answer with a temp directory now and the real
|
|
186
|
+
// home directory later.
|
|
187
|
+
const { executorId, homeDir = homedir(), projectDir = process.cwd() } = options;
|
|
188
|
+
const home = scopeRoot('the home directory', homeDir);
|
|
189
|
+
const project = scopeRoot('the project directory', projectDir);
|
|
190
|
+
const layers = {};
|
|
191
|
+
// Documents go in whole, not just their `executor` key: `setLayer` is what
|
|
192
|
+
// rejects a prototype-polluting document, and it can only reject what it sees.
|
|
193
|
+
const globalPath = executorConfigPath(home);
|
|
194
|
+
const globalDocument = await readConfigDocument(globalPath);
|
|
195
|
+
if (globalDocument !== undefined)
|
|
196
|
+
layers.global = readDocument(globalPath, globalDocument);
|
|
197
|
+
// Running brambo FROM your home directory is ONE document, not two. Loading it
|
|
198
|
+
// into both layers reported `project` as the deciding layer for a project that
|
|
199
|
+
// does not exist — a false provenance on the one line this story adds.
|
|
200
|
+
if (project !== home) {
|
|
201
|
+
const projectPath = executorConfigPath(project);
|
|
202
|
+
const projectDocument = await readConfigDocument(projectPath);
|
|
203
|
+
if (projectDocument !== undefined)
|
|
204
|
+
layers.project = readDocument(projectPath, projectDocument);
|
|
205
|
+
}
|
|
206
|
+
if (executorId !== undefined) {
|
|
207
|
+
const requested = executorId.trim();
|
|
208
|
+
if (requested.length === 0)
|
|
209
|
+
throw blankExecutor();
|
|
210
|
+
layers.invocation = { [EXECUTOR_CONFIG_KEY]: requested };
|
|
211
|
+
}
|
|
212
|
+
return layers;
|
|
213
|
+
}
|
|
214
|
+
export function seedExecutorConfig(config, layers = {}) {
|
|
215
|
+
// Brambo's built-in default is a LAYER, never a constructor fallback. That is
|
|
216
|
+
// what makes "nothing configured" a reportable provenance rather than an
|
|
217
|
+
// invisible branch. Caller-supplied defaults compose UNDER it, so a document
|
|
218
|
+
// still wins over both.
|
|
219
|
+
config.setLayer('defaults', deepMerge(layers.defaults ?? {}, { [EXECUTOR_CONFIG_KEY]: DEFAULT_EXECUTOR_ID }));
|
|
220
|
+
// A document brambo READ goes into the layer its file belongs to. A document a
|
|
221
|
+
// caller merely handed over goes into `agent` — the layer for "the running
|
|
222
|
+
// host supplied this" — so the provenance brambo reports can never be a claim
|
|
223
|
+
// the caller made up. Two supplied documents compose in the same order.
|
|
224
|
+
let supplied;
|
|
225
|
+
let recommendation;
|
|
226
|
+
for (const layer of ['global', 'project']) {
|
|
227
|
+
const entry = layers[layer];
|
|
228
|
+
if (entry === undefined)
|
|
229
|
+
continue;
|
|
230
|
+
if (!wasReadFromDisk(entry)) {
|
|
231
|
+
supplied = supplied === undefined ? entry.document : deepMerge(supplied, entry.document);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
// A PROJECT RECOMMENDS A METHOD; IT DOES NOT SELECT ONE, so the key never
|
|
235
|
+
// becomes part of this layer.
|
|
236
|
+
//
|
|
237
|
+
// `assertMethodMayMount` refuses a project-layer method because importing it
|
|
238
|
+
// is running a cloned repository's code. That refusal was FATAL, and driven
|
|
239
|
+
// at 220f288 it was wider than the threat: the run stopped whatever else was
|
|
240
|
+
// configured, so a clone carrying the key denied service to a method the
|
|
241
|
+
// MACHINE's owner had selected, with hand-editing JSON as the only exit --
|
|
242
|
+
// the one answer `config-write.ts:10-12` says the product exists to remove.
|
|
243
|
+
//
|
|
244
|
+
// DROPPED HERE RATHER THAN AT SELECTION, and that was measured. The obvious
|
|
245
|
+
// shape is `selectMethod` falling back through `snapshot('global')`, but
|
|
246
|
+
// `snapshot()` has ZERO production consumers outside the kernel's own
|
|
247
|
+
// definition, so that would make the method selection the product's only
|
|
248
|
+
// layer-by-layer reader. Dropping before composition costs no new resolution
|
|
249
|
+
// rule at all -- and it keeps `dump()` honest, which is the real prize: the
|
|
250
|
+
// composed view says what brambo ACTED ON, and a gate pins that no entry ever
|
|
251
|
+
// reports `method` decided by `project`.
|
|
252
|
+
//
|
|
253
|
+
// Only a document READ FROM DISK reaches here; one a host supplied composes
|
|
254
|
+
// into `agent`, which is that host's own code and stays mountable.
|
|
255
|
+
let document = entry.document;
|
|
256
|
+
if (layer === 'project' && isRecord(document) && typeof document[METHOD_CONFIG_KEY] === 'string') {
|
|
257
|
+
const { [METHOD_CONFIG_KEY]: recommended, ...rest } = document;
|
|
258
|
+
document = rest;
|
|
259
|
+
recommendation = { specifier: recommended, filePath: entry.filePath };
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
config.setLayer(layer, document);
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
throw unusable(entry.filePath, `the '${layer}' configuration layer rejected it: ${describeError(error)}`, error);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (supplied !== undefined) {
|
|
269
|
+
try {
|
|
270
|
+
config.setLayer('agent', supplied);
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
throw new BramboError(BRAMBO_ERROR_CODES.configurationUnusable, `the configuration this host supplied cannot be used: the 'agent' layer rejected it: ${describeError(error)}`, { cause: error });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (layers.invocation !== undefined)
|
|
277
|
+
config.setLayer('invocation', layers.invocation);
|
|
278
|
+
if (recommendation === undefined)
|
|
279
|
+
return undefined;
|
|
280
|
+
// WHAT IS USED INSTEAD IS READ FROM THE COMPOSED VIEW, NOT GUESSED. Reading
|
|
281
|
+
// `layers.global` would be a second answer to "which layer decides", and two
|
|
282
|
+
// answers is how the notice ends up naming a value the run does not use -- the
|
|
283
|
+
// exact failure `selectExecutor` takes value and provenance from ONE entry to
|
|
284
|
+
// avoid.
|
|
285
|
+
const decided = config.dump().find((entry) => entry.path.length === 1 && entry.path[0] === METHOD_CONFIG_KEY);
|
|
286
|
+
const using = typeof decided?.value === 'string' ? decided.value : undefined;
|
|
287
|
+
// FOLLOWING THE ADVICE HAS TO SILENCE THE NOTICE.
|
|
288
|
+
// `brambo swap method ./mine.mjs` run from the project stores the RESOLVED
|
|
289
|
+
// absolute path, so a user who adopted the recommendation would otherwise be
|
|
290
|
+
// told it was declined on every run, forever. Advice that nags after being
|
|
291
|
+
// taken is the same defect class as advice that does nothing, and this
|
|
292
|
+
// milestone found that one twice.
|
|
293
|
+
//
|
|
294
|
+
// `dirname` twice because the project document is `<projectDir>/.brambo/config.json`.
|
|
295
|
+
if (using !== undefined && resolve(dirname(dirname(recommendation.filePath)), recommendation.specifier) === using) {
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
return { key: METHOD_CONFIG_KEY, specifier: recommendation.specifier, filePath: recommendation.filePath, using };
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* The selection an already-seeded configuration decides, with the layer that
|
|
302
|
+
* decided it. Pure: it reads the composed view and touches no file.
|
|
303
|
+
*/
|
|
304
|
+
export function selectExecutor(config) {
|
|
305
|
+
// The value AND its provenance from ONE dump entry, so the two cannot disagree.
|
|
306
|
+
const decided = config
|
|
307
|
+
.dump()
|
|
308
|
+
.find((entry) => entry.path.length === 1 && entry.path[0] === EXECUTOR_CONFIG_KEY);
|
|
309
|
+
if (decided === undefined || typeof decided.value !== 'string') {
|
|
310
|
+
// Unreachable while `defaults` supplies a string at this path and every
|
|
311
|
+
// narrower layer was type-checked at its OWN file. Coded rather than
|
|
312
|
+
// asserted, because the alternative to a message here is `undefined`
|
|
313
|
+
// reaching the catalogue — and it names NO path, because the one it used to
|
|
314
|
+
// guess was the project's, which told the user to fix a file that was fine.
|
|
315
|
+
throw new BramboError(BRAMBO_ERROR_CODES.configurationUnusable, `brambo could not resolve an '${EXECUTOR_CONFIG_KEY}' selection through its configuration layers`);
|
|
316
|
+
}
|
|
317
|
+
if (!EXECUTOR_CATALOGUE.has(decided.value))
|
|
318
|
+
throw unknownExecutor(decided.value);
|
|
319
|
+
return { executorId: decided.value, layer: decided.layer, available: availableExecutorIds() };
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Which executor this run uses, resolved through the kernel's layered
|
|
323
|
+
* configuration: `defaults` -> `global` -> `project` -> `invocation`.
|
|
324
|
+
*
|
|
325
|
+
* This — not `runSession` — is what reads the filesystem. A session primitive
|
|
326
|
+
* whose behaviour depends on files under the running user's home is not usable
|
|
327
|
+
* from a host that already knows what it wants, and it would make every existing
|
|
328
|
+
* `brambo run` test depend on the `~/.brambo` of whoever ran the suite.
|
|
329
|
+
*
|
|
330
|
+
* Ships from `@skanl/brambo-session` beside `runSession`, so FR-29 holds: a third party
|
|
331
|
+
* imports this package and gets the selection AND the run, with no CLI involved.
|
|
332
|
+
*
|
|
333
|
+
* `brambo run` does NOT call this: it reads the layers once and hands them to
|
|
334
|
+
* `runSession`, which seeds the KERNEL's configuration and selects from that one
|
|
335
|
+
* composed document. The three steps are exactly the three this function performs.
|
|
336
|
+
*/
|
|
337
|
+
export async function resolveExecutor(options = {}) {
|
|
338
|
+
const layers = await readExecutorConfigLayers(options);
|
|
339
|
+
const config = createLayeredConfig();
|
|
340
|
+
seedExecutorConfig(config, layers);
|
|
341
|
+
return selectExecutor(config);
|
|
342
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { createSessionKernel, runSession, executeTool, SESSION_ACTION_COST, SESSION_ACTION_ID, type SandboxEvent, type ToolApproval, type ToolApprovalRequest, type ToolCompositionOptions, type ToolExecutionEvent, type ExecuteToolOptions, type SessionKernelOptions, type SessionOptions, } from './run-session.ts';
|
|
2
|
+
export { resolveExecutor, readExecutorConfigLayers, type ExecutorConfigDocument, type ExecutorConfigLayers, type ExecutorSelection, type ResolveExecutorOptions, } from './executors.ts';
|
|
3
|
+
export { resolveMethod, swapMethod } from './methods.ts';
|
|
4
|
+
export { createToolExecutor } from './tool-executor.ts';
|
|
5
|
+
export { createRemoteMcpClient, RemoteMcpError } from './remote-mcp.ts';
|
|
6
|
+
export type { RemoteMcpClient, RemoteMcpClientOptions, RemoteMcpResponse, StreamableHttpTransport } from './remote-mcp.ts';
|
|
7
|
+
export { selectWorkspaceProvider, worktreeStateDir, type WorkspaceProviderSelection } from './workspaces.ts';
|
|
8
|
+
export { inspectWorktrees, removeWorktree, type ClaimedWorktree, type InterruptedRemoval, type UnclaimedDirectory, type WorktreeInspection, type WorktreeOutcome, type WorktreeOutcomeKind, } from '@skanl/brambo-workspace-git-worktree';
|
|
9
|
+
export { inspectLocalWorkspaces, removeLocalWorkspace, type ClaimedLocalWorkspace, type InspectLocalWorkspacesOptions, type LocalWorkspaceInspection, type LocalWorkspaceOutcome, type LocalWorkspaceOutcomeKind, type UnclaimedLocalDirectory, } from '@skanl/brambo-workspace-local';
|
|
10
|
+
export { readUsageReports, recordUsageObservation, usageObservationsPath, type UsageStoreOptions, } from './usage.ts';
|
|
11
|
+
export type { ChildProcessSpawner, CliExecutorAdapterOptions, SpawnedChild, SpawnOptions, SpawnOutcome, } from '@skanl/brambo-adapter-cli';
|
|
12
|
+
export type { ExecutorAdapter, ResultEnvelope, SandboxCapabilityFacts, SandboxPolicy, SandboxProvider, RunRequest, UsageAbsence, UsageObservation, UsageReport, UsageWindow, WorkspaceHandle, WorkspaceProvider, ToolExecutionContext, ToolExecutor, ToolInvocation, ToolResult, } from '@skanl/brambo-contracts';
|
|
13
|
+
export { USAGE_ABSENCE_REASONS } from '@skanl/brambo-contracts';
|
|
14
|
+
export { createLogSink, createMemoryLogSink } from '@skanl/brambo-kernel';
|
|
15
|
+
export type { ActionPolicy, LogRecord, LogSink, MemoryLogSink, BramboKernel } from '@skanl/brambo-kernel';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export { createSessionKernel, runSession, executeTool, SESSION_ACTION_COST, SESSION_ACTION_ID, } from './run-session.js';
|
|
2
|
+
// The selection, beside the run it feeds (FR-29). A consumer that imports this
|
|
3
|
+
// package gets BOTH halves of `brambo run` — which executor, and the session —
|
|
4
|
+
// without `@skanl/brambo-cli`. `ExecutorSelection.available` carries the id list, so
|
|
5
|
+
// nothing else of the catalogue has to be on the surface to print alternatives.
|
|
6
|
+
export { resolveExecutor, readExecutorConfigLayers, } from './executors.js';
|
|
7
|
+
// `selectMethod` is deliberately NOT exported: `runSession` is its only caller,
|
|
8
|
+
// and publishing a surface nothing outside consumes is the defect the handoff
|
|
9
|
+
// records for four kernel exports that nothing reads.
|
|
10
|
+
//
|
|
11
|
+
// `swapMethod` IS exported although brambo's own CLI never passes an outgoing
|
|
12
|
+
// method — one CLI process has nothing mounted to unmount. FR-28's ordered swap
|
|
13
|
+
// is delivered to the audience the PRD names first ("brambo ships as an SDK
|
|
14
|
+
// first"): a host with a long-lived kernel. Unexported, the guarantee this story
|
|
15
|
+
// exists to provide would be reachable only from this package's own tests.
|
|
16
|
+
export { resolveMethod, swapMethod } from './methods.js';
|
|
17
|
+
export { createToolExecutor } from './tool-executor.js';
|
|
18
|
+
export { createRemoteMcpClient, RemoteMcpError } from './remote-mcp.js';
|
|
19
|
+
// The workspace selection, beside the executor one and for the same FR-29
|
|
20
|
+
// reason: a consumer that imports only this package can ask which provider a
|
|
21
|
+
// composed configuration names, without `@skanl/brambo-cli`.
|
|
22
|
+
//
|
|
23
|
+
// ONE value, and the trimming is the same call `resolveExecutor`'s block above
|
|
24
|
+
// records: `WorkspaceProviderSelection.available` carries the closed catalogue,
|
|
25
|
+
// so `availableWorkspaceProviderIds` and the id constants would be surface
|
|
26
|
+
// nothing outside consumes. `createSelectedWorkspacePlugin` stays unexported for
|
|
27
|
+
// the harder reason — it hands back a `PluginFactory`, and a factory a caller
|
|
28
|
+
// can invoke with an `ActivationContext` of its own is exactly the bypass
|
|
29
|
+
// surface the block below records five withdrawn exports for.
|
|
30
|
+
export { selectWorkspaceProvider, worktreeStateDir } from './workspaces.js';
|
|
31
|
+
// Taking a worktree back, beside the run that creates one (Story 4.3 / spec
|
|
32
|
+
// M16.A). `brambo run` under the git-worktree provider cuts a real worktree and
|
|
33
|
+
// nothing removed one; these are the two halves of the exit, and they are
|
|
34
|
+
// re-exported for the same FR-29 reason as everything above — a host that
|
|
35
|
+
// installed only this package can look at what brambo holds and take it back,
|
|
36
|
+
// without `@skanl/brambo-cli`. `worktreeStateDir` is what turns a project directory
|
|
37
|
+
// into the argument they take, so the three travel together.
|
|
38
|
+
//
|
|
39
|
+
// `WorktreeLedger` is deliberately NOT here. It is the store these two functions
|
|
40
|
+
// operate through, and publishing it would hand a caller `retire()` and
|
|
41
|
+
// `claimRemoval()` with none of the checks around them — the "no factory a
|
|
42
|
+
// caller can invoke" rule the block below records five withdrawn exports for,
|
|
43
|
+
// pointed at a destructive operation instead of a kernel.
|
|
44
|
+
export { inspectWorktrees, removeWorktree, } from '@skanl/brambo-workspace-git-worktree';
|
|
45
|
+
// The SAME pair for the DEFAULT provider (spec M27.A). `brambo run` under
|
|
46
|
+
// `local` — which is what runs when nothing selects otherwise — creates a
|
|
47
|
+
// directory per session and nothing removed one; these are the two halves of
|
|
48
|
+
// that exit, re-exported for the same FR-29 reason as the worktree pair above.
|
|
49
|
+
//
|
|
50
|
+
// BOTH pairs are on the surface, and that is D4 rather than duplication: a
|
|
51
|
+
// project that switched `workspace.provider` has leftovers of both kinds, so a
|
|
52
|
+
// verb that asked only the currently selected store would strand the other
|
|
53
|
+
// forever. `worktreeStateDir` is the argument both pairs take — `runSession`
|
|
54
|
+
// seeds it as `workspace.rootDir` for whichever provider is mounted — so the
|
|
55
|
+
// two stores are read out of one path and cannot look in different places.
|
|
56
|
+
export { inspectLocalWorkspaces, removeLocalWorkspace, } from '@skanl/brambo-workspace-local';
|
|
57
|
+
// The recorded quota reading, beside the run that produces it (Story M15.A,
|
|
58
|
+
// D7): `brambo run` records, `brambo status` reads, and nothing here invokes an
|
|
59
|
+
// executor. Both halves are exported for the same FR-29 reason as the two
|
|
60
|
+
// selections above — a host that installed only this package gets the whole
|
|
61
|
+
// pair without `@skanl/brambo-cli`.
|
|
62
|
+
export { readUsageReports, recordUsageObservation, usageObservationsPath, } from './usage.js';
|
|
63
|
+
// A VALUE, not a type: `UsageAbsence.reason` is routed on (AD-7), and a consumer
|
|
64
|
+
// that cannot name the codes would have to compare the strings by hand.
|
|
65
|
+
export { USAGE_ABSENCE_REASONS } from '@skanl/brambo-contracts';
|
|
66
|
+
// Two sink constructors, and neither is a factory in the sense the note below
|
|
67
|
+
// withdraws. `createMemoryLogSink` retains; `createLogSink` takes the caller's
|
|
68
|
+
// own write function and retains nothing — it is the bring-your-own-exporter
|
|
69
|
+
// door, and `SessionOptions.log` is the only thing either one is for. What was
|
|
70
|
+
// withdrawn was a factory a caller could invoke with an `ActivationContext` to
|
|
71
|
+
// get back a wired vendor adapter; a function from `LogWrite` to `LogSink`
|
|
72
|
+
// composes nothing and reaches no adapter.
|
|
73
|
+
export { createLogSink, createMemoryLogSink } from '@skanl/brambo-kernel';
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { type MethodActivation, type MethodPlugin } from '@skanl/brambo-contracts';
|
|
2
|
+
/**
|
|
3
|
+
* Loads a MethodPlugin from a module specifier and validates it against the
|
|
4
|
+
* published contract.
|
|
5
|
+
*
|
|
6
|
+
* The specifier is STORED verbatim — it may be a relative path or a bare package
|
|
7
|
+
* name, and normalising it would corrupt the second kind — but it is RESOLVED
|
|
8
|
+
* against `baseDir` before `import()` sees it (see {@link resolveFrom}), because
|
|
9
|
+
* a specifier the user wrote means what it means where the user is standing.
|
|
10
|
+
* `baseDir` defaults to `process.cwd()`; every caller in brambo passes the
|
|
11
|
+
* directory it was pointed at explicitly.
|
|
12
|
+
*
|
|
13
|
+
* Validation goes through `validateMethodPlugin` rather than a second copy of
|
|
14
|
+
* the rules, which is what makes M5.B's "every violation, not the first"
|
|
15
|
+
* guarantee reach an author here.
|
|
16
|
+
*
|
|
17
|
+
* A specifier that will not resolve is a CODED refusal, never an empty result:
|
|
18
|
+
* a broken selection that behaved like "no method selected" would run a
|
|
19
|
+
* different methodology than the one configured without saying so, which is the
|
|
20
|
+
* failure story 2.7c removed for `executor`.
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveMethod(specifier: string, baseDir?: string): Promise<MethodPlugin>;
|
|
23
|
+
/**
|
|
24
|
+
* Refuses to MOUNT a selection the `project` layer decided.
|
|
25
|
+
*
|
|
26
|
+
* `brambo run` used to import and EXECUTE a module named by the
|
|
27
|
+
* `.brambo/config.json` of the directory it was run in. Driven against a temp
|
|
28
|
+
* project holding a `hostile.mjs` whose only statement is a `writeFileSync`:
|
|
29
|
+
* the run exited 2 and the file existed, while the same project with no
|
|
30
|
+
* `method` key left it unwritten. Clone a repository, run brambo inside it, and
|
|
31
|
+
* you have run its author's code.
|
|
32
|
+
*
|
|
33
|
+
* A module cannot be inspected without being LOADED, so neither validation nor
|
|
34
|
+
* reordering can prevent this — `validateMethodPlugin` already refuses the
|
|
35
|
+
* manifest, and the top-level statements have run by then. The deciding LAYER is
|
|
36
|
+
* the only fact available before the import, and it is enough to separate a
|
|
37
|
+
* choice from an arrival: `global` is the machine owner's own document, `agent`
|
|
38
|
+
* is one a host handed over programmatically and is therefore that host's own
|
|
39
|
+
* code, and `project` is the one that travels with a clone.
|
|
40
|
+
*
|
|
41
|
+
* SECOND LINE OF DEFENCE SINCE M30.D, AND STILL LOAD-BEARING. The ordinary path
|
|
42
|
+
* no longer reaches this clause: `seedExecutorConfig` drops a `method` key from
|
|
43
|
+
* a project document READ FROM DISK, so composition yields the next layer and
|
|
44
|
+
* the run says what it declined. That was the fix for a refusal wider than its
|
|
45
|
+
* threat — driven, a project key stopped the run whatever else was configured,
|
|
46
|
+
* so a clone denied service to the machine owner's own selection.
|
|
47
|
+
*
|
|
48
|
+
* But a SUPPLIED kernel owns its configuration (`run-session.ts:51`) and never
|
|
49
|
+
* reaches admission, so a host that seeds its own `project` layer arrives here.
|
|
50
|
+
* Driven, not assumed: with this clause deleted that path RESOLVES — the module
|
|
51
|
+
* is imported and the run returns ok. `kernel-composition.test.ts` pins it by
|
|
52
|
+
* the side effect, because the first version of that clause asserted only the
|
|
53
|
+
* error code and stayed green with the guard deleted.
|
|
54
|
+
*
|
|
55
|
+
* AND AD-5 DOES NOT SAY WHAT THIS COMMENT USED TO SAY IT SAID. It read "REFUSED
|
|
56
|
+
* rather than ignored, per AD-5", treating the rule as a binary. AD-5 is typed
|
|
57
|
+
* absence over silence — unavailable is not failed — so its opposite of IGNORED
|
|
58
|
+
* is TYPED AND REPORTED, not FATAL. Only the silent skip would violate it, which
|
|
59
|
+
* is why admission reports what it dropped instead of dropping it quietly.
|
|
60
|
+
*
|
|
61
|
+
* THE PLACEMENT IS THE GUARANTEE. This must be called between `selectMethod`
|
|
62
|
+
* and `resolveMethod`. After the import there is nothing left to prevent, and a
|
|
63
|
+
* check moved there would still pass its own test — which is why the test for
|
|
64
|
+
* it is falsified by moving it, not only by deleting it.
|
|
65
|
+
*
|
|
66
|
+
* WHAT THIS DOES NOT DO, AND THE REASON CHANGED AFTER IT WAS DRIVEN: honour a
|
|
67
|
+
* project selection brambo ITSELF wrote via `project swap method`. That was
|
|
68
|
+
* recorded here as merely deferred — "needs ownership tracking on config writes"
|
|
69
|
+
* — and the roadmap ordered it first because it "removes a restriction rather
|
|
70
|
+
* than adding a mechanism". Both sentences are wrong.
|
|
71
|
+
*
|
|
72
|
+
* An ownership record would prove brambo wrote the NAME. The danger is the module
|
|
73
|
+
* BYTES, which no record covers and which any `git pull` replaces — and AD-6's
|
|
74
|
+
* records authorise REMOVAL (`ownedPaths` is "what makes a record authority for a
|
|
75
|
+
* removal"), never EXECUTION. Reading one here would also need
|
|
76
|
+
* `@skanl/brambo-session -> @skanl/brambo-projection`, an edge `packages/session/test/guard.test.ts`
|
|
77
|
+
* pins closed. So it is a mechanism, and it is a trust store wearing an ownership
|
|
78
|
+
* record's clothes; the honest version of it is the deferred per-directory trust
|
|
79
|
+
* decision, not a rider on this guard.
|
|
80
|
+
*
|
|
81
|
+
* What IS still open, and it is smaller and realer: this refusal is fatal, so a
|
|
82
|
+
* cloned repository carrying a `method` key denies service to a method the
|
|
83
|
+
* machine's owner selected for themselves. Falling back to the next layer and
|
|
84
|
+
* SAYING so would fix that — it renegotiates E1's frozen exit code, which is a
|
|
85
|
+
* story rather than a rider. Recorded in `deferred-work.md`.
|
|
86
|
+
*/
|
|
87
|
+
export declare function assertMethodMayMount(selected: {
|
|
88
|
+
readonly specifier: string;
|
|
89
|
+
readonly layer: string;
|
|
90
|
+
}): void;
|
|
91
|
+
/**
|
|
92
|
+
* The method a composed configuration selects, with the layer that decided it —
|
|
93
|
+
* the same shape and the same `dump()` read `selectExecutor` uses, so the two
|
|
94
|
+
* selections cannot disagree about what a layer means.
|
|
95
|
+
*
|
|
96
|
+
* THIS BLOCK USED TO SIT ABOVE `assertMethodMayMount`'s OWN JSDoc, so it bound to
|
|
97
|
+
* nothing: measured on the emitted surface, `dist/methods.d.ts` declared
|
|
98
|
+
* `selectMethod` with no documentation at all, and the guard's text cited "`selectMethod`'s
|
|
99
|
+
* own rule right below" for a rule that documented nothing and did not ship.
|
|
100
|
+
*
|
|
101
|
+
* `undefined` is the ORDINARY state in v1 and is not a failure: PRD §6.2 places
|
|
102
|
+
* methodologies post-v1, so most runs select none and must cost nothing. A
|
|
103
|
+
* selection that is present but not a usable string IS a failure, because a
|
|
104
|
+
* `method: 42` silently ignored is a run using a different methodology than the
|
|
105
|
+
* document names.
|
|
106
|
+
*
|
|
107
|
+
* It never sees a `project` layer from a document brambo read: `seedExecutorConfig`
|
|
108
|
+
* drops that key before composition, which is what keeps `dump()` honest about
|
|
109
|
+
* the layer brambo acted on.
|
|
110
|
+
*/
|
|
111
|
+
export declare function selectMethod(config: {
|
|
112
|
+
dump(): readonly {
|
|
113
|
+
readonly path: readonly string[];
|
|
114
|
+
readonly value: unknown;
|
|
115
|
+
readonly layer: string;
|
|
116
|
+
}[];
|
|
117
|
+
}): {
|
|
118
|
+
readonly specifier: string;
|
|
119
|
+
readonly layer: string;
|
|
120
|
+
} | undefined;
|
|
121
|
+
/**
|
|
122
|
+
* Mounts `incoming`, unmounting `outgoing` first — FR-28's ordering, and the
|
|
123
|
+
* only place it is provable.
|
|
124
|
+
*
|
|
125
|
+
* The outgoing teardown is AWAITED TO SETTLEMENT before the incoming hook is
|
|
126
|
+
* called. Not "started before": a swap that overlapped them would let a
|
|
127
|
+
* methodology's templates be removed while the next one's were being written,
|
|
128
|
+
* and the two orders are indistinguishable from the outside until they collide.
|
|
129
|
+
*
|
|
130
|
+
* A failed teardown REFUSES the swap instead of mounting the incoming anyway.
|
|
131
|
+
* A half-swapped environment is worse than a refused one, because nothing
|
|
132
|
+
* reports it: the outgoing believes it is unmounted, the incoming was never
|
|
133
|
+
* asked, and the next run inherits both beliefs.
|
|
134
|
+
*
|
|
135
|
+
* `outgoing` is `undefined` at a session start, which is the ordinary case.
|
|
136
|
+
*/
|
|
137
|
+
export declare function swapMethod(outgoing: MethodActivation | undefined, incoming: MethodPlugin): Promise<MethodActivation>;
|