@dungle-scrubs/harness-cli-normalizer 0.4.5 → 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 (149) hide show
  1. package/README.md +53 -7
  2. package/dist/cli/args.d.ts.map +1 -1
  3. package/dist/cli/args.js +13 -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 +28 -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 +81 -25
  14. package/dist/cli/inspect.js.map +1 -1
  15. package/dist/cli/provenance.d.ts +3 -0
  16. package/dist/cli/provenance.d.ts.map +1 -0
  17. package/dist/cli/provenance.js +11 -0
  18. package/dist/cli/provenance.js.map +1 -0
  19. package/dist/cli/refuse.d.ts +14 -0
  20. package/dist/cli/refuse.d.ts.map +1 -0
  21. package/dist/cli/refuse.js +38 -0
  22. package/dist/cli/refuse.js.map +1 -0
  23. package/dist/cli/run.d.ts.map +1 -1
  24. package/dist/cli/run.js +27 -68
  25. package/dist/cli/run.js.map +1 -1
  26. package/dist/cli/session.d.ts.map +1 -1
  27. package/dist/cli/session.js +3 -3
  28. package/dist/cli/session.js.map +1 -1
  29. package/dist/cli/skills-root.d.ts +2 -1
  30. package/dist/cli/skills-root.d.ts.map +1 -1
  31. package/dist/cli/skills-root.js +6 -4
  32. package/dist/cli/skills-root.js.map +1 -1
  33. package/dist/execution/decode.d.ts.map +1 -1
  34. package/dist/execution/decode.js +2 -1
  35. package/dist/execution/decode.js.map +1 -1
  36. package/dist/execution/deps.d.ts +1 -1
  37. package/dist/execution/deps.js +1 -1
  38. package/dist/execution/failure.d.ts +5 -1
  39. package/dist/execution/failure.d.ts.map +1 -1
  40. package/dist/execution/failure.js +12 -0
  41. package/dist/execution/failure.js.map +1 -1
  42. package/dist/execution/index.d.ts +2 -2
  43. package/dist/execution/index.js +2 -2
  44. package/dist/execution/open-session.d.ts.map +1 -1
  45. package/dist/execution/open-session.js +23 -8
  46. package/dist/execution/open-session.js.map +1 -1
  47. package/dist/execution/stream-turn.d.ts.map +1 -1
  48. package/dist/execution/stream-turn.js +18 -10
  49. package/dist/execution/stream-turn.js.map +1 -1
  50. package/dist/interpretation/argv.d.ts +3 -0
  51. package/dist/interpretation/argv.d.ts.map +1 -1
  52. package/dist/interpretation/argv.js +6 -1
  53. package/dist/interpretation/argv.js.map +1 -1
  54. package/dist/interpretation/content.d.ts.map +1 -1
  55. package/dist/interpretation/content.js +4 -1
  56. package/dist/interpretation/content.js.map +1 -1
  57. package/dist/interpretation/hints.d.ts.map +1 -1
  58. package/dist/interpretation/hints.js +4 -0
  59. package/dist/interpretation/hints.js.map +1 -1
  60. package/dist/interpretation/limits.d.ts +1 -0
  61. package/dist/interpretation/limits.d.ts.map +1 -1
  62. package/dist/interpretation/limits.js +22 -0
  63. package/dist/interpretation/limits.js.map +1 -1
  64. package/dist/interpretation/resolve-options.d.ts.map +1 -1
  65. package/dist/interpretation/resolve-options.js +124 -3
  66. package/dist/interpretation/resolve-options.js.map +1 -1
  67. package/dist/interpretation/support.d.ts +1 -0
  68. package/dist/interpretation/support.d.ts.map +1 -1
  69. package/dist/interpretation/support.js +29 -0
  70. package/dist/interpretation/support.js.map +1 -1
  71. package/dist/interpretation/tool-selection.d.ts +6 -16
  72. package/dist/interpretation/tool-selection.d.ts.map +1 -1
  73. package/dist/interpretation/tool-selection.js +216 -80
  74. package/dist/interpretation/tool-selection.js.map +1 -1
  75. package/dist/interpretation/tool-vocabulary.d.ts +38 -0
  76. package/dist/interpretation/tool-vocabulary.d.ts.map +1 -0
  77. package/dist/interpretation/tool-vocabulary.js +142 -0
  78. package/dist/interpretation/tool-vocabulary.js.map +1 -0
  79. package/dist/interpretation/turn-options.d.ts.map +1 -1
  80. package/dist/interpretation/turn-options.js +107 -2
  81. package/dist/interpretation/turn-options.js.map +1 -1
  82. package/dist/interpretation/versions.d.ts +1 -1
  83. package/dist/interpretation/versions.d.ts.map +1 -1
  84. package/dist/interpretation/vocabulary.d.ts +1 -0
  85. package/dist/interpretation/vocabulary.d.ts.map +1 -1
  86. package/dist/interpretation/vocabulary.js +5 -0
  87. package/dist/interpretation/vocabulary.js.map +1 -1
  88. package/dist/knowledge/claude-code.d.ts.map +1 -1
  89. package/dist/knowledge/claude-code.js +14 -13
  90. package/dist/knowledge/claude-code.js.map +1 -1
  91. package/dist/knowledge/codex.d.ts.map +1 -1
  92. package/dist/knowledge/codex.js +10 -4
  93. package/dist/knowledge/codex.js.map +1 -1
  94. package/dist/knowledge/descriptor.d.ts +20 -1
  95. package/dist/knowledge/descriptor.d.ts.map +1 -1
  96. package/dist/knowledge/descriptor.js +18 -1
  97. package/dist/knowledge/descriptor.js.map +1 -1
  98. package/dist/knowledge/index.d.ts +1 -1
  99. package/dist/knowledge/index.d.ts.map +1 -1
  100. package/dist/knowledge/index.js +1 -1
  101. package/dist/knowledge/index.js.map +1 -1
  102. package/dist/knowledge/matchers.d.ts +2 -1
  103. package/dist/knowledge/matchers.d.ts.map +1 -1
  104. package/dist/knowledge/matchers.js +15 -0
  105. package/dist/knowledge/matchers.js.map +1 -1
  106. package/dist/knowledge/muse.d.ts.map +1 -1
  107. package/dist/knowledge/muse.js +18 -3
  108. package/dist/knowledge/muse.js.map +1 -1
  109. package/dist/knowledge/overrides.d.ts.map +1 -1
  110. package/dist/knowledge/overrides.js +3 -2
  111. package/dist/knowledge/overrides.js.map +1 -1
  112. package/dist/knowledge/pi.d.ts.map +1 -1
  113. package/dist/knowledge/pi.js +12 -8
  114. package/dist/knowledge/pi.js.map +1 -1
  115. package/package.json +1 -1
  116. package/src/cli/args.ts +13 -0
  117. package/src/cli/config.ts +88 -0
  118. package/src/cli/help.ts +28 -7
  119. package/src/cli/inspect.ts +78 -21
  120. package/src/cli/provenance.ts +19 -0
  121. package/src/cli/refuse.ts +50 -0
  122. package/src/cli/run.ts +27 -94
  123. package/src/cli/session.ts +4 -3
  124. package/src/cli/skills-root.ts +11 -4
  125. package/src/execution/decode.ts +2 -1
  126. package/src/execution/deps.ts +1 -1
  127. package/src/execution/failure.ts +17 -1
  128. package/src/execution/index.ts +2 -2
  129. package/src/execution/open-session.ts +33 -7
  130. package/src/execution/stream-turn.ts +21 -9
  131. package/src/interpretation/argv.ts +9 -1
  132. package/src/interpretation/content.ts +5 -1
  133. package/src/interpretation/hints.ts +8 -0
  134. package/src/interpretation/limits.ts +29 -0
  135. package/src/interpretation/resolve-options.ts +129 -3
  136. package/src/interpretation/support.ts +30 -0
  137. package/src/interpretation/tool-selection.ts +236 -96
  138. package/src/interpretation/tool-vocabulary.ts +183 -0
  139. package/src/interpretation/turn-options.ts +99 -2
  140. package/src/interpretation/versions.ts +1 -1
  141. package/src/interpretation/vocabulary.ts +5 -0
  142. package/src/knowledge/claude-code.ts +14 -13
  143. package/src/knowledge/codex.ts +10 -4
  144. package/src/knowledge/descriptor.ts +42 -2
  145. package/src/knowledge/index.ts +5 -1
  146. package/src/knowledge/matchers.ts +17 -1
  147. package/src/knowledge/muse.ts +18 -3
  148. package/src/knowledge/overrides.ts +4 -2
  149. package/src/knowledge/pi.ts +12 -8
@@ -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({
@@ -7,7 +7,7 @@
7
7
  * precedence.
8
8
  */
9
9
 
10
- export type VersionStatus = "ok" | "behind" | "ahead" | "unknown";
10
+ export type VersionStatus = "ok" | "behind" | "ahead" | "unknown" | "drift";
11
11
 
12
12
  const parse = (v: string): number[] =>
13
13
  v
@@ -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";
@@ -71,6 +86,11 @@ export interface AuthMatcher {
71
86
  readonly kind: AuthFailureKind;
72
87
  }
73
88
 
89
+ export interface TransportMatcher {
90
+ readonly pattern: string;
91
+ readonly flags?: string;
92
+ }
93
+
74
94
  /** The turn-option vocabulary is closed for the same reason `LimitCode` is:
75
95
  * a descriptor must not invent an option a consumer has no field for. Every
76
96
  * key here maps to a field on `TurnOptions`; adding a key without a consumer
@@ -90,6 +110,7 @@ export const TURN_OPTION_KEYS = deepFreeze([
90
110
  // that strips the payload changes every bare run's semantics.
91
111
  "systemPrompt",
92
112
  "appendSystemPrompt",
113
+ "access",
93
114
  ] as const);
94
115
  export type TurnOptionKey = (typeof TURN_OPTION_KEYS)[number];
95
116
 
@@ -136,6 +157,17 @@ export type TurnOptionSpec =
136
157
  readonly values: readonly string[];
137
158
  readonly default?: string;
138
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
+ })
139
171
  /** Ladder comes from vocabulary.efforts / effortsByModel, not from here. */
140
172
  | (SpecBase & { readonly kind: "effort" })
141
173
  /** Open selector, CLEAN_SELECTOR-validated. */
@@ -182,8 +214,14 @@ export const getOptionRender = (
182
214
  spec: TurnOptionSpec,
183
215
  phase: "launch" | "resume",
184
216
  ): OptionRender | null => {
185
- if (spec.kind === "discovery") return null;
186
- 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);
187
225
  };
188
226
 
189
227
  export interface HarnessDescriptor {
@@ -400,11 +438,13 @@ export interface HarnessDescriptor {
400
438
  readonly builtins: ReadonlyArray<{
401
439
  readonly name: string;
402
440
  readonly defaultEnabled: boolean;
441
+ readonly canonical: CanonicalTool | null;
403
442
  }>;
404
443
  readonly categories: ReadonlyArray<{
405
444
  readonly key: "shell" | "write" | "web" | "exec" | "view-image";
406
445
  readonly disableFlag: string | null;
407
446
  readonly configKey: string | null;
447
+ readonly canonical: readonly CanonicalTool[];
408
448
  }>;
409
449
  readonly denySemantics: "remove-from-set" | "policy-gate" | "no-lists";
410
450
  };
@@ -12,7 +12,11 @@
12
12
  export { claudeCode } from "./claude-code.js";
13
13
  export { codexCli } from "./codex.js";
14
14
  export * from "./descriptor.js";
15
- export { SHARED_AUTH_MATCHERS, SHARED_LIMIT_MATCHERS } from "./matchers.js";
15
+ export {
16
+ SHARED_AUTH_MATCHERS,
17
+ SHARED_LIMIT_MATCHERS,
18
+ SHARED_TRANSPORT_MATCHERS,
19
+ } from "./matchers.js";
16
20
  export { museCode } from "./muse.js";
17
21
  export {
18
22
  type DescriptorSet,
@@ -4,7 +4,7 @@
4
4
  * or a provider 401 must classify no matter which CLI printed it. Each
5
5
  * descriptor spreads these after its harness-specific phrasings.
6
6
  */
7
- import type { AuthMatcher, LimitMatcher } from "./descriptor.js";
7
+ import type { AuthMatcher, LimitMatcher, TransportMatcher } from "./descriptor.js";
8
8
 
9
9
  export const SHARED_LIMIT_MATCHERS: ReadonlyArray<LimitMatcher> = [
10
10
  { pattern: "you'?ve hit your usage limit", flags: "i", code: "usage-limit" },
@@ -37,3 +37,19 @@ export const SHARED_AUTH_MATCHERS: ReadonlyArray<AuthMatcher> = [
37
37
  { pattern: "401 unauthorized", flags: "i", kind: "expired" },
38
38
  { pattern: "invalid api key", flags: "i", kind: "invalid-key" },
39
39
  ];
40
+
41
+ export const SHARED_TRANSPORT_MATCHERS: ReadonlyArray<TransportMatcher> = [
42
+ { pattern: "connection error", flags: "i" },
43
+ { pattern: "ECONNREFUSED", flags: "i" },
44
+ { pattern: "ECONNRESET", flags: "i" },
45
+ { pattern: "ENOTFOUND", flags: "i" },
46
+ { pattern: "EAI_AGAIN", flags: "i" },
47
+ { pattern: "ETIMEDOUT", flags: "i" },
48
+ { pattern: "fetch failed", flags: "i" },
49
+ { pattern: "socket hang up", flags: "i" },
50
+ { pattern: "network error", flags: "i" },
51
+ { pattern: "service unavailable", flags: "i" },
52
+ { pattern: "bad gateway", flags: "i" },
53
+ { pattern: "gateway time-?out", flags: "i" },
54
+ { pattern: "\\b(?:HTTP|status(?:[_ ]?code)?|code)\\b\\W*[:=]?\\W*50[234]\\b", flags: "i" },
55
+ ];
@@ -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
@@ -78,7 +78,10 @@ export const piCli: HarnessDescriptor = deepFreeze({
78
78
  announce: { match: { type: "session" }, idField: "id" },
79
79
  },
80
80
  limitMatchers: [...SHARED_LIMIT_MATCHERS],
81
- authMatchers: [...SHARED_AUTH_MATCHERS],
81
+ authMatchers: [
82
+ { pattern: "No API key found for", flags: "i", kind: "not-logged-in" },
83
+ ...SHARED_AUTH_MATCHERS,
84
+ ],
82
85
  autonomy: null,
83
86
  vocabulary: {
84
87
  modelFlag: "--model",
@@ -151,6 +154,7 @@ export const piCli: HarnessDescriptor = deepFreeze({
151
154
  },
152
155
  },
153
156
  },
157
+ access: { kind: "tool-preset", render: { kind: "flag-value", flag: "--tools" } },
154
158
  },
155
159
  // Phase 0 fixtures: pi-both-tool-flags.md. Both list flags legal at once;
156
160
  // exclude subtracts from include. --tools is strict over BUILT-INS but
@@ -163,13 +167,13 @@ export const piCli: HarnessDescriptor = deepFreeze({
163
167
  includeIsStrictAllowlist: true,
164
168
  composable: true,
165
169
  builtins: [
166
- { name: "read", defaultEnabled: true },
167
- { name: "bash", defaultEnabled: true },
168
- { name: "edit", defaultEnabled: true },
169
- { name: "write", defaultEnabled: true },
170
- { name: "grep", defaultEnabled: false },
171
- { name: "find", defaultEnabled: false },
172
- { 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" },
173
177
  ],
174
178
  categories: [],
175
179
  denySemantics: "remove-from-set",