@henols/vice-mcp 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -117,6 +117,33 @@ import { resolveGhidraProject, buildAnalyzeHeadlessArgv, hasDotPrefixedSegment,
117
117
  // once per broker lifetime -- never re-probed per c1541.* call within the
118
118
  // SAME process, per findSiblingBinary()'s own memo below.
119
119
  import { resolvedBackend } from "./backend-detect.mjs";
120
+ // This module's THIRD sibling import, and its own use of the `.mjs`-extension
121
+ // rule for a host-bound sibling -- the same form the `backend-detect.mjs`
122
+ // import immediately above already uses, a plain static VALUE import, never
123
+ // the lazy `createRequire()` dual-path load `backend-detect.mts`'s own
124
+ // `toolLocationSeam()` uses. That dance exists ONLY for a module that ships
125
+ // two ways -- unbuilt, imported directly by a real no-build-step entry point,
126
+ // AND compiled -- and `host-tool.mts` does not: every real consumer (this
127
+ // project's own broker, the direct host-spawn route, and every test file
128
+ // that reaches `runHostTool()`) imports the COMPILED `resources/host-tool.mjs`
129
+ // artifact only, exactly like the `ghidra-project.mjs` import above already
130
+ // does; the unbuilt `.mts` source is never loaded as a live ESM module by
131
+ // anything in this tree (confirmed: no import specifier anywhere in this
132
+ // repository names `"./host-tool.mts"`). A static `"./tool-location.mjs"`
133
+ // specifier is therefore safe here the same way it is safe for
134
+ // `ghidra-project.mjs`: it only ever resolves once both compiled siblings sit
135
+ // together in `resources/`, which `build.ts`'s `HOST_BOUND_ARTIFACTS` already
136
+ // guarantees for both. `resolveTool` is `LOC-01`'s seam call (a `tools.json`
137
+ // entry for `acme`/`acme-lib`/`ghidra` reaching this module's spawn);
138
+ // `remedyTextsFor` is `DECL-03`'s remedy reader (plan 60-02) -- every refusal
139
+ // this module returns for `acme`, `acme-lib`, `ghidra` or `dxa` composes its
140
+ // closing sentence from this function's return value, never a re-authored
141
+ // literal. `resolveOnPath` is plan 60-04's addition (`LOC-04`, PD-08):
142
+ // `findSiblingBinary()`'s own inline `$PATH` loop now calls this exported
143
+ // walk instead of carrying a second copy of the same algorithm -- the
144
+ // SECOND of Phase 59 `D-02`'s three coexisting `$PATH`-walk copies to
145
+ // collapse (plan 60-01 collapsed the first, inside `backend-detect.mts`).
146
+ import { resolveTool, remedyTextsFor, resolveOnPath } from "./tool-location.mjs";
120
147
  // This module's own directory, used ONLY to compute the vendored dxa
121
148
  // binary's fixed path. Never an environment-variable override: dxa is
122
149
  // vendored AND built by this project (unlike ACME_BIN/GHIDRA_HOME, which
@@ -891,22 +918,81 @@ export function resolveWorkspacePath(repoRoot, relative) {
891
918
  }
892
919
  return { ok: true, path: walkedCandidate.path };
893
920
  }
921
+ /** Returns `locate` verbatim when supplied, or -- when absent -- a locator
922
+ * derived from `process.cwd()` (PD-06). The production route
923
+ * (`runHostTool()`) always supplies a real locator built from its own
924
+ * already-resolved `repoRootAbs`; this fallback exists only so the argv-shape
925
+ * test call sites across this file's own test suite -- which exercise argv
926
+ * construction alone -- keep compiling and keep returning the same argv they
927
+ * return today, with no per-call-site edit required. Documented as a last
928
+ * resort, not a guess: a working directory with no `.c64-re-tools/tools.json`
929
+ * makes the file layer a silent no-op for that call, so this fallback can
930
+ * only WIDEN resolution (adding the environment and `$PATH`/fixed-prefix
931
+ * layers a bare literal never had) where a real project root already happens
932
+ * to be the working directory -- mirrors `backend-detect.mts`'s own
933
+ * `ResolvedBackendDeps` derivation (PD-03) for the identical reason. */
934
+ function locatorFrom(locate) {
935
+ if (locate)
936
+ return locate;
937
+ const projectRoot = process.cwd();
938
+ return { toolsDir: join(projectRoot, ".c64-re-tools"), projectRoot };
939
+ }
940
+ /** Appends a declared tool's remedy prose (`remedyTextsFor()`, DECL-03) to a
941
+ * refusal `base` sentence, joined with `"; "` -- appending NOTHING at all
942
+ * when the declaration carries no remedy for the running platform, so a
943
+ * refusal never ends in a dangling separator with nothing after it. `here`
944
+ * threads `HostToolLocator.here` through so a test pointing resolution at a
945
+ * scratch declaration (see that field's own doc comment) reads the remedy
946
+ * text from the SAME scratch declaration, never the real committed one. */
947
+ function withRemedy(base, id, here) {
948
+ const remedies = remedyTextsFor(id, here !== undefined ? { here } : {});
949
+ return remedies.length > 0 ? `${base} -- ${remedies.join("; ")}` : base;
950
+ }
894
951
  /** Deterministic: the same typed request and the same resolved paths yield
895
952
  * a byte-identical argv array on two successive calls -- no randomness, no
896
953
  * timestamp, no environment-dependent ordering. `log` is OPTIONAL and used
897
954
  * ONLY by the c1541.dir branch to report a PATH-fallback binary resolution
898
955
  * -- every pre-existing branch ignores it, exactly as they already ignore
899
- * any parameter they do not need. */
900
- export function buildHostToolArgv(request, resolved, log) {
956
+ * any parameter they do not need. `locate` is OPTIONAL (PD-06): the one
957
+ * production caller, `runHostTool()`, always supplies it; every other call
958
+ * site -- this file's own argv-shape tests foremost -- may omit it and falls
959
+ * back to `locatorFrom()`'s own `process.cwd()`-derived pair. */
960
+ export function buildHostToolArgv(request, resolved, log, locate) {
961
+ const loc = locatorFrom(locate);
901
962
  if (request.tool === "acme.build") {
902
963
  const { args } = request;
903
964
  const { sourcePath, outDirPath, includePaths } = resolved;
904
965
  const stem = join(outDirPath, basename(sourcePath).replace(/\.(a|asm|s)$/i, ""));
905
966
  const prg = `${stem}.prg`;
906
967
  // Overridable local variable named for what it holds -- never `binPath`/
907
- // `viceBin`/`VICE_BIN`/`x64sc`, which spawn-seam.test.ts's
908
- // EMULATOR_BIN_SHAPE would misclassify as an emulator spawn site.
909
- const acmePath = process.env.ACME_BIN && process.env.ACME_BIN !== "" ? process.env.ACME_BIN : "acme";
968
+ // `viceBin`/`VICE_BIN`/`x64sc`. Kept deliberately, by CONVENTION, since
969
+ // the test that once scanned for those four tokens (its own file,
970
+ // its own EMULATOR_BIN_SHAPE classifier) was deleted in commit
971
+ // `276c15c9` and is not revived here -- no guard enforces this naming
972
+ // rule today. What IS mechanically enforced, over the four tool-location
973
+ // environment-variable names this seam reads, is the closed consumer set
974
+ // `tool-location-consumers.test.ts` (plan 60-05) asserts.
975
+ // Resolved through the seam (LOC-01) rather than a direct read of the
976
+ // declared ACME environment variable: env -> tools.json -> $PATH, in
977
+ // that order, the one precedence this tree now states once. A malformed
978
+ // tools.json entry is a REFUSAL (quoted verbatim, no remedy appended --
979
+ // the declaration's remedy is prose for "ACME is missing", not for "your
980
+ // tools.json entry is wrong"); a well-formed-but-nonexistent path anywhere
981
+ // along the chain is "not found", which DOES carry the declared remedy.
982
+ // This existence check is NEW behaviour (T-60-08): today there is none at
983
+ // all, and a missing ACME surfaces only as a raw operating-system spawn
984
+ // error inside spawnErrorMessage.
985
+ const acmeResolved = resolveTool("acme", { toolsDir: loc.toolsDir, projectRoot: loc.projectRoot, here: loc.here });
986
+ if (acmeResolved.refusal) {
987
+ return { ok: false, message: `host_tool "acme.build" refuses: ${acmeResolved.refusal}` };
988
+ }
989
+ if (acmeResolved.path === null) {
990
+ return {
991
+ ok: false,
992
+ message: withRemedy(`host_tool "acme.build" refuses: the ACME binary was not found (tried: ${acmeResolved.tried.join(", ")})`, "acme", loc.here),
993
+ };
994
+ }
995
+ const acmePath = acmeResolved.path;
910
996
  // Fixed flags first, in the SAME order src/skills/acme-build/scripts/
911
997
  // acme.mjs's build() uses today, then one -D per define and one -I pair
912
998
  // per include in caller-given order, then --setpc if given, then the
@@ -950,23 +1036,34 @@ export function buildHostToolArgv(request, resolved, log) {
950
1036
  }
951
1037
  if (request.tool === "ghidra.analyze") {
952
1038
  const { importPath, projectLocation, projectName, preScriptPath, postScriptPath, scriptPathResolved, entrypointsPathResolved, exportPathResolved, dataRangesPathResolved, } = resolved;
953
- // Named environment variable, never a guessed install location and
954
- // never this repository's own local probe directory (T-34-16).
955
- const ghidraHome = process.env.GHIDRA_HOME;
956
- if (ghidraHome === undefined || ghidraHome === "") {
1039
+ // Resolved through the seam (LOC-01) -- env (GHIDRA_HOME) -> tools.json
1040
+ // -> no $PATH probe at all (a `directory`-kind id gets no probe layer,
1041
+ // D-15) -- never a guessed install location and never this repository's
1042
+ // own local probe directory (T-34-16). Ghidra has no fixed-prefix
1043
+ // fallback by design (unlike ACME's library, PD-07 below), so this
1044
+ // preserves today's env-only-then-refuse behaviour exactly while adding
1045
+ // the file layer between the environment and the refusal.
1046
+ const ghidraResolved = resolveTool("ghidra", { toolsDir: loc.toolsDir, projectRoot: loc.projectRoot, here: loc.here });
1047
+ if (ghidraResolved.path === null) {
957
1048
  return {
958
1049
  ok: false,
959
- message: `host_tool "ghidra.analyze" requires the GHIDRA_HOME environment variable to name a Ghidra installation directory; it is unset`,
1050
+ message: withRemedy(`host_tool "ghidra.analyze" refuses: no Ghidra installation directory is known${ghidraResolved.refusal ? `: ${ghidraResolved.refusal}` : ` (tried: ${ghidraResolved.tried.join(", ")})`}`, "ghidra", loc.here),
960
1051
  };
961
1052
  }
1053
+ const ghidraHome = ghidraResolved.path;
962
1054
  // Overridable local variable named for what it holds -- never `binPath`/
963
- // `viceBin`/`VICE_BIN`/`x64sc`, which spawn-seam.test.ts's
964
- // EMULATOR_BIN_SHAPE would misclassify as an emulator spawn site.
1055
+ // `viceBin`/`VICE_BIN`/`x64sc`. Kept deliberately, by CONVENTION, since
1056
+ // the test that once scanned for those four tokens (its own file,
1057
+ // its own EMULATOR_BIN_SHAPE classifier) was deleted in commit
1058
+ // `276c15c9` and is not revived here -- no guard enforces this naming
1059
+ // rule today. What IS mechanically enforced, over the four tool-location
1060
+ // environment-variable names this seam reads, is the closed consumer set
1061
+ // `tool-location-consumers.test.ts` (plan 60-05) asserts.
965
1062
  const ghidraPath = join(ghidraHome, "support", "analyzeHeadless");
966
1063
  if (!existsSync(ghidraPath)) {
967
1064
  return {
968
1065
  ok: false,
969
- message: `host_tool "ghidra.analyze" refuses: GHIDRA_HOME's resolved launcher does not exist on disk (${ghidraPath})`,
1066
+ message: withRemedy(`host_tool "ghidra.analyze" refuses: the resolved Ghidra installation directory (${ghidraHome}) does not contain "support/analyzeHeadless"`, "ghidra", loc.here),
970
1067
  };
971
1068
  }
972
1069
  // The checked, NON-MATERIALISING language preflight -- refuses by
@@ -1054,15 +1151,22 @@ export function buildHostToolArgv(request, resolved, log) {
1054
1151
  if (request.tool === "dxa.disassemble") {
1055
1152
  const { args } = request;
1056
1153
  const { imagePath, outDirPath, entrypointsPath, datablocksPath, labelsPath } = resolved;
1057
- // A-01: fixed, computed path -- never an env-var override (see the HERE
1058
- // and findDxaBinary() comments above). Refuses BY NAME when the vendored
1059
- // binary does not exist at EITHER candidate location, naming build.bash
1060
- // as the remedy, per PLAN.md item 6.
1154
+ // A-01: fixed, computed path -- never an env-var override, never
1155
+ // tools.json, never the seam at all (Phase 59 D-16, LOC-05: dxa is
1156
+ // vendored and built by this project, so an override could only ever
1157
+ // select a binary this project did not build and did not pin -- see the
1158
+ // HERE and findDxaBinary() comments above). Refuses BY NAME when the
1159
+ // vendored binary does not exist at EITHER candidate location; the
1160
+ // remedy sentence is the declaration's own (DECL-03, `remedyTextsFor()`),
1161
+ // never a literal re-authored here -- it happens to match the previous
1162
+ // hardcoded build-script sentence's text by coincidence of two
1163
+ // independently-authored strings, and after this change there is one
1164
+ // source.
1061
1165
  const dxaFound = findDxaBinary(HERE);
1062
1166
  if (dxaFound.path === null) {
1063
1167
  return {
1064
1168
  ok: false,
1065
- message: `host_tool "dxa.disassemble" refuses: the vendored dxa binary does not exist (tried: ${dxaFound.tried.join(", ")}) -- run "bash vendor/dxa/build.bash build" to produce it`,
1169
+ message: withRemedy(`host_tool "dxa.disassemble" refuses: the vendored dxa binary does not exist (tried: ${dxaFound.tried.join(", ")})`, "dxa", loc.here),
1066
1170
  };
1067
1171
  }
1068
1172
  const dxaPath = dxaFound.path;
@@ -1095,25 +1199,27 @@ export function buildHostToolArgv(request, resolved, log) {
1095
1199
  if (request.tool === "ghidra.installExtension") {
1096
1200
  const { moduleName } = resolved;
1097
1201
  // Independently re-derived rather than threaded through `resolved` --
1098
- // mirrors ghidra.analyze's own branch above, which reads GHIDRA_HOME
1099
- // itself instead of accepting it as a resolved field. Both existence
1100
- // checks were already performed (and, for the copy, already acted on)
1101
- // by runHostTool()'s own resolution branch before this function was
1102
- // ever called; re-checking here is defense in depth, the same posture
1103
- // buildAnalyzeHeadlessArgv()'s own independent dot-segment re-check
1104
- // takes for a caller that bypassed the resolution branch entirely.
1105
- const ghidraHome = process.env.GHIDRA_HOME;
1106
- if (ghidraHome === undefined || ghidraHome === "") {
1202
+ // mirrors ghidra.analyze's own branch above, which resolves GHIDRA_HOME
1203
+ // through the seam itself instead of accepting it as a resolved field.
1204
+ // Both existence checks were already performed (and, for the copy,
1205
+ // already acted on) by runHostTool()'s own resolution branch before this
1206
+ // function was ever called; re-checking here is defense in depth, the
1207
+ // same posture buildAnalyzeHeadlessArgv()'s own independent dot-segment
1208
+ // re-check takes for a caller that bypassed the resolution branch
1209
+ // entirely.
1210
+ const ghidraResolved = resolveTool("ghidra", { toolsDir: loc.toolsDir, projectRoot: loc.projectRoot, here: loc.here });
1211
+ if (ghidraResolved.path === null) {
1107
1212
  return {
1108
1213
  ok: false,
1109
- message: `host_tool "ghidra.installExtension" requires the GHIDRA_HOME environment variable to name a Ghidra installation directory; it is unset`,
1214
+ message: withRemedy(`host_tool "ghidra.installExtension" refuses: no Ghidra installation directory is known${ghidraResolved.refusal ? `: ${ghidraResolved.refusal}` : ` (tried: ${ghidraResolved.tried.join(", ")})`}`, "ghidra", loc.here),
1110
1215
  };
1111
1216
  }
1217
+ const ghidraHome = ghidraResolved.path;
1112
1218
  const sleighPath = join(ghidraHome, "support", "sleigh");
1113
1219
  if (!existsSync(sleighPath)) {
1114
1220
  return {
1115
1221
  ok: false,
1116
- message: `host_tool "ghidra.installExtension" refuses: GHIDRA_HOME's resolved "support/sleigh" does not exist on disk (${sleighPath})`,
1222
+ message: withRemedy(`host_tool "ghidra.installExtension" refuses: the resolved Ghidra installation directory (${ghidraHome}) does not contain "support/sleigh"`, "ghidra", loc.here),
1117
1223
  };
1118
1224
  }
1119
1225
  const installLanguagesDir = join(ghidraHome, "Ghidra", "Extensions", moduleName, "data", "languages");
@@ -1129,20 +1235,35 @@ export function buildHostToolArgv(request, resolved, log) {
1129
1235
  request.tool === "c1541.chain" ||
1130
1236
  request.tool === "c1541.read") {
1131
1237
  const { imagePath, outDirPath } = resolved;
1132
- // Resolved as a SIBLING of whichever x64sc backend-detect.mts already
1133
- // resolved -- never a bare-name spawn, which a host carrying both a
1134
- // stock and a fork build would silently answer with whichever build's
1135
- // directory happens to sort first on $PATH (MEASURED live on this
1136
- // project's own dev host, see the import comment above).
1238
+ // A tools.json entry for "c1541" (plan 60-04, LOC-04) is honoured FIRST
1239
+ // (PD-08) -- c1541 carries no environment variable of its own (Phase 59
1240
+ // D-06), so this is its only override route. Absent one, resolved as a
1241
+ // SIBLING of whichever x64sc backend-detect.mts already resolved --
1242
+ // never a bare-name spawn, which a host carrying both a stock and a
1243
+ // fork build would silently answer with whichever build's directory
1244
+ // happens to sort first on $PATH (MEASURED live on this project's own
1245
+ // dev host, see the import comment above).
1137
1246
  // `resolvedBackend()` with no `supervisorDir` never touches the on-disk
1138
1247
  // cache; it still memoises in-process, which is what keeps a
1139
1248
  // long-running broker's SECOND call here free -- see the import
1140
1249
  // comment's own memoisation posture.
1141
- const c1541Found = findSiblingBinary("c1541", resolvedBackend().binPath, log);
1250
+ const c1541Found = findSiblingBinary("c1541", resolvedBackend().binPath, log, loc);
1251
+ // WR-01 (plan 60-07): a refusal from the seam's own file layer (a
1252
+ // malformed tools.json entry -- wrong kind, missing executable bit, path
1253
+ // absent on disk) is quoted verbatim with NO remedy appended, mirroring
1254
+ // buildHostToolArgv()'s own ACME branch above -- the declaration's
1255
+ // remedy is prose for "c1541 is missing", not for "your tools.json entry
1256
+ // is wrong", and appending it here would send a user with a working
1257
+ // c1541 install to the wrong fix. Only the true not-found case (no
1258
+ // refusal, path still null) keeps today's generic sentence and its
1259
+ // withRemedy() call.
1260
+ if (c1541Found.refusal) {
1261
+ return { ok: false, message: `host_tool "${request.tool}" refuses: ${c1541Found.refusal}` };
1262
+ }
1142
1263
  if (c1541Found.path === null) {
1143
1264
  return {
1144
1265
  ok: false,
1145
- message: `host_tool "${request.tool}" refuses: "c1541" does not exist (tried: ${c1541Found.tried.join(", ")})`,
1266
+ message: withRemedy(`host_tool "${request.tool}" refuses: "c1541" does not exist (tried: ${c1541Found.tried.join(", ")})`, "c1541", loc.here),
1146
1267
  };
1147
1268
  }
1148
1269
  const c1541Path = c1541Found.path;
@@ -1204,14 +1325,22 @@ export function buildHostToolArgv(request, resolved, log) {
1204
1325
  }
1205
1326
  if (request.tool === "petcat.decode") {
1206
1327
  const { imagePath, outDirPath } = resolved;
1207
- // Resolved as a SIBLING of whichever x64sc backend-detect.mts already
1208
- // resolved, never a bare-name spawn, with a logged $PATH-fallback
1209
- // warning -- the same mechanism c1541.* already use above.
1210
- const petcatFound = findSiblingBinary("petcat", resolvedBackend().binPath, log);
1328
+ // Same precedence as c1541.* above (plan 60-04, LOC-04, PD-08): a
1329
+ // tools.json entry for "petcat" wins first, then a SIBLING of whichever
1330
+ // x64sc backend-detect.mts already resolved, never a bare-name spawn,
1331
+ // with a logged $PATH-fallback warning -- the same mechanism c1541.*
1332
+ // already use above.
1333
+ const petcatFound = findSiblingBinary("petcat", resolvedBackend().binPath, log, loc);
1334
+ // WR-01 (plan 60-07): same refusal-first branch as the c1541 case above
1335
+ // -- a seam refusal is quoted verbatim with no remedy appended; only the
1336
+ // true not-found case carries the remedy.
1337
+ if (petcatFound.refusal) {
1338
+ return { ok: false, message: `host_tool "petcat.decode" refuses: ${petcatFound.refusal}` };
1339
+ }
1211
1340
  if (petcatFound.path === null) {
1212
1341
  return {
1213
1342
  ok: false,
1214
- message: `host_tool "petcat.decode" refuses: "petcat" does not exist (tried: ${petcatFound.tried.join(", ")})`,
1343
+ message: withRemedy(`host_tool "petcat.decode" refuses: "petcat" does not exist (tried: ${petcatFound.tried.join(", ")})`, "petcat", loc.here),
1215
1344
  };
1216
1345
  }
1217
1346
  const petcatPath = petcatFound.path;
@@ -1680,11 +1809,21 @@ function spawnHostTool(toolPath, argv, timeoutMs, env, cwd) {
1680
1809
  // ---------------------------------------------------------------------------
1681
1810
  // The ACME library probe. Moved server-side from acme.mjs's own
1682
1811
  // findAcmeLib(): the project owner's rule is that a container has no PATH
1683
- // to a host binary, and these five candidates are HOST paths -- so probing
1684
- // them belongs on the host side of the seam, not in the container-side
1685
- // skill script. Behaviourally identical to the removed client-side
1686
- // function: same candidate order, same marker file, same "first candidate
1687
- // whose marker exists wins" rule.
1812
+ // to a host binary, and these candidates are HOST paths -- so probing them
1813
+ // belongs on the host side of the seam, not in the container-side skill
1814
+ // script.
1815
+ //
1816
+ // PD-07 (Phase 60, correcting an earlier claim in 60-PATTERNS.md that this
1817
+ // function is "a drop-in" for the seam): WIDENED, not replaced. The seam's
1818
+ // probe layer is a $PATH walk for executable-kind ids ONLY -- a
1819
+ // directory-kind id (acme-lib's own declared `kind`) gets no probe layer at
1820
+ // all -- so the seam now answers the environment (`ACME`) and `tools.json`
1821
+ // layers ahead of this function's own fixed, well-known-install-location
1822
+ // list, and that list stays as the probe layer beneath both: same order,
1823
+ // same marker file, same "first candidate whose marker exists wins" rule it
1824
+ // has always had. Only the environment-variable candidate is gone from the
1825
+ // list below -- the seam's own env layer already covers it, reading the
1826
+ // SAME `ACME` name from the declaration rather than a literal here.
1688
1827
  // ---------------------------------------------------------------------------
1689
1828
  /** The marker file used to validate a candidate ACME library directory --
1690
1829
  * the layout fact `acme.mjs`'s own troubleshooting hint names. */
@@ -1712,10 +1851,21 @@ export function findDxaBinary(here) {
1712
1851
  }
1713
1852
  return { path: null, tried };
1714
1853
  }
1715
- function findAcmeLib() {
1854
+ function findAcmeLib(locate) {
1855
+ const loc = locatorFrom(locate);
1716
1856
  const tried = [];
1857
+ // The seam layer FIRST (PD-07): env (`ACME`) -> tools.json. A malformed
1858
+ // tools.json entry for `acme-lib` is a REFUSAL the seam already detected;
1859
+ // this function has no `refusal`-shaped return of its own (its callers
1860
+ // never had one), so a refusal is folded into "not found" here rather than
1861
+ // surfaced separately -- the caller's own conditional stderr hint (below,
1862
+ // in runHostTool()) already treats a null `path` uniformly regardless of
1863
+ // why.
1864
+ const seamResolved = resolveTool("acme-lib", { toolsDir: loc.toolsDir, projectRoot: loc.projectRoot, here: loc.here });
1865
+ tried.push(...seamResolved.tried);
1866
+ if (seamResolved.path !== null)
1867
+ return { path: seamResolved.path, tried };
1717
1868
  const candidates = [
1718
- process.env.ACME,
1719
1869
  "/usr/local/share/acme",
1720
1870
  "/usr/share/acme",
1721
1871
  "/usr/lib/acme",
@@ -1734,11 +1884,20 @@ function findAcmeLib() {
1734
1884
  // only when it does not exist, and return every candidate tried so a
1735
1885
  // refusal can name them all. Unlike findDxaBinary() (a FIXED,
1736
1886
  // project-vendored path) and findAcmeLib() (a FIXED list of well-known
1737
- // host install locations), this probe's first candidate is COMPUTED per
1887
+ // host install locations), this probe's SECOND candidate is COMPUTED per
1738
1888
  // call, from whichever x64sc backend-detect.mts already resolved -- see
1739
1889
  // the resolvedBackend() import comment above for why that call is cheap
1740
1890
  // here. No version probe: deliberately withdrawn by the project owner --
1741
1891
  // this stays a name-and-location probe only and must not gain one back.
1892
+ //
1893
+ // Plan 60-04 (LOC-04, PD-08) adds a FIRST candidate ahead of the sibling:
1894
+ // c1541 and petcat carry no environment-variable location of their own
1895
+ // (Phase 59 D-06), so `tools.json` is their only override route, and
1896
+ // ROADMAP criterion 4 requires it sit AHEAD of the sibling probe -- a
1897
+ // sibling that happens to exist must never silently outrank a path the
1898
+ // user explicitly wrote in the file. The resulting order: tools.json (via
1899
+ // the seam), then the sibling-of-x64sc candidate (unchanged), then $PATH
1900
+ // (now the seam's own exported walk, not a second hand-written copy).
1742
1901
  // ---------------------------------------------------------------------------
1743
1902
  /** Memoised per binary name for the process lifetime -- mirrors
1744
1903
  * backend-detect.mts's own stated posture of resolving once per process,
@@ -1750,40 +1909,93 @@ function findAcmeLib() {
1750
1909
  * -- this probe's OWN per-binary-name memo exists because its first
1751
1910
  * candidate is computed from a resolvedBackend() call that is itself
1752
1911
  * memoised, so re-deriving it per call would just re-walk $PATH for no new
1753
- * information). */
1912
+ * information).
1913
+ *
1914
+ * **`WR-02` (plan 60-07, code review): this memo's caching of a `tools.json`
1915
+ * answer is inconsistent with the seam's own no-memo rationale.**
1916
+ * `tool-location.mts`'s own module header states plainly that the seam
1917
+ * "deliberately holds no memo" and that caching a resolved location would
1918
+ * make a stale answer structurally possible. This memo does exactly that for
1919
+ * the one layer the whole phase exists to make user-editable: an edited
1920
+ * `tools.json` entry for `c1541`/`petcat` has NO EFFECT until this process
1921
+ * restarts, while the identical edit for `acme`/`acme-lib`/`ghidra` (neither
1922
+ * of which is memoised this way) takes effect on the very next call. The
1923
+ * roadmap's own cross-cutting constraint for this phase requires this memo
1924
+ * keep its current semantics -- widening what it can resolve (this plan's
1925
+ * own `refusal` field, below) must not change WHEN it caches -- so this
1926
+ * tension is recorded here, not resolved: no reset hatch is added, and no
1927
+ * guard enforces the tension away. The trigger for revisiting it is named in
1928
+ * `60-01-PLAN.md`'s own assumption-delta note: when the sibling-probe
1929
+ * mechanism this memo exists for moves inside the seam itself, the memo
1930
+ * moves with it or is dropped with it -- that is the right moment to
1931
+ * reconcile this function's caching posture with the seam's stated
1932
+ * no-memo rationale, not before. */
1754
1933
  const siblingBinaryMemo = new Map();
1755
- function findSiblingBinary(binaryName, resolvedX64scPath, log) {
1934
+ /** `locate` threads the caller's `HostToolLocator` into the seam call below
1935
+ * (`buildHostToolArgv()`'s own `loc`, itself built from `locatorFrom()`) --
1936
+ * absent when the caller omits it, exactly like `buildHostToolArgv()`'s own
1937
+ * `locate` parameter, and falling back to the SAME `locatorFrom()` helper.
1938
+ *
1939
+ * The seam call is deliberately NOT trusted for its own internal `$PATH`
1940
+ * layer (layer 3 inside `resolveTool()`, which fires for any
1941
+ * executable-kind id -- both `c1541` and `petcat` are -- once neither the
1942
+ * environment nor `tools.json` answered). Only a `layer === "file"` answer
1943
+ * is accepted as this function's file-layer result: accepting a
1944
+ * `"probe"`-layer answer here too would let the seam's OWN `$PATH` walk
1945
+ * silently win over the sibling candidate below, before this function ever
1946
+ * got a chance to try it -- inverting PD-08's precedence (tools.json, THEN
1947
+ * sibling, THEN `$PATH`) and resolving to a `$PATH` match with NO shadowing
1948
+ * warning, the one thing this function exists to prevent. A `refusal` (a
1949
+ * named `tools.json` entry that failed its own checks) is always terminal
1950
+ * regardless of layer, matching the seam's own file-layer contract
1951
+ * (`tool-location.mts`, D-09) -- it never falls through to the sibling
1952
+ * candidate. */
1953
+ function findSiblingBinary(binaryName, resolvedX64scPath, log, locate) {
1756
1954
  const memoised = siblingBinaryMemo.get(binaryName);
1757
1955
  if (memoised)
1758
1956
  return memoised;
1759
1957
  const tried = [];
1760
- // First candidate: the SAME directory the resolved x64sc itself lives in.
1958
+ // Layer 1 (PD-08): the tools.json seam, ahead of the sibling candidate.
1959
+ const loc = locatorFrom(locate);
1960
+ const seamResolved = resolveTool(binaryName, { toolsDir: loc.toolsDir, projectRoot: loc.projectRoot, here: loc.here });
1961
+ tried.push(...seamResolved.tried);
1962
+ if (seamResolved.refusal) {
1963
+ // WR-01 (plan 60-07): the seam's own reason is carried verbatim rather
1964
+ // than discarded -- this is the one field the two call sites below now
1965
+ // read instead of composing their own generic "does not exist" sentence.
1966
+ const result = { path: null, tried, refusal: seamResolved.refusal };
1967
+ siblingBinaryMemo.set(binaryName, result);
1968
+ return result;
1969
+ }
1970
+ if (seamResolved.path !== null && seamResolved.layer === "file") {
1971
+ const result = { path: seamResolved.path, tried, refusal: null };
1972
+ siblingBinaryMemo.set(binaryName, result);
1973
+ return result;
1974
+ }
1975
+ // Layer 2: the SAME directory the resolved x64sc itself lives in.
1761
1976
  const siblingCandidate = join(dirname(resolvedX64scPath), binaryName);
1762
1977
  tried.push(siblingCandidate);
1763
1978
  if (existsSync(siblingCandidate)) {
1764
- const result = { path: siblingCandidate, tried };
1979
+ const result = { path: siblingCandidate, tried, refusal: null };
1765
1980
  siblingBinaryMemo.set(binaryName, result);
1766
1981
  return result;
1767
1982
  }
1768
- // Fallback: a $PATH walk (mirrors defaultResolveBinPath()'s own algorithm,
1769
- // backend-detect.mts), logging a warning naming the resolved x64sc path,
1770
- // the PATH match, and that this MAY be a DIFFERENT VICE build than the
1771
- // emulator -- never a silent PATH fallback.
1772
- const pathEnv = process.env.PATH ?? "";
1773
- for (const dir of pathEnv.split(":")) {
1774
- if (!dir)
1775
- continue;
1776
- const candidate = join(dir, binaryName);
1777
- tried.push(candidate);
1778
- if (existsSync(candidate)) {
1779
- log?.(`host_tool: "${binaryName}" was not found alongside the resolved x64sc (${resolvedX64scPath}); ` +
1780
- `falling back to a $PATH match at ${candidate} -- this may be a DIFFERENT VICE build than the emulator`);
1781
- const result = { path: candidate, tried };
1782
- siblingBinaryMemo.set(binaryName, result);
1783
- return result;
1784
- }
1983
+ // Layer 3: the seam's own exported $PATH walk (resolveOnPath(), the
1984
+ // SECOND of Phase 59 D-02's three coexisting copies to collapse -- plan
1985
+ // 60-01 collapsed the first, inside backend-detect.mts). Mirrors
1986
+ // defaultResolveBinPath()'s own algorithm exactly; the warning stays HERE
1987
+ // rather than inside the seam, because only this function knows the
1988
+ // resolved x64sc a $PATH match is being compared against.
1989
+ const probe = resolveOnPath(binaryName, process.env);
1990
+ tried.push(...probe.tried);
1991
+ if (probe.path) {
1992
+ log?.(`host_tool: "${binaryName}" was not found alongside the resolved x64sc (${resolvedX64scPath}); ` +
1993
+ `falling back to a $PATH match at ${probe.path} -- this may be a DIFFERENT VICE build than the emulator`);
1994
+ const result = { path: probe.path, tried, refusal: null };
1995
+ siblingBinaryMemo.set(binaryName, result);
1996
+ return result;
1785
1997
  }
1786
- const result = { path: null, tried };
1998
+ const result = { path: null, tried, refusal: null };
1787
1999
  siblingBinaryMemo.set(binaryName, result);
1788
2000
  return result;
1789
2001
  }
@@ -1815,6 +2027,13 @@ export async function runHostTool(raw, deps) {
1815
2027
  if (request.tool === "oracle.run")
1816
2028
  return runOracleRun(request.args, deps);
1817
2029
  const repoRootAbs = resolvePath(deps.repoRoot);
2030
+ // Built ONCE, from the already-computed repoRootAbs (PD-06), and passed as
2031
+ // the fourth argument to every buildHostToolArgv() call site below plus
2032
+ // findAcmeLib() and ghidra.installExtension's own pre-materialisation
2033
+ // resolution -- one locator, not a per-branch re-derivation. `here` is
2034
+ // `deps.here`, always undefined in production (HostToolDeps.here's own
2035
+ // doc comment).
2036
+ const hostToolLocator = { toolsDir: join(repoRootAbs, ".c64-re-tools"), projectRoot: repoRootAbs, here: deps.here };
1818
2037
  let built;
1819
2038
  let acmeLib = null;
1820
2039
  // resolveGhidraProject() (below, in the ghidra.analyze branch) RESERVES
@@ -1853,8 +2072,8 @@ export async function runHostTool(raw, deps) {
1853
2072
  return { ok: false, message: includeResolved.message };
1854
2073
  includePaths.push(includeResolved.path);
1855
2074
  }
1856
- built = buildHostToolArgv(request, { sourcePath: sourceResolved.path, outDirPath, includePaths });
1857
- acmeLib = findAcmeLib();
2075
+ built = buildHostToolArgv(request, { sourcePath: sourceResolved.path, outDirPath, includePaths }, undefined, hostToolLocator);
2076
+ acmeLib = findAcmeLib(hostToolLocator);
1858
2077
  }
1859
2078
  else if (request.tool === "ghidra.analyze") {
1860
2079
  // Converted from a previous `if (acme.build) … else (ghidra.analyze)`
@@ -1938,7 +2157,7 @@ export async function runHostTool(raw, deps) {
1938
2157
  entrypointsPathResolved,
1939
2158
  exportPathResolved,
1940
2159
  dataRangesPathResolved,
1941
- });
2160
+ }, undefined, hostToolLocator);
1942
2161
  }
1943
2162
  else if (request.tool === "dxa.disassemble") {
1944
2163
  // (35-01, item 7). `image` and each present optional path resolved
@@ -1986,7 +2205,7 @@ export async function runHostTool(raw, deps) {
1986
2205
  entrypointsPath,
1987
2206
  datablocksPath,
1988
2207
  labelsPath,
1989
- });
2208
+ }, undefined, hostToolLocator);
1990
2209
  }
1991
2210
  else if (request.tool === "ghidra.installExtension") {
1992
2211
  // `sourceDir`
@@ -2018,18 +2237,23 @@ export async function runHostTool(raw, deps) {
2018
2237
  `installation; got ${JSON.stringify(request.args.sourceDir)}, which resolves to ${sourceDirResolved.path}`,
2019
2238
  };
2020
2239
  }
2021
- const ghidraHome = process.env.GHIDRA_HOME;
2022
- if (ghidraHome === undefined || ghidraHome === "") {
2240
+ const ghidraResolvedPre = resolveTool("ghidra", {
2241
+ toolsDir: hostToolLocator.toolsDir,
2242
+ projectRoot: hostToolLocator.projectRoot,
2243
+ here: hostToolLocator.here,
2244
+ });
2245
+ if (ghidraResolvedPre.path === null) {
2023
2246
  return {
2024
2247
  ok: false,
2025
- message: `host_tool "ghidra.installExtension" requires the GHIDRA_HOME environment variable to name a Ghidra installation directory; it is unset`,
2248
+ message: withRemedy(`host_tool "ghidra.installExtension" refuses: no Ghidra installation directory is known${ghidraResolvedPre.refusal ? `: ${ghidraResolvedPre.refusal}` : ` (tried: ${ghidraResolvedPre.tried.join(", ")})`}`, "ghidra", hostToolLocator.here),
2026
2249
  };
2027
2250
  }
2251
+ const ghidraHome = ghidraResolvedPre.path;
2028
2252
  const sleighPath = join(ghidraHome, "support", "sleigh");
2029
2253
  if (!existsSync(sleighPath)) {
2030
2254
  return {
2031
2255
  ok: false,
2032
- message: `host_tool "ghidra.installExtension" refuses: GHIDRA_HOME's resolved "support/sleigh" does not exist on disk (${sleighPath})`,
2256
+ message: withRemedy(`host_tool "ghidra.installExtension" refuses: the resolved Ghidra installation directory (${ghidraHome}) does not contain "support/sleigh"`, "ghidra", hostToolLocator.here),
2033
2257
  };
2034
2258
  }
2035
2259
  const stockLanguagesDir = join(ghidraHome, "Ghidra", "Processors", "6502", "data", "languages");
@@ -2055,7 +2279,7 @@ export async function runHostTool(raw, deps) {
2055
2279
  message: `host_tool "ghidra.installExtension" failed to materialise the extension at ${installDir}: ${e instanceof Error ? e.message : String(e)}`,
2056
2280
  };
2057
2281
  }
2058
- built = buildHostToolArgv(request, { sourceDirPath: sourceDirResolved.path, moduleName: request.args.moduleName });
2282
+ built = buildHostToolArgv(request, { sourceDirPath: sourceDirResolved.path, moduleName: request.args.moduleName }, undefined, hostToolLocator);
2059
2283
  }
2060
2284
  else {
2061
2285
  // request.tool is one of the five c1541.* ids or petcat.decode -- every
@@ -2080,7 +2304,7 @@ export async function runHostTool(raw, deps) {
2080
2304
  else {
2081
2305
  outDirPath = dirname(imageResolved.path);
2082
2306
  }
2083
- built = buildHostToolArgv(request, { imagePath: imageResolved.path, outDirPath }, deps.log);
2307
+ built = buildHostToolArgv(request, { imagePath: imageResolved.path, outDirPath }, deps.log, hostToolLocator);
2084
2308
  }
2085
2309
  if (!built.ok) {
2086
2310
  // buildHostToolArgv()'s own GHIDRA_HOME/launcher/language preflight
@@ -2456,7 +2680,7 @@ async function runOracleRun(args, deps) {
2456
2680
  }
2457
2681
  // ---------------------------------------------------------------------------
2458
2682
  // CLI entry point (guarded on being the process entry point, the
2459
- // check-npm-packages.mjs:159 IS_ENTRY_POINT idiom). Needed for the
2683
+ // entry-point idiom the retired tarball checker also used). Needed for the
2460
2684
  // host-local route (no broker in the loop). `node resources/host-tool.mjs
2461
2685
  // run --repo-root <path> --request <json>` prints the response as one JSON
2462
2686
  // line on stdout and exits non-zero on a refusal.