@dungle-scrubs/harness-cli-normalizer 0.4.6 → 0.5.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.
Files changed (90) 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/run.d.ts.map +1 -1
  20. package/dist/cli/run.js +9 -40
  21. package/dist/cli/run.js.map +1 -1
  22. package/dist/interpretation/argv.d.ts +3 -0
  23. package/dist/interpretation/argv.d.ts.map +1 -1
  24. package/dist/interpretation/argv.js +2 -0
  25. package/dist/interpretation/argv.js.map +1 -1
  26. package/dist/interpretation/hints.d.ts.map +1 -1
  27. package/dist/interpretation/hints.js +4 -0
  28. package/dist/interpretation/hints.js.map +1 -1
  29. package/dist/interpretation/resolve-options.d.ts.map +1 -1
  30. package/dist/interpretation/resolve-options.js +124 -3
  31. package/dist/interpretation/resolve-options.js.map +1 -1
  32. package/dist/interpretation/support.d.ts +1 -0
  33. package/dist/interpretation/support.d.ts.map +1 -1
  34. package/dist/interpretation/support.js +29 -0
  35. package/dist/interpretation/support.js.map +1 -1
  36. package/dist/interpretation/tool-selection.d.ts +6 -16
  37. package/dist/interpretation/tool-selection.d.ts.map +1 -1
  38. package/dist/interpretation/tool-selection.js +205 -85
  39. package/dist/interpretation/tool-selection.js.map +1 -1
  40. package/dist/interpretation/tool-vocabulary.d.ts +38 -0
  41. package/dist/interpretation/tool-vocabulary.d.ts.map +1 -0
  42. package/dist/interpretation/tool-vocabulary.js +142 -0
  43. package/dist/interpretation/tool-vocabulary.js.map +1 -0
  44. package/dist/interpretation/turn-options.d.ts.map +1 -1
  45. package/dist/interpretation/turn-options.js +107 -2
  46. package/dist/interpretation/turn-options.js.map +1 -1
  47. package/dist/interpretation/vocabulary.d.ts +1 -0
  48. package/dist/interpretation/vocabulary.d.ts.map +1 -1
  49. package/dist/interpretation/vocabulary.js +5 -0
  50. package/dist/interpretation/vocabulary.js.map +1 -1
  51. package/dist/knowledge/claude-code.d.ts.map +1 -1
  52. package/dist/knowledge/claude-code.js +14 -13
  53. package/dist/knowledge/claude-code.js.map +1 -1
  54. package/dist/knowledge/codex.d.ts.map +1 -1
  55. package/dist/knowledge/codex.js +10 -4
  56. package/dist/knowledge/codex.js.map +1 -1
  57. package/dist/knowledge/descriptor.d.ts +16 -1
  58. package/dist/knowledge/descriptor.d.ts.map +1 -1
  59. package/dist/knowledge/descriptor.js +18 -1
  60. package/dist/knowledge/descriptor.js.map +1 -1
  61. package/dist/knowledge/muse.d.ts.map +1 -1
  62. package/dist/knowledge/muse.js +18 -3
  63. package/dist/knowledge/muse.js.map +1 -1
  64. package/dist/knowledge/overrides.d.ts.map +1 -1
  65. package/dist/knowledge/overrides.js +3 -2
  66. package/dist/knowledge/overrides.js.map +1 -1
  67. package/dist/knowledge/pi.d.ts.map +1 -1
  68. package/dist/knowledge/pi.js +8 -7
  69. package/dist/knowledge/pi.js.map +1 -1
  70. package/package.json +1 -1
  71. package/src/cli/args.ts +5 -0
  72. package/src/cli/config.ts +88 -0
  73. package/src/cli/help.ts +25 -7
  74. package/src/cli/inspect.ts +61 -20
  75. package/src/cli/refuse.ts +50 -0
  76. package/src/cli/run.ts +9 -58
  77. package/src/interpretation/argv.ts +5 -0
  78. package/src/interpretation/hints.ts +8 -0
  79. package/src/interpretation/resolve-options.ts +129 -3
  80. package/src/interpretation/support.ts +30 -0
  81. package/src/interpretation/tool-selection.ts +225 -101
  82. package/src/interpretation/tool-vocabulary.ts +183 -0
  83. package/src/interpretation/turn-options.ts +99 -2
  84. package/src/interpretation/vocabulary.ts +5 -0
  85. package/src/knowledge/claude-code.ts +14 -13
  86. package/src/knowledge/codex.ts +10 -4
  87. package/src/knowledge/descriptor.ts +37 -2
  88. package/src/knowledge/muse.ts +18 -3
  89. package/src/knowledge/overrides.ts +4 -2
  90. package/src/knowledge/pi.ts +8 -7
@@ -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
+ };
@@ -14,7 +14,9 @@ import { DISCOVERY_FACETS, resolveRender, TURN_OPTION_KEYS } from "../knowledge/
14
14
  import type { DiscoveryOptions, TurnOptions } from "./argv.js";
15
15
  import { hintFor } from "./hints.js";
16
16
  import { ArgvRefusalError } from "./refusal.js";
17
- import { CLEAN_SELECTOR, resolveModel, validateEffort } from "./vocabulary.js";
17
+ import { renderToolSelection } from "./tool-selection.js";
18
+ import { READ_PRESET } from "./tool-vocabulary.js";
19
+ import { CLEAN_SELECTOR, resolveModel, validateAccess, validateEffort } from "./vocabulary.js";
18
20
 
19
21
  // TOML-quoted value for config-kv: JSON.stringify is sufficient for the
20
22
  // closed vocabularies that may use it (enum, effort) - no value contains a
@@ -34,6 +36,20 @@ export const renderTurnOptions = (
34
36
  ): string[] => {
35
37
  const sequences: string[][] = [];
36
38
 
39
+ const accessRaw = opts.access;
40
+ if (accessRaw !== undefined) {
41
+ const v = validateAccess(accessRaw);
42
+ if (!v.ok) {
43
+ throw new ArgvRefusalError({
44
+ issue: "invalid-option-value",
45
+ harness: h.name,
46
+ option: "access",
47
+ supported: ["read", "write"],
48
+ detail: String(accessRaw),
49
+ });
50
+ }
51
+ }
52
+
37
53
  for (const key of TURN_OPTION_KEYS) {
38
54
  const spec = h.turnOptions[key];
39
55
  const raw = (opts as unknown as Record<string, unknown>)[key];
@@ -166,9 +182,85 @@ export const renderTurnOptions = (
166
182
  continue;
167
183
  }
168
184
 
185
+ // Access is opt-in-only, no profile default; dispatch to tool-selection for the preset.
186
+ if (key === "access") {
187
+ if (raw === undefined) continue;
188
+ if (spec === undefined) {
189
+ throw new ArgvRefusalError({
190
+ issue: "unsupported-option",
191
+ harness: h.name,
192
+ option: key,
193
+ supported: Object.keys(h.turnOptions).length ? Object.keys(h.turnOptions) : ["(none)"],
194
+ detail: String(raw),
195
+ hint: hintFor(h.name, key),
196
+ });
197
+ }
198
+ const v = validateAccess(raw as string);
199
+ if (!v.ok) {
200
+ throw new ArgvRefusalError({
201
+ issue: "invalid-option-value",
202
+ harness: h.name,
203
+ option: key,
204
+ supported: ["read", "write"],
205
+ detail: String(raw),
206
+ });
207
+ }
208
+ // When discovery.tools is off, the read preset must not re-enable tools.
209
+ if (raw === "read" && opts.discovery?.tools === false) {
210
+ if (spec.kind === "tool-preset") continue;
211
+ }
212
+ switch (spec.kind) {
213
+ case "tool-preset": {
214
+ if (raw === "write") break;
215
+ const toolMapForHarness = (opts as { toolMap?: Record<string, Record<string, string>> })
216
+ .toolMap?.[h.name];
217
+ const filtered = (READ_PRESET as readonly string[]).filter((c) => {
218
+ if (toolMapForHarness?.[c] !== undefined) return true;
219
+ if (h.tools.builtins.some((b) => b.canonical === c)) return true;
220
+ if (h.tools.categories.some((cat) => (cat.canonical as readonly string[]).includes(c)))
221
+ return true;
222
+ return false;
223
+ });
224
+ if (filtered.length === 0) break;
225
+ const rendered = renderToolSelection(h, {
226
+ include: filtered as unknown as string[],
227
+ toolMap: toolMapForHarness,
228
+ });
229
+ if (rendered.tokens.length > 0) sequences.push([...rendered.tokens]);
230
+ break;
231
+ }
232
+ case "flag-value": {
233
+ const fv = spec as Extract<typeof spec, { kind: "flag-value" }>;
234
+ const mapped = fv.values[raw as string];
235
+ if (mapped !== undefined) sequences.push([fv.flag, mapped]);
236
+ break;
237
+ }
238
+ case "flag-list-by-value": {
239
+ const fl = spec as Extract<typeof spec, { kind: "flag-list-by-value" }>;
240
+ const list = fl.flags[raw as string] ?? [];
241
+ if (list.length > 0) sequences.push([...list]);
242
+ break;
243
+ }
244
+ default: {
245
+ const _exhaustive: never = spec as never;
246
+ throw new ArgvRefusalError({
247
+ issue: "invalid-option-value",
248
+ harness: h.name,
249
+ option: key,
250
+ supported: [],
251
+ detail: String(_exhaustive),
252
+ });
253
+ }
254
+ }
255
+ continue;
256
+ }
257
+
169
258
  // Non-discovery keys
170
- // Handle enum default on launch
259
+ // Handle enum default on launch. An access preset already claimed the
260
+ // sandbox flag on harnesses that express access through it (codex);
261
+ // the enum default must not emit a second --sandbox.
171
262
  if (raw === undefined) {
263
+ if (key === "sandbox" && opts.access !== undefined) continue;
172
264
  if (
173
265
  spec !== undefined &&
174
266
  spec.kind === "enum" &&
@@ -430,6 +522,11 @@ export const renderTurnOptions = (
430
522
  case "discovery":
431
523
  // already handled
432
524
  break;
525
+ case "tool-preset":
526
+ case "flag-value":
527
+ case "flag-list-by-value":
528
+ // access-only kinds - handled above for key === "access"
529
+ break;
433
530
  default: {
434
531
  const _exhaustive: never = spec as never;
435
532
  throw new ArgvRefusalError({
@@ -57,6 +57,11 @@ export const validateModel = (h: HarnessDescriptor, model: string): Validated =>
57
57
  /** Validate an effort against the ladder that applies to the pick: the
58
58
  * model's own ladder where the harness constrains per model (codex), else
59
59
  * the harness-wide ladder. */
60
+ export const validateAccess = (value: string): Validated => {
61
+ if (value === "read" || value === "write") return { ok: true, id: value };
62
+ return { ok: false, reason: `unknown access ${JSON.stringify(value)}; accepted: read, write` };
63
+ };
64
+
60
65
  export const validateEffort = (h: HarnessDescriptor, effort: string, model?: string): Validated => {
61
66
  let ladder = h.vocabulary.efforts;
62
67
  if (model !== undefined && h.vocabulary.effortsByModel !== undefined) {
@@ -177,6 +177,7 @@ export const claudeCode: HarnessDescriptor = deepFreeze({
177
177
  },
178
178
  },
179
179
  },
180
+ access: { kind: "tool-preset", render: { kind: "flag-value", flag: "--allowedTools" } },
180
181
  },
181
182
  // Phase 0 fixtures: claude-tool-interplay.md. include is a permission
182
183
  // grant (Bash, Edit stay visible under --allowedTools Read); only the
@@ -191,19 +192,19 @@ export const claudeCode: HarnessDescriptor = deepFreeze({
191
192
  includeIsStrictAllowlist: false,
192
193
  composable: true,
193
194
  builtins: [
194
- { name: "Bash", defaultEnabled: true },
195
- { name: "Edit", defaultEnabled: true },
196
- { name: "Glob", defaultEnabled: true },
197
- { name: "Grep", defaultEnabled: true },
198
- { name: "Read", defaultEnabled: true },
199
- { name: "Write", defaultEnabled: true },
200
- { name: "WebFetch", defaultEnabled: true },
201
- { name: "WebSearch", defaultEnabled: true },
202
- { name: "Monitor", defaultEnabled: true },
203
- { name: "Task", defaultEnabled: true },
204
- { name: "Skill", defaultEnabled: true },
205
- { name: "NotebookEdit", defaultEnabled: true },
206
- { name: "LSP", defaultEnabled: true },
195
+ { name: "Bash", defaultEnabled: true, canonical: "shell" },
196
+ { name: "Edit", defaultEnabled: true, canonical: "edit" },
197
+ { name: "Glob", defaultEnabled: true, canonical: "glob" },
198
+ { name: "Grep", defaultEnabled: true, canonical: "grep" },
199
+ { name: "Read", defaultEnabled: true, canonical: "read" },
200
+ { name: "Write", defaultEnabled: true, canonical: "write" },
201
+ { name: "WebFetch", defaultEnabled: true, canonical: "web-fetch" },
202
+ { name: "WebSearch", defaultEnabled: true, canonical: "web-search" },
203
+ { name: "Monitor", defaultEnabled: true, canonical: null },
204
+ { name: "Task", defaultEnabled: true, canonical: "subagent" },
205
+ { name: "Skill", defaultEnabled: true, canonical: "skill" },
206
+ { name: "NotebookEdit", defaultEnabled: true, canonical: null },
207
+ { name: "LSP", defaultEnabled: true, canonical: null },
207
208
  ],
208
209
  categories: [],
209
210
  denySemantics: "remove-from-set",
@@ -122,6 +122,12 @@ export const codexCli: HarnessDescriptor = deepFreeze({
122
122
  render: { kind: "flag-value", flag: "--sandbox" },
123
123
  resumeRender: null,
124
124
  },
125
+ access: {
126
+ kind: "flag-value",
127
+ flag: "--sandbox",
128
+ values: { read: "read-only", write: "workspace-write" },
129
+ render: { kind: "flag-value", flag: "--sandbox" },
130
+ },
125
131
  },
126
132
  // Phase 0 fixtures: codex-tool-surface.md. No name lists anywhere -
127
133
  // not on the CLI, not in config.toml. Control is feature booleans
@@ -136,10 +142,10 @@ export const codexCli: HarnessDescriptor = deepFreeze({
136
142
  composable: false,
137
143
  builtins: [],
138
144
  categories: [
139
- { key: "shell", disableFlag: null, configKey: "features.shell_tool" },
140
- { key: "exec", disableFlag: null, configKey: "features.unified_exec" },
141
- { key: "web", disableFlag: null, configKey: "web_search" },
142
- { key: "view-image", disableFlag: null, configKey: "tools.view_image" },
145
+ { key: "shell", disableFlag: null, configKey: "features.shell_tool", canonical: [] },
146
+ { key: "exec", disableFlag: null, configKey: "features.unified_exec", canonical: [] },
147
+ { key: "web", disableFlag: null, configKey: "web_search", canonical: [] },
148
+ { key: "view-image", disableFlag: null, configKey: "tools.view_image", canonical: [] },
143
149
  ],
144
150
  denySemantics: "no-lists",
145
151
  },
@@ -26,6 +26,21 @@ export const deepFreeze = <T>(value: T): T => {
26
26
  return value;
27
27
  };
28
28
 
29
+ export const CANONICAL_TOOLS = deepFreeze([
30
+ "read",
31
+ "edit",
32
+ "write",
33
+ "shell",
34
+ "grep",
35
+ "glob",
36
+ "list",
37
+ "web-fetch",
38
+ "web-search",
39
+ "subagent",
40
+ "skill",
41
+ ] as const);
42
+ export type CanonicalTool = (typeof CANONICAL_TOOLS)[number];
43
+
29
44
  export type StreamingGranularity = "token" | "message" | "none";
30
45
 
31
46
  export type HarnessMode = "headless-turn" | "headless-session" | "interactive";
@@ -95,6 +110,7 @@ export const TURN_OPTION_KEYS = deepFreeze([
95
110
  // that strips the payload changes every bare run's semantics.
96
111
  "systemPrompt",
97
112
  "appendSystemPrompt",
113
+ "access",
98
114
  ] as const);
99
115
  export type TurnOptionKey = (typeof TURN_OPTION_KEYS)[number];
100
116
 
@@ -141,6 +157,17 @@ export type TurnOptionSpec =
141
157
  readonly values: readonly string[];
142
158
  readonly default?: string;
143
159
  })
160
+ /** Access preset: read/write dimension with per-harness rendering. */
161
+ | (SpecBase & { readonly kind: "tool-preset" })
162
+ | (SpecBase & {
163
+ readonly kind: "flag-value";
164
+ readonly flag: string;
165
+ readonly values: Readonly<Record<string, string>>;
166
+ })
167
+ | (SpecBase & {
168
+ readonly kind: "flag-list-by-value";
169
+ readonly flags: Readonly<Record<string, readonly string[]>>;
170
+ })
144
171
  /** Ladder comes from vocabulary.efforts / effortsByModel, not from here. */
145
172
  | (SpecBase & { readonly kind: "effort" })
146
173
  /** Open selector, CLEAN_SELECTOR-validated. */
@@ -187,8 +214,14 @@ export const getOptionRender = (
187
214
  spec: TurnOptionSpec,
188
215
  phase: "launch" | "resume",
189
216
  ): OptionRender | null => {
190
- if (spec.kind === "discovery") return null;
191
- return resolveRender(spec, phase);
217
+ if (
218
+ spec.kind === "discovery" ||
219
+ spec.kind === "tool-preset" ||
220
+ spec.kind === "flag-value" ||
221
+ spec.kind === "flag-list-by-value"
222
+ )
223
+ return null;
224
+ return resolveRender(spec as SpecBase, phase);
192
225
  };
193
226
 
194
227
  export interface HarnessDescriptor {
@@ -405,11 +438,13 @@ export interface HarnessDescriptor {
405
438
  readonly builtins: ReadonlyArray<{
406
439
  readonly name: string;
407
440
  readonly defaultEnabled: boolean;
441
+ readonly canonical: CanonicalTool | null;
408
442
  }>;
409
443
  readonly categories: ReadonlyArray<{
410
444
  readonly key: "shell" | "write" | "web" | "exec" | "view-image";
411
445
  readonly disableFlag: string | null;
412
446
  readonly configKey: string | null;
447
+ readonly canonical: readonly CanonicalTool[];
413
448
  }>;
414
449
  readonly denySemantics: "remove-from-set" | "policy-gate" | "no-lists";
415
450
  };
@@ -109,6 +109,11 @@ export const museCode: HarnessDescriptor = deepFreeze({
109
109
  max: 10000,
110
110
  render: { kind: "flag-value", flag: "--max-model-steps" },
111
111
  },
112
+ access: {
113
+ kind: "flag-list-by-value",
114
+ flags: { read: ["--disable-write", "--disable-shell"], write: [] },
115
+ render: { kind: "flag-list", flags: ["--disable-write", "--disable-shell"] },
116
+ },
112
117
  },
113
118
  // Phase 0 fixtures: muse-category-flags.md. No name lists; category
114
119
  // switches are enforcement gates - tools stay listed in the catalog but
@@ -124,9 +129,19 @@ export const museCode: HarnessDescriptor = deepFreeze({
124
129
  composable: false,
125
130
  builtins: [],
126
131
  categories: [
127
- { key: "write", disableFlag: "--disable-write", configKey: null },
128
- { key: "shell", disableFlag: "--disable-shell", configKey: null },
129
- { key: "web", disableFlag: "--disable-web-tools", configKey: null },
132
+ {
133
+ key: "write",
134
+ disableFlag: "--disable-write",
135
+ configKey: null,
136
+ canonical: ["write", "edit"],
137
+ },
138
+ { key: "shell", disableFlag: "--disable-shell", configKey: null, canonical: ["shell"] },
139
+ {
140
+ key: "web",
141
+ disableFlag: "--disable-web-tools",
142
+ configKey: null,
143
+ canonical: ["web-fetch", "web-search"],
144
+ },
130
145
  ],
131
146
  denySemantics: "policy-gate",
132
147
  },
@@ -25,12 +25,14 @@ import { piCli } from "./pi.js";
25
25
 
26
26
  export type DescriptorSet = Partial<Record<HarnessName, HarnessDescriptor>>;
27
27
 
28
- export const defaultDescriptors = (): DescriptorSet => ({
28
+ const SHARED_DESCRIPTORS: DescriptorSet = {
29
29
  claude: claudeCode,
30
30
  codex: codexCli,
31
31
  pi: piCli,
32
32
  muse: museCode,
33
- });
33
+ };
34
+
35
+ export const defaultDescriptors = (): DescriptorSet => SHARED_DESCRIPTORS;
34
36
 
35
37
  /** Tracks which descriptors were produced by an override file and what their
36
38
  * matcher counts are, so the execution layer can emit `matcherOverrides` on
@@ -154,6 +154,7 @@ export const piCli: HarnessDescriptor = deepFreeze({
154
154
  },
155
155
  },
156
156
  },
157
+ access: { kind: "tool-preset", render: { kind: "flag-value", flag: "--tools" } },
157
158
  },
158
159
  // Phase 0 fixtures: pi-both-tool-flags.md. Both list flags legal at once;
159
160
  // exclude subtracts from include. --tools is strict over BUILT-INS but
@@ -166,13 +167,13 @@ export const piCli: HarnessDescriptor = deepFreeze({
166
167
  includeIsStrictAllowlist: true,
167
168
  composable: true,
168
169
  builtins: [
169
- { name: "read", defaultEnabled: true },
170
- { name: "bash", defaultEnabled: true },
171
- { name: "edit", defaultEnabled: true },
172
- { name: "write", defaultEnabled: true },
173
- { name: "grep", defaultEnabled: false },
174
- { name: "find", defaultEnabled: false },
175
- { name: "ls", defaultEnabled: false },
170
+ { name: "read", defaultEnabled: true, canonical: "read" },
171
+ { name: "bash", defaultEnabled: true, canonical: "shell" },
172
+ { name: "edit", defaultEnabled: true, canonical: "edit" },
173
+ { name: "write", defaultEnabled: true, canonical: "write" },
174
+ { name: "grep", defaultEnabled: false, canonical: "grep" },
175
+ { name: "find", defaultEnabled: false, canonical: "glob" },
176
+ { name: "ls", defaultEnabled: false, canonical: "list" },
176
177
  ],
177
178
  categories: [],
178
179
  denySemantics: "remove-from-set",