bareloop 0.5.1 → 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/CHANGELOG.md +722 -1
- package/README.md +13 -3
- package/bareloop.context.md +311 -239
- package/package.json +3 -3
- package/src/clock.js +181 -0
- package/src/exits.js +8 -2
- package/src/index.js +23 -17
- package/src/job.js +231 -108
- package/src/ledger.js +28 -3
- package/src/plan.js +266 -20
- package/src/planrun.js +870 -101
- package/src/ralph.js +226 -7
- package/src/root.js +32 -12
- package/src/run.js +61 -390
- package/src/stall.js +252 -0
- package/src/tools.js +611 -0
- package/src/validate.js +35 -250
- package/types/clock.d.ts +109 -0
- package/types/index.d.ts +4 -6
- package/types/job.d.ts +83 -8
- package/types/ledger.d.ts +3 -1
- package/types/plan.d.ts +71 -7
- package/types/planrun.d.ts +41 -3
- package/types/ralph.d.ts +62 -4
- package/types/root.d.ts +10 -2
- package/types/run.d.ts +9 -17
- package/types/stall.d.ts +80 -0
- package/types/tools.d.ts +262 -0
- package/types/validate.d.ts +21 -38
- package/src/extract.js +0 -103
- package/src/interpret.js +0 -887
- package/types/extract.d.ts +0 -25
- package/types/interpret.d.ts +0 -201
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
|
+
};
|