@dungle-scrubs/harness-cli-normalizer 0.4.6 → 0.5.1

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 (95) hide show
  1. package/README.md +43 -3
  2. package/dist/cli/args.d.ts.map +1 -1
  3. package/dist/cli/args.js +5 -0
  4. package/dist/cli/args.js.map +1 -1
  5. package/dist/cli/config.d.ts.map +1 -1
  6. package/dist/cli/config.js +65 -0
  7. package/dist/cli/config.js.map +1 -1
  8. package/dist/cli/help.d.ts +2 -2
  9. package/dist/cli/help.d.ts.map +1 -1
  10. package/dist/cli/help.js +25 -7
  11. package/dist/cli/help.js.map +1 -1
  12. package/dist/cli/inspect.d.ts.map +1 -1
  13. package/dist/cli/inspect.js +63 -24
  14. package/dist/cli/inspect.js.map +1 -1
  15. package/dist/cli/refuse.d.ts +14 -0
  16. package/dist/cli/refuse.d.ts.map +1 -0
  17. package/dist/cli/refuse.js +38 -0
  18. package/dist/cli/refuse.js.map +1 -0
  19. package/dist/cli/resume-guard.d.ts +18 -0
  20. package/dist/cli/resume-guard.d.ts.map +1 -0
  21. package/dist/cli/resume-guard.js +26 -0
  22. package/dist/cli/resume-guard.js.map +1 -0
  23. package/dist/cli/run.d.ts.map +1 -1
  24. package/dist/cli/run.js +16 -54
  25. package/dist/cli/run.js.map +1 -1
  26. package/dist/interpretation/argv.d.ts +3 -0
  27. package/dist/interpretation/argv.d.ts.map +1 -1
  28. package/dist/interpretation/argv.js +2 -0
  29. package/dist/interpretation/argv.js.map +1 -1
  30. package/dist/interpretation/hints.d.ts.map +1 -1
  31. package/dist/interpretation/hints.js +4 -0
  32. package/dist/interpretation/hints.js.map +1 -1
  33. package/dist/interpretation/resolve-options.d.ts.map +1 -1
  34. package/dist/interpretation/resolve-options.js +124 -3
  35. package/dist/interpretation/resolve-options.js.map +1 -1
  36. package/dist/interpretation/support.d.ts +1 -0
  37. package/dist/interpretation/support.d.ts.map +1 -1
  38. package/dist/interpretation/support.js +29 -0
  39. package/dist/interpretation/support.js.map +1 -1
  40. package/dist/interpretation/tool-selection.d.ts +6 -16
  41. package/dist/interpretation/tool-selection.d.ts.map +1 -1
  42. package/dist/interpretation/tool-selection.js +205 -85
  43. package/dist/interpretation/tool-selection.js.map +1 -1
  44. package/dist/interpretation/tool-vocabulary.d.ts +38 -0
  45. package/dist/interpretation/tool-vocabulary.d.ts.map +1 -0
  46. package/dist/interpretation/tool-vocabulary.js +142 -0
  47. package/dist/interpretation/tool-vocabulary.js.map +1 -0
  48. package/dist/interpretation/turn-options.d.ts.map +1 -1
  49. package/dist/interpretation/turn-options.js +107 -2
  50. package/dist/interpretation/turn-options.js.map +1 -1
  51. package/dist/interpretation/vocabulary.d.ts +1 -0
  52. package/dist/interpretation/vocabulary.d.ts.map +1 -1
  53. package/dist/interpretation/vocabulary.js +5 -0
  54. package/dist/interpretation/vocabulary.js.map +1 -1
  55. package/dist/knowledge/claude-code.d.ts.map +1 -1
  56. package/dist/knowledge/claude-code.js +14 -13
  57. package/dist/knowledge/claude-code.js.map +1 -1
  58. package/dist/knowledge/codex.d.ts.map +1 -1
  59. package/dist/knowledge/codex.js +10 -4
  60. package/dist/knowledge/codex.js.map +1 -1
  61. package/dist/knowledge/descriptor.d.ts +16 -1
  62. package/dist/knowledge/descriptor.d.ts.map +1 -1
  63. package/dist/knowledge/descriptor.js +18 -1
  64. package/dist/knowledge/descriptor.js.map +1 -1
  65. package/dist/knowledge/muse.d.ts.map +1 -1
  66. package/dist/knowledge/muse.js +18 -3
  67. package/dist/knowledge/muse.js.map +1 -1
  68. package/dist/knowledge/overrides.d.ts.map +1 -1
  69. package/dist/knowledge/overrides.js +3 -2
  70. package/dist/knowledge/overrides.js.map +1 -1
  71. package/dist/knowledge/pi.d.ts.map +1 -1
  72. package/dist/knowledge/pi.js +8 -7
  73. package/dist/knowledge/pi.js.map +1 -1
  74. package/package.json +1 -1
  75. package/src/cli/args.ts +5 -0
  76. package/src/cli/config.ts +88 -0
  77. package/src/cli/help.ts +25 -7
  78. package/src/cli/inspect.ts +61 -20
  79. package/src/cli/refuse.ts +50 -0
  80. package/src/cli/resume-guard.ts +35 -0
  81. package/src/cli/run.ts +16 -71
  82. package/src/interpretation/argv.ts +5 -0
  83. package/src/interpretation/hints.ts +8 -0
  84. package/src/interpretation/resolve-options.ts +129 -3
  85. package/src/interpretation/support.ts +30 -0
  86. package/src/interpretation/tool-selection.ts +225 -101
  87. package/src/interpretation/tool-vocabulary.ts +183 -0
  88. package/src/interpretation/turn-options.ts +99 -2
  89. package/src/interpretation/vocabulary.ts +5 -0
  90. package/src/knowledge/claude-code.ts +14 -13
  91. package/src/knowledge/codex.ts +10 -4
  92. package/src/knowledge/descriptor.ts +37 -2
  93. package/src/knowledge/muse.ts +18 -3
  94. package/src/knowledge/overrides.ts +4 -2
  95. package/src/knowledge/pi.ts +8 -7
@@ -1,73 +1,89 @@
1
1
  /**
2
- * Tool-selection rendering: turns normalized include/exclude lists into the
3
- * per-harness argv tokens, per decisions D1-D3 and the Phase 0 evidence.
4
- *
5
- * Semantics:
6
- * - include: exact allowlist over the descriptor's curated names. pi's flag
7
- * is strict over built-ins, rendered directly. claude's include flag only
8
- * pre-approves without restricting the visible set, so an exact allowlist
9
- * on claude renders as a deny-complement (all known minus included).
10
- * - exclude: the complement of the named tools over all descriptor-known
11
- * names (D2). pi's native exclude subtracts from its default set (4
12
- * tools), so a D2 exclude renders as a computed include list there.
13
- * - mutual exclusion (D1): both flags in one call refuse.
14
- * - extensible rule (D3): curated names validate and map; unknown
15
- * clean-selector names pass through and are reported as unmapped so
16
- * provenance can surface them.
2
+ * Tool-selection rendering with canonical vocabulary.
17
3
  */
18
4
  import type { HarnessDescriptor } from "../knowledge/descriptor.js";
19
5
  import { defaultDescriptors } from "../knowledge/overrides.js";
6
+ import { hintFor } from "./hints.js";
20
7
  import { ArgvRefusalError } from "./refusal.js";
21
- import { supportedBy } from "./support.js";
8
+ import { supportedBy, supportedByCanonical } from "./support.js";
9
+ import {
10
+ allCanonicalNames,
11
+ canonicalTable,
12
+ hasCounterpart,
13
+ NATIVE_PREFIX,
14
+ nativeFor,
15
+ parseToolSelector,
16
+ READ_PRESET,
17
+ validateCanonicalList,
18
+ } from "./tool-vocabulary.js";
22
19
 
23
20
  export const TOOL_SELECTOR = /^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,127}$/;
24
21
 
25
22
  export interface ToolSelection {
26
23
  readonly include?: readonly string[];
27
24
  readonly exclude?: readonly string[];
25
+ readonly toolMap?: Readonly<Record<string, string>>;
28
26
  }
29
27
 
30
28
  export interface RenderedToolSelection {
31
29
  readonly tokens: readonly string[];
32
- readonly unmapped: readonly string[];
30
+ readonly passthrough: readonly string[];
33
31
  }
34
32
 
35
- const validateNames = (h: HarnessDescriptor, names: readonly string[]): string[] => {
36
- for (const name of names) {
37
- if (name.trim() === "" || name.includes(",")) {
38
- throw new ArgvRefusalError({
39
- issue: "invalid-tool-grant",
40
- harness: h.name,
41
- supported: ["non-empty, comma-free tool names"],
42
- detail: `tools=${JSON.stringify(names)}`,
43
- });
44
- }
45
- if (!TOOL_SELECTOR.test(name)) {
46
- throw new ArgvRefusalError({
47
- issue: "invalid-tool-grant",
48
- harness: h.name,
49
- supported: [`must match ${TOOL_SELECTOR.source}`],
50
- detail: name,
51
- });
52
- }
33
+ const validateInnerName = (h: HarnessDescriptor, name: string, raw: readonly string[]): void => {
34
+ if (name.trim() === "" || name.includes(",")) {
35
+ throw new ArgvRefusalError({
36
+ issue: "invalid-tool-grant",
37
+ harness: h.name,
38
+ supported: ["non-empty, comma-free tool names"],
39
+ detail: `tools=${JSON.stringify(raw)}`,
40
+ });
41
+ }
42
+ if (!TOOL_SELECTOR.test(name)) {
43
+ throw new ArgvRefusalError({
44
+ issue: "invalid-tool-grant",
45
+ harness: h.name,
46
+ supported: [`must match ${TOOL_SELECTOR.source}`],
47
+ detail: name,
48
+ });
53
49
  }
54
- return [...names];
55
50
  };
56
51
 
57
- /** Curated-name mapping plus extensible pass-through; returns the effective
58
- * names and which of them the descriptor could not vouch for. */
59
- const resolveNames = (
52
+ const categoriesFor = (
60
53
  h: HarnessDescriptor,
61
- names: readonly string[],
62
- ): { mapped: string[]; unmapped: string[] } => {
63
- const known = new Set(h.tools.builtins.map((t) => t.name));
64
- const mapped: string[] = [];
65
- const unmapped: string[] = [];
66
- for (const name of names) {
67
- if (known.has(name)) mapped.push(name);
68
- else unmapped.push(name);
54
+ canonicals: readonly string[],
55
+ table: ReturnType<typeof canonicalTable>,
56
+ ): Set<string> => {
57
+ const cats = new Set<string>();
58
+ for (const c of canonicals) {
59
+ const val = table[c]?.[h.name];
60
+ if (val?.kind === "category") cats.add(val.key);
69
61
  }
70
- return { mapped, unmapped };
62
+ return cats;
63
+ };
64
+
65
+ export const renderAccessPreset = (
66
+ h: HarnessDescriptor,
67
+ access: "read" | "write",
68
+ opts: { toolMap?: Readonly<Record<string, string>> } = {},
69
+ ): string[] => {
70
+ if (access === "write") return [];
71
+ // read preset
72
+ const filtered = (READ_PRESET as readonly string[]).filter((c) => {
73
+ if (opts.toolMap?.[c] !== undefined) return true;
74
+ const table = canonicalTable(defaultDescriptors());
75
+ return table[c]?.[h.name] !== undefined;
76
+ });
77
+ if (filtered.length === 0) return [];
78
+ // For tool-preset harnesses, render via tool-selection include
79
+ const spec = h.turnOptions.access;
80
+ if (spec && spec.kind === "tool-preset") {
81
+ const rendered = renderToolSelection(h, { include: filtered, toolMap: opts.toolMap });
82
+ return [...rendered.tokens];
83
+ }
84
+ // For flag-value / flag-list-by-value harnesses, caller should handle via spec mapping;
85
+ // we return empty here so turn-options can map via spec values.
86
+ return [];
71
87
  };
72
88
 
73
89
  export const renderToolSelection = (
@@ -77,7 +93,6 @@ export const renderToolSelection = (
77
93
  const hasInclude = selection.include !== undefined;
78
94
  const hasExclude = selection.exclude !== undefined;
79
95
 
80
- // D1: mutual exclusivity, structured refusal.
81
96
  if (hasInclude && hasExclude) {
82
97
  throw new ArgvRefusalError({
83
98
  issue: "mutually-exclusive-options",
@@ -87,31 +102,116 @@ export const renderToolSelection = (
87
102
  detail: "mutual exclusion",
88
103
  });
89
104
  }
90
- if (!hasInclude && !hasExclude) return { tokens: [], unmapped: [] };
105
+ if (!hasInclude && !hasExclude) return { tokens: [], passthrough: [] };
106
+
107
+ const set = defaultDescriptors();
108
+ const table = canonicalTable(set);
109
+ const toolMap = selection.toolMap ?? {};
110
+ const allCanonical = allCanonicalNames(set, {
111
+ [h.name]: toolMap as unknown as Record<string, string>,
112
+ } as unknown as import("./tool-vocabulary.js").ToolMap);
113
+
114
+ const splitSelection = (
115
+ names: readonly string[],
116
+ ): { canonical: string[]; passthrough: string[] } => {
117
+ const canonical: string[] = [];
118
+ const passthrough: string[] = [];
119
+ const setForValidate = new Set(allCanonical);
120
+ for (const raw of names) {
121
+ const parsed = parseToolSelector(raw);
122
+ if (parsed.kind === "native") {
123
+ validateInnerName(h, parsed.name, names);
124
+ passthrough.push(parsed.name);
125
+ continue;
126
+ }
127
+ validateInnerName(h, parsed.name, names);
128
+ if (!setForValidate.has(parsed.name)) {
129
+ throw new ArgvRefusalError({
130
+ issue: "unknown-tool-name",
131
+ harness: h.name,
132
+ option: "tools",
133
+ supported: [...allCanonical],
134
+ hint: "use native:<name> for an extension or MCP tool",
135
+ detail: `unknown tool name(s) ${parsed.name}`,
136
+ });
137
+ }
138
+ canonical.push(parsed.name);
139
+ }
140
+ return { canonical, passthrough };
141
+ };
142
+
143
+ const isCodexShape = h.tools.includeFlag === null && h.tools.categories.length === 0;
144
+ const isCategoryShape = h.tools.includeFlag === null && h.tools.categories.length > 0;
91
145
 
92
- if (h.tools.includeFlag === null || h.tools.excludeFlag === null) {
93
- const option = hasInclude ? "tools" : "excludeTools";
94
- const by = supportedBy(defaultDescriptors(), option);
146
+ if (isCodexShape) {
95
147
  throw new ArgvRefusalError({
96
148
  issue: "unsupported-option",
97
149
  harness: h.name,
98
- option,
150
+ option: hasInclude ? "tools" : "excludeTools",
99
151
  supported: ["per-tool name lists"],
100
- supportedBy: by,
152
+ supportedBy: supportedBy(set, hasInclude ? "tools" : "excludeTools"),
101
153
  hint:
102
- h.name === "codex"
103
- ? "nearest control on codex: category switches via config keys (features.shell_tool, web_search) or sandbox modes - see `hcn inspect codex`"
104
- : "nearest control on muse: category switches (--disable-write, --disable-shell, --disable-web-tools) gate tool execution per session",
154
+ hintFor(h.name, hasInclude ? "tools" : "excludeTools") ??
155
+ "nearest control on codex: category switches via config keys (features.shell_tool, web_search) or sandbox modes - see `hcn inspect codex`",
105
156
  });
106
157
  }
158
+ if (isCategoryShape) {
159
+ const names = hasInclude ? selection.include! : selection.exclude!;
160
+ const { canonical, passthrough } = splitSelection(names);
161
+
162
+ if (hasExclude) {
163
+ // one helper replaces the three no-counterpart loops
164
+ for (const c of canonical) {
165
+ const has = toolMap[c] !== undefined || table[c]?.[h.name] !== undefined;
166
+ if (!has) {
167
+ throw new ArgvRefusalError({
168
+ issue: "unsupported-option",
169
+ harness: h.name,
170
+ option: "excludeTools",
171
+ supported: ["per-tool name lists"],
172
+ supportedBy: supportedByCanonical(set, c),
173
+ hint: hintFor(h.name, "excludeTools"),
174
+ });
175
+ }
176
+ }
177
+ const cats = categoriesFor(h, canonical, table);
178
+ const tokens: string[] = [];
179
+ for (const cat of h.tools.categories) {
180
+ if (cats.has(cat.key) && cat.disableFlag) tokens.push(cat.disableFlag);
181
+ }
182
+ return { tokens, passthrough };
183
+ }
184
+
185
+ for (const c of canonical) {
186
+ const has = toolMap[c] !== undefined || table[c]?.[h.name] !== undefined;
187
+ if (!has) {
188
+ throw new ArgvRefusalError({
189
+ issue: "unsupported-option",
190
+ harness: h.name,
191
+ option: "tools",
192
+ supported: ["per-tool name lists"],
193
+ supportedBy: supportedByCanonical(set, c),
194
+ hint: hintFor(h.name, "tools"),
195
+ });
196
+ }
197
+ }
198
+ if (canonical.length === 0) {
199
+ const tokens = h.tools.categories.map((c) => c.disableFlag).filter((f): f is string => !!f);
200
+ return { tokens, passthrough };
201
+ }
202
+ const grantedCats = categoriesFor(h, canonical, table);
203
+ const tokens: string[] = [];
204
+ for (const cat of h.tools.categories) {
205
+ if (!grantedCats.has(cat.key) && cat.disableFlag) tokens.push(cat.disableFlag);
206
+ }
207
+ return { tokens, passthrough };
208
+ }
107
209
 
210
+ // From here: h has list flags (claude, pi)
108
211
  if (hasInclude) {
109
- const names = validateNames(h, selection.include!);
212
+ const names = selection.include!;
110
213
  if (names.length === 0) {
111
- // Empty include list = deny everything. On pi --tools "" has no
112
- // defined meaning, so refuse; on claude emit the deny complement
113
- // without an empty include token.
114
- if (h.tools.includeIsStrictAllowlist || h.tools.excludeFlag === null) {
214
+ if (h.tools.includeIsStrictAllowlist) {
115
215
  throw new ArgvRefusalError({
116
216
  issue: "invalid-tool-grant",
117
217
  harness: h.name,
@@ -121,66 +221,90 @@ export const renderToolSelection = (
121
221
  });
122
222
  }
123
223
  const known = h.tools.builtins.map((t) => t.name);
124
- return { tokens: [h.tools.excludeFlag, known.join(",")], unmapped: [] };
224
+ return { tokens: [h.tools.excludeFlag!, known.join(",")], passthrough: [] };
125
225
  }
126
- const { mapped, unmapped } = resolveNames(h, names);
127
- if (!h.tools.includeIsStrictAllowlist) {
128
- if (mapped.length === 0 && unmapped.length > 0) {
226
+ const { canonical, passthrough } = splitSelection(names);
227
+ for (const c of canonical) {
228
+ const has = toolMap[c] !== undefined || table[c]?.[h.name] !== undefined;
229
+ if (!has) {
129
230
  throw new ArgvRefusalError({
130
- issue: "unknown-tool-name",
231
+ issue: "unsupported-option",
131
232
  harness: h.name,
132
233
  option: "tools",
133
- supported: [`known tool names: ${h.tools.builtins.map((t) => t.name).join(", ")}`],
134
- detail: `unknown tool name(s) ${unmapped.join(", ")}`,
234
+ supported: ["per-tool name lists"],
235
+ supportedBy: supportedByCanonical(set, c),
236
+ hint: `add toolMap.${h.name}.${c} to ~/.config/hcn/config.json or pass native:${c}`,
135
237
  });
136
238
  }
137
- // claude: exact allowlist must reshape the visible set via the deny
138
- // complement (probe 2b). The include flag also carries the granted
139
- // names (curated + pass-throughs) so they skip approval prompts;
140
- // unmapped names ride along - the deny complement is computed over
141
- // curated names only, so an extension tool in an include is granted,
142
- // never denied.
239
+ }
240
+ const mapped: string[] = [];
241
+ for (const c of canonical) {
242
+ let n: string | null = toolMap[c] ?? null;
243
+ if (n === null) {
244
+ const entry = table[c]?.[h.name];
245
+ if (entry?.kind === "builtin") n = entry.native;
246
+ }
247
+ if (n) mapped.push(n);
248
+ }
249
+ if (!h.tools.includeIsStrictAllowlist) {
250
+ // claude's include flag pre-approves without restricting, so an exact allowlist renders as the deny complement
143
251
  const known = h.tools.builtins.map((t) => t.name);
144
252
  const excluded = known.filter((n) => !mapped.includes(n));
145
- const tokens: string[] = [h.tools.includeFlag!, [...mapped, ...unmapped].join(",")];
146
- tokens.push(h.tools.excludeFlag!, excluded.join(","));
147
- return { tokens, unmapped };
253
+ const tokens: string[] = [h.tools.includeFlag!, [...mapped, ...passthrough].join(",")];
254
+ // only add exclude if needed? original pushed exclude even when? Keep original behavior: push excludeFlag excluded
255
+ if (excluded.length > 0 || passthrough.length > 0) {
256
+ tokens.push(h.tools.excludeFlag!, excluded.join(","));
257
+ } else {
258
+ // when all tools included, exclude nothing? but keep token? original pushed exclude always
259
+ tokens.push(h.tools.excludeFlag!, excluded.join(","));
260
+ }
261
+ return { tokens, passthrough };
148
262
  }
149
- // pi: strict over built-ins, direct include; unmapped (extension/MCP)
150
- // names ride along in the same list - pi governs them too.
151
- return { tokens: [h.tools.includeFlag!, [...mapped, ...unmapped].join(",")], unmapped };
263
+ return {
264
+ tokens: [h.tools.includeFlag!, [...mapped, ...passthrough].join(",")],
265
+ passthrough,
266
+ };
152
267
  }
153
268
 
154
- // exclude (D2): all descriptor-known names minus the excluded ones.
155
- const names = validateNames(h, selection.exclude!);
156
- const { mapped, unmapped: excludedUnmapped } = resolveNames(h, names);
157
- if (excludedUnmapped.length > 0) {
158
- // Excluding a name the descriptor does not know: permitted (it may be a
159
- // runtime-registered tool), rendered natively where the harness's deny
160
- // list can carry it; on claude only, since its include path consumes
161
- // unknown names without effect (silent-acceptance hazard).
269
+ // exclude
270
+ const names = selection.exclude!;
271
+ const { canonical, passthrough: excludedPassthrough } = splitSelection(names);
272
+ for (const c of canonical) {
273
+ const has = toolMap[c] !== undefined || table[c]?.[h.name] !== undefined;
274
+ if (!has) {
275
+ throw new ArgvRefusalError({
276
+ issue: "unsupported-option",
277
+ harness: h.name,
278
+ option: "excludeTools",
279
+ supported: ["per-tool name lists"],
280
+ supportedBy: supportedByCanonical(set, c),
281
+ hint: `add toolMap.${h.name}.${c} to ~/.config/hcn/config.json or pass native:${c}`,
282
+ });
283
+ }
284
+ }
285
+ if (excludedPassthrough.length > 0) {
162
286
  if (!h.tools.includeIsStrictAllowlist) {
163
- const tokens = [h.tools.excludeFlag!, [...mapped, ...excludedUnmapped].join(",")];
164
- return { tokens, unmapped: excludedUnmapped };
287
+ const mapped = canonical.map(
288
+ (c) => toolMap[c] ?? (table[c]?.[h.name] as { native: string } | undefined)?.native ?? c,
289
+ );
290
+ const tokens = [h.tools.excludeFlag!, [...mapped, ...excludedPassthrough].join(",")];
291
+ return { tokens, passthrough: excludedPassthrough };
165
292
  }
166
- // pi: a D2 exclude renders as a computed include (its native exclude
167
- // subtracts from the default set, which cannot express "all minus X"
168
- // when off-by-default tools are involved). An unknown excluded name
169
- // cannot be complemented - refuse rather than silently grant it.
170
293
  throw new ArgvRefusalError({
171
294
  issue: "unknown-tool-name",
172
295
  harness: h.name,
173
296
  option: "excludeTools",
174
297
  supported: [`known tool names: ${h.tools.builtins.map((t) => t.name).join(", ")}`],
175
- detail: `cannot exclude unknown name(s) ${excludedUnmapped.join(", ")}: the complement cannot be computed`,
298
+ detail: `cannot exclude unknown name(s) ${excludedPassthrough.join(", ")}: the complement cannot be computed`,
176
299
  });
177
300
  }
301
+ const mapped = canonical
302
+ .map((c) => toolMap[c] ?? (table[c]?.[h.name] as { native: string } | undefined)?.native ?? "")
303
+ .filter(Boolean);
178
304
  const known = h.tools.builtins.filter((t) => !mapped.includes(t.name));
179
305
  const kept = known.map((t) => t.name);
180
306
  if (!h.tools.includeIsStrictAllowlist) {
181
- // claude: deny list IS the complement expression.
182
- return { tokens: [h.tools.excludeFlag!, mapped.join(",")], unmapped: [] };
307
+ return { tokens: [h.tools.excludeFlag!, mapped.join(",")], passthrough: [] };
183
308
  }
184
- // pi: computed include over every known name minus the excluded.
185
- return { tokens: [h.tools.includeFlag!, kept.join(",")], unmapped: [] };
309
+ return { tokens: [h.tools.includeFlag!, kept.join(",")], passthrough: [] };
186
310
  };
@@ -0,0 +1,183 @@
1
+ import type { DescriptorSet } from "../knowledge/overrides.js";
2
+ import { ArgvRefusalError } from "./refusal.js";
3
+
4
+ export const NATIVE_PREFIX = "native:" as const;
5
+
6
+ export type VocabularyEntry =
7
+ | { kind: "builtin"; native: string }
8
+ | { kind: "category"; key: string };
9
+
10
+ export const READ_PRESET = ["read", "grep", "glob", "list", "web-fetch", "web-search"] as const;
11
+
12
+ export type ParsedSelector = { kind: "canonical"; name: string } | { kind: "native"; name: string };
13
+
14
+ export const parseToolSelector = (raw: string): ParsedSelector =>
15
+ raw.startsWith(NATIVE_PREFIX)
16
+ ? { kind: "native", name: raw.slice(NATIVE_PREFIX.length) }
17
+ : { kind: "canonical", name: raw };
18
+
19
+ export type ToolMap = Readonly<Record<string, Readonly<Record<string, string>>>>;
20
+
21
+ export type ToolMapTier = "user-config" | "project-config";
22
+
23
+ export type MergedToolMap = Readonly<
24
+ Record<string, Readonly<Record<string, { native: string; tier: ToolMapTier }>>>
25
+ >;
26
+
27
+ /** Pure merge per harness per canonical: project > user. */
28
+ export const mergeToolMaps = (tiers: {
29
+ readonly user?: ToolMap;
30
+ readonly project?: ToolMap;
31
+ }): MergedToolMap => {
32
+ const merged: Record<string, Record<string, { native: string; tier: ToolMapTier }>> = {};
33
+ const harnesses = new Set<string>([
34
+ ...Object.keys(tiers.user ?? {}),
35
+ ...Object.keys(tiers.project ?? {}),
36
+ ]);
37
+ for (const h of harnesses) {
38
+ const userEntries = tiers.user?.[h] ?? {};
39
+ const projectEntries = tiers.project?.[h] ?? {};
40
+ const canonicals = new Set<string>([
41
+ ...Object.keys(userEntries),
42
+ ...Object.keys(projectEntries),
43
+ ]);
44
+ const perHarness: Record<string, { native: string; tier: ToolMapTier }> = {};
45
+ for (const c of canonicals) {
46
+ if (projectEntries[c] !== undefined) {
47
+ perHarness[c] = { native: projectEntries[c] as string, tier: "project-config" };
48
+ } else if (userEntries[c] !== undefined) {
49
+ perHarness[c] = { native: userEntries[c] as string, tier: "user-config" };
50
+ }
51
+ }
52
+ if (Object.keys(perHarness).length > 0) {
53
+ merged[h] = perHarness;
54
+ }
55
+ }
56
+ return merged;
57
+ };
58
+
59
+ export type CanonicalTable = Readonly<
60
+ Record<string, Readonly<Partial<Record<string, VocabularyEntry>>>>
61
+ >;
62
+
63
+ const tableCache = new WeakMap<DescriptorSet, CanonicalTable>();
64
+
65
+ export const canonicalTable = (set: DescriptorSet): CanonicalTable => {
66
+ const cached = tableCache.get(set);
67
+ if (cached) return cached;
68
+ const allCanonical = new Set<string>();
69
+ for (const h of Object.values(set)) {
70
+ if (!h) continue;
71
+ for (const b of h.tools.builtins) {
72
+ if (b.canonical !== null) allCanonical.add(b.canonical);
73
+ }
74
+ for (const c of h.tools.categories) {
75
+ for (const n of c.canonical) allCanonical.add(n);
76
+ }
77
+ }
78
+ const table: Record<string, Record<string, VocabularyEntry>> = {};
79
+ for (const canonical of allCanonical) {
80
+ const perHarness: Record<string, VocabularyEntry> = {};
81
+ for (const h of Object.values(set)) {
82
+ if (!h) continue;
83
+ const builtin = h.tools.builtins.find((b) => b.canonical === canonical);
84
+ if (builtin) {
85
+ perHarness[h.name] = { kind: "builtin", native: builtin.name };
86
+ continue;
87
+ }
88
+ const cat = h.tools.categories.find((c) =>
89
+ (c.canonical as readonly string[]).includes(canonical),
90
+ );
91
+ if (cat) {
92
+ perHarness[h.name] = { kind: "category", key: cat.key };
93
+ }
94
+ }
95
+ table[canonical] = perHarness;
96
+ }
97
+ const frozen = table as CanonicalTable;
98
+ tableCache.set(set, frozen);
99
+ return frozen;
100
+ };
101
+
102
+ // Backwards compat alias
103
+ export const canonicalToolTable = canonicalTable;
104
+
105
+ export const canonicalNames = (set: DescriptorSet): readonly string[] => {
106
+ const table = canonicalTable(set);
107
+ return Object.keys(table).sort();
108
+ };
109
+
110
+ export const allCanonicalNames = (
111
+ set: DescriptorSet,
112
+ toolMap?: ToolMap | MergedToolMap,
113
+ ): readonly string[] => {
114
+ const base = canonicalNames(set);
115
+ const extra = toolMap
116
+ ? Object.values(toolMap).flatMap((m) => Object.keys(m as Record<string, unknown>))
117
+ : [];
118
+ return [...new Set([...base, ...extra])].sort();
119
+ };
120
+
121
+ export const hasCounterpart = (
122
+ table: CanonicalTable,
123
+ canonical: string,
124
+ harnessName: string,
125
+ toolMap?: MergedToolMap | ToolMap,
126
+ ): boolean => {
127
+ if (toolMap) {
128
+ const hm = (toolMap as MergedToolMap)[harnessName];
129
+ if (hm && (hm as Record<string, unknown>)[canonical] !== undefined) {
130
+ const v = (hm as Record<string, unknown>)[canonical] as unknown;
131
+ if (typeof v === "object" && v !== null && "native" in (v as Record<string, unknown>))
132
+ return true;
133
+ if (typeof v === "string") return true;
134
+ }
135
+ }
136
+ const entry = table[canonical];
137
+ if (!entry) return false;
138
+ return entry[harnessName] !== undefined;
139
+ };
140
+
141
+ export const nativeFor = (
142
+ table: CanonicalTable,
143
+ canonical: string,
144
+ harnessName: string,
145
+ toolMap?: MergedToolMap | ToolMap,
146
+ ): string | null => {
147
+ if (toolMap) {
148
+ const hm = (toolMap as Record<string, Record<string, unknown>>)[harnessName];
149
+ const val = hm?.[canonical];
150
+ if (val !== undefined) {
151
+ if (typeof val === "object" && val !== null && "native" in (val as Record<string, unknown>)) {
152
+ return (val as { native: string }).native;
153
+ }
154
+ if (typeof val === "string") return val as string;
155
+ }
156
+ }
157
+ const entry = table[canonical];
158
+ const v = entry?.[harnessName];
159
+ if (v?.kind === "builtin") return v.native;
160
+ return null;
161
+ };
162
+
163
+ export const validateCanonicalList = (
164
+ names: readonly string[] | undefined,
165
+ allCanonical: readonly string[],
166
+ harness: string,
167
+ ): void => {
168
+ if (!names) return;
169
+ const set = new Set(allCanonical);
170
+ for (const name of names) {
171
+ if (name.startsWith(NATIVE_PREFIX)) continue;
172
+ if (!set.has(name)) {
173
+ throw new ArgvRefusalError({
174
+ issue: "unknown-tool-name",
175
+ harness: harness as import("../knowledge/descriptor.js").HarnessName,
176
+ option: "tools",
177
+ supported: allCanonical as unknown as string[],
178
+ hint: "use native:<name> for an extension or MCP tool",
179
+ detail: `unknown tool name ${JSON.stringify(name)}`,
180
+ });
181
+ }
182
+ }
183
+ };