@henols/c64-re-tools 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 (46) hide show
  1. package/README.md +1 -1
  2. package/THIRD-PARTY-NOTICES.md +26 -0
  3. package/bin/cli.mjs +18 -7
  4. package/package.json +6 -4
  5. package/skills/acme-build/SKILL.md +83 -33
  6. package/skills/acme-build/scripts/acme.mjs +159 -64
  7. package/skills/acme-build/template.a +1 -1
  8. package/skills/c64-disk-access/SKILL.md +156 -0
  9. package/skills/c64-disk-access/scripts/c1541.mjs +569 -0
  10. package/skills/c64-memory-mapping/SKILL.md +419 -23
  11. package/skills/c64-memory-mapping/scripts/driver.mjs +1 -1
  12. package/skills/c64-petcat/SKILL.md +87 -0
  13. package/skills/c64-petcat/scripts/petcat.mjs +221 -0
  14. package/skills/c64-program-recon/SKILL.md +497 -92
  15. package/skills/c64-program-recon/references/control-flow.md +12 -15
  16. package/skills/c64-program-recon/references/graphics.md +1 -1
  17. package/skills/c64-program-recon/references/observation-hazards.md +18 -16
  18. package/skills/c64-program-recon/references/reconstruction.md +11 -6
  19. package/skills/c64-program-recon/references/sound-and-input.md +6 -8
  20. package/skills/c64-program-recon/references/tool-selection.md +37 -18
  21. package/skills/c64-program-recon/scripts/packer-finding.mjs +709 -0
  22. package/skills/c64-program-recon/templates/memory-map.template.md +27 -13
  23. package/skills/c64-provenance-diff/SKILL.md +43 -8
  24. package/skills/c64-provenance-diff/scripts/diff-images.mjs +9 -6
  25. package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +20 -8
  26. package/skills/c64-ram-capture/RELEASES.json.example +17 -0
  27. package/skills/c64-ram-capture/SKILL.md +147 -46
  28. package/skills/c64-ram-capture/scripts/compare.mjs +2 -2
  29. package/skills/c64-ram-capture/scripts/derive-transients.mjs +575 -0
  30. package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +3 -3
  31. package/skills/c64-ram-capture/scripts/mcp-module.mjs +174 -0
  32. package/skills/c64-ram-capture/scripts/project-paths.mjs +1 -1
  33. package/skills/c64-ram-capture/scripts/releases.mjs +1 -1
  34. package/skills/c64-ram-capture/scripts/vsf-slice.mjs +147 -0
  35. package/skills/c64-ram-capture/scripts/watch-loads.mjs +19 -13
  36. package/skills/c64-ram-capture/templates/capture-record.template.md +44 -4
  37. package/skills/c64-ram-capture/transients/README.md +136 -0
  38. package/skills/routine-queue-walker/SKILL.md +365 -0
  39. package/skills/routine-queue-walker/scripts/completeness-report.mjs +463 -0
  40. package/skills/vice-wedge-triage/SKILL.md +104 -97
  41. package/skills/c64-provenance-diff/scripts/diff-images.test.mjs +0 -665
  42. package/skills/c64-ram-capture/scripts/d64-parse.mjs +0 -243
  43. package/skills/c64-ram-capture/scripts/d64-parse.test.mjs +0 -243
  44. package/skills/c64-ram-capture/scripts/dump-artifacts.test.mjs +0 -133
  45. package/skills/c64-ram-capture/scripts/test-corpus.mjs +0 -75
  46. package/skills/c64-ram-capture/scripts/watch-loads.test.mjs +0 -339
@@ -0,0 +1,709 @@
1
+ #!/usr/bin/env node
2
+ // packer-finding.mjs -- the ONE place this project answers "which packer was
3
+ // used on this binary", as a project-owned recon finding with an ordered
4
+ // oracle chain and a hard, reasoned unknown.
5
+ //
6
+ // ---------------------------------------------------------------------------
7
+ // WHY THIS FILE EXISTS
8
+ // ---------------------------------------------------------------------------
9
+ // DATED PROVENANCE (2026-08-29): the retired static analyser this project once
10
+ // rented -- the external analyser, pinned at 0.9.20 -- computed packer identity on
11
+ // EVERY load and then threw it away before it reached any machine-readable
12
+ // surface. That analyser is GONE from this repository; nothing below calls it,
13
+ // and this paragraph is history in the past tense, not a route. It is kept
14
+ // because it is the whole reason this file exists: without it, the obvious
15
+ // next step is to go looking for the packer-name call that was already proven
16
+ // not to be there. At the pin, that absence was established four independent
17
+ // ways, each read at the pin:
18
+ //
19
+ // (a) the curated binary-info tool emits a fixed seven-field object
20
+ // (origin, size, system, filename, description, an illegal-opcode hint
21
+ // and entropy) built from a static literal -- a packed input cannot
22
+ // change the field set;
23
+ // (b) the unpack result carries no name field at all: it returns data,
24
+ // start/end addresses, an entry point, a dependency address and an
25
+ // instruction count, and the one place the detected packer is consulted
26
+ // applies a memory patch and feeds a progress callback the MCP handler
27
+ // passes nothing to;
28
+ // (c) the loaded-project value that DOES hold a detected packer name is an
29
+ // in-memory return value only -- it is absent from the serialised
30
+ // project shape, so saving and re-reading the project file yields
31
+ // nothing;
32
+ // (d) every consumer of the name lives in the terminal-UI crate. There are
33
+ // zero non-UI, non-internal consumers, and the command line has no flag
34
+ // that reports file info.
35
+ //
36
+ // So there was no read-only route to a packer name in that analyser, and there
37
+ // is none on the surface that replaced it either: the annotation store holds
38
+ // annotations, the derived reads decode instructions, and neither answers
39
+ // "which packer". The two routes that would produce a name -- copying that
40
+ // upstream project's signature table (or transcribing its bytes into search
41
+ // patterns), and inventing a tool name on this project's own surface that no
42
+ // verb actually implements -- are both refused: the first is the copy this
43
+ // milestone exists to avoid, and the second would be a fabricated tool that
44
+ // this repository's own honesty gates would then treat as legitimate. What is
45
+ // left, and what this file is, is a project-owned finding with an EXTERNAL
46
+ // oracle and an explicit unknown.
47
+ //
48
+ // ---------------------------------------------------------------------------
49
+ // WHAT THIS IS THE ONE AUTHORITATIVE PLACE FOR
50
+ // ---------------------------------------------------------------------------
51
+ // - the ordered oracle chain (external identifier, then a packedness-only
52
+ // entropy gate, then an explicit unknown) and its first-hit-wins rule;
53
+ // - the finding's response shape and its four-verdict vocabulary
54
+ // (`PACKER_VERDICTS`);
55
+ // - the external identifier's probe (`probeUnp64`) and the defensive parser
56
+ // for its standard output (`parseUnp64Stdout`).
57
+ //
58
+ // ---------------------------------------------------------------------------
59
+ // WHAT NOT TO DO -- the four rules, each enforced structurally below
60
+ // ---------------------------------------------------------------------------
61
+ // 1. NEVER write the `packer` field from anything but an oracle's own
62
+ // verbatim output. Not from entropy, not from a decompression or
63
+ // dependency address, not from a byte pattern. There is exactly ONE
64
+ // assignment of that field in this module, inside `identifiedByOracle()`
65
+ // below, and it refuses a value the oracle did not supply.
66
+ // 2. NEVER return the high confidence level off the oracle route. The
67
+ // entropy route is MEDIUM and answers packedness only; the no-route case
68
+ // is LOW and answers `unknown`. `CONFIDENCE_HIGH` is ASSIGNED at exactly
69
+ // one site in this file, in the oracle branch, and the colocated test
70
+ // counts that site at source level.
71
+ // 3. NEVER return the `unknown` verdict with an empty reason. A silent null
72
+ // with nothing said about it is the failure mode this whole file exists
73
+ // to prevent, so `unknownFinding()` throws rather than build one. More
74
+ // generally: every finding whose `packer` is null states, in
75
+ // `unavailableReason`, why no name is being reported.
76
+ // 4. NEVER add vocabulary beyond the four verdicts. No fractional rating, no
77
+ // ratio, no hedged phrasing, no named-but-unconfirmed packer. A reader
78
+ // must get either a name an oracle stated or an explicit unknown, and
79
+ // nothing in between.
80
+ //
81
+ // Also: NEVER invoke the oracle through a command interpreter. Every child
82
+ // process below is launched with an argument ARRAY and an explicitly disabled
83
+ // interpreter, and a configured oracle path that does not exist on disk is
84
+ // treated as oracle-absent rather than being placed into any command anywhere
85
+ // (T-19-18). The oracle's standard output reaches exactly one field, through
86
+ // one bounded parser that evaluates nothing (T-19-19).
87
+ //
88
+ // ---------------------------------------------------------------------------
89
+ // STATUS OF THE ORACLE BRANCH (recorded, deliberate)
90
+ // ---------------------------------------------------------------------------
91
+ // The env-var convention below (`UNP64`, then `UNP64_PATH`) is the same one
92
+ // upstream's own comparison harness uses, and is verified. The SHAPE of the
93
+ // identifier's standard output is NOT verified here -- the tool was not
94
+ // installed on the machine where this was written, so `parseUnp64Stdout()` is
95
+ // written defensively and its accepted marker set is a stated assumption, not
96
+ // a measurement. Installing the identifier and running it against a genuinely
97
+ // packed fixture is the experiment that would settle it; until then the
98
+ // oracle-route test SKIPS with a visible reason and never reads as a pass.
99
+ import { execFileSync } from "node:child_process";
100
+ import { existsSync, readFileSync } from "node:fs";
101
+ import { basename, dirname, resolve } from "node:path";
102
+
103
+ import { resolveMcpModule, refusalMessage } from "../../c64-ram-capture/scripts/mcp-module.mjs";
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // The oracle's own child-process spawn.
107
+ // ---------------------------------------------------------------------------
108
+ // Both `probeUnp64()` and `runUnp64()` used to spawn `unp64` directly
109
+ // (`spawnSync`, and a `mkdtempSync()`-created scratch directory for the
110
+ // unpacked output). Both spawn sites are now behind the host-tool execution
111
+ // seam (`src/mcp/vice/host-tool.mts`'s `oracle.probe`/`oracle.run` allowlist
112
+ // entries) -- the project owner's rule of 2026-08-28 is that this script
113
+ // runs container-side, the oracle binary lives host-side, and there is no
114
+ // container PATH to find it on. Everything about the BINARY (locating it
115
+ // from `ORACLE_ENV_VARS`, the version-banner probe, the scratch output
116
+ // location, the argument array, the runtime bound, the standard-output cap)
117
+ // now lives in host-tool.mts; this file keeps everything about the FINDING
118
+ // (the name parser, the accepted character set, the caps below, the
119
+ // packedness threshold, and both functions' never-throw contract).
120
+
121
+ // SYNCHRONOUS ON PURPOSE: `execFileSync`, not the async `spawn` acme.mjs's
122
+ // own migration uses. `probeUnp64()`/`runUnp64()` are called synchronously,
123
+ // with no `await`, throughout this module's own colocated test file
124
+ // (`packer-finding.test.mjs`, unmodified by this migration) -- including at
125
+ // module scope (`const PROBED = probeUnp64();`). Converting them to
126
+ // async/Promise-returning functions would silently break every one of those
127
+ // call sites (a Promise is not the finding object the assertions expect),
128
+ // so the OUTER call into the seam's CLI wrapper must itself be synchronous.
129
+ // The asynchronous work (the actual child-process spawn of the oracle
130
+ // binary) still happens -- inside the SPAWNED subprocess, in
131
+ // host-tool.mts's own async `runHostTool()` -- `execFileSync` merely blocks
132
+ // this function until that subprocess exits, exactly as `spawnSync` used to
133
+ // block until `unp64` itself exited.
134
+ const HOST_TOOL_CLIENT_FILE = "host-tool-client.ts";
135
+
136
+ /**
137
+ * Synchronously invokes the host-tool execution seam for `tool`/`args`,
138
+ * optionally rooted at `repoRoot` for workspace-relative path resolution.
139
+ * NEVER throws: an unresolvable ladder, a spawn failure, a timeout, or
140
+ * unparseable output all return `{ ok: false, message }` -- the SAME shape
141
+ * a tool's own transport-level refusal uses, so callers translate a failure
142
+ * here identically to a `{ ok: false }` response from the seam itself.
143
+ */
144
+ function invokeSeamSync(tool, args, repoRoot) {
145
+ const resolved = resolveMcpModule(HOST_TOOL_CLIENT_FILE);
146
+ if (!resolved.ok) {
147
+ return { ok: false, message: refusalMessage(HOST_TOOL_CLIENT_FILE, resolved.rungs) };
148
+ }
149
+
150
+ const cliArgs = [resolved.path, "run", "--tool", tool, "--args", JSON.stringify(args)];
151
+ if (repoRoot) cliArgs.push("--repo-root", repoRoot);
152
+
153
+ let stdout;
154
+ try {
155
+ stdout = execFileSync(process.execPath, cliArgs, {
156
+ encoding: "utf8",
157
+ timeout: ORACLE_TIMEOUT_MS + 5_000,
158
+ shell: false,
159
+ windowsHide: true,
160
+ });
161
+ } catch (err) {
162
+ // execFileSync throws on a non-zero exit, a timeout, or a genuine spawn
163
+ // failure -- but a non-zero exit is the NORMAL signal for a tool-level
164
+ // `{ ok: false }` result (host-tool-client.ts's own CLI wrapper always
165
+ // prints its one JSON line before exiting non-zero), so recover it from
166
+ // the error object rather than treating every non-zero exit as a
167
+ // transport failure.
168
+ const recovered = typeof err.stdout === "string" ? err.stdout : err.stdout ? err.stdout.toString("utf8") : "";
169
+ if (recovered.trim() === "") {
170
+ return { ok: false, message: err instanceof Error ? err.message : String(err) };
171
+ }
172
+ stdout = recovered;
173
+ }
174
+
175
+ const lines = stdout.split("\n").filter((line) => line.trim() !== "");
176
+ const last = lines[lines.length - 1];
177
+ if (last === undefined) return { ok: false, message: "host-tool-client.ts produced no output" };
178
+ try {
179
+ return JSON.parse(last);
180
+ } catch {
181
+ return { ok: false, message: `host-tool-client.ts produced non-JSON output: ${last}` };
182
+ }
183
+ }
184
+
185
+ /** Splits an arbitrary (absolute or cwd-relative) file path into a workspace
186
+ * root + a plain relative name, so a single-file oracle.run request can
187
+ * satisfy the seam's workspace-relative path requirement (`resolveWorkspacePath()`
188
+ * in host-tool.mts refuses an absolute path outright) without needing the
189
+ * caller's actual project root at all -- the smallest possible root for a
190
+ * single file is its own containing directory. */
191
+ function toWorkspaceRelative(anyPath) {
192
+ const abs = resolve(anyPath);
193
+ return { repoRoot: dirname(abs), source: basename(abs) };
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Vocabulary. Exactly four verdicts, frozen. Rule 4.
198
+ // ---------------------------------------------------------------------------
199
+
200
+ /**
201
+ * The complete verdict vocabulary. Nothing else may ever appear in a
202
+ * finding's `verdict` field.
203
+ *
204
+ * identified -- an oracle stated a name, verbatim.
205
+ * packed-unidentified -- the entropy gate places the bytes at or above its
206
+ * packedness threshold, and NO oracle named the
207
+ * packer. Packedness, never identity.
208
+ * unpacked -- the entropy gate puts the bytes below its
209
+ * threshold. Still not an identity claim.
210
+ * unknown -- no route produced an answer. Always carries a
211
+ * reason.
212
+ */
213
+ export const PACKER_VERDICTS = Object.freeze(["identified", "packed-unidentified", "unpacked", "unknown"]);
214
+
215
+ const VERDICT_IDENTIFIED = "identified";
216
+ const VERDICT_PACKED_UNIDENTIFIED = "packed-unidentified";
217
+ const VERDICT_UNPACKED = "unpacked";
218
+ const VERDICT_UNKNOWN = "unknown";
219
+
220
+ const CONFIDENCE_HIGH = "HIGH";
221
+ const CONFIDENCE_MEDIUM = "MEDIUM";
222
+ const CONFIDENCE_LOW = "LOW";
223
+
224
+ const ROUTE_ORACLE = "unp64";
225
+ const ROUTE_ENTROPY = "entropy-only";
226
+ const ROUTE_NONE = "none";
227
+
228
+ /**
229
+ * The packedness threshold, taken from the curated binary-info tool's own
230
+ * description ("values higher than 7.5 suggest the binary might be
231
+ * compressed") rather than restated from a second source. Shannon entropy
232
+ * over bytes runs 0.0 to 8.0.
233
+ *
234
+ * This number gates PACKEDNESS ONLY. It can never reach the `packer` field --
235
+ * see rule 1.
236
+ */
237
+ export const PACKED_ENTROPY_THRESHOLD = 7.5;
238
+
239
+ /** Default command name when neither environment variable is set. */
240
+ const DEFAULT_ORACLE_COMMAND = "unp64";
241
+
242
+ /** The two environment variables the external identifier is located from, in
243
+ * this order -- the same convention upstream's own comparison harness uses. */
244
+ const ORACLE_ENV_VARS = Object.freeze(["UNP64", "UNP64_PATH"]);
245
+
246
+ /** The opt-in variable that turns an absent oracle from an expected skip into
247
+ * a hard failure, by the established `VICE_REQUIRE_*` precedent. */
248
+ export const REQUIRE_ORACLE_ENV_VAR = "VICE_REQUIRE_UNP64";
249
+
250
+ /** Every child process here is bounded. A hung identifier is treated exactly
251
+ * like an absent one (T-19-23). */
252
+ const ORACLE_TIMEOUT_MS = 20_000;
253
+
254
+ /** Hard cap on how much of the oracle's standard output the parser will even
255
+ * look at. Longer than this is rejected outright rather than scanned
256
+ * (T-19-19). */
257
+ export const MAX_ORACLE_STDOUT_BYTES = 64 * 1024;
258
+
259
+ /** Hard cap on the length of a parsed packer name. */
260
+ export const MAX_PACKER_NAME_LENGTH = 64;
261
+
262
+ /**
263
+ * The accepted shape of a parsed name: begins alphanumeric, then a small,
264
+ * explicitly listed printable set. Deliberately narrow -- the parsed value's
265
+ * only destination is a report field, and narrowing it here means a hostile
266
+ * standard output cannot smuggle control characters or markup into a document
267
+ * a human later reads.
268
+ */
269
+ const PACKER_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9 ._/+-]*$/;
270
+
271
+ /**
272
+ * Line markers the parser accepts, each anchored at the start of a trimmed
273
+ * line and each followed by the name. ASSUMED, not measured -- see the module
274
+ * header's status note. An unrecognised line yields null, never a guess drawn
275
+ * from the surrounding text.
276
+ */
277
+ const ORACLE_NAME_MARKERS = Object.freeze([/^packer\s*:\s*(.+)$/i, /^detected\s+packer\s*:\s*(.+)$/i, /^detected\s*:\s*(.+)$/i]);
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Entropy -- packedness only
281
+ // ---------------------------------------------------------------------------
282
+
283
+ /**
284
+ * Shannon entropy over a byte buffer, 0.0 to 8.0 -- the same quantity the
285
+ * curated binary-info tool reports, computed here so the command-line entry
286
+ * can answer from a bare file with no live session.
287
+ *
288
+ * Returns null for an empty buffer: zero bytes is an absent measurement, not
289
+ * a measurement of zero (a zero would read as "definitely not compressed",
290
+ * which is a claim nobody made).
291
+ */
292
+ export function shannonEntropy(bytes) {
293
+ if (!bytes || bytes.length === 0) return null;
294
+ const counts = new Array(256).fill(0);
295
+ for (const b of bytes) counts[b & 0xff]++;
296
+ let total = 0;
297
+ for (const count of counts) {
298
+ if (count === 0) continue;
299
+ const p = count / bytes.length;
300
+ total -= p * Math.log2(p);
301
+ }
302
+ return total;
303
+ }
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // The oracle: probe, run, parse. All three read-only.
307
+ // ---------------------------------------------------------------------------
308
+
309
+ /**
310
+ * Turns a container-side environment record
311
+ * into a DIAGNOSTIC HINT, never a configuration value. This script's own
312
+ * filesystem is not the filesystem the oracle runs on -- host-tool.mts's
313
+ * `resolveOracleCommand()` decides the oracle's location from the HOST
314
+ * BROKER PROCESS'S OWN environment now, so a variable set in THIS
315
+ * (container-side) environment can only ever explain a possibly-surprising
316
+ * absent result, never select what actually runs.
317
+ *
318
+ * Answers `null` when no oracle variable is set in `env`. Otherwise answers
319
+ * a non-empty string naming WHICH variable was set and stating that the
320
+ * seam consults the host broker process's own environment instead --
321
+ * NEVER interpolating the variable's value (T-19-18, unchanged by this
322
+ * migration).
323
+ */
324
+ export function oracleConfigurationHint(env = process.env) {
325
+ const source = env ?? {};
326
+ for (const name of ORACLE_ENV_VARS) {
327
+ const value = source[name];
328
+ if (typeof value === "string" && value.trim() !== "") {
329
+ return (
330
+ `${name} is set in this container-side environment, but it is not consulted: the host-tool ` +
331
+ "execution seam reads the oracle's location from the HOST BROKER PROCESS'S OWN environment, " +
332
+ `not this script's -- point ${name} at the oracle in the environment the host broker process sees`
333
+ );
334
+ }
335
+ }
336
+ return null;
337
+ }
338
+
339
+ /** Appends `hint` to `reason` when both are present, returns whichever of the
340
+ * two is non-null when only one is, and returns `null` when neither is. Kept
341
+ * as its own function so the "when absent, append the hint" rule in
342
+ * `probeUnp64()` below is one small, testable operation rather than inlined
343
+ * string-concatenation logic repeated at every call site. */
344
+ function appendHint(reason, hint) {
345
+ if (hint === null) return reason ?? null;
346
+ if (reason === null || reason === undefined) return hint;
347
+ return `${reason} ${hint}`;
348
+ }
349
+
350
+ /**
351
+ * Locates and probes the external packer identifier, WITHOUT touching any
352
+ * input file.
353
+ *
354
+ * This function decides NOTHING about the
355
+ * binary any more -- it sends the seam call UNCONDITIONALLY, with an empty
356
+ * argument object, whether or not a container-side oracle variable is set.
357
+ * There is no filesystem existence check on an oracle path here (the removed
358
+ * check answered the wrong question: this script's own filesystem is not the
359
+ * filesystem the oracle runs on). A container-side variable can only ever
360
+ * add a diagnostic hint to an ABSENT result -- see `oracleConfigurationHint()`
361
+ * -- never select what the host executes.
362
+ *
363
+ * Never throws. A launch error, a non-zero status or a timeout are all
364
+ * "absent", never a failure -- absence of the oracle is an expected state.
365
+ */
366
+ export function probeUnp64(env = process.env) {
367
+ const hint = oracleConfigurationHint(env);
368
+ const response = invokeSeamSync("oracle.probe", {});
369
+
370
+ if (!response || response.ok !== true) {
371
+ const reason = (response && response.message) || "the packer identifier oracle.probe seam call failed";
372
+ return { available: false, command: null, version: null, reason: appendHint(reason, hint) };
373
+ }
374
+
375
+ const available = response.available === true;
376
+ return {
377
+ available,
378
+ command: available ? response.command : null,
379
+ version: available ? response.version : null,
380
+ reason: available ? null : appendHint(response.reason, hint),
381
+ };
382
+ }
383
+
384
+ /**
385
+ * Runs the located identifier against `filePath`, read-only.
386
+ *
387
+ * The input file is never modified: any unpacked output the identifier writes
388
+ * goes to a scratch path under the system temporary directory, which is
389
+ * removed before this function returns (T-19-24). The child is launched with
390
+ * an argument ARRAY and an explicitly disabled command interpreter, so neither
391
+ * the configured command nor the caller's filename is ever parsed as a
392
+ * command (T-19-18).
393
+ *
394
+ * Never throws: every failure is reported as `{ ok: false, reason }`.
395
+ */
396
+ export function runUnp64(probe, filePath) {
397
+ if (!probe || probe.available !== true || typeof probe.command !== "string") {
398
+ return { ok: false, stdout: "", reason: "the oracle was not available" };
399
+ }
400
+ if (typeof filePath !== "string" || filePath === "") {
401
+ return { ok: false, stdout: "", reason: "no input file was given to the oracle" };
402
+ }
403
+ if (!existsSync(filePath)) {
404
+ return { ok: false, stdout: "", reason: "the input file does not exist" };
405
+ }
406
+
407
+ // The scratch output location, the argument array, the runtime bound and
408
+ // the input file's absolute/relative form are all resolved host-side now;
409
+ // this file only ever hands the seam a workspace-relative `source`, rooted
410
+ // at the smallest root that can express it -- the file's own directory.
411
+ const { repoRoot, source } = toWorkspaceRelative(filePath);
412
+ const response = invokeSeamSync("oracle.run", { source }, repoRoot);
413
+ if (!response || typeof response.ok !== "boolean") {
414
+ return { ok: false, stdout: "", reason: (response && response.message) || "the oracle.run seam call failed" };
415
+ }
416
+ return { ok: response.ok, stdout: typeof response.stdout === "string" ? response.stdout : "", reason: response.reason ?? null };
417
+ }
418
+
419
+ /**
420
+ * Parses a packer name out of the oracle's standard output.
421
+ *
422
+ * Defensive by construction (T-19-19): an explicit byte cap before anything
423
+ * is scanned, no evaluation of any kind, a narrow accepted character set, and
424
+ * a length cap on the result. Empty, truncated, over-long and unrecognised
425
+ * input all return null WITHOUT throwing -- a parser that throws inside a
426
+ * recon pass would turn an unhelpful oracle into a stopped session.
427
+ *
428
+ * The returned value's only destination is the finding's `packer` field. It
429
+ * never reaches a command, a path, or any executable position.
430
+ */
431
+ export function parseUnp64Stdout(text) {
432
+ if (typeof text !== "string") return null;
433
+ if (text.length === 0) return null;
434
+ if (text.length > MAX_ORACLE_STDOUT_BYTES) return null;
435
+
436
+ for (const rawLine of text.split(/\r?\n/)) {
437
+ const line = rawLine.trim();
438
+ if (line === "") continue;
439
+ for (const marker of ORACLE_NAME_MARKERS) {
440
+ const matched = line.match(marker);
441
+ if (!matched) continue;
442
+ const candidate = (matched[1] ?? "").trim();
443
+ if (candidate === "") return null;
444
+ if (candidate.length > MAX_PACKER_NAME_LENGTH) return null;
445
+ if (!PACKER_NAME_RE.test(candidate)) return null;
446
+ return candidate;
447
+ }
448
+ }
449
+ return null;
450
+ }
451
+
452
+ // ---------------------------------------------------------------------------
453
+ // Finding constructors. Each route has exactly one, and only the oracle's
454
+ // constructor may set `packer`.
455
+ // ---------------------------------------------------------------------------
456
+
457
+ /**
458
+ * THE ONLY PLACE IN THIS MODULE THAT ASSIGNS A PACKER NAME (rule 1), and the
459
+ * only place the high confidence level is written (rule 2). It refuses to
460
+ * build a finding from anything but a non-empty string the oracle's own
461
+ * output supplied.
462
+ */
463
+ function identifiedByOracle(name, evidence, checkedAt) {
464
+ if (typeof name !== "string" || name.trim() === "") {
465
+ throw new Error("identifiedByOracle: refusing to report a packer name the oracle did not state");
466
+ }
467
+ return Object.freeze({
468
+ packer: name,
469
+ verdict: VERDICT_IDENTIFIED,
470
+ confidence: CONFIDENCE_HIGH,
471
+ route: ROUTE_ORACLE,
472
+ evidence: Object.freeze(evidence.slice()),
473
+ checkedAt,
474
+ unavailableReason: null,
475
+ });
476
+ }
477
+
478
+ /** The entropy route. Answers PACKEDNESS and nothing else -- `packer` is
479
+ * written as the literal null here, and the entropy value has no path to it
480
+ * (rule 1). Never the high confidence level (rule 2). */
481
+ function fromEntropy(entropy, threshold, evidence, checkedAt) {
482
+ const packed = entropy >= threshold;
483
+ return Object.freeze({
484
+ packer: null,
485
+ verdict: packed ? VERDICT_PACKED_UNIDENTIFIED : VERDICT_UNPACKED,
486
+ confidence: CONFIDENCE_MEDIUM,
487
+ route: ROUTE_ENTROPY,
488
+ evidence: Object.freeze(evidence.slice()),
489
+ checkedAt,
490
+ unavailableReason: packed
491
+ ? "the entropy gate answers packedness only -- no oracle named the packer, and this project never " +
492
+ "infers a name from entropy, from a decompression address, or from a byte pattern"
493
+ : "no name is claimed: the entropy gate places these bytes below its packedness threshold, which is " +
494
+ "a statement about compression and not about identity",
495
+ });
496
+ }
497
+
498
+ /** The no-route case. Rule 3 is enforced here: an `unknown` verdict with an
499
+ * empty reason cannot be constructed at all. */
500
+ function unknownFinding(reason, evidence, checkedAt) {
501
+ if (typeof reason !== "string" || reason.trim() === "") {
502
+ throw new Error("unknownFinding: refusing to return an unknown verdict with no reason (rule 3)");
503
+ }
504
+ return Object.freeze({
505
+ packer: null,
506
+ verdict: VERDICT_UNKNOWN,
507
+ confidence: CONFIDENCE_LOW,
508
+ route: ROUTE_NONE,
509
+ evidence: Object.freeze(evidence.slice()),
510
+ checkedAt,
511
+ unavailableReason: reason,
512
+ });
513
+ }
514
+
515
+ // ---------------------------------------------------------------------------
516
+ // The finding
517
+ // ---------------------------------------------------------------------------
518
+
519
+ /**
520
+ * Produces the packer recon finding for one binary, walking the ordered oracle
521
+ * chain: the external identifier first (first hit wins for the name), then the
522
+ * packedness-only entropy gate, then an explicit unknown.
523
+ *
524
+ * Options:
525
+ * filePath -- the binary to ask the oracle about. Required for the oracle
526
+ * route; the entropy route does not need it when `entropy` is
527
+ * supplied directly.
528
+ * entropy -- a measured entropy value (for example the one the curated
529
+ * binary-info tool reports). When absent and `bytes` is given,
530
+ * it is computed locally; the evidence entry records WHICH.
531
+ * bytes -- the binary's bytes, for the local entropy computation.
532
+ * threshold -- overrides the packedness threshold. The default is the one
533
+ * the curated tool's own description states.
534
+ * probe -- injectable oracle probe, so a test can force the oracle
535
+ * absent without uninstalling anything.
536
+ * run -- injectable oracle runner, same reason.
537
+ * now -- injectable clock, so a test can pin `checkedAt`.
538
+ */
539
+ export function packerFinding(options = {}) {
540
+ const {
541
+ filePath = null,
542
+ entropy = null,
543
+ bytes = null,
544
+ threshold = PACKED_ENTROPY_THRESHOLD,
545
+ env = process.env,
546
+ probe = probeUnp64,
547
+ run = runUnp64,
548
+ now = () => new Date().toISOString(),
549
+ } = options;
550
+
551
+ const checkedAt = now();
552
+ const evidence = [];
553
+
554
+ // --- Route 1: the external oracle. The ONLY route that can name a packer.
555
+ const probed = probe(env);
556
+ evidence.push(
557
+ Object.freeze({
558
+ source: ROUTE_ORACLE,
559
+ available: probed.available === true,
560
+ version: probed.available === true ? probed.version : null,
561
+ reason: probed.available === true ? null : (probed.reason ?? "the oracle was not available"),
562
+ }),
563
+ );
564
+
565
+ if (probed.available === true && typeof filePath === "string" && filePath !== "") {
566
+ const result = run(probed, filePath);
567
+ const raw = typeof result.stdout === "string" ? result.stdout.slice(0, MAX_PACKER_NAME_LENGTH * 8) : "";
568
+ evidence.push(Object.freeze({ source: ROUTE_ORACLE, ok: result.ok === true, raw, reason: result.reason ?? null }));
569
+ if (result.ok === true) {
570
+ const name = parseUnp64Stdout(result.stdout);
571
+ if (name !== null) {
572
+ return identifiedByOracle(name, evidence, checkedAt);
573
+ }
574
+ // The oracle ran and named nothing. That is a real answer about the
575
+ // oracle, not about the packer -- fall through to the entropy gate
576
+ // rather than inventing a name from what it did print.
577
+ }
578
+ }
579
+
580
+ // --- Route 2: the entropy gate. Packedness only, never a name.
581
+ let measured = null;
582
+ let entropySource = null;
583
+ if (typeof entropy === "number" && Number.isFinite(entropy)) {
584
+ measured = entropy;
585
+ // THE RECORDED PROVENANCE VALUE, DECIDED 2026-08-29 -- not string-replaced.
586
+ // Until this date this field carried the retired static analyser's
587
+ // binary-info verb, in that verb's own tool-name shape. Carrying that
588
+ // shape forward under ANY spelling is wrong twice over. It is a FACT
589
+ // ABOUT A PAST RUN: renaming it to a verb on the current surface would
590
+ // claim a run that never happened. And this branch cannot know who
591
+ // produced the number in the first place -- `--entropy` is
592
+ // caller-supplied, and the caller may equally have measured it, read it
593
+ // from a derived binary-info read, or copied it out of a report. So the
594
+ // value now names the CHANNEL, which this code can actually observe,
595
+ // rather than guessing a producer; the historical producer is recorded
596
+ // above in prose, with no token an extractor could mistake for a live
597
+ // route.
598
+ entropySource = "caller-supplied";
599
+ } else if (bytes && bytes.length > 0) {
600
+ measured = shannonEntropy(bytes);
601
+ entropySource = "local-shannon-entropy";
602
+ }
603
+
604
+ if (measured !== null) {
605
+ evidence.push(Object.freeze({ source: entropySource, entropy: measured, threshold }));
606
+ return fromEntropy(measured, threshold, evidence, checkedAt);
607
+ }
608
+
609
+ // --- Route 3: the explicit unknown, always with a reason.
610
+ return unknownFinding(
611
+ "no packer-identity route was available: this project's own tool and command-line surfaces expose no " +
612
+ "packer name, the external oracle was absent, and no entropy measurement was supplied, so packedness " +
613
+ "could not be established either",
614
+ evidence,
615
+ checkedAt,
616
+ );
617
+ }
618
+
619
+ // ---------------------------------------------------------------------------
620
+ // The live gate. Absence of the oracle is an EXPECTED SKIP by default and a
621
+ // hard FAIL under the opt-in variable -- never a pass either way.
622
+ // ---------------------------------------------------------------------------
623
+
624
+ /**
625
+ * Returns a non-empty skip reason naming the absent oracle, or `false` when a
626
+ * real one is available -- meant to be handed straight to a test runner's
627
+ * `{ skip }` option so the skip is VISIBLE in the report rather than a test
628
+ * that quietly returns early and reads as a pass.
629
+ */
630
+ export function skipReasonForUnp64(probed) {
631
+ const result = probed ?? probeUnp64();
632
+ if (result.available === true) return false;
633
+ return (
634
+ `the oracle-route tests are skipped -- no external packer identifier was found (${result.reason ?? "reason not recorded"}). ` +
635
+ `Point ${ORACLE_ENV_VARS.join(" or ")} at one, or install "${DEFAULT_ORACLE_COMMAND}". ` +
636
+ `An absent oracle is an EXPECTED SKIP here, never a pass: set ${REQUIRE_ORACLE_ENV_VAR} to turn it into a hard failure.`
637
+ );
638
+ }
639
+
640
+ // ---------------------------------------------------------------------------
641
+ // Command-line entry: one file in, one JSON object out.
642
+ // ---------------------------------------------------------------------------
643
+
644
+ const USAGE = `usage: node src/skills/c64-program-recon/scripts/packer-finding.mjs <file> [--entropy N]
645
+
646
+ Prints ONE JSON object: the packer recon finding for <file>.
647
+
648
+ packer the packer name, or null. Non-null ONLY when an external
649
+ oracle stated it verbatim. This project never guesses one.
650
+ verdict one of: ${PACKER_VERDICTS.join(", ")}
651
+ confidence HIGH (oracle route only), MEDIUM (entropy route), LOW
652
+ route ${ROUTE_ORACLE}, ${ROUTE_ENTROPY}, or ${ROUTE_NONE}
653
+ evidence what each step actually saw
654
+ checkedAt when this was answered
655
+ unavailableReason why no name is being reported, whenever there is none
656
+
657
+ --entropy overrides the locally computed value with one you already have (the
658
+ curated binary-info tool reports it). The entropy gate answers PACKEDNESS and
659
+ never identity.`;
660
+
661
+ function readFlag(argv, name) {
662
+ const index = argv.indexOf(`--${name}`);
663
+ if (index === -1) return undefined;
664
+ const value = argv[index + 1];
665
+ if (value === undefined || value.startsWith("--")) return undefined;
666
+ return value;
667
+ }
668
+
669
+ function main(argv) {
670
+ const positional = [];
671
+ for (let i = 0; i < argv.length; i++) {
672
+ const token = argv[i];
673
+ if (token === "--entropy") {
674
+ i++;
675
+ continue;
676
+ }
677
+ if (token.startsWith("--")) continue;
678
+ positional.push(token);
679
+ }
680
+
681
+ const filePath = positional[0];
682
+ if (filePath === undefined || argv.includes("--help") || argv.includes("-h")) {
683
+ console.log(USAGE);
684
+ process.exit(filePath === undefined && !argv.includes("--help") && !argv.includes("-h") ? 2 : 0);
685
+ }
686
+
687
+ let bytes = null;
688
+ try {
689
+ bytes = readFileSync(filePath);
690
+ } catch (err) {
691
+ console.error(`packer-finding: could not read ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
692
+ process.exit(1);
693
+ }
694
+
695
+ const entropyRaw = readFlag(argv, "entropy");
696
+ const entropy = entropyRaw === undefined ? null : Number.parseFloat(entropyRaw);
697
+ if (entropyRaw !== undefined && !Number.isFinite(entropy)) {
698
+ console.error(`packer-finding: --entropy must be a number, got "${entropyRaw}"`);
699
+ process.exit(1);
700
+ }
701
+
702
+ const finding = packerFinding({ filePath, bytes, ...(entropy === null ? {} : { entropy }) });
703
+ console.log(JSON.stringify(finding, null, 2));
704
+ }
705
+
706
+ // Run only when invoked directly, never when imported by the colocated test.
707
+ if (process.argv[1] && process.argv[1].endsWith("packer-finding.mjs")) {
708
+ main(process.argv.slice(2));
709
+ }