@adhisang/minecraft-modding-mcp 7.0.0-rc.0 → 7.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +131 -49
  2. package/README.md +2 -0
  3. package/dist/cache-registry.d.ts +10 -0
  4. package/dist/cache-registry.js +44 -3
  5. package/dist/entry-tools/batch-class-members-service.js +8 -0
  6. package/dist/entry-tools/inspect-minecraft/handlers/class-members.js +21 -0
  7. package/dist/entry-tools/inspect-minecraft/internal.d.ts +4 -0
  8. package/dist/entry-tools/manage-cache-service.d.ts +2 -2
  9. package/dist/entry-tools/validate-project/cases/mixin.js +26 -6
  10. package/dist/entry-tools/validate-project/cases/project-summary.d.ts +7 -7
  11. package/dist/entry-tools/validate-project-service.d.ts +2 -2
  12. package/dist/entry-tools/verify-mixin-target-service.js +19 -1
  13. package/dist/error-mapping.d.ts +118 -0
  14. package/dist/error-mapping.js +177 -7
  15. package/dist/index.js +3 -1
  16. package/dist/json-rpc-framing.d.ts +53 -1
  17. package/dist/json-rpc-framing.js +190 -25
  18. package/dist/mapping/loaders/tiny-loom-selection.js +10 -3
  19. package/dist/mapping/parsers/tiny.js +4 -0
  20. package/dist/mapping-service.js +24 -3
  21. package/dist/maven-resolver.d.ts +61 -0
  22. package/dist/maven-resolver.js +95 -4
  23. package/dist/maven-token.d.ts +65 -0
  24. package/dist/maven-token.js +120 -0
  25. package/dist/mcp-helpers.js +14 -3
  26. package/dist/minecraft-explorer-service.d.ts +13 -1
  27. package/dist/minecraft-explorer-service.js +200 -7
  28. package/dist/repo-downloader.d.ts +260 -0
  29. package/dist/repo-downloader.js +1083 -14
  30. package/dist/source/artifact-resolver.d.ts +97 -3
  31. package/dist/source/artifact-resolver.js +228 -24
  32. package/dist/source/class-source.d.ts +15 -0
  33. package/dist/source/class-source.js +163 -18
  34. package/dist/source/indexer.js +14 -0
  35. package/dist/source/nested-jars.d.ts +16 -1
  36. package/dist/source/nested-jars.js +66 -3
  37. package/dist/source/workspace-target.js +39 -20
  38. package/dist/source-jar-reader.d.ts +30 -2
  39. package/dist/source-jar-reader.js +39 -8
  40. package/dist/source-resolver.d.ts +48 -0
  41. package/dist/source-resolver.js +735 -81
  42. package/dist/source-service.d.ts +10 -1
  43. package/dist/stdio-supervisor.d.ts +75 -1
  44. package/dist/stdio-supervisor.js +240 -14
  45. package/dist/storage/artifacts-repo.d.ts +2 -0
  46. package/dist/storage/artifacts-repo.js +22 -0
  47. package/dist/storage/db.js +32 -10
  48. package/dist/tool-guidance.js +17 -7
  49. package/dist/tool-schemas.d.ts +10 -2
  50. package/dist/tool-schemas.js +4 -0
  51. package/dist/v1-parity-schemas.js +16 -0
  52. package/dist/version-diff-service.js +25 -4
  53. package/dist/version-service.js +23 -8
  54. package/dist/warning-details.js +3 -1
  55. package/dist/workspace-mapping-service.d.ts +8 -1
  56. package/dist/workspace-mapping-service.js +28 -19
  57. package/docs/tool-reference.md +31 -7
  58. package/package.json +6 -4
@@ -4,6 +4,7 @@ import { join, resolve } from "node:path";
4
4
  import { mapWithConcurrencyLimit } from "./concurrency.js";
5
5
  import { createError, ERROR_CODES } from "./errors.js";
6
6
  import { normalizeOptionalPathForHost } from "./path-converter.js";
7
+ import { downloadSidecarPath, isDownloadSidecarPath } from "./repo-downloader.js";
7
8
  import { openDatabase } from "./storage/db.js";
8
9
  import { getProcessWorkspaceContextCache } from "./workspace-context-cache.js";
9
10
  export const PUBLIC_CACHE_KINDS = [
@@ -226,7 +227,11 @@ function openDb(config) {
226
227
  if (!existsSync(config.sqlitePath)) {
227
228
  return undefined;
228
229
  }
229
- return openDatabase(config).db;
230
+ return openDatabase({
231
+ sqlitePath: config.sqlitePath,
232
+ sqliteCacheKb: config.sqliteCacheKb,
233
+ sqliteMmapSize: config.sqliteMmapSize
234
+ }).db;
230
235
  }
231
236
  function candidatePathsForEntry(entry) {
232
237
  const paths = new Set();
@@ -467,15 +472,28 @@ async function fileBackedEntries(config, cacheKind, detectCorruption) {
467
472
  }
468
473
  const root = kindRoot(config, cacheKind);
469
474
  const files = await listFilesRecursive(root);
470
- return mapWithConcurrencyLimit(files, CACHE_STAT_CONCURRENCY, async (filePath) => {
475
+ // A download sidecar (`<jar>.cache.json`, or the `.<hex>.tmp` leftover of an
476
+ // interrupted write) is the identity record of the jar beside it, not a cached
477
+ // artifact in its own right. Listing one would report a `downloads` entry whose
478
+ // jarPath is a JSON file and would let a jarPath selector delete a description
479
+ // instead of the thing described. The finished record's bytes are folded into
480
+ // the jar's entry below, so a listed entry weighs everything that belongs to
481
+ // it; bytes that describe nothing - an orphan record, a half-written one - are
482
+ // deliberately unaccounted, because there is no entry for them to belong to.
483
+ // Only this kind names files that way; every other kind keeps every file.
484
+ const entryFiles = cacheKind === "downloads"
485
+ ? files.filter((filePath) => !isDownloadSidecarPath(filePath))
486
+ : files;
487
+ return mapWithConcurrencyLimit(entryFiles, CACHE_STAT_CONCURRENCY, async (filePath) => {
471
488
  const fileStat = await stat(filePath);
489
+ const sidecarBytes = cacheKind === "downloads" ? await downloadSidecarSizeBytes(filePath) : 0;
472
490
  const normalizedEntryId = filePath.slice(root.length + 1);
473
491
  const inferredScope = inferScope(filePath, normalizedEntryId) ?? (cacheKind === "decompiled-source" ? "vanilla" : undefined);
474
492
  return {
475
493
  cacheKind,
476
494
  entryId: normalizedEntryId,
477
495
  path: filePath,
478
- sizeBytes: fileStat.size,
496
+ sizeBytes: fileStat.size + sidecarBytes,
479
497
  status: "healthy",
480
498
  meta: {
481
499
  updatedAt: fileStat.mtime.toISOString(),
@@ -483,6 +501,8 @@ async function fileBackedEntries(config, cacheKind, detectCorruption) {
483
501
  mapping: inferMapping(filePath, normalizedEntryId),
484
502
  scope: inferredScope,
485
503
  projectPath: inferProjectPath(filePath, config.pathRuntimeInfo),
504
+ // Health follows the cached artifact's own bytes: a zero-byte jar stays
505
+ // partial no matter how much its sidecar weighs.
486
506
  partial: fileStat.size === 0,
487
507
  corrupt: cacheKind === "registry" && detectCorruption ? await isCorruptRegistryJson(filePath) : false,
488
508
  inUse: filePath.endsWith(".lock") ||
@@ -495,6 +515,19 @@ async function fileBackedEntries(config, cacheKind, detectCorruption) {
495
515
  };
496
516
  });
497
517
  }
518
+ /**
519
+ * Bytes of the sidecar describing `downloadPath`, or 0 when there is none.
520
+ * A download cached before sidecars existed, or one whose sidecar write failed,
521
+ * is a normal state and must not fail the inventory.
522
+ */
523
+ async function downloadSidecarSizeBytes(downloadPath) {
524
+ try {
525
+ return (await stat(downloadSidecarPath(downloadPath))).size;
526
+ }
527
+ catch {
528
+ return 0;
529
+ }
530
+ }
498
531
  /**
499
532
  * Binary-remap cache entries are keyed by the final artifact id even when the
500
533
  * on-disk entry is a corrupt final directory or a leftover temp path.
@@ -661,6 +694,14 @@ export function createCacheRegistry(config) {
661
694
  workspaceCache.invalidate(entry.entryId);
662
695
  continue;
663
696
  }
697
+ if (entry.cacheKind === "downloads") {
698
+ // The sidecar is part of this entry, so it goes with the jar —
699
+ // outside the existsSync guard below, so a jar that vanished
700
+ // out-of-band since the listing still takes its sidecar with it
701
+ // instead of leaving an orphan behind. `force` makes a missing
702
+ // sidecar a no-op.
703
+ await rm(downloadSidecarPath(entry.path), { force: true });
704
+ }
664
705
  if (existsSync(entry.path)) {
665
706
  // Only binary-remap inventory can return directories as entries;
666
707
  // other file-backed kinds keep their existing file-only contract.
@@ -48,6 +48,14 @@ export class BatchClassMembersService {
48
48
  }
49
49
  const raw = (await this.deps.getClassMembers({
50
50
  artifactId: sharedArtifact.artifactId,
51
+ // This artifactId is OURS, not the caller's: the shared target was
52
+ // resolved above and every entry is dispatched by the result. Without
53
+ // saying so, get-class-members reads the bare presence of an
54
+ // artifactId as the caller having named the artifact, and reports a
55
+ // missing binary jar as their mistake - once per entry - though
56
+ // `target` here cannot name an artifact at all. Only a jar the caller
57
+ // named themselves is genuinely their choice.
58
+ artifactSelectedBy: input.target.kind === "jar" ? "caller" : "tool",
51
59
  className: entry.className,
52
60
  access: entry.access,
53
61
  includeSynthetic: entry.includeSynthetic,
@@ -1,5 +1,17 @@
1
1
  import { buildEntryToolResult, buildEntryToolMeta, createNextAction, createSummarySubject, createTruncationMeta } from "../../response-contract.js";
2
2
  import { buildClassSubject, resolveClassArtifactReference, invalidTaskSubjectError } from "../internal.js";
3
+ function artifactSelectedByFor(ref) {
4
+ if (!ref) {
5
+ return "tool";
6
+ }
7
+ // A `resolved-id` reference is NOT a caller choice, despite being written by
8
+ // the caller. The id is an opaque handle some earlier resolve produced: it
9
+ // says nothing about whether the artifact carries a binary jar, and it cannot
10
+ // be re-resolved into one that does. Only `target: { kind: "jar", ... }`
11
+ // names a jar the caller actually picked and can pick differently, which is
12
+ // the same line `get-class-members` draws for its own `artifact` target.
13
+ return ref.type === "resolve-target" && ref.target.kind === "jar" ? "caller" : "tool";
14
+ }
3
15
  export async function handleClassMembers(deps, subject, detail, include, limit) {
4
16
  if (subject.kind !== "class" && !(subject.kind === "workspace" && subject.focus?.kind === "class")) {
5
17
  invalidTaskSubjectError("class-members", subject);
@@ -9,6 +21,15 @@ export async function handleClassMembers(deps, subject, detail, include, limit)
9
21
  const members = await deps.getClassMembers({
10
22
  className: classSubject.className,
11
23
  artifactId: artifact.artifactId || undefined,
24
+ // The artifactId above is one WE produced - resolveClassArtifactReference
25
+ // collapses every subject shape to one, including the workspace
26
+ // auto-resolution that happens with no artifact reference at all. Only the
27
+ // caller's own reference says whether they picked the artifact, and only a
28
+ // jar target does: it names an exact jar they can name differently.
29
+ // Anything else (a resolved-id handle, a version/coordinate target, or an
30
+ // omitted reference) leaves the artifact effectively ours, so a missing
31
+ // binary jar is not their input to fix.
32
+ artifactSelectedBy: artifactSelectedByFor(classSubject.artifact),
12
33
  mapping: classSubject.mapping,
13
34
  scope: classSubject.scope,
14
35
  projectPath: classSubject.projectPath,
@@ -424,6 +424,10 @@ export type InspectMinecraftDeps = {
424
424
  strictVersion?: boolean;
425
425
  maxMembers?: number;
426
426
  includeDescriptors?: boolean;
427
+ /** Who chose the artifact: see `GetClassMembersInput.artifactSelectedBy`.
428
+ * This tool always collapses its subject to an artifactId before calling,
429
+ * so without it every failure would be attributed to the caller. */
430
+ artifactSelectedBy?: "caller" | "tool";
427
431
  }) => Promise<GetClassMembersOutput>;
428
432
  searchClassSource: (input: {
429
433
  artifactId: string;
@@ -27,9 +27,9 @@ export declare const manageCacheShape: {
27
27
  jarPath: z.ZodOptional<z.ZodString>;
28
28
  entryId: z.ZodOptional<z.ZodString>;
29
29
  status: z.ZodOptional<z.ZodEnum<{
30
+ stale: "stale";
30
31
  healthy: "healthy";
31
32
  partial: "partial";
32
- stale: "stale";
33
33
  orphaned: "orphaned";
34
34
  corrupt: "corrupt";
35
35
  in_use: "in_use";
@@ -81,9 +81,9 @@ export declare const manageCacheSchema: z.ZodObject<{
81
81
  jarPath: z.ZodOptional<z.ZodString>;
82
82
  entryId: z.ZodOptional<z.ZodString>;
83
83
  status: z.ZodOptional<z.ZodEnum<{
84
+ stale: "stale";
84
85
  healthy: "healthy";
85
86
  partial: "partial";
86
- stale: "stale";
87
87
  orphaned: "orphaned";
88
88
  corrupt: "corrupt";
89
89
  in_use: "in_use";
@@ -21,14 +21,34 @@ export async function handleMixin(deps, input, detail, include) {
21
21
  details: {
22
22
  task: "mixin",
23
23
  failedStage: "input-validation",
24
- nextAction: "Pass version explicitly (e.g. \"1.21.10\"). task=\"project-summary\" supports preferProjectVersion for auto-detection from gradle.properties, but direct task=\"mixin\" requires an explicit version.",
24
+ nextAction: "Pass version explicitly: the Minecraft version this project targets. task=\"project-summary\" supports preferProjectVersion for auto-detection from gradle.properties, but direct task=\"mixin\" requires an explicit version. Call the suggested list-versions to see what is available, then replay the exampleCalls template with that version substituted.",
25
+ // No concrete version can be derived here -- the caller omitted it and this
26
+ // task does not auto-detect one. A `suggestedCall` is a payload the caller
27
+ // may replay verbatim, so filling the hole with a made-up version produced
28
+ // a call that RUNS and validates the mixin against the wrong Minecraft
29
+ // version, which is worse than no suggestion.
30
+ //
31
+ // So the two roles are split, following what the sibling "a version is
32
+ // required but none was resolved" site already does in
33
+ // src/source/class-source.ts: `suggestedCall` is a REAL next step that
34
+ // needs nothing the caller does not have (list-versions takes no
35
+ // arguments and answers exactly the question blocking them), while the
36
+ // task="mixin" retry shape travels as an `exampleCalls` template whose
37
+ // placeholder makes the substitution the caller must perform obvious.
38
+ ...buildSuggestedCall({ tool: "list-versions", params: {} }),
25
39
  ...buildSuggestedCall({
26
40
  tool: "validate-project",
27
- params: {
28
- task: "mixin",
29
- subject: input.subject,
30
- version: "1.21.10"
31
- }
41
+ params: undefined,
42
+ examples: [
43
+ {
44
+ params: {
45
+ task: "mixin",
46
+ subject: input.subject,
47
+ version: "<your-mc-version>"
48
+ },
49
+ reason: "Replace <your-mc-version> with the Minecraft version this project targets (gradle.properties, or task=\"project-summary\" with preferProjectVersion=true reports it)."
50
+ }
51
+ ]
32
52
  })
33
53
  }
34
54
  });
@@ -9,7 +9,7 @@ export declare function handleProjectSummary(deps: ValidateProjectDeps, input: V
9
9
  warnings: string[];
10
10
  tasks?: {
11
11
  "workspace.detected": {
12
- status: "error" | "ok" | "missing" | "skipped";
12
+ status: "ok" | "error" | "missing" | "skipped";
13
13
  durationMs?: number;
14
14
  error?: {
15
15
  code: string;
@@ -20,7 +20,7 @@ export declare function handleProjectSummary(deps: ValidateProjectDeps, input: V
20
20
  evidence?: string[];
21
21
  };
22
22
  "gradle.readable": {
23
- status: "error" | "ok" | "missing" | "skipped";
23
+ status: "ok" | "error" | "missing" | "skipped";
24
24
  durationMs?: number;
25
25
  error?: {
26
26
  code: string;
@@ -32,7 +32,7 @@ export declare function handleProjectSummary(deps: ValidateProjectDeps, input: V
32
32
  buildScripts?: string[];
33
33
  };
34
34
  "loom.cache.found": {
35
- status: "error" | "ok" | "missing" | "skipped";
35
+ status: "ok" | "error" | "missing" | "skipped";
36
36
  durationMs?: number;
37
37
  error?: {
38
38
  code: string;
@@ -43,7 +43,7 @@ export declare function handleProjectSummary(deps: ValidateProjectDeps, input: V
43
43
  cachePath?: string;
44
44
  };
45
45
  "minecraft.artifact.resolved": {
46
- status: "error" | "ok" | "missing" | "skipped";
46
+ status: "ok" | "error" | "missing" | "skipped";
47
47
  durationMs?: number;
48
48
  error?: {
49
49
  code: string;
@@ -55,7 +55,7 @@ export declare function handleProjectSummary(deps: ValidateProjectDeps, input: V
55
55
  mapping?: import("../../../types.js").SourceMapping;
56
56
  };
57
57
  "mixins.validated": {
58
- status: "error" | "ok" | "missing" | "skipped";
58
+ status: "ok" | "error" | "missing" | "skipped";
59
59
  durationMs?: number;
60
60
  error?: {
61
61
  code: string;
@@ -70,7 +70,7 @@ export declare function handleProjectSummary(deps: ValidateProjectDeps, input: V
70
70
  };
71
71
  };
72
72
  "accessWideners.validated": {
73
- status: "error" | "ok" | "missing" | "skipped";
73
+ status: "ok" | "error" | "missing" | "skipped";
74
74
  durationMs?: number;
75
75
  error?: {
76
76
  code: string;
@@ -84,7 +84,7 @@ export declare function handleProjectSummary(deps: ValidateProjectDeps, input: V
84
84
  };
85
85
  };
86
86
  "accessTransformers.validated": {
87
- status: "error" | "ok" | "missing" | "skipped";
87
+ status: "ok" | "error" | "missing" | "skipped";
88
88
  durationMs?: number;
89
89
  error?: {
90
90
  code: string;
@@ -134,8 +134,8 @@ export declare const validateProjectShape: {
134
134
  sourceRoots: z.ZodOptional<z.ZodArray<z.ZodString>>;
135
135
  configPaths: z.ZodOptional<z.ZodArray<z.ZodString>>;
136
136
  minSeverity: z.ZodDefault<z.ZodEnum<{
137
- all: "all";
138
137
  error: "error";
138
+ all: "all";
139
139
  warning: "warning";
140
140
  }>>;
141
141
  hideUncertain: z.ZodDefault<z.ZodBoolean>;
@@ -246,8 +246,8 @@ export declare const validateProjectSchema: z.ZodObject<{
246
246
  sourceRoots: z.ZodOptional<z.ZodArray<z.ZodString>>;
247
247
  configPaths: z.ZodOptional<z.ZodArray<z.ZodString>>;
248
248
  minSeverity: z.ZodDefault<z.ZodEnum<{
249
- all: "all";
250
249
  error: "error";
250
+ all: "all";
251
251
  warning: "warning";
252
252
  }>>;
253
253
  hideUncertain: z.ZodDefault<z.ZodBoolean>;
@@ -221,7 +221,25 @@ export class VerifyMixinTargetService {
221
221
  throw createError({
222
222
  code: ERROR_CODES.CONTEXT_UNRESOLVED,
223
223
  message: `verify-mixin-target requires a binary jar but artifact "${resolved.artifactId}" has none.`,
224
- details: { artifactId: resolved.artifactId, owner }
224
+ details: {
225
+ artifactId: resolved.artifactId,
226
+ owner,
227
+ // `ERR_CONTEXT_UNRESOLVED` classifies as `code_issue` by code, which
228
+ // is right for the sibling case (a caller naming a version no
229
+ // artifact carries) and for a `target: { kind: "jar", ... }` request:
230
+ // there the caller named the exact jar, and having none for its own
231
+ // sources is exactly the input the caller can change. It is wrong
232
+ // only when the artifact was picked by the TOOL from a non-jar target
233
+ // (version/coordinate/workspace/dependency), where whether it ships a
234
+ // binary jar is not something the request can express. Published as
235
+ // caller-fixable in that case it invites an endless retry of an input
236
+ // that was never at fault, so this site overrides the default only
237
+ // then.
238
+ issueOrigin: input.target?.kind === "jar" ? undefined : "tool_issue",
239
+ nextAction: `verify-mixin-target reads the target's members from bytecode, so it needs an artifact with a binary jar. `
240
+ + `Re-target with target: { kind: "jar", value: "<path to the jar>" }, or with a version whose artifact carries one `
241
+ + `(resolve-artifact reports binaryJarPath for the target you pass).`
242
+ }
225
243
  });
226
244
  }
227
245
  let signature;
@@ -57,6 +57,23 @@ export type ProblemDetails = {
57
57
  exampleCalls?: ExampleCall[];
58
58
  /** Ranked near-miss candidates for a class/symbol that was not found. */
59
59
  didYouMean?: DidYouMeanCandidate[];
60
+ /**
61
+ * Nested-jar inventory of the shell jar the lookup ran against, as jar-entry
62
+ * names (e.g. "META-INF/jars/api.jar"). A shell jar holds no classes of its
63
+ * own, so a class-not-found error on one is only actionable with the list of
64
+ * inner jars the caller can target next. Like `didYouMean`, it travels as a
65
+ * dedicated typed field: `context` is primitive-only and can never carry it.
66
+ */
67
+ nestedJars?: string[];
68
+ /**
69
+ * True when `nestedJars` is a SHORTENED view of the shell's inventory —
70
+ * entries were dropped by the count cap, the per-entry length cap, or the
71
+ * total-bytes cap. Without it a caller cannot tell a complete inventory from
72
+ * a trimmed one and may conclude a class is bundled nowhere. Additive and
73
+ * omitted entirely when the published list is complete, matching the
74
+ * `candidatesTruncated` precedent.
75
+ */
76
+ nestedJarsTruncated?: boolean;
60
77
  failedStage?: string;
61
78
  context?: Record<string, string | number | boolean>;
62
79
  };
@@ -67,6 +84,67 @@ export type ProblemDetails = {
67
84
  * whole rather than partially published.
68
85
  */
69
86
  export declare function extractDidYouMean(details: unknown): DidYouMeanCandidate[] | undefined;
87
+ /**
88
+ * The `nestedJars` pair as published: the inventory plus the additive flag that
89
+ * says whether it is complete. Both keys are omitted when there is nothing to
90
+ * publish, so the object spreads directly into a ProblemDetails.
91
+ */
92
+ export type NestedJarsField = {
93
+ nestedJars?: string[];
94
+ nestedJarsTruncated?: boolean;
95
+ };
96
+ /**
97
+ * Validates and extracts a `nestedJars` inventory from error details.
98
+ * `buildClassSourceNotFoundError` records it whenever the lookup ran against a
99
+ * shell jar, but `context` is primitive-only, so without this the inventory
100
+ * never left the process.
101
+ *
102
+ * Two distinct dispositions, deliberately not conflated:
103
+ * - MALFORMED (a non-string or empty-string element, anywhere in the array):
104
+ * the whole array is dropped, matching {@link extractDidYouMean}. The scan
105
+ * deliberately continues past the caps so a malformed element beyond them is
106
+ * still found — the upstream array is a real jar's own entry list, bounded by
107
+ * the archive, so the full scan is not an exposure.
108
+ * - OVERSIZED (an entry longer than {@link MAX_NESTED_JAR_ENTRY_BYTES}, an
109
+ * entry past the count cap, or one that would push the list past the total
110
+ * byte budget): that ENTRY alone is excluded and `nestedJarsTruncated` is
111
+ * set. An implausible name is not evidence that the rest of the inventory is
112
+ * untrustworthy, so it must not drop the array.
113
+ *
114
+ * An empty inventory is dropped as before: the producing site omits the key
115
+ * entirely in that case, so an empty array carries no information a caller could
116
+ * act on. If the caps leave nothing publishable, both keys are omitted rather
117
+ * than publishing a truncation flag with no list beside it.
118
+ */
119
+ export declare function extractNestedJarsField(details: unknown): NestedJarsField;
120
+ /**
121
+ * Inventory-only view of {@link extractNestedJarsField}.
122
+ *
123
+ * NOT for an emission site. All three - the tool envelope, the batch entry, the
124
+ * error resource - publish the field pair, because dropping the flag makes a
125
+ * shortened inventory indistinguishable from a complete one. This remains for
126
+ * callers that want the list alone (tests pinning the validation and capping
127
+ * rules), and a new publisher should reach for the field-returning form.
128
+ */
129
+ export declare function extractNestedJars(details: unknown): string[] | undefined;
130
+ /**
131
+ * Per-throw-site {@link IssueOrigin} override carried on an AppError's
132
+ * `details.issueOrigin`.
133
+ *
134
+ * The default classification is keyed purely on the error CODE, which is right
135
+ * for codes whose every throw site shares one origin. A few codes do not:
136
+ * `ERR_CONTEXT_UNRESOLVED` covers both a caller naming a version no artifact
137
+ * carries (genuinely `code_issue`) and the tool resolving an artifact with no
138
+ * binary jar behind the caller's back (`tool_issue`). Publishing the latter as
139
+ * caller-fixable sends an agent into a retry loop over input it cannot repair.
140
+ *
141
+ * This override is deliberately opt-in and per-error: `issueOriginForErrorCode`
142
+ * and its default sets stay untouched, so the exhaustive classification map in
143
+ * tests/runtime/error-mapping.test.ts keeps forcing a conscious decision for
144
+ * every new code. The key is not in `CONTEXT_ALLOWLIST`, so it cannot leak into
145
+ * the public `context` blob.
146
+ */
147
+ export declare function extractIssueOriginOverride(details: unknown): IssueOrigin | undefined;
70
148
  export declare function statusForErrorCode(code: string): number;
71
149
  /**
72
150
  * Single source of truth mapping an error code to its {@link RetryClass}. Used
@@ -85,6 +163,46 @@ export declare function retryClassForErrorCode(code: string): RetryClass;
85
163
  * not satisfy).
86
164
  */
87
165
  export declare function issueOriginForErrorCode(code: string): IssueOrigin;
166
+ /**
167
+ * The single source of truth for a ProblemDetails classification pair, and the
168
+ * ONLY supported way to compute one for an error the server caught. Every site
169
+ * that turns a caught error into a published envelope spreads this result — the
170
+ * tool envelope (`mapErrorToProblem`), batch entries, the batch-aborted
171
+ * sentinel, and the `mc://` resource read — so one failure always publishes the
172
+ * same `{ retryClass, issueOrigin }` no matter which access path the caller
173
+ * used.
174
+ *
175
+ * KNOWN EXCEPTION — the synthetic supervisor replies in `src/stdio-supervisor.ts`
176
+ * (`buildSupervisorQueueLimitReply`, `buildValidateProjectTimeoutReply`) write
177
+ * the pair as literals and never import this builder. They are not emission
178
+ * sites for a caught error at all: the supervisor answers a request the worker
179
+ * never got to run, so there is no source `AppError` whose `details` could carry
180
+ * an override and nothing to pass as the required `details` argument. Their
181
+ * literals are `transient` / `tool_issue`, which is exactly what this function
182
+ * returns for `ERR_LIMIT_EXCEEDED` and `ERR_TOOL_TIMEOUT` today — but that is a
183
+ * duplicated value held in agreement by hand, not a second call into this
184
+ * classifier. Reclassifying either code here must be mirrored there.
185
+ *
186
+ * It exists because that pair used to be assembled by hand at each site. The
187
+ * `issueOrigin` half has a per-throw-site override (see
188
+ * {@link extractIssueOriginOverride}), and honouring it was copy-pasted into
189
+ * some sites and forgotten in others — which is exactly how one identical
190
+ * AppError came to publish `tool_issue` through a tool call and `code_issue`
191
+ * through a resource read. Composing the pair here removes the opportunity:
192
+ * emission modules import this function and no longer reach the component
193
+ * classifiers at all.
194
+ *
195
+ * `details` is REQUIRED, deliberately without a default. A site that genuinely
196
+ * has no AppError behind it (a ZodError, a sanitized non-AppError, the
197
+ * batch-aborted sentinel) must pass `undefined` explicitly, so skipping the
198
+ * override can only ever be a written-down decision rather than an omission.
199
+ *
200
+ * Only `issueOrigin` is overridable per throw site. `retryClass` stays purely
201
+ * code-derived: it is a documented wire contract, and changing the value an
202
+ * existing code publishes is a Breaking change, so a `retryClass` override seam
203
+ * was deliberately deferred rather than added alongside this one.
204
+ */
205
+ export declare function problemClassification(code: string, details: unknown): Pick<ProblemDetails, "retryClass" | "issueOrigin">;
88
206
  /**
89
207
  * Pick the allowlisted, primitive-valued fields out of an AppError's `details`
90
208
  * so the public envelope can carry structured repair context without leaking