@henols/vice-mcp 0.2.0 → 0.2.2

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.
Files changed (49) hide show
  1. package/README.md +2 -1
  2. package/THIRD-PARTY-NOTICES.md +1 -1
  3. package/anno-acme-ident.ts +97 -0
  4. package/anno-cli.ts +1465 -0
  5. package/anno-confidence.ts +233 -0
  6. package/anno-coverage.ts +2465 -0
  7. package/anno-d64.ts +310 -0
  8. package/anno-derive.ts +590 -0
  9. package/anno-details.ts +169 -0
  10. package/anno-enum-gen.ts +533 -0
  11. package/anno-export-asm.ts +1310 -0
  12. package/anno-index.ts +150 -0
  13. package/anno-memmap-render.ts +672 -0
  14. package/anno-regbits-gen.ts +421 -0
  15. package/anno-regbits.json +1370 -0
  16. package/anno-register.ts +240 -0
  17. package/anno-store.ts +3486 -0
  18. package/anno-symbols.ts +266 -0
  19. package/anno-tools.ts +2111 -0
  20. package/anno-types.ts +1636 -0
  21. package/block-class.ts +201 -0
  22. package/build.ts +1 -1
  23. package/capability-registry.ts +3 -1
  24. package/disasm-decoder.ts +14 -14
  25. package/disasm-opcodes.ts +4 -4
  26. package/disasm-renderer.ts +2 -2
  27. package/hostpath.ts +1 -1
  28. package/install-resources.ts +1 -1
  29. package/package.json +23 -3
  30. package/prg-image.ts +119 -0
  31. package/repo-root.ts +20 -5
  32. package/resources/broker-launch.mjs +8 -4
  33. package/resources/vice-launcher.sh +3 -3
  34. package/stock-address.ts +5 -5
  35. package/stock-cia.ts +2 -2
  36. package/stock-condition.ts +7 -7
  37. package/stock-connect.ts +1 -1
  38. package/stock-dispatch.ts +35 -5
  39. package/stock-execution.ts +5 -3
  40. package/stock-input.ts +9 -9
  41. package/stock-machine.ts +17 -6
  42. package/stock-protocol.ts +16 -11
  43. package/stock-registers.ts +54 -29
  44. package/stock-sprites.ts +3 -3
  45. package/stock-symbols.ts +33 -9
  46. package/stock-timing.ts +1 -1
  47. package/stock-vicii.ts +1 -1
  48. package/version.ts +1 -1
  49. package/vice-proxy.ts +168 -0
package/anno-tools.ts ADDED
@@ -0,0 +1,2111 @@
1
+ #!/usr/bin/env node
2
+ // anno-tools.ts
3
+ //
4
+ // WHAT THIS IS THE ONE AUTHORITATIVE PLACE FOR: the curated `anno_*` tool
5
+ // surface. The `AnnoToolDefinition`s themselves (`ANNO_TOOL_DEFINITIONS`), the
6
+ // allow-list DERIVED from them (`CURATED_ANNO_TOOLS`), its enforcement
7
+ // (`assertAnnoTool()`) together with the per-verb argument validators that gate
8
+ // shares with the batch verb, the caller-supplied store-path validation, and
9
+ // the runner (`runAnnoTool()`) that opens the owned annotation store, answers
10
+ // exactly one call against it, and closes it again. No other module may
11
+ // hand-list a curated `anno_*` name, hand-validate an `anno_*` store path, or
12
+ // reach `anno-store.ts` on behalf of an MCP call -- `vice-proxy.ts` imports
13
+ // `ANNO_TOOL_DEFINITIONS` and `runAnnoTool` from here and nothing else.
14
+ //
15
+ // WHY THIS FILE EXISTS, in the words of the decisions that shaped it:
16
+ //
17
+ // D-05 (ONE PREFIX). `anno_` names the tools, `anno-` names the modules.
18
+ // There is no second annotation family advertised alongside this one: the
19
+ // registration loop in `vice-proxy.ts` was SUBSTITUTED, not appended to, so
20
+ // an agent never has to choose between two surfaces over the same subject.
21
+ // `stock-dispatch.test.ts`'s ordered two-entry `BACKEND_SEAM_BYPASS_KEYS`
22
+ // goes red the instant a second family is registered beside this one.
23
+ //
24
+ // D-06 (OPEN/CLOSE PER CALL, EXPLICIT `store` ON EVERY VERB). This module
25
+ // holds NO module-level store handle and no ambient "current store" -- every
26
+ // verb takes `store` as an argument, `runAnnoTool()` opens it, and the
27
+ // `finally` below closes it on every path including the throwing one. That
28
+ // is why there is no session to crash, no revision to go stale between
29
+ // calls, and nothing for a second concurrent caller to corrupt: the store is
30
+ // open for the duration of one tool call and not one instruction longer.
31
+ //
32
+ // D-07 (EVERY DERIVED READ NAMES ITS OWN IMAGE). The store holds
33
+ // annotations, never program bytes. Every verb that derives an answer FROM
34
+ // the bytes -- the disassembly, the region read, the binary info, the
35
+ // cross-references, the search, the address details -- takes an explicit
36
+ // `image` path. An optional-argument-with-fallback hybrid was rejected
37
+ // outright: an omitted argument would read as a plausible-looking success
38
+ // against whatever image happened to be recorded last.
39
+ //
40
+ // D-09 (THERE IS NO CURSOR, ANYWHERE). Upstream's own procedure text says
41
+ // never to rely on a current cursor address, and this project has no editor
42
+ // to have one. The verb that would have exposed it is folded into
43
+ // `anno_disassemble`'s explicit address argument. Nothing on this surface --
44
+ // no identifier, no schema property, no dispatch branch -- names a cursor or
45
+ // a current address, and `anno-tools.test.ts` asserts that over this file's
46
+ // comment-and-string-stripped source so this paragraph cannot satisfy the
47
+ // check by containing the word.
48
+ //
49
+ // MCP-02 (THE HOST-PATH SEAM IS UNREACHABLE FROM HERE, BY CONSTRUCTION).
50
+ // CLAUDE.md requires derived tools to be intercepted before
51
+ // `forwardToVice()`, because `rewriteArguments()` runs inside it and would
52
+ // hand a container-translated path to a runner acting proxy-locally. This
53
+ // family needs no such interception: `runAnnoTool()` is registered through
54
+ // `buildViceTool()` directly, so it can never reach `forwardToVice()`,
55
+ // `call()` or `ensureViceSession()`, and this module must never import
56
+ // `hostpath.ts` -- `hostpath-consumers.test.ts` names it as forbidden and
57
+ // keeps that consumer set at exactly five modules. Both the store path and
58
+ // the image path are PROXY-LOCAL filesystem paths and translating either
59
+ // would point this code at a file on the wrong side of the container
60
+ // boundary.
61
+ //
62
+ // TWO REFUSAL CHANNELS, AND THE DIFFERENCE IS DELIBERATE:
63
+ //
64
+ // 1. AN INVALID ARGUMENT resolves `{isError:true}` naming the
65
+ // `AnnoStoreError` subclass that fired. The caller passed something this
66
+ // surface cannot act on, and it should not send it again unchanged.
67
+ // 2. A WELL-FORMED REQUEST THIS SURFACE CANNOT ANSWER resolves
68
+ // `{isError:false}` carrying `{available:false, reason}` in the body.
69
+ // That is not a caller error -- the question was legal, the answer is
70
+ // "no". Returning `isError:true` for it teaches an agent to retry
71
+ // something that will never succeed; returning `[]` or `0` for it is the
72
+ // plausible-looking zero MCP-04 exists against. Every reason names what
73
+ // was asked for, why it cannot be answered, and where the nearest
74
+ // answerable thing lives, in the shape `stock-cia.ts:116-124` established
75
+ // and at the >= 40-character length `check-skill-tool-coverage.mjs:285`
76
+ // already enforces in CI.
77
+ //
78
+ // `anno_batch_execute` IS THE ONE SANCTIONED NESTED-ARGUMENT VERB ON THIS
79
+ // SURFACE, AND NO SECOND MAY JOIN IT. A meta-tool that takes an arbitrary tool
80
+ // name inside its own arguments is precisely the smuggling shape `vice.ts`'s
81
+ // `DENY_LIST` exists to close: the outer name passes the gate while the inner
82
+ // name never sees it. This one verb earns the exception by being the only
83
+ // route to the multi-edit pass an annotation run actually performs, and it
84
+ // pays for it with `assertAnnoBatch()` below -- a recursive, DEPTH-CAPPED
85
+ // pre-validator that refuses the WHOLE batch, before any store is opened, if
86
+ // anything at any depth is wrong. Adding a second such verb would reopen the
87
+ // hole this one closes.
88
+ //
89
+ // WHAT NOT TO DO:
90
+ // - Never hand-type a second list of curated names. `CURATED_ANNO_TOOLS` is
91
+ // derived from `ANNO_TOOL_DEFINITIONS`'s own `name` values precisely so a
92
+ // name cannot be curated in one place and absent from the other (T-29-02).
93
+ // - Never widen `CURATED_ANNO_TOOLS` without adding the definition here with
94
+ // a named criterion. The gate's FIRST statement is set membership; a name
95
+ // that is not in the set is refused before any argument is looked at.
96
+ // - Never re-implement an argument rule the store already owns. Addresses go
97
+ // through `parseStoreAddress`, ranges through `assertRangeShape`, data
98
+ // types through `assertDataType`, label names through `assertLegalLabel`,
99
+ // comment text through `assertCommentText`, enum names through
100
+ // `assertEnumName`. A second, divergent rule here would accept a value the
101
+ // store then refuses, or the reverse, and the disagreement would be
102
+ // invisible because both look authoritative.
103
+ // - Never sanitize. An illegal label, enum name or comment is REJECTED by
104
+ // name, never quoted, trimmed, coerced or normalized into a legal one:
105
+ // the store's printed name must never diverge from the symbol an export
106
+ // would emit (T-29-23).
107
+ // - Never add a second comment-length check or a truncation. The byte bound
108
+ // is `assertCommentText()`'s and it is measured in UTF-8 BYTES, not code
109
+ // units; this layer adds nothing on top of it.
110
+ // - Never map `changed: false` to an error. A repeated identical edit
111
+ // SUCCEEDING while reporting no change is the store's own idempotency, and
112
+ // an agent re-running an annotation pass must not have to diff first.
113
+ // - Never drop `contradictedComments` or `reinterpretedSplitTables` from
114
+ // `anno_set_data_type`'s body. 28-VERIFICATION.md hands this phase the
115
+ // obligation in writing: the disclosure must be SURFACED where the human
116
+ // sees it, or the human never sees it. A success that quietly drops it is
117
+ // exactly the plausible-looking clean answer this surface forbids.
118
+ // - Never move `assertAnnoTool()` out of `runAnnoTool()`'s `try`. That
119
+ // asymmetry is WR-02, recorded as out of scope at `anno-tools.ts:772-774`
120
+ // and CLOSED here: inside the `try`, a refusal RESOLVES `{isError:true}`
121
+ // like every other failure instead of REJECTING the returned promise, so
122
+ // the caller has one shape to handle rather than two.
123
+ // - Never resolve a store or image path with `resolve()` + `startsWith`.
124
+ // Containment goes through `storePathWithinWorkspace()`, which resolves the
125
+ // deepest EXISTING ancestor's realpath (WR-01) -- a not-yet-existing leaf
126
+ // under a directory symlink escaped the naive form entirely.
127
+ // - Never hold the handle beyond the call, and never open a store outside a
128
+ // `try`/`finally` that closes it (T-29-03).
129
+ // - Never collapse a failure into a bare string. The runner's catch names
130
+ // the error CLASS, so a caller can tell an `AnnoStoreCorruptError` from an
131
+ // `AnnoStorePathError` from the text alone (T-29-04, D18-12).
132
+ //
133
+ import { existsSync, readFileSync, statSync } from "node:fs";
134
+ import { extname } from "node:path";
135
+
136
+ import {
137
+ addScope,
138
+ applyEnumUsage,
139
+ clearEnumUsage,
140
+ closeStore,
141
+ createProjectEnum,
142
+ currentRevision,
143
+ listComments,
144
+ listEnumUsage,
145
+ listLabels,
146
+ listProjectEnums,
147
+ listRanges,
148
+ listScopes,
149
+ openStore,
150
+ removeScope,
151
+ setComment,
152
+ setDataType,
153
+ setLabel,
154
+ updateProjectEnum,
155
+ } from "./anno-store.ts";
156
+ import type { AnnoStoreHandle } from "./anno-store.ts";
157
+ import {
158
+ AnnoRevisionArgumentError,
159
+ AnnoStoreError,
160
+ AnnoStorePathError,
161
+ assertCommentText,
162
+ assertCommentType,
163
+ assertDataType,
164
+ assertEnumName,
165
+ assertLabelKind,
166
+ assertLegalLabel,
167
+ assertRangeShape,
168
+ parseStoreAddress,
169
+ storePathWithinWorkspace,
170
+ } from "./anno-types.ts";
171
+ import type { AnnoStoreErrorOptions, CommentRow, LabelRow } from "./anno-types.ts";
172
+ import { crossReferencesTo, searchAnnotations } from "./anno-derive.ts";
173
+ import { composeAddressDetails } from "./anno-details.ts";
174
+ import { decode } from "./disasm-decoder.ts";
175
+ import { render } from "./disasm-renderer.ts";
176
+ import { flatImageOrigin, parsePrg } from "./prg-image.ts";
177
+ import { repoRoot } from "./repo-root.ts";
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // The wire shapes this module produces/consumes. Deliberately NOT imported
181
+ // from vice-proxy.ts (that file has no exported ToolDefinition/ToolCallResult
182
+ // -- both are file-local types there); these are structurally identical so a
183
+ // value built here is interchangeable wherever vice-proxy.ts combines it with
184
+ // its own manifest-sourced tools.
185
+ // ---------------------------------------------------------------------------
186
+
187
+ export interface AnnoToolDefinition {
188
+ name: string;
189
+ description: string;
190
+ inputSchema: {
191
+ type: "object";
192
+ properties: Record<string, unknown>;
193
+ required?: string[];
194
+ };
195
+ // Structural compatibility with vice.ts's own ToolInfo (vice-proxy.ts's
196
+ // ToolDefinition alias), which carries this index signature -- lets
197
+ // vice-proxy.ts's buildViceTool() accept an AnnoToolDefinition directly,
198
+ // with no per-call cast at the registration site.
199
+ [key: string]: unknown;
200
+ }
201
+
202
+ interface ToolCallResult {
203
+ content: { type: "text"; text: string }[];
204
+ isError: boolean;
205
+ }
206
+
207
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
208
+ return typeof value === "object" && value !== null && !Array.isArray(value);
209
+ }
210
+
211
+ function okText(text: string): ToolCallResult {
212
+ return { content: [{ type: "text", text }], isError: false };
213
+ }
214
+
215
+ function errText(text: string): ToolCallResult {
216
+ return { content: [{ type: "text", text }], isError: true };
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Refusals. Both are `AnnoStoreError` subclasses and therefore `ViceError`s --
221
+ // never a bare `Error` -- so one `catch` can take the whole family, and the
222
+ // runner's `[${errName}]` prefix below names which member fired.
223
+ // ---------------------------------------------------------------------------
224
+
225
+ export interface AnnoUncuratedToolErrorOptions extends AnnoStoreErrorOptions {
226
+ toolName?: string;
227
+ batchIndex?: number;
228
+ }
229
+
230
+ /** A tool name outside `CURATED_ANNO_TOOLS` was dispatched, directly or as an
231
+ * inner call of a batch. The message names BOTH resolution routes, so the
232
+ * refusal is actionable without reading this file. */
233
+ export class AnnoUncuratedToolError extends AnnoStoreError {
234
+ toolName?: string;
235
+ batchIndex?: number;
236
+
237
+ constructor(message: string, { toolName, batchIndex, ...rest }: AnnoUncuratedToolErrorOptions = {}) {
238
+ super(message, rest);
239
+ this.name = "AnnoUncuratedToolError";
240
+ this.toolName = toolName;
241
+ this.batchIndex = batchIndex;
242
+ }
243
+ }
244
+
245
+ export interface AnnoToolArgumentErrorOptions extends AnnoStoreErrorOptions {
246
+ toolName?: string;
247
+ argument?: string;
248
+ batchIndex?: number;
249
+ }
250
+
251
+ /** A curated tool was called with an argument the transport cannot have
252
+ * checked. `vice-proxy.ts:3230`'s `validate: (value) => ({ value })` means the
253
+ * MCP transport validates NOTHING -- `required` in an `inputSchema` is
254
+ * documentation for the model, not an enforced contract -- so every required
255
+ * argument is re-checked here, at the only boundary that actually runs. */
256
+ export class AnnoToolArgumentError extends AnnoStoreError {
257
+ toolName?: string;
258
+ argument?: string;
259
+ batchIndex?: number;
260
+
261
+ constructor(message: string, { toolName, argument, batchIndex, ...rest }: AnnoToolArgumentErrorOptions = {}) {
262
+ super(message, rest);
263
+ this.name = "AnnoToolArgumentError";
264
+ this.toolName = toolName;
265
+ this.argument = argument;
266
+ this.batchIndex = batchIndex;
267
+ }
268
+ }
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // Shared argument helpers. `batchIndex` is threaded through EVERY validator so
272
+ // one refusal message serves both call routes: `anno_set_label_name refused:`
273
+ // when the verb was called directly, `anno_set_label_name refused (calls[3]):`
274
+ // when it was smuggled inside a batch payload. That is the shared-validator
275
+ // discipline `anno-tools.ts:790-800` records -- one validator per verb, called
276
+ // from both sites, so a refusal fires identically either way.
277
+ // ---------------------------------------------------------------------------
278
+
279
+ function argBag(args: unknown): Record<string, unknown> {
280
+ return isPlainObject(args) ? args : {};
281
+ }
282
+
283
+ function whereOf(batchIndex?: number): string {
284
+ return batchIndex !== undefined ? ` (calls[${batchIndex}])` : "";
285
+ }
286
+
287
+ function refuseArg(name: string, argument: string, detail: string, batchIndex?: number): never {
288
+ throw new AnnoToolArgumentError(`${name} refused${whereOf(batchIndex)}: ${detail}`, { toolName: name, argument, batchIndex });
289
+ }
290
+
291
+ /** Narrows the universally-required `store` argument to a non-empty string.
292
+ * Path CONTAINMENT is a separate concern and lives in `resolveWorkspacePath()`
293
+ * below; this only establishes that there is a path to contain. */
294
+ function assertStoreArg(name: string, args: unknown, batchIndex?: number): string {
295
+ const bag = argBag(args);
296
+ if (typeof bag.store !== "string" || bag.store.trim() === "") {
297
+ refuseArg(
298
+ name,
299
+ "store",
300
+ '"store" must be a non-empty string naming an annotation store -- every anno_* verb names its own store (D-06), ' +
301
+ "because there is no ambient current store to inherit.",
302
+ batchIndex,
303
+ );
304
+ }
305
+ return bag.store as string;
306
+ }
307
+
308
+ /** Narrows `max_results` to a positive integer. Required, with no default:
309
+ * see the description on each list-returning definition for why a silent
310
+ * default is worse than a refusal here. */
311
+ function assertMaxResults(name: string, args: unknown, batchIndex?: number): number {
312
+ const raw = argBag(args).max_results;
313
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw <= 0) {
314
+ refuseArg(
315
+ name,
316
+ "max_results",
317
+ `"max_results" must be a positive integer, got ${JSON.stringify(raw)} -- it is REQUIRED and has no default on ` +
318
+ "this surface, so a truncated answer is always an explicit ceiling.",
319
+ batchIndex,
320
+ );
321
+ }
322
+ return raw as number;
323
+ }
324
+
325
+ /** Requires a present argument and hands it to `parseStoreAddress` -- the ONE
326
+ * address parser, which owns the `$`/`0x` forms and the deliberate refusal of
327
+ * an unprefixed numeric string. Absence is a DIFFERENT fact from malformity,
328
+ * so it gets its own refusal rather than being folded into the parser's. */
329
+ function assertAddressArg(name: string, args: unknown, key: string, batchIndex?: number): number {
330
+ const raw = argBag(args)[key];
331
+ if (raw === undefined) {
332
+ refuseArg(name, key, `"${key}" is required and was not supplied.`, batchIndex);
333
+ }
334
+ return parseStoreAddress(raw, { what: key });
335
+ }
336
+
337
+ /** The inclusive span two verbs in three share. Both ends go through the one
338
+ * address parser; the SHAPE (ends inside the address space, end not below
339
+ * start, and -- for a split layout -- the even-byte-count rule) goes through
340
+ * `assertRangeShape`, which owns all three. */
341
+ function assertSpanArgs(name: string, args: unknown, dataType: Parameters<typeof assertRangeShape>[2], batchIndex?: number): { start: number; end: number } {
342
+ const start = assertAddressArg(name, args, "start_address", batchIndex);
343
+ const end = assertAddressArg(name, args, "end_address", batchIndex);
344
+ assertRangeShape(start, end, dataType);
345
+ return { start, end };
346
+ }
347
+
348
+ /** Validates the optional `base_revision` compare-and-swap argument.
349
+ *
350
+ * `anno-store.ts`'s own `assertRevisionArgument` is module-private, so this
351
+ * throws that module's OWN exported `AnnoRevisionArgumentError` rather than a
352
+ * fourth class: WR-22's recorded failure was a revision-shaped argument
353
+ * (`"0001"`) surviving as far as SQLite, whose INTEGER affinity turned an
354
+ * argument error into a corruption refusal. A caller must be able to tell
355
+ * "you passed the wrong thing" from "the annotations are gone" BY CLASS. */
356
+ function assertBaseRevisionArg(name: string, args: unknown, batchIndex?: number): number | undefined {
357
+ const raw = argBag(args).base_revision;
358
+ if (raw === undefined) return undefined;
359
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
360
+ throw new AnnoRevisionArgumentError(
361
+ `${name} refused${whereOf(batchIndex)}: "base_revision" must be a non-negative integer, got ${JSON.stringify(raw)} -- ` +
362
+ "a numeric STRING in particular is refused here rather than left to SQLite's column affinity, which turns an argument " +
363
+ "error into a corruption refusal (WR-22).",
364
+ { value: raw, parameter: "base_revision" },
365
+ );
366
+ }
367
+ return raw;
368
+ }
369
+
370
+ /** Validates a label `name` argument through the ONE identifier rule
371
+ * (`assertLegalLabel`) and re-throws as an `AnnoToolArgumentError` carrying the
372
+ * offending name and, inside a batch, the offending index. REJECT, NEVER
373
+ * SANITIZE (T-29-23): substituting a character would merge this name with
374
+ * whatever the substitution produces, and nothing would record that it
375
+ * happened -- the store's printed name must never diverge from the symbol an
376
+ * export would emit. */
377
+ function assertLegalLabelArg(name: string, args: unknown, batchIndex?: number): void {
378
+ const raw = argBag(args).name;
379
+ try {
380
+ assertLegalLabel(raw);
381
+ } catch (err) {
382
+ const reason = err instanceof Error ? err.message : String(err);
383
+ refuseArg(
384
+ name,
385
+ "name",
386
+ `${JSON.stringify(raw)} is not a legal ACME identifier (${reason}) -- REJECTED, never sanitized or quoted.`,
387
+ batchIndex,
388
+ );
389
+ }
390
+ }
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // The curated tool definitions.
394
+ //
395
+ // `store` is on EVERY definition and is always required (D-06). There is no
396
+ // "current store" for a verb to inherit, which is what makes a call's effect a
397
+ // function of its own arguments alone. Each description is written for an
398
+ // AGENT: what the verb answers, what it costs, and what it will refuse.
399
+ // ---------------------------------------------------------------------------
400
+
401
+ /** How deep a nested `anno_batch_execute` may go before the payload is refused
402
+ * by name rather than walked (T-29-24). Four levels is far past any legitimate
403
+ * use -- a batch of batches of batches has no procedure behind it -- and is
404
+ * chosen to be obviously sufficient rather than tuned. */
405
+ export const ANNO_MAX_BATCH_DEPTH = 4;
406
+
407
+ const STORE_PROPERTY = {
408
+ store: {
409
+ type: "string",
410
+ description:
411
+ "Absolute or workspace-relative path to the .annostore annotation store. Refused if it resolves outside the " +
412
+ "workspace root, including via a symlink. REQUIRED on every verb: there is no ambient 'current store'.",
413
+ },
414
+ } as const;
415
+
416
+ const IMAGE_PROPERTY = {
417
+ image: {
418
+ type: "string",
419
+ description:
420
+ "Absolute or workspace-relative path to the program image this answer is DERIVED from -- a .prg (2-byte " +
421
+ "little-endian load address plus payload) or an exactly-65536-byte flat capture (.raw/.bin, dispatched by " +
422
+ "extension before any length check). REQUIRED on every derived read (D-07): the store holds annotations and " +
423
+ "never bytes, so an omitted image would read as a plausible success against whatever was recorded last. " +
424
+ "Refused if it resolves outside the workspace root, including via a symlink.",
425
+ },
426
+ } as const;
427
+
428
+ const BASE_REVISION_PROPERTY = {
429
+ base_revision: {
430
+ type: "integer",
431
+ description:
432
+ "Optional compare-and-swap guard: the revision this edit was computed against. The write is refused with a " +
433
+ "named stale-revision error if the store has moved on. Omit it for an unconditional write. A numeric STRING " +
434
+ "is refused rather than coerced.",
435
+ },
436
+ } as const;
437
+
438
+ export const ANNO_TOOL_DEFINITIONS: readonly AnnoToolDefinition[] = [
439
+ {
440
+ name: "anno_set_label_name",
441
+ description:
442
+ "Binds a name to one address in the annotation store, so a disassembly reads as `jsr irq_handler` rather than " +
443
+ "`jsr $c000`. Costs one store open, one write and one close. REFUSES, never rewrites: a name that is not a legal " +
444
+ "ACME identifier (letter or underscore, then letters/digits/underscores) or that is a 6502/6510 mnemonic is " +
445
+ "rejected with the offending name in the message, because the store's printed name must never diverge from the " +
446
+ "symbol an export would emit. Also refuses a name already bound to a DIFFERENT address rather than rebinding it. " +
447
+ "Setting the same name at the same address again SUCCEEDS and reports `changed: false` -- re-running an " +
448
+ "annotation pass is not an error.",
449
+ inputSchema: {
450
+ type: "object",
451
+ properties: {
452
+ ...STORE_PROPERTY,
453
+ address: {
454
+ description:
455
+ "The address to name. An integer 0..65535, a \"$hex\" string, or a \"0x\" string; an unprefixed numeric " +
456
+ "string is refused on purpose, because a mis-based address written into the store is persistent and silently wrong.",
457
+ },
458
+ name: {
459
+ type: "string",
460
+ description:
461
+ "The label name. Must be a legal ACME identifier and must not be a 6502/6510 mnemonic. An illegal name is " +
462
+ "REJECTED, never sanitized or quoted.",
463
+ },
464
+ kind: {
465
+ type: "string",
466
+ enum: ["User", "Auto", "System", "Platform"],
467
+ description:
468
+ "Provenance of the name. 'User' (the default when omitted) = a human chose it; 'Auto' = generated; " +
469
+ "'System'/'Platform' = a known ROM or hardware name.",
470
+ },
471
+ ...BASE_REVISION_PROPERTY,
472
+ },
473
+ required: ["store", "address", "name"],
474
+ },
475
+ },
476
+ {
477
+ name: "anno_set_comment",
478
+ description:
479
+ "Stores a comment at one address, replacing whatever that placement held. 'line' comments sit on their own line " +
480
+ "before the instruction; 'side' comments sit inline on the same line. The two placements coexist at one address. " +
481
+ "Carrier for the [confirmed-code]/[probable-code]/[confirmed-data]/[probable-data]/[unknown] confidence-prefix " +
482
+ "convention. Do NOT include a leading ';' -- the store holds the words and the exporter adds the prefix, so a " +
483
+ "stored ';' would be emitted twice and is refused. Over-long text is REFUSED rather than truncated, and the bound " +
484
+ "is measured in UTF-8 BYTES, so a multi-byte comment is bounded by what actually lands in the file. A " +
485
+ "byte-identical repeat SUCCEEDS and reports `changed: false`.",
486
+ inputSchema: {
487
+ type: "object",
488
+ properties: {
489
+ ...STORE_PROPERTY,
490
+ address: { description: "The address to comment. Integer, \"$hex\" or \"0x\" string; an unprefixed numeric string is refused." },
491
+ comment: { type: "string", description: "The comment text, without the ';' prefix." },
492
+ type: {
493
+ type: "string",
494
+ enum: ["line", "side"],
495
+ description: "'line' = own line before the instruction. 'side' = inline on the same line.",
496
+ },
497
+ ...BASE_REVISION_PROPERTY,
498
+ },
499
+ required: ["store", "address", "comment", "type"],
500
+ },
501
+ },
502
+ {
503
+ name: "anno_set_data_type",
504
+ description:
505
+ "Types an inclusive address range, preserving whatever the overlapping rows said about the addresses outside it. " +
506
+ "A SUCCESSFUL result can carry two disclosures, and both are always present in the body: `contradictedComments` " +
507
+ "names comments whose recorded confidence now contradicts the type just applied, and `reinterpretedSplitTables` " +
508
+ "names every split table this write FRAGMENTED, with the entry-address pairs it read before, the pairs each " +
509
+ "surviving remainder reads now, and the pairs preserved. Neither is an error and neither is dropped: a split " +
510
+ "table's entries re-pair as a function of the row's start AND its length, so a fragment decodes to different " +
511
+ "16-bit values than the ones a human recorded, and a success that hid that would be worse than a refusal. " +
512
+ "A split layout REFUSES an odd byte count (the low half and the high half must be the same length). Retyping the " +
513
+ "same range the same way SUCCEEDS and reports `changed: false`.",
514
+ inputSchema: {
515
+ type: "object",
516
+ properties: {
517
+ ...STORE_PROPERTY,
518
+ start_address: { description: "Start of the range, INCLUSIVE. Integer, \"$hex\" or \"0x\" string." },
519
+ end_address: { description: "End of the range, INCLUSIVE. A one-byte range has end_address === start_address." },
520
+ data_type: {
521
+ type: "string",
522
+ enum: [
523
+ "code",
524
+ "byte",
525
+ "word",
526
+ "address",
527
+ "petscii",
528
+ "screencode",
529
+ "lo_hi_address",
530
+ "hi_lo_address",
531
+ "lo_hi_word",
532
+ "hi_lo_word",
533
+ "external_file",
534
+ "undefined",
535
+ ],
536
+ description:
537
+ "code=6502/6510 instructions; byte=raw 8-bit data (sprites, charset, tables, unknowns); word=16-bit LE " +
538
+ "values; address=16-bit LE pointers (produces cross-references, use for jump tables and vectors); " +
539
+ "petscii=PETSCII text; screencode=screen-code text; lo_hi_address=split address table, low bytes first " +
540
+ "then high bytes (even count required); hi_lo_address=split address table, high bytes first (even count " +
541
+ "required); lo_hi_word=split word table, low bytes first (e.g. SID frequency tables); hi_lo_word=split " +
542
+ "word table, high bytes first; external_file=large binary blob to export as-is; undefined=reset the range " +
543
+ "to unknown.",
544
+ },
545
+ ...BASE_REVISION_PROPERTY,
546
+ },
547
+ required: ["store", "start_address", "end_address", "data_type"],
548
+ },
549
+ },
550
+ {
551
+ name: "anno_add_scope",
552
+ description:
553
+ "Adds a lexical scope over an inclusive range, so symbols inside it are local to it. Nested and overlapping " +
554
+ "scopes are UNSUPPORTED by the schema this store mirrors and are REFUSED, naming both the incoming span and the " +
555
+ "existing scope's id and span; the incoming scope is neither trimmed nor split. Two scopes that merely TOUCH at " +
556
+ "a boundary are disjoint and both accepted. An identical repeat SUCCEEDS and reports `changed: false`. " +
557
+ "MIND THE ENDS: one transposed end (say $1000..$ffff instead of $1000..$10ff) makes every later scope above that " +
558
+ "start refuse -- use anno_remove_scope to undo it rather than burning revisions off the 32-deep snapshot ring.",
559
+ inputSchema: {
560
+ type: "object",
561
+ properties: {
562
+ ...STORE_PROPERTY,
563
+ start_address: { description: "Start of the scope, INCLUSIVE. Integer, \"$hex\" or \"0x\" string." },
564
+ end_address: { description: "End of the scope, INCLUSIVE." },
565
+ ...BASE_REVISION_PROPERTY,
566
+ },
567
+ required: ["store", "start_address", "end_address"],
568
+ },
569
+ },
570
+ {
571
+ name: "anno_remove_scope",
572
+ description:
573
+ "Removes the scope whose span is EXACTLY start_address..end_address -- the inverse of anno_add_scope, and the " +
574
+ "recovery route for a transposed span, which would otherwise be undoable only by reverting through the 32-deep " +
575
+ "snapshot ring. The span must match both stored ends exactly: a scope is never trimmed, split or partially " +
576
+ "removed, because a partial removal would leave a shape nothing downstream can express while reporting success. " +
577
+ "Read the stored spans with anno_get_blocks (include: [\"scopes\"]) first if you are unsure. Removing a scope " +
578
+ "that is not there SUCCEEDS and reports `changed: false`.",
579
+ inputSchema: {
580
+ type: "object",
581
+ properties: {
582
+ ...STORE_PROPERTY,
583
+ start_address: { description: "Start of the scope to remove, INCLUSIVE. Must match the stored start exactly." },
584
+ end_address: { description: "End of the scope to remove, INCLUSIVE. Must match the stored end exactly." },
585
+ ...BASE_REVISION_PROPERTY,
586
+ },
587
+ required: ["store", "start_address", "end_address"],
588
+ },
589
+ },
590
+ {
591
+ name: "anno_get_symbols",
592
+ description:
593
+ "Returns labels held in an annotation store, in ascending insertion order, optionally narrowed to " +
594
+ "an address range. Every call names its own store (there is no ambient 'current store') and the " +
595
+ "store is opened and closed within the call. `max_results` is REQUIRED and has no default on this " +
596
+ "surface: pass an explicit ceiling and compare the returned count against it to detect truncation.",
597
+ inputSchema: {
598
+ type: "object",
599
+ properties: {
600
+ ...STORE_PROPERTY,
601
+ max_results: {
602
+ type: "integer",
603
+ description:
604
+ "Maximum number of labels to return. REQUIRED -- no default on this surface, so a truncated " +
605
+ "answer is always the caller's own explicit ceiling rather than a silent one.",
606
+ },
607
+ start_address: {
608
+ description:
609
+ "Optional lower bound (inclusive) of the address range to filter by. An integer 0..65535, a " +
610
+ "\"$hex\" string, or a \"0x\" string; an unprefixed numeric string is refused.",
611
+ },
612
+ end_address: {
613
+ description:
614
+ "Optional upper bound (inclusive) of the address range to filter by. Same accepted forms as " +
615
+ "start_address.",
616
+ },
617
+ },
618
+ required: ["store", "max_results"],
619
+ },
620
+ },
621
+ {
622
+ name: "anno_get_comments",
623
+ description:
624
+ "Returns stored comments, each with its address, its placement ('line' or 'side') and its text, in ascending " +
625
+ "insertion order. Filters are combined with AND: specific `addresses`, an inclusive `start_address`/`end_address` " +
626
+ "window, and a placement `type`. The confidence-prefix convention lives in the returned text -- filter by prefix " +
627
+ "on your own side, or use anno_search. `max_results` is REQUIRED with no default; the true match count is " +
628
+ "returned beside the truncated list, so truncation is a fact you are told rather than one you infer.",
629
+ inputSchema: {
630
+ type: "object",
631
+ properties: {
632
+ ...STORE_PROPERTY,
633
+ max_results: { type: "integer", description: "Maximum number of comments to return. REQUIRED -- no default on this surface." },
634
+ addresses: {
635
+ type: "array",
636
+ description: "Optional list of specific addresses. Integers, \"$hex\" or \"0x\" strings; unprefixed numeric strings are refused.",
637
+ },
638
+ start_address: { description: "Optional lower bound (inclusive) of the address window." },
639
+ end_address: { description: "Optional upper bound (inclusive) of the address window." },
640
+ type: { type: "string", enum: ["line", "side"], description: "Optional placement filter." },
641
+ },
642
+ required: ["store", "max_results"],
643
+ },
644
+ },
645
+ {
646
+ name: "anno_get_blocks",
647
+ description:
648
+ "Returns the typed ranges (blocks) this store holds -- each with its inclusive span and its data type -- " +
649
+ "optionally narrowed by `block_type`. This is also the read route for the store's other structural annotations: " +
650
+ "pass `include` to add `scopes` (every lexical scope's id and span, which anno_remove_scope needs to match " +
651
+ "exactly), `enums` (every project enum with its variants mapping) and `enum_usage` (every address-to-enum " +
652
+ "association, with the enum's name resolved through its id at read time). `max_results` is REQUIRED with no " +
653
+ "default and bounds the RANGE list; the true match count is returned beside it.",
654
+ inputSchema: {
655
+ type: "object",
656
+ properties: {
657
+ ...STORE_PROPERTY,
658
+ max_results: { type: "integer", description: "Maximum number of ranges to return. REQUIRED -- no default on this surface." },
659
+ block_type: {
660
+ type: "string",
661
+ description: "Optional exact data-type filter, e.g. 'code' or 'lo_hi_address'. Must be one of the twelve data types.",
662
+ },
663
+ include: {
664
+ type: "array",
665
+ items: { type: "string", enum: ["scopes", "enums", "enum_usage"] },
666
+ description:
667
+ "Optional extra structural annotations to return alongside the ranges. Each is returned whole (these " +
668
+ "collections are small by construction), so they are not governed by max_results.",
669
+ },
670
+ },
671
+ required: ["store", "max_results"],
672
+ },
673
+ },
674
+ {
675
+ name: "anno_create_project_enum",
676
+ description:
677
+ "Creates a project-local enum -- a name, a variants mapping and an optional description -- embedded in the " +
678
+ "annotation store rather than anywhere machine-global. Variant keys are numeric strings in decimal, 0x/$ hex or " +
679
+ "0b/% binary; two keys naming the SAME number are refused, because that would mean two variant names for one " +
680
+ "value and nothing downstream could say which. A name already held with DIFFERENT contents is refused rather " +
681
+ "than overwritten -- use anno_update_project_enum, which replaces the variants mapping wholesale and says so. " +
682
+ "An identical re-create SUCCEEDS and reports `changed: false`. The body returns every enum the store now holds.",
683
+ inputSchema: {
684
+ type: "object",
685
+ properties: {
686
+ ...STORE_PROPERTY,
687
+ name: { type: "string", description: "Unique identifier: a letter or underscore, then letters/digits/underscores. Refused, never sanitized." },
688
+ variants: {
689
+ type: "object",
690
+ description: "Variant mapping. Keys are numeric strings (decimal, 0x/$ hex, 0b/% binary); values are variant names.",
691
+ },
692
+ description: { type: "string", description: "Optional summary explaining the enum's purpose." },
693
+ ...BASE_REVISION_PROPERTY,
694
+ },
695
+ required: ["store", "name", "variants"],
696
+ },
697
+ },
698
+ {
699
+ name: "anno_update_project_enum",
700
+ description:
701
+ "Renames a project enum, replaces its variants mapping, replaces its description, or any combination. THE " +
702
+ "VARIANTS MAPPING IS REPLACED WHOLESALE when supplied, never merged: a merge would make a variant impossible to " +
703
+ "REMOVE, since there would be no way to express its absence. A rename onto a name another enum already holds is " +
704
+ "refused rather than merging two enums into one. Renaming does NOT orphan an enum usage: usages are associated " +
705
+ "by enum id, not by name. Updating an enum that does not exist is refused. A no-op update SUCCEEDS and reports " +
706
+ "`changed: false`. The body returns every enum the store now holds.",
707
+ inputSchema: {
708
+ type: "object",
709
+ properties: {
710
+ ...STORE_PROPERTY,
711
+ name: { type: "string", description: "Existing name of the enum to update." },
712
+ new_name: { type: "string", description: "Optional new name. Same identifier rule; refused, never sanitized." },
713
+ variants: { type: "object", description: "Optional COMPLETE replacement variants mapping. Omit to leave the mapping alone." },
714
+ description: { type: "string", description: "Optional replacement description." },
715
+ ...BASE_REVISION_PROPERTY,
716
+ },
717
+ required: ["store", "name"],
718
+ },
719
+ },
720
+ {
721
+ name: "anno_apply_enum_usage",
722
+ description:
723
+ "Associates one address with one project enum, so an immediate operand or constant reference at that address " +
724
+ "formats as a variant name. OMITTING `name`, or passing an empty string, CLEARS the association at that address " +
725
+ "instead -- that is the schema's own contract for this verb, and clearing an address that carries none SUCCEEDS " +
726
+ "reporting `changed: false`. One address carries at most one enum, so applying a different enum REPLACES rather " +
727
+ "than refuses. Applying an enum that does not exist is refused rather than creating it implicitly, because a " +
728
+ "mistyped name would otherwise become a real, empty enum that formats nothing and looks deliberate. The body " +
729
+ "returns every address-to-enum association the store now holds.",
730
+ inputSchema: {
731
+ type: "object",
732
+ properties: {
733
+ ...STORE_PROPERTY,
734
+ address: { description: "The instruction address. Integer, \"$hex\" or \"0x\" string; an unprefixed numeric string is refused." },
735
+ name: { type: "string", description: "The enum to apply. OMIT, or pass an empty string, to CLEAR the association at this address." },
736
+ ...BASE_REVISION_PROPERTY,
737
+ },
738
+ required: ["store", "address"],
739
+ },
740
+ },
741
+ {
742
+ name: "anno_save_project",
743
+ description:
744
+ "Reports the store's current revision. IT PERFORMS NO WRITE, and it exists to say so: every mutating verb on " +
745
+ "this surface has ALREADY committed and fsynced its own write by the time it returns, so there is no unsaved " +
746
+ "state for an explicit save to flush and no window in which a crash could lose an edit this verb would have " +
747
+ "rescued. Durability belongs to the store, not to a verb an agent has to remember to call. Use this to read the " +
748
+ "revision -- for a subsequent `base_revision` compare-and-swap, or to confirm that a pass advanced the store as " +
749
+ "far as expected. The body states the no-write property alongside the revision, so a caller is never left " +
750
+ "inferring it from an empty success.",
751
+ inputSchema: {
752
+ type: "object",
753
+ properties: { ...STORE_PROPERTY },
754
+ required: ["store"],
755
+ },
756
+ },
757
+ {
758
+ name: "anno_disassemble",
759
+ description:
760
+ "Renders ACME-ready `!cpu 6510` source for the instructions starting AT AN EXPLICIT ADDRESS you supply. " +
761
+ "There is no cursor and no 'current address' on this surface -- upstream's own procedure text says never to rely " +
762
+ "on one and this project has no editor to have one, so the address is always yours and always in the call. " +
763
+ "Decoded fresh from the image bytes on every call; nothing is cached and nothing is written. An opcode ACME " +
764
+ "cannot express is emitted as `!byte` with the mnemonic moved into a comment, never as a mnemonic that would " +
765
+ "fail to reassemble. The extent is bounded by the SAME byte cap that governs anno_read_region -- one cap, both " +
766
+ "views, so there is no per-view rule to get subtly wrong -- and defaults to that cap when end_address is " +
767
+ "omitted. A wider range is REFUSED by name with the cap and the requested width in the message, never " +
768
+ "silently truncated.",
769
+ inputSchema: {
770
+ type: "object",
771
+ properties: {
772
+ ...STORE_PROPERTY,
773
+ ...IMAGE_PROPERTY,
774
+ address: {
775
+ description:
776
+ "The address to start decoding at, EXPLICITLY. Integer 0..65535, \"$hex\" or \"0x\" string; an unprefixed " +
777
+ "numeric string is refused.",
778
+ },
779
+ end_address: {
780
+ description:
781
+ "Optional last address to decode, INCLUSIVE. Omitted, the extent is the byte cap (or the end of the image, " +
782
+ "whichever comes first).",
783
+ },
784
+ },
785
+ required: ["store", "image", "address"],
786
+ },
787
+ },
788
+ {
789
+ name: "anno_read_region",
790
+ description:
791
+ "Reads ONE routine or table at an explicit inclusive address range, instead of exporting the whole program. " +
792
+ "`view: 'disasm'` is what routine documentation wants; `view: 'hexdump'` is what data-table classification and " +
793
+ "table extraction want; omitted, the view is 'disasm'. The combined byte count (end_address - start_address + 1) " +
794
+ "is capped, and the SAME cap governs anno_disassemble -- one cap, both views. A request above the cap is REFUSED " +
795
+ "by name, naming the cap and the requested width, rather than silently truncated: a full-64K disassembly view " +
796
+ "dumped into an agent's context is exactly the hazard the cap exists to prevent, and this family is not chunked, " +
797
+ "so the cap is the only bound there is.",
798
+ inputSchema: {
799
+ type: "object",
800
+ properties: {
801
+ ...STORE_PROPERTY,
802
+ ...IMAGE_PROPERTY,
803
+ start_address: { description: "Start of the range, INCLUSIVE. Integer, \"$hex\" or \"0x\" string." },
804
+ end_address: { description: "End of the range, INCLUSIVE." },
805
+ view: {
806
+ type: "string",
807
+ enum: ["disasm", "hexdump"],
808
+ description: "'disasm' = rendered 6510 source. 'hexdump' = raw hex bytes. Omitted defaults to 'disasm'.",
809
+ },
810
+ },
811
+ required: ["store", "image", "start_address", "end_address"],
812
+ },
813
+ },
814
+ {
815
+ name: "anno_get_binary_info",
816
+ description:
817
+ "Reports what the named image FILE is: how it was dispatched (a .prg's 2-byte little-endian load address, or a " +
818
+ "flat 64K capture's origin of 0), the origin, the total byte length, the payload byte length, and the Shannon " +
819
+ "entropy of the payload -- a value above 7.5 suggests the image is compressed or packed and that a depack pass " +
820
+ "is needed before any of it will decode sensibly. DISPATCH IS BY EXTENSION FIRST, never by byte length: a " +
821
+ "truncated .raw capture that fell through to the .prg parser once produced an origin read backwards out of its " +
822
+ "own payload bytes, exited zero, and made every downstream address silently wrong. A file too short to be a .prg " +
823
+ "is REFUSED by name.",
824
+ inputSchema: {
825
+ type: "object",
826
+ properties: { ...STORE_PROPERTY, ...IMAGE_PROPERTY },
827
+ required: ["store", "image"],
828
+ },
829
+ },
830
+ {
831
+ name: "anno_get_cross_references",
832
+ description:
833
+ "Every address that references the address you name, unioned from three sources and returned ascending and " +
834
+ "de-duplicated: the instructions decoded fresh out of every range typed `code`, the typed split ADDRESS tables " +
835
+ "(the `_address` forms produce cross-references and the `_word` forms do not -- that is the schema's own " +
836
+ "distinction, not a judgement made here), and the stored rows, which are the only half on disk and only because " +
837
+ "a computed dispatch or a hand-asserted edge cannot be recovered from bytes at all. DERIVED ON EVERY CALL AND " +
838
+ "NEVER CACHED: a cached derivation is a second on-disk truth that can disagree with the range table it came " +
839
+ "from. `max_results` is REQUIRED with no default; the true total rides beside the truncated list.",
840
+ inputSchema: {
841
+ type: "object",
842
+ properties: {
843
+ ...STORE_PROPERTY,
844
+ ...IMAGE_PROPERTY,
845
+ address: { description: "The target address to find references TO. Integer, \"$hex\" or \"0x\" string." },
846
+ max_results: { type: "integer", description: "Maximum number of referencing addresses to return. REQUIRED -- no default." },
847
+ },
848
+ required: ["store", "image", "address", "max_results"],
849
+ },
850
+ },
851
+ {
852
+ name: "anno_search",
853
+ description:
854
+ "Searches three corpora for a substring: label names, comment text, and the instruction text rendered from every " +
855
+ "range typed `code`. MATCHING IS BYTE-EXACT AND CASE-SENSITIVE, applied identically to all three, and the rule " +
856
+ "is restated in the body so an empty answer tells you which rule produced it. Every corpus is named in the body " +
857
+ "with the number of entries it held, so a genuine zero over a real corpus is distinguishable from a corpus this " +
858
+ "surface does not have. NAMING A CORPUS THIS SURFACE DOES NOT HAVE (any search_<name> other than the three) is " +
859
+ "answered with `{available:false, reason}` in a SUCCESSFUL body -- not an error, because the request was " +
860
+ "well-formed, and not an empty result set, because an empty result set for an unanswerable question is a lie " +
861
+ "that reads like an answer. `max_results` is REQUIRED with no default: an implicit default would silently " +
862
+ "truncate a full-program pass.",
863
+ inputSchema: {
864
+ type: "object",
865
+ properties: {
866
+ ...STORE_PROPERTY,
867
+ ...IMAGE_PROPERTY,
868
+ query: { type: "string", description: "The substring to find. Case-sensitive and byte-exact. An empty query is refused -- that is a listing, not a search." },
869
+ max_results: { type: "integer", description: "Maximum number of hits to return. REQUIRED -- no default on this surface." },
870
+ search_labels: { type: "boolean", description: "Search the label-name corpus. Defaults to true." },
871
+ search_comments: { type: "boolean", description: "Search the comment-text corpus. Defaults to true." },
872
+ search_instructions: { type: "boolean", description: "Search the rendered instruction corpus. Defaults to true. This is the expensive one: it decodes every code range." },
873
+ },
874
+ required: ["store", "image", "query", "max_results"],
875
+ },
876
+ },
877
+ {
878
+ name: "anno_get_address_details",
879
+ description:
880
+ "Everything this project knows about ONE address, composed from four reads: the labels bound there, the comments " +
881
+ "there, the typed range that covers it (resolved narrowest-range-wins through the paint index, never by a " +
882
+ "start/end bracket scan), and the cross-references that reach it. THE COMPOSITION IS DISCLOSED: the body carries " +
883
+ "`composed_client_side` and a `composed_from` list naming all four sources, so a composition is never mistaken " +
884
+ "for something the store held whole. A component with no answer comes back as `{available:false, reason}` rather " +
885
+ "than as an empty list, so an address that genuinely has no comments stays distinguishable from a question this " +
886
+ "composition could not put. Nothing is written on any path.",
887
+ inputSchema: {
888
+ type: "object",
889
+ properties: {
890
+ ...STORE_PROPERTY,
891
+ ...IMAGE_PROPERTY,
892
+ address: { description: "The address to inspect. Integer, \"$hex\" or \"0x\" string." },
893
+ },
894
+ required: ["store", "image", "address"],
895
+ },
896
+ },
897
+ {
898
+ name: "anno_batch_execute",
899
+ description:
900
+ "Executes several curated anno_* calls against ONE store, in order, inside one open/close pair. Use it for a " +
901
+ "multi-edit pass -- marking many regions, renaming many labels -- and not for calls that depend on each other's " +
902
+ "results. The store (and the image, when the inner calls need one) is named ONCE at the top level and every " +
903
+ "inner call inherits it, INCLUDING through nesting -- a batch inside a batch inherits it too, and so does that " +
904
+ "batch's own inner calls; an inner `store` is overridden at every depth, never honoured. TWO PHASES, and the difference matters " +
905
+ "when you read the answer. FIRST, the whole payload is pre-validated before anything is opened: a malformed " +
906
+ "payload, an EMPTY calls array, a malformed entry, an inner name outside the curated set at any depth, an " +
907
+ "illegal label name, or an over-cap region range refuses the WHOLE batch by index, and nothing executes. " +
908
+ "SECOND, execution runs to COMPLETION, pushing a success or error status for every entry and never aborting on " +
909
+ "the first failure. So `isError:true` means this batch should never have been sent; an error ENTRY inside a " +
910
+ "successful result means that one call did not work. Nesting deeper than " +
911
+ String(ANNO_MAX_BATCH_DEPTH) +
912
+ " levels is refused by name rather than walked.",
913
+ inputSchema: {
914
+ type: "object",
915
+ properties: {
916
+ ...STORE_PROPERTY,
917
+ image: {
918
+ type: "string",
919
+ description:
920
+ "Optional program image, inherited by every inner call that derives an answer from bytes. Required only " +
921
+ "if the batch contains such a call.",
922
+ },
923
+ calls: {
924
+ type: "array",
925
+ items: {
926
+ type: "object",
927
+ properties: {
928
+ name: { type: "string", description: "The curated anno_* verb to run. An uncurated name refuses the WHOLE batch." },
929
+ arguments: { type: "object", description: "That verb's own arguments, minus store (and image), which the batch supplies." },
930
+ },
931
+ required: ["name", "arguments"],
932
+ },
933
+ description: "The calls to run, in order. Must be a NON-EMPTY array: an empty batch is refused, never run as a zero-length success.",
934
+ },
935
+ },
936
+ required: ["store", "calls"],
937
+ },
938
+ },
939
+ ];
940
+
941
+ /** The allow-list, DERIVED from the definitions above rather than hand-typed
942
+ * (T-29-02): a name cannot be curated in one place and absent from the other,
943
+ * because there is only one place. */
944
+ export const CURATED_ANNO_TOOLS: readonly string[] = ANNO_TOOL_DEFINITIONS.map((def) => def.name);
945
+
946
+ // ---------------------------------------------------------------------------
947
+ // Per-verb argument validators. Each is called from BOTH the outer gate
948
+ // (`assertAnnoTool`) and, when a call arrives inside `anno_batch_execute`, that
949
+ // verb's own inner loop -- through the ONE dispatch below, so there is no way
950
+ // to add a verb to one route and forget the other.
951
+ // ---------------------------------------------------------------------------
952
+
953
+ /** Validates `anno_get_symbols`'s own arguments. The optional range bounds go
954
+ * through `parseStoreAddress()` -- the ONE address parser -- so `$d020`,
955
+ * `0xd020` and `53280` are accepted or refused here exactly as the store
956
+ * itself would accept or refuse them, never by a second, divergent rule. */
957
+ function assertGetSymbolsArgs(args: unknown, batchIndex?: number): void {
958
+ assertStoreArg("anno_get_symbols", args, batchIndex);
959
+ assertMaxResults("anno_get_symbols", args, batchIndex);
960
+ const bag = argBag(args);
961
+ if (bag.start_address !== undefined) parseStoreAddress(bag.start_address, { what: "start_address" });
962
+ if (bag.end_address !== undefined) parseStoreAddress(bag.end_address, { what: "end_address" });
963
+ }
964
+
965
+ function assertSetLabelArgs(args: unknown, batchIndex?: number): void {
966
+ assertStoreArg("anno_set_label_name", args, batchIndex);
967
+ assertAddressArg("anno_set_label_name", args, "address", batchIndex);
968
+ assertLegalLabelArg("anno_set_label_name", args, batchIndex);
969
+ const bag = argBag(args);
970
+ if (bag.kind !== undefined) assertLabelKind(bag.kind);
971
+ assertBaseRevisionArg("anno_set_label_name", args, batchIndex);
972
+ }
973
+
974
+ function assertSetCommentArgs(args: unknown, batchIndex?: number): void {
975
+ assertStoreArg("anno_set_comment", args, batchIndex);
976
+ assertAddressArg("anno_set_comment", args, "address", batchIndex);
977
+ const bag = argBag(args);
978
+ if (bag.comment === undefined) refuseArg("anno_set_comment", "comment", '"comment" is required and was not supplied.', batchIndex);
979
+ // The byte bound, the ';'-prefix rule and the refuse-never-truncate policy
980
+ // are ALL `assertCommentText()`'s. This layer adds no second length check,
981
+ // no truncation and no Unicode normalization -- the bound is measured in
982
+ // UTF-8 BYTES there, and a second rule here would disagree with it silently.
983
+ assertCommentText(bag.comment);
984
+ assertCommentType(bag.type);
985
+ assertBaseRevisionArg("anno_set_comment", args, batchIndex);
986
+ }
987
+
988
+ function assertSetDataTypeArgs(args: unknown, batchIndex?: number): void {
989
+ assertStoreArg("anno_set_data_type", args, batchIndex);
990
+ // ORDERING IS LOAD-BEARING, and it is the store's own: the data type is
991
+ // narrowed FIRST because `assertRangeShape` needs it to decide whether the
992
+ // even-byte-count rule applies at all.
993
+ const dataType = assertDataType(argBag(args).data_type);
994
+ assertSpanArgs("anno_set_data_type", args, dataType, batchIndex);
995
+ assertBaseRevisionArg("anno_set_data_type", args, batchIndex);
996
+ }
997
+
998
+ function assertScopeArgs(name: string, args: unknown, batchIndex?: number): void {
999
+ assertStoreArg(name, args, batchIndex);
1000
+ // "byte" selects the two shape rules that DO apply to a scope (both ends
1001
+ // inside the address space; the end not below the start) and none of the
1002
+ // ones that do not -- a scope is not a table, so a three-byte routine is a
1003
+ // perfectly good scope. This mirrors `addScope`'s own choice exactly.
1004
+ assertSpanArgs(name, args, "byte", batchIndex);
1005
+ assertBaseRevisionArg(name, args, batchIndex);
1006
+ }
1007
+
1008
+ function assertGetCommentsArgs(args: unknown, batchIndex?: number): void {
1009
+ assertStoreArg("anno_get_comments", args, batchIndex);
1010
+ assertMaxResults("anno_get_comments", args, batchIndex);
1011
+ const bag = argBag(args);
1012
+ if (bag.addresses !== undefined) {
1013
+ if (!Array.isArray(bag.addresses)) {
1014
+ refuseArg("anno_get_comments", "addresses", '"addresses" must be an array of addresses when supplied.', batchIndex);
1015
+ }
1016
+ for (const entry of bag.addresses as unknown[]) parseStoreAddress(entry, { what: "addresses[]" });
1017
+ }
1018
+ if (bag.start_address !== undefined) parseStoreAddress(bag.start_address, { what: "start_address" });
1019
+ if (bag.end_address !== undefined) parseStoreAddress(bag.end_address, { what: "end_address" });
1020
+ if (bag.type !== undefined) assertCommentType(bag.type);
1021
+ }
1022
+
1023
+ const BLOCK_INCLUDES: readonly string[] = Object.freeze(["scopes", "enums", "enum_usage"]);
1024
+
1025
+ function assertGetBlocksArgs(args: unknown, batchIndex?: number): void {
1026
+ assertStoreArg("anno_get_blocks", args, batchIndex);
1027
+ assertMaxResults("anno_get_blocks", args, batchIndex);
1028
+ const bag = argBag(args);
1029
+ if (bag.block_type !== undefined) assertDataType(bag.block_type);
1030
+ if (bag.include !== undefined) {
1031
+ if (!Array.isArray(bag.include)) {
1032
+ refuseArg("anno_get_blocks", "include", '"include" must be an array when supplied.', batchIndex);
1033
+ }
1034
+ for (const entry of bag.include as unknown[]) {
1035
+ if (typeof entry !== "string" || !BLOCK_INCLUDES.includes(entry)) {
1036
+ refuseArg(
1037
+ "anno_get_blocks",
1038
+ "include",
1039
+ `${JSON.stringify(entry)} is not one of the ${BLOCK_INCLUDES.length} extra collections -- expected one of: ${BLOCK_INCLUDES.join(", ")}.`,
1040
+ batchIndex,
1041
+ );
1042
+ }
1043
+ }
1044
+ }
1045
+ }
1046
+
1047
+ function assertEnumNameArg(name: string, args: unknown, key: string, batchIndex?: number): void {
1048
+ const raw = argBag(args)[key];
1049
+ try {
1050
+ assertEnumName(raw);
1051
+ } catch (err) {
1052
+ const reason = err instanceof Error ? err.message : String(err);
1053
+ refuseArg(name, key, `${JSON.stringify(raw)} is not a legal enum name (${reason}) -- REJECTED, never sanitized.`, batchIndex);
1054
+ }
1055
+ }
1056
+
1057
+ function assertCreateEnumArgs(args: unknown, batchIndex?: number): void {
1058
+ assertStoreArg("anno_create_project_enum", args, batchIndex);
1059
+ assertEnumNameArg("anno_create_project_enum", args, "name", batchIndex);
1060
+ const bag = argBag(args);
1061
+ if (!isPlainObject(bag.variants)) {
1062
+ refuseArg("anno_create_project_enum", "variants", '"variants" must be an object mapping numeric-string keys to variant names.', batchIndex);
1063
+ }
1064
+ if (bag.description !== undefined) assertCommentText(bag.description, { what: "description", allowLeadingSemicolon: true });
1065
+ assertBaseRevisionArg("anno_create_project_enum", args, batchIndex);
1066
+ }
1067
+
1068
+ function assertUpdateEnumArgs(args: unknown, batchIndex?: number): void {
1069
+ assertStoreArg("anno_update_project_enum", args, batchIndex);
1070
+ assertEnumNameArg("anno_update_project_enum", args, "name", batchIndex);
1071
+ const bag = argBag(args);
1072
+ if (bag.new_name !== undefined) assertEnumNameArg("anno_update_project_enum", args, "new_name", batchIndex);
1073
+ if (bag.variants !== undefined && !isPlainObject(bag.variants)) {
1074
+ refuseArg("anno_update_project_enum", "variants", '"variants" must be an object when supplied -- it REPLACES the mapping wholesale.', batchIndex);
1075
+ }
1076
+ if (bag.description !== undefined) assertCommentText(bag.description, { what: "description", allowLeadingSemicolon: true });
1077
+ assertBaseRevisionArg("anno_update_project_enum", args, batchIndex);
1078
+ }
1079
+
1080
+ /** True when this call is the CLEAR form -- `name` omitted, or an empty
1081
+ * string. The schema's own contract ("Omit or send empty to clear"), read in
1082
+ * ONE place so the validator and the dispatcher can never disagree about which
1083
+ * of the two store functions a given payload selects. */
1084
+ function isEnumUsageClear(args: unknown): boolean {
1085
+ const raw = argBag(args).name;
1086
+ return raw === undefined || raw === "";
1087
+ }
1088
+
1089
+ function assertApplyEnumUsageArgs(args: unknown, batchIndex?: number): void {
1090
+ assertStoreArg("anno_apply_enum_usage", args, batchIndex);
1091
+ assertAddressArg("anno_apply_enum_usage", args, "address", batchIndex);
1092
+ if (!isEnumUsageClear(args)) assertEnumNameArg("anno_apply_enum_usage", args, "name", batchIndex);
1093
+ assertBaseRevisionArg("anno_apply_enum_usage", args, batchIndex);
1094
+ }
1095
+
1096
+ function assertSaveProjectArgs(args: unknown, batchIndex?: number): void {
1097
+ assertStoreArg("anno_save_project", args, batchIndex);
1098
+ }
1099
+
1100
+
1101
+ // ---------------------------------------------------------------------------
1102
+ // THE ONE SIZE CAP, GOVERNING BOTH VIEWS (T-29-25).
1103
+ //
1104
+ // A full-64K disassembly view dumped into an agent's context is the hazard this
1105
+ // cap exists to prevent; these verbs read a ROUTINE at a range, not the whole
1106
+ // program. 4096 is one sixteenth of the address space and far above any
1107
+ // realistic single routine. ONE cap covers the region read AND the disassemble
1108
+ // view, deliberately, so there is no per-view rule to get subtly wrong -- and
1109
+ // the disassembly view at the cap is the worst case, since the hexdump view of
1110
+ // the same byte count renders far less text.
1111
+ //
1112
+ // THE CAP IS THE ONLY BOUND THERE IS FOR THIS FAMILY. `vice-proxy.ts`'s
1113
+ // `wrapPossiblyChunked()` splits an oversized answer across a continuation
1114
+ // sequence, but `buildViceTool()` calls `run` DIRECTLY, so nothing on this
1115
+ // surface is chunked; and the client's own inline-response ceiling was measured
1116
+ // at 40-60 KB, far below the proxy's 500,000-character output cap. That is why
1117
+ // the second mitigation -- `max_results` REQUIRED with no default on every
1118
+ // list-returning verb, with the true total returned beside the truncated list
1119
+ // -- is not optional either.
1120
+ // ---------------------------------------------------------------------------
1121
+
1122
+ export const ANNO_READ_REGION_MAX_BYTES = 4096;
1123
+
1124
+ /** The environment variable that overrides the cap. Exported so a caller and a
1125
+ * test name it in one place rather than two. */
1126
+ export const ANNO_READ_REGION_MAX_BYTES_ENV = "ANNO_READ_REGION_MAX_BYTES";
1127
+
1128
+ /** Reads the cap override AT CALL TIME, never frozen at module load -- the same
1129
+ * read-at-call-time convention `repoRoot()` is called under above, so one
1130
+ * `node --test` process can point several different caps at this code within a
1131
+ * single run. Falls back to the named default on an absent, non-finite or
1132
+ * non-positive override. */
1133
+ function currentReadRegionMaxBytes(): number {
1134
+ const raw = process.env[ANNO_READ_REGION_MAX_BYTES_ENV];
1135
+ if (raw === undefined) return ANNO_READ_REGION_MAX_BYTES;
1136
+ const n = Number(raw);
1137
+ return Number.isFinite(n) && n > 0 ? n : ANNO_READ_REGION_MAX_BYTES;
1138
+ }
1139
+
1140
+ export interface AnnoRegionRangeErrorOptions extends AnnoStoreErrorOptions {
1141
+ toolName?: string;
1142
+ start?: number;
1143
+ end?: number;
1144
+ requestedBytes?: number;
1145
+ cap?: number;
1146
+ batchIndex?: number;
1147
+ }
1148
+
1149
+ /** A region or disassembly extent wider than the cap. Its own class, because a
1150
+ * caller must be able to tell "your range is too wide" from every other
1151
+ * argument refusal without substring-matching a message. */
1152
+ export class AnnoRegionRangeError extends AnnoStoreError {
1153
+ toolName?: string;
1154
+ start?: number;
1155
+ end?: number;
1156
+ requestedBytes?: number;
1157
+ cap?: number;
1158
+ batchIndex?: number;
1159
+
1160
+ constructor(message: string, { toolName, start, end, requestedBytes, cap, batchIndex, ...rest }: AnnoRegionRangeErrorOptions = {}) {
1161
+ super(message, rest);
1162
+ this.name = "AnnoRegionRangeError";
1163
+ this.toolName = toolName;
1164
+ this.start = start;
1165
+ this.end = end;
1166
+ this.requestedBytes = requestedBytes;
1167
+ this.cap = cap;
1168
+ this.batchIndex = batchIndex;
1169
+ }
1170
+ }
1171
+
1172
+ /** Enforces the ONE cap over an inclusive span, naming BOTH the cap and the
1173
+ * requested width so the message is actionable without reading this file.
1174
+ * Called from `anno_read_region` and `anno_disassemble` alike. */
1175
+ function assertWithinRegionCap(name: string, start: number, end: number, batchIndex?: number): void {
1176
+ const requestedBytes = end - start + 1;
1177
+ const cap = currentReadRegionMaxBytes();
1178
+ if (requestedBytes > cap) {
1179
+ throw new AnnoRegionRangeError(
1180
+ `${name} refused${whereOf(batchIndex)}: requested ${requestedBytes} bytes ($${start.toString(16).padStart(4, "0")}..` +
1181
+ `$${end.toString(16).padStart(4, "0")} inclusive), which exceeds the ${ANNO_READ_REGION_MAX_BYTES_ENV} cap of ${cap} -- ` +
1182
+ `valid range is 1..${cap} bytes. This verb reads a routine at a range, not the whole program, and this family is NOT ` +
1183
+ `chunked, so the cap is the only bound there is. Narrow the range, or set ${ANNO_READ_REGION_MAX_BYTES_ENV} to override.`,
1184
+ { toolName: name, start, end, requestedBytes, cap, batchIndex },
1185
+ );
1186
+ }
1187
+ }
1188
+
1189
+ /** Narrows the universally-required `image` argument (D-07) to a non-empty
1190
+ * string. Containment is `resolveWorkspacePath()`'s concern, exactly as for the
1191
+ * store path. */
1192
+ function assertImageArg(name: string, args: unknown, batchIndex?: number): string {
1193
+ const bag = argBag(args);
1194
+ if (typeof bag.image !== "string" || bag.image.trim() === "") {
1195
+ refuseArg(
1196
+ name,
1197
+ "image",
1198
+ '"image" must be a non-empty string naming the program image this answer is derived from -- the store holds ' +
1199
+ "annotations, never bytes, and an omitted image would read as a plausible success against whatever was recorded last (D-07).",
1200
+ batchIndex,
1201
+ );
1202
+ }
1203
+ return bag.image as string;
1204
+ }
1205
+
1206
+ function assertQueryArg(name: string, args: unknown, batchIndex?: number): void {
1207
+ const raw = argBag(args).query;
1208
+ if (typeof raw !== "string" || raw === "") {
1209
+ refuseArg(
1210
+ name,
1211
+ "query",
1212
+ `"query" must be a non-empty string, got ${JSON.stringify(raw)} -- an empty query matches every entry of every corpus, ` +
1213
+ "which is a listing rather than a search, and the list verbs are what listing is for.",
1214
+ batchIndex,
1215
+ );
1216
+ }
1217
+ }
1218
+
1219
+ function assertDisassembleArgs(args: unknown, batchIndex?: number): void {
1220
+ assertStoreArg("anno_disassemble", args, batchIndex);
1221
+ assertImageArg("anno_disassemble", args, batchIndex);
1222
+ const start = assertAddressArg("anno_disassemble", args, "address", batchIndex);
1223
+ const bag = argBag(args);
1224
+ if (bag.end_address !== undefined) {
1225
+ const end = parseStoreAddress(bag.end_address, { what: "end_address" });
1226
+ assertRangeShape(start, end, "byte");
1227
+ assertWithinRegionCap("anno_disassemble", start, end, batchIndex);
1228
+ }
1229
+ }
1230
+
1231
+ function assertReadRegionArgs(args: unknown, batchIndex?: number): void {
1232
+ assertStoreArg("anno_read_region", args, batchIndex);
1233
+ assertImageArg("anno_read_region", args, batchIndex);
1234
+ const { start, end } = assertSpanArgs("anno_read_region", args, "byte", batchIndex);
1235
+ assertWithinRegionCap("anno_read_region", start, end, batchIndex);
1236
+ const view = argBag(args).view;
1237
+ if (view !== undefined && view !== "disasm" && view !== "hexdump") {
1238
+ refuseArg("anno_read_region", "view", `${JSON.stringify(view)} is not a view -- expected "disasm" or "hexdump".`, batchIndex);
1239
+ }
1240
+ }
1241
+
1242
+ function assertBinaryInfoArgs(args: unknown, batchIndex?: number): void {
1243
+ assertStoreArg("anno_get_binary_info", args, batchIndex);
1244
+ assertImageArg("anno_get_binary_info", args, batchIndex);
1245
+ }
1246
+
1247
+ function assertCrossReferencesArgs(args: unknown, batchIndex?: number): void {
1248
+ assertStoreArg("anno_get_cross_references", args, batchIndex);
1249
+ assertImageArg("anno_get_cross_references", args, batchIndex);
1250
+ assertAddressArg("anno_get_cross_references", args, "address", batchIndex);
1251
+ assertMaxResults("anno_get_cross_references", args, batchIndex);
1252
+ }
1253
+
1254
+ function assertSearchArgs(args: unknown, batchIndex?: number): void {
1255
+ assertStoreArg("anno_search", args, batchIndex);
1256
+ assertImageArg("anno_search", args, batchIndex);
1257
+ assertQueryArg("anno_search", args, batchIndex);
1258
+ assertMaxResults("anno_search", args, batchIndex);
1259
+ }
1260
+
1261
+ function assertAddressDetailsArgs(args: unknown, batchIndex?: number): void {
1262
+ assertStoreArg("anno_get_address_details", args, batchIndex);
1263
+ assertImageArg("anno_get_address_details", args, batchIndex);
1264
+ assertAddressArg("anno_get_address_details", args, "address", batchIndex);
1265
+ }
1266
+
1267
+
1268
+ // ---------------------------------------------------------------------------
1269
+ // `anno_batch_execute` -- TWO EXPLICITLY SEPARATE PHASES, documented as two.
1270
+ //
1271
+ // PHASE ONE, PRE-VALIDATION (`assertAnnoBatch`), runs before any store is
1272
+ // opened. It refuses the WHOLE batch on: a malformed payload, an empty `calls`
1273
+ // array, a malformed entry, an uncurated inner name at ANY depth, or an inner
1274
+ // call whose own per-verb validator refuses -- each naming the offending index.
1275
+ // Nothing has executed when it fires, so there is no partial write to explain.
1276
+ //
1277
+ // PHASE TWO, EXECUTION, runs inside ONE `openStore`/`closeStore` pair for the
1278
+ // whole batch. It loops to COMPLETION, pushing a per-entry `{status:"success"}`
1279
+ // or `{status:"error"}` for every entry, and never aborts on the first failure.
1280
+ //
1281
+ // THE TWO ARE NOT IN CONFLICT, and this is the reconciliation the plan records:
1282
+ // per-item status reporting and whole-batch refusal are two PHASES of one call,
1283
+ // not two answers to one question. A refusal in phase one becomes
1284
+ // `isError: true` through the runner's own catch and means "this batch should
1285
+ // never have been sent". An inner call failing in phase two becomes an error
1286
+ // ENTRY inside a successful outer result and means "this call in the batch did
1287
+ // not work". The measured upstream note at `anno-tools.ts:63-75` establishes
1288
+ // the second half: the loop always runs to completion and each outcome is
1289
+ // pushed with its own status.
1290
+ //
1291
+ // TWO THINGS THIS VALIDATOR HAS THAT ITS ANALOG DID NOT:
1292
+ //
1293
+ // 1. AN EXPLICIT DEPTH CAP. The original recursion was unbounded and was safe
1294
+ // only because a child-process spawn cost dominated any nesting an
1295
+ // attacker could send. That cost is gone -- this runs in-process -- so a
1296
+ // deeply nested payload is a stack-exhaustion route (T-29-24). Past the
1297
+ // cap the batch is REFUSED BY NAME, naming the cap, rather than walked.
1298
+ // 2. AN EXPLICIT REFUSAL FOR AN EMPTY `calls` ARRAY. A zero-length batch is
1299
+ // an ambiguous request, and executing it as a zero-length SUCCESS is
1300
+ // exactly the plausible-looking zero this surface forbids. A malformed
1301
+ // payload is a refusal; so is an empty one.
1302
+ // ---------------------------------------------------------------------------
1303
+
1304
+ /**
1305
+ * PHASE ONE. Walks an `anno_batch_execute` payload and refuses the WHOLE batch
1306
+ * if anything, at any depth, is wrong.
1307
+ *
1308
+ * The per-verb argument validators fire through `assertVerbArgs()` -- the SAME
1309
+ * function the outer gate calls -- with the entry's index interpolated into the
1310
+ * message, so an illegal label name or an over-cap region range is refused
1311
+ * identically whether the verb was called directly or smuggled inside a batch.
1312
+ * That is the shared-validator discipline, and it is what makes the outer
1313
+ * allow-list gate mean anything for a nested-argument verb.
1314
+ *
1315
+ * THE SAME DISCIPLINE APPLIES TO THE ARGUMENTS THEMSELVES. Every inner
1316
+ * payload this function walks -- a leaf verb's or a nested batch's -- is
1317
+ * obtained from `batchArgumentsFor()`, the one function phase two also asks.
1318
+ * A phase that computed an inner call's arguments its own way would be
1319
+ * validating a payload the executor never runs, which is what CR-06 was.
1320
+ */
1321
+ export function assertAnnoBatch(args: unknown, depth = 0): void {
1322
+ if (depth > ANNO_MAX_BATCH_DEPTH) {
1323
+ throw new AnnoUncuratedToolError(
1324
+ `anno_batch_execute refused: nesting deeper than ${ANNO_MAX_BATCH_DEPTH} levels -- refused BY NAME rather than walked, ` +
1325
+ "because an unbounded walk over an attacker-shaped payload is a stack-exhaustion route (T-29-24). Flatten the batch.",
1326
+ { toolName: "anno_batch_execute" },
1327
+ );
1328
+ }
1329
+ if (!isPlainObject(args) || !Array.isArray(args.calls)) {
1330
+ throw new AnnoUncuratedToolError(
1331
+ 'anno_batch_execute refused: "calls" must be an array of {name, arguments} objects -- a malformed batch payload is ' +
1332
+ "treated as a REFUSAL, never as an empty batch that passes through.",
1333
+ { toolName: "anno_batch_execute" },
1334
+ );
1335
+ }
1336
+ const calls = args.calls as unknown[];
1337
+ if (calls.length === 0) {
1338
+ throw new AnnoUncuratedToolError(
1339
+ 'anno_batch_execute refused: "calls" is an EMPTY array. A zero-length batch is an ambiguous request, and running it as a ' +
1340
+ "zero-length success would be a plausible-looking zero -- the caller would be told a pass completed when nothing was asked for.",
1341
+ { toolName: "anno_batch_execute" },
1342
+ );
1343
+ }
1344
+ calls.forEach((call, i) => {
1345
+ if (!isPlainObject(call) || typeof call.name !== "string") {
1346
+ throw new AnnoUncuratedToolError(
1347
+ `anno_batch_execute refused WHOLE: calls[${i}] is malformed (missing a string "name") -- treated as a refusal, never ` +
1348
+ "as an empty batch that passes through.",
1349
+ { toolName: "anno_batch_execute", batchIndex: i },
1350
+ );
1351
+ }
1352
+ if (!CURATED_ANNO_TOOLS.includes(call.name)) {
1353
+ throw new AnnoUncuratedToolError(
1354
+ `anno_batch_execute refused WHOLE: calls[${i}].name "${call.name}" is outside the curated anno_* tool surface -- a batch ` +
1355
+ "is refused whole if any inner name is outside the curated set (D-33).",
1356
+ { toolName: call.name, batchIndex: i },
1357
+ );
1358
+ }
1359
+ if (call.name === "anno_batch_execute") {
1360
+ // RECURSES ON THE EFFECTIVE ARGUMENTS, NOT THE RAW BAG, and that is the
1361
+ // whole of CR-06. Phase two -- `dispatchBatchExecute()` -- has always
1362
+ // recursed on `batchArgumentsFor(bag, call)`; phase one used to recurse
1363
+ // on `call.arguments`. The two phases therefore disagreed about what the
1364
+ // inner payload WAS, and a nested batch written the documented way (the
1365
+ // store named ONCE at the top, every inner call inheriting it) was
1366
+ // refused whole at every depth -- with a message saying there is no
1367
+ // ambient store to inherit, the exact opposite of this verb's own
1368
+ // description. Read this line as a pair with the executor's recursion:
1369
+ // one function, `batchArgumentsFor()`, defines an inner call's effective
1370
+ // arguments, and both phases ask it.
1371
+ assertAnnoBatch(batchArgumentsFor(args, call), depth + 1);
1372
+ return;
1373
+ }
1374
+ assertVerbArgs(call.name, batchArgumentsFor(args, call), i);
1375
+ });
1376
+ }
1377
+
1378
+ /** An inner call's effective arguments. The batch names the store ONCE, at the
1379
+ * top level, and every inner call inherits it -- an inner call that named its
1380
+ * own store would be a different store for one entry of a batch that reads as
1381
+ * one transaction's worth of work, which is a shape nothing here wants. An
1382
+ * inner `store` is therefore OVERRIDDEN by the batch's own, never merged with
1383
+ * it and never silently honoured. */
1384
+ function batchArgumentsFor(batchArgs: Record<string, unknown>, call: Record<string, unknown>): Record<string, unknown> {
1385
+ return { ...argBag(call.arguments), store: batchArgs.store, ...(batchArgs.image !== undefined ? { image: batchArgs.image } : {}) };
1386
+ }
1387
+
1388
+ /**
1389
+ * THE ONE PER-VERB VALIDATOR DISPATCH. Both the outer gate and (once it lands)
1390
+ * the batch pre-validator call THIS function, never the individual validators
1391
+ * directly, so a verb cannot be validated on one route and waved through on the
1392
+ * other. `batchIndex` is `undefined` for a direct call and the offending index
1393
+ * for a batch entry; every refusal message interpolates it.
1394
+ */
1395
+ function assertVerbArgs(name: string, args: unknown, batchIndex?: number): void {
1396
+ if (name === "anno_get_symbols") return assertGetSymbolsArgs(args, batchIndex);
1397
+ if (name === "anno_set_label_name") return assertSetLabelArgs(args, batchIndex);
1398
+ if (name === "anno_set_comment") return assertSetCommentArgs(args, batchIndex);
1399
+ if (name === "anno_set_data_type") return assertSetDataTypeArgs(args, batchIndex);
1400
+ if (name === "anno_add_scope") return assertScopeArgs("anno_add_scope", args, batchIndex);
1401
+ if (name === "anno_remove_scope") return assertScopeArgs("anno_remove_scope", args, batchIndex);
1402
+ if (name === "anno_get_comments") return assertGetCommentsArgs(args, batchIndex);
1403
+ if (name === "anno_get_blocks") return assertGetBlocksArgs(args, batchIndex);
1404
+ if (name === "anno_create_project_enum") return assertCreateEnumArgs(args, batchIndex);
1405
+ if (name === "anno_update_project_enum") return assertUpdateEnumArgs(args, batchIndex);
1406
+ if (name === "anno_apply_enum_usage") return assertApplyEnumUsageArgs(args, batchIndex);
1407
+ if (name === "anno_save_project") return assertSaveProjectArgs(args, batchIndex);
1408
+ if (name === "anno_disassemble") return assertDisassembleArgs(args, batchIndex);
1409
+ if (name === "anno_read_region") return assertReadRegionArgs(args, batchIndex);
1410
+ if (name === "anno_get_binary_info") return assertBinaryInfoArgs(args, batchIndex);
1411
+ if (name === "anno_get_cross_references") return assertCrossReferencesArgs(args, batchIndex);
1412
+ if (name === "anno_search") return assertSearchArgs(args, batchIndex);
1413
+ if (name === "anno_get_address_details") return assertAddressDetailsArgs(args, batchIndex);
1414
+ if (name === "anno_batch_execute") return assertAnnoBatch(args);
1415
+ // Every curated verb has an arm above. A curated name reaching here is a bug
1416
+ // in THIS file, and saying so by name is cheaper than a validator silently
1417
+ // accepting a payload nobody checked.
1418
+ throw new AnnoUncuratedToolError(
1419
+ `"${name}" is curated but has no argument validator in anno-tools.ts. Resolution routes: add one to ` +
1420
+ "assertVerbArgs, or remove the definition.",
1421
+ { toolName: name, batchIndex },
1422
+ );
1423
+ }
1424
+
1425
+ /**
1426
+ * The allow-list gate. Its body's FIRST check is set membership (see WHAT NOT
1427
+ * TO DO above, and `vice.ts`'s `DENY_LIST` precedent inverted into an
1428
+ * allow-list): a `name` outside `CURATED_ANNO_TOOLS` is refused outright,
1429
+ * before any argument is inspected, so an unknown verb can never reach a
1430
+ * validator that might coincidentally accept its payload. Only then are the
1431
+ * named verb's own arguments checked.
1432
+ */
1433
+ export function assertAnnoTool(name: string, args?: unknown): void {
1434
+ if (!CURATED_ANNO_TOOLS.includes(name)) {
1435
+ throw new AnnoUncuratedToolError(
1436
+ `"${name}" is not part of the curated anno_* tool surface. Resolution routes: implement it and ` +
1437
+ "add it to ANNO_TOOL_DEFINITIONS with a named criterion, or remove the caller reference.",
1438
+ { toolName: name },
1439
+ );
1440
+ }
1441
+ assertVerbArgs(name, args);
1442
+ }
1443
+
1444
+ // ---------------------------------------------------------------------------
1445
+ // Workspace path validation (T-29-01). The same posture `anno-tools.ts` took
1446
+ // for a caller-supplied project path and `stock-symbols.ts` takes for a `.lbl`
1447
+ // file: an LLM-supplied path reaching the filesystem. Resolved against
1448
+ // `repoRoot()` through `storePathWithinWorkspace()`, which carries WR-01's
1449
+ // finding -- containment is enforced against the deepest EXISTING ancestor's
1450
+ // realpath, so a not-yet-existing leaf under a directory symlink cannot slip
1451
+ // past by way of an ENOENT fallback to the literal path.
1452
+ //
1453
+ // The STORE path and the IMAGE path go through the SAME helper. They are two
1454
+ // LLM-supplied paths with one containment rule, and giving the image its own
1455
+ // rule would be a second answer to the one question this function answers once.
1456
+ //
1457
+ // `repoRoot()` is called at DISPATCH time, never frozen at module load, for
1458
+ // the same reason the region cap's override is read at call time: one
1459
+ // `node --test` process can then point several different workspace roots at
1460
+ // this code within a single run.
1461
+ // ---------------------------------------------------------------------------
1462
+
1463
+ function resolveWorkspacePath(raw: string): string {
1464
+ return storePathWithinWorkspace(raw, repoRoot());
1465
+ }
1466
+
1467
+ function resolveStoreArg(name: string, args: unknown): string {
1468
+ return resolveWorkspacePath(assertStoreArg(name, args));
1469
+ }
1470
+
1471
+ // ---------------------------------------------------------------------------
1472
+ // "GONE" AND "EMPTY" MUST NOT READ THE SAME, ON THE WRITE PATH TOO.
1473
+ //
1474
+ // `openStore`'s `mustExist` option bundles two inseparable halves -- refuse an
1475
+ // absent path, AND open the connection `readOnly` -- because it exists to judge
1476
+ // a file the caller is about to install, and a judge that can modify what it
1477
+ // judges is not a judge. That bundling is right for its purpose and wrong for
1478
+ // this one: a write verb needs the refusal WITHOUT the read-only open, and
1479
+ // there is no third state to ask `openStore` for.
1480
+ //
1481
+ // So the refusal is made HERE, by name, before the connection is constructed,
1482
+ // and the residual window that `mustExist`'s read-only open would otherwise
1483
+ // have closed is closed by INODE IDENTITY instead. The window is real: between
1484
+ // the existence check and the constructor the file can be unlinked, after
1485
+ // which a writable open CREATES it and the verb writes into a store it
1486
+ // invented, reporting success. Comparing the inode across the open detects
1487
+ // exactly that -- an unlinked-and-recreated file is a different inode -- and
1488
+ // turns an invented store into a named refusal.
1489
+ // ---------------------------------------------------------------------------
1490
+
1491
+ /** The verbs that only READ. They get `openStore`'s `mustExist` (and therefore
1492
+ * its read-only connection), which is strictly the safer open; every other verb
1493
+ * takes the existence-check-plus-inode-guard route below. Derived from nothing
1494
+ * -- it is a hand-listed property of each verb, and a verb missing from here is
1495
+ * merely opened writably, never wrongly refused. */
1496
+ const READ_ONLY_ANNO_VERBS: readonly string[] = Object.freeze([
1497
+ "anno_get_symbols",
1498
+ "anno_get_comments",
1499
+ "anno_get_blocks",
1500
+ "anno_save_project",
1501
+ "anno_disassemble",
1502
+ "anno_read_region",
1503
+ "anno_get_binary_info",
1504
+ "anno_get_cross_references",
1505
+ "anno_search",
1506
+ "anno_get_address_details",
1507
+ ]);
1508
+
1509
+ /** Refuses an absent store BY NAME, returning the inode the later guard
1510
+ * compares against. A write verb must never CREATE the file it was asked to
1511
+ * annotate: "the annotations are gone" and "there are no annotations" are
1512
+ * different facts and must not read the same. */
1513
+ function assertStorePresent(name: string, storePath: string): number {
1514
+ if (!existsSync(storePath)) {
1515
+ throw new AnnoStorePathError(
1516
+ `${name} refused: no annotation store exists at ${JSON.stringify(storePath)} -- refusing to CREATE one, because "the ` +
1517
+ 'annotations are gone" and "there are no annotations" must not read the same. Create the store deliberately first.',
1518
+ { path: storePath },
1519
+ );
1520
+ }
1521
+ return statSync(storePath).ino;
1522
+ }
1523
+
1524
+ /** Closes the window between the existence check and the open. */
1525
+ function assertSameFile(name: string, storePath: string, inodeBefore: number): void {
1526
+ if (statSync(storePath).ino !== inodeBefore) {
1527
+ throw new AnnoStorePathError(
1528
+ `${name} refused: the file at ${JSON.stringify(storePath)} was replaced between the existence check and the open, so this ` +
1529
+ "call would have written into a store it created itself rather than the one it was asked to annotate. Nothing was written.",
1530
+ { path: storePath },
1531
+ );
1532
+ }
1533
+ }
1534
+
1535
+ // ---------------------------------------------------------------------------
1536
+ // The dispatch table. Each dispatcher receives an ALREADY-OPEN handle it does
1537
+ // not own: opening and closing are `runAnnoTool`'s job and only
1538
+ // `runAnnoTool`'s, so there is exactly one `finally` in this module to get
1539
+ // right rather than one per verb.
1540
+ //
1541
+ // Every dispatcher surfaces `changed` from its `AnnoWriteResult` and NEVER maps
1542
+ // `changed: false` to an error.
1543
+ // ---------------------------------------------------------------------------
1544
+
1545
+ function dispatchGetSymbols(handle: AnnoStoreHandle, args: unknown): unknown {
1546
+ const maxResults = assertMaxResults("anno_get_symbols", args);
1547
+ const bag = argBag(args);
1548
+ const start = bag.start_address !== undefined ? parseStoreAddress(bag.start_address, { what: "start_address" }) : undefined;
1549
+ const end = bag.end_address !== undefined ? parseStoreAddress(bag.end_address, { what: "end_address" }) : undefined;
1550
+
1551
+ const all: LabelRow[] = listLabels(handle);
1552
+ const matched = all.filter((row) => {
1553
+ if (start !== undefined && row.address < start) return false;
1554
+ if (end !== undefined && row.address > end) return false;
1555
+ return true;
1556
+ });
1557
+ const symbols = matched.slice(0, maxResults);
1558
+ // `truncated` is reported rather than left for the caller to infer from a
1559
+ // count that happens to equal its own ceiling -- the ceiling being hit and
1560
+ // the answer being complete-at-exactly-the-ceiling are different facts.
1561
+ return { store: handle.path, symbols, returned: symbols.length, matched: matched.length, truncated: matched.length > symbols.length };
1562
+ }
1563
+
1564
+ function dispatchSetLabelName(handle: AnnoStoreHandle, args: unknown): unknown {
1565
+ const bag = argBag(args);
1566
+ const written = setLabel(handle, {
1567
+ address: bag.address as number | string,
1568
+ name: bag.name,
1569
+ // 'User' is the default because a name arriving through this surface was
1570
+ // chosen by whoever made the call; an unstated provenance is a human's.
1571
+ kind: bag.kind === undefined ? "User" : bag.kind,
1572
+ baseRevision: assertBaseRevisionArg("anno_set_label_name", args),
1573
+ });
1574
+ return { store: handle.path, address: parseStoreAddress(bag.address, { what: "address" }), name: bag.name, kind: bag.kind ?? "User", ...written };
1575
+ }
1576
+
1577
+ function dispatchSetComment(handle: AnnoStoreHandle, args: unknown): unknown {
1578
+ const bag = argBag(args);
1579
+ const written = setComment(handle, {
1580
+ address: bag.address as number | string,
1581
+ commentType: bag.type,
1582
+ text: bag.comment,
1583
+ baseRevision: assertBaseRevisionArg("anno_set_comment", args),
1584
+ });
1585
+ return { store: handle.path, address: parseStoreAddress(bag.address, { what: "address" }), type: bag.type, ...written };
1586
+ }
1587
+
1588
+ function dispatchSetDataType(handle: AnnoStoreHandle, args: unknown): unknown {
1589
+ const bag = argBag(args);
1590
+ const written = setDataType(handle, {
1591
+ start: bag.start_address as number | string,
1592
+ endInclusive: bag.end_address as number | string,
1593
+ dataType: bag.data_type,
1594
+ baseRevision: assertBaseRevisionArg("anno_set_data_type", args),
1595
+ });
1596
+ // BOTH disclosures ride out on the SUCCESSFUL body, as named top-level
1597
+ // fields, every time -- including when they are empty, so "this write
1598
+ // contradicted nothing" is a fact the caller is told rather than the absence
1599
+ // of a field it has to know to look for. This is 28-VERIFICATION.md's F-4
1600
+ // obligation, discharged at the layer the human actually reads.
1601
+ return {
1602
+ store: handle.path,
1603
+ start_address: parseStoreAddress(bag.start_address, { what: "start_address" }),
1604
+ end_address: parseStoreAddress(bag.end_address, { what: "end_address" }),
1605
+ data_type: bag.data_type,
1606
+ revision: written.revision,
1607
+ changed: written.changed,
1608
+ contradictedComments: written.contradictedComments,
1609
+ reinterpretedSplitTables: written.reinterpretedSplitTables,
1610
+ };
1611
+ }
1612
+
1613
+ function dispatchScope(name: string, handle: AnnoStoreHandle, args: unknown): unknown {
1614
+ const bag = argBag(args);
1615
+ const span = {
1616
+ start: bag.start_address as number | string,
1617
+ endInclusive: bag.end_address as number | string,
1618
+ baseRevision: assertBaseRevisionArg(name, args),
1619
+ };
1620
+ const written = name === "anno_add_scope" ? addScope(handle, span) : removeScope(handle, span);
1621
+ return {
1622
+ store: handle.path,
1623
+ start_address: parseStoreAddress(bag.start_address, { what: "start_address" }),
1624
+ end_address: parseStoreAddress(bag.end_address, { what: "end_address" }),
1625
+ ...written,
1626
+ scopes: listScopes(handle),
1627
+ };
1628
+ }
1629
+
1630
+ function dispatchGetComments(handle: AnnoStoreHandle, args: unknown): unknown {
1631
+ const maxResults = assertMaxResults("anno_get_comments", args);
1632
+ const bag = argBag(args);
1633
+ const wanted =
1634
+ bag.addresses === undefined ? undefined : new Set((bag.addresses as unknown[]).map((entry) => parseStoreAddress(entry, { what: "addresses[]" })));
1635
+ const start = bag.start_address !== undefined ? parseStoreAddress(bag.start_address, { what: "start_address" }) : undefined;
1636
+ const end = bag.end_address !== undefined ? parseStoreAddress(bag.end_address, { what: "end_address" }) : undefined;
1637
+ const type = bag.type !== undefined ? assertCommentType(bag.type) : undefined;
1638
+
1639
+ const all: CommentRow[] = listComments(handle);
1640
+ const matched = all.filter((row) => {
1641
+ if (wanted !== undefined && !wanted.has(row.address)) return false;
1642
+ if (start !== undefined && row.address < start) return false;
1643
+ if (end !== undefined && row.address > end) return false;
1644
+ if (type !== undefined && row.commentType !== type) return false;
1645
+ return true;
1646
+ });
1647
+ const comments = matched.slice(0, maxResults);
1648
+ return { store: handle.path, comments, returned: comments.length, matched: matched.length, truncated: matched.length > comments.length };
1649
+ }
1650
+
1651
+ function dispatchGetBlocks(handle: AnnoStoreHandle, args: unknown): unknown {
1652
+ const maxResults = assertMaxResults("anno_get_blocks", args);
1653
+ const bag = argBag(args);
1654
+ const blockType = bag.block_type !== undefined ? assertDataType(bag.block_type) : undefined;
1655
+ const include = new Set((Array.isArray(bag.include) ? bag.include : []) as string[]);
1656
+
1657
+ const matched = listRanges(handle).filter((row) => blockType === undefined || row.dataType === blockType);
1658
+ const blocks = matched.slice(0, maxResults);
1659
+ return {
1660
+ store: handle.path,
1661
+ blocks,
1662
+ returned: blocks.length,
1663
+ matched: matched.length,
1664
+ truncated: matched.length > blocks.length,
1665
+ ...(include.has("scopes") ? { scopes: listScopes(handle) } : {}),
1666
+ ...(include.has("enums") ? { enums: listProjectEnums(handle) } : {}),
1667
+ ...(include.has("enum_usage") ? { enum_usage: listEnumUsage(handle) } : {}),
1668
+ };
1669
+ }
1670
+
1671
+ function dispatchCreateProjectEnum(handle: AnnoStoreHandle, args: unknown): unknown {
1672
+ const bag = argBag(args);
1673
+ const written = createProjectEnum(handle, {
1674
+ name: bag.name,
1675
+ variants: bag.variants,
1676
+ description: bag.description,
1677
+ baseRevision: assertBaseRevisionArg("anno_create_project_enum", args),
1678
+ });
1679
+ return { store: handle.path, name: bag.name, ...written, enums: listProjectEnums(handle) };
1680
+ }
1681
+
1682
+ function dispatchUpdateProjectEnum(handle: AnnoStoreHandle, args: unknown): unknown {
1683
+ const bag = argBag(args);
1684
+ const written = updateProjectEnum(handle, {
1685
+ name: bag.name,
1686
+ newName: bag.new_name,
1687
+ variants: bag.variants,
1688
+ description: bag.description,
1689
+ baseRevision: assertBaseRevisionArg("anno_update_project_enum", args),
1690
+ });
1691
+ return { store: handle.path, name: bag.new_name ?? bag.name, ...written, enums: listProjectEnums(handle) };
1692
+ }
1693
+
1694
+ function dispatchApplyEnumUsage(handle: AnnoStoreHandle, args: unknown): unknown {
1695
+ const bag = argBag(args);
1696
+ const baseRevision = assertBaseRevisionArg("anno_apply_enum_usage", args);
1697
+ const cleared = isEnumUsageClear(args);
1698
+ const written = cleared
1699
+ ? clearEnumUsage(handle, { address: bag.address as number | string, baseRevision })
1700
+ : applyEnumUsage(handle, { address: bag.address as number | string, name: bag.name, baseRevision });
1701
+ return {
1702
+ store: handle.path,
1703
+ address: parseStoreAddress(bag.address, { what: "address" }),
1704
+ name: cleared ? null : bag.name,
1705
+ cleared,
1706
+ ...written,
1707
+ enum_usage: listEnumUsage(handle),
1708
+ };
1709
+ }
1710
+
1711
+ /** THE HONEST SAVE. It opens (through the runner), reads the revision, and
1712
+ * closes. It writes NOTHING, and the body says so in its own words rather than
1713
+ * leaving the caller to infer durability from an empty success. `curated` in
1714
+ * the manifest means a route is required; returning `{available:false}` was
1715
+ * rejected, because a permanent refusal for a curated disposition is what the
1716
+ * `omit` disposition is for and the manifest does not say `omit`.
1717
+ *
1718
+ * THE REVISION IS READ EXACTLY ONCE, into a `const`, and that single value
1719
+ * feeds both the returned field and the note's prose. This is the one verb
1720
+ * whose output a caller is TOLD to use as a `base_revision` compare-and-swap
1721
+ * guard, so a field and a prose that could name different revisions is a guard
1722
+ * built on a number its own note contradicts -- and a guard nobody can trust is
1723
+ * worse than no guard, because it is acted on (WR-10). Two reads agreeing is an
1724
+ * accident of when they ran; one read agreeing with itself is a property. */
1725
+ function dispatchSaveProject(handle: AnnoStoreHandle): unknown {
1726
+ const revision = currentRevision(handle);
1727
+ return {
1728
+ store: handle.path,
1729
+ revision,
1730
+ wrote: false,
1731
+ note:
1732
+ "This verb performed NO write. Every mutating verb on this surface commits and fsyncs its own write before it " +
1733
+ "returns, so the store was already durable at revision " +
1734
+ String(revision) +
1735
+ " when this call arrived and there was nothing for an explicit save to flush. The revision is reported so it can " +
1736
+ "be used as a base_revision compare-and-swap guard on a later write.",
1737
+ };
1738
+ }
1739
+
1740
+
1741
+ // ---------------------------------------------------------------------------
1742
+ // The image loader (D-07). The store holds annotations and never bytes, so
1743
+ // every derived read names its own image and this function is the ONE place
1744
+ // that turns that name into bytes plus an origin.
1745
+ //
1746
+ // DISPATCH IS BY EXTENSION FIRST, NEVER BY BYTE LENGTH. The branch order below
1747
+ // was copied from the CLI's own bootstrap dispatch rather than re-derived; that
1748
+ // verb was removed on 2026-08-29 when the CLI narrowed to two (D-14), so THIS
1749
+ // is now the only implementation of the order and the citation that named the
1750
+ // CLI's line range is deliberately gone rather than left dangling. The
1751
+ // incident it encodes (WR-07): a 4096-byte flat `.raw` capture fell through to
1752
+ // the `.prg` parser, whose first two bytes become the load address, so a
1753
+ // truncated capture silently "bootstrapped" with an origin read backwards out
1754
+ // of its own payload bytes and exited zero -- every downstream address wrong,
1755
+ // no diagnostic. The extension check runs BEFORE any length check so
1756
+ // `flatImageOrigin()`'s own named refusal stays reachable for those two
1757
+ // extensions.
1758
+ // ---------------------------------------------------------------------------
1759
+
1760
+ interface LoadedImage {
1761
+ path: string;
1762
+ kind: "prg" | "flat";
1763
+ origin: number;
1764
+ body: Uint8Array;
1765
+ totalBytes: number;
1766
+ }
1767
+
1768
+ function loadImage(name: string, args: unknown): LoadedImage {
1769
+ const raw = assertImageArg(name, args);
1770
+ const path = resolveWorkspacePath(raw);
1771
+ if (!existsSync(path)) {
1772
+ throw new AnnoStorePathError(
1773
+ `${name} refused: no image exists at ${JSON.stringify(path)} -- a derived read names the bytes it derives from (D-07), ` +
1774
+ "and an image that is not there is a different fact from an image with nothing in it.",
1775
+ { path },
1776
+ );
1777
+ }
1778
+ const bytes = new Uint8Array(readFileSync(path));
1779
+ const ext = extname(path).toLowerCase();
1780
+ try {
1781
+ if (ext === ".raw" || ext === ".bin") {
1782
+ return { path, kind: "flat", origin: flatImageOrigin(bytes), body: bytes, totalBytes: bytes.length };
1783
+ }
1784
+ if (ext !== ".prg" && bytes.length === 65536) {
1785
+ return { path, kind: "flat", origin: flatImageOrigin(bytes), body: bytes, totalBytes: bytes.length };
1786
+ }
1787
+ const { origin, body } = parsePrg(bytes);
1788
+ return { path, kind: "prg", origin, body, totalBytes: bytes.length };
1789
+ } catch (err) {
1790
+ // `prg-image.ts` throws a bare `Error` by design -- it is a pure
1791
+ // byte-layout module with no error family of its own. Wrapped here so the
1792
+ // never-throw boundary can still name a class, and so the message carries
1793
+ // the caller's own vocabulary (the image path) rather than only the
1794
+ // internal function name.
1795
+ const reason = err instanceof Error ? err.message : String(err);
1796
+ throw new AnnoToolArgumentError(
1797
+ `${name} refused: ${JSON.stringify(path)} is not an image this surface can read (${reason}). Supply a .prg (a 2-byte ` +
1798
+ "little-endian load address plus a payload) or an exactly-65536-byte flat capture.",
1799
+ { toolName: name, argument: "image" },
1800
+ );
1801
+ }
1802
+ }
1803
+
1804
+ /** Shannon entropy of `bytes`, in bits per byte. Above roughly 7.5 the image is
1805
+ * very likely compressed or packed, and nothing in it will decode sensibly
1806
+ * until it is depacked -- which is why this is REPORTED rather than left for a
1807
+ * caller to wonder about after a disassembly comes back as noise. */
1808
+ function shannonEntropy(bytes: Uint8Array): number {
1809
+ if (bytes.length === 0) return 0;
1810
+ const histogram = new Uint32Array(256);
1811
+ for (const byte of bytes) histogram[byte] += 1;
1812
+ let entropy = 0;
1813
+ for (const count of histogram) {
1814
+ if (count === 0) continue;
1815
+ const p = count / bytes.length;
1816
+ entropy -= p * Math.log2(p);
1817
+ }
1818
+ return Math.round(entropy * 1000) / 1000;
1819
+ }
1820
+
1821
+ /** The slice of `image` covering the inclusive span, or `null` when the span
1822
+ * falls outside the bytes the image actually holds. `null` rather than a short
1823
+ * slice: a partial answer to a range question reads as a complete answer to a
1824
+ * smaller one.
1825
+ *
1826
+ * TOTAL OVER EVERY (start, end) PAIR, and that is three cases, not two. Below
1827
+ * the origin and past the last byte are the obvious two. The third is an
1828
+ * INVERTED span -- a resolved `from` past its own `to` -- which passes both
1829
+ * bound checks while covering no bytes at all, and which `subarray()` would
1830
+ * hand back as a zero-length success. That is the same failure as a short
1831
+ * slice wearing a smaller hat: answering a question about no bytes with an
1832
+ * empty result reads as a complete answer to a smaller question, which is the
1833
+ * very thing this `null` return exists against (CR-01). */
1834
+ function sliceSpan(image: LoadedImage, start: number, end: number): Uint8Array | null {
1835
+ const from = start - image.origin;
1836
+ const to = end - image.origin;
1837
+ if (from < 0 || to >= image.body.length || from > to) return null;
1838
+ return image.body.subarray(from, to + 1);
1839
+ }
1840
+
1841
+ /** The ONE refusal builder both read verbs report through. `anno_disassemble`
1842
+ * and `anno_read_region` each call `sliceSpan()` exactly once, over the span
1843
+ * their own answer would have reported -- the span the CALLER can see -- and
1844
+ * each reaches this builder from that one verdict. Their AGREEMENT is the
1845
+ * property CR-01 was reported against: the defect was `anno_disassemble`
1846
+ * narrowing the requested end down to the image's last address BEFORE slicing,
1847
+ * so an out-of-image start produced an empty slice instead of the `null` that
1848
+ * reaches here, and the caller got `instructions:0` with an `end_address`
1849
+ * numerically below the `address` asked about. Do not reintroduce a per-verb
1850
+ * narrowing: it makes the two verbs disagree about the same bytes. */
1851
+ function outsideImage(name: string, image: LoadedImage, start: number, end: number): Record<string, unknown> {
1852
+ const last = image.origin + image.body.length - 1;
1853
+ return {
1854
+ available: false,
1855
+ reason:
1856
+ `${name} was asked for $${start.toString(16).padStart(4, "0")}..$${end.toString(16).padStart(4, "0")}, which is not ` +
1857
+ `entirely inside the image: ${JSON.stringify(image.path)} loads at $${image.origin.toString(16).padStart(4, "0")} and ` +
1858
+ `ends at $${last.toString(16).padStart(4, "0")}. Reported as unanswerable rather than served as a short slice, because a ` +
1859
+ "partial answer to a range question reads as a complete answer to a smaller one. Narrow the range, or name the image that " +
1860
+ "actually covers those addresses.",
1861
+ };
1862
+ }
1863
+
1864
+ function hexdump(bytes: Uint8Array, start: number): string[] {
1865
+ const lines: string[] = [];
1866
+ for (let offset = 0; offset < bytes.length; offset += 16) {
1867
+ const chunk = bytes.subarray(offset, offset + 16);
1868
+ const hex = [...chunk].map((b) => b.toString(16).padStart(2, "0")).join(" ");
1869
+ lines.push(`$${(start + offset).toString(16).padStart(4, "0")} ${hex}`);
1870
+ }
1871
+ return lines;
1872
+ }
1873
+
1874
+ function dispatchDisassemble(args: unknown): unknown {
1875
+ const image = loadImage("anno_disassemble", args);
1876
+ const bag = argBag(args);
1877
+ const start = parseStoreAddress(bag.address, { what: "address" });
1878
+ const cap = currentReadRegionMaxBytes();
1879
+ const last = image.origin + image.body.length - 1;
1880
+ // An omitted end is the CAP, not the whole image: the default has to be the
1881
+ // bound, or the default is the hazard.
1882
+ const requestedEnd = bag.end_address !== undefined ? parseStoreAddress(bag.end_address, { what: "end_address" }) : Math.min(start + cap - 1, last);
1883
+ if (bag.end_address !== undefined) assertWithinRegionCap("anno_disassemble", start, requestedEnd, undefined);
1884
+ // Sliced on the span the CALLER named, never on one narrowed down to the
1885
+ // image's last address first. The narrowing used to happen here, and it is
1886
+ // what made this verb disagree with `anno_read_region` (CR-01) -- see
1887
+ // `outsideImage()`. Note what is NOT lost: an omitted `end_address` derives
1888
+ // `requestedEnd` from the image's own last address above, so it is inside
1889
+ // the image by construction and nothing a caller named is narrowed away.
1890
+ const slice = sliceSpan(image, start, requestedEnd);
1891
+ if (slice === null) return outsideImage("anno_disassemble", image, start, requestedEnd);
1892
+
1893
+ const instructions = decode(slice, start, { end: requestedEnd });
1894
+ return {
1895
+ image: image.path,
1896
+ origin: image.origin,
1897
+ address: start,
1898
+ end_address: requestedEnd,
1899
+ instructions: instructions.length,
1900
+ listing: render(instructions, { origin: start }),
1901
+ };
1902
+ }
1903
+
1904
+ function dispatchReadRegion(args: unknown): unknown {
1905
+ const image = loadImage("anno_read_region", args);
1906
+ const bag = argBag(args);
1907
+ const start = parseStoreAddress(bag.start_address, { what: "start_address" });
1908
+ const end = parseStoreAddress(bag.end_address, { what: "end_address" });
1909
+ const view = bag.view === "hexdump" ? "hexdump" : "disasm";
1910
+ const slice = sliceSpan(image, start, end);
1911
+ if (slice === null) return outsideImage("anno_read_region", image, start, end);
1912
+
1913
+ if (view === "hexdump") {
1914
+ return { image: image.path, origin: image.origin, start_address: start, end_address: end, view, bytes: slice.length, hexdump: hexdump(slice, start).join("\n") };
1915
+ }
1916
+ const instructions = decode(slice, start, { end });
1917
+ return {
1918
+ image: image.path,
1919
+ origin: image.origin,
1920
+ start_address: start,
1921
+ end_address: end,
1922
+ view,
1923
+ bytes: slice.length,
1924
+ instructions: instructions.length,
1925
+ listing: render(instructions, { origin: start }),
1926
+ };
1927
+ }
1928
+
1929
+ function dispatchBinaryInfo(args: unknown): unknown {
1930
+ const image = loadImage("anno_get_binary_info", args);
1931
+ const entropy = shannonEntropy(image.body);
1932
+ return {
1933
+ image: image.path,
1934
+ kind: image.kind,
1935
+ origin: image.origin,
1936
+ total_bytes: image.totalBytes,
1937
+ body_bytes: image.body.length,
1938
+ last_address: image.origin + image.body.length - 1,
1939
+ entropy,
1940
+ likely_packed: entropy > 7.5,
1941
+ };
1942
+ }
1943
+
1944
+ function dispatchCrossReferences(handle: AnnoStoreHandle, args: unknown): unknown {
1945
+ const image = loadImage("anno_get_cross_references", args);
1946
+ const maxResults = assertMaxResults("anno_get_cross_references", args);
1947
+ const bag = argBag(args);
1948
+ const union = crossReferencesTo(handle, image.body, image.origin, bag.address as number | string);
1949
+ const callers = union.callers.slice(0, maxResults);
1950
+ return {
1951
+ store: handle.path,
1952
+ image: image.path,
1953
+ to: union.to,
1954
+ callers,
1955
+ returned: callers.length,
1956
+ total: union.count,
1957
+ truncated: union.count > callers.length,
1958
+ };
1959
+ }
1960
+
1961
+ function dispatchSearch(handle: AnnoStoreHandle, args: unknown): unknown {
1962
+ const image = loadImage("anno_search", args);
1963
+ const bag = argBag(args);
1964
+ // THE CALLER'S OWN BAG IS PASSED THROUGH, not reconstructed from the three
1965
+ // keys this layer knows about. `searchAnnotations` detects a corpus this
1966
+ // surface does not have by scanning for `search_<name>` keys it does not
1967
+ // recognise, so rebuilding the request here would silently DROP exactly the
1968
+ // signal the unanswerable-corpus report depends on -- and the caller would
1969
+ // get a clean, plausible, wrong hit list for a corpus that was never
1970
+ // searched. `query` and `max_results` are re-stated last so the validated
1971
+ // values win over whatever shape arrived.
1972
+ const result = searchAnnotations(handle, image.body, image.origin, {
1973
+ ...bag,
1974
+ query: bag.query as string,
1975
+ max_results: assertMaxResults("anno_search", args),
1976
+ });
1977
+
1978
+ const unanswerable = Object.keys(result.unavailable);
1979
+ if (unanswerable.length > 0) {
1980
+ // THE WHOLE CALL IS ANSWERED AS UNANSWERABLE, not served as a partial
1981
+ // result set with a footnote. The request named a corpus this surface does
1982
+ // not have, so any hit list returned beside that would look like the
1983
+ // complete answer to the question actually asked -- which is the
1984
+ // plausible-looking zero this shape exists against. `isError` stays FALSE:
1985
+ // the request was well-formed and the answer is "no".
1986
+ return {
1987
+ available: false,
1988
+ reason: unanswerable.map((corpus) => result.unavailable[corpus]!.reason).join(" "),
1989
+ unanswerable_corpora: unanswerable,
1990
+ corpora: result.corpora,
1991
+ };
1992
+ }
1993
+ return { store: handle.path, image: image.path, ...result };
1994
+ }
1995
+
1996
+ function dispatchAddressDetails(handle: AnnoStoreHandle, args: unknown): unknown {
1997
+ const image = loadImage("anno_get_address_details", args);
1998
+ const bag = argBag(args);
1999
+ return { store: handle.path, image: image.path, ...composeAddressDetails(handle, image.body, image.origin, bag.address as number | string) };
2000
+ }
2001
+
2002
+
2003
+ /** PHASE TWO. Runs every entry against the ONE already-open handle, to
2004
+ * COMPLETION, pushing a per-entry status and never aborting on the first
2005
+ * failure. Pre-validation has already refused every batch that should not have
2006
+ * been sent, so a failure here is genuinely about one call rather than about
2007
+ * the payload. */
2008
+ async function dispatchBatchExecute(handle: AnnoStoreHandle, args: unknown): Promise<unknown> {
2009
+ const bag = argBag(args);
2010
+ const calls = bag.calls as Record<string, unknown>[];
2011
+ const results: Record<string, unknown>[] = [];
2012
+ for (const [index, call] of calls.entries()) {
2013
+ const name = call.name as string;
2014
+ const innerArgs = batchArgumentsFor(bag, call);
2015
+ try {
2016
+ const value = name === "anno_batch_execute" ? await dispatchBatchExecute(handle, innerArgs) : await dispatch(name, innerArgs, handle);
2017
+ results.push({ index, name, status: "success", result: value });
2018
+ } catch (err) {
2019
+ // NAMED BY CLASS, exactly as the outer boundary names it, so a per-item
2020
+ // failure is as diagnosable as a whole-call one.
2021
+ const errName = err instanceof Error ? err.name : "Error";
2022
+ const errMessage = err instanceof Error ? err.message : String(err);
2023
+ results.push({ index, name, status: "error", error: `[${errName}] ${errMessage}` });
2024
+ }
2025
+ }
2026
+ const failed = results.filter((entry) => entry.status === "error").length;
2027
+ return {
2028
+ store: handle.path,
2029
+ results,
2030
+ executed: results.length,
2031
+ succeeded: results.length - failed,
2032
+ failed,
2033
+ note:
2034
+ "Every entry ran: this loop does not abort on the first failure, so an error entry here means THAT CALL did not work, " +
2035
+ "not that the batch should not have been sent. A batch that should not have been sent is refused WHOLE before anything " +
2036
+ "is opened, and arrives as isError:true instead of as a per-item status.",
2037
+ };
2038
+ }
2039
+
2040
+ async function dispatch(name: string, args: unknown, handle: AnnoStoreHandle): Promise<unknown> {
2041
+ if (name === "anno_get_symbols") return dispatchGetSymbols(handle, args);
2042
+ if (name === "anno_set_label_name") return dispatchSetLabelName(handle, args);
2043
+ if (name === "anno_set_comment") return dispatchSetComment(handle, args);
2044
+ if (name === "anno_set_data_type") return dispatchSetDataType(handle, args);
2045
+ if (name === "anno_add_scope" || name === "anno_remove_scope") return dispatchScope(name, handle, args);
2046
+ if (name === "anno_get_comments") return dispatchGetComments(handle, args);
2047
+ if (name === "anno_get_blocks") return dispatchGetBlocks(handle, args);
2048
+ if (name === "anno_create_project_enum") return dispatchCreateProjectEnum(handle, args);
2049
+ if (name === "anno_update_project_enum") return dispatchUpdateProjectEnum(handle, args);
2050
+ if (name === "anno_apply_enum_usage") return dispatchApplyEnumUsage(handle, args);
2051
+ if (name === "anno_save_project") return dispatchSaveProject(handle);
2052
+ if (name === "anno_disassemble") return dispatchDisassemble(args);
2053
+ if (name === "anno_read_region") return dispatchReadRegion(args);
2054
+ if (name === "anno_get_binary_info") return dispatchBinaryInfo(args);
2055
+ if (name === "anno_get_cross_references") return dispatchCrossReferences(handle, args);
2056
+ if (name === "anno_search") return dispatchSearch(handle, args);
2057
+ if (name === "anno_get_address_details") return dispatchAddressDetails(handle, args);
2058
+ if (name === "anno_batch_execute") return dispatchBatchExecute(handle, args);
2059
+ // Unreachable: `assertAnnoTool()` above has already refused every name
2060
+ // outside `CURATED_ANNO_TOOLS`, and every curated name has an arm here. It
2061
+ // refuses BY NAME anyway rather than returning a plausible-looking empty
2062
+ // answer -- a curated name with no dispatch arm is a bug in this file, and
2063
+ // saying so is cheaper than a silent `{}` somebody has to trace back.
2064
+ throw new AnnoUncuratedToolError(
2065
+ `"${name}" is curated but has no dispatch arm in anno-tools.ts. Resolution routes: implement it and ` +
2066
+ "add it to ANNO_TOOL_DEFINITIONS with a named criterion, or remove the caller reference.",
2067
+ { toolName: name },
2068
+ );
2069
+ }
2070
+
2071
+ /**
2072
+ * Runs one curated `anno_*` tool call. THE NEVER-THROW BOUNDARY: every failure
2073
+ * -- an uncurated name, a malformed argument, a path outside the workspace, a
2074
+ * corrupt store, a bug in a dispatcher -- resolves as `{isError:true}` text
2075
+ * naming the error CLASS. Nothing rejects the returned promise.
2076
+ *
2077
+ * `assertAnnoTool` is INSIDE the `try`, deliberately and unlike
2078
+ * `anno-tools.ts`'s `runAnnoTool`, whose gate sits outside it so a refusal
2079
+ * REJECTS instead of resolving. That asymmetry is WR-02, recorded as out of
2080
+ * scope at `anno-tools.ts:772-774`; it is closed here.
2081
+ *
2082
+ * NO VERB EVER CREATES THE STORE IT WAS ASKED TO USE (D-06): "the annotations
2083
+ * are gone" and "there are no annotations" must not read the same. A read-only
2084
+ * verb gets that through `openStore`'s own `mustExist`; a writing verb gets it
2085
+ * through `assertStorePresent()` plus the inode guard above, because
2086
+ * `mustExist` also forces a read-only connection and there is no third state to
2087
+ * ask for. `closeStore` runs in a `finally`, so the handle is released on the
2088
+ * throwing path exactly as on the succeeding one (T-29-03).
2089
+ */
2090
+ export async function runAnnoTool(name: string, args: unknown): Promise<ToolCallResult> {
2091
+ try {
2092
+ assertAnnoTool(name, args);
2093
+ const storePath = resolveStoreArg(name, args);
2094
+ const inodeBefore = assertStorePresent(name, storePath);
2095
+ const handle = openStore(storePath, { workspaceRoot: repoRoot(), mustExist: READ_ONLY_ANNO_VERBS.includes(name) });
2096
+ try {
2097
+ assertSameFile(name, storePath, inodeBefore);
2098
+ return okText(JSON.stringify(await dispatch(name, args, handle)));
2099
+ } finally {
2100
+ closeStore(handle);
2101
+ }
2102
+ } catch (err) {
2103
+ // Named by class (D18-12: a mid-window failure must surface a named,
2104
+ // distinguishable error, never a silent success) -- a caller can tell
2105
+ // AnnoStoreCorruptError apart from AnnoStorePathError etc. from this text
2106
+ // alone, without re-parsing loose message wording.
2107
+ const errName = err instanceof Error ? err.name : "Error";
2108
+ const errMessage = err instanceof Error ? err.message : String(err);
2109
+ return errText(`${name} failed: [${errName}] ${errMessage}`);
2110
+ }
2111
+ }