bareloop 0.5.0 → 0.6.0

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/src/tools.js ADDED
@@ -0,0 +1,611 @@
1
+ // The worker tool menu — the machinery a plan step's worker is built from: the
2
+ // verb→tool map, the litectx retrieval tools, the gate's action translator, and
3
+ // the personas/strategy lines that ship with a granted verb (F19: capability
4
+ // without strategy is inert).
5
+ //
6
+ // This file WAS interpret.js, the config-v1 interpreter. That path — the
7
+ // operator-authored steps[] chain and the drafted workflow config — was deleted
8
+ // 2026-07-26 (PRD v1.32); what remains is the tool machinery the plan flow
9
+ // imports, kept because it is the worker's surface, not the dead path's.
10
+ //
11
+ // One trap encoded here, paid for in adaptlearn: `onLlmResult` is a Loop
12
+ // CONSTRUCTOR option — passed to run() it is silently ignored and the budget
13
+ // axis goes blind (F3). The consumer (planrun) wires it at construction.
14
+
15
+ import { createRequire } from 'node:module';
16
+ import { join, resolve, sep } from 'node:path';
17
+ import { homedir } from 'node:os';
18
+
19
+ const require = createRequire(import.meta.url);
20
+
21
+ const { createShellTools } = require('bare-agent/tools');
22
+ // litectx's rank-tiered render — a free function, not a ctx method (its contract's
23
+ // own recipe: the caller slices the chunk body, compress renders the signature tier)
24
+ const { compress: compressNode } = require('litectx');
25
+
26
+ import { extractArtifact, priceOf } from './text.js';
27
+ // The isolate verbs write MODEL-AUTHORED text into `<workdir>/.litectx` — a file
28
+ // inside the tree that outlives the step and reads back through ctx_recall. That is
29
+ // the hard line's own sentence ("a record that captures a key captures it forever"),
30
+ // so the scrub is IMPORTED, never injected: an injected redactor is one a future
31
+ // caller can forget to pass, and this leak has to be inexpressible, not optional.
32
+ // Same ONE inventory the spine channel and the close output ride through.
33
+ import { redactSecrets } from './validate.js';
34
+
35
+ /** @typedef {{body?: string|null, text?: string|null}} RecallHit litectx recall hit — body present only with `{body: true}` */
36
+
37
+ const PERSONA = 'You are a senior engineer. Reply with ONLY the complete contents of the requested JavaScript file — no markdown fences, no commentary. ESM.';
38
+ // The tool persona states the LOOP CONTRACT (F16): the worker is one attempt inside
39
+ // `while close-red and under-cap`, not a one-shot. Without knowing that, a model does
40
+ // the rational one-shot thing — read everything, be certain, then act — and the real
41
+ // run spent its ENTIRE budget on 12 rounds of reading without one write, never once
42
+ // reaching the close. Every round re-pays for every earlier tool result, so reads
43
+ // compound: that run's context grew 2k → 121k tokens and its last round cost $0.25.
44
+ // Telling the worker it will be re-run with the close's verdict makes an early,
45
+ // cheap, wrong attempt the rational move — which is exactly what the loop wants.
46
+ export const PERSONA_TOOLS = 'You are a senior engineer working in a repository through file tools. '
47
+ + 'ALWAYS use absolute paths — relative paths resolve against the process, not the repository, and will be denied. '
48
+ + 'You are ONE attempt inside an automated loop: when you finish, a test suite runs and, if it still fails, you are called again with its output. '
49
+ + 'So do not try to be certain before acting. Read only what you need to form your best hypothesis, make the change with the write tool, and stop. '
50
+ + 'A wrong cheap attempt is corrected by the next round; exhaustive reading is not — every file you read is re-sent on every later round and the run has a hard budget it can exhaust before you ever write. '
51
+ + 'Make the required changes with the write tool, then reply with a short summary of what you changed. Never put file contents in your reply.';
52
+
53
+ // The retrieval verbs only pay if the worker reaches for them INSTEAD of paging a file.
54
+ // F18 measured the failure they exist to fix: the worker read one 117 KB file NINE times
55
+ // and dragged 1.37 MB of source through context to reach an 8-line function. The tool
56
+ // descriptions carry the mechanics; the persona has to carry the STRATEGY, or a model
57
+ // with a familiar `read` and an unfamiliar `recall` will simply keep reading.
58
+ // (Module-level + exported since Layer 2: the plan-step worker shares the SAME
59
+ // personas/strategies — two spellings of the loop contract would drift, F16.)
60
+ export const RETRIEVAL_STRATEGY = '\nYou also have a repository index. To read a function, do NOT read its whole file: '
61
+ + 'call ctx_recall with the function name to get a pointer, then ctx_get with that pointer to read that function alone '
62
+ + '(it comes with its doc-comment, which states what the function is SUPPOSED to do — compare it against what the code does). '
63
+ + 'Reserve shell_read for whole files that are genuinely small, and for files you cannot name a symbol in yet. '
64
+ + 'Search only finds what you can NAME: a failing test names the symptom, not the cause, so read the failing test first, '
65
+ + 'see which function it calls, and recall THAT.';
66
+ // BA-13: like retrieval, the edit verb only pays if the worker reaches for it INSTEAD
67
+ // of a whole-file rewrite. F31 measured the default: 4 of 5 big-file whole-writes
68
+ // broke the tree, and every one was a rewrite to change ~one line. The tool
69
+ // description carries the mechanics; the persona carries the strategy.
70
+ export const EDIT_STRATEGY = '\nPrefer the edit tool over whole-file writes: quote the EXACT text to change (it must match exactly once) and its replacement. '
71
+ + 'Rewriting a whole file to change one line is how trees get broken and budgets get burned — reserve the write tool for genuinely new files.';
72
+
73
+ // ---- tool mode (2b): the spec-side grant menu mapped to the underlying tools ----
74
+ // read/grep/write are bare-agent's shell tools; the ctx_* verbs are litectx's,
75
+ // composed from the SAME LiteCtx the retrieval pair already uses. P (design
76
+ // record 2026-07-28) widened this from six to the full four-component catalog —
77
+ // every verb an EXISTING implementation (menu-is-inventory, hamr's #3), grouped:
78
+ // write write · edit (tree, writeScope fence)
79
+ // select read · grep · recall · get · impact · related · recent (read-only)
80
+ // compress compress · peek (read-only)
81
+ // isolate stash · remember · forget (store-write class, own action types)
82
+ export const TOOL_BY_VERB = Object.freeze({
83
+ read: 'shell_read', grep: 'shell_grep', write: 'shell_write', edit: 'shell_edit',
84
+ recall: 'ctx_recall', get: 'ctx_get',
85
+ impact: 'ctx_impact', related: 'ctx_related', recent: 'ctx_recent',
86
+ compress: 'ctx_compress', peek: 'ctx_peek',
87
+ stash: 'ctx_stash', remember: 'ctx_remember', forget: 'ctx_forget',
88
+ });
89
+ // L1 — the isolate verbs are the one worker surface that writes UNGATED bytes: the
90
+ // writeScope fence judges the tree, and a store write never passes it, so without a
91
+ // bound a worker can grow the on-disk store for as many rounds as the wallet funds.
92
+ // 64KB (hamr's threshold) stops degenerate store-dumping, not legitimate use:
93
+ // observed notes run hundreds of bytes to a few KB, and a >64KB "note" is a FILE —
94
+ // files belong in the tree, behind the fence.
95
+ // The residual, named honestly: this bounds ONE PAYLOAD, not the store. litectx
96
+ // upserts on the same key, so re-stashing an id is idempotent — but N distinct ids
97
+ // are N separate 64KB payloads, and the aggregate stays unbounded. An aggregate cap
98
+ // (per-run store bytes, or a key count) is a THRESHOLD, and thresholds are hamr's
99
+ // call — this is the known limit, not a defect to fix by picking a number here.
100
+ export const ISOLATE_MAX_BYTES = 65536;
101
+ export const CTX_TOOLS = Object.freeze(['ctx_recall', 'ctx_get', 'ctx_impact', 'ctx_related', 'ctx_recent', 'ctx_compress', 'ctx_peek', 'ctx_stash', 'ctx_remember', 'ctx_forget']);
102
+
103
+ // F19 applied per component: a granted verb ships WITH the line that says WHEN to
104
+ // reach for it, or the model keeps using the familiar tool. planrun composes the
105
+ // strategies whose component has at least one granted verb.
106
+ export const COMPONENT_STRATEGIES = Object.freeze({
107
+ select: '\nBefore changing a function, ask what depends on it: ctx_impact(<symbol>) names its callers and importers, '
108
+ + 'ctx_related(<path>) walks the import graph around a file, and ctx_recent lists recently-edited symbols. '
109
+ + 'A fix that type-checks but breaks a dependent you never looked at is the failure these exist to prevent.',
110
+ compress: '\nWhen you only need what a function IS rather than how it works, ctx_compress(<path>,<symbol>) returns its '
111
+ + 'signature and doc without the body — far cheaper than reading it. ctx_peek(<id>) shows the head and tail of '
112
+ + 'something you stashed without paying for its bytes again.',
113
+ isolate: '\nYour context is re-sent and re-billed every round. Park bulky intermediate results with ctx_stash(<id>,<text>) '
114
+ + 'and check them later with ctx_peek(<id>) instead of carrying them in conversation. Record a durable conclusion '
115
+ + 'with ctx_remember(<id>,<text>) so a later step can ctx_recall it; ctx_forget(<id>) retracts a note that proved wrong.',
116
+ });
117
+
118
+ // ---- F19, INVERTED: a strategy must never name a tool the grant lacks --------
119
+ // The paragraphs above are the FULL-COMPONENT prose, and they were emitted per
120
+ // COMPONENT: any one isolate verb lit a paragraph naming `ctx_peek` (a COMPRESS
121
+ // verb), either retrieval verb lit the two-step `ctx_recall` → `ctx_get`
122
+ // prescription, any one select verb named all three. So a worker granted `stash`
123
+ // alone was told to call a tool that was not on its menu — the inversion of F19,
124
+ // and worse than silence: the model spends rounds reaching for a menu entry that
125
+ // does not exist. planrun states the law for the select case ("a worker granted
126
+ // only `impact` must not be steered to tools it lacks"); `EDIT_STRATEGY`'s
127
+ // `granted.includes('edit')` is the house pattern. This assembles the same prose
128
+ // SENTENCE BY SENTENCE, each gated on its own verb.
129
+ //
130
+ // The full-menu text is unchanged BY TEST, not by comment: `strategyFor(TOOL_MENU)`
131
+ // is asserted byte-identical to the concatenation of the paragraphs above, so a
132
+ // partial grant can only ever see LESS, never different.
133
+
134
+ /** `a`, `a, and b`, `a, b, and c` — the clause grammar the select paragraph uses. */
135
+ const andJoin = (/** @type {string[]} */ xs) =>
136
+ xs.length < 2 ? (xs[0] ?? '') : `${xs.slice(0, -1).join(', ')}, and ${xs.at(-1)}`;
137
+
138
+ /** the select paragraph's per-verb clauses, in the order the paragraph reads them */
139
+ const SELECT_CLAUSES = /** @type {[string, string][]} */ ([
140
+ ['impact', 'ctx_impact(<symbol>) names its callers and importers'],
141
+ ['related', 'ctx_related(<path>) walks the import graph around a file'],
142
+ ['recent', 'ctx_recent lists recently-edited symbols'],
143
+ ]);
144
+
145
+ /**
146
+ * The strategy suffix for ONE worker's grant: every sentence that survives names
147
+ * only tools this worker can actually call. Reproduces the exported paragraphs
148
+ * exactly on a full grant.
149
+ * @param {string[]} granted the step's granted VERBS (the menu IS the grant, 2b)
150
+ * @returns {string} the system-prompt suffix, '' when nothing is granted
151
+ */
152
+ export function strategyFor(granted) {
153
+ const g = (/** @type {string} */ v) => granted.includes(v);
154
+ /** @type {string[]} */
155
+ const out = [];
156
+
157
+ if (g('edit')) out.push(EDIT_STRATEGY);
158
+
159
+ // retrieval — the two-step prescription needs BOTH verbs; each alone gets the
160
+ // half it can run. The `shell_read` sentence rides only with a granted `read`.
161
+ if (g('recall') || g('get')) {
162
+ const s = ['You also have a repository index.'];
163
+ if (g('recall') && g('get')) s.push('To read a function, do NOT read its whole file: call ctx_recall with the function name to get a pointer, then ctx_get with that pointer to read that function alone (it comes with its doc-comment, which states what the function is SUPPOSED to do — compare it against what the code does).');
164
+ else if (g('recall')) s.push('To find a function, do NOT page through whole files: call ctx_recall with the function name and it names the file and lines that define it.');
165
+ else s.push('To read a function, do NOT read its whole file: call ctx_get with its path and line range to read that function alone (it comes with its doc-comment, which states what the function is SUPPOSED to do — compare it against what the code does).');
166
+ if (g('read')) s.push('Reserve shell_read for whole files that are genuinely small, and for files you cannot name a symbol in yet.');
167
+ if (g('recall')) s.push('Search only finds what you can NAME: a failing test names the symptom, not the cause, so read the failing test first, see which function it calls, and recall THAT.');
168
+ out.push('\n' + s.join(' '));
169
+ }
170
+
171
+ // select — one clause per granted verb, joined with the paragraph's own grammar
172
+ const clauses = SELECT_CLAUSES.filter(([v]) => g(v)).map(([, t]) => t);
173
+ if (clauses.length) {
174
+ out.push(`\nBefore changing a function, ask what depends on it: ${andJoin(clauses)}. `
175
+ + 'A fix that type-checks but breaks a dependent you never looked at is the failure these exist to prevent.');
176
+ }
177
+
178
+ // compress — two independent sentences, one per verb
179
+ const cs = [];
180
+ if (g('compress')) cs.push('When you only need what a function IS rather than how it works, ctx_compress(<path>,<symbol>) returns its signature and doc without the body — far cheaper than reading it.');
181
+ if (g('peek')) cs.push('ctx_peek(<id>) shows the head and tail of something you stashed without paying for its bytes again.');
182
+ if (cs.length) out.push('\n' + cs.join(' '));
183
+
184
+ // isolate — the stash advice drops its ctx_peek half without `peek`, and the
185
+ // durable-note sentence drops its ctx_recall half without `recall` (both are
186
+ // OTHER components' verbs, which is exactly how the paragraph over-reached)
187
+ const is = [];
188
+ if (g('stash')) {
189
+ is.push(g('peek')
190
+ ? 'Park bulky intermediate results with ctx_stash(<id>,<text>) and check them later with ctx_peek(<id>) instead of carrying them in conversation.'
191
+ : 'Park bulky intermediate results with ctx_stash(<id>,<text>) instead of carrying them in conversation.');
192
+ }
193
+ const durable = [];
194
+ if (g('remember')) {
195
+ durable.push(g('recall')
196
+ ? 'Record a durable conclusion with ctx_remember(<id>,<text>) so a later step can ctx_recall it'
197
+ : 'Record a durable conclusion with ctx_remember(<id>,<text>) so a later step can find it');
198
+ }
199
+ if (g('forget')) durable.push('ctx_forget(<id>) retracts a note that proved wrong');
200
+ if (durable.length) is.push(durable.join('; ') + '.');
201
+ if (is.length) out.push('\nYour context is re-sent and re-billed every round. ' + is.join(' '));
202
+
203
+ return out.join('');
204
+ }
205
+
206
+ // L8 — ONE line space, named in the contract instead of renumbered (validated
207
+ // 2026-07-30 against a real index, not read off the source). Every line number
208
+ // litectx hands back is a 0-BASED index: a chunk's `startLine`/`endLine`
209
+ // (chunker.js: "0-based, inclusive"), impact's `defs[].startLine`, and impact's
210
+ // `callers[].line`. So a symbol whose doc-comment sits on editor line 5 prints
211
+ // as 4 — the off-by-one a worker would hit if it cross-referenced an editor.
212
+ // The tempting fix (render impact 1-based, since its numbers are display-only)
213
+ // was REFUTED by measurement: impact's def range dereferences through ctx_get
214
+ // verbatim, and ctx_recall prints the SAME number for the SAME chunk. They are
215
+ // one interchangeable handle space, and ctx_get refuses anything that is not a
216
+ // chunk boundary — so a 1-based impact would make two tools print two numbers
217
+ // for one chunk and turn a clean refusal into a guessing game. Consistency is
218
+ // the resolution; the description is where the worker learns the space.
219
+ const LINE_SPACE = 'Line numbers here are 0-BASED index positions from the repository index — one lower than the line an editor shows. '
220
+ + 'Copy them between ctx_recall, ctx_get and ctx_impact as-is; never adjust them, and never quote one as an editor line.';
221
+
222
+ /**
223
+ * The retrieval pair (F19). `shell_read` cannot seek — it starts at byte zero — so a
224
+ * pointer at a symbol was inert and the worker paged whole files to reach one function.
225
+ * `ctx_recall` hands back a POINTER (no body: a search index returns pointers, and bodies
226
+ * on every hit rebuild the bloat — measured, a 5-hit recall dumps ~15k tokens unbidden);
227
+ * `ctx_get` trades that pointer for ONE chunk. A chunk starts at its doc-comment, so the
228
+ * body arrives WITH the docstring that says what it was supposed to do.
229
+ * @param {any} lc the run's LiteCtx (rooted at workdir)
230
+ * @param {string} workdir
231
+ * @param {(type: string, data: object) => void} emit the spine — a retrieval verb whose
232
+ * RESULT is invisible cannot be judged: a `ctx_get` that silently reds (stale pointer,
233
+ * bad range) looks exactly like one that worked, and the worker's fallback to a
234
+ * whole-file read looks like a free choice instead of a forced one.
235
+ */
236
+ export function createCtxTools(lc, workdir, emit) {
237
+ // ctx_get's failure contract, generalised to the palette (review 2026-07-31).
238
+ // `emit` exists in this file because a verb whose RESULT is invisible cannot be
239
+ // judged — and a THROWN call is the most invisible result there is: it looks
240
+ // exactly like a verb that was never reached, so the worker's fall-back to a
241
+ // whole-file read reads as a free choice instead of a forced one (the F18
242
+ // blindness rule). The worker gets a refusal RESULT naming the cause, never a
243
+ // throw (L1: throws stay reserved for the BA-4 param-guard class), so a locked
244
+ // store or a corrupt index costs one round instead of the run.
245
+ // `fields` reads its args defensively — a field reader that throws would take
246
+ // the emit down with it, which is the hole this closes.
247
+ /** @param {string} tool @param {(args: any) => object} fields @param {(args: any) => Promise<string>} fn */
248
+ const guarded = (tool, fields, fn) => async (/** @type {any} */ args) => {
249
+ try {
250
+ return await fn(args);
251
+ } catch (e) {
252
+ const detail = String(/** @type {Error} */ (e)?.message || e);
253
+ emit('ctx-tool', { tool, ...fields(args), outcome: 'error', bytes: 0, detail });
254
+ return `${tool} failed: ${detail}`;
255
+ }
256
+ };
257
+ return [
258
+ {
259
+ name: 'ctx_recall',
260
+ description: 'Search the repository index for a symbol or phrase. Returns POINTERS (path, symbol, line range) — not code. '
261
+ + 'Pass a pointer to ctx_get to read that one function. Search finds what you can NAME: it will not find a bug from a failing '
262
+ + "test's output (the symptom and the cause live in different files) — read the failing test first, then recall the function it calls. "
263
+ + LINE_SPACE,
264
+ parameters: {
265
+ type: 'object',
266
+ properties: {
267
+ query: { type: 'string', description: 'Symbol name or phrase, e.g. "keywords" or "stopword filter".' },
268
+ n: { type: 'integer', description: 'Max pointers to return (default 5, max 20).' },
269
+ },
270
+ required: ['query'],
271
+ },
272
+ execute: async (/** @type {{query: string, n?: number}} */ { query, n }) => {
273
+ const q = String(query);
274
+ const cap = Math.min(Math.max(Number(n) || 5, 1), 20);
275
+ const hits = await lc.recall(q, { kind: 'code', n: cap });
276
+ // The memory axis rides the same verb (hardening find, 2026-07-30): the
277
+ // isolate persona promises "remember so a later step can ctx_recall it",
278
+ // and a recall pinned to kind:'code' made notes write-only through the
279
+ // tool surface. A note's BODY comes back inline — it is a conclusion,
280
+ // not a pointer, and there is no worker verb that dereferences a memory
281
+ // id — capped per note so a bloated note cannot page the context.
282
+ const notes = await lc.recall(q, { kind: 'fact', n: cap, body: true });
283
+ const lines = hits.map((/** @type {any} */ h) => (h.chunk
284
+ ? `${h.path}\t${h.chunk.symbol ?? '(anonymous)'}\t${h.chunk.nodeType}\tlines ${h.chunk.startLine}-${h.chunk.endLine}`
285
+ : `${h.path}\t(whole file — no chunk)`));
286
+ for (const h of notes) {
287
+ const body = typeof h.body === 'string' ? h.body : '';
288
+ lines.push(`memory\t${h.path}\t${body.length > 400 ? `${body.slice(0, 400)}… [note truncated at 400 chars]` : body}`);
289
+ }
290
+ const out = lines.length ? lines.join('\n') : 'no hits';
291
+ emit('ctx-tool', { tool: 'ctx_recall', query: q, hits: hits.length, notes: notes.length, paths: [...hits, ...notes].map((/** @type {any} */ h) => h.path), bytes: Buffer.byteLength(out) });
292
+ return out;
293
+ },
294
+ },
295
+ {
296
+ name: 'ctx_get',
297
+ description: 'Read ONE function by the line range ctx_recall gave you — code plus its doc-comment, without the rest of the file. '
298
+ + 'The line range is a HANDLE you copy from ctx_recall, never one you compute: a range that is not a chunk boundary is refused, '
299
+ + 'and a file edited since it was indexed is refused (re-run ctx_recall for a fresh pointer). '
300
+ + LINE_SPACE,
301
+ parameters: {
302
+ type: 'object',
303
+ properties: {
304
+ path: { type: 'string', description: 'Repository-relative path, exactly as ctx_recall printed it.' },
305
+ startLine: { type: 'integer', description: 'From ctx_recall.' },
306
+ endLine: { type: 'integer', description: 'From ctx_recall.' },
307
+ },
308
+ required: ['path', 'startLine', 'endLine'],
309
+ },
310
+ execute: async (/** @type {{path: string, startLine: number, endLine: number}} */ { path: p, startLine, endLine }) => {
311
+ // The path is spelled as recall printed it (repo-relative); the gate judges the
312
+ // resolved absolute path, exactly as toolAction resolves it. Boundary-aware
313
+ // prefix (workdir + sep, the line-247 pattern): bare startsWith would garble a
314
+ // sibling like `${workdir}-backup/x` into `ackup/x` — the gate independently
315
+ // denies such a path before execute() runs, so this is defense in depth only.
316
+ const rel = String(p).startsWith(workdir + sep) ? String(p).slice(workdir.length + 1) : String(p);
317
+ try {
318
+ const item = lc.get(rel, { startLine: Number(startLine), endLine: Number(endLine) });
319
+ if (!item?.text) {
320
+ emit('ctx-tool', { tool: 'ctx_get', path: rel, startLine, endLine, outcome: 'no-chunk', bytes: 0 });
321
+ return `no chunk at ${startLine}-${endLine} in "${rel}" — copy a line range from ctx_recall, do not compute one`;
322
+ }
323
+ emit('ctx-tool', { tool: 'ctx_get', path: rel, startLine, endLine, outcome: 'ok', bytes: Buffer.byteLength(item.text) });
324
+ return item.text;
325
+ } catch (e) {
326
+ // StalePointerError: the file changed after indexing, so these lines now describe
327
+ // DIFFERENT code. Its message IS the recovery instruction — hand it to the worker.
328
+ const detail = String(/** @type {Error} */ (e)?.message || e);
329
+ emit('ctx-tool', { tool: 'ctx_get', path: rel, startLine, endLine, outcome: 'stale', bytes: 0, detail });
330
+ return `stale pointer: ${detail}`;
331
+ }
332
+ },
333
+ },
334
+ {
335
+ name: 'ctx_impact',
336
+ description: 'Before changing a symbol, see what depends on it: callers, importers, and mention sites across the repository. '
337
+ + 'Returns a text readout of the blast radius — a fix that compiles but breaks an unseen dependent is what this prevents. '
338
+ + LINE_SPACE,
339
+ parameters: {
340
+ type: 'object',
341
+ properties: { symbol: { type: 'string', description: 'Function/class/identifier name, e.g. "addNums".' } },
342
+ required: ['symbol'],
343
+ },
344
+ execute: guarded('ctx_impact', (a) => ({ symbol: String(a?.symbol ?? '') }), async (/** @type {{symbol: string}} */ { symbol }) => {
345
+ const imp = await lc.impact(String(symbol));
346
+ if (!imp) {
347
+ emit('ctx-tool', { tool: 'ctx_impact', symbol: String(symbol), outcome: 'unknown-symbol', bytes: 0 });
348
+ return `no indexed symbol "${symbol}" — check the spelling with ctx_recall first`;
349
+ }
350
+ // The readout's real shape, checked against a live index (the POC's note said
351
+ // "defs/callers/callees are arrays of {path, line}" and was right for two of
352
+ // the three): defs are {path,startLine,endLine} and callers are {path,line,
353
+ // symbol} — but CALLEES are bare NAMES (litectx Impact.callees: string[]), so
354
+ // reading .path/.line off them printed `calls undefined:undefined` on every
355
+ // symbol that calls anything. Garbage a worker cannot tell from a pointer.
356
+ // confirmed/mentions are COUNTS; risk is the one-word summary.
357
+ const lines = [];
358
+ for (const d of imp.defs ?? []) lines.push(`defined\t${d.path}:${d.startLine}`);
359
+ for (const c of imp.callers ?? []) lines.push(`called by\t${c.path}:${c.line}${c.symbol ? ` (in ${c.symbol})` : ''}`);
360
+ for (const c of imp.callees ?? []) lines.push(`calls\t${c}`);
361
+ lines.push(`risk ${imp.risk} — ${imp.confirmed} confirmed caller(s), ${imp.mentions} mention(s)`);
362
+ // The hedges are not decoration — they are the other half of the count.
363
+ // litectx's impact is deliberately asymmetric (over-count safe, under-count
364
+ // dangerous), so "isolated / low risk" only ever ships HEDGED and `callers`
365
+ // may be capped; dropping the caveat leaves "risk low — 0 confirmed
366
+ // caller(s)" reading as a licence to change the symbol freely, which is the
367
+ // one dangerous act the asymmetry exists to prevent. Empty when litectx
368
+ // hedged nothing — a header with nothing under it is bytes the worker
369
+ // re-reads every call.
370
+ // Frozen BEFORE the caveats: `hits` means impact content (defs, callers,
371
+ // callees, risk) and keeps that meaning. A caveat qualifies the count, it
372
+ // is not more of it — reading lines.length after the loop would re-mean a
373
+ // shipped spine field the day a new line type ships, which is how a field
374
+ // stops measuring what its readers think it measures.
375
+ const hits = lines.length;
376
+ for (const h of imp.hedges ?? []) lines.push(`caveat\t${h}`);
377
+ const out = lines.join('\n');
378
+ emit('ctx-tool', { tool: 'ctx_impact', symbol: String(symbol), hits, bytes: Buffer.byteLength(out) });
379
+ return out;
380
+ }),
381
+ },
382
+ {
383
+ name: 'ctx_related',
384
+ description: 'Walk the import graph around one file: what it imports and what imports it (1 hop). '
385
+ + 'Use before editing a file to know its neighbourhood; use ctx_impact for a specific symbol instead.',
386
+ parameters: {
387
+ type: 'object',
388
+ properties: { path: { type: 'string', description: 'Repository-relative path, e.g. "src/chunker.js".' } },
389
+ required: ['path'],
390
+ },
391
+ execute: guarded('ctx_related', (a) => ({ path: String(a?.path ?? '') }), async (/** @type {{path: string}} */ { path: p }) => {
392
+ const rel = String(p).startsWith(workdir + sep) ? String(p).slice(workdir.length + 1) : String(p);
393
+ const r = lc.related(rel, { dir: 'both', hops: 1 });
394
+ if (!r || !r.items.length) {
395
+ emit('ctx-tool', { tool: 'ctx_related', path: rel, hits: 0, bytes: 0 });
396
+ return `no import edges recorded for "${rel}" — the file is isolated or the path is not indexed`;
397
+ }
398
+ const out = r.items.map((/** @type {any} */ i) => `${i.via === 'in' ? 'imported by' : 'imports'}\t${i.id}`).join('\n');
399
+ emit('ctx-tool', { tool: 'ctx_related', path: rel, hits: r.items.length, bytes: Buffer.byteLength(out) });
400
+ return out;
401
+ }),
402
+ },
403
+ {
404
+ name: 'ctx_recent',
405
+ description: 'List recently-edited symbols in this repository, most recent first — where the churn is.',
406
+ parameters: { type: 'object', properties: {} },
407
+ execute: guarded('ctx_recent', () => ({}), async () => {
408
+ const rows = lc.recentActivity() ?? [];
409
+ const out = rows.length
410
+ ? rows.map((/** @type {any} */ r) => `${r.id ?? r.symbol}\t${r.kind ?? ''}\tedits ${r.edits ?? '?'}`).join('\n')
411
+ : 'no recent edit activity recorded';
412
+ emit('ctx-tool', { tool: 'ctx_recent', hits: rows.length, bytes: Buffer.byteLength(out) });
413
+ return out;
414
+ }),
415
+ },
416
+ {
417
+ name: 'ctx_compress',
418
+ description: 'Read a function\'s SIGNATURE — declaration head plus doc-comment, implementation body elided. '
419
+ + 'Far cheaper than the body when you only need what it is, not how it works. Give the path and the symbol name.',
420
+ parameters: {
421
+ type: 'object',
422
+ properties: {
423
+ path: { type: 'string', description: 'Repository-relative path.' },
424
+ symbol: { type: 'string', description: 'The function/class name inside that file.' },
425
+ },
426
+ required: ['path', 'symbol'],
427
+ },
428
+ execute: guarded('ctx_compress', (a) => ({ path: String(a?.path ?? ''), symbol: String(a?.symbol ?? '') }), async (/** @type {{path: string, symbol: string}} */ { path: p, symbol }) => {
429
+ const rel = String(p).startsWith(workdir + sep) ? String(p).slice(workdir.length + 1) : String(p);
430
+ // recall→chunk→slice→compress: the contract's own recipe (compress takes a
431
+ // body the CALLER slices; recall/nodesForPath give the range, not the text)
432
+ const node = lc.getNode(rel);
433
+ const chunk = node?.chunks.find((/** @type {any} */ c) => c.symbol === String(symbol));
434
+ if (!chunk) {
435
+ emit('ctx-tool', { tool: 'ctx_compress', path: rel, symbol: String(symbol), outcome: 'no-symbol', bytes: 0 });
436
+ return `no symbol "${symbol}" in "${rel}" — ctx_recall the name to find where it lives`;
437
+ }
438
+ const file = lc.get(rel);
439
+ if (!file?.text) {
440
+ emit('ctx-tool', { tool: 'ctx_compress', path: rel, symbol: String(symbol), outcome: 'no-body', bytes: 0 });
441
+ return `"${rel}" has no readable body in the index`;
442
+ }
443
+ const body = file.text.split('\n').slice(chunk.startLine, chunk.endLine + 1).join('\n');
444
+ const sig = await compressNode({ text: body, format: node.format, symbol: String(symbol) }, { level: 'signature' });
445
+ emit('ctx-tool', { tool: 'ctx_compress', path: rel, symbol: String(symbol), bytes: Buffer.byteLength(sig), savedBytes: Buffer.byteLength(body) - Buffer.byteLength(sig) });
446
+ return sig;
447
+ }),
448
+ },
449
+ {
450
+ name: 'ctx_stash',
451
+ description: 'Park a payload OUT of your context under a key — it is stored, not lost, and costs nothing per round while parked. '
452
+ + 'Use for bulky intermediate results (long outputs, gathered evidence) you may need later. Retrieve its head/tail with ctx_peek. '
453
+ + `A payload over ${ISOLATE_MAX_BYTES} bytes is refused unparked — that size is a file, so write it to the tree instead.`,
454
+ parameters: {
455
+ type: 'object',
456
+ properties: {
457
+ id: { type: 'string', description: 'Key to park under, e.g. "step2-tsc-output".' },
458
+ text: { type: 'string', description: 'The payload to park.' },
459
+ },
460
+ required: ['id', 'text'],
461
+ },
462
+ execute: guarded('ctx_stash', (a) => ({ id: redactSecrets(a?.id) }), async (/** @type {{id: string, text: string}} */ { id, text }) => {
463
+ // Scrubbed BEFORE anything else touches it (the hard line): `.litectx` is a
464
+ // file inside the tree, it outlives the step that wrote it, and ctx_recall
465
+ // reads notes back inline — an append-only record that captures a key
466
+ // captures it forever. The KEY goes through the same scrub as the body, and
467
+ // every id-taking verb scrubs on LOOKUP too, or a stash would be unreachable
468
+ // by the id the worker actually spelled.
469
+ const key = redactSecrets(id);
470
+ const body = redactSecrets(text);
471
+ // …and the cap is measured on the SCRUBBED payload, because that is the one
472
+ // that enters the store: a size read off the pre-redaction text would report
473
+ // bytes that never landed (the F6/blind-instrument class), and the bound
474
+ // exists to bound the store.
475
+ const size = Buffer.byteLength(body);
476
+ // L1: over-cap is a REFUSAL RESULT, never a throw (throws stay reserved for
477
+ // the BA-4 param-guard class) — the worker is told the size and the limit so
478
+ // its retry is a DISTINCT call, and the store is left untouched.
479
+ if (size > ISOLATE_MAX_BYTES) {
480
+ emit('ctx-tool', { tool: 'ctx_stash', id: key, outcome: 'over-cap', bytes: 0, size });
481
+ return `ctx_stash: text is ${size} bytes — the limit is ${ISOLATE_MAX_BYTES} bytes per payload. Nothing was parked. `
482
+ + `Park the part you actually need later, or write the bulk to a file with the write tool and stash its path.`;
483
+ }
484
+ lc.stash(key, body);
485
+ const out = `parked ${size} bytes under "${key}"`;
486
+ emit('ctx-tool', { tool: 'ctx_stash', id: key, bytes: size });
487
+ return out;
488
+ }),
489
+ },
490
+ {
491
+ name: 'ctx_peek',
492
+ description: 'Look at the head and tail of a stashed payload WITHOUT paying for its full bytes. '
493
+ + 'The cheap check on something you parked with ctx_stash.',
494
+ parameters: {
495
+ type: 'object',
496
+ properties: { id: { type: 'string', description: 'The key it was stashed under.' } },
497
+ required: ['id'],
498
+ },
499
+ execute: guarded('ctx_peek', (a) => ({ id: redactSecrets(a?.id) }), async (/** @type {{id: string}} */ { id }) => {
500
+ // the SAME transform ctx_stash keyed with — scrubbing only on write would
501
+ // strand every payload whose id carried a token
502
+ const key = redactSecrets(id);
503
+ const p = lc.peek(key);
504
+ if (!p) {
505
+ emit('ctx-tool', { tool: 'ctx_peek', id: key, outcome: 'no-key', bytes: 0 });
506
+ return `nothing stashed under "${key}"`;
507
+ }
508
+ const out = `stash "${key}": ${p.bytes} bytes\nhead: ${p.head}\ntail: ${p.tail}`;
509
+ emit('ctx-tool', { tool: 'ctx_peek', id: key, bytes: Buffer.byteLength(out) });
510
+ return out;
511
+ }),
512
+ },
513
+ {
514
+ name: 'ctx_remember',
515
+ description: 'Record a durable note under a key — it survives this step and a later step can find it with ctx_recall. '
516
+ + 'Use for conclusions worth keeping (root causes, decisions), never for bulk data (use ctx_stash for that). '
517
+ + `A note over ${ISOLATE_MAX_BYTES} bytes is refused unrecorded — record the conclusion, not the evidence.`,
518
+ parameters: {
519
+ type: 'object',
520
+ properties: {
521
+ id: { type: 'string', description: 'Note key, e.g. "root-cause".' },
522
+ text: { type: 'string', description: 'The note.' },
523
+ },
524
+ required: ['id', 'text'],
525
+ },
526
+ execute: guarded('ctx_remember', (a) => ({ id: redactSecrets(a?.id) }), async (/** @type {{id: string, text: string}} */ { id, text }) => {
527
+ // scrubbed at the same seam as ctx_stash, and a note needs it harder: its
528
+ // body rides back INLINE through ctx_recall, so a captured token would be
529
+ // re-read into the context of every later step that searches near it
530
+ const key = redactSecrets(id);
531
+ const body = redactSecrets(text);
532
+ const size = Buffer.byteLength(body);
533
+ // L1, same bound as ctx_stash — and a note is the one that most needs it: a
534
+ // note's body rides back inline through ctx_recall, so an unbounded store
535
+ // write is also an unbounded future context read.
536
+ if (size > ISOLATE_MAX_BYTES) {
537
+ emit('ctx-tool', { tool: 'ctx_remember', id: key, outcome: 'over-cap', bytes: 0, size });
538
+ return `ctx_remember: text is ${size} bytes — the limit is ${ISOLATE_MAX_BYTES} bytes per note. Nothing was recorded. `
539
+ + `A note is a CONCLUSION, not a file: record what you concluded and leave the evidence it came from in the tree.`;
540
+ }
541
+ await lc.remember(key, body, { kind: 'fact', by: 'agent' });
542
+ emit('ctx-tool', { tool: 'ctx_remember', id: key, bytes: size });
543
+ return `remembered "${key}"`;
544
+ }),
545
+ },
546
+ {
547
+ name: 'ctx_forget',
548
+ description: 'Retract a note recorded with ctx_remember — use when a conclusion proved wrong.',
549
+ parameters: {
550
+ type: 'object',
551
+ properties: { id: { type: 'string', description: 'The note key to retract.' } },
552
+ required: ['id'],
553
+ },
554
+ execute: guarded('ctx_forget', (a) => ({ id: redactSecrets(a?.id) }), async (/** @type {{id: string}} */ { id }) => {
555
+ // a retraction addresses the key ctx_remember actually wrote (scrubbed), not
556
+ // the one the worker still holds in its context — same transform, both doors
557
+ const key = redactSecrets(id);
558
+ const n = lc.forget(key);
559
+ emit('ctx-tool', { tool: 'ctx_forget', id: key, removed: n });
560
+ return `forgot ${n} note(s) under "${key}"`;
561
+ }),
562
+ },
563
+ ];
564
+ }
565
+
566
+ // The load-bearing containment line (2b POC, scenario B): bare-agent's built-in
567
+ // tools are deliberately ungated and their action type is their own NAME, which
568
+ // never trips bareguard's fs primitives — this translator maps them onto
569
+ // write/read actions so the SAME fence text mode enforces manually governs
570
+ // every tool call. Paths resolve exactly as the tools resolve them
571
+ // (path.resolve(expandHome(p)), POC scenario F) so the gate judges the same
572
+ // file the tool would touch; a relative spelling resolves against the process
573
+ // cwd and reds at the fence, and the deny reason teaches the retry.
574
+ /** @param {string} p */
575
+ const expandHome = (p) => (p === '~' || p.startsWith('~/')) ? join(homedir(), p.slice(1)) : p;
576
+ /** @param {string} name @param {any} args @param {string} [workdir] */
577
+ export const toolAction = (name, args, workdir) => {
578
+ if (name === 'shell_write') return { type: 'write', path: resolve(expandHome(String(args?.path ?? ''))), args: { bytes: String(args?.content ?? '').length } };
579
+ // shell_edit (BA-13) is judged as bareguard's own 'edit' action type — the SAME
580
+ // fs.writeScope fence as write, decided upstream (bareguard FS_TYPES). `bytes` is
581
+ // the replacement's size so the audit can read the edit economy (a 40-byte splice
582
+ // vs an 8KB rewrite is the variable BA-13 exists to move — F18 blindness rule).
583
+ if (name === 'shell_edit') return { type: 'edit', path: resolve(expandHome(String(args?.path ?? ''))), args: { bytes: String(args?.newText ?? '').length } };
584
+ // `tool` rides the action so the AUDIT can tell the read tools apart. Without it every
585
+ // read tool collapses to {type:'read', path} and a whole-file shell_read is
586
+ // indistinguishable from a bounded ctx_get chunk — the ledger cannot see the one
587
+ // variable the retrieval arm exists to test (the F18 blindness, repeated).
588
+ if (name === 'shell_read' || name === 'shell_grep') return { type: 'read', path: resolve(expandHome(String(args?.path ?? ''))), args: { tool: name } };
589
+ // The retrieval verbs are READS and are judged as reads by the SAME fence (F14/F19):
590
+ // ctx_get names a file, so the gate judges that file — the deny list (gate audit,
591
+ // primitive smoke, the litectx store itself) applies to a chunk read exactly as it
592
+ // applies to shell_read, or the worker would read the arbiter's books through the
593
+ // other door. ctx_recall names no file: it is a read OF THE INDEX, judged against the
594
+ // run directory, so it is contained by readScope and cannot reach outside the repo.
595
+ if (name === 'ctx_get') return { type: 'read', path: resolve(String(workdir ?? ''), expandHome(String(args?.path ?? ''))), args: { tool: name } };
596
+ if (name === 'ctx_recall') return { type: 'read', path: resolve(String(workdir ?? '')), args: { tool: name } };
597
+ // P's select/compress verbs are READS like the retrieval pair: the path-naming ones
598
+ // are judged against the named file (the deny list applies through every door), the
599
+ // index-wide ones against the run directory. `tool` rides along for the audit (F18).
600
+ if (name === 'ctx_related' || name === 'ctx_compress') return { type: 'read', path: resolve(String(workdir ?? ''), expandHome(String(args?.path ?? ''))), args: { tool: name } };
601
+ if (name === 'ctx_impact' || name === 'ctx_recent' || name === 'ctx_peek') return { type: 'read', path: resolve(String(workdir ?? '')), args: { tool: name } };
602
+ // Isolate verbs write to the litectx STORE, never the tree — they get their OWN
603
+ // action types (default-allow through the gate; admission is the signed spec's
604
+ // tools array). Judging them as 'write' would count store parks as tree writes in
605
+ // the F32 workerWrites instrument and corrupt worker-crash routing; judging them
606
+ // as 'read' would hide a write-class act from the audit. `bytes` rides so the
607
+ // audit can read the isolate economy.
608
+ if (name === 'ctx_stash' || name === 'ctx_remember') return { type: name, args: { id: String(args?.id ?? ''), bytes: String(args?.text ?? '').length } };
609
+ if (name === 'ctx_forget') return { type: name, args: { id: String(args?.id ?? '') } };
610
+ return { type: name, args };
611
+ };