@henols/vice-mcp 0.2.1 → 0.2.3

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 (108) hide show
  1. package/README.md +4 -3
  2. package/THIRD-PARTY-NOTICES.md +423 -25
  3. package/{r2000-acme-ident.ts → anno-acme-ident.ts} +13 -13
  4. package/anno-bank.ts +171 -0
  5. package/anno-cli.ts +3040 -0
  6. package/{r2000-confidence.ts → anno-confidence.ts} +22 -22
  7. package/anno-coverage.ts +2465 -0
  8. package/anno-derive.ts +590 -0
  9. package/anno-details.ts +169 -0
  10. package/anno-enum-gen.ts +919 -0
  11. package/anno-export-asm.ts +2396 -0
  12. package/anno-graphics.ts +338 -0
  13. package/anno-hazard-report.ts +1367 -0
  14. package/anno-import.ts +495 -0
  15. package/anno-index.ts +150 -0
  16. package/anno-join.ts +480 -0
  17. package/{r2000-memmap-render.ts → anno-memmap-render.ts} +236 -95
  18. package/anno-provenance-ledger.ts +472 -0
  19. package/{r2000-regbits-gen.ts → anno-regbits-gen.ts} +20 -15
  20. package/{r2000-regbits.json → anno-regbits.json} +2 -2
  21. package/anno-register.ts +399 -0
  22. package/anno-store-export.ts +661 -0
  23. package/anno-store.ts +4002 -0
  24. package/anno-symbols.ts +266 -0
  25. package/anno-tools.ts +3264 -0
  26. package/anno-types.ts +1909 -0
  27. package/backend-detect.mts +124 -312
  28. package/block-class.ts +201 -0
  29. package/build.ts +4 -2
  30. package/capture-predicate.ts +597 -0
  31. package/channel-lock.ts +349 -0
  32. package/disasm-decoder.ts +14 -14
  33. package/disasm-opcodes.ts +4 -4
  34. package/disasm-renderer.ts +2 -2
  35. package/evid-ingest.ts +217 -0
  36. package/evid-reconcile.ts +316 -0
  37. package/host-tool-client.ts +430 -0
  38. package/hostpath.ts +1 -1
  39. package/incident-record.ts +23 -12
  40. package/install-resources.ts +30 -14
  41. package/memmap-lookup.ts +285 -0
  42. package/package.json +48 -23
  43. package/prg-image.ts +118 -0
  44. package/repo-root.ts +107 -8
  45. package/resources/backend-detect.mjs +98 -236
  46. package/resources/broker-control.mjs +189 -16
  47. package/resources/broker-epoch.mjs +1 -1
  48. package/resources/broker-kill.mjs +8 -2
  49. package/resources/broker-launch.mjs +373 -214
  50. package/resources/broker-state.mjs +64 -18
  51. package/resources/container-guard.mjs +1 -1
  52. package/resources/ghidra-project.mjs +790 -0
  53. package/resources/host-tool.mjs +2561 -0
  54. package/resources/vice-broker.mjs +330 -184
  55. package/resources/vice-launcher.sh +130 -12
  56. package/stock-address.ts +6 -6
  57. package/stock-cia.ts +2 -2
  58. package/stock-condition.ts +8 -8
  59. package/stock-connect.ts +10 -6
  60. package/stock-derived.ts +29 -37
  61. package/stock-diagnose.ts +200 -36
  62. package/stock-dispatch.ts +200 -68
  63. package/stock-execution.ts +5 -3
  64. package/stock-handler.ts +1 -1
  65. package/stock-input.ts +9 -9
  66. package/stock-machine.ts +17 -6
  67. package/stock-paths.ts +18 -14
  68. package/stock-petscii.ts +1 -1
  69. package/stock-protocol.ts +17 -12
  70. package/stock-recycle.ts +83 -2
  71. package/stock-registers.ts +54 -29
  72. package/stock-reproducible-run.ts +811 -0
  73. package/stock-run-until.ts +100 -1
  74. package/stock-sprites.ts +3 -3
  75. package/stock-symbols.ts +13 -13
  76. package/stock-timing.ts +2 -2
  77. package/stock-vicii.ts +1 -1
  78. package/stop-oracle.ts +167 -0
  79. package/text-capability-probe.ts +660 -0
  80. package/text-connect.ts +157 -0
  81. package/text-protocol.ts +810 -0
  82. package/text-tools.ts +778 -0
  83. package/textmon-backtrace.ts +385 -0
  84. package/textmon-cpuhistory.ts +335 -0
  85. package/textmon-memmap.ts +494 -0
  86. package/textmon-profile.ts +458 -0
  87. package/textmon-registers.ts +748 -0
  88. package/tools-manifest.stock.json +864 -3
  89. package/version.ts +1 -1
  90. package/vice-broker-client.ts +189 -42
  91. package/vice-errors.ts +268 -0
  92. package/vice-proxy.ts +392 -2175
  93. package/vsf-slice.ts +640 -0
  94. package/capability-registry.ts +0 -388
  95. package/r2000-cli.ts +0 -1103
  96. package/r2000-d64.ts +0 -310
  97. package/r2000-enum-gen.ts +0 -574
  98. package/r2000-launch.ts +0 -357
  99. package/r2000-mcp-client.ts +0 -596
  100. package/r2000-project.ts +0 -190
  101. package/r2000-symbols.ts +0 -388
  102. package/r2000-tools.ts +0 -914
  103. package/r2000-verify.ts +0 -184
  104. package/refresh-manifest.ts +0 -124
  105. package/tools-manifest.json +0 -1223
  106. package/vice-probe.ts +0 -278
  107. package/vice-sync.ts +0 -336
  108. package/vice.ts +0 -772
package/anno-tools.ts ADDED
@@ -0,0 +1,3264 @@
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 confused-deputy shape a
81
+ // generic meta-tool surface this project closed once had, with an
82
+ // outer-name-only refusal array: the outer name passes the gate while the
83
+ // inner name never sees it. This one verb earns the exception by being the only
84
+ // route to the multi-edit pass an annotation run actually performs, and it
85
+ // pays for it with `assertAnnoBatch()` below -- a recursive, DEPTH-CAPPED
86
+ // pre-validator that refuses the WHOLE batch, before any store is opened, if
87
+ // anything at any depth is wrong. Adding a second such verb would reopen the
88
+ // hole this one closes.
89
+ //
90
+ // WHAT NOT TO DO:
91
+ // - Never hand-type a second list of curated names. `CURATED_ANNO_TOOLS` is
92
+ // derived from `ANNO_TOOL_DEFINITIONS`'s own `name` values precisely so a
93
+ // name cannot be curated in one place and absent from the other (T-29-02).
94
+ // - Never widen `CURATED_ANNO_TOOLS` without adding the definition here with
95
+ // a named criterion. The gate's FIRST statement is set membership; a name
96
+ // that is not in the set is refused before any argument is looked at.
97
+ // - Never re-implement an argument rule the store already owns. Addresses go
98
+ // through `parseStoreAddress`, ranges through `assertRangeShape`, data
99
+ // types through `assertDataType`, label names through `assertLegalLabel`,
100
+ // comment text through `assertCommentText`, enum names through
101
+ // `assertEnumName`. A second, divergent rule here would accept a value the
102
+ // store then refuses, or the reverse, and the disagreement would be
103
+ // invisible because both look authoritative.
104
+ // - Never sanitize. An illegal label, enum name or comment is REJECTED by
105
+ // name, never quoted, trimmed, coerced or normalized into a legal one:
106
+ // the store's printed name must never diverge from the symbol an export
107
+ // would emit (T-29-23).
108
+ // - Never add a second comment-length check or a truncation. The byte bound
109
+ // is `assertCommentText()`'s and it is measured in UTF-8 BYTES, not code
110
+ // units; this layer adds nothing on top of it.
111
+ // - Never map `changed: false` to an error. A repeated identical edit
112
+ // SUCCEEDING while reporting no change is the store's own idempotency, and
113
+ // an agent re-running an annotation pass must not have to diff first.
114
+ // - Never drop `contradictedComments` or `reinterpretedSplitTables` from
115
+ // `anno_set_data_type`'s body. 28-VERIFICATION.md hands this phase the
116
+ // obligation in writing: the disclosure must be SURFACED where the human
117
+ // sees it, or the human never sees it. A success that quietly drops it is
118
+ // exactly the plausible-looking clean answer this surface forbids.
119
+ // - Never move `assertAnnoTool()` out of `runAnnoTool()`'s `try`. That
120
+ // asymmetry is WR-02, recorded as out of scope at `anno-tools.ts:772-774`
121
+ // and CLOSED here: inside the `try`, a refusal RESOLVES `{isError:true}`
122
+ // like every other failure instead of REJECTING the returned promise, so
123
+ // the caller has one shape to handle rather than two.
124
+ // - Never resolve a store or image path with `resolve()` + `startsWith`.
125
+ // Containment goes through `storePathWithinWorkspace()`, which resolves the
126
+ // deepest EXISTING ancestor's realpath (WR-01) -- a not-yet-existing leaf
127
+ // under a directory symlink escaped the naive form entirely.
128
+ // - Never hold the handle beyond the call, and never open a store outside a
129
+ // `try`/`finally` that closes it (T-29-03).
130
+ // - Never collapse a failure into a bare string. The runner's catch names
131
+ // the error CLASS, so a caller can tell an `AnnoStoreCorruptError` from an
132
+ // `AnnoStorePathError` from the text alone (T-29-04, D18-12).
133
+ //
134
+ import { existsSync, readFileSync, statSync } from "node:fs";
135
+ import { extname } from "node:path";
136
+
137
+ import {
138
+ addExcludedRange,
139
+ addScope,
140
+ applyEnumUsage,
141
+ applyWrite,
142
+ clearEnumUsage,
143
+ closeStore,
144
+ createProjectEnum,
145
+ currentRevision,
146
+ deleteExecObservationsForRun,
147
+ insertExecObservations,
148
+ listComments,
149
+ listEnumUsage,
150
+ listExcludedRanges,
151
+ listExecObservations,
152
+ listLabels,
153
+ listObservedRuns,
154
+ listProjectEnums,
155
+ listRanges,
156
+ listScopes,
157
+ listXrefs,
158
+ openStore,
159
+ removeExcludedRange,
160
+ removeScope,
161
+ setComment,
162
+ setDataType,
163
+ setLabel,
164
+ updateProjectEnum,
165
+ } from "./anno-store.ts";
166
+ import type { AnnoStoreHandle } from "./anno-store.ts";
167
+ import {
168
+ AnnoRevisionArgumentError,
169
+ AnnoStoreError,
170
+ AnnoStorePathError,
171
+ AnnoStoreStaleRevisionError,
172
+ assertCommentText,
173
+ assertCommentType,
174
+ assertDataType,
175
+ assertEnumName,
176
+ assertLabelKind,
177
+ assertLegalLabel,
178
+ assertRangeShape,
179
+ parseStoreAddress,
180
+ parseVariantKey,
181
+ storePathWithinWorkspace,
182
+ } from "./anno-types.ts";
183
+ import type { AnnoStoreErrorOptions, CommentRow, EnumUsageRow, LabelRow, ProjectEnumRow } from "./anno-types.ts";
184
+ import { crossReferencesTo, searchAnnotations } from "./anno-derive.ts";
185
+ import { composeAddressDetails } from "./anno-details.ts";
186
+ import { decode } from "./disasm-decoder.ts";
187
+ import type { Instruction } from "./disasm-decoder.ts";
188
+ import { render } from "./disasm-renderer.ts";
189
+ // D-16's SECOND renderer (plan 45-05): `anno-export-asm.ts` carries the
190
+ // proof (a real-ACME byte-diff oracle), this file carries the readability --
191
+ // both call decomposeRegisterValue(), the ONE owning decoder, and NEITHER
192
+ // decodes a bit itself. `REGISTER_ENUM_NAME_RE` below is deliberately a
193
+ // SEPARATE, small predicate from `anno-export-asm.ts`'s own copy: D-16 names
194
+ // two renderers, each owning its own substitution glue, and only the decoder
195
+ // itself is shared. `hasRegBitsEntry()` is likewise shared (45-REVIEW CR-01,
196
+ // fixed 2026-09-11): both renderers gate the decoder attempt on TABLE
197
+ // MEMBERSHIP, not name shape alone, via this one exported predicate -- a
198
+ // second, locally-derived membership test would be exactly the kind of
199
+ // "two answers to one question" this file's own header elsewhere refuses.
200
+ import { decomposeRegisterValue, hasRegBitsEntry, type RegisterDecomposition } from "./anno-enum-gen.ts";
201
+ import { importGhidraExport } from "./anno-import.ts";
202
+ import type { ConstWriteFact } from "./anno-import.ts";
203
+ import { runMemmapJoin } from "./anno-join.ts";
204
+ import { accessMapRanges, parseAccessMap } from "./textmon-memmap.ts";
205
+ import { ingestAccessMap, runIdentityFrom, type IngestRunIdentity } from "./evid-ingest.ts";
206
+ import { reconcileObservedExecution } from "./evid-reconcile.ts";
207
+ // The pure, read-only movement-hazard report. Declares its own
208
+ // input shapes and never reads a store, a file or a tool on its own behalf --
209
+ // the SAME caller-fetches-everything split `anno-coverage.ts`'s own header
210
+ // states for the coverage instrument, and exactly why the store re-point
211
+ // below is a CALLER-side change and nothing more.
212
+ import { buildHazardReport } from "./anno-hazard-report.ts";
213
+ import { flatImageOrigin, parsePrg } from "./prg-image.ts";
214
+ import { repoRoot } from "./repo-root.ts";
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // The wire shapes this module produces/consumes. Deliberately NOT imported
218
+ // from vice-proxy.ts (that file has no exported ToolDefinition/ToolCallResult
219
+ // -- both are file-local types there); these are structurally identical so a
220
+ // value built here is interchangeable wherever vice-proxy.ts combines it with
221
+ // its own manifest-sourced tools.
222
+ // ---------------------------------------------------------------------------
223
+
224
+ export interface AnnoToolDefinition {
225
+ name: string;
226
+ description: string;
227
+ inputSchema: {
228
+ type: "object";
229
+ properties: Record<string, unknown>;
230
+ required?: string[];
231
+ };
232
+ // Structural compatibility with vice.ts's own ToolInfo (vice-proxy.ts's
233
+ // ToolDefinition alias), which carries this index signature -- lets
234
+ // vice-proxy.ts's buildViceTool() accept an AnnoToolDefinition directly,
235
+ // with no per-call cast at the registration site.
236
+ [key: string]: unknown;
237
+ }
238
+
239
+ interface ToolCallResult {
240
+ content: { type: "text"; text: string }[];
241
+ isError: boolean;
242
+ }
243
+
244
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
245
+ return typeof value === "object" && value !== null && !Array.isArray(value);
246
+ }
247
+
248
+ function okText(text: string): ToolCallResult {
249
+ return { content: [{ type: "text", text }], isError: false };
250
+ }
251
+
252
+ function errText(text: string): ToolCallResult {
253
+ return { content: [{ type: "text", text }], isError: true };
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Refusals. Both are `AnnoStoreError` subclasses and therefore `ViceError`s --
258
+ // never a bare `Error` -- so one `catch` can take the whole family, and the
259
+ // runner's `[${errName}]` prefix below names which member fired.
260
+ // ---------------------------------------------------------------------------
261
+
262
+ export interface AnnoUncuratedToolErrorOptions extends AnnoStoreErrorOptions {
263
+ toolName?: string;
264
+ batchIndex?: number;
265
+ }
266
+
267
+ /** A tool name outside `CURATED_ANNO_TOOLS` was dispatched, directly or as an
268
+ * inner call of a batch. The message names BOTH resolution routes, so the
269
+ * refusal is actionable without reading this file. */
270
+ export class AnnoUncuratedToolError extends AnnoStoreError {
271
+ toolName?: string;
272
+ batchIndex?: number;
273
+
274
+ constructor(message: string, { toolName, batchIndex, ...rest }: AnnoUncuratedToolErrorOptions = {}) {
275
+ super(message, rest);
276
+ this.name = "AnnoUncuratedToolError";
277
+ this.toolName = toolName;
278
+ this.batchIndex = batchIndex;
279
+ }
280
+ }
281
+
282
+ export interface AnnoToolArgumentErrorOptions extends AnnoStoreErrorOptions {
283
+ toolName?: string;
284
+ argument?: string;
285
+ batchIndex?: number;
286
+ }
287
+
288
+ /** A curated tool was called with an argument the transport cannot have
289
+ * checked. `vice-proxy.ts:3230`'s `validate: (value) => ({ value })` means the
290
+ * MCP transport validates NOTHING -- `required` in an `inputSchema` is
291
+ * documentation for the model, not an enforced contract -- so every required
292
+ * argument is re-checked here, at the only boundary that actually runs. */
293
+ export class AnnoToolArgumentError extends AnnoStoreError {
294
+ toolName?: string;
295
+ argument?: string;
296
+ batchIndex?: number;
297
+
298
+ constructor(message: string, { toolName, argument, batchIndex, ...rest }: AnnoToolArgumentErrorOptions = {}) {
299
+ super(message, rest);
300
+ this.name = "AnnoToolArgumentError";
301
+ this.toolName = toolName;
302
+ this.argument = argument;
303
+ this.batchIndex = batchIndex;
304
+ }
305
+ }
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // Shared argument helpers. `batchIndex` is threaded through EVERY validator so
309
+ // one refusal message serves both call routes: `anno_set_label_name refused:`
310
+ // when the verb was called directly, `anno_set_label_name refused (calls[3]):`
311
+ // when it was smuggled inside a batch payload. That is the shared-validator
312
+ // discipline `anno-tools.ts:790-800` records -- one validator per verb, called
313
+ // from both sites, so a refusal fires identically either way.
314
+ // ---------------------------------------------------------------------------
315
+
316
+ function argBag(args: unknown): Record<string, unknown> {
317
+ return isPlainObject(args) ? args : {};
318
+ }
319
+
320
+ function whereOf(batchIndex?: number): string {
321
+ return batchIndex !== undefined ? ` (calls[${batchIndex}])` : "";
322
+ }
323
+
324
+ function refuseArg(name: string, argument: string, detail: string, batchIndex?: number): never {
325
+ throw new AnnoToolArgumentError(`${name} refused${whereOf(batchIndex)}: ${detail}`, { toolName: name, argument, batchIndex });
326
+ }
327
+
328
+ /** Narrows the universally-required `store` argument to a non-empty string.
329
+ * Path CONTAINMENT is a separate concern and lives in `resolveWorkspacePath()`
330
+ * below; this only establishes that there is a path to contain. */
331
+ function assertStoreArg(name: string, args: unknown, batchIndex?: number): string {
332
+ const bag = argBag(args);
333
+ if (typeof bag.store !== "string" || bag.store.trim() === "") {
334
+ refuseArg(
335
+ name,
336
+ "store",
337
+ '"store" must be a non-empty string naming an annotation store -- every anno_* verb names its own store (D-06), ' +
338
+ "because there is no ambient current store to inherit.",
339
+ batchIndex,
340
+ );
341
+ }
342
+ return bag.store as string;
343
+ }
344
+
345
+ /** Narrows `max_results` to a positive integer. Required, with no default:
346
+ * see the description on each list-returning definition for why a silent
347
+ * default is worse than a refusal here. */
348
+ function assertMaxResults(name: string, args: unknown, batchIndex?: number): number {
349
+ const raw = argBag(args).max_results;
350
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw <= 0) {
351
+ refuseArg(
352
+ name,
353
+ "max_results",
354
+ `"max_results" must be a positive integer, got ${JSON.stringify(raw)} -- it is REQUIRED and has no default on ` +
355
+ "this surface, so a truncated answer is always an explicit ceiling.",
356
+ batchIndex,
357
+ );
358
+ }
359
+ return raw as number;
360
+ }
361
+
362
+ /** `anno_evid_disagreements`'s own OPTIONAL `max_results` (plan 43-06).
363
+ * Unlike every other list-returning verb (`assertMaxResults` above, REQUIRED
364
+ * with no default), an unbounded disagreement report is the ordinary case: a
365
+ * sound store often disagrees nowhere at all, and forcing a ceiling on a
366
+ * legitimately small or empty answer would buy nothing. When SUPPLIED, the
367
+ * bound and refusal wording are the SAME as `assertMaxResults`'s -- this is
368
+ * not a second, looser rule, only an optional one. */
369
+ function assertOptionalMaxResults(name: string, args: unknown, batchIndex?: number): number | undefined {
370
+ const raw = argBag(args).max_results;
371
+ if (raw === undefined) return undefined;
372
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw <= 0) {
373
+ refuseArg(name, "max_results", `"max_results" must be a positive integer when supplied, got ${JSON.stringify(raw)}.`, batchIndex);
374
+ }
375
+ return raw as number;
376
+ }
377
+
378
+ /** Requires a present argument and hands it to `parseStoreAddress` -- the ONE
379
+ * address parser, which owns the `$`/`0x` forms and the deliberate refusal of
380
+ * an unprefixed numeric string. Absence is a DIFFERENT fact from malformity,
381
+ * so it gets its own refusal rather than being folded into the parser's. */
382
+ function assertAddressArg(name: string, args: unknown, key: string, batchIndex?: number): number {
383
+ const raw = argBag(args)[key];
384
+ if (raw === undefined) {
385
+ refuseArg(name, key, `"${key}" is required and was not supplied.`, batchIndex);
386
+ }
387
+ return parseStoreAddress(raw, { what: key });
388
+ }
389
+
390
+ /** The inclusive span two verbs in three share. Both ends go through the one
391
+ * address parser; the SHAPE (ends inside the address space, end not below
392
+ * start, and -- for a split layout -- the even-byte-count rule) goes through
393
+ * `assertRangeShape`, which owns all three. */
394
+ function assertSpanArgs(name: string, args: unknown, dataType: Parameters<typeof assertRangeShape>[2], batchIndex?: number): { start: number; end: number } {
395
+ const start = assertAddressArg(name, args, "start_address", batchIndex);
396
+ const end = assertAddressArg(name, args, "end_address", batchIndex);
397
+ assertRangeShape(start, end, dataType);
398
+ return { start, end };
399
+ }
400
+
401
+ /** Validates the optional `base_revision` compare-and-swap argument.
402
+ *
403
+ * `anno-store.ts`'s own `assertRevisionArgument` is module-private, so this
404
+ * throws that module's OWN exported `AnnoRevisionArgumentError` rather than a
405
+ * fourth class: WR-22's recorded failure was a revision-shaped argument
406
+ * (`"0001"`) surviving as far as SQLite, whose INTEGER affinity turned an
407
+ * argument error into a corruption refusal. A caller must be able to tell
408
+ * "you passed the wrong thing" from "the annotations are gone" BY CLASS. */
409
+ function assertBaseRevisionArg(name: string, args: unknown, batchIndex?: number): number | undefined {
410
+ const raw = argBag(args).base_revision;
411
+ if (raw === undefined) return undefined;
412
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
413
+ throw new AnnoRevisionArgumentError(
414
+ `${name} refused${whereOf(batchIndex)}: "base_revision" must be a non-negative integer, got ${JSON.stringify(raw)} -- ` +
415
+ "a numeric STRING in particular is refused here rather than left to SQLite's column affinity, which turns an argument " +
416
+ "error into a corruption refusal (WR-22).",
417
+ { value: raw, parameter: "base_revision" },
418
+ );
419
+ }
420
+ return raw;
421
+ }
422
+
423
+ /** Validates a label `name` argument through the ONE identifier rule
424
+ * (`assertLegalLabel`) and re-throws as an `AnnoToolArgumentError` carrying the
425
+ * offending name and, inside a batch, the offending index. REJECT, NEVER
426
+ * SANITIZE (T-29-23): substituting a character would merge this name with
427
+ * whatever the substitution produces, and nothing would record that it
428
+ * happened -- the store's printed name must never diverge from the symbol an
429
+ * export would emit. */
430
+ function assertLegalLabelArg(name: string, args: unknown, batchIndex?: number): void {
431
+ const raw = argBag(args).name;
432
+ try {
433
+ assertLegalLabel(raw);
434
+ } catch (err) {
435
+ const reason = err instanceof Error ? err.message : String(err);
436
+ refuseArg(
437
+ name,
438
+ "name",
439
+ `${JSON.stringify(raw)} is not a legal ACME identifier (${reason}) -- REJECTED, never sanitized or quoted.`,
440
+ batchIndex,
441
+ );
442
+ }
443
+ }
444
+
445
+ // ---------------------------------------------------------------------------
446
+ // The curated tool definitions.
447
+ //
448
+ // `store` is on EVERY definition and is always required (D-06). There is no
449
+ // "current store" for a verb to inherit, which is what makes a call's effect a
450
+ // function of its own arguments alone. Each description is written for an
451
+ // AGENT: what the verb answers, what it costs, and what it will refuse.
452
+ // ---------------------------------------------------------------------------
453
+
454
+ /** How deep a nested `anno_batch_execute` may go before the payload is refused
455
+ * by name rather than walked (T-29-24). Four levels is far past any legitimate
456
+ * use -- a batch of batches of batches has no procedure behind it -- and is
457
+ * chosen to be obviously sufficient rather than tuned. */
458
+ export const ANNO_MAX_BATCH_DEPTH = 4;
459
+
460
+ const STORE_PROPERTY = {
461
+ store: {
462
+ type: "string",
463
+ description:
464
+ "Absolute or workspace-relative path to the .annostore annotation store. Refused if it resolves outside the " +
465
+ "workspace root, including via a symlink. REQUIRED on every verb: there is no ambient 'current store'.",
466
+ },
467
+ } as const;
468
+
469
+ const IMAGE_PROPERTY = {
470
+ image: {
471
+ type: "string",
472
+ description:
473
+ "Absolute or workspace-relative path to the program image this answer is DERIVED from -- a .prg (2-byte " +
474
+ "little-endian load address plus payload) or an exactly-65536-byte flat capture (.raw/.bin, dispatched by " +
475
+ "extension before any length check). REQUIRED on every derived read (D-07): the store holds annotations and " +
476
+ "never bytes, so an omitted image would read as a plausible success against whatever was recorded last. " +
477
+ "Refused if it resolves outside the workspace root, including via a symlink.",
478
+ },
479
+ } as const;
480
+
481
+ const BASE_REVISION_PROPERTY = {
482
+ base_revision: {
483
+ type: "integer",
484
+ description:
485
+ "Optional compare-and-swap guard: the revision this edit was computed against. The write is refused with a " +
486
+ "named stale-revision error if the store has moved on. Omit it for an unconditional write. A numeric STRING " +
487
+ "is refused rather than coerced.",
488
+ },
489
+ } as const;
490
+
491
+ export const ANNO_TOOL_DEFINITIONS: readonly AnnoToolDefinition[] = [
492
+ {
493
+ name: "anno_set_label_name",
494
+ description:
495
+ "Binds a name to one address in the annotation store, so a disassembly reads as `jsr irq_handler` rather than " +
496
+ "`jsr $c000`. Costs one store open, one write and one close. REFUSES, never rewrites: a name that is not a legal " +
497
+ "ACME identifier (letter or underscore, then letters/digits/underscores) or that is a 6502/6510 mnemonic is " +
498
+ "rejected with the offending name in the message, because the store's printed name must never diverge from the " +
499
+ "symbol an export would emit. Also refuses a name already bound to a DIFFERENT address rather than rebinding it. " +
500
+ "Setting the same name at the same address again SUCCEEDS and reports `changed: false` -- re-running an " +
501
+ "annotation pass is not an error.",
502
+ inputSchema: {
503
+ type: "object",
504
+ properties: {
505
+ ...STORE_PROPERTY,
506
+ address: {
507
+ description:
508
+ "The address to name. An integer 0..65535, a \"$hex\" string, or a \"0x\" string; an unprefixed numeric " +
509
+ "string is refused on purpose, because a mis-based address written into the store is persistent and silently wrong.",
510
+ },
511
+ name: {
512
+ type: "string",
513
+ description:
514
+ "The label name. Must be a legal ACME identifier and must not be a 6502/6510 mnemonic. An illegal name is " +
515
+ "REJECTED, never sanitized or quoted.",
516
+ },
517
+ kind: {
518
+ type: "string",
519
+ enum: ["User", "Auto", "System", "Platform"],
520
+ description:
521
+ "Provenance of the name. 'User' (the default when omitted) = a human chose it; 'Auto' = generated; " +
522
+ "'System'/'Platform' = a known ROM or hardware name.",
523
+ },
524
+ ...BASE_REVISION_PROPERTY,
525
+ },
526
+ required: ["store", "address", "name"],
527
+ },
528
+ },
529
+ {
530
+ name: "anno_set_comment",
531
+ description:
532
+ "Stores a comment at one address, replacing whatever that placement held. 'line' comments sit on their own line " +
533
+ "before the instruction; 'side' comments sit inline on the same line. The two placements coexist at one address. " +
534
+ "Carrier for the [confirmed-code]/[probable-code]/[confirmed-data]/[probable-data]/[unknown] confidence-prefix " +
535
+ "convention. Do NOT include a leading ';' -- the store holds the words and the exporter adds the prefix, so a " +
536
+ "stored ';' would be emitted twice and is refused. Over-long text is REFUSED rather than truncated, and the bound " +
537
+ "is measured in UTF-8 BYTES, so a multi-byte comment is bounded by what actually lands in the file. A " +
538
+ "byte-identical repeat SUCCEEDS and reports `changed: false`.",
539
+ inputSchema: {
540
+ type: "object",
541
+ properties: {
542
+ ...STORE_PROPERTY,
543
+ address: { description: "The address to comment. Integer, \"$hex\" or \"0x\" string; an unprefixed numeric string is refused." },
544
+ comment: { type: "string", description: "The comment text, without the ';' prefix." },
545
+ type: {
546
+ type: "string",
547
+ enum: ["line", "side"],
548
+ description: "'line' = own line before the instruction. 'side' = inline on the same line.",
549
+ },
550
+ ...BASE_REVISION_PROPERTY,
551
+ },
552
+ required: ["store", "address", "comment", "type"],
553
+ },
554
+ },
555
+ {
556
+ name: "anno_set_data_type",
557
+ description:
558
+ "Types an inclusive address range, preserving whatever the overlapping rows said about the addresses outside it. " +
559
+ "A SUCCESSFUL result can carry two disclosures, and both are always present in the body: `contradictedComments` " +
560
+ "names comments whose recorded confidence now contradicts the type just applied, and `reinterpretedSplitTables` " +
561
+ "names every split table this write FRAGMENTED, with the entry-address pairs it read before, the pairs each " +
562
+ "surviving remainder reads now, and the pairs preserved. Neither is an error and neither is dropped: a split " +
563
+ "table's entries re-pair as a function of the row's start AND its length, so a fragment decodes to different " +
564
+ "16-bit values than the ones a human recorded, and a success that hid that would be worse than a refusal. " +
565
+ "A split layout REFUSES an odd byte count (the low half and the high half must be the same length). Retyping the " +
566
+ "same range the same way SUCCEEDS and reports `changed: false`.",
567
+ inputSchema: {
568
+ type: "object",
569
+ properties: {
570
+ ...STORE_PROPERTY,
571
+ start_address: { description: "Start of the range, INCLUSIVE. Integer, \"$hex\" or \"0x\" string." },
572
+ end_address: { description: "End of the range, INCLUSIVE. A one-byte range has end_address === start_address." },
573
+ data_type: {
574
+ type: "string",
575
+ enum: [
576
+ "code",
577
+ "byte",
578
+ "word",
579
+ "address",
580
+ "petscii",
581
+ "screencode",
582
+ "lo_hi_address",
583
+ "hi_lo_address",
584
+ "lo_hi_word",
585
+ "hi_lo_word",
586
+ "external_file",
587
+ "undefined",
588
+ ],
589
+ description:
590
+ "code=6502/6510 instructions; byte=raw 8-bit data (sprites, charset, tables, unknowns); word=16-bit LE " +
591
+ "values; address=16-bit LE pointers (produces cross-references, use for jump tables and vectors); " +
592
+ "petscii=PETSCII text; screencode=screen-code text; lo_hi_address=split address table, low bytes first " +
593
+ "then high bytes (even count required); hi_lo_address=split address table, high bytes first (even count " +
594
+ "required); lo_hi_word=split word table, low bytes first (e.g. SID frequency tables); hi_lo_word=split " +
595
+ "word table, high bytes first; external_file=large binary blob to export as-is; undefined=reset the range " +
596
+ "to unknown.",
597
+ },
598
+ ...BASE_REVISION_PROPERTY,
599
+ },
600
+ required: ["store", "start_address", "end_address", "data_type"],
601
+ },
602
+ },
603
+ {
604
+ name: "anno_add_scope",
605
+ description:
606
+ "Adds a lexical scope over an inclusive range, so symbols inside it are local to it. Nested and overlapping " +
607
+ "scopes are UNSUPPORTED by the schema this store mirrors and are REFUSED, naming both the incoming span and the " +
608
+ "existing scope's id and span; the incoming scope is neither trimmed nor split. Two scopes that merely TOUCH at " +
609
+ "a boundary are disjoint and both accepted. An identical repeat SUCCEEDS and reports `changed: false`. " +
610
+ "MIND THE ENDS: one transposed end (say $1000..$ffff instead of $1000..$10ff) makes every later scope above that " +
611
+ "start refuse -- use anno_remove_scope to undo it rather than burning revisions off the 32-deep snapshot ring.",
612
+ inputSchema: {
613
+ type: "object",
614
+ properties: {
615
+ ...STORE_PROPERTY,
616
+ start_address: { description: "Start of the scope, INCLUSIVE. Integer, \"$hex\" or \"0x\" string." },
617
+ end_address: { description: "End of the scope, INCLUSIVE." },
618
+ ...BASE_REVISION_PROPERTY,
619
+ },
620
+ required: ["store", "start_address", "end_address"],
621
+ },
622
+ },
623
+ {
624
+ name: "anno_remove_scope",
625
+ description:
626
+ "Removes the scope whose span is EXACTLY start_address..end_address -- the inverse of anno_add_scope, and the " +
627
+ "recovery route for a transposed span, which would otherwise be undoable only by reverting through the 32-deep " +
628
+ "snapshot ring. The span must match both stored ends exactly: a scope is never trimmed, split or partially " +
629
+ "removed, because a partial removal would leave a shape nothing downstream can express while reporting success. " +
630
+ "Read the stored spans with anno_get_blocks (include: [\"scopes\"]) first if you are unsure. Removing a scope " +
631
+ "that is not there SUCCEEDS and reports `changed: false`.",
632
+ inputSchema: {
633
+ type: "object",
634
+ properties: {
635
+ ...STORE_PROPERTY,
636
+ start_address: { description: "Start of the scope to remove, INCLUSIVE. Must match the stored start exactly." },
637
+ end_address: { description: "End of the scope to remove, INCLUSIVE. Must match the stored end exactly." },
638
+ ...BASE_REVISION_PROPERTY,
639
+ },
640
+ required: ["store", "start_address", "end_address"],
641
+ },
642
+ },
643
+ {
644
+ name: "anno_exclude_range",
645
+ description:
646
+ "Records the user's request to leave an inclusive span out, WITH the reason, as a durable row (BUILD-05/BUILD-07). " +
647
+ "RECORDING AN EXCLUSION DOES NOT REMOVE ANYTHING: the export still emits every byte of that span; the record is " +
648
+ "what makes the request VISIBLE in the output instead of invisible as a gap. What gets reversed, kept or left out " +
649
+ "is the end-user's decision, and this verb is how the user states it -- it is not the tool deciding. An " +
650
+ "overlapping span is REFUSED naming both spans; two records that merely TOUCH at a boundary are disjoint and both " +
651
+ "accepted; an identical repeat SUCCEEDS reporting `changed: false`; the same extent with a DIFFERENT reason is " +
652
+ "REFUSED rather than overwriting the stored reason. MIND THE ENDS: one transposed end makes every later exclusion " +
653
+ "overlapping that start refuse -- use anno_include_range to undo it rather than burning revisions off the 32-deep " +
654
+ "snapshot ring.",
655
+ inputSchema: {
656
+ type: "object",
657
+ properties: {
658
+ ...STORE_PROPERTY,
659
+ start_address: { description: "Start of the excluded span, INCLUSIVE. Integer, \"$hex\" or \"0x\" string." },
660
+ end_address: { description: "End of the excluded span, INCLUSIVE." },
661
+ reason: {
662
+ type: "string",
663
+ description:
664
+ "Why the user asked for this span to be left out. REQUIRED and must be non-empty: a reason column " +
665
+ "satisfied by an empty string records that something was excluded and loses WHY.",
666
+ },
667
+ ...BASE_REVISION_PROPERTY,
668
+ },
669
+ required: ["store", "start_address", "end_address", "reason"],
670
+ },
671
+ },
672
+ {
673
+ name: "anno_include_range",
674
+ description:
675
+ "Removes the exclusion whose span is EXACTLY start_address..end_address -- the exact inverse of anno_exclude_range. " +
676
+ "Both stored ends must match exactly, because a record is never trimmed, split or partially removed. Read the " +
677
+ "stored spans with anno_exclude_range's sibling read (the excludedRanges list on either verb's own success body) " +
678
+ "first if you are unsure. Removing an exclusion that is not there SUCCEEDS and reports `changed: false`.",
679
+ inputSchema: {
680
+ type: "object",
681
+ properties: {
682
+ ...STORE_PROPERTY,
683
+ start_address: { description: "Start of the exclusion to remove, INCLUSIVE. Must match the stored start exactly." },
684
+ end_address: { description: "End of the exclusion to remove, INCLUSIVE. Must match the stored end exactly." },
685
+ ...BASE_REVISION_PROPERTY,
686
+ },
687
+ required: ["store", "start_address", "end_address"],
688
+ },
689
+ },
690
+ {
691
+ name: "anno_get_symbols",
692
+ description:
693
+ "Returns labels held in an annotation store, in ascending insertion order, optionally narrowed to " +
694
+ "an address range. Every call names its own store (there is no ambient 'current store') and the " +
695
+ "store is opened and closed within the call. `max_results` is REQUIRED and has no default on this " +
696
+ "surface: pass an explicit ceiling and compare the returned count against it to detect truncation.",
697
+ inputSchema: {
698
+ type: "object",
699
+ properties: {
700
+ ...STORE_PROPERTY,
701
+ max_results: {
702
+ type: "integer",
703
+ description:
704
+ "Maximum number of labels to return. REQUIRED -- no default on this surface, so a truncated " +
705
+ "answer is always the caller's own explicit ceiling rather than a silent one.",
706
+ },
707
+ start_address: {
708
+ description:
709
+ "Optional lower bound (inclusive) of the address range to filter by. An integer 0..65535, a " +
710
+ "\"$hex\" string, or a \"0x\" string; an unprefixed numeric string is refused.",
711
+ },
712
+ end_address: {
713
+ description:
714
+ "Optional upper bound (inclusive) of the address range to filter by. Same accepted forms as " +
715
+ "start_address.",
716
+ },
717
+ },
718
+ required: ["store", "max_results"],
719
+ },
720
+ },
721
+ {
722
+ name: "anno_get_comments",
723
+ description:
724
+ "Returns stored comments, each with its address, its placement ('line' or 'side') and its text, in ascending " +
725
+ "insertion order. Filters are combined with AND: specific `addresses`, an inclusive `start_address`/`end_address` " +
726
+ "window, and a placement `type`. The confidence-prefix convention lives in the returned text -- filter by prefix " +
727
+ "on your own side, or use anno_search. `max_results` is REQUIRED with no default; the true match count is " +
728
+ "returned beside the truncated list, so truncation is a fact you are told rather than one you infer.",
729
+ inputSchema: {
730
+ type: "object",
731
+ properties: {
732
+ ...STORE_PROPERTY,
733
+ max_results: { type: "integer", description: "Maximum number of comments to return. REQUIRED -- no default on this surface." },
734
+ addresses: {
735
+ type: "array",
736
+ description: "Optional list of specific addresses. Integers, \"$hex\" or \"0x\" strings; unprefixed numeric strings are refused.",
737
+ },
738
+ start_address: { description: "Optional lower bound (inclusive) of the address window." },
739
+ end_address: { description: "Optional upper bound (inclusive) of the address window." },
740
+ type: { type: "string", enum: ["line", "side"], description: "Optional placement filter." },
741
+ },
742
+ required: ["store", "max_results"],
743
+ },
744
+ },
745
+ {
746
+ name: "anno_get_blocks",
747
+ description:
748
+ "Returns the typed ranges (blocks) this store holds -- each with its inclusive span and its data type -- " +
749
+ "optionally narrowed by `block_type`. This is also the read route for the store's other structural annotations: " +
750
+ "pass `include` to add `scopes` (every lexical scope's id and span, which anno_remove_scope needs to match " +
751
+ "exactly), `enums` (every project enum with its variants mapping) and `enum_usage` (every address-to-enum " +
752
+ "association, with the enum's name resolved through its id at read time). `max_results` is REQUIRED with no " +
753
+ "default and bounds the RANGE list; the true match count is returned beside it.",
754
+ inputSchema: {
755
+ type: "object",
756
+ properties: {
757
+ ...STORE_PROPERTY,
758
+ max_results: { type: "integer", description: "Maximum number of ranges to return. REQUIRED -- no default on this surface." },
759
+ block_type: {
760
+ type: "string",
761
+ description: "Optional exact data-type filter, e.g. 'code' or 'lo_hi_address'. Must be one of the twelve data types.",
762
+ },
763
+ include: {
764
+ type: "array",
765
+ items: { type: "string", enum: ["scopes", "enums", "enum_usage"] },
766
+ description:
767
+ "Optional extra structural annotations to return alongside the ranges. Each is returned whole (these " +
768
+ "collections are small by construction), so they are not governed by max_results.",
769
+ },
770
+ },
771
+ required: ["store", "max_results"],
772
+ },
773
+ },
774
+ {
775
+ name: "anno_create_project_enum",
776
+ description:
777
+ "Creates a project-local enum -- a name, a variants mapping and an optional description -- embedded in the " +
778
+ "annotation store rather than anywhere machine-global. Variant keys are numeric strings in decimal, 0x/$ hex or " +
779
+ "0b/% binary; two keys naming the SAME number are refused, because that would mean two variant names for one " +
780
+ "value and nothing downstream could say which. A name already held with DIFFERENT contents is refused rather " +
781
+ "than overwritten -- use anno_update_project_enum, which replaces the variants mapping wholesale and says so. " +
782
+ "An identical re-create SUCCEEDS and reports `changed: false`. The body returns every enum the store now holds.",
783
+ inputSchema: {
784
+ type: "object",
785
+ properties: {
786
+ ...STORE_PROPERTY,
787
+ name: { type: "string", description: "Unique identifier: a letter or underscore, then letters/digits/underscores. Refused, never sanitized." },
788
+ variants: {
789
+ type: "object",
790
+ description: "Variant mapping. Keys are numeric strings (decimal, 0x/$ hex, 0b/% binary); values are variant names.",
791
+ },
792
+ description: { type: "string", description: "Optional summary explaining the enum's purpose." },
793
+ ...BASE_REVISION_PROPERTY,
794
+ },
795
+ required: ["store", "name", "variants"],
796
+ },
797
+ },
798
+ {
799
+ name: "anno_update_project_enum",
800
+ description:
801
+ "Renames a project enum, replaces its variants mapping, replaces its description, or any combination. THE " +
802
+ "VARIANTS MAPPING IS REPLACED WHOLESALE when supplied, never merged: a merge would make a variant impossible to " +
803
+ "REMOVE, since there would be no way to express its absence. A rename onto a name another enum already holds is " +
804
+ "refused rather than merging two enums into one. Renaming does NOT orphan an enum usage: usages are associated " +
805
+ "by enum id, not by name. Updating an enum that does not exist is refused. A no-op update SUCCEEDS and reports " +
806
+ "`changed: false`. The body returns every enum the store now holds.",
807
+ inputSchema: {
808
+ type: "object",
809
+ properties: {
810
+ ...STORE_PROPERTY,
811
+ name: { type: "string", description: "Existing name of the enum to update." },
812
+ new_name: { type: "string", description: "Optional new name. Same identifier rule; refused, never sanitized." },
813
+ variants: { type: "object", description: "Optional COMPLETE replacement variants mapping. Omit to leave the mapping alone." },
814
+ description: { type: "string", description: "Optional replacement description." },
815
+ ...BASE_REVISION_PROPERTY,
816
+ },
817
+ required: ["store", "name"],
818
+ },
819
+ },
820
+ {
821
+ name: "anno_apply_enum_usage",
822
+ description:
823
+ "Associates one address with one project enum, so an immediate operand or constant reference at that address " +
824
+ "formats as a variant name. OMITTING `name`, or passing an empty string, CLEARS the association at that address " +
825
+ "instead -- that is the schema's own contract for this verb, and clearing an address that carries none SUCCEEDS " +
826
+ "reporting `changed: false`. One address carries at most one enum, so applying a different enum REPLACES rather " +
827
+ "than refuses. Applying an enum that does not exist is refused rather than creating it implicitly, because a " +
828
+ "mistyped name would otherwise become a real, empty enum that formats nothing and looks deliberate. The body " +
829
+ "returns every address-to-enum association the store now holds.",
830
+ inputSchema: {
831
+ type: "object",
832
+ properties: {
833
+ ...STORE_PROPERTY,
834
+ address: { description: "The instruction address. Integer, \"$hex\" or \"0x\" string; an unprefixed numeric string is refused." },
835
+ name: { type: "string", description: "The enum to apply. OMIT, or pass an empty string, to CLEAR the association at this address." },
836
+ ...BASE_REVISION_PROPERTY,
837
+ },
838
+ required: ["store", "address"],
839
+ },
840
+ },
841
+ {
842
+ name: "anno_save_project",
843
+ description:
844
+ "Reports the store's current revision. IT PERFORMS NO WRITE, and it exists to say so: every mutating verb on " +
845
+ "this surface has ALREADY committed and fsynced its own write by the time it returns, so there is no unsaved " +
846
+ "state for an explicit save to flush and no window in which a crash could lose an edit this verb would have " +
847
+ "rescued. Durability belongs to the store, not to a verb an agent has to remember to call. Use this to read the " +
848
+ "revision -- for a subsequent `base_revision` compare-and-swap, or to confirm that a pass advanced the store as " +
849
+ "far as expected. The body states the no-write property alongside the revision, so a caller is never left " +
850
+ "inferring it from an empty success.",
851
+ inputSchema: {
852
+ type: "object",
853
+ properties: { ...STORE_PROPERTY },
854
+ required: ["store"],
855
+ },
856
+ },
857
+ {
858
+ name: "anno_disassemble",
859
+ description:
860
+ "Renders ACME-ready `!cpu 6510` source for the instructions starting AT AN EXPLICIT ADDRESS you supply. " +
861
+ "There is no cursor and no 'current address' on this surface -- upstream's own procedure text says never to rely " +
862
+ "on one and this project has no editor to have one, so the address is always yours and always in the call. " +
863
+ "Decoded fresh from the image bytes on every call; nothing is cached and nothing is written. An opcode ACME " +
864
+ "cannot express is emitted as `!byte` with the mnemonic moved into a comment, never as a mnemonic that would " +
865
+ "fail to reassemble. The extent is bounded by the SAME byte cap that governs anno_read_region -- one cap, both " +
866
+ "views, so there is no per-view rule to get subtly wrong -- and defaults to that cap when end_address is " +
867
+ "omitted. A wider range is REFUSED by name with the cap and the requested width in the message, never " +
868
+ "silently truncated. A register write bound to a project enum (via anno_apply_enum_usage) renders through " +
869
+ "its named member instead of a hex literal -- a single-field register as `#<enum>_<VARIANT>`, or, for a " +
870
+ "multi-field register, as its bits OR-ed together by name (`#D018_SELECT..0 | D018_CHARACTER..2 | " +
871
+ "D018_VIDEO..0`) with a trailing comment naming every field and its decoded value, so a bound write reads " +
872
+ "the same way here as it does in the exported ACME source.",
873
+ inputSchema: {
874
+ type: "object",
875
+ properties: {
876
+ ...STORE_PROPERTY,
877
+ ...IMAGE_PROPERTY,
878
+ address: {
879
+ description:
880
+ "The address to start decoding at, EXPLICITLY. Integer 0..65535, \"$hex\" or \"0x\" string; an unprefixed " +
881
+ "numeric string is refused.",
882
+ },
883
+ end_address: {
884
+ description:
885
+ "Optional last address to decode, INCLUSIVE. Omitted, the extent is the byte cap (or the end of the image, " +
886
+ "whichever comes first).",
887
+ },
888
+ },
889
+ required: ["store", "image", "address"],
890
+ },
891
+ },
892
+ {
893
+ name: "anno_read_region",
894
+ description:
895
+ "Reads ONE routine or table at an explicit inclusive address range, instead of exporting the whole program. " +
896
+ "`view: 'disasm'` is what routine documentation wants; `view: 'hexdump'` is what data-table classification and " +
897
+ "table extraction want; omitted, the view is 'disasm'. The combined byte count (end_address - start_address + 1) " +
898
+ "is capped, and the SAME cap governs anno_disassemble -- one cap, both views. A request above the cap is REFUSED " +
899
+ "by name, naming the cap and the requested width, rather than silently truncated: a full-64K disassembly view " +
900
+ "dumped into an agent's context is exactly the hazard the cap exists to prevent, and this family is not chunked, " +
901
+ "so the cap is the only bound there is.",
902
+ inputSchema: {
903
+ type: "object",
904
+ properties: {
905
+ ...STORE_PROPERTY,
906
+ ...IMAGE_PROPERTY,
907
+ start_address: { description: "Start of the range, INCLUSIVE. Integer, \"$hex\" or \"0x\" string." },
908
+ end_address: { description: "End of the range, INCLUSIVE." },
909
+ view: {
910
+ type: "string",
911
+ enum: ["disasm", "hexdump"],
912
+ description: "'disasm' = rendered 6510 source. 'hexdump' = raw hex bytes. Omitted defaults to 'disasm'.",
913
+ },
914
+ },
915
+ required: ["store", "image", "start_address", "end_address"],
916
+ },
917
+ },
918
+ {
919
+ name: "anno_get_binary_info",
920
+ description:
921
+ "Reports what the named image FILE is: how it was dispatched (a .prg's 2-byte little-endian load address, or a " +
922
+ "flat 64K capture's origin of 0), the origin, the total byte length, the payload byte length, and the Shannon " +
923
+ "entropy of the payload -- a value above 7.5 suggests the image is compressed or packed and that a depack pass " +
924
+ "is needed before any of it will decode sensibly. DISPATCH IS BY EXTENSION FIRST, never by byte length: a " +
925
+ "truncated .raw capture that fell through to the .prg parser once produced an origin read backwards out of its " +
926
+ "own payload bytes, exited zero, and made every downstream address silently wrong. A file too short to be a .prg " +
927
+ "is REFUSED by name.",
928
+ inputSchema: {
929
+ type: "object",
930
+ properties: { ...STORE_PROPERTY, ...IMAGE_PROPERTY },
931
+ required: ["store", "image"],
932
+ },
933
+ },
934
+ {
935
+ name: "anno_get_cross_references",
936
+ description:
937
+ "Every address that references the address you name, unioned from three sources and returned ascending and " +
938
+ "de-duplicated: the instructions decoded fresh out of every range typed `code`, the typed split ADDRESS tables " +
939
+ "(the `_address` forms produce cross-references and the `_word` forms do not -- that is the schema's own " +
940
+ "distinction, not a judgement made here), and the stored rows, which are the only half on disk and only because " +
941
+ "a computed dispatch or a hand-asserted edge cannot be recovered from bytes at all. DERIVED ON EVERY CALL AND " +
942
+ "NEVER CACHED: a cached derivation is a second on-disk truth that can disagree with the range table it came " +
943
+ "from. `max_results` is REQUIRED with no default; the true total rides beside the truncated list.",
944
+ inputSchema: {
945
+ type: "object",
946
+ properties: {
947
+ ...STORE_PROPERTY,
948
+ ...IMAGE_PROPERTY,
949
+ address: { description: "The target address to find references TO. Integer, \"$hex\" or \"0x\" string." },
950
+ max_results: { type: "integer", description: "Maximum number of referencing addresses to return. REQUIRED -- no default." },
951
+ },
952
+ required: ["store", "image", "address", "max_results"],
953
+ },
954
+ },
955
+ {
956
+ name: "anno_search",
957
+ description:
958
+ "Searches three corpora for a substring: label names, comment text, and the instruction text rendered from every " +
959
+ "range typed `code`. MATCHING IS BYTE-EXACT AND CASE-SENSITIVE, applied identically to all three, and the rule " +
960
+ "is restated in the body so an empty answer tells you which rule produced it. Every corpus is named in the body " +
961
+ "with the number of entries it held, so a genuine zero over a real corpus is distinguishable from a corpus this " +
962
+ "surface does not have. NAMING A CORPUS THIS SURFACE DOES NOT HAVE (any search_<name> other than the three) is " +
963
+ "answered with `{available:false, reason}` in a SUCCESSFUL body -- not an error, because the request was " +
964
+ "well-formed, and not an empty result set, because an empty result set for an unanswerable question is a lie " +
965
+ "that reads like an answer. `max_results` is REQUIRED with no default: an implicit default would silently " +
966
+ "truncate a full-program pass.",
967
+ inputSchema: {
968
+ type: "object",
969
+ properties: {
970
+ ...STORE_PROPERTY,
971
+ ...IMAGE_PROPERTY,
972
+ 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." },
973
+ max_results: { type: "integer", description: "Maximum number of hits to return. REQUIRED -- no default on this surface." },
974
+ search_labels: { type: "boolean", description: "Search the label-name corpus. Defaults to true." },
975
+ search_comments: { type: "boolean", description: "Search the comment-text corpus. Defaults to true." },
976
+ search_instructions: { type: "boolean", description: "Search the rendered instruction corpus. Defaults to true. This is the expensive one: it decodes every code range." },
977
+ },
978
+ required: ["store", "image", "query", "max_results"],
979
+ },
980
+ },
981
+ {
982
+ name: "anno_get_address_details",
983
+ description:
984
+ "Everything this project knows about ONE address, composed from four reads: the labels bound there, the comments " +
985
+ "there, the typed range that covers it (resolved narrowest-range-wins through the paint index, never by a " +
986
+ "start/end bracket scan), and the cross-references that reach it. THE COMPOSITION IS DISCLOSED: the body carries " +
987
+ "`composed_client_side` and a `composed_from` list naming all four sources, so a composition is never mistaken " +
988
+ "for something the store held whole. A component with no answer comes back as `{available:false, reason}` rather " +
989
+ "than as an empty list, so an address that genuinely has no comments stays distinguishable from a question this " +
990
+ "composition could not put. Nothing is written on any path.",
991
+ inputSchema: {
992
+ type: "object",
993
+ properties: {
994
+ ...STORE_PROPERTY,
995
+ ...IMAGE_PROPERTY,
996
+ address: { description: "The address to inspect. Integer, \"$hex\" or \"0x\" string." },
997
+ },
998
+ required: ["store", "image", "address"],
999
+ },
1000
+ },
1001
+ {
1002
+ name: "anno_batch_execute",
1003
+ description:
1004
+ "Executes several curated anno_* calls against ONE store, in order, inside one open/close pair. Use it for a " +
1005
+ "multi-edit pass -- marking many regions, renaming many labels -- and not for calls that depend on each other's " +
1006
+ "results. The store (and the image, when the inner calls need one) is named ONCE at the top level and every " +
1007
+ "inner call inherits it, INCLUDING through nesting -- a batch inside a batch inherits it too, and so does that " +
1008
+ "batch's own inner calls; an inner `store` is overridden at every depth, never honoured. TWO PHASES, and the difference matters " +
1009
+ "when you read the answer. FIRST, the whole payload is pre-validated before anything is opened: a malformed " +
1010
+ "payload, an EMPTY calls array, a malformed entry, an inner name outside the curated set at any depth, an " +
1011
+ "illegal label name, or an over-cap region range refuses the WHOLE batch by index, and nothing executes. " +
1012
+ "SECOND, execution runs to COMPLETION, pushing a success or error status for every entry and never aborting on " +
1013
+ "the first failure. So `isError:true` means this batch should never have been sent; an error ENTRY inside a " +
1014
+ "successful result means that one call did not work. Nesting deeper than " +
1015
+ String(ANNO_MAX_BATCH_DEPTH) +
1016
+ " levels is refused by name rather than walked.",
1017
+ inputSchema: {
1018
+ type: "object",
1019
+ properties: {
1020
+ ...STORE_PROPERTY,
1021
+ image: {
1022
+ type: "string",
1023
+ description:
1024
+ "Optional program image, inherited by every inner call that derives an answer from bytes. Required only " +
1025
+ "if the batch contains such a call.",
1026
+ },
1027
+ calls: {
1028
+ type: "array",
1029
+ items: {
1030
+ type: "object",
1031
+ properties: {
1032
+ name: { type: "string", description: "The curated anno_* verb to run. An uncurated name refuses the WHOLE batch." },
1033
+ arguments: { type: "object", description: "That verb's own arguments, minus store (and image), which the batch supplies." },
1034
+ },
1035
+ required: ["name", "arguments"],
1036
+ },
1037
+ 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.",
1038
+ },
1039
+ },
1040
+ required: ["store", "calls"],
1041
+ },
1042
+ },
1043
+ {
1044
+ name: "anno_import_ghidra_export",
1045
+ description:
1046
+ "Imports a host-written Ghidra export transfer file (GhidraStructExport.java's `## `-delimited format) into " +
1047
+ "the store, writing one anno_xref row per surviving `## REFERENCES` line and DELETING the transfer file once " +
1048
+ "every write has durably committed. Costs one store open and one close. REFUSES, writes nothing and deletes " +
1049
+ "nothing: on a malformed, truncated or digest-mismatched export (naming the section and the offending line), " +
1050
+ "on an export_path that resolves outside the workspace root, or on an absent transfer file. Reports " +
1051
+ "referencesSeen, xrefsWritten, xrefsAlreadyPresent (duplicate references are deduped, never double-counted), " +
1052
+ "and kindsSeenNotImported -- reference types this store's four-member vocabulary does not carry, dropped and " +
1053
+ "counted rather than refused, because a real corpus binary carries ordinary jump and call references " +
1054
+ "constantly. Every written row's bank column is null: this verb does not resolve bank state itself. Also " +
1055
+ "reports constWrites -- the export's `## CONST_WRITES` facts (recovered $01/$D011/$D018/$DD00 stores), always " +
1056
+ "present (possibly empty). The transfer file naming them is DELETED by this same call (IMP-02), so this " +
1057
+ "return value is the only place they survive: pass the SAME constWrites array, unchanged, to a following " +
1058
+ "anno_join_memmap call's own const_writes argument to activate bank-state resolution (AUTO-04/AUTO-05) and " +
1059
+ "VIC-register graphics-range derivation (AUTO-06/AUTO-07) for this image.",
1060
+ inputSchema: {
1061
+ type: "object",
1062
+ properties: {
1063
+ ...STORE_PROPERTY,
1064
+ export_path: {
1065
+ type: "string",
1066
+ description:
1067
+ "Absolute or workspace-relative path to the host-written transfer file. CONSUMED AND DELETED by a " +
1068
+ "successful call -- refused if it resolves outside the workspace root, including via a symlink.",
1069
+ },
1070
+ sha256: {
1071
+ type: "string",
1072
+ description:
1073
+ "Optional sha256 digest the producer reported for the transfer file's bytes. When supplied, a mismatch " +
1074
+ "against the file's own computed digest refuses the whole call before anything is read further -- a " +
1075
+ "corruption/drift detector, never a security boundary.",
1076
+ },
1077
+ ...BASE_REVISION_PROPERTY,
1078
+ },
1079
+ required: ["store", "export_path"],
1080
+ },
1081
+ },
1082
+ {
1083
+ name: "anno_join_memmap",
1084
+ description:
1085
+ "The mechanical join: reads every distinct cross-reference target the store already holds, skips addresses " +
1086
+ "inside the supplied image's own loaded range (those are program addresses, never looked up), and annotates " +
1087
+ "every remaining address with the narrowest c64-memory-mapping/memmap.json entry containing it. No agent " +
1088
+ "call, no queue walk and no skill invocation anywhere in this call. Reports addressesConsidered, annotated, " +
1089
+ "skippedInImage, skippedNoMapEntry, declined and commentsChanged, plus a per-address decisions array naming " +
1090
+ "the outcome and, for every skip, WHY. Running this twice over an unchanged store reports commentsChanged: 0 " +
1091
+ "on the second run -- re-running a join pass is not an error. Passing const_writes (typically the SAME " +
1092
+ "constWrites array anno_import_ghidra_export just returned for this image, unchanged) additionally activates " +
1093
+ "bank-state resolution: a $01-conditional address (AUTO-04) declines with a named reason rather than " +
1094
+ "guessing when the reaching processor-port value is absent or disagreeing (AUTO-05), and VIC-register " +
1095
+ "graphics ranges are derived and written back (AUTO-06/AUTO-07, graphics_map_index selects which of several " +
1096
+ "derived combinations when more than one exists, default 0). Omitting const_writes entirely is a complete " +
1097
+ "no-op for both of these -- every address resolves exactly as if this argument did not exist.",
1098
+ inputSchema: {
1099
+ type: "object",
1100
+ properties: {
1101
+ ...STORE_PROPERTY,
1102
+ ...IMAGE_PROPERTY,
1103
+ ...BASE_REVISION_PROPERTY,
1104
+ const_writes: {
1105
+ type: "array",
1106
+ items: {
1107
+ type: "object",
1108
+ properties: {
1109
+ store_address: { type: "integer", description: "The instruction's own address that performed the store." },
1110
+ target_address: { type: "integer", description: "The watched hardware address ($0001/$D011/$D018/$DD00) written to." },
1111
+ value: { type: "integer", description: "The compile-time constant value written." },
1112
+ },
1113
+ required: ["store_address", "target_address", "value"],
1114
+ },
1115
+ description:
1116
+ "Optional recovered const-write facts (AUTO-04/AUTO-05/AUTO-06/AUTO-07) -- pass back the constWrites " +
1117
+ "array anno_import_ghidra_export returned for the SAME image, unchanged. Supplying it (even []) " +
1118
+ "activates bank-state resolution and graphics-range derivation/write-back; omitting it entirely is a " +
1119
+ "complete no-op for both.",
1120
+ },
1121
+ graphics_map_index: {
1122
+ type: "integer",
1123
+ description:
1124
+ "Which of several derived VIC-register-value combinations to write back as graphics ranges, when " +
1125
+ "const_writes yields more than one distinct combination (D-37-27: several valid maps are never merged " +
1126
+ "into one). Defaults to 0. Consulted ONLY when const_writes is supplied at all. Out of range for the " +
1127
+ "derived map count REFUSES the whole call rather than silently clamping or picking a default.",
1128
+ },
1129
+ },
1130
+ required: ["store", "image"],
1131
+ },
1132
+ },
1133
+ {
1134
+ name: "anno_evid_ingest",
1135
+ description:
1136
+ "Turns one raw memmapshow reply plus one run identity into durable runtime-execution evidence rows, so a later " +
1137
+ "session can query what the emulator actually executed instead of re-running the program. Writes a row ONLY for " +
1138
+ "an OBSERVED execute bit: an address memmapshow mentioned with read or write access but no execute gets NO row, " +
1139
+ "and an address the reply never mentioned at all gets NO row either -- an address with no row is the ABSENCE of " +
1140
+ "an assertion, never an assertion that the address is data. Requires the EXACT launch argv and digests it itself " +
1141
+ "(argv_digest is never accepted as an argument), so a caller cannot invent a run identity. A memmapshow reply " +
1142
+ "this surface cannot parse is REFUSED, naming its refusal code and offending line, rather than partially " +
1143
+ "absorbed -- nothing is written on a refusal. Re-ingesting the SAME reply for the SAME run identity succeeds " +
1144
+ "and reports changed:false with observationsWritten:0 -- re-running an ingest pass is not an error. Every count " +
1145
+ "in the answer carries a denominator (addressesQueried) beside it; no percentage is ever reported.",
1146
+ inputSchema: {
1147
+ type: "object",
1148
+ properties: {
1149
+ ...STORE_PROPERTY,
1150
+ memmap_text: {
1151
+ type: "string",
1152
+ description:
1153
+ "The raw memmapshow reply exactly as the text monitor returned it -- never a pre-parsed object. A reply " +
1154
+ "this parser cannot decode is REFUSED, naming its refusal code and offending line; nothing is written.",
1155
+ },
1156
+ image_sha256: {
1157
+ type: "string",
1158
+ description:
1159
+ "The program image this run executed, named by the sha256 digest of its own bytes -- exactly 64 " +
1160
+ "lowercase hex characters. This verb does not read image bytes itself and accepts no path to one.",
1161
+ },
1162
+ argv: {
1163
+ type: "array",
1164
+ items: { type: "string" },
1165
+ description:
1166
+ "The EXACT emulator launch argument vector, including argv[0] -- a different binary is a different " +
1167
+ "launch. This verb digests it itself; a pre-computed digest is never accepted, so a caller cannot invent " +
1168
+ "a run identity.",
1169
+ },
1170
+ seed: {
1171
+ type: "string",
1172
+ description: "The determinism seed the launch pinned. A non-empty string; not a digest and carries no shape beyond that.",
1173
+ },
1174
+ ...BASE_REVISION_PROPERTY,
1175
+ },
1176
+ required: ["store", "memmap_text", "image_sha256", "argv", "seed"],
1177
+ },
1178
+ },
1179
+ {
1180
+ name: "anno_evid_disagreements",
1181
+ description:
1182
+ "Answers where the byte-derived block classification and the observed-execution evidence DISAGREE, with the " +
1183
+ "disagreements reported FIRST: an address the block table calls 'data' at which the emulator was observed " +
1184
+ "executing is proof a byte-derived guess was wrong, from a source (real execution) that never saw the guess. " +
1185
+ "Agreement (block table says 'code', evidence confirms it) is reported as agreementCount ONLY -- never as rows, " +
1186
+ "because a wall of agreeing rows would bury the one output this query exists to surface. An address the block " +
1187
+ "table covers with NO observation anywhere is blockCoveredNeverObservedCount, and is NOT evidence that the " +
1188
+ "address is data -- an address never observed executing proves nothing. Two further counts " +
1189
+ "(observedOutsideAnyBlockCount, observedAtUndefinedBlockCount) name evidence about addresses the block table " +
1190
+ "does not classify as code or data at all, so the denominator can never quietly drop real evidence. This verb " +
1191
+ "READS the block table and the runtime evidence table; it writes to NEITHER, and a repeated call never changes " +
1192
+ "either. Optional image_sha256/argv_digest/seed scope the question to ONE run identity's observations rather " +
1193
+ "than the union across every run that has ever contributed -- supply all three together or none; a partial " +
1194
+ "identity is refused. max_results bounds the returned disagreements array only, and is OPTIONAL (an empty or " +
1195
+ "small disagreement report is the ordinary, sound case, so no ceiling is forced); the true disagreement count " +
1196
+ "and whether truncation occurred are always reported beside it. Every count in the answer carries denominator " +
1197
+ "beside it; no percentage or rate is ever formed.",
1198
+ inputSchema: {
1199
+ type: "object",
1200
+ properties: {
1201
+ ...STORE_PROPERTY,
1202
+ max_results: {
1203
+ type: "integer",
1204
+ description:
1205
+ "Optional bound on the returned disagreements array only. Unlike every other list-returning anno_* verb, " +
1206
+ "this is NOT required -- an empty or small disagreement report is the ordinary, sound case. When " +
1207
+ "supplied, must be a positive integer.",
1208
+ },
1209
+ image_sha256: {
1210
+ type: "string",
1211
+ description:
1212
+ "Optional run-identity filter: the program image this run executed, exactly 64 lowercase hex characters. " +
1213
+ "Supply image_sha256, argv_digest AND seed together to scope to one run, or omit all three to see the " +
1214
+ "union across every run this store holds.",
1215
+ },
1216
+ argv_digest: {
1217
+ type: "string",
1218
+ description:
1219
+ "Optional run-identity filter: the exact digest anno_evid_ingest/anno_evid_runs already computed for a " +
1220
+ "run's launch argv, exactly 64 lowercase hex characters. Never invented by a caller -- pass back what " +
1221
+ "anno_evid_runs reported. Required alongside image_sha256/seed when filtering by run identity.",
1222
+ },
1223
+ seed: {
1224
+ type: "string",
1225
+ description:
1226
+ "Optional run-identity filter: the determinism seed that run's launch pinned. A non-empty string. " +
1227
+ "Required alongside image_sha256/argv_digest when filtering by run identity.",
1228
+ },
1229
+ },
1230
+ required: ["store"],
1231
+ },
1232
+ },
1233
+ {
1234
+ name: "anno_evid_runs",
1235
+ description:
1236
+ "Answers every run identity the store holds an observed-execution row for, with its accumulated observation " +
1237
+ "count and the denominator that count is a fraction of -- so a later session can see what evidence already " +
1238
+ "exists without re-running the program. However many runs contribute observations, their union is NEVER " +
1239
+ "exhaustive coverage of the image: observationCount is a count against denominator, never a rate, and this " +
1240
+ "verb forms no percentage from it.",
1241
+ inputSchema: {
1242
+ type: "object",
1243
+ properties: {
1244
+ ...STORE_PROPERTY,
1245
+ },
1246
+ required: ["store"],
1247
+ },
1248
+ },
1249
+ {
1250
+ name: "anno_evid_reset",
1251
+ description:
1252
+ "Clears every observed-execution row for ONE run identity, so that bracket can be re-measured from nothing. " +
1253
+ "Touches no other run identity's rows and no row of the byte-derived block table. Requires the EXACT launch " +
1254
+ "argv and digests it itself (a pre-computed digest is never accepted), so a caller cannot invent a run identity " +
1255
+ "-- the same discipline anno_evid_ingest uses. A run identity holding no observations SUCCEEDS and reports " +
1256
+ "changed:false and observationsRemoved:0 -- resetting an empty bracket is the ordinary thing, not a mistake. " +
1257
+ "Clearing the emulator's own accumulated access map is a DIFFERENT operation, reached through vice_memmap_zap " +
1258
+ "-- a caller re-measuring a bracket from nothing does BOTH: vice_memmap_zap on the emulator side, " +
1259
+ "anno_evid_reset on the store side.",
1260
+ inputSchema: {
1261
+ type: "object",
1262
+ properties: {
1263
+ ...STORE_PROPERTY,
1264
+ image_sha256: {
1265
+ type: "string",
1266
+ description:
1267
+ "The program image this run executed, named by the sha256 digest of its own bytes -- exactly 64 " +
1268
+ "lowercase hex characters. This verb does not read image bytes itself and accepts no path to one.",
1269
+ },
1270
+ argv: {
1271
+ type: "array",
1272
+ items: { type: "string" },
1273
+ description:
1274
+ "The EXACT emulator launch argument vector, including argv[0] -- a different binary is a different " +
1275
+ "launch. This verb digests it itself; a pre-computed digest is never accepted, so a caller cannot invent " +
1276
+ "a run identity.",
1277
+ },
1278
+ seed: {
1279
+ type: "string",
1280
+ description: "The determinism seed the launch pinned. A non-empty string; not a digest and carries no shape beyond that.",
1281
+ },
1282
+ ...BASE_REVISION_PROPERTY,
1283
+ },
1284
+ required: ["store", "image_sha256", "argv", "seed"],
1285
+ },
1286
+ },
1287
+ {
1288
+ name: "anno_hazard_report",
1289
+ description:
1290
+ "Enumerates what blocks a program's code or data from being MOVED, relocated, rebased or stripped, across " +
1291
+ "the movement-hazard constructions this surface can detect from decoded bytes alone. It REPORTS " +
1292
+ "and changes NOTHING: it never removes, strips, relocates or rebases any part of the image, and it never " +
1293
+ "emits an instruction, flag or field a caller could act on as an automatic relocation -- the operator " +
1294
+ "decides what happens to the bytes it describes. Each finding carries its own detection mechanism and a " +
1295
+ "detection-strength token (observed-corroborated, static-shape-matched, static-signature-only) -- a " +
1296
+ "SEPARATE, smaller vocabulary from this store's own five-grade confidence grades, answering a different " +
1297
+ "question (how strong is this ONE static signal, never what does this address classify as). Every checked " +
1298
+ "region reports exactly one of three outcomes -- hazard-reported, no-signal, unclassified -- and NONE of " +
1299
+ "them is a safety claim: a region with no finding is explicitly NOT a claim that the region is safe to " +
1300
+ "move, clean, or hazard-free, only that nothing this report knows how to look for fired there. " +
1301
+ "Always-emitted named limits (for example, a self-modification through a runtime-computed pointer is " +
1302
+ "undetected by construction) accompany every answer. Opens the store READ-ONLY and reads no other table: " +
1303
+ "this verb creates nothing and writes nothing.",
1304
+ inputSchema: {
1305
+ type: "object",
1306
+ properties: {
1307
+ ...STORE_PROPERTY,
1308
+ ...IMAGE_PROPERTY,
1309
+ max_results: {
1310
+ type: "integer",
1311
+ description:
1312
+ "Optional bound on the returned findings array only. Unlike most list-returning anno_* verbs, this is " +
1313
+ "NOT required -- an empty or small finding set is the ordinary, sound case. When supplied, must be a " +
1314
+ "positive integer.",
1315
+ },
1316
+ },
1317
+ required: ["store", "image"],
1318
+ },
1319
+ },
1320
+ ];
1321
+
1322
+ /** The allow-list, DERIVED from the definitions above rather than hand-typed
1323
+ * (T-29-02): a name cannot be curated in one place and absent from the other,
1324
+ * because there is only one place. */
1325
+ export const CURATED_ANNO_TOOLS: readonly string[] = ANNO_TOOL_DEFINITIONS.map((def) => def.name);
1326
+
1327
+ // ---------------------------------------------------------------------------
1328
+ // Per-verb argument validators. Each is called from BOTH the outer gate
1329
+ // (`assertAnnoTool`) and, when a call arrives inside `anno_batch_execute`, that
1330
+ // verb's own inner loop -- through the ONE dispatch below, so there is no way
1331
+ // to add a verb to one route and forget the other.
1332
+ // ---------------------------------------------------------------------------
1333
+
1334
+ /** Validates `anno_get_symbols`'s own arguments. The optional range bounds go
1335
+ * through `parseStoreAddress()` -- the ONE address parser -- so `$d020`,
1336
+ * `0xd020` and `53280` are accepted or refused here exactly as the store
1337
+ * itself would accept or refuse them, never by a second, divergent rule. */
1338
+ function assertGetSymbolsArgs(args: unknown, batchIndex?: number): void {
1339
+ assertStoreArg("anno_get_symbols", args, batchIndex);
1340
+ assertMaxResults("anno_get_symbols", args, batchIndex);
1341
+ const bag = argBag(args);
1342
+ if (bag.start_address !== undefined) parseStoreAddress(bag.start_address, { what: "start_address" });
1343
+ if (bag.end_address !== undefined) parseStoreAddress(bag.end_address, { what: "end_address" });
1344
+ }
1345
+
1346
+ function assertSetLabelArgs(args: unknown, batchIndex?: number): void {
1347
+ assertStoreArg("anno_set_label_name", args, batchIndex);
1348
+ assertAddressArg("anno_set_label_name", args, "address", batchIndex);
1349
+ assertLegalLabelArg("anno_set_label_name", args, batchIndex);
1350
+ const bag = argBag(args);
1351
+ if (bag.kind !== undefined) assertLabelKind(bag.kind);
1352
+ assertBaseRevisionArg("anno_set_label_name", args, batchIndex);
1353
+ }
1354
+
1355
+ function assertSetCommentArgs(args: unknown, batchIndex?: number): void {
1356
+ assertStoreArg("anno_set_comment", args, batchIndex);
1357
+ assertAddressArg("anno_set_comment", args, "address", batchIndex);
1358
+ const bag = argBag(args);
1359
+ if (bag.comment === undefined) refuseArg("anno_set_comment", "comment", '"comment" is required and was not supplied.', batchIndex);
1360
+ // The byte bound, the ';'-prefix rule and the refuse-never-truncate policy
1361
+ // are ALL `assertCommentText()`'s. This layer adds no second length check,
1362
+ // no truncation and no Unicode normalization -- the bound is measured in
1363
+ // UTF-8 BYTES there, and a second rule here would disagree with it silently.
1364
+ assertCommentText(bag.comment);
1365
+ assertCommentType(bag.type);
1366
+ assertBaseRevisionArg("anno_set_comment", args, batchIndex);
1367
+ }
1368
+
1369
+ function assertSetDataTypeArgs(args: unknown, batchIndex?: number): void {
1370
+ assertStoreArg("anno_set_data_type", args, batchIndex);
1371
+ // ORDERING IS LOAD-BEARING, and it is the store's own: the data type is
1372
+ // narrowed FIRST because `assertRangeShape` needs it to decide whether the
1373
+ // even-byte-count rule applies at all.
1374
+ const dataType = assertDataType(argBag(args).data_type);
1375
+ assertSpanArgs("anno_set_data_type", args, dataType, batchIndex);
1376
+ assertBaseRevisionArg("anno_set_data_type", args, batchIndex);
1377
+ }
1378
+
1379
+ function assertScopeArgs(name: string, args: unknown, batchIndex?: number): void {
1380
+ assertStoreArg(name, args, batchIndex);
1381
+ // "byte" selects the two shape rules that DO apply to a scope (both ends
1382
+ // inside the address space; the end not below the start) and none of the
1383
+ // ones that do not -- a scope is not a table, so a three-byte routine is a
1384
+ // perfectly good scope. This mirrors `addScope`'s own choice exactly.
1385
+ assertSpanArgs(name, args, "byte", batchIndex);
1386
+ assertBaseRevisionArg(name, args, batchIndex);
1387
+ }
1388
+
1389
+ /** Shared validator for `anno_exclude_range` / `anno_include_range`, called
1390
+ * from `assertVerbArgs()` by two arms so the direct route and
1391
+ * `anno_batch_execute`'s inner loop cannot diverge (mirrors `assertScopeArgs`
1392
+ * exactly). "byte" selects the same two span shape rules a scope uses --
1393
+ * an exclusion is not a table. `reason` is required ONLY for the setter: the
1394
+ * unsetter names an existing record by its span alone. This layer refuses an
1395
+ * absent, non-string or empty/whitespace-only reason at the surface; the
1396
+ * store's own `assertCommentText()` re-checks the full comment-text
1397
+ * vocabulary at write time (T-46-01) -- this is not a second, divergent rule,
1398
+ * only an earlier gate on the same three malformed shapes. */
1399
+ function assertExcludedRangeArgs(name: string, args: unknown, batchIndex?: number): void {
1400
+ assertStoreArg(name, args, batchIndex);
1401
+ assertSpanArgs(name, args, "byte", batchIndex);
1402
+ if (name === "anno_exclude_range") {
1403
+ const reason = argBag(args).reason;
1404
+ if (typeof reason !== "string" || reason.trim() === "") {
1405
+ refuseArg(
1406
+ name,
1407
+ "reason",
1408
+ `"reason" must be a non-empty string stating why the user asked for this span to be left out, got ${JSON.stringify(reason)}.`,
1409
+ batchIndex,
1410
+ );
1411
+ }
1412
+ }
1413
+ assertBaseRevisionArg(name, args, batchIndex);
1414
+ }
1415
+
1416
+ function assertGetCommentsArgs(args: unknown, batchIndex?: number): void {
1417
+ assertStoreArg("anno_get_comments", args, batchIndex);
1418
+ assertMaxResults("anno_get_comments", args, batchIndex);
1419
+ const bag = argBag(args);
1420
+ if (bag.addresses !== undefined) {
1421
+ if (!Array.isArray(bag.addresses)) {
1422
+ refuseArg("anno_get_comments", "addresses", '"addresses" must be an array of addresses when supplied.', batchIndex);
1423
+ }
1424
+ for (const entry of bag.addresses as unknown[]) parseStoreAddress(entry, { what: "addresses[]" });
1425
+ }
1426
+ if (bag.start_address !== undefined) parseStoreAddress(bag.start_address, { what: "start_address" });
1427
+ if (bag.end_address !== undefined) parseStoreAddress(bag.end_address, { what: "end_address" });
1428
+ if (bag.type !== undefined) assertCommentType(bag.type);
1429
+ }
1430
+
1431
+ const BLOCK_INCLUDES: readonly string[] = Object.freeze(["scopes", "enums", "enum_usage"]);
1432
+
1433
+ function assertGetBlocksArgs(args: unknown, batchIndex?: number): void {
1434
+ assertStoreArg("anno_get_blocks", args, batchIndex);
1435
+ assertMaxResults("anno_get_blocks", args, batchIndex);
1436
+ const bag = argBag(args);
1437
+ if (bag.block_type !== undefined) assertDataType(bag.block_type);
1438
+ if (bag.include !== undefined) {
1439
+ if (!Array.isArray(bag.include)) {
1440
+ refuseArg("anno_get_blocks", "include", '"include" must be an array when supplied.', batchIndex);
1441
+ }
1442
+ for (const entry of bag.include as unknown[]) {
1443
+ if (typeof entry !== "string" || !BLOCK_INCLUDES.includes(entry)) {
1444
+ refuseArg(
1445
+ "anno_get_blocks",
1446
+ "include",
1447
+ `${JSON.stringify(entry)} is not one of the ${BLOCK_INCLUDES.length} extra collections -- expected one of: ${BLOCK_INCLUDES.join(", ")}.`,
1448
+ batchIndex,
1449
+ );
1450
+ }
1451
+ }
1452
+ }
1453
+ }
1454
+
1455
+ function assertEnumNameArg(name: string, args: unknown, key: string, batchIndex?: number): void {
1456
+ const raw = argBag(args)[key];
1457
+ try {
1458
+ assertEnumName(raw);
1459
+ } catch (err) {
1460
+ const reason = err instanceof Error ? err.message : String(err);
1461
+ refuseArg(name, key, `${JSON.stringify(raw)} is not a legal enum name (${reason}) -- REJECTED, never sanitized.`, batchIndex);
1462
+ }
1463
+ }
1464
+
1465
+ function assertCreateEnumArgs(args: unknown, batchIndex?: number): void {
1466
+ assertStoreArg("anno_create_project_enum", args, batchIndex);
1467
+ assertEnumNameArg("anno_create_project_enum", args, "name", batchIndex);
1468
+ const bag = argBag(args);
1469
+ if (!isPlainObject(bag.variants)) {
1470
+ refuseArg("anno_create_project_enum", "variants", '"variants" must be an object mapping numeric-string keys to variant names.', batchIndex);
1471
+ }
1472
+ if (bag.description !== undefined) assertCommentText(bag.description, { what: "description", allowLeadingSemicolon: true });
1473
+ assertBaseRevisionArg("anno_create_project_enum", args, batchIndex);
1474
+ }
1475
+
1476
+ function assertUpdateEnumArgs(args: unknown, batchIndex?: number): void {
1477
+ assertStoreArg("anno_update_project_enum", args, batchIndex);
1478
+ assertEnumNameArg("anno_update_project_enum", args, "name", batchIndex);
1479
+ const bag = argBag(args);
1480
+ if (bag.new_name !== undefined) assertEnumNameArg("anno_update_project_enum", args, "new_name", batchIndex);
1481
+ if (bag.variants !== undefined && !isPlainObject(bag.variants)) {
1482
+ refuseArg("anno_update_project_enum", "variants", '"variants" must be an object when supplied -- it REPLACES the mapping wholesale.', batchIndex);
1483
+ }
1484
+ if (bag.description !== undefined) assertCommentText(bag.description, { what: "description", allowLeadingSemicolon: true });
1485
+ assertBaseRevisionArg("anno_update_project_enum", args, batchIndex);
1486
+ }
1487
+
1488
+ /** True when this call is the CLEAR form -- `name` omitted, or an empty
1489
+ * string. The schema's own contract ("Omit or send empty to clear"), read in
1490
+ * ONE place so the validator and the dispatcher can never disagree about which
1491
+ * of the two store functions a given payload selects. */
1492
+ function isEnumUsageClear(args: unknown): boolean {
1493
+ const raw = argBag(args).name;
1494
+ return raw === undefined || raw === "";
1495
+ }
1496
+
1497
+ function assertApplyEnumUsageArgs(args: unknown, batchIndex?: number): void {
1498
+ assertStoreArg("anno_apply_enum_usage", args, batchIndex);
1499
+ assertAddressArg("anno_apply_enum_usage", args, "address", batchIndex);
1500
+ if (!isEnumUsageClear(args)) assertEnumNameArg("anno_apply_enum_usage", args, "name", batchIndex);
1501
+ assertBaseRevisionArg("anno_apply_enum_usage", args, batchIndex);
1502
+ }
1503
+
1504
+ function assertSaveProjectArgs(args: unknown, batchIndex?: number): void {
1505
+ assertStoreArg("anno_save_project", args, batchIndex);
1506
+ }
1507
+
1508
+ function assertImportGhidraExportArgs(args: unknown, batchIndex?: number): void {
1509
+ assertStoreArg("anno_import_ghidra_export", args, batchIndex);
1510
+ const bag = argBag(args);
1511
+ if (typeof bag.export_path !== "string" || bag.export_path.trim() === "") {
1512
+ refuseArg("anno_import_ghidra_export", "export_path", '"export_path" is required and must be a non-empty string.', batchIndex);
1513
+ }
1514
+ if (bag.sha256 !== undefined && (typeof bag.sha256 !== "string" || bag.sha256.trim() === "")) {
1515
+ refuseArg("anno_import_ghidra_export", "sha256", '"sha256" must be a non-empty string when supplied.', batchIndex);
1516
+ }
1517
+ assertBaseRevisionArg("anno_import_ghidra_export", args, batchIndex);
1518
+ }
1519
+
1520
+ /** Validates one `const_writes[i]` element against the wire shape declared on
1521
+ * `anno_join_memmap`'s own schema, and narrows it to a `ConstWriteFact`
1522
+ * (CR-01 fix). Each of the three fields is required and must be a
1523
+ * non-negative integer -- these are ALREADY-RESOLVED facts a caller is
1524
+ * round-tripping from a prior anno_import_ghidra_export call, never an
1525
+ * agent-typed address, so there is no `$`/`0x` ambiguity to route through
1526
+ * `parseStoreAddress()` here. */
1527
+ function assertConstWriteFactArg(name: string, raw: unknown, index: number, batchIndex?: number): ConstWriteFact {
1528
+ if (!isPlainObject(raw)) {
1529
+ refuseArg(
1530
+ name,
1531
+ "const_writes",
1532
+ `"const_writes[${index}]" must be an object with store_address/target_address/value fields, got ${JSON.stringify(raw)}.`,
1533
+ batchIndex,
1534
+ );
1535
+ }
1536
+ const bag = raw as Record<string, unknown>;
1537
+ for (const key of ["store_address", "target_address", "value"] as const) {
1538
+ const value = bag[key];
1539
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
1540
+ refuseArg(
1541
+ name,
1542
+ "const_writes",
1543
+ `"const_writes[${index}].${key}" must be a non-negative integer, got ${JSON.stringify(value)}.`,
1544
+ batchIndex,
1545
+ );
1546
+ }
1547
+ }
1548
+ return {
1549
+ storeAddress: bag.store_address as number,
1550
+ targetAddress: bag.target_address as number,
1551
+ value: bag.value as number,
1552
+ };
1553
+ }
1554
+
1555
+ /** Validates the optional `const_writes` array, returning `undefined` when
1556
+ * omitted -- OMISSION, not emptiness, is what `runMemmapJoin()` treats as
1557
+ * "skip the bank-state/graphics machinery entirely" (D-37-24's own
1558
+ * documented activation switch), so this must not default an absent
1559
+ * argument to `[]`. */
1560
+ function assertConstWritesArg(name: string, args: unknown, batchIndex?: number): ConstWriteFact[] | undefined {
1561
+ const raw = argBag(args).const_writes;
1562
+ if (raw === undefined) return undefined;
1563
+ if (!Array.isArray(raw)) {
1564
+ refuseArg(name, "const_writes", `"const_writes" must be an array when supplied, got ${JSON.stringify(raw)}.`, batchIndex);
1565
+ }
1566
+ return raw.map((entry, i) => assertConstWriteFactArg(name, entry, i, batchIndex));
1567
+ }
1568
+
1569
+ /** Validates the optional `graphics_map_index` argument. */
1570
+ function assertGraphicsMapIndexArg(name: string, args: unknown, batchIndex?: number): number | undefined {
1571
+ const raw = argBag(args).graphics_map_index;
1572
+ if (raw === undefined) return undefined;
1573
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
1574
+ refuseArg(
1575
+ name,
1576
+ "graphics_map_index",
1577
+ `"graphics_map_index" must be a non-negative integer when supplied, got ${JSON.stringify(raw)}.`,
1578
+ batchIndex,
1579
+ );
1580
+ }
1581
+ return raw;
1582
+ }
1583
+
1584
+ function assertJoinMemmapArgs(args: unknown, batchIndex?: number): void {
1585
+ assertStoreArg("anno_join_memmap", args, batchIndex);
1586
+ assertImageArg("anno_join_memmap", args, batchIndex);
1587
+ assertBaseRevisionArg("anno_join_memmap", args, batchIndex);
1588
+ assertConstWritesArg("anno_join_memmap", args, batchIndex);
1589
+ assertGraphicsMapIndexArg("anno_join_memmap", args, batchIndex);
1590
+ }
1591
+
1592
+ /** The run-identity digest shape: exactly 64 lowercase hex characters. This
1593
+ * module's own copy of the check (mirroring `evid-ingest.ts`'s identical,
1594
+ * deliberately un-imported copy): `image_sha256` never reaches a digest
1595
+ * function here, so there is nothing to route through a shared regex, and a
1596
+ * caller-visible refusal must fire BEFORE any store is opened -- before
1597
+ * `evid-ingest.ts`'s own `runIdentityFrom()` ever runs. */
1598
+ const EVID_DIGEST_RE = /^[0-9a-f]{64}$/;
1599
+
1600
+ /** `anno_evid_ingest`'s own argument assertion, wired into `assertVerbArgs`
1601
+ * beside `anno_join_memmap`'s. Refuses BY NAME, before any store is opened: a
1602
+ * non-string/empty `memmap_text`, an `image_sha256` that is not exactly 64
1603
+ * lowercase hex characters, an `argv` that is not a non-empty array of
1604
+ * strings, and a `seed` that is not a non-empty string (T-43-21). */
1605
+ function assertEvidIngestArgs(args: unknown, batchIndex?: number): void {
1606
+ assertStoreArg("anno_evid_ingest", args, batchIndex);
1607
+ assertBaseRevisionArg("anno_evid_ingest", args, batchIndex);
1608
+ const bag = argBag(args);
1609
+ if (typeof bag.memmap_text !== "string" || bag.memmap_text.trim() === "") {
1610
+ refuseArg(
1611
+ "anno_evid_ingest",
1612
+ "memmap_text",
1613
+ `"memmap_text" must be a non-empty string carrying the raw memmapshow reply, got ${JSON.stringify(bag.memmap_text)}.`,
1614
+ batchIndex,
1615
+ );
1616
+ }
1617
+ if (typeof bag.image_sha256 !== "string" || !EVID_DIGEST_RE.test(bag.image_sha256)) {
1618
+ refuseArg(
1619
+ "anno_evid_ingest",
1620
+ "image_sha256",
1621
+ `"image_sha256" must be exactly 64 lowercase hex characters, got ${JSON.stringify(bag.image_sha256)}.`,
1622
+ batchIndex,
1623
+ );
1624
+ }
1625
+ if (!Array.isArray(bag.argv) || bag.argv.length === 0 || bag.argv.some((entry) => typeof entry !== "string")) {
1626
+ refuseArg(
1627
+ "anno_evid_ingest",
1628
+ "argv",
1629
+ `"argv" must be a non-empty array of strings naming the exact emulator launch argument vector, got ${JSON.stringify(bag.argv)}.`,
1630
+ batchIndex,
1631
+ );
1632
+ }
1633
+ if (typeof bag.seed !== "string" || bag.seed.length === 0) {
1634
+ refuseArg("anno_evid_ingest", "seed", `"seed" must be a non-empty string, got ${JSON.stringify(bag.seed)}.`, batchIndex);
1635
+ }
1636
+ }
1637
+
1638
+ /** `anno_evid_disagreements`'s own argument assertion (plan 43-06). The
1639
+ * three run-identity filters are ALL-OR-NONE, mirroring
1640
+ * `listExecObservations()`'s own rule in `anno-store.ts` exactly: a partial
1641
+ * identity would silently widen the match to every run sharing the supplied
1642
+ * field, which is not what "filter by run identity" means. */
1643
+ function assertEvidDisagreementsArgs(args: unknown, batchIndex?: number): void {
1644
+ assertStoreArg("anno_evid_disagreements", args, batchIndex);
1645
+ assertOptionalMaxResults("anno_evid_disagreements", args, batchIndex);
1646
+ const bag = argBag(args);
1647
+ const filterFieldsGiven = [bag.image_sha256, bag.argv_digest, bag.seed].filter((v) => v !== undefined).length;
1648
+ if (filterFieldsGiven > 0 && filterFieldsGiven < 3) {
1649
+ refuseArg(
1650
+ "anno_evid_disagreements",
1651
+ "image_sha256",
1652
+ "a run-identity filter requires image_sha256, argv_digest AND seed together -- a partial identity would " +
1653
+ "silently widen the match to every run sharing the supplied field(s).",
1654
+ batchIndex,
1655
+ );
1656
+ }
1657
+ if (filterFieldsGiven === 3) {
1658
+ if (typeof bag.image_sha256 !== "string" || !EVID_DIGEST_RE.test(bag.image_sha256)) {
1659
+ refuseArg(
1660
+ "anno_evid_disagreements",
1661
+ "image_sha256",
1662
+ `"image_sha256" must be exactly 64 lowercase hex characters, got ${JSON.stringify(bag.image_sha256)}.`,
1663
+ batchIndex,
1664
+ );
1665
+ }
1666
+ if (typeof bag.argv_digest !== "string" || !EVID_DIGEST_RE.test(bag.argv_digest)) {
1667
+ refuseArg(
1668
+ "anno_evid_disagreements",
1669
+ "argv_digest",
1670
+ `"argv_digest" must be exactly 64 lowercase hex characters, got ${JSON.stringify(bag.argv_digest)}.`,
1671
+ batchIndex,
1672
+ );
1673
+ }
1674
+ if (typeof bag.seed !== "string" || bag.seed.length === 0) {
1675
+ refuseArg("anno_evid_disagreements", "seed", `"seed" must be a non-empty string, got ${JSON.stringify(bag.seed)}.`, batchIndex);
1676
+ }
1677
+ }
1678
+ }
1679
+
1680
+ /** `anno_evid_runs`'s own argument assertion (plan 43-06): just the
1681
+ * universal `store` argument, since this verb takes no other input. */
1682
+ function assertEvidRunsArgs(args: unknown, batchIndex?: number): void {
1683
+ assertStoreArg("anno_evid_runs", args, batchIndex);
1684
+ }
1685
+
1686
+ /** `anno_evid_reset`'s own argument assertion (plan 43-06), the SAME shape
1687
+ * as `assertEvidIngestArgs` minus `memmap_text` -- refuses BY NAME, before
1688
+ * any store is opened: an `image_sha256` that is not exactly 64 lowercase
1689
+ * hex characters, an `argv` that is not a non-empty array of strings, and a
1690
+ * `seed` that is not a non-empty string. */
1691
+ function assertEvidResetArgs(args: unknown, batchIndex?: number): void {
1692
+ assertStoreArg("anno_evid_reset", args, batchIndex);
1693
+ assertBaseRevisionArg("anno_evid_reset", args, batchIndex);
1694
+ const bag = argBag(args);
1695
+ if (typeof bag.image_sha256 !== "string" || !EVID_DIGEST_RE.test(bag.image_sha256)) {
1696
+ refuseArg(
1697
+ "anno_evid_reset",
1698
+ "image_sha256",
1699
+ `"image_sha256" must be exactly 64 lowercase hex characters, got ${JSON.stringify(bag.image_sha256)}.`,
1700
+ batchIndex,
1701
+ );
1702
+ }
1703
+ if (!Array.isArray(bag.argv) || bag.argv.length === 0 || bag.argv.some((entry) => typeof entry !== "string")) {
1704
+ refuseArg(
1705
+ "anno_evid_reset",
1706
+ "argv",
1707
+ `"argv" must be a non-empty array of strings naming the exact emulator launch argument vector, got ${JSON.stringify(bag.argv)}.`,
1708
+ batchIndex,
1709
+ );
1710
+ }
1711
+ if (typeof bag.seed !== "string" || bag.seed.length === 0) {
1712
+ refuseArg("anno_evid_reset", "seed", `"seed" must be a non-empty string, got ${JSON.stringify(bag.seed)}.`, batchIndex);
1713
+ }
1714
+ }
1715
+
1716
+ /** `anno_hazard_report`'s own argument assertion. Reuses the shared store,
1717
+ * image and optional-max-results assertions rather than inlining a fourth
1718
+ * check -- this verb has no argument shape of its own beyond those three. */
1719
+ function assertHazardReportArgs(args: unknown, batchIndex?: number): void {
1720
+ assertStoreArg("anno_hazard_report", args, batchIndex);
1721
+ assertImageArg("anno_hazard_report", args, batchIndex);
1722
+ assertOptionalMaxResults("anno_hazard_report", args, batchIndex);
1723
+ }
1724
+
1725
+ // ---------------------------------------------------------------------------
1726
+ // THE ONE SIZE CAP, GOVERNING BOTH VIEWS (T-29-25).
1727
+ //
1728
+ // A full-64K disassembly view dumped into an agent's context is the hazard this
1729
+ // cap exists to prevent; these verbs read a ROUTINE at a range, not the whole
1730
+ // program. 4096 is one sixteenth of the address space and far above any
1731
+ // realistic single routine. ONE cap covers the region read AND the disassemble
1732
+ // view, deliberately, so there is no per-view rule to get subtly wrong -- and
1733
+ // the disassembly view at the cap is the worst case, since the hexdump view of
1734
+ // the same byte count renders far less text.
1735
+ //
1736
+ // THIS CAP IS NOT THE ONLY BOUND FOR THIS FAMILY, BUT IT REMAINS THE
1737
+ // LOAD-BEARING ONE. `vice-proxy.ts`'s `wrapPossiblyChunked()` runs at the
1738
+ // proxy's single tools/call choke point -- the one place every registered
1739
+ // tool's result is checked before it reaches the wire -- so an over-cap
1740
+ // answer from this family crosses that same override exactly like any
1741
+ // other tool's, and is split across a continuation sequence rather than
1742
+ // delivered whole. That does not make this cap redundant: the client's own
1743
+ // inline-response ceiling was measured at 40-60 KB, far below the proxy's
1744
+ // 500,000-character output cap, so a result that never trips the proxy's
1745
+ // split can still be far too large to be useful. That is why the second
1746
+ // mitigation -- `max_results` REQUIRED with no default on every
1747
+ // list-returning verb, with the true total returned beside the truncated list
1748
+ // -- is not optional either.
1749
+ // ---------------------------------------------------------------------------
1750
+
1751
+ export const ANNO_READ_REGION_MAX_BYTES = 4096;
1752
+
1753
+ /** The environment variable that overrides the cap. Exported so a caller and a
1754
+ * test name it in one place rather than two. */
1755
+ export const ANNO_READ_REGION_MAX_BYTES_ENV = "ANNO_READ_REGION_MAX_BYTES";
1756
+
1757
+ /** Reads the cap override AT CALL TIME, never frozen at module load -- the same
1758
+ * read-at-call-time convention `repoRoot()` is called under above, so one
1759
+ * `node --test` process can point several different caps at this code within a
1760
+ * single run. Falls back to the named default on an absent, non-finite or
1761
+ * non-positive override. */
1762
+ function currentReadRegionMaxBytes(): number {
1763
+ const raw = process.env[ANNO_READ_REGION_MAX_BYTES_ENV];
1764
+ if (raw === undefined) return ANNO_READ_REGION_MAX_BYTES;
1765
+ const n = Number(raw);
1766
+ return Number.isFinite(n) && n > 0 ? n : ANNO_READ_REGION_MAX_BYTES;
1767
+ }
1768
+
1769
+ export interface AnnoRegionRangeErrorOptions extends AnnoStoreErrorOptions {
1770
+ toolName?: string;
1771
+ start?: number;
1772
+ end?: number;
1773
+ requestedBytes?: number;
1774
+ cap?: number;
1775
+ batchIndex?: number;
1776
+ }
1777
+
1778
+ /** A region or disassembly extent wider than the cap. Its own class, because a
1779
+ * caller must be able to tell "your range is too wide" from every other
1780
+ * argument refusal without substring-matching a message. */
1781
+ export class AnnoRegionRangeError extends AnnoStoreError {
1782
+ toolName?: string;
1783
+ start?: number;
1784
+ end?: number;
1785
+ requestedBytes?: number;
1786
+ cap?: number;
1787
+ batchIndex?: number;
1788
+
1789
+ constructor(message: string, { toolName, start, end, requestedBytes, cap, batchIndex, ...rest }: AnnoRegionRangeErrorOptions = {}) {
1790
+ super(message, rest);
1791
+ this.name = "AnnoRegionRangeError";
1792
+ this.toolName = toolName;
1793
+ this.start = start;
1794
+ this.end = end;
1795
+ this.requestedBytes = requestedBytes;
1796
+ this.cap = cap;
1797
+ this.batchIndex = batchIndex;
1798
+ }
1799
+ }
1800
+
1801
+ /** Enforces the ONE cap over an inclusive span, naming BOTH the cap and the
1802
+ * requested width so the message is actionable without reading this file.
1803
+ * Called from `anno_read_region` and `anno_disassemble` alike. */
1804
+ function assertWithinRegionCap(name: string, start: number, end: number, batchIndex?: number): void {
1805
+ const requestedBytes = end - start + 1;
1806
+ const cap = currentReadRegionMaxBytes();
1807
+ if (requestedBytes > cap) {
1808
+ throw new AnnoRegionRangeError(
1809
+ `${name} refused${whereOf(batchIndex)}: requested ${requestedBytes} bytes ($${start.toString(16).padStart(4, "0")}..` +
1810
+ `$${end.toString(16).padStart(4, "0")} inclusive), which exceeds the ${ANNO_READ_REGION_MAX_BYTES_ENV} cap of ${cap} -- ` +
1811
+ `valid range is 1..${cap} bytes. This verb reads a routine at a range, not the whole program, and this family is NOT ` +
1812
+ `chunked, so the cap is the only bound there is. Narrow the range, or set ${ANNO_READ_REGION_MAX_BYTES_ENV} to override.`,
1813
+ { toolName: name, start, end, requestedBytes, cap, batchIndex },
1814
+ );
1815
+ }
1816
+ }
1817
+
1818
+ /** Narrows the universally-required `image` argument (D-07) to a non-empty
1819
+ * string. Containment is `resolveWorkspacePath()`'s concern, exactly as for the
1820
+ * store path. */
1821
+ function assertImageArg(name: string, args: unknown, batchIndex?: number): string {
1822
+ const bag = argBag(args);
1823
+ if (typeof bag.image !== "string" || bag.image.trim() === "") {
1824
+ refuseArg(
1825
+ name,
1826
+ "image",
1827
+ '"image" must be a non-empty string naming the program image this answer is derived from -- the store holds ' +
1828
+ "annotations, never bytes, and an omitted image would read as a plausible success against whatever was recorded last (D-07).",
1829
+ batchIndex,
1830
+ );
1831
+ }
1832
+ return bag.image as string;
1833
+ }
1834
+
1835
+ function assertQueryArg(name: string, args: unknown, batchIndex?: number): void {
1836
+ const raw = argBag(args).query;
1837
+ if (typeof raw !== "string" || raw === "") {
1838
+ refuseArg(
1839
+ name,
1840
+ "query",
1841
+ `"query" must be a non-empty string, got ${JSON.stringify(raw)} -- an empty query matches every entry of every corpus, ` +
1842
+ "which is a listing rather than a search, and the list verbs are what listing is for.",
1843
+ batchIndex,
1844
+ );
1845
+ }
1846
+ }
1847
+
1848
+ function assertDisassembleArgs(args: unknown, batchIndex?: number): void {
1849
+ assertStoreArg("anno_disassemble", args, batchIndex);
1850
+ assertImageArg("anno_disassemble", args, batchIndex);
1851
+ const start = assertAddressArg("anno_disassemble", args, "address", batchIndex);
1852
+ const bag = argBag(args);
1853
+ if (bag.end_address !== undefined) {
1854
+ const end = parseStoreAddress(bag.end_address, { what: "end_address" });
1855
+ assertRangeShape(start, end, "byte");
1856
+ assertWithinRegionCap("anno_disassemble", start, end, batchIndex);
1857
+ }
1858
+ }
1859
+
1860
+ function assertReadRegionArgs(args: unknown, batchIndex?: number): void {
1861
+ assertStoreArg("anno_read_region", args, batchIndex);
1862
+ assertImageArg("anno_read_region", args, batchIndex);
1863
+ const { start, end } = assertSpanArgs("anno_read_region", args, "byte", batchIndex);
1864
+ assertWithinRegionCap("anno_read_region", start, end, batchIndex);
1865
+ const view = argBag(args).view;
1866
+ if (view !== undefined && view !== "disasm" && view !== "hexdump") {
1867
+ refuseArg("anno_read_region", "view", `${JSON.stringify(view)} is not a view -- expected "disasm" or "hexdump".`, batchIndex);
1868
+ }
1869
+ }
1870
+
1871
+ function assertBinaryInfoArgs(args: unknown, batchIndex?: number): void {
1872
+ assertStoreArg("anno_get_binary_info", args, batchIndex);
1873
+ assertImageArg("anno_get_binary_info", args, batchIndex);
1874
+ }
1875
+
1876
+ function assertCrossReferencesArgs(args: unknown, batchIndex?: number): void {
1877
+ assertStoreArg("anno_get_cross_references", args, batchIndex);
1878
+ assertImageArg("anno_get_cross_references", args, batchIndex);
1879
+ assertAddressArg("anno_get_cross_references", args, "address", batchIndex);
1880
+ assertMaxResults("anno_get_cross_references", args, batchIndex);
1881
+ }
1882
+
1883
+ function assertSearchArgs(args: unknown, batchIndex?: number): void {
1884
+ assertStoreArg("anno_search", args, batchIndex);
1885
+ assertImageArg("anno_search", args, batchIndex);
1886
+ assertQueryArg("anno_search", args, batchIndex);
1887
+ assertMaxResults("anno_search", args, batchIndex);
1888
+ }
1889
+
1890
+ function assertAddressDetailsArgs(args: unknown, batchIndex?: number): void {
1891
+ assertStoreArg("anno_get_address_details", args, batchIndex);
1892
+ assertImageArg("anno_get_address_details", args, batchIndex);
1893
+ assertAddressArg("anno_get_address_details", args, "address", batchIndex);
1894
+ }
1895
+
1896
+
1897
+ // ---------------------------------------------------------------------------
1898
+ // `anno_batch_execute` -- TWO EXPLICITLY SEPARATE PHASES, documented as two.
1899
+ //
1900
+ // PHASE ONE, PRE-VALIDATION (`assertAnnoBatch`), runs before any store is
1901
+ // opened. It refuses the WHOLE batch on: a malformed payload, an empty `calls`
1902
+ // array, a malformed entry, an uncurated inner name at ANY depth, or an inner
1903
+ // call whose own per-verb validator refuses -- each naming the offending index.
1904
+ // Nothing has executed when it fires, so there is no partial write to explain.
1905
+ //
1906
+ // PHASE TWO, EXECUTION, runs inside ONE `openStore`/`closeStore` pair for the
1907
+ // whole batch. It loops to COMPLETION, pushing a per-entry `{status:"success"}`
1908
+ // or `{status:"error"}` for every entry, and never aborts on the first failure.
1909
+ //
1910
+ // THE TWO ARE NOT IN CONFLICT, and this is the reconciliation the plan records:
1911
+ // per-item status reporting and whole-batch refusal are two PHASES of one call,
1912
+ // not two answers to one question. A refusal in phase one becomes
1913
+ // `isError: true` through the runner's own catch and means "this batch should
1914
+ // never have been sent". An inner call failing in phase two becomes an error
1915
+ // ENTRY inside a successful outer result and means "this call in the batch did
1916
+ // not work". The measured upstream note at `anno-tools.ts:63-75` establishes
1917
+ // the second half: the loop always runs to completion and each outcome is
1918
+ // pushed with its own status.
1919
+ //
1920
+ // TWO THINGS THIS VALIDATOR HAS THAT ITS ANALOG DID NOT:
1921
+ //
1922
+ // 1. AN EXPLICIT DEPTH CAP. The original recursion was unbounded and was safe
1923
+ // only because a child-process spawn cost dominated any nesting an
1924
+ // attacker could send. That cost is gone -- this runs in-process -- so a
1925
+ // deeply nested payload is a stack-exhaustion route (T-29-24). Past the
1926
+ // cap the batch is REFUSED BY NAME, naming the cap, rather than walked.
1927
+ // 2. AN EXPLICIT REFUSAL FOR AN EMPTY `calls` ARRAY. A zero-length batch is
1928
+ // an ambiguous request, and executing it as a zero-length SUCCESS is
1929
+ // exactly the plausible-looking zero this surface forbids. A malformed
1930
+ // payload is a refusal; so is an empty one.
1931
+ // ---------------------------------------------------------------------------
1932
+
1933
+ /**
1934
+ * PHASE ONE. Walks an `anno_batch_execute` payload and refuses the WHOLE batch
1935
+ * if anything, at any depth, is wrong.
1936
+ *
1937
+ * The per-verb argument validators fire through `assertVerbArgs()` -- the SAME
1938
+ * function the outer gate calls -- with the entry's index interpolated into the
1939
+ * message, so an illegal label name or an over-cap region range is refused
1940
+ * identically whether the verb was called directly or smuggled inside a batch.
1941
+ * That is the shared-validator discipline, and it is what makes the outer
1942
+ * allow-list gate mean anything for a nested-argument verb.
1943
+ *
1944
+ * THE SAME DISCIPLINE APPLIES TO THE ARGUMENTS THEMSELVES. Every inner
1945
+ * payload this function walks -- a leaf verb's or a nested batch's -- is
1946
+ * obtained from `batchArgumentsFor()`, the one function phase two also asks.
1947
+ * A phase that computed an inner call's arguments its own way would be
1948
+ * validating a payload the executor never runs, which is what CR-06 was.
1949
+ */
1950
+ export function assertAnnoBatch(args: unknown, depth = 0): void {
1951
+ if (depth > ANNO_MAX_BATCH_DEPTH) {
1952
+ throw new AnnoUncuratedToolError(
1953
+ `anno_batch_execute refused: nesting deeper than ${ANNO_MAX_BATCH_DEPTH} levels -- refused BY NAME rather than walked, ` +
1954
+ "because an unbounded walk over an attacker-shaped payload is a stack-exhaustion route (T-29-24). Flatten the batch.",
1955
+ { toolName: "anno_batch_execute" },
1956
+ );
1957
+ }
1958
+ if (!isPlainObject(args) || !Array.isArray(args.calls)) {
1959
+ throw new AnnoUncuratedToolError(
1960
+ 'anno_batch_execute refused: "calls" must be an array of {name, arguments} objects -- a malformed batch payload is ' +
1961
+ "treated as a REFUSAL, never as an empty batch that passes through.",
1962
+ { toolName: "anno_batch_execute" },
1963
+ );
1964
+ }
1965
+ const calls = args.calls as unknown[];
1966
+ if (calls.length === 0) {
1967
+ throw new AnnoUncuratedToolError(
1968
+ 'anno_batch_execute refused: "calls" is an EMPTY array. A zero-length batch is an ambiguous request, and running it as a ' +
1969
+ "zero-length success would be a plausible-looking zero -- the caller would be told a pass completed when nothing was asked for.",
1970
+ { toolName: "anno_batch_execute" },
1971
+ );
1972
+ }
1973
+ calls.forEach((call, i) => {
1974
+ if (!isPlainObject(call) || typeof call.name !== "string") {
1975
+ throw new AnnoUncuratedToolError(
1976
+ `anno_batch_execute refused WHOLE: calls[${i}] is malformed (missing a string "name") -- treated as a refusal, never ` +
1977
+ "as an empty batch that passes through.",
1978
+ { toolName: "anno_batch_execute", batchIndex: i },
1979
+ );
1980
+ }
1981
+ if (!CURATED_ANNO_TOOLS.includes(call.name)) {
1982
+ throw new AnnoUncuratedToolError(
1983
+ `anno_batch_execute refused WHOLE: calls[${i}].name "${call.name}" is outside the curated anno_* tool surface -- a batch ` +
1984
+ "is refused whole if any inner name is outside the curated set (D-33).",
1985
+ { toolName: call.name, batchIndex: i },
1986
+ );
1987
+ }
1988
+ if (call.name === "anno_batch_execute") {
1989
+ // RECURSES ON THE EFFECTIVE ARGUMENTS, NOT THE RAW BAG, and that is the
1990
+ // whole of CR-06. Phase two -- `dispatchBatchExecute()` -- has always
1991
+ // recursed on `batchArgumentsFor(bag, call)`; phase one used to recurse
1992
+ // on `call.arguments`. The two phases therefore disagreed about what the
1993
+ // inner payload WAS, and a nested batch written the documented way (the
1994
+ // store named ONCE at the top, every inner call inheriting it) was
1995
+ // refused whole at every depth -- with a message saying there is no
1996
+ // ambient store to inherit, the exact opposite of this verb's own
1997
+ // description. Read this line as a pair with the executor's recursion:
1998
+ // one function, `batchArgumentsFor()`, defines an inner call's effective
1999
+ // arguments, and both phases ask it.
2000
+ assertAnnoBatch(batchArgumentsFor(args, call), depth + 1);
2001
+ return;
2002
+ }
2003
+ assertVerbArgs(call.name, batchArgumentsFor(args, call), i);
2004
+ });
2005
+ }
2006
+
2007
+ /** An inner call's effective arguments. The batch names the store ONCE, at the
2008
+ * top level, and every inner call inherits it -- an inner call that named its
2009
+ * own store would be a different store for one entry of a batch that reads as
2010
+ * one transaction's worth of work, which is a shape nothing here wants. An
2011
+ * inner `store` is therefore OVERRIDDEN by the batch's own, never merged with
2012
+ * it and never silently honoured. */
2013
+ function batchArgumentsFor(batchArgs: Record<string, unknown>, call: Record<string, unknown>): Record<string, unknown> {
2014
+ return { ...argBag(call.arguments), store: batchArgs.store, ...(batchArgs.image !== undefined ? { image: batchArgs.image } : {}) };
2015
+ }
2016
+
2017
+ /**
2018
+ * THE ONE PER-VERB VALIDATOR DISPATCH. Both the outer gate and (once it lands)
2019
+ * the batch pre-validator call THIS function, never the individual validators
2020
+ * directly, so a verb cannot be validated on one route and waved through on the
2021
+ * other. `batchIndex` is `undefined` for a direct call and the offending index
2022
+ * for a batch entry; every refusal message interpolates it.
2023
+ */
2024
+ function assertVerbArgs(name: string, args: unknown, batchIndex?: number): void {
2025
+ if (name === "anno_get_symbols") return assertGetSymbolsArgs(args, batchIndex);
2026
+ if (name === "anno_set_label_name") return assertSetLabelArgs(args, batchIndex);
2027
+ if (name === "anno_set_comment") return assertSetCommentArgs(args, batchIndex);
2028
+ if (name === "anno_set_data_type") return assertSetDataTypeArgs(args, batchIndex);
2029
+ if (name === "anno_add_scope") return assertScopeArgs("anno_add_scope", args, batchIndex);
2030
+ if (name === "anno_remove_scope") return assertScopeArgs("anno_remove_scope", args, batchIndex);
2031
+ if (name === "anno_exclude_range") return assertExcludedRangeArgs("anno_exclude_range", args, batchIndex);
2032
+ if (name === "anno_include_range") return assertExcludedRangeArgs("anno_include_range", args, batchIndex);
2033
+ if (name === "anno_get_comments") return assertGetCommentsArgs(args, batchIndex);
2034
+ if (name === "anno_get_blocks") return assertGetBlocksArgs(args, batchIndex);
2035
+ if (name === "anno_create_project_enum") return assertCreateEnumArgs(args, batchIndex);
2036
+ if (name === "anno_update_project_enum") return assertUpdateEnumArgs(args, batchIndex);
2037
+ if (name === "anno_apply_enum_usage") return assertApplyEnumUsageArgs(args, batchIndex);
2038
+ if (name === "anno_save_project") return assertSaveProjectArgs(args, batchIndex);
2039
+ if (name === "anno_import_ghidra_export") return assertImportGhidraExportArgs(args, batchIndex);
2040
+ if (name === "anno_join_memmap") return assertJoinMemmapArgs(args, batchIndex);
2041
+ if (name === "anno_evid_ingest") return assertEvidIngestArgs(args, batchIndex);
2042
+ if (name === "anno_evid_disagreements") return assertEvidDisagreementsArgs(args, batchIndex);
2043
+ if (name === "anno_evid_runs") return assertEvidRunsArgs(args, batchIndex);
2044
+ if (name === "anno_evid_reset") return assertEvidResetArgs(args, batchIndex);
2045
+ if (name === "anno_hazard_report") return assertHazardReportArgs(args, batchIndex);
2046
+ if (name === "anno_disassemble") return assertDisassembleArgs(args, batchIndex);
2047
+ if (name === "anno_read_region") return assertReadRegionArgs(args, batchIndex);
2048
+ if (name === "anno_get_binary_info") return assertBinaryInfoArgs(args, batchIndex);
2049
+ if (name === "anno_get_cross_references") return assertCrossReferencesArgs(args, batchIndex);
2050
+ if (name === "anno_search") return assertSearchArgs(args, batchIndex);
2051
+ if (name === "anno_get_address_details") return assertAddressDetailsArgs(args, batchIndex);
2052
+ if (name === "anno_batch_execute") return assertAnnoBatch(args);
2053
+ // Every curated verb has an arm above. A curated name reaching here is a bug
2054
+ // in THIS file, and saying so by name is cheaper than a validator silently
2055
+ // accepting a payload nobody checked.
2056
+ throw new AnnoUncuratedToolError(
2057
+ `"${name}" is curated but has no argument validator in anno-tools.ts. Resolution routes: add one to ` +
2058
+ "assertVerbArgs, or remove the definition.",
2059
+ { toolName: name, batchIndex },
2060
+ );
2061
+ }
2062
+
2063
+ /**
2064
+ * The allow-list gate. Its body's FIRST check is set membership (see WHAT NOT
2065
+ * TO DO above, and the same confused-deputy precedent inverted into an
2066
+ * allow-list): a `name` outside `CURATED_ANNO_TOOLS` is refused outright,
2067
+ * before any argument is inspected, so an unknown verb can never reach a
2068
+ * validator that might coincidentally accept its payload. Only then are the
2069
+ * named verb's own arguments checked.
2070
+ */
2071
+ export function assertAnnoTool(name: string, args?: unknown): void {
2072
+ if (!CURATED_ANNO_TOOLS.includes(name)) {
2073
+ throw new AnnoUncuratedToolError(
2074
+ `"${name}" is not part of the curated anno_* tool surface. Resolution routes: implement it and ` +
2075
+ "add it to ANNO_TOOL_DEFINITIONS with a named criterion, or remove the caller reference.",
2076
+ { toolName: name },
2077
+ );
2078
+ }
2079
+ assertVerbArgs(name, args);
2080
+ }
2081
+
2082
+ // ---------------------------------------------------------------------------
2083
+ // Workspace path validation (T-29-01). The same posture `anno-tools.ts` took
2084
+ // for a caller-supplied project path and `stock-symbols.ts` takes for a `.lbl`
2085
+ // file: an LLM-supplied path reaching the filesystem. Resolved against
2086
+ // `repoRoot()` through `storePathWithinWorkspace()`, which carries WR-01's
2087
+ // finding -- containment is enforced against the deepest EXISTING ancestor's
2088
+ // realpath, so a not-yet-existing leaf under a directory symlink cannot slip
2089
+ // past by way of an ENOENT fallback to the literal path.
2090
+ //
2091
+ // The STORE path and the IMAGE path go through the SAME helper. They are two
2092
+ // LLM-supplied paths with one containment rule, and giving the image its own
2093
+ // rule would be a second answer to the one question this function answers once.
2094
+ //
2095
+ // `repoRoot()` is called at DISPATCH time, never frozen at module load, for
2096
+ // the same reason the region cap's override is read at call time: one
2097
+ // `node --test` process can then point several different workspace roots at
2098
+ // this code within a single run.
2099
+ // ---------------------------------------------------------------------------
2100
+
2101
+ function resolveWorkspacePath(raw: string): string {
2102
+ return storePathWithinWorkspace(raw, repoRoot());
2103
+ }
2104
+
2105
+ function resolveStoreArg(name: string, args: unknown): string {
2106
+ return resolveWorkspacePath(assertStoreArg(name, args));
2107
+ }
2108
+
2109
+ // ---------------------------------------------------------------------------
2110
+ // "GONE" AND "EMPTY" MUST NOT READ THE SAME, ON THE WRITE PATH TOO.
2111
+ //
2112
+ // `openStore`'s `mustExist` option bundles two inseparable halves -- refuse an
2113
+ // absent path, AND open the connection `readOnly` -- because it exists to judge
2114
+ // a file the caller is about to install, and a judge that can modify what it
2115
+ // judges is not a judge. That bundling is right for its purpose and wrong for
2116
+ // this one: a write verb needs the refusal WITHOUT the read-only open, and
2117
+ // there is no third state to ask `openStore` for.
2118
+ //
2119
+ // So the refusal is made HERE, by name, before the connection is constructed,
2120
+ // and the residual window that `mustExist`'s read-only open would otherwise
2121
+ // have closed is closed by INODE IDENTITY instead. The window is real: between
2122
+ // the existence check and the constructor the file can be unlinked, after
2123
+ // which a writable open CREATES it and the verb writes into a store it
2124
+ // invented, reporting success. Comparing the inode across the open detects
2125
+ // exactly that -- an unlinked-and-recreated file is a different inode -- and
2126
+ // turns an invented store into a named refusal.
2127
+ // ---------------------------------------------------------------------------
2128
+
2129
+ /** The verbs that only READ. They get `openStore`'s `mustExist` (and therefore
2130
+ * its read-only connection), which is strictly the safer open; every other verb
2131
+ * takes the existence-check-plus-inode-guard route below. Derived from nothing
2132
+ * -- it is a hand-listed property of each verb, and a verb missing from here is
2133
+ * merely opened writably, never wrongly refused. */
2134
+ export const READ_ONLY_ANNO_VERBS: readonly string[] = Object.freeze([
2135
+ "anno_get_symbols",
2136
+ "anno_get_comments",
2137
+ "anno_get_blocks",
2138
+ "anno_save_project",
2139
+ "anno_disassemble",
2140
+ "anno_read_region",
2141
+ "anno_get_binary_info",
2142
+ "anno_get_cross_references",
2143
+ "anno_search",
2144
+ "anno_get_address_details",
2145
+ "anno_evid_disagreements",
2146
+ "anno_evid_runs",
2147
+ "anno_hazard_report",
2148
+ ]);
2149
+
2150
+ /** Refuses an absent store BY NAME, returning the inode the later guard
2151
+ * compares against. A write verb must never CREATE the file it was asked to
2152
+ * annotate: "the annotations are gone" and "there are no annotations" are
2153
+ * different facts and must not read the same. */
2154
+ function assertStorePresent(name: string, storePath: string): number {
2155
+ if (!existsSync(storePath)) {
2156
+ throw new AnnoStorePathError(
2157
+ `${name} refused: no annotation store exists at ${JSON.stringify(storePath)} -- refusing to CREATE one, because "the ` +
2158
+ 'annotations are gone" and "there are no annotations" must not read the same. Create the store deliberately first.',
2159
+ { path: storePath },
2160
+ );
2161
+ }
2162
+ return statSync(storePath).ino;
2163
+ }
2164
+
2165
+ /** Closes the window between the existence check and the open. */
2166
+ function assertSameFile(name: string, storePath: string, inodeBefore: number): void {
2167
+ if (statSync(storePath).ino !== inodeBefore) {
2168
+ throw new AnnoStorePathError(
2169
+ `${name} refused: the file at ${JSON.stringify(storePath)} was replaced between the existence check and the open, so this ` +
2170
+ "call would have written into a store it created itself rather than the one it was asked to annotate. Nothing was written.",
2171
+ { path: storePath },
2172
+ );
2173
+ }
2174
+ }
2175
+
2176
+ // ---------------------------------------------------------------------------
2177
+ // The dispatch table. Each dispatcher receives an ALREADY-OPEN handle it does
2178
+ // not own: opening and closing are `runAnnoTool`'s job and only
2179
+ // `runAnnoTool`'s, so there is exactly one `finally` in this module to get
2180
+ // right rather than one per verb.
2181
+ //
2182
+ // Every dispatcher surfaces `changed` from its `AnnoWriteResult` and NEVER maps
2183
+ // `changed: false` to an error.
2184
+ // ---------------------------------------------------------------------------
2185
+
2186
+ function dispatchGetSymbols(handle: AnnoStoreHandle, args: unknown): unknown {
2187
+ const maxResults = assertMaxResults("anno_get_symbols", args);
2188
+ const bag = argBag(args);
2189
+ const start = bag.start_address !== undefined ? parseStoreAddress(bag.start_address, { what: "start_address" }) : undefined;
2190
+ const end = bag.end_address !== undefined ? parseStoreAddress(bag.end_address, { what: "end_address" }) : undefined;
2191
+
2192
+ const all: LabelRow[] = listLabels(handle);
2193
+ const matched = all.filter((row) => {
2194
+ if (start !== undefined && row.address < start) return false;
2195
+ if (end !== undefined && row.address > end) return false;
2196
+ return true;
2197
+ });
2198
+ const symbols = matched.slice(0, maxResults);
2199
+ // `truncated` is reported rather than left for the caller to infer from a
2200
+ // count that happens to equal its own ceiling -- the ceiling being hit and
2201
+ // the answer being complete-at-exactly-the-ceiling are different facts.
2202
+ return { store: handle.path, symbols, returned: symbols.length, matched: matched.length, truncated: matched.length > symbols.length };
2203
+ }
2204
+
2205
+ function dispatchSetLabelName(handle: AnnoStoreHandle, args: unknown): unknown {
2206
+ const bag = argBag(args);
2207
+ const written = setLabel(handle, {
2208
+ address: bag.address as number | string,
2209
+ name: bag.name,
2210
+ // 'User' is the default because a name arriving through this surface was
2211
+ // chosen by whoever made the call; an unstated provenance is a human's.
2212
+ kind: bag.kind === undefined ? "User" : bag.kind,
2213
+ baseRevision: assertBaseRevisionArg("anno_set_label_name", args),
2214
+ });
2215
+ return { store: handle.path, address: parseStoreAddress(bag.address, { what: "address" }), name: bag.name, kind: bag.kind ?? "User", ...written };
2216
+ }
2217
+
2218
+ function dispatchSetComment(handle: AnnoStoreHandle, args: unknown): unknown {
2219
+ const bag = argBag(args);
2220
+ const written = setComment(handle, {
2221
+ address: bag.address as number | string,
2222
+ commentType: bag.type,
2223
+ text: bag.comment,
2224
+ baseRevision: assertBaseRevisionArg("anno_set_comment", args),
2225
+ });
2226
+ return { store: handle.path, address: parseStoreAddress(bag.address, { what: "address" }), type: bag.type, ...written };
2227
+ }
2228
+
2229
+ function dispatchSetDataType(handle: AnnoStoreHandle, args: unknown): unknown {
2230
+ const bag = argBag(args);
2231
+ const written = setDataType(handle, {
2232
+ start: bag.start_address as number | string,
2233
+ endInclusive: bag.end_address as number | string,
2234
+ dataType: bag.data_type,
2235
+ baseRevision: assertBaseRevisionArg("anno_set_data_type", args),
2236
+ });
2237
+ // BOTH disclosures ride out on the SUCCESSFUL body, as named top-level
2238
+ // fields, every time -- including when they are empty, so "this write
2239
+ // contradicted nothing" is a fact the caller is told rather than the absence
2240
+ // of a field it has to know to look for. This is 28-VERIFICATION.md's F-4
2241
+ // obligation, discharged at the layer the human actually reads.
2242
+ return {
2243
+ store: handle.path,
2244
+ start_address: parseStoreAddress(bag.start_address, { what: "start_address" }),
2245
+ end_address: parseStoreAddress(bag.end_address, { what: "end_address" }),
2246
+ data_type: bag.data_type,
2247
+ revision: written.revision,
2248
+ changed: written.changed,
2249
+ contradictedComments: written.contradictedComments,
2250
+ reinterpretedSplitTables: written.reinterpretedSplitTables,
2251
+ };
2252
+ }
2253
+
2254
+ function dispatchScope(name: string, handle: AnnoStoreHandle, args: unknown): unknown {
2255
+ const bag = argBag(args);
2256
+ const span = {
2257
+ start: bag.start_address as number | string,
2258
+ endInclusive: bag.end_address as number | string,
2259
+ baseRevision: assertBaseRevisionArg(name, args),
2260
+ };
2261
+ const written = name === "anno_add_scope" ? addScope(handle, span) : removeScope(handle, span);
2262
+ return {
2263
+ store: handle.path,
2264
+ start_address: parseStoreAddress(bag.start_address, { what: "start_address" }),
2265
+ end_address: parseStoreAddress(bag.end_address, { what: "end_address" }),
2266
+ ...written,
2267
+ scopes: listScopes(handle),
2268
+ };
2269
+ }
2270
+
2271
+ /** One dispatcher serving `anno_exclude_range` / `anno_include_range`,
2272
+ * modelled on `dispatchScope()`. `excludedRanges` rides on EVERY successful
2273
+ * body, including when it is empty, for the same reason `dispatchSetDataType`'s
2274
+ * own disclosures do: the resulting state is a fact the caller is told, not
2275
+ * the absence of a field it has to know to look for. */
2276
+ function dispatchExcludedRange(name: string, handle: AnnoStoreHandle, args: unknown): unknown {
2277
+ const bag = argBag(args);
2278
+ const span = {
2279
+ start: bag.start_address as number | string,
2280
+ endInclusive: bag.end_address as number | string,
2281
+ baseRevision: assertBaseRevisionArg(name, args),
2282
+ };
2283
+ const written =
2284
+ name === "anno_exclude_range"
2285
+ ? addExcludedRange(handle, { ...span, reason: bag.reason as string })
2286
+ : removeExcludedRange(handle, span);
2287
+ return {
2288
+ store: handle.path,
2289
+ start_address: parseStoreAddress(bag.start_address, { what: "start_address" }),
2290
+ end_address: parseStoreAddress(bag.end_address, { what: "end_address" }),
2291
+ ...written,
2292
+ excludedRanges: listExcludedRanges(handle),
2293
+ };
2294
+ }
2295
+
2296
+ function dispatchGetComments(handle: AnnoStoreHandle, args: unknown): unknown {
2297
+ const maxResults = assertMaxResults("anno_get_comments", args);
2298
+ const bag = argBag(args);
2299
+ const wanted =
2300
+ bag.addresses === undefined ? undefined : new Set((bag.addresses as unknown[]).map((entry) => parseStoreAddress(entry, { what: "addresses[]" })));
2301
+ const start = bag.start_address !== undefined ? parseStoreAddress(bag.start_address, { what: "start_address" }) : undefined;
2302
+ const end = bag.end_address !== undefined ? parseStoreAddress(bag.end_address, { what: "end_address" }) : undefined;
2303
+ const type = bag.type !== undefined ? assertCommentType(bag.type) : undefined;
2304
+
2305
+ const all: CommentRow[] = listComments(handle);
2306
+ const matched = all.filter((row) => {
2307
+ if (wanted !== undefined && !wanted.has(row.address)) return false;
2308
+ if (start !== undefined && row.address < start) return false;
2309
+ if (end !== undefined && row.address > end) return false;
2310
+ if (type !== undefined && row.commentType !== type) return false;
2311
+ return true;
2312
+ });
2313
+ const comments = matched.slice(0, maxResults);
2314
+ return { store: handle.path, comments, returned: comments.length, matched: matched.length, truncated: matched.length > comments.length };
2315
+ }
2316
+
2317
+ function dispatchGetBlocks(handle: AnnoStoreHandle, args: unknown): unknown {
2318
+ const maxResults = assertMaxResults("anno_get_blocks", args);
2319
+ const bag = argBag(args);
2320
+ const blockType = bag.block_type !== undefined ? assertDataType(bag.block_type) : undefined;
2321
+ const include = new Set((Array.isArray(bag.include) ? bag.include : []) as string[]);
2322
+
2323
+ const matched = listRanges(handle).filter((row) => blockType === undefined || row.dataType === blockType);
2324
+ const blocks = matched.slice(0, maxResults);
2325
+ return {
2326
+ store: handle.path,
2327
+ blocks,
2328
+ returned: blocks.length,
2329
+ matched: matched.length,
2330
+ truncated: matched.length > blocks.length,
2331
+ ...(include.has("scopes") ? { scopes: listScopes(handle) } : {}),
2332
+ ...(include.has("enums") ? { enums: listProjectEnums(handle) } : {}),
2333
+ ...(include.has("enum_usage") ? { enum_usage: listEnumUsage(handle) } : {}),
2334
+ };
2335
+ }
2336
+
2337
+ function dispatchCreateProjectEnum(handle: AnnoStoreHandle, args: unknown): unknown {
2338
+ const bag = argBag(args);
2339
+ const written = createProjectEnum(handle, {
2340
+ name: bag.name,
2341
+ variants: bag.variants,
2342
+ description: bag.description,
2343
+ baseRevision: assertBaseRevisionArg("anno_create_project_enum", args),
2344
+ });
2345
+ return { store: handle.path, name: bag.name, ...written, enums: listProjectEnums(handle) };
2346
+ }
2347
+
2348
+ function dispatchUpdateProjectEnum(handle: AnnoStoreHandle, args: unknown): unknown {
2349
+ const bag = argBag(args);
2350
+ const written = updateProjectEnum(handle, {
2351
+ name: bag.name,
2352
+ newName: bag.new_name,
2353
+ variants: bag.variants,
2354
+ description: bag.description,
2355
+ baseRevision: assertBaseRevisionArg("anno_update_project_enum", args),
2356
+ });
2357
+ return { store: handle.path, name: bag.new_name ?? bag.name, ...written, enums: listProjectEnums(handle) };
2358
+ }
2359
+
2360
+ function dispatchApplyEnumUsage(handle: AnnoStoreHandle, args: unknown): unknown {
2361
+ const bag = argBag(args);
2362
+ const baseRevision = assertBaseRevisionArg("anno_apply_enum_usage", args);
2363
+ const cleared = isEnumUsageClear(args);
2364
+ const written = cleared
2365
+ ? clearEnumUsage(handle, { address: bag.address as number | string, baseRevision })
2366
+ : applyEnumUsage(handle, { address: bag.address as number | string, name: bag.name, baseRevision });
2367
+ return {
2368
+ store: handle.path,
2369
+ address: parseStoreAddress(bag.address, { what: "address" }),
2370
+ name: cleared ? null : bag.name,
2371
+ cleared,
2372
+ ...written,
2373
+ enum_usage: listEnumUsage(handle),
2374
+ };
2375
+ }
2376
+
2377
+ /** THE HONEST SAVE. It opens (through the runner), reads the revision, and
2378
+ * closes. It writes NOTHING, and the body says so in its own words rather than
2379
+ * leaving the caller to infer durability from an empty success. `curated` in
2380
+ * the manifest means a route is required; returning `{available:false}` was
2381
+ * rejected, because a permanent refusal for a curated disposition is what the
2382
+ * `omit` disposition is for and the manifest does not say `omit`.
2383
+ *
2384
+ * THE REVISION IS READ EXACTLY ONCE, into a `const`, and that single value
2385
+ * feeds both the returned field and the note's prose. This is the one verb
2386
+ * whose output a caller is TOLD to use as a `base_revision` compare-and-swap
2387
+ * guard, so a field and a prose that could name different revisions is a guard
2388
+ * built on a number its own note contradicts -- and a guard nobody can trust is
2389
+ * worse than no guard, because it is acted on (WR-10). Two reads agreeing is an
2390
+ * accident of when they ran; one read agreeing with itself is a property. */
2391
+ function dispatchSaveProject(handle: AnnoStoreHandle): unknown {
2392
+ const revision = currentRevision(handle);
2393
+ return {
2394
+ store: handle.path,
2395
+ revision,
2396
+ wrote: false,
2397
+ note:
2398
+ "This verb performed NO write. Every mutating verb on this surface commits and fsyncs its own write before it " +
2399
+ "returns, so the store was already durable at revision " +
2400
+ String(revision) +
2401
+ " when this call arrived and there was nothing for an explicit save to flush. The revision is reported so it can " +
2402
+ "be used as a base_revision compare-and-swap guard on a later write.",
2403
+ };
2404
+ }
2405
+
2406
+ /** Enforces `base_revision` as a whole-call precondition rather than
2407
+ * threading it through each of the many writes `importGhidraExport()` and
2408
+ * `runMemmapJoin()` may issue: both verbs commit several writes per call, and
2409
+ * a single up-front comparison against the revision the caller computed its
2410
+ * batch against is the coherent point to apply an optimistic-concurrency
2411
+ * guard for a multi-write verb -- checked BEFORE anything is written, exactly
2412
+ * like every other refusal on this surface. */
2413
+ function assertNotStale(name: string, handle: AnnoStoreHandle, baseRevision: number | undefined): void {
2414
+ if (baseRevision === undefined) return;
2415
+ const rev = currentRevision(handle);
2416
+ if (baseRevision !== rev) {
2417
+ throw new AnnoStoreStaleRevisionError(
2418
+ `${name} refused: base revision ${baseRevision} is not the current on-disk revision ${rev}. Nothing was written.`,
2419
+ { baseRevision, currentRevision: rev },
2420
+ );
2421
+ }
2422
+ }
2423
+
2424
+ function dispatchImportGhidraExport(handle: AnnoStoreHandle, args: unknown): unknown {
2425
+ const bag = argBag(args);
2426
+ const baseRevision = assertBaseRevisionArg("anno_import_ghidra_export", args);
2427
+ assertNotStale("anno_import_ghidra_export", handle, baseRevision);
2428
+ const exportPath = resolveExportPathArg(bag.export_path as string);
2429
+ return importGhidraExport(handle, {
2430
+ exportPath,
2431
+ expectedSha256: bag.sha256 as string | undefined,
2432
+ });
2433
+ }
2434
+
2435
+ /** `resolveWorkspacePath()` itself, never a second hand-rolled resolve-and-
2436
+ * prefix-test (T-37-01) -- but its underlying `AnnoStorePathError` message
2437
+ * says "store path ... is outside the workspace root", unaware of which
2438
+ * higher-level argument it was protecting, because `store` and `image` both
2439
+ * reuse the same generic wording. Wrapped here so a refusal on `export_path`
2440
+ * NAMES the argument rather than reading identically to a `store` refusal. */
2441
+ function resolveExportPathArg(raw: string): string {
2442
+ try {
2443
+ return resolveWorkspacePath(raw);
2444
+ } catch (err) {
2445
+ if (err instanceof AnnoStorePathError) {
2446
+ throw new AnnoStorePathError(`anno_import_ghidra_export refused: export_path ${err.message}`, {
2447
+ path: err.path,
2448
+ workspaceRoot: err.workspaceRoot,
2449
+ });
2450
+ }
2451
+ throw err;
2452
+ }
2453
+ }
2454
+
2455
+ function dispatchJoinMemmap(handle: AnnoStoreHandle, args: unknown): unknown {
2456
+ const baseRevision = assertBaseRevisionArg("anno_join_memmap", args);
2457
+ assertNotStale("anno_join_memmap", handle, baseRevision);
2458
+ const image = loadImage("anno_join_memmap", args);
2459
+ // CR-01 fix: `const_writes`/`graphics_map_index` are threaded into
2460
+ // `runMemmapJoin()` exactly as its own `RunMemmapJoinArgs` documents --
2461
+ // OMISSION (not `[]`) is what keeps every pre-existing call (no
2462
+ // const_writes at all) a byte-identical no-op for the bank-state and
2463
+ // graphics machinery.
2464
+ const constWrites = assertConstWritesArg("anno_join_memmap", args);
2465
+ const graphicsMapIndex = assertGraphicsMapIndexArg("anno_join_memmap", args);
2466
+ return runMemmapJoin(handle, {
2467
+ imageOrigin: image.origin,
2468
+ imageByteLength: image.body.length,
2469
+ ...(constWrites !== undefined ? { constWrites } : {}),
2470
+ ...(graphicsMapIndex !== undefined ? { graphicsMapIndex } : {}),
2471
+ });
2472
+ }
2473
+
2474
+ /**
2475
+ * `anno_evid_ingest`'s dispatch arm (EVID-01, EVID-04, plan 43-05). Calls
2476
+ * `parseAccessMap()` -- THE ONE PARSE -- then `ingestAccessMap()` from
2477
+ * `evid-ingest.ts`; on a refusal it throws inside the `ViceError` family
2478
+ * (never absorbs a drifted reply, T-43-22); on success it writes the WHOLE
2479
+ * observation array through ONE `insertExecObservations()` call, so the
2480
+ * write is one transaction through the store's single commit site
2481
+ * (T-43-26). `observationsWritten` is `insertExecObservations()`'s own
2482
+ * `insertedCount` (WR-02) -- counted row-by-row INSIDE that same
2483
+ * transaction, never from a separate pre-write read -- not the size of the
2484
+ * array handed in: re-ingesting the identical reply must report
2485
+ * `observationsWritten: 0` even though the same-shaped array was passed
2486
+ * again.
2487
+ *
2488
+ * `denominator` travels beside every count this answer reports
2489
+ * (`addressesQueried`, the parsed map's own projection) -- a bare
2490
+ * `observationsWritten` would invite the reading "the rest is data", which
2491
+ * is why the denominator is never omitted. No percentage is ever formed
2492
+ * here.
2493
+ */
2494
+ function dispatchEvidIngest(handle: AnnoStoreHandle, args: unknown): unknown {
2495
+ const bag = argBag(args);
2496
+ const baseRevision = assertBaseRevisionArg("anno_evid_ingest", args);
2497
+
2498
+ const parsed = parseAccessMap(bag.memmap_text as string);
2499
+ const identity: IngestRunIdentity = {
2500
+ imageSha256: bag.image_sha256 as string,
2501
+ argv: bag.argv as string[],
2502
+ seed: bag.seed as string,
2503
+ };
2504
+ const ingested = ingestAccessMap(parsed, identity);
2505
+ if (!ingested.ok) {
2506
+ throw new AnnoToolArgumentError(`anno_evid_ingest refused: ${ingested.message}`, {
2507
+ toolName: "anno_evid_ingest",
2508
+ argument: "memmap_text",
2509
+ });
2510
+ }
2511
+
2512
+ const ranges = parsed.ok ? accessMapRanges(parsed.value) : undefined;
2513
+ const addressesWithRecordedAccess = ranges?.addressesWithRecordedAccess ?? 0;
2514
+ const addressesQueried = ranges?.addressesQueried ?? 0;
2515
+
2516
+ // A reply that recorded no execution anywhere is a real, legitimate
2517
+ // answer -- not an error -- but `insertExecObservations` refuses an EMPTY
2518
+ // observations array, so that zero-write case is reported directly here
2519
+ // rather than calling a store function built to refuse it. WR-01: it is
2520
+ // still routed through `applyWrite()` with a no-op mutator (rather than
2521
+ // returning early on `currentRevision(handle)` alone) so a stale
2522
+ // `base_revision` is refused on THIS path exactly as it would be on the
2523
+ // non-empty path below -- every other write verb in this store enforces
2524
+ // staleness through `applyWrite()`'s own check, and a caller relying on
2525
+ // that contract must not get a silent success here instead.
2526
+ if (ingested.observations.length === 0) {
2527
+ const { revision } = applyWrite(handle, () => false, { baseRevision });
2528
+ return {
2529
+ store: handle.path,
2530
+ revision,
2531
+ changed: false,
2532
+ observationsWritten: 0,
2533
+ addressesWithRecordedAccess,
2534
+ addressesQueried,
2535
+ denominator: addressesQueried,
2536
+ };
2537
+ }
2538
+
2539
+ // WR-02: `observationsWritten` is the COUNT `insertExecObservations()`
2540
+ // itself returns, counted row-by-row INSIDE its own `applyWrite`
2541
+ // transaction -- never a `listExecObservations()` read taken before that
2542
+ // transaction opens. A separately-derived pre-read can be overtaken by a
2543
+ // concurrent writer to the same run identity between the read and this
2544
+ // call's own commit, overstating how many rows THIS call actually added;
2545
+ // counting inside the transaction that performs the insert is the one
2546
+ // place this number can be exact.
2547
+ const written = insertExecObservations(handle, {
2548
+ imageSha256: ingested.runIdentity.imageSha256,
2549
+ argvDigest: ingested.runIdentity.argvDigest,
2550
+ seed: ingested.runIdentity.seed,
2551
+ observations: ingested.observations.map((o) => ({ address: o.address, sourceBank: o.sourceBank })),
2552
+ baseRevision,
2553
+ });
2554
+
2555
+ return {
2556
+ store: handle.path,
2557
+ revision: written.revision,
2558
+ changed: written.changed,
2559
+ observationsWritten: written.insertedCount,
2560
+ addressesWithRecordedAccess,
2561
+ addressesQueried,
2562
+ denominator: addressesQueried,
2563
+ };
2564
+ }
2565
+
2566
+ /**
2567
+ * `anno_evid_disagreements`'s dispatch arm (EVID-03/EVID-04, plan 43-06).
2568
+ * Fetches BOTH sides HERE -- `listExecObservations()` and `listRanges()` --
2569
+ * so `reconcileObservedExecution()` (`evid-reconcile.ts`) is never handed a
2570
+ * store to open itself; that pure module's own header states it must never
2571
+ * fetch either side.
2572
+ *
2573
+ * The byte-derived ranges are mapped through `blocksFromStore()`, reached by
2574
+ * a LAZY `await import("./anno-cli.ts")` -- the same lazy pattern
2575
+ * `vice-proxy.ts:307` already uses to reach `runAnnoCli`, so this file's own
2576
+ * static import graph (and therefore the MCP server's startup cost) is
2577
+ * unchanged: `anno-cli.ts` drags in `anno-coverage.ts`, `anno-memmap-render.ts`
2578
+ * and `anno-export-asm.ts`, none of which this verb needs. The mapping
2579
+ * itself is NOT re-implemented here: a second `RangeRow` -> `BlockEntry` site
2580
+ * would be a second answer to "what class is this address", which is
2581
+ * exactly the boundary `block-class.ts` (and `blocksFromStore()`'s own
2582
+ * comment) exists to keep at one.
2583
+ *
2584
+ * `max_results` (optional, `assertOptionalMaxResults`) bounds the RETURNED
2585
+ * `disagreements` array only -- `agreementCount` and every other bucket are
2586
+ * already counts, never rows, so there is nothing else to truncate.
2587
+ * `reconciliation`'s own key order is preserved by spreading it before
2588
+ * re-assigning `disagreements`: JS does not move an existing key to the end
2589
+ * of an object literal on reassignment, so `disagreements` stays the FIRST
2590
+ * key after `store` (EVID-03).
2591
+ */
2592
+ async function dispatchEvidDisagreements(handle: AnnoStoreHandle, args: unknown): Promise<unknown> {
2593
+ const maxResults = assertOptionalMaxResults("anno_evid_disagreements", args);
2594
+ const bag = argBag(args);
2595
+ const hasRunFilter = bag.image_sha256 !== undefined;
2596
+ const observations = listExecObservations(
2597
+ handle,
2598
+ hasRunFilter ? { imageSha256: bag.image_sha256, argvDigest: bag.argv_digest, seed: bag.seed } : {},
2599
+ );
2600
+ // Lazy, deliberately: see this function's own doc comment above for why a
2601
+ // static top-level import of anno-cli.ts must never appear in this file.
2602
+ const { blocksFromStore } = await import("./anno-cli.ts");
2603
+ const blocks = blocksFromStore(listRanges(handle));
2604
+ const reconciliation = reconcileObservedExecution({ blocks, observations });
2605
+ const disagreements = maxResults === undefined ? reconciliation.disagreements : reconciliation.disagreements.slice(0, maxResults);
2606
+ return {
2607
+ store: handle.path,
2608
+ ...reconciliation,
2609
+ disagreements,
2610
+ returned: disagreements.length,
2611
+ matched: reconciliation.disagreements.length,
2612
+ truncated: reconciliation.disagreements.length > disagreements.length,
2613
+ };
2614
+ }
2615
+
2616
+ /** `anno_evid_runs`'s dispatch arm (plan 43-06): `listObservedRuns()`'s own
2617
+ * answer, carried through UNCHANGED beside `store` -- its `denominator` is
2618
+ * reported exactly as that function computed it, never re-derived here. */
2619
+ function dispatchEvidRuns(handle: AnnoStoreHandle, args: unknown): unknown {
2620
+ void args; // this verb takes no argument beyond the universal `store`
2621
+ return { store: handle.path, ...listObservedRuns(handle) };
2622
+ }
2623
+
2624
+ /**
2625
+ * `anno_evid_reset`'s dispatch arm (EVID-05, plan 43-06): the store-side
2626
+ * half of a bracket reset, beside plan 43-03's emulator-side
2627
+ * `vice_memmap_zap`. Derives the run identity through `runIdentityFrom()`
2628
+ * from `evid-ingest.ts` -- the SAME single digest site `anno_evid_ingest`
2629
+ * uses -- never a second hashing site here, and never a caller-supplied
2630
+ * digest. `observationsRemoved` is read from a `listExecObservations()`
2631
+ * query taken BEFORE the delete, so the answer names exactly how many rows
2632
+ * this call removed rather than leaving a caller to infer it from `changed`
2633
+ * alone. `baseRevision` is threaded straight into
2634
+ * `deleteExecObservationsForRun()`, which enforces staleness itself through
2635
+ * `applyWrite()` -- the same "let the store's own write sequence check it"
2636
+ * discipline `dispatchEvidIngest()` above already uses, so there is no
2637
+ * second, redundant `assertNotStale()` call here.
2638
+ */
2639
+ function dispatchEvidReset(handle: AnnoStoreHandle, args: unknown): unknown {
2640
+ const bag = argBag(args);
2641
+ const baseRevision = assertBaseRevisionArg("anno_evid_reset", args);
2642
+ const identity = runIdentityFrom({
2643
+ imageSha256: bag.image_sha256 as string,
2644
+ argv: bag.argv as string[],
2645
+ seed: bag.seed as string,
2646
+ });
2647
+ const existing = listExecObservations(handle, {
2648
+ imageSha256: identity.imageSha256,
2649
+ argvDigest: identity.argvDigest,
2650
+ seed: identity.seed,
2651
+ });
2652
+ const written = deleteExecObservationsForRun(handle, {
2653
+ imageSha256: identity.imageSha256,
2654
+ argvDigest: identity.argvDigest,
2655
+ seed: identity.seed,
2656
+ baseRevision,
2657
+ });
2658
+ return {
2659
+ store: handle.path,
2660
+ revision: written.revision,
2661
+ changed: written.changed,
2662
+ observationsRemoved: existing.length,
2663
+ // `denominator` travels beside `observationsRemoved` for the same reason
2664
+ // it travels beside every other count this evidence layer reports
2665
+ // (EVID-04, plan 43-07's own structural guard): a bare count invites the
2666
+ // reading "the rest is data". The bracket this call reset held exactly
2667
+ // `existing.length` rows before the delete, so that is what
2668
+ // `observationsRemoved` is a fraction of -- a full reset makes the two
2669
+ // numbers equal, but the field is never omitted just because it agrees.
2670
+ denominator: existing.length,
2671
+ };
2672
+ }
2673
+
2674
+ // ---------------------------------------------------------------------------
2675
+ // The image loader (D-07). The store holds annotations and never bytes, so
2676
+ // every derived read names its own image and this function is the ONE place
2677
+ // that turns that name into bytes plus an origin.
2678
+ //
2679
+ // DISPATCH IS BY EXTENSION FIRST, NEVER BY BYTE LENGTH. The branch order below
2680
+ // was copied from the CLI's own bootstrap dispatch rather than re-derived; that
2681
+ // verb was removed on 2026-08-29 when the CLI narrowed to two (D-14), so THIS
2682
+ // is now the only implementation of the order and the citation that named the
2683
+ // CLI's line range is deliberately gone rather than left dangling. The
2684
+ // incident it encodes (WR-07): a 4096-byte flat `.raw` capture fell through to
2685
+ // the `.prg` parser, whose first two bytes become the load address, so a
2686
+ // truncated capture silently "bootstrapped" with an origin read backwards out
2687
+ // of its own payload bytes and exited zero -- every downstream address wrong,
2688
+ // no diagnostic. The extension check runs BEFORE any length check so
2689
+ // `flatImageOrigin()`'s own named refusal stays reachable for those two
2690
+ // extensions.
2691
+ // ---------------------------------------------------------------------------
2692
+
2693
+ interface LoadedImage {
2694
+ path: string;
2695
+ kind: "prg" | "flat";
2696
+ origin: number;
2697
+ body: Uint8Array;
2698
+ totalBytes: number;
2699
+ }
2700
+
2701
+ function loadImage(name: string, args: unknown): LoadedImage {
2702
+ const raw = assertImageArg(name, args);
2703
+ const path = resolveWorkspacePath(raw);
2704
+ if (!existsSync(path)) {
2705
+ throw new AnnoStorePathError(
2706
+ `${name} refused: no image exists at ${JSON.stringify(path)} -- a derived read names the bytes it derives from (D-07), ` +
2707
+ "and an image that is not there is a different fact from an image with nothing in it.",
2708
+ { path },
2709
+ );
2710
+ }
2711
+ const bytes = new Uint8Array(readFileSync(path));
2712
+ const ext = extname(path).toLowerCase();
2713
+ try {
2714
+ if (ext === ".raw" || ext === ".bin") {
2715
+ return { path, kind: "flat", origin: flatImageOrigin(bytes), body: bytes, totalBytes: bytes.length };
2716
+ }
2717
+ if (ext !== ".prg" && bytes.length === 65536) {
2718
+ return { path, kind: "flat", origin: flatImageOrigin(bytes), body: bytes, totalBytes: bytes.length };
2719
+ }
2720
+ const { origin, body } = parsePrg(bytes);
2721
+ return { path, kind: "prg", origin, body, totalBytes: bytes.length };
2722
+ } catch (err) {
2723
+ // `prg-image.ts` throws a bare `Error` by design -- it is a pure
2724
+ // byte-layout module with no error family of its own. Wrapped here so the
2725
+ // never-throw boundary can still name a class, and so the message carries
2726
+ // the caller's own vocabulary (the image path) rather than only the
2727
+ // internal function name.
2728
+ const reason = err instanceof Error ? err.message : String(err);
2729
+ throw new AnnoToolArgumentError(
2730
+ `${name} refused: ${JSON.stringify(path)} is not an image this surface can read (${reason}). Supply a .prg (a 2-byte ` +
2731
+ "little-endian load address plus a payload) or an exactly-65536-byte flat capture.",
2732
+ { toolName: name, argument: "image" },
2733
+ );
2734
+ }
2735
+ }
2736
+
2737
+ /** Shannon entropy of `bytes`, in bits per byte. Above roughly 7.5 the image is
2738
+ * very likely compressed or packed, and nothing in it will decode sensibly
2739
+ * until it is depacked -- which is why this is REPORTED rather than left for a
2740
+ * caller to wonder about after a disassembly comes back as noise. */
2741
+ function shannonEntropy(bytes: Uint8Array): number {
2742
+ if (bytes.length === 0) return 0;
2743
+ const histogram = new Uint32Array(256);
2744
+ for (const byte of bytes) histogram[byte] += 1;
2745
+ let entropy = 0;
2746
+ for (const count of histogram) {
2747
+ if (count === 0) continue;
2748
+ const p = count / bytes.length;
2749
+ entropy -= p * Math.log2(p);
2750
+ }
2751
+ return Math.round(entropy * 1000) / 1000;
2752
+ }
2753
+
2754
+ /** The slice of `image` covering the inclusive span, or `null` when the span
2755
+ * falls outside the bytes the image actually holds. `null` rather than a short
2756
+ * slice: a partial answer to a range question reads as a complete answer to a
2757
+ * smaller one.
2758
+ *
2759
+ * TOTAL OVER EVERY (start, end) PAIR, and that is three cases, not two. Below
2760
+ * the origin and past the last byte are the obvious two. The third is an
2761
+ * INVERTED span -- a resolved `from` past its own `to` -- which passes both
2762
+ * bound checks while covering no bytes at all, and which `subarray()` would
2763
+ * hand back as a zero-length success. That is the same failure as a short
2764
+ * slice wearing a smaller hat: answering a question about no bytes with an
2765
+ * empty result reads as a complete answer to a smaller question, which is the
2766
+ * very thing this `null` return exists against (CR-01). */
2767
+ function sliceSpan(image: LoadedImage, start: number, end: number): Uint8Array | null {
2768
+ const from = start - image.origin;
2769
+ const to = end - image.origin;
2770
+ if (from < 0 || to >= image.body.length || from > to) return null;
2771
+ return image.body.subarray(from, to + 1);
2772
+ }
2773
+
2774
+ /** The ONE refusal builder both read verbs report through. `anno_disassemble`
2775
+ * and `anno_read_region` each call `sliceSpan()` exactly once, over the span
2776
+ * their own answer would have reported -- the span the CALLER can see -- and
2777
+ * each reaches this builder from that one verdict. Their AGREEMENT is the
2778
+ * property CR-01 was reported against: the defect was `anno_disassemble`
2779
+ * narrowing the requested end down to the image's last address BEFORE slicing,
2780
+ * so an out-of-image start produced an empty slice instead of the `null` that
2781
+ * reaches here, and the caller got `instructions:0` with an `end_address`
2782
+ * numerically below the `address` asked about. Do not reintroduce a per-verb
2783
+ * narrowing: it makes the two verbs disagree about the same bytes. */
2784
+ function outsideImage(name: string, image: LoadedImage, start: number, end: number): Record<string, unknown> {
2785
+ const last = image.origin + image.body.length - 1;
2786
+ return {
2787
+ available: false,
2788
+ reason:
2789
+ `${name} was asked for $${start.toString(16).padStart(4, "0")}..$${end.toString(16).padStart(4, "0")}, which is not ` +
2790
+ `entirely inside the image: ${JSON.stringify(image.path)} loads at $${image.origin.toString(16).padStart(4, "0")} and ` +
2791
+ `ends at $${last.toString(16).padStart(4, "0")}. Reported as unanswerable rather than served as a short slice, because a ` +
2792
+ "partial answer to a range question reads as a complete answer to a smaller one. Narrow the range, or name the image that " +
2793
+ "actually covers those addresses.",
2794
+ };
2795
+ }
2796
+
2797
+ function hexdump(bytes: Uint8Array, start: number): string[] {
2798
+ const lines: string[] = [];
2799
+ for (let offset = 0; offset < bytes.length; offset += 16) {
2800
+ const chunk = bytes.subarray(offset, offset + 16);
2801
+ const hex = [...chunk].map((b) => b.toString(16).padStart(2, "0")).join(" ");
2802
+ lines.push(`$${(start + offset).toString(16).padStart(4, "0")} ${hex}`);
2803
+ }
2804
+ return lines;
2805
+ }
2806
+
2807
+ /** The SAME shape `registerKeyFor().slice(1)` produces (uppercase, exactly
2808
+ * four hex digits) -- `anno-export-asm.ts`'s own `REGISTER_ENUM_NAME_RE`
2809
+ * comment explains why an enum usage is only a CANDIDATE for the decoder
2810
+ * when its name has this shape, and why that check is not centralised: two
2811
+ * renderers, two small local copies of this one shape predicate, one shared
2812
+ * decoder. Kept in sync by inspection (both are one line) rather than by
2813
+ * import, per D-16's own "two renderers" design.
2814
+ *
2815
+ * SHAPE ALONE IS NOT ENOUGH (45-REVIEW CR-01, fixed 2026-09-11): the call
2816
+ * site below also requires `hasRegBitsEntry()` -- imported from
2817
+ * `anno-enum-gen.ts` above, the ONE shared membership predicate, NOT a third
2818
+ * local copy -- to confirm `anno-regbits.json` actually covers the register
2819
+ * before attempting the decoder at all. */
2820
+ const REGISTER_ENUM_NAME_RE = /^[0-9A-F]{4}$/;
2821
+
2822
+ /** `#$XX` -> `#<replacement>` on the ASSEMBLER-VISIBLE half of `line`, the
2823
+ * same confinement `anno-export-asm.ts`'s `substituteImmediateEnum()` uses
2824
+ * (never rewriting inside a trailing `;` comment, where a renderer's own
2825
+ * NOTE text could coincidentally contain the same hex digits). A rendered
2826
+ * line that does not carry the expected literal is a disagreement between
2827
+ * this function and `disasm-renderer.ts`, and it is refused rather than
2828
+ * silently left unchanged. */
2829
+ function substituteReadableImmediate(line: string, value: number, replacement: string, address: number): string {
2830
+ const literal = `#$${(value & 0xff).toString(16).padStart(2, "0")}`;
2831
+ const separatorIndex = line.indexOf(" ; ");
2832
+ const directiveHalf = separatorIndex >= 0 ? line.slice(0, separatorIndex) : line;
2833
+ const commentHalf = separatorIndex >= 0 ? line.slice(separatorIndex) : "";
2834
+ const at = directiveHalf.indexOf(literal);
2835
+ if (at < 0) {
2836
+ throw new AnnoStoreError(
2837
+ `anno_disassemble: the instruction at $${address.toString(16).padStart(4, "0")} carries an enum usage, but its rendered line does ` +
2838
+ `not contain the immediate literal ${literal} this renderer expected to replace. Refusing rather than emitting a line whose ` +
2839
+ "substitution silently did nothing.",
2840
+ );
2841
+ }
2842
+ return `${directiveHalf.slice(0, at)}#${replacement}${directiveHalf.slice(at + literal.length)}${commentHalf}`;
2843
+ }
2844
+
2845
+ /** Appends `comment` as a trailing `;`-comment on `line`, joining it with any
2846
+ * EXISTING trailing comment (a `disasm-renderer.ts` note, e.g. an NMOS
2847
+ * page-wrap warning) via `" | "` -- the SAME separator `formatNotesComment()`
2848
+ * already uses to join multiple notes on one instruction, so a line with
2849
+ * both a note and a decoded register comment reads as one vocabulary rather
2850
+ * than two different join styles on one line. */
2851
+ function appendReadableComment(line: string, comment: string): string {
2852
+ const separatorIndex = line.indexOf(" ; ");
2853
+ if (separatorIndex < 0) return `${line} ; ${comment}`;
2854
+ return `${line} | ${comment}`;
2855
+ }
2856
+
2857
+ /**
2858
+ * D-16's SECOND renderer (plan 45-05): the READABILITY half. `anno-export-
2859
+ * asm.ts` carries the proof (a real-ACME byte-diff oracle); this is what a
2860
+ * Claude session actually reads. Calls `decomposeRegisterValue()` -- the ONE
2861
+ * owning decoder -- for exactly the same reason: this function decodes
2862
+ * NOTHING itself.
2863
+ *
2864
+ * BYTE-IDENTICAL TO `render()`'S OWN OUTPUT when the store carries no enum
2865
+ * usage inside the decoded range at all (the fast-path return below), and
2866
+ * for every instruction `usageByAddress` does not cover even when it does --
2867
+ * D-16 widens what a bound instruction shows; it does not touch anything
2868
+ * else `render()` already produces.
2869
+ *
2870
+ * THE LINE-INDEX MAPPING THIS RELIES ON: `render(instructions, { origin })`
2871
+ * is called here WITHOUT `showSymbols`, so `resolveSymbol()` (`disasm-
2872
+ * renderer.ts`) always returns `undefined` and its own symbol-header loop
2873
+ * never emits a line -- the header is EXACTLY `"!cpu 6510"` then `"* =
2874
+ * $XXXX"`, two lines, and `instructions[i]` maps to `lines[HEADER_LINES +
2875
+ * i]` with no other possible offset. A future caller of this function that
2876
+ * ever passes `showSymbols: true` would break that mapping silently; this
2877
+ * function does not, and does not need to for the readability job D-16 gives
2878
+ * it.
2879
+ */
2880
+ function renderDisassembleListing(handle: AnnoStoreHandle, instructions: readonly Instruction[], origin: number): string {
2881
+ const baseListing = render(instructions as Instruction[], { origin });
2882
+
2883
+ const usageByAddress = new Map<number, EnumUsageRow>();
2884
+ for (const row of listEnumUsage(handle)) usageByAddress.set(row.address, row);
2885
+ if (usageByAddress.size === 0) return baseListing;
2886
+
2887
+ const enumsByName = new Map<string, ProjectEnumRow>();
2888
+ for (const row of listProjectEnums(handle)) enumsByName.set(row.name, row);
2889
+
2890
+ const HEADER_LINES = 2;
2891
+ const lines = baseListing.split("\n");
2892
+
2893
+ instructions.forEach((instr, index) => {
2894
+ const usage = usageByAddress.get(instr.address);
2895
+ if (usage === undefined) return;
2896
+
2897
+ // THE SAME REFUSAL SHAPE THE EXPORT BOUNDARY RAISES (`anno-export-
2898
+ // asm.ts`'s own enum-substitution block) for the same conditions, not a
2899
+ // silently plain listing for a store row this readable surface cannot
2900
+ // honour.
2901
+ const project = enumsByName.get(usage.enumName);
2902
+ if (project === undefined) {
2903
+ throw new AnnoStoreError(
2904
+ `anno_disassemble: the enum usage at $${instr.address.toString(16).padStart(4, "0")} names enum ${JSON.stringify(usage.enumName)}, ` +
2905
+ "which the store holds no definition for. Refusing to render a readable operand whose vocabulary is missing.",
2906
+ );
2907
+ }
2908
+ const role = instr.operand?.role;
2909
+ if (role !== "immediate" || !instr.acmeExpressible) {
2910
+ throw new AnnoStoreError(
2911
+ `anno_disassemble: the enum usage at $${instr.address.toString(16).padStart(4, "0")} names enum ${JSON.stringify(usage.enumName)}, but ` +
2912
+ "the instruction there is not an assembler-visible IMMEDIATE operand -- an enum renders on the immediate operand only. Refusing " +
2913
+ "rather than rendering a readable line with no substitution.",
2914
+ );
2915
+ }
2916
+
2917
+ // D-16: attempted ONLY when BOTH (45-REVIEW CR-01, fixed 2026-09-11) the
2918
+ // enum's name has the register-key shape -- see `REGISTER_ENUM_NAME_RE`'s
2919
+ // own comment for why a name that does not (e.g. a hand-authored
2920
+ // `viccolor`) is never a candidate -- AND `anno-regbits.json` actually
2921
+ // has a table entry for it (`hasRegBitsEntry()`). A register-shaped name
2922
+ // for a register the table does not cover (e.g. `D020`) is not a
2923
+ // decomposition failure; it falls through to the single-symbol shape
2924
+ // below with no decomposition attempted at all.
2925
+ let decomposition: RegisterDecomposition | undefined;
2926
+ if (REGISTER_ENUM_NAME_RE.test(usage.enumName) && hasRegBitsEntry(`$${usage.enumName}`)) {
2927
+ try {
2928
+ // `Number("0x...")`, never `parseInt()` -- this file's own guard
2929
+ // (anno-tools.test.ts) forbids a second, divergent numeric-parsing
2930
+ // rule beside the store's own. `usage.enumName` is already proven
2931
+ // to match REGISTER_ENUM_NAME_RE (four hex digits) above.
2932
+ decomposition = decomposeRegisterValue(Number(`0x${usage.enumName}`), instr.operand!.value);
2933
+ } catch (err) {
2934
+ throw new AnnoStoreError(
2935
+ `anno_disassemble: decomposing the enum usage at $${instr.address.toString(16).padStart(4, "0")} (enum ` +
2936
+ `${JSON.stringify(usage.enumName)}) against its bit-name table failed: ${err instanceof Error ? err.message : String(err)}`,
2937
+ );
2938
+ }
2939
+ }
2940
+
2941
+ const lineIndex = HEADER_LINES + index;
2942
+ const currentLine = lines[lineIndex]!;
2943
+
2944
+ if (decomposition !== undefined && decomposition.multiField) {
2945
+ // D-17: OR-ed named constants AND the decoded comment -- both, exactly
2946
+ // as the export renders them, so a Claude session reading this listing
2947
+ // sees what the export proves.
2948
+ const orExpression = decomposition.terms.map((term) => term.name).join(" | ");
2949
+ const substituted = substituteReadableImmediate(currentLine, instr.operand!.value, orExpression, instr.address);
2950
+ lines[lineIndex] = appendReadableComment(substituted, decomposition.comment);
2951
+ return;
2952
+ }
2953
+
2954
+ // THE EXISTING SINGLE-SYMBOL SHAPE (D-16: not replaced) -- a single-field
2955
+ // register, an enum usage whose name is not register-shaped at all, OR
2956
+ // (45-REVIEW CR-01) a register-shaped name for a register
2957
+ // `anno-regbits.json` has no entry for (e.g. `D020`).
2958
+ let matched: string | undefined;
2959
+ for (const [key, variantName] of Object.entries(project.variants)) {
2960
+ if (parseVariantKey(key) === instr.operand!.value) matched = variantName;
2961
+ }
2962
+ if (matched === undefined) {
2963
+ throw new AnnoStoreError(
2964
+ `anno_disassemble: enum ${JSON.stringify(usage.enumName)} is bound to the immediate operand at ` +
2965
+ `$${instr.address.toString(16).padStart(4, "0")}, whose value is $${(instr.operand!.value & 0xff).toString(16).padStart(2, "0")}, ` +
2966
+ "and the enum has no variant for that value.",
2967
+ );
2968
+ }
2969
+ lines[lineIndex] = substituteReadableImmediate(currentLine, instr.operand!.value, `${usage.enumName}_${matched}`, instr.address);
2970
+ });
2971
+
2972
+ return lines.join("\n");
2973
+ }
2974
+
2975
+ function dispatchDisassemble(handle: AnnoStoreHandle, args: unknown): unknown {
2976
+ const image = loadImage("anno_disassemble", args);
2977
+ const bag = argBag(args);
2978
+ const start = parseStoreAddress(bag.address, { what: "address" });
2979
+ const cap = currentReadRegionMaxBytes();
2980
+ const last = image.origin + image.body.length - 1;
2981
+ // An omitted end is the CAP, not the whole image: the default has to be the
2982
+ // bound, or the default is the hazard.
2983
+ const requestedEnd = bag.end_address !== undefined ? parseStoreAddress(bag.end_address, { what: "end_address" }) : Math.min(start + cap - 1, last);
2984
+ if (bag.end_address !== undefined) assertWithinRegionCap("anno_disassemble", start, requestedEnd, undefined);
2985
+ // Sliced on the span the CALLER named, never on one narrowed down to the
2986
+ // image's last address first. The narrowing used to happen here, and it is
2987
+ // what made this verb disagree with `anno_read_region` (CR-01) -- see
2988
+ // `outsideImage()`. Note what is NOT lost: an omitted `end_address` derives
2989
+ // `requestedEnd` from the image's own last address above, so it is inside
2990
+ // the image by construction and nothing a caller named is narrowed away.
2991
+ const slice = sliceSpan(image, start, requestedEnd);
2992
+ if (slice === null) return outsideImage("anno_disassemble", image, start, requestedEnd);
2993
+
2994
+ const instructions = decode(slice, start, { end: requestedEnd });
2995
+ return {
2996
+ image: image.path,
2997
+ origin: image.origin,
2998
+ address: start,
2999
+ end_address: requestedEnd,
3000
+ instructions: instructions.length,
3001
+ listing: renderDisassembleListing(handle, instructions, start),
3002
+ };
3003
+ }
3004
+
3005
+ /**
3006
+ * `anno_hazard_report`'s dispatch arm. Fetches EVERY input here -- the
3007
+ * byte-derived ranges, labels, comments, cross-references, execution
3008
+ * observations and the image bytes -- and hands them to `buildHazardReport()`
3009
+ * exactly once; the pure module itself never fetches any of it (see its own
3010
+ * header). The byte-derived ranges are mapped through `blocksFromStore()`,
3011
+ * reached by the SAME lazy `await import("./anno-cli.ts")`
3012
+ * `dispatchEvidDisagreements()` already uses above, so this file's own static
3013
+ * import graph -- and therefore the MCP server's startup cost -- stays
3014
+ * unchanged: `anno-cli.ts` drags in `anno-coverage.ts`, `anno-memmap-render.ts`
3015
+ * and `anno-export-asm.ts`, none of which this verb needs either. The mapping
3016
+ * itself is NOT re-implemented here, for the same reason `dispatchEvidDisagreements`
3017
+ * states for itself.
3018
+ */
3019
+ async function dispatchHazardReport(handle: AnnoStoreHandle, args: unknown): Promise<unknown> {
3020
+ const maxResults = assertOptionalMaxResults("anno_hazard_report", args);
3021
+ const image = loadImage("anno_hazard_report", args);
3022
+ const { blocksFromStore } = await import("./anno-cli.ts");
3023
+ const ranges = blocksFromStore(listRanges(handle));
3024
+ const symbols = listLabels(handle);
3025
+ const comments = listComments(handle);
3026
+ const xrefs = listXrefs(handle);
3027
+ const execObservations = listExecObservations(handle);
3028
+ const report = buildHazardReport({
3029
+ bytes: image.body,
3030
+ origin: image.origin,
3031
+ symbols,
3032
+ comments,
3033
+ ranges,
3034
+ xrefs,
3035
+ execObservations,
3036
+ });
3037
+ const findings = maxResults === undefined ? report.findings : report.findings.slice(0, maxResults);
3038
+ return {
3039
+ store: handle.path,
3040
+ image: image.path,
3041
+ ...report,
3042
+ findings,
3043
+ returned: findings.length,
3044
+ matched: report.findings.length,
3045
+ truncated: report.truncated || report.findings.length > findings.length,
3046
+ };
3047
+ }
3048
+
3049
+ function dispatchReadRegion(args: unknown): unknown {
3050
+ const image = loadImage("anno_read_region", args);
3051
+ const bag = argBag(args);
3052
+ const start = parseStoreAddress(bag.start_address, { what: "start_address" });
3053
+ const end = parseStoreAddress(bag.end_address, { what: "end_address" });
3054
+ const view = bag.view === "hexdump" ? "hexdump" : "disasm";
3055
+ const slice = sliceSpan(image, start, end);
3056
+ if (slice === null) return outsideImage("anno_read_region", image, start, end);
3057
+
3058
+ if (view === "hexdump") {
3059
+ return { image: image.path, origin: image.origin, start_address: start, end_address: end, view, bytes: slice.length, hexdump: hexdump(slice, start).join("\n") };
3060
+ }
3061
+ const instructions = decode(slice, start, { end });
3062
+ return {
3063
+ image: image.path,
3064
+ origin: image.origin,
3065
+ start_address: start,
3066
+ end_address: end,
3067
+ view,
3068
+ bytes: slice.length,
3069
+ instructions: instructions.length,
3070
+ listing: render(instructions, { origin: start }),
3071
+ };
3072
+ }
3073
+
3074
+ function dispatchBinaryInfo(args: unknown): unknown {
3075
+ const image = loadImage("anno_get_binary_info", args);
3076
+ const entropy = shannonEntropy(image.body);
3077
+ return {
3078
+ image: image.path,
3079
+ kind: image.kind,
3080
+ origin: image.origin,
3081
+ total_bytes: image.totalBytes,
3082
+ body_bytes: image.body.length,
3083
+ last_address: image.origin + image.body.length - 1,
3084
+ entropy,
3085
+ likely_packed: entropy > 7.5,
3086
+ };
3087
+ }
3088
+
3089
+ function dispatchCrossReferences(handle: AnnoStoreHandle, args: unknown): unknown {
3090
+ const image = loadImage("anno_get_cross_references", args);
3091
+ const maxResults = assertMaxResults("anno_get_cross_references", args);
3092
+ const bag = argBag(args);
3093
+ const union = crossReferencesTo(handle, image.body, image.origin, bag.address as number | string);
3094
+ const callers = union.callers.slice(0, maxResults);
3095
+ return {
3096
+ store: handle.path,
3097
+ image: image.path,
3098
+ to: union.to,
3099
+ callers,
3100
+ returned: callers.length,
3101
+ total: union.count,
3102
+ truncated: union.count > callers.length,
3103
+ };
3104
+ }
3105
+
3106
+ function dispatchSearch(handle: AnnoStoreHandle, args: unknown): unknown {
3107
+ const image = loadImage("anno_search", args);
3108
+ const bag = argBag(args);
3109
+ // THE CALLER'S OWN BAG IS PASSED THROUGH, not reconstructed from the three
3110
+ // keys this layer knows about. `searchAnnotations` detects a corpus this
3111
+ // surface does not have by scanning for `search_<name>` keys it does not
3112
+ // recognise, so rebuilding the request here would silently DROP exactly the
3113
+ // signal the unanswerable-corpus report depends on -- and the caller would
3114
+ // get a clean, plausible, wrong hit list for a corpus that was never
3115
+ // searched. `query` and `max_results` are re-stated last so the validated
3116
+ // values win over whatever shape arrived.
3117
+ const result = searchAnnotations(handle, image.body, image.origin, {
3118
+ ...bag,
3119
+ query: bag.query as string,
3120
+ max_results: assertMaxResults("anno_search", args),
3121
+ });
3122
+
3123
+ const unanswerable = Object.keys(result.unavailable);
3124
+ if (unanswerable.length > 0) {
3125
+ // THE WHOLE CALL IS ANSWERED AS UNANSWERABLE, not served as a partial
3126
+ // result set with a footnote. The request named a corpus this surface does
3127
+ // not have, so any hit list returned beside that would look like the
3128
+ // complete answer to the question actually asked -- which is the
3129
+ // plausible-looking zero this shape exists against. `isError` stays FALSE:
3130
+ // the request was well-formed and the answer is "no".
3131
+ return {
3132
+ available: false,
3133
+ reason: unanswerable.map((corpus) => result.unavailable[corpus]!.reason).join(" "),
3134
+ unanswerable_corpora: unanswerable,
3135
+ corpora: result.corpora,
3136
+ };
3137
+ }
3138
+ return { store: handle.path, image: image.path, ...result };
3139
+ }
3140
+
3141
+ function dispatchAddressDetails(handle: AnnoStoreHandle, args: unknown): unknown {
3142
+ const image = loadImage("anno_get_address_details", args);
3143
+ const bag = argBag(args);
3144
+ return { store: handle.path, image: image.path, ...composeAddressDetails(handle, image.body, image.origin, bag.address as number | string) };
3145
+ }
3146
+
3147
+
3148
+ /** PHASE TWO. Runs every entry against the ONE already-open handle, to
3149
+ * COMPLETION, pushing a per-entry status and never aborting on the first
3150
+ * failure. Pre-validation has already refused every batch that should not have
3151
+ * been sent, so a failure here is genuinely about one call rather than about
3152
+ * the payload. */
3153
+ async function dispatchBatchExecute(handle: AnnoStoreHandle, args: unknown): Promise<unknown> {
3154
+ const bag = argBag(args);
3155
+ const calls = bag.calls as Record<string, unknown>[];
3156
+ const results: Record<string, unknown>[] = [];
3157
+ for (const [index, call] of calls.entries()) {
3158
+ const name = call.name as string;
3159
+ const innerArgs = batchArgumentsFor(bag, call);
3160
+ try {
3161
+ const value = name === "anno_batch_execute" ? await dispatchBatchExecute(handle, innerArgs) : await dispatch(name, innerArgs, handle);
3162
+ results.push({ index, name, status: "success", result: value });
3163
+ } catch (err) {
3164
+ // NAMED BY CLASS, exactly as the outer boundary names it, so a per-item
3165
+ // failure is as diagnosable as a whole-call one.
3166
+ const errName = err instanceof Error ? err.name : "Error";
3167
+ const errMessage = err instanceof Error ? err.message : String(err);
3168
+ results.push({ index, name, status: "error", error: `[${errName}] ${errMessage}` });
3169
+ }
3170
+ }
3171
+ const failed = results.filter((entry) => entry.status === "error").length;
3172
+ return {
3173
+ store: handle.path,
3174
+ results,
3175
+ executed: results.length,
3176
+ succeeded: results.length - failed,
3177
+ failed,
3178
+ note:
3179
+ "Every entry ran: this loop does not abort on the first failure, so an error entry here means THAT CALL did not work, " +
3180
+ "not that the batch should not have been sent. A batch that should not have been sent is refused WHOLE before anything " +
3181
+ "is opened, and arrives as isError:true instead of as a per-item status.",
3182
+ };
3183
+ }
3184
+
3185
+ async function dispatch(name: string, args: unknown, handle: AnnoStoreHandle): Promise<unknown> {
3186
+ if (name === "anno_get_symbols") return dispatchGetSymbols(handle, args);
3187
+ if (name === "anno_set_label_name") return dispatchSetLabelName(handle, args);
3188
+ if (name === "anno_set_comment") return dispatchSetComment(handle, args);
3189
+ if (name === "anno_set_data_type") return dispatchSetDataType(handle, args);
3190
+ if (name === "anno_add_scope" || name === "anno_remove_scope") return dispatchScope(name, handle, args);
3191
+ if (name === "anno_exclude_range" || name === "anno_include_range") return dispatchExcludedRange(name, handle, args);
3192
+ if (name === "anno_get_comments") return dispatchGetComments(handle, args);
3193
+ if (name === "anno_get_blocks") return dispatchGetBlocks(handle, args);
3194
+ if (name === "anno_create_project_enum") return dispatchCreateProjectEnum(handle, args);
3195
+ if (name === "anno_update_project_enum") return dispatchUpdateProjectEnum(handle, args);
3196
+ if (name === "anno_apply_enum_usage") return dispatchApplyEnumUsage(handle, args);
3197
+ if (name === "anno_save_project") return dispatchSaveProject(handle);
3198
+ if (name === "anno_import_ghidra_export") return dispatchImportGhidraExport(handle, args);
3199
+ if (name === "anno_join_memmap") return dispatchJoinMemmap(handle, args);
3200
+ if (name === "anno_evid_ingest") return dispatchEvidIngest(handle, args);
3201
+ if (name === "anno_evid_disagreements") return dispatchEvidDisagreements(handle, args);
3202
+ if (name === "anno_evid_runs") return dispatchEvidRuns(handle, args);
3203
+ if (name === "anno_evid_reset") return dispatchEvidReset(handle, args);
3204
+ if (name === "anno_disassemble") return dispatchDisassemble(handle, args);
3205
+ if (name === "anno_hazard_report") return dispatchHazardReport(handle, args);
3206
+ if (name === "anno_read_region") return dispatchReadRegion(args);
3207
+ if (name === "anno_get_binary_info") return dispatchBinaryInfo(args);
3208
+ if (name === "anno_get_cross_references") return dispatchCrossReferences(handle, args);
3209
+ if (name === "anno_search") return dispatchSearch(handle, args);
3210
+ if (name === "anno_get_address_details") return dispatchAddressDetails(handle, args);
3211
+ if (name === "anno_batch_execute") return dispatchBatchExecute(handle, args);
3212
+ // Unreachable: `assertAnnoTool()` above has already refused every name
3213
+ // outside `CURATED_ANNO_TOOLS`, and every curated name has an arm here. It
3214
+ // refuses BY NAME anyway rather than returning a plausible-looking empty
3215
+ // answer -- a curated name with no dispatch arm is a bug in this file, and
3216
+ // saying so is cheaper than a silent `{}` somebody has to trace back.
3217
+ throw new AnnoUncuratedToolError(
3218
+ `"${name}" is curated but has no dispatch arm in anno-tools.ts. Resolution routes: implement it and ` +
3219
+ "add it to ANNO_TOOL_DEFINITIONS with a named criterion, or remove the caller reference.",
3220
+ { toolName: name },
3221
+ );
3222
+ }
3223
+
3224
+ /**
3225
+ * Runs one curated `anno_*` tool call. THE NEVER-THROW BOUNDARY: every failure
3226
+ * -- an uncurated name, a malformed argument, a path outside the workspace, a
3227
+ * corrupt store, a bug in a dispatcher -- resolves as `{isError:true}` text
3228
+ * naming the error CLASS. Nothing rejects the returned promise.
3229
+ *
3230
+ * `assertAnnoTool` is INSIDE the `try`, deliberately and unlike
3231
+ * `anno-tools.ts`'s `runAnnoTool`, whose gate sits outside it so a refusal
3232
+ * REJECTS instead of resolving. That asymmetry is WR-02, recorded as out of
3233
+ * scope at `anno-tools.ts:772-774`; it is closed here.
3234
+ *
3235
+ * NO VERB EVER CREATES THE STORE IT WAS ASKED TO USE (D-06): "the annotations
3236
+ * are gone" and "there are no annotations" must not read the same. A read-only
3237
+ * verb gets that through `openStore`'s own `mustExist`; a writing verb gets it
3238
+ * through `assertStorePresent()` plus the inode guard above, because
3239
+ * `mustExist` also forces a read-only connection and there is no third state to
3240
+ * ask for. `closeStore` runs in a `finally`, so the handle is released on the
3241
+ * throwing path exactly as on the succeeding one (T-29-03).
3242
+ */
3243
+ export async function runAnnoTool(name: string, args: unknown): Promise<ToolCallResult> {
3244
+ try {
3245
+ assertAnnoTool(name, args);
3246
+ const storePath = resolveStoreArg(name, args);
3247
+ const inodeBefore = assertStorePresent(name, storePath);
3248
+ const handle = openStore(storePath, { workspaceRoot: repoRoot(), mustExist: READ_ONLY_ANNO_VERBS.includes(name) });
3249
+ try {
3250
+ assertSameFile(name, storePath, inodeBefore);
3251
+ return okText(JSON.stringify(await dispatch(name, args, handle)));
3252
+ } finally {
3253
+ closeStore(handle);
3254
+ }
3255
+ } catch (err) {
3256
+ // Named by class (D18-12: a mid-window failure must surface a named,
3257
+ // distinguishable error, never a silent success) -- a caller can tell
3258
+ // AnnoStoreCorruptError apart from AnnoStorePathError etc. from this text
3259
+ // alone, without re-parsing loose message wording.
3260
+ const errName = err instanceof Error ? err.name : "Error";
3261
+ const errMessage = err instanceof Error ? err.message : String(err);
3262
+ return errText(`${name} failed: [${errName}] ${errMessage}`);
3263
+ }
3264
+ }