@henols/vice-mcp 0.2.2 → 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 (77) hide show
  1. package/README.md +2 -2
  2. package/THIRD-PARTY-NOTICES.md +422 -1
  3. package/anno-bank.ts +171 -0
  4. package/anno-cli.ts +1674 -99
  5. package/anno-enum-gen.ts +416 -30
  6. package/anno-export-asm.ts +1175 -89
  7. package/anno-graphics.ts +338 -0
  8. package/anno-hazard-report.ts +1367 -0
  9. package/anno-import.ts +495 -0
  10. package/anno-join.ts +480 -0
  11. package/anno-provenance-ledger.ts +472 -0
  12. package/anno-register.ts +159 -0
  13. package/anno-store-export.ts +661 -0
  14. package/anno-store.ts +518 -2
  15. package/anno-tools.ts +1169 -16
  16. package/anno-types.ts +275 -2
  17. package/backend-detect.mts +124 -312
  18. package/build.ts +3 -1
  19. package/capture-predicate.ts +597 -0
  20. package/channel-lock.ts +349 -0
  21. package/evid-ingest.ts +217 -0
  22. package/evid-reconcile.ts +316 -0
  23. package/host-tool-client.ts +430 -0
  24. package/incident-record.ts +23 -12
  25. package/install-resources.ts +29 -13
  26. package/memmap-lookup.ts +285 -0
  27. package/package.json +27 -8
  28. package/prg-image.ts +1 -2
  29. package/repo-root.ts +87 -3
  30. package/resources/backend-detect.mjs +98 -236
  31. package/resources/broker-control.mjs +189 -16
  32. package/resources/broker-epoch.mjs +1 -1
  33. package/resources/broker-kill.mjs +8 -2
  34. package/resources/broker-launch.mjs +365 -210
  35. package/resources/broker-state.mjs +64 -18
  36. package/resources/container-guard.mjs +1 -1
  37. package/resources/ghidra-project.mjs +790 -0
  38. package/resources/host-tool.mjs +2561 -0
  39. package/resources/vice-broker.mjs +330 -184
  40. package/resources/vice-launcher.sh +127 -9
  41. package/stock-address.ts +1 -1
  42. package/stock-condition.ts +1 -1
  43. package/stock-connect.ts +9 -5
  44. package/stock-derived.ts +29 -37
  45. package/stock-diagnose.ts +200 -36
  46. package/stock-dispatch.ts +179 -77
  47. package/stock-handler.ts +1 -1
  48. package/stock-paths.ts +18 -14
  49. package/stock-petscii.ts +1 -1
  50. package/stock-protocol.ts +1 -1
  51. package/stock-recycle.ts +83 -2
  52. package/stock-reproducible-run.ts +811 -0
  53. package/stock-run-until.ts +100 -1
  54. package/stock-symbols.ts +4 -4
  55. package/stock-timing.ts +1 -1
  56. package/stop-oracle.ts +167 -0
  57. package/text-capability-probe.ts +660 -0
  58. package/text-connect.ts +157 -0
  59. package/text-protocol.ts +810 -0
  60. package/text-tools.ts +778 -0
  61. package/textmon-backtrace.ts +385 -0
  62. package/textmon-cpuhistory.ts +335 -0
  63. package/textmon-memmap.ts +494 -0
  64. package/textmon-profile.ts +458 -0
  65. package/textmon-registers.ts +748 -0
  66. package/tools-manifest.stock.json +864 -3
  67. package/vice-broker-client.ts +189 -42
  68. package/vice-errors.ts +268 -0
  69. package/vice-proxy.ts +339 -2144
  70. package/vsf-slice.ts +640 -0
  71. package/anno-d64.ts +0 -310
  72. package/capability-registry.ts +0 -390
  73. package/refresh-manifest.ts +0 -124
  74. package/tools-manifest.json +0 -1223
  75. package/vice-probe.ts +0 -278
  76. package/vice-sync.ts +0 -336
  77. package/vice.ts +0 -772
package/anno-tools.ts CHANGED
@@ -77,9 +77,10 @@
77
77
  //
78
78
  // `anno_batch_execute` IS THE ONE SANCTIONED NESTED-ARGUMENT VERB ON THIS
79
79
  // SURFACE, AND NO SECOND MAY JOIN IT. A meta-tool that takes an arbitrary tool
80
- // name inside its own arguments is precisely the smuggling shape `vice.ts`'s
81
- // `DENY_LIST` exists to close: the outer name passes the gate while the inner
82
- // name never sees it. This one verb earns the exception by being the only
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
83
84
  // route to the multi-edit pass an annotation run actually performs, and it
84
85
  // pays for it with `assertAnnoBatch()` below -- a recursive, DEPTH-CAPPED
85
86
  // pre-validator that refuses the WHOLE batch, before any store is opened, if
@@ -134,19 +135,28 @@ import { existsSync, readFileSync, statSync } from "node:fs";
134
135
  import { extname } from "node:path";
135
136
 
136
137
  import {
138
+ addExcludedRange,
137
139
  addScope,
138
140
  applyEnumUsage,
141
+ applyWrite,
139
142
  clearEnumUsage,
140
143
  closeStore,
141
144
  createProjectEnum,
142
145
  currentRevision,
146
+ deleteExecObservationsForRun,
147
+ insertExecObservations,
143
148
  listComments,
144
149
  listEnumUsage,
150
+ listExcludedRanges,
151
+ listExecObservations,
145
152
  listLabels,
153
+ listObservedRuns,
146
154
  listProjectEnums,
147
155
  listRanges,
148
156
  listScopes,
157
+ listXrefs,
149
158
  openStore,
159
+ removeExcludedRange,
150
160
  removeScope,
151
161
  setComment,
152
162
  setDataType,
@@ -158,6 +168,7 @@ import {
158
168
  AnnoRevisionArgumentError,
159
169
  AnnoStoreError,
160
170
  AnnoStorePathError,
171
+ AnnoStoreStaleRevisionError,
161
172
  assertCommentText,
162
173
  assertCommentType,
163
174
  assertDataType,
@@ -166,13 +177,39 @@ import {
166
177
  assertLegalLabel,
167
178
  assertRangeShape,
168
179
  parseStoreAddress,
180
+ parseVariantKey,
169
181
  storePathWithinWorkspace,
170
182
  } from "./anno-types.ts";
171
- import type { AnnoStoreErrorOptions, CommentRow, LabelRow } from "./anno-types.ts";
183
+ import type { AnnoStoreErrorOptions, CommentRow, EnumUsageRow, LabelRow, ProjectEnumRow } from "./anno-types.ts";
172
184
  import { crossReferencesTo, searchAnnotations } from "./anno-derive.ts";
173
185
  import { composeAddressDetails } from "./anno-details.ts";
174
186
  import { decode } from "./disasm-decoder.ts";
187
+ import type { Instruction } from "./disasm-decoder.ts";
175
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";
176
213
  import { flatImageOrigin, parsePrg } from "./prg-image.ts";
177
214
  import { repoRoot } from "./repo-root.ts";
178
215
 
@@ -322,6 +359,22 @@ function assertMaxResults(name: string, args: unknown, batchIndex?: number): num
322
359
  return raw as number;
323
360
  }
324
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
+
325
378
  /** Requires a present argument and hands it to `parseStoreAddress` -- the ONE
326
379
  * address parser, which owns the `$`/`0x` forms and the deliberate refusal of
327
380
  * an unprefixed numeric string. Absence is a DIFFERENT fact from malformity,
@@ -587,6 +640,53 @@ export const ANNO_TOOL_DEFINITIONS: readonly AnnoToolDefinition[] = [
587
640
  required: ["store", "start_address", "end_address"],
588
641
  },
589
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
+ },
590
690
  {
591
691
  name: "anno_get_symbols",
592
692
  description:
@@ -765,7 +865,11 @@ export const ANNO_TOOL_DEFINITIONS: readonly AnnoToolDefinition[] = [
765
865
  "fail to reassemble. The extent is bounded by the SAME byte cap that governs anno_read_region -- one cap, both " +
766
866
  "views, so there is no per-view rule to get subtly wrong -- and defaults to that cap when end_address is " +
767
867
  "omitted. A wider range is REFUSED by name with the cap and the requested width in the message, never " +
768
- "silently truncated.",
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.",
769
873
  inputSchema: {
770
874
  type: "object",
771
875
  properties: {
@@ -936,6 +1040,283 @@ export const ANNO_TOOL_DEFINITIONS: readonly AnnoToolDefinition[] = [
936
1040
  required: ["store", "calls"],
937
1041
  },
938
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
+ },
939
1320
  ];
940
1321
 
941
1322
  /** The allow-list, DERIVED from the definitions above rather than hand-typed
@@ -1005,6 +1386,33 @@ function assertScopeArgs(name: string, args: unknown, batchIndex?: number): void
1005
1386
  assertBaseRevisionArg(name, args, batchIndex);
1006
1387
  }
1007
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
+
1008
1416
  function assertGetCommentsArgs(args: unknown, batchIndex?: number): void {
1009
1417
  assertStoreArg("anno_get_comments", args, batchIndex);
1010
1418
  assertMaxResults("anno_get_comments", args, batchIndex);
@@ -1097,6 +1505,222 @@ function assertSaveProjectArgs(args: unknown, batchIndex?: number): void {
1097
1505
  assertStoreArg("anno_save_project", args, batchIndex);
1098
1506
  }
1099
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
+ }
1100
1724
 
1101
1725
  // ---------------------------------------------------------------------------
1102
1726
  // THE ONE SIZE CAP, GOVERNING BOTH VIEWS (T-29-25).
@@ -1109,12 +1733,17 @@ function assertSaveProjectArgs(args: unknown, batchIndex?: number): void {
1109
1733
  // the disassembly view at the cap is the worst case, since the hexdump view of
1110
1734
  // the same byte count renders far less text.
1111
1735
  //
1112
- // THE CAP IS THE ONLY BOUND THERE IS FOR THIS FAMILY. `vice-proxy.ts`'s
1113
- // `wrapPossiblyChunked()` splits an oversized answer across a continuation
1114
- // sequence, but `buildViceTool()` calls `run` DIRECTLY, so nothing on this
1115
- // surface is chunked; and the client's own inline-response ceiling was measured
1116
- // at 40-60 KB, far below the proxy's 500,000-character output cap. That is why
1117
- // the second mitigation -- `max_results` REQUIRED with no default on every
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
1118
1747
  // list-returning verb, with the true total returned beside the truncated list
1119
1748
  // -- is not optional either.
1120
1749
  // ---------------------------------------------------------------------------
@@ -1399,12 +2028,21 @@ function assertVerbArgs(name: string, args: unknown, batchIndex?: number): void
1399
2028
  if (name === "anno_set_data_type") return assertSetDataTypeArgs(args, batchIndex);
1400
2029
  if (name === "anno_add_scope") return assertScopeArgs("anno_add_scope", args, batchIndex);
1401
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);
1402
2033
  if (name === "anno_get_comments") return assertGetCommentsArgs(args, batchIndex);
1403
2034
  if (name === "anno_get_blocks") return assertGetBlocksArgs(args, batchIndex);
1404
2035
  if (name === "anno_create_project_enum") return assertCreateEnumArgs(args, batchIndex);
1405
2036
  if (name === "anno_update_project_enum") return assertUpdateEnumArgs(args, batchIndex);
1406
2037
  if (name === "anno_apply_enum_usage") return assertApplyEnumUsageArgs(args, batchIndex);
1407
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);
1408
2046
  if (name === "anno_disassemble") return assertDisassembleArgs(args, batchIndex);
1409
2047
  if (name === "anno_read_region") return assertReadRegionArgs(args, batchIndex);
1410
2048
  if (name === "anno_get_binary_info") return assertBinaryInfoArgs(args, batchIndex);
@@ -1424,7 +2062,7 @@ function assertVerbArgs(name: string, args: unknown, batchIndex?: number): void
1424
2062
 
1425
2063
  /**
1426
2064
  * The allow-list gate. Its body's FIRST check is set membership (see WHAT NOT
1427
- * TO DO above, and `vice.ts`'s `DENY_LIST` precedent inverted into an
2065
+ * TO DO above, and the same confused-deputy precedent inverted into an
1428
2066
  * allow-list): a `name` outside `CURATED_ANNO_TOOLS` is refused outright,
1429
2067
  * before any argument is inspected, so an unknown verb can never reach a
1430
2068
  * validator that might coincidentally accept its payload. Only then are the
@@ -1493,7 +2131,7 @@ function resolveStoreArg(name: string, args: unknown): string {
1493
2131
  * takes the existence-check-plus-inode-guard route below. Derived from nothing
1494
2132
  * -- it is a hand-listed property of each verb, and a verb missing from here is
1495
2133
  * merely opened writably, never wrongly refused. */
1496
- const READ_ONLY_ANNO_VERBS: readonly string[] = Object.freeze([
2134
+ export const READ_ONLY_ANNO_VERBS: readonly string[] = Object.freeze([
1497
2135
  "anno_get_symbols",
1498
2136
  "anno_get_comments",
1499
2137
  "anno_get_blocks",
@@ -1504,6 +2142,9 @@ const READ_ONLY_ANNO_VERBS: readonly string[] = Object.freeze([
1504
2142
  "anno_get_cross_references",
1505
2143
  "anno_search",
1506
2144
  "anno_get_address_details",
2145
+ "anno_evid_disagreements",
2146
+ "anno_evid_runs",
2147
+ "anno_hazard_report",
1507
2148
  ]);
1508
2149
 
1509
2150
  /** Refuses an absent store BY NAME, returning the inode the later guard
@@ -1627,6 +2268,31 @@ function dispatchScope(name: string, handle: AnnoStoreHandle, args: unknown): un
1627
2268
  };
1628
2269
  }
1629
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
+
1630
2296
  function dispatchGetComments(handle: AnnoStoreHandle, args: unknown): unknown {
1631
2297
  const maxResults = assertMaxResults("anno_get_comments", args);
1632
2298
  const bag = argBag(args);
@@ -1737,6 +2403,273 @@ function dispatchSaveProject(handle: AnnoStoreHandle): unknown {
1737
2403
  };
1738
2404
  }
1739
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
+ }
1740
2673
 
1741
2674
  // ---------------------------------------------------------------------------
1742
2675
  // The image loader (D-07). The store holds annotations and never bytes, so
@@ -1871,7 +2804,175 @@ function hexdump(bytes: Uint8Array, start: number): string[] {
1871
2804
  return lines;
1872
2805
  }
1873
2806
 
1874
- function dispatchDisassemble(args: unknown): unknown {
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 {
1875
2976
  const image = loadImage("anno_disassemble", args);
1876
2977
  const bag = argBag(args);
1877
2978
  const start = parseStoreAddress(bag.address, { what: "address" });
@@ -1897,7 +2998,51 @@ function dispatchDisassemble(args: unknown): unknown {
1897
2998
  address: start,
1898
2999
  end_address: requestedEnd,
1899
3000
  instructions: instructions.length,
1900
- listing: render(instructions, { origin: start }),
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,
1901
3046
  };
1902
3047
  }
1903
3048
 
@@ -2043,13 +3188,21 @@ async function dispatch(name: string, args: unknown, handle: AnnoStoreHandle): P
2043
3188
  if (name === "anno_set_comment") return dispatchSetComment(handle, args);
2044
3189
  if (name === "anno_set_data_type") return dispatchSetDataType(handle, args);
2045
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);
2046
3192
  if (name === "anno_get_comments") return dispatchGetComments(handle, args);
2047
3193
  if (name === "anno_get_blocks") return dispatchGetBlocks(handle, args);
2048
3194
  if (name === "anno_create_project_enum") return dispatchCreateProjectEnum(handle, args);
2049
3195
  if (name === "anno_update_project_enum") return dispatchUpdateProjectEnum(handle, args);
2050
3196
  if (name === "anno_apply_enum_usage") return dispatchApplyEnumUsage(handle, args);
2051
3197
  if (name === "anno_save_project") return dispatchSaveProject(handle);
2052
- if (name === "anno_disassemble") return dispatchDisassemble(args);
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);
2053
3206
  if (name === "anno_read_region") return dispatchReadRegion(args);
2054
3207
  if (name === "anno_get_binary_info") return dispatchBinaryInfo(args);
2055
3208
  if (name === "anno_get_cross_references") return dispatchCrossReferences(handle, args);