@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
@@ -0,0 +1,50 @@
1
+ import { failureFromRejected } from "../execution/failure.js";
2
+ import type { ArgvRefusalError, RefusalIssue } from "../interpretation/refusal.js";
3
+ import { writeEventNdjson } from "./render.js";
4
+
5
+ export interface Refusal {
6
+ readonly message: string;
7
+ readonly issue: RefusalIssue;
8
+ readonly option?: ArgvRefusalError["option"];
9
+ readonly facet?: ArgvRefusalError["facet"];
10
+ readonly supported?: readonly string[];
11
+ readonly supportedBy?: ArgvRefusalError["supportedBy"];
12
+ readonly hint?: string;
13
+ readonly trailer?: readonly string[];
14
+ }
15
+
16
+ export const refusalOf = (err: ArgvRefusalError): Refusal => ({
17
+ message: err.message,
18
+ issue: err.issue,
19
+ option: err.option,
20
+ facet: err.facet,
21
+ supported: err.supported,
22
+ supportedBy: err.supportedBy,
23
+ hint: err.hint,
24
+ });
25
+
26
+ export const refuse = (r: Refusal, json: boolean): void => {
27
+ process.stderr.write(`${r.message}\n`);
28
+ if (r.hint) process.stderr.write(`hint: ${r.hint}\n`);
29
+ if (r.supportedBy?.length) {
30
+ process.stderr.write(
31
+ `supported on: ${r.supportedBy.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
32
+ );
33
+ }
34
+ if (r.supported?.length) process.stderr.write(`supported: ${r.supported.join(", ")}\n`);
35
+ for (const line of r.trailer ?? []) process.stderr.write(`${line}\n`);
36
+ if (json) {
37
+ const summary = failureFromRejected({
38
+ issue: r.issue,
39
+ option: r.option,
40
+ facet: r.facet,
41
+ supported: r.supported,
42
+ supportedBy: r.supportedBy,
43
+ hint: r.hint,
44
+ detail: r.message,
45
+ });
46
+ writeEventNdjson({ kind: "failure", ...summary });
47
+ writeEventNdjson({ kind: "done", exitCode: null, cause: "failed", failure: summary });
48
+ }
49
+ process.exitCode = 2;
50
+ };
@@ -0,0 +1,35 @@
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { storePath } from "../interpretation/store.js";
3
+ import type { HarnessDescriptor } from "../knowledge/descriptor.js";
4
+
5
+ export interface ResumeStoreCheck {
6
+ /** The store path hcn expects for this cwd and id, or null when the
7
+ * descriptor cannot compute one. */
8
+ readonly path: string | null;
9
+ readonly exists: boolean;
10
+ }
11
+
12
+ /** Where a harness that creates sessions on an unknown id (pi, muse) would
13
+ * have filed this session. The cwd is resolved to its real path first: the
14
+ * harness slugs the directory it actually ran in, and on macOS a temp
15
+ * directory reached through /var is really under /private/var - slugging
16
+ * the unresolved path refused valid resumes. */
17
+ export const resumeStore = (
18
+ h: HarnessDescriptor,
19
+ opts: { readonly cwd: string; readonly home: string; readonly sessionId: string },
20
+ ): ResumeStoreCheck => {
21
+ let cwd = opts.cwd;
22
+ try {
23
+ cwd = realpathSync.native(opts.cwd);
24
+ } catch {
25
+ // A cwd that does not exist yet resolves to itself; the harness will
26
+ // create it and slug whatever it created.
27
+ }
28
+ let path: string | null = null;
29
+ try {
30
+ path = storePath(h, { home: opts.home, cwd, sessionId: opts.sessionId });
31
+ } catch {
32
+ path = null;
33
+ }
34
+ return { path, exists: path !== null && existsSync(path) };
35
+ };
package/src/cli/run.ts CHANGED
@@ -1,71 +1,17 @@
1
- import { existsSync } from "node:fs";
2
1
  import type { HarnessEvent } from "../execution/events.js";
3
- import { failureFromRejected } from "../execution/failure.js";
4
2
  import { nodeRunnerDeps } from "../execution/node-deps.js";
5
3
  import { KILL_GRACE_MS, redactArgv, streamTurn } from "../execution/stream-turn.js";
6
4
  import { buildLaunchArgv, buildResumeArgv } from "../interpretation/argv.js";
7
5
  import { composeEscalatedPrompt } from "../interpretation/question.js";
8
- import type { RefusalIssue } from "../interpretation/refusal.js";
9
6
  import { ArgvRefusalError } from "../interpretation/refusal.js";
10
7
  import { FloorExceededError, resolveEffectiveOptions } from "../interpretation/resolve-options.js";
11
- import { storePath } from "../interpretation/store.js";
12
8
  import { recognizeNativeSpelling, supportedBy } from "../interpretation/support.js";
13
9
  import { defaultDescriptors } from "../knowledge/overrides.js";
14
10
  import { parseRunExtra, parseTurnOptions, resolvePromptAsync } from "./args.js";
11
+ import { refusalOf, refuse } from "./refuse.js";
15
12
  import { createRenderState, renderEvent, writeEventNdjson } from "./render.js";
16
13
  import { resolveHarness } from "./resolve-harness.js";
17
-
18
- /** One shape for every hcn-side refusal. Stderr keeps the prose (message,
19
- * hint, support lists, usage pointer); under --json the same fields also
20
- * ride stdout as a `failure` event plus a `done`, so a stream reader never
21
- * meets an empty stream on exit 2. */
22
- interface Refusal {
23
- readonly message: string;
24
- readonly issue: RefusalIssue;
25
- readonly option?: ArgvRefusalError["option"];
26
- readonly facet?: ArgvRefusalError["facet"];
27
- readonly supported?: readonly string[];
28
- readonly supportedBy?: ArgvRefusalError["supportedBy"];
29
- readonly hint?: string;
30
- /** Plain stderr lines written after the structured ones. */
31
- readonly trailer?: readonly string[];
32
- }
33
-
34
- const refusalOf = (err: ArgvRefusalError): Refusal => ({
35
- message: err.message,
36
- issue: err.issue,
37
- option: err.option,
38
- facet: err.facet,
39
- supported: err.supported,
40
- supportedBy: err.supportedBy,
41
- hint: err.hint,
42
- });
43
-
44
- const refuse = (r: Refusal, json: boolean): void => {
45
- process.stderr.write(`${r.message}\n`);
46
- if (r.hint) process.stderr.write(`hint: ${r.hint}\n`);
47
- if (r.supportedBy?.length) {
48
- process.stderr.write(
49
- `supported on: ${r.supportedBy.map((e) => `${e.harness} (${e.spelling})`).join(", ")}\n`,
50
- );
51
- }
52
- if (r.supported?.length) process.stderr.write(`supported: ${r.supported.join(", ")}\n`);
53
- for (const line of r.trailer ?? []) process.stderr.write(`${line}\n`);
54
- if (json) {
55
- const summary = failureFromRejected({
56
- issue: r.issue,
57
- option: r.option,
58
- facet: r.facet,
59
- supported: r.supported,
60
- supportedBy: r.supportedBy,
61
- hint: r.hint,
62
- detail: r.message,
63
- });
64
- writeEventNdjson({ kind: "failure", ...summary });
65
- writeEventNdjson({ kind: "done", exitCode: null, cause: "failed", failure: summary });
66
- }
67
- process.exitCode = 2;
68
- };
14
+ import { resumeStore } from "./resume-guard.js";
69
15
 
70
16
  export const run = async (harnessName: string, rawArgs: string[]): Promise<void> => {
71
17
  const h = resolveHarness(harnessName);
@@ -295,6 +241,10 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
295
241
  refuse({ message: resErr.message, issue: "invalid-tool-grant" }, wantJson);
296
242
  return;
297
243
  }
244
+ if (resErr instanceof ArgvRefusalError) {
245
+ refuse(refusalOf(resErr), wantJson);
246
+ return;
247
+ }
298
248
  throw resErr;
299
249
  }
300
250
  const { provenance, unrenderable } = resolved;
@@ -408,12 +358,12 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
408
358
  // all refuses inside buildLaunchArgv below, so a throw here is left
409
359
  // to that path.
410
360
  const grant = (fullOpts.resume ? turnOpts : effectiveTurnOpts).tools;
411
- if (grant !== undefined && grant.length > 0 && !h.tools.includeIsStrictAllowlist) {
361
+ if (grant !== undefined && grant.length > 0) {
412
362
  try {
413
363
  const { renderToolSelection } = await import("../interpretation/tool-selection.js");
414
- const { unmapped } = renderToolSelection(h, { include: [...grant] });
415
- if (unmapped.length > 0) {
416
- process.stderr.write(`provenance: unmapped tools = ${JSON.stringify(unmapped)}\n`);
364
+ const { passthrough } = renderToolSelection(h, { include: [...grant] });
365
+ if (passthrough.length > 0) {
366
+ process.stderr.write(`provenance: native tools = ${JSON.stringify(passthrough)}\n`);
417
367
  }
418
368
  } catch {
419
369
  // refused below with the structured message
@@ -426,17 +376,12 @@ export const run = async (harnessName: string, rawArgs: string[]): Promise<void>
426
376
  // the session store path does not exist; where the path cannot be
427
377
  // computed, the runner's pre-spawn warning is the only guard.
428
378
  if (fullOpts.resume !== undefined && h.resume.onMissing === "create") {
429
- let path: string | null = null;
430
- try {
431
- path = storePath(h, {
432
- home: process.env.HOME ?? process.env.USERPROFILE ?? "",
433
- cwd: extra.cwd ?? process.cwd(),
434
- sessionId: fullOpts.resume,
435
- });
436
- } catch {
437
- path = null;
438
- }
439
- if (path !== null && !existsSync(path)) {
379
+ const { path, exists } = resumeStore(h, {
380
+ home: process.env.HOME ?? process.env.USERPROFILE ?? "",
381
+ cwd: extra.cwd ?? process.cwd(),
382
+ sessionId: fullOpts.resume,
383
+ });
384
+ if (path !== null && !exists) {
440
385
  refuse(
441
386
  {
442
387
  message: `no ${h.name} session ${fullOpts.resume} found at ${path}`,
@@ -83,6 +83,9 @@ export interface TurnOptions {
83
83
  readonly escalateQuestions?: boolean;
84
84
  /** Internal: set by CLI when prompt came from --prompt/--prompt-file to bypass leading '-' guard */
85
85
  readonly __explicitPrompt?: boolean;
86
+ /** toolMap extensible vocabulary per harness (issue toolMap) */
87
+ readonly toolMap?: Readonly<Record<string, Readonly<Record<string, string>>>>;
88
+ readonly access?: "read" | "write";
86
89
  }
87
90
 
88
91
  export interface ResumeOptions extends TurnOptions {
@@ -124,9 +127,11 @@ const turnTail = (h: HarnessDescriptor, opts: TurnOptions): string[] => {
124
127
  tail.push(h.autonomy.flag);
125
128
  }
126
129
  if (opts.tools !== undefined || opts.excludeTools !== undefined) {
130
+ const perHarnessMap = opts.toolMap?.[h.name];
127
131
  const rendered = renderToolSelection(h, {
128
132
  include: opts.tools,
129
133
  exclude: opts.excludeTools,
134
+ toolMap: perHarnessMap,
130
135
  });
131
136
  tail.push(...rendered.tokens);
132
137
  }
@@ -27,6 +27,10 @@ const HINTS: Readonly<Record<string, Readonly<Record<string, string>>>> = deepFr
27
27
  "claude has no isolated instruction-file toggle; --setting-sources project isolates from user-level settings but also skips hooks, LSP and keychain reads - weigh that before using it as an approximation",
28
28
  },
29
29
  codex: {
30
+ tools:
31
+ "codex has no per-tool name lists; use --sandbox read-only for a read-only grant or category switches via config keys (features.shell_tool, web_search)",
32
+ excludeTools:
33
+ "codex has no per-tool name lists; use category switches via config keys (features.shell_tool) or --sandbox modes",
30
34
  write:
31
35
  "codex has no write toggle; use --sandbox read-only (config: sandbox_mode) so shell commands cannot write either",
32
36
  // issue #48: the append half has no codex spelling.
@@ -58,6 +62,10 @@ const HINTS: Readonly<Record<string, Readonly<Record<string, string>>>> = deepFr
58
62
  "pi has no step cap flag; bound the work in the prompt or impose a wall-clock timeout at the caller",
59
63
  },
60
64
  muse: {
65
+ tools:
66
+ "muse has no per-tool name lists; gate execution with --disable-write, --disable-shell, --disable-web-tools - there is no allowlist",
67
+ excludeTools:
68
+ "muse has no per-tool name lists; gate execution with --disable-write, --disable-shell, --disable-web-tools category switches",
61
69
  sandbox:
62
70
  "muse's sandbox is on by default and not selectable per-call; --disable-sandbox exists to turn it OFF, and exposure can be tuned with --disable-write, --disable-shell, --disable-web-tools - there is no mode selector",
63
71
  provider:
@@ -8,8 +8,13 @@
8
8
  * sandbox default already follows).
9
9
  */
10
10
  import type { HarnessDescriptor } from "../knowledge/descriptor.js";
11
+ import { defaultDescriptors } from "../knowledge/overrides.js";
11
12
  import { DEFAULT_TURN_PROFILE, type ProfileKey } from "../knowledge/profile.js";
12
13
  import type { TurnOptions } from "./argv.js";
14
+ import { ArgvRefusalError } from "./refusal.js";
15
+ import type { ToolMap } from "./tool-vocabulary.js";
16
+ import { allCanonicalNames, mergeToolMaps, validateCanonicalList } from "./tool-vocabulary.js";
17
+ import { validateAccess } from "./vocabulary.js";
13
18
 
14
19
  export type ProvenanceTier = "arg" | "project-config" | "user-config" | "profile" | "harness";
15
20
 
@@ -126,8 +131,100 @@ export const resolveEffectiveOptions = (
126
131
  }
127
132
  const resolved: Record<string, unknown> = { ...effectiveArgs };
128
133
 
129
- // D5 floor: a project toolset floor caps any arg grant; exceeding it is
130
- // a structured refusal naming both sets - never a silent clamp.
134
+ // Validate access value before exclusivity so invalid reports invalid-option-value, not mutual exclusion.
135
+ if (resolved.access !== undefined) {
136
+ const v = validateAccess(String(resolved.access));
137
+ if (!v.ok) {
138
+ throw new ArgvRefusalError({
139
+ issue: "invalid-option-value",
140
+ harness: h.name,
141
+ option: "access",
142
+ supported: ["read", "write"],
143
+ detail: String(resolved.access),
144
+ });
145
+ }
146
+ }
147
+ // Access exclusivity on codex: explicit --sandbox together with --access refuses.
148
+ // Profile sandbox yields to access - only explicit sandbox counts.
149
+ if (h.name === "codex" && resolved.access !== undefined) {
150
+ const hasExplicitSandbox =
151
+ effectiveArgs.sandbox !== undefined || sourceTier("sandbox") !== undefined;
152
+ if (hasExplicitSandbox) {
153
+ throw new ArgvRefusalError({
154
+ issue: "mutually-exclusive-options",
155
+ harness: h.name,
156
+ option: "access",
157
+ supported: ["--access or --sandbox, not both on codex"],
158
+ detail: "mutual exclusion",
159
+ });
160
+ }
161
+ }
162
+ // Access vs tools exclusivity: only explicit tools/excludeTools from args or config, never profile-derived.
163
+ if (resolved.access !== undefined) {
164
+ const explicitTools = effectiveArgs.tools !== undefined || sourceTier("tools") !== undefined;
165
+ const explicitExclude =
166
+ (effectiveArgs as unknown as Record<string, unknown>).excludeTools !== undefined ||
167
+ sourceTier("excludeTools") !== undefined;
168
+ if (explicitTools || explicitExclude) {
169
+ throw new ArgvRefusalError({
170
+ issue: "mutually-exclusive-options",
171
+ harness: h.name,
172
+ option: "access",
173
+ supported: ["--access is a preset allowlist, not a filter over --tools/--exclude-tools"],
174
+ detail: "mutual exclusion",
175
+ });
176
+ }
177
+ }
178
+
179
+ // toolMap merge per harness per canonical (project > user)
180
+ const rawToolMapUser = (tiers.user as { toolMap?: ToolMap } | undefined)?.toolMap;
181
+ const rawToolMapProject = (tiers.project as { toolMap?: ToolMap } | undefined)?.toolMap;
182
+ const mergedToolMap = mergeToolMaps({ user: rawToolMapUser, project: rawToolMapProject });
183
+ if (Object.keys(mergedToolMap).length > 0) {
184
+ // Convert mergedToolMap to legacy shape for resolved.toolMap consumers
185
+ const legacy: Record<string, Record<string, string>> = {};
186
+ for (const [harness, per] of Object.entries(mergedToolMap)) {
187
+ legacy[harness] = {};
188
+ for (const [canon, entry] of Object.entries(per as Record<string, { native: string }>)) {
189
+ legacy[harness]![canon] = entry.native;
190
+ }
191
+ }
192
+ resolved.toolMap = legacy as unknown as typeof resolved.toolMap;
193
+ const harnessMap = mergedToolMap[h.name];
194
+ if (harnessMap) {
195
+ for (const [canonical, entry] of Object.entries(harnessMap)) {
196
+ provenance.push({
197
+ key: `tools.${canonical}`,
198
+ value: entry.native,
199
+ tier: entry.tier as ProvenanceTier,
200
+ });
201
+ }
202
+ }
203
+ }
204
+
205
+ // Lazy allCanonical build only when tools context present
206
+ const needsCanonical =
207
+ tiers.project?.tools !== undefined ||
208
+ tiers.user?.tools !== undefined ||
209
+ effectiveArgs.tools !== undefined ||
210
+ (effectiveArgs as unknown as Record<string, unknown>).excludeTools !== undefined ||
211
+ Object.keys(toolsets).length > 0 ||
212
+ tiers.project?.toolMap !== undefined ||
213
+ tiers.user?.toolMap !== undefined;
214
+ let allCanonical: readonly string[] | undefined;
215
+ const getAllCanonical = (): readonly string[] => {
216
+ if (allCanonical) return allCanonical;
217
+ allCanonical = allCanonicalNames(defaultDescriptors(), mergedToolMap as unknown as ToolMap);
218
+ return allCanonical;
219
+ };
220
+ if (needsCanonical) {
221
+ const ac = getAllCanonical();
222
+ validateCanonicalList(tiers.project?.tools as readonly string[] | undefined, ac, h.name);
223
+ validateCanonicalList(tiers.user?.tools as readonly string[] | undefined, ac, h.name);
224
+ for (const set of Object.values(toolsets)) {
225
+ validateCanonicalList(set as readonly string[], ac, h.name);
226
+ }
227
+ }
131
228
  const floor = tiers.project?.tools;
132
229
  if (floor !== undefined && effectiveArgs.tools !== undefined) {
133
230
  const floorSet = new Set(floor);
@@ -158,6 +255,19 @@ export const resolveEffectiveOptions = (
158
255
  provenance.push({ key, value, tier: "harness" });
159
256
  continue;
160
257
  }
258
+ // When access is set, skip the all-known expansion (access is a preset allowlist, not a filter).
259
+ // Same shape as --no-tools skip; provenance owned by tier that set access.
260
+ if (key === "tools" && value === "all-known" && resolved.access !== undefined) {
261
+ const accessTier: ProvenanceTier =
262
+ effectiveArgs.access !== undefined ? "arg" : (sourceTier("access") ?? "user-config");
263
+ provenance.push({ key, value: "none (access preset)", tier: accessTier });
264
+ continue;
265
+ }
266
+ // Profile sandbox yields to access - when access is set, drop profile sandbox.
267
+ if (key === "sandbox" && resolved.access !== undefined) {
268
+ provenance.push({ key, value: `${String(value)} (access)`, tier: "harness" });
269
+ continue;
270
+ }
161
271
  // D13: the tools marker expands per descriptor. On a harness whose
162
272
  // default is already everything (claude), expansion emits nothing -
163
273
  // the emit-nothing rule, recorded in provenance. On a harness with
@@ -186,7 +296,9 @@ export const resolveEffectiveOptions = (
186
296
  provenance.push({ key, value: "all known (already default)", tier: "profile" });
187
297
  continue;
188
298
  }
189
- const expanded = h.tools.builtins.map((t) => t.name);
299
+ const expanded = h.tools.builtins
300
+ .filter((t) => t.canonical !== null)
301
+ .map((t) => t.canonical as string);
190
302
  resolved[key] = expanded;
191
303
  provenance.push({ key, value: expanded, tier: "profile" });
192
304
  continue;
@@ -213,6 +325,7 @@ export const resolveEffectiveOptions = (
213
325
  // (validated later by the same renderers as args).
214
326
  for (const [key, value] of Object.entries(config)) {
215
327
  if (key === "toolsets") continue; // expanded into args above, never a turn option
328
+ if (key === "toolMap") continue; // per-canonical provenance already emitted
216
329
  if (key in DEFAULT_TURN_PROFILE) continue;
217
330
  if (effectiveArgs[key as keyof TurnOptions] !== undefined) {
218
331
  provenance.push({ key, value: effectiveArgs[key as keyof TurnOptions], tier: "arg" });
@@ -223,6 +336,19 @@ export const resolveEffectiveOptions = (
223
336
  provenance.push({ key, value, tier });
224
337
  }
225
338
 
339
+ // Access divergence / fixup
340
+ if (resolved.access !== undefined && h.turnOptions.access === undefined) {
341
+ unrenderable.push("access");
342
+ for (let i = provenance.length - 1; i >= 0; i--)
343
+ if (provenance[i]?.key === "access") provenance.splice(i, 1);
344
+ provenance.push({ key: "access", value: resolved.access as string, tier: "harness" });
345
+ delete (resolved as Record<string, unknown>).access;
346
+ } else if (resolved.access !== undefined && !provenance.some((p) => p.key === "access")) {
347
+ const tier: ProvenanceTier =
348
+ effectiveArgs.access !== undefined ? "arg" : (sourceTier("access") ?? "user-config");
349
+ provenance.push({ key: "access", value: resolved.access as string, tier });
350
+ }
351
+
226
352
  return {
227
353
  options: resolved as unknown as TurnOptions,
228
354
  provenance,
@@ -14,6 +14,8 @@
14
14
  import type { HarnessDescriptor } from "../knowledge/descriptor.js";
15
15
  import type { DescriptorSet } from "../knowledge/overrides.js";
16
16
  import type { RefusalOption } from "./refusal.js";
17
+ import type { VocabularyEntry } from "./tool-vocabulary.js";
18
+ import { canonicalTable } from "./tool-vocabulary.js";
17
19
 
18
20
  export interface SupportEntry {
19
21
  readonly harness: string;
@@ -23,6 +25,15 @@ export interface SupportEntry {
23
25
 
24
26
  const spellingOf = (h: HarnessDescriptor, option: RefusalOption): string | null => {
25
27
  switch (option) {
28
+ case "access": {
29
+ const spec = h.turnOptions.access;
30
+ if (spec === undefined) return null;
31
+ if (spec.kind === "flag-value") return spec.flag;
32
+ if (spec.kind === "flag-list-by-value") return Object.values(spec.flags)[0]?.[0] ?? null;
33
+ if (spec.kind === "tool-preset")
34
+ return h.tools.includeFlag ?? h.tools.excludeFlag ?? "--sandbox";
35
+ return null;
36
+ }
26
37
  case "tools":
27
38
  return h.tools.includeFlag;
28
39
  case "excludeTools":
@@ -69,6 +80,25 @@ export const supportedBy = (set: DescriptorSet, option: RefusalOption): readonly
69
80
  return out;
70
81
  };
71
82
 
83
+ export const supportedByCanonical = (
84
+ set: DescriptorSet,
85
+ canonical: string,
86
+ ): readonly SupportEntry[] => {
87
+ const table = canonicalTable(set);
88
+ const entry = table[canonical];
89
+ if (!entry) return [];
90
+ const out: SupportEntry[] = [];
91
+ for (const [har, val] of Object.entries(entry)) {
92
+ if (val === undefined) continue;
93
+ const spelling =
94
+ (val as VocabularyEntry).kind === "builtin"
95
+ ? (val as { kind: "builtin"; native: string }).native
96
+ : (val as { kind: "category"; key: string }).key;
97
+ out.push({ harness: har, spelling });
98
+ }
99
+ return out;
100
+ };
101
+
72
102
  /** Reverse lookup for native-spelling recognition (D7 part B): given a raw
73
103
  * flag token a caller typed, find the option it belongs to and which
74
104
  * harnesses spell it that way. Returns null for tokens no descriptor