@dungle-scrubs/harness-cli-normalizer 0.3.1 → 0.4.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 (97) hide show
  1. package/README.md +64 -86
  2. package/dist/cli/args.d.ts +11 -0
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +23 -5
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/cli/config.d.ts +25 -0
  7. package/dist/cli/config.d.ts.map +1 -0
  8. package/dist/cli/config.js +139 -0
  9. package/dist/cli/config.js.map +1 -0
  10. package/dist/cli/inspect.d.ts.map +1 -1
  11. package/dist/cli/inspect.js +35 -1
  12. package/dist/cli/inspect.js.map +1 -1
  13. package/dist/cli/render.d.ts.map +1 -1
  14. package/dist/cli/render.js +10 -1
  15. package/dist/cli/render.js.map +1 -1
  16. package/dist/cli/run.d.ts.map +1 -1
  17. package/dist/cli/run.js +134 -4
  18. package/dist/cli/run.js.map +1 -1
  19. package/dist/execution/failure.d.ts +21 -4
  20. package/dist/execution/failure.d.ts.map +1 -1
  21. package/dist/execution/failure.js +26 -3
  22. package/dist/execution/failure.js.map +1 -1
  23. package/dist/execution/stream-turn.d.ts +4 -0
  24. package/dist/execution/stream-turn.d.ts.map +1 -1
  25. package/dist/execution/stream-turn.js +28 -4
  26. package/dist/execution/stream-turn.js.map +1 -1
  27. package/dist/interpretation/argv.d.ts +1 -0
  28. package/dist/interpretation/argv.d.ts.map +1 -1
  29. package/dist/interpretation/argv.js +13 -19
  30. package/dist/interpretation/argv.js.map +1 -1
  31. package/dist/interpretation/hints.d.ts +22 -0
  32. package/dist/interpretation/hints.d.ts.map +1 -0
  33. package/dist/interpretation/hints.js +59 -0
  34. package/dist/interpretation/hints.js.map +1 -0
  35. package/dist/interpretation/refusal.d.ts +25 -4
  36. package/dist/interpretation/refusal.d.ts.map +1 -1
  37. package/dist/interpretation/refusal.js +16 -0
  38. package/dist/interpretation/refusal.js.map +1 -1
  39. package/dist/interpretation/resolve-options.d.ts +42 -0
  40. package/dist/interpretation/resolve-options.d.ts.map +1 -0
  41. package/dist/interpretation/resolve-options.js +140 -0
  42. package/dist/interpretation/resolve-options.js.map +1 -0
  43. package/dist/interpretation/support.d.ts +32 -0
  44. package/dist/interpretation/support.d.ts.map +1 -0
  45. package/dist/interpretation/support.js +107 -0
  46. package/dist/interpretation/support.js.map +1 -0
  47. package/dist/interpretation/tool-selection.d.ts +29 -0
  48. package/dist/interpretation/tool-selection.d.ts.map +1 -0
  49. package/dist/interpretation/tool-selection.js +122 -0
  50. package/dist/interpretation/tool-selection.js.map +1 -0
  51. package/dist/interpretation/turn-options.d.ts.map +1 -1
  52. package/dist/interpretation/turn-options.js +4 -0
  53. package/dist/interpretation/turn-options.js.map +1 -1
  54. package/dist/knowledge/claude-code.d.ts.map +1 -1
  55. package/dist/knowledge/claude-code.js +34 -1
  56. package/dist/knowledge/claude-code.js.map +1 -1
  57. package/dist/knowledge/codex.d.ts.map +1 -1
  58. package/dist/knowledge/codex.js +19 -0
  59. package/dist/knowledge/codex.js.map +1 -1
  60. package/dist/knowledge/descriptor.d.ts +32 -0
  61. package/dist/knowledge/descriptor.d.ts.map +1 -1
  62. package/dist/knowledge/muse.d.ts.map +1 -1
  63. package/dist/knowledge/muse.js +19 -0
  64. package/dist/knowledge/muse.js.map +1 -1
  65. package/dist/knowledge/pi.d.ts.map +1 -1
  66. package/dist/knowledge/pi.js +21 -0
  67. package/dist/knowledge/pi.js.map +1 -1
  68. package/dist/knowledge/profile.d.ts +13 -0
  69. package/dist/knowledge/profile.d.ts.map +1 -0
  70. package/dist/knowledge/profile.js +31 -0
  71. package/dist/knowledge/profile.js.map +1 -0
  72. package/package.json +2 -23
  73. package/src/cli/args.ts +31 -1
  74. package/src/cli/config.ts +166 -0
  75. package/src/cli/inspect.ts +34 -1
  76. package/src/cli/render.ts +10 -1
  77. package/src/cli/run.ts +161 -5
  78. package/src/execution/failure.ts +44 -5
  79. package/src/execution/stream-turn.ts +33 -3
  80. package/src/interpretation/argv.ts +14 -19
  81. package/src/interpretation/hints.ts +99 -0
  82. package/src/interpretation/refusal.ts +32 -3
  83. package/src/interpretation/resolve-options.ts +190 -0
  84. package/src/interpretation/support.ts +119 -0
  85. package/src/interpretation/tool-selection.ts +161 -0
  86. package/src/interpretation/turn-options.ts +4 -0
  87. package/src/knowledge/claude-code.ts +34 -1
  88. package/src/knowledge/codex.ts +19 -0
  89. package/src/knowledge/descriptor.ts +32 -0
  90. package/src/knowledge/muse.ts +19 -0
  91. package/src/knowledge/pi.ts +21 -0
  92. package/src/knowledge/profile.ts +33 -0
  93. package/dist/index.d.ts +0 -8
  94. package/dist/index.d.ts.map +0 -1
  95. package/dist/index.js +0 -8
  96. package/dist/index.js.map +0 -1
  97. package/src/index.ts +0 -7
@@ -17,19 +17,32 @@ export const REFUSAL_ISSUES = deepFreeze([
17
17
  "unknown-model",
18
18
  "invalid-env",
19
19
  "invalid-tool-grant",
20
+ "unknown-tool-name",
21
+ "mutually-exclusive-options",
20
22
  "prompt-flag-injection",
21
23
  "no-autonomy-mode",
22
24
  "no-session-mode",
23
25
  ] as const);
24
26
  export type RefusalIssue = (typeof REFUSAL_ISSUES)[number];
25
27
 
28
+ /** The option a refusal names: turn-option spec keys (descriptor tables)
29
+ * plus the tool-list dimensions and autonomy, which render via dedicated
30
+ * descriptor fields rather than a spec table. Kept closed so consumers can
31
+ * branch on it without a default arm. */
32
+ export type RefusalOption =
33
+ | TurnOptionKey
34
+ | "tools"
35
+ | "excludeTools"
36
+ | "autonomy"
37
+ | `discovery.${string}`;
38
+
26
39
  /** One helper builds the message from the structured fields so message and
27
40
  * fields cannot drift. Every message names an alternative, not only a
28
41
  * negation, so an agent can pivot without reading the descriptor. */
29
42
  export const buildRefusalMessage = (
30
43
  issue: RefusalIssue,
31
44
  harness: HarnessName,
32
- option?: TurnOptionKey,
45
+ option?: RefusalOption,
33
46
  facet?: DiscoveryFacet,
34
47
  supported: readonly string[] = [],
35
48
  detail?: string,
@@ -66,6 +79,10 @@ export const buildRefusalMessage = (
66
79
  return `invalid env key or value for ${harness}${detailSuffix}; ${supportedStr} - keys must match ^[A-Za-z_][A-Za-z0-9_]*$ and contain no NUL`;
67
80
  case "invalid-tool-grant":
68
81
  return `tool grant for ${harness} contains an empty entry or a comma; a blank tool flag value grants nothing detectable, and a comma inside one name silently splits the grant; ${supportedStr} - provide comma-free, non-empty tool names as separate entries`;
82
+ case "unknown-tool-name":
83
+ return `${harness} cannot compute a tool complement around an unknown name${detailSuffix}; ${supportedStr} - exclude only curated names, or pass the unknown name through an include list instead`;
84
+ case "mutually-exclusive-options":
85
+ return `${harness} cannot combine${optionPart}${detailSuffix}; ${supportedStr} - pass exactly one of them`;
69
86
  case "prompt-flag-injection":
70
87
  return `positional prompt may not start with '-'; it would be parsed as a flag by ${harness}${detailSuffix}; ${supportedStr} - remove leading '-' or prefix with a space`;
71
88
  case "no-autonomy-mode":
@@ -83,15 +100,25 @@ export const buildRefusalMessage = (
83
100
  export class ArgvRefusalError extends Error {
84
101
  readonly issue: RefusalIssue;
85
102
  readonly harness: HarnessName;
86
- readonly option?: TurnOptionKey;
103
+ readonly option?: RefusalOption;
87
104
  readonly facet?: DiscoveryFacet;
88
105
  readonly supported: readonly string[];
106
+ /** D7: which harnesses DO express the refused option, native spellings
107
+ * included. Derived by the raise site from descriptors - absent when the
108
+ * refusing layer has no descriptor set in scope. */
109
+ readonly supportedBy?: ReadonlyArray<{ harness: string; spelling: string }>;
110
+ /** D8: nearest-alternative suggestion for the CURRENT harness - keeps a
111
+ * scanning agent on its chosen harness instead of switching. Curatorial
112
+ * data set at the raise site; absent when no hint exists. */
113
+ readonly hint?: string;
89
114
  constructor(args: {
90
115
  readonly issue: RefusalIssue;
91
116
  readonly harness: HarnessName;
92
- readonly option?: TurnOptionKey;
117
+ readonly option?: RefusalOption;
93
118
  readonly facet?: DiscoveryFacet;
94
119
  readonly supported?: readonly string[];
120
+ readonly supportedBy?: ReadonlyArray<{ harness: string; spelling: string }>;
121
+ readonly hint?: string;
95
122
  readonly detail?: string;
96
123
  readonly message?: string;
97
124
  }) {
@@ -112,5 +139,7 @@ export class ArgvRefusalError extends Error {
112
139
  this.option = args.option;
113
140
  this.facet = args.facet;
114
141
  this.supported = args.supported ?? [];
142
+ this.supportedBy = args.supportedBy;
143
+ this.hint = args.hint;
115
144
  }
116
145
  }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Option resolution: the precedence chain args > user config > built-in
3
+ * profile, plus provenance. Pure - the config FILE is read by the CLI layer
4
+ * and passed in as data; this layer only decides what wins.
5
+ *
6
+ * Launch-only: callers apply resolved options on launch, never resume (a
7
+ * resumed session keeps its session's settings - same rule the codex
8
+ * sandbox default already follows).
9
+ */
10
+ import type { HarnessDescriptor } from "../knowledge/descriptor.js";
11
+ import { DEFAULT_TURN_PROFILE, type ProfileKey } from "../knowledge/profile.js";
12
+ import type { TurnOptions } from "./argv.js";
13
+
14
+ export type ProvenanceTier = "arg" | "project-config" | "user-config" | "profile" | "harness";
15
+
16
+ export interface ProvenanceEntry {
17
+ readonly key: string;
18
+ readonly value: unknown;
19
+ readonly tier: ProvenanceTier;
20
+ }
21
+
22
+ /** D5: the project floor caps arg grants; exceeding it refuses naming both
23
+ * sets. Structured-first: fields, not prose parsing. */
24
+ export class FloorExceededError extends Error {
25
+ readonly harness: string;
26
+ readonly excess: readonly string[];
27
+ readonly floor: readonly string[];
28
+ constructor(harness: string, excess: readonly string[], floor: readonly string[]) {
29
+ super(
30
+ `tool grant exceeds the project floor: ${JSON.stringify(excess)} not in floor ${JSON.stringify(floor)} - request a grant within the floor or raise the floor in the project config`,
31
+ );
32
+ this.name = "FloorExceededError";
33
+ this.harness = harness;
34
+ this.excess = excess;
35
+ this.floor = floor;
36
+ }
37
+ }
38
+
39
+ export interface ResolvedOptions {
40
+ readonly options: TurnOptions;
41
+ readonly provenance: readonly ProvenanceEntry[];
42
+ /** Profile dimensions this harness cannot express (skip-and-report,
43
+ * never refuse): rendered as divergence, not failure. */
44
+ readonly unrenderable: readonly string[];
45
+ }
46
+
47
+ /** Expressibility per profile dimension. Dimensions whose "on" state is
48
+ * the harness's own default (discovery all-on) or whose "off" state emits
49
+ * nothing (autonomy false) are expressible EVERYWHERE - the profile value
50
+ * resolves to "emit nothing," which every harness can do. Divergence is
51
+ * reserved for dimensions that would emit a flag the harness lacks. */
52
+ const EXPRESSIBLE: Readonly<Record<ProfileKey, (h: HarnessDescriptor) => boolean>> = {
53
+ effort: (h) => h.turnOptions.effort !== undefined,
54
+ sandbox: (h) => h.turnOptions.sandbox !== undefined,
55
+ discovery: () => true,
56
+ autonomy: () => true,
57
+ };
58
+
59
+ export interface ConfigTiers {
60
+ /** ~/.config/hcn/config.json (XDG) - machine-wide defaults. */
61
+ readonly user?: Readonly<Partial<TurnOptions>>;
62
+ /** <git-root>/.hcn/config.json - auto-discovered (ratified A), the ALL-
63
+ * OFF tier; its `tools` key is both the default grant and the FLOOR: an
64
+ * arg grant exceeding it refuses, naming both sets (D5). */
65
+ readonly project?: Readonly<Partial<TurnOptions>>;
66
+ }
67
+
68
+ /** Merge semantics (gap 1, resolved): config keys are scalars and lists in
69
+ * schema v1 - there is nothing to deep-merge INTO - so precedence is whole-
70
+ * key replacement: arg > project > user > profile. A future nested key
71
+ * (per-harness sections) ships with schema v2 and its own merge rule. */
72
+ const effectiveConfig = (tiers: ConfigTiers): Readonly<Partial<TurnOptions>> => ({
73
+ ...(tiers.user ?? {}),
74
+ ...(tiers.project ?? {}),
75
+ });
76
+
77
+ /** Resolve the effective options for a LAUNCH. `args` is what the caller
78
+ * passed explicitly (highest tier); `userConfig` the parsed config file;
79
+ * the profile is the floor. Keys absent everywhere defer to the harness
80
+ * and are reported with tier "harness" only when something (config or
81
+ * profile) attempted them - a truly untouched dimension is nobody's
82
+ * business and appears in provenance as tier "harness" with no value. */
83
+ /** A discovery value emits nothing when every facet is true (on). */
84
+ const emitsNothing = (value: unknown): boolean =>
85
+ typeof value === "object" &&
86
+ value !== null &&
87
+ Object.values(value as Record<string, unknown>).every((v) => v === true);
88
+
89
+ export const resolveEffectiveOptions = (
90
+ h: HarnessDescriptor,
91
+ args: TurnOptions,
92
+ tiers: ConfigTiers = {},
93
+ ): ResolvedOptions => {
94
+ const provenance: ProvenanceEntry[] = [];
95
+ const unrenderable: string[] = [];
96
+ const config = effectiveConfig(tiers);
97
+ const sourceTier = (key: string): ProvenanceTier | undefined => {
98
+ if (tiers.project?.[key as keyof TurnOptions] !== undefined) return "project-config";
99
+ if (tiers.user?.[key as keyof TurnOptions] !== undefined) return "user-config";
100
+ return undefined;
101
+ };
102
+
103
+ // D5 toolset expansion: a bare --tools name matching a configured
104
+ // toolset resolves to its list BEFORE the floor check, so a named set
105
+ // within the floor passes and one exceeding it refuses naming the set's
106
+ // members. Project toolsets win name collisions over user toolsets.
107
+ const toolsets = {
108
+ ...((tiers.user as { toolsets?: Record<string, string[]> } | undefined)?.toolsets ?? {}),
109
+ ...((tiers.project as { toolsets?: Record<string, string[]> } | undefined)?.toolsets ?? {}),
110
+ };
111
+ let effectiveArgs = args;
112
+ if (
113
+ args.tools !== undefined &&
114
+ args.tools.length === 1 &&
115
+ toolsets[args.tools[0] as string] !== undefined
116
+ ) {
117
+ effectiveArgs = { ...args, tools: toolsets[args.tools[0] as string] };
118
+ provenance.push({
119
+ key: "tools",
120
+ value: effectiveArgs.tools,
121
+ tier: "arg",
122
+ });
123
+ }
124
+ const resolved: Record<string, unknown> = { ...effectiveArgs };
125
+
126
+ // D5 floor: a project toolset floor caps any arg grant; exceeding it is
127
+ // a structured refusal naming both sets - never a silent clamp.
128
+ const floor = tiers.project?.tools;
129
+ if (floor !== undefined && effectiveArgs.tools !== undefined) {
130
+ const floorSet = new Set(floor);
131
+ const excess = effectiveArgs.tools.filter((t) => !floorSet.has(t));
132
+ if (excess.length > 0) {
133
+ throw new FloorExceededError(h.name, excess, [...floor]);
134
+ }
135
+ }
136
+
137
+ // Profile is the floor: apply only where nothing above it set the key.
138
+ for (const [key, value] of Object.entries(DEFAULT_TURN_PROFILE)) {
139
+ const argsSet = effectiveArgs[key as keyof TurnOptions] !== undefined;
140
+ const tier = sourceTier(key);
141
+ if (argsSet) {
142
+ provenance.push({ key, value: effectiveArgs[key as keyof TurnOptions], tier: "arg" });
143
+ continue;
144
+ }
145
+ if (tier !== undefined) {
146
+ provenance.push({ key, value: config[key as keyof TurnOptions], tier });
147
+ resolved[key] = config[key as keyof TurnOptions];
148
+ continue;
149
+ }
150
+ const expressible = EXPRESSIBLE[key as ProfileKey]?.(h) ?? false;
151
+ if (!expressible) {
152
+ // Skip-and-report: a profile default this harness cannot express is
153
+ // reported divergence, never a refusal and never silence.
154
+ unrenderable.push(key);
155
+ provenance.push({ key, value, tier: "harness" });
156
+ continue;
157
+ }
158
+ // Dimensions whose value reduces to "emit nothing" (autonomy false,
159
+ // discovery all-on) stay ABSENT from the resolved options - the
160
+ // harness's default already satisfies the profile, and emitting
161
+ // explicit on-flags would change resume grammar and add breakage
162
+ // surface for no semantic gain. Provenance still records the tier.
163
+ if ((key === "autonomy" && value === false) || (key === "discovery" && emitsNothing(value))) {
164
+ provenance.push({ key, value, tier: "profile" });
165
+ continue;
166
+ }
167
+ resolved[key] = value;
168
+ provenance.push({ key, value, tier: "profile" });
169
+ }
170
+
171
+ // Config keys outside the profile surface pass through at their own tier
172
+ // (validated later by the same renderers as args).
173
+ for (const [key, value] of Object.entries(config)) {
174
+ if (key === "toolsets") continue; // expanded into args above, never a turn option
175
+ if (key in DEFAULT_TURN_PROFILE) continue;
176
+ if (effectiveArgs[key as keyof TurnOptions] !== undefined) {
177
+ provenance.push({ key, value: effectiveArgs[key as keyof TurnOptions], tier: "arg" });
178
+ continue;
179
+ }
180
+ resolved[key] = value;
181
+ const tier = sourceTier(key) ?? "user-config";
182
+ provenance.push({ key, value, tier });
183
+ }
184
+
185
+ return {
186
+ options: resolved as unknown as TurnOptions,
187
+ provenance,
188
+ unrenderable,
189
+ };
190
+ };
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Cross-harness support derivation: pure functions that answer "which
3
+ * harnesses express this option, and under what native spelling" from the
4
+ * descriptor set. Phase 3 (D7): refusal diagnostics derive support lists at
5
+ * runtime instead of hardcoding them, so a descriptor edit can never leave
6
+ * a refusal message stale. The autonomy refusal in argv.ts was the
7
+ * counter-pattern - a hardcoded flag array that drifts on descriptor
8
+ * change.
9
+ *
10
+ * Interpretation-layer pure function; takes the descriptor set as an
11
+ * argument rather than importing the defaults, so override sets work.
12
+ */
13
+
14
+ import type { HarnessDescriptor } from "../knowledge/descriptor.js";
15
+ import type { DescriptorSet } from "../knowledge/overrides.js";
16
+ import type { RefusalOption } from "./refusal.js";
17
+
18
+ export interface SupportEntry {
19
+ readonly harness: string;
20
+ /** The native spelling a caller would use directly on that harness. */
21
+ readonly spelling: string;
22
+ }
23
+
24
+ const spellingOf = (h: HarnessDescriptor, option: RefusalOption): string | null => {
25
+ switch (option) {
26
+ case "tools":
27
+ return h.tools.includeFlag;
28
+ case "excludeTools":
29
+ return h.tools.excludeFlag;
30
+ case "autonomy":
31
+ return h.autonomy?.flag ?? null;
32
+ case "effort":
33
+ case "sandbox":
34
+ case "provider":
35
+ case "write":
36
+ case "shell":
37
+ case "maxSteps":
38
+ case "discovery": {
39
+ const spec = h.turnOptions[option];
40
+ if (spec === undefined) return null;
41
+ const rawRender =
42
+ spec.kind === "discovery"
43
+ ? (
44
+ Object.values(spec.facets)[0] as
45
+ | { render?: { flag?: string; flags?: string[] } }
46
+ | undefined
47
+ )?.render
48
+ : (spec as { render?: { flag?: string; flags?: string[] } }).render;
49
+ if (rawRender === undefined) return null;
50
+ // flag-value/config-kv carry `flag`; flag-list carries `flags` - the
51
+ // first entry is the spelling a caller would type.
52
+ return rawRender.flag ?? rawRender.flags?.[0] ?? null;
53
+ }
54
+ default:
55
+ return null;
56
+ }
57
+ };
58
+
59
+ /** Every harness in the set that can express `option`, with its native
60
+ * spelling. Ordered by the set's insertion order (defaults: claude, codex,
61
+ * pi, muse) so output is stable across calls. */
62
+ export const supportedBy = (set: DescriptorSet, option: RefusalOption): readonly SupportEntry[] => {
63
+ const out: SupportEntry[] = [];
64
+ for (const h of Object.values(set)) {
65
+ if (h === undefined) continue;
66
+ const spelling = spellingOf(h, option);
67
+ if (spelling !== null) out.push({ harness: h.name, spelling });
68
+ }
69
+ return out;
70
+ };
71
+
72
+ /** Reverse lookup for native-spelling recognition (D7 part B): given a raw
73
+ * flag token a caller typed, find the option it belongs to and which
74
+ * harnesses spell it that way. Returns null for tokens no descriptor
75
+ * knows - those keep the plain unknown-flag error. */
76
+ export const recognizeNativeSpelling = (
77
+ set: DescriptorSet,
78
+ flag: string,
79
+ ): { readonly option: RefusalOption; readonly entries: readonly SupportEntry[] } | null => {
80
+ const normalized = flag.toLowerCase();
81
+ const candidates: RefusalOption[] = [
82
+ "tools",
83
+ "excludeTools",
84
+ "autonomy",
85
+ "effort",
86
+ "sandbox",
87
+ "provider",
88
+ "write",
89
+ "shell",
90
+ "maxSteps",
91
+ ];
92
+ for (const option of candidates) {
93
+ const entries = supportedBy(set, option).filter((e) => e.spelling.toLowerCase() === normalized);
94
+ if (entries.length > 0) return { option, entries };
95
+ }
96
+ // Discovery facets: single-dash spellings (pi -nt/-nc/-ne/-ns) and
97
+ // claude's --setting-sources. Facet spellings map to the facet name so
98
+ // the redirect can name the normalized spelling.
99
+ const FACET_KEYS = ["tools", "instructionFiles", "extensions", "skills"] as const;
100
+ for (const facet of FACET_KEYS) {
101
+ const entries: SupportEntry[] = [];
102
+ for (const h of Object.values(set)) {
103
+ if (h === undefined) continue;
104
+ const spec = h.turnOptions.discovery;
105
+ if (spec === undefined || spec.kind !== "discovery") continue;
106
+ const facetSpec = spec.facets[facet];
107
+ if (facetSpec === undefined) continue;
108
+ const render = facetSpec.render as { flag?: string; flags?: string[] };
109
+ const spelling = render.flag ?? render.flags?.[0];
110
+ if (spelling !== undefined && spelling.toLowerCase() === normalized) {
111
+ entries.push({ harness: h.name, spelling });
112
+ }
113
+ }
114
+ if (entries.length > 0) {
115
+ return { option: `discovery.${facet}` as RefusalOption, entries };
116
+ }
117
+ }
118
+ return null;
119
+ };
@@ -0,0 +1,161 @@
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.
17
+ */
18
+ import type { HarnessDescriptor } from "../knowledge/descriptor.js";
19
+ import { defaultDescriptors } from "../knowledge/overrides.js";
20
+ import { ArgvRefusalError } from "./refusal.js";
21
+ import { supportedBy } from "./support.js";
22
+
23
+ export const TOOL_SELECTOR = /^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,127}$/;
24
+
25
+ export interface ToolSelection {
26
+ readonly include?: readonly string[];
27
+ readonly exclude?: readonly string[];
28
+ }
29
+
30
+ export interface RenderedToolSelection {
31
+ readonly tokens: readonly string[];
32
+ readonly unmapped: readonly string[];
33
+ }
34
+
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
+ }
53
+ }
54
+ return [...names];
55
+ };
56
+
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 = (
60
+ 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);
69
+ }
70
+ return { mapped, unmapped };
71
+ };
72
+
73
+ export const renderToolSelection = (
74
+ h: HarnessDescriptor,
75
+ selection: ToolSelection,
76
+ ): RenderedToolSelection => {
77
+ const hasInclude = selection.include !== undefined;
78
+ const hasExclude = selection.exclude !== undefined;
79
+
80
+ // D1: mutual exclusivity, structured refusal.
81
+ if (hasInclude && hasExclude) {
82
+ throw new ArgvRefusalError({
83
+ issue: "mutually-exclusive-options",
84
+ harness: h.name,
85
+ option: "tools",
86
+ supported: ["--tools (exact allowlist) or --exclude-tools (complement), never both"],
87
+ detail: "mutual exclusion",
88
+ });
89
+ }
90
+ if (!hasInclude && !hasExclude) return { tokens: [], unmapped: [] };
91
+
92
+ if (h.tools.includeFlag === null || h.tools.excludeFlag === null) {
93
+ const option = hasInclude ? "tools" : "excludeTools";
94
+ const by = supportedBy(defaultDescriptors(), option);
95
+ throw new ArgvRefusalError({
96
+ issue: "unsupported-option",
97
+ harness: h.name,
98
+ option,
99
+ supported: ["per-tool name lists"],
100
+ supportedBy: by,
101
+ 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",
105
+ });
106
+ }
107
+
108
+ if (hasInclude) {
109
+ const names = validateNames(h, selection.include!);
110
+ const { mapped, unmapped } = resolveNames(h, names);
111
+ if (!h.tools.includeIsStrictAllowlist) {
112
+ // claude: exact allowlist must reshape the visible set via the deny
113
+ // complement (probe 2b). The include flag also carries the granted
114
+ // names (curated + pass-throughs) so they skip approval prompts;
115
+ // unmapped names ride along - the deny complement is computed over
116
+ // curated names only, so an extension tool in an include is granted,
117
+ // never denied.
118
+ const known = h.tools.builtins.map((t) => t.name);
119
+ const excluded = known.filter((n) => !mapped.includes(n));
120
+ const tokens: string[] = [h.tools.includeFlag!, [...mapped, ...unmapped].join(",")];
121
+ tokens.push(h.tools.excludeFlag!, excluded.join(","));
122
+ return { tokens, unmapped };
123
+ }
124
+ // pi: strict over built-ins, direct include; unmapped (extension/MCP)
125
+ // names ride along in the same list - pi governs them too.
126
+ return { tokens: [h.tools.includeFlag!, [...mapped, ...unmapped].join(",")], unmapped };
127
+ }
128
+
129
+ // exclude (D2): all descriptor-known names minus the excluded ones.
130
+ const names = validateNames(h, selection.exclude!);
131
+ const { mapped, unmapped: excludedUnmapped } = resolveNames(h, names);
132
+ if (excludedUnmapped.length > 0) {
133
+ // Excluding a name the descriptor does not know: permitted (it may be a
134
+ // runtime-registered tool), rendered natively where the harness's deny
135
+ // list can carry it; on claude only, since its include path consumes
136
+ // unknown names without effect (silent-acceptance hazard).
137
+ if (!h.tools.includeIsStrictAllowlist) {
138
+ const tokens = [h.tools.excludeFlag!, [...mapped, ...excludedUnmapped].join(",")];
139
+ return { tokens, unmapped: excludedUnmapped };
140
+ }
141
+ // pi: a D2 exclude renders as a computed include (its native exclude
142
+ // subtracts from the default set, which cannot express "all minus X"
143
+ // when off-by-default tools are involved). An unknown excluded name
144
+ // cannot be complemented - refuse rather than silently grant it.
145
+ throw new ArgvRefusalError({
146
+ issue: "unknown-tool-name",
147
+ harness: h.name,
148
+ option: "excludeTools",
149
+ supported: [`known tool names: ${h.tools.builtins.map((t) => t.name).join(", ")}`],
150
+ detail: `cannot exclude unknown name(s) ${excludedUnmapped.join(", ")}: the complement cannot be computed`,
151
+ });
152
+ }
153
+ const known = h.tools.builtins.filter((t) => !mapped.includes(t.name));
154
+ const kept = known.map((t) => t.name);
155
+ if (!h.tools.includeIsStrictAllowlist) {
156
+ // claude: deny list IS the complement expression.
157
+ return { tokens: [h.tools.excludeFlag!, mapped.join(",")], unmapped: [] };
158
+ }
159
+ // pi: computed include over every known name minus the excluded.
160
+ return { tokens: [h.tools.includeFlag!, kept.join(",")], unmapped: [] };
161
+ };
@@ -12,6 +12,7 @@
12
12
  import type { HarnessDescriptor, OptionRender, SpecBase } from "../knowledge/descriptor.js";
13
13
  import { DISCOVERY_FACETS, resolveRender, TURN_OPTION_KEYS } from "../knowledge/descriptor.js";
14
14
  import type { DiscoveryOptions, TurnOptions } from "./argv.js";
15
+ import { hintFor } from "./hints.js";
15
16
  import { ArgvRefusalError } from "./refusal.js";
16
17
  import { CLEAN_SELECTOR, resolveModel, validateEffort } from "./vocabulary.js";
17
18
 
@@ -62,6 +63,7 @@ export const renderTurnOptions = (
62
63
  option: "discovery",
63
64
  supported: Object.keys(h.turnOptions).length ? Object.keys(h.turnOptions) : ["(none)"],
64
65
  detail: String(requested[0]),
66
+ hint: hintFor(h.name, `discovery.${String(requested[0])}`),
65
67
  });
66
68
  }
67
69
  if (spec.kind !== "discovery") {
@@ -109,6 +111,7 @@ export const renderTurnOptions = (
109
111
  option: "discovery",
110
112
  facet,
111
113
  supported: supportedFacets.length ? supportedFacets : ["(none)"],
114
+ hint: hintFor(h.name, `discovery.${facet}`),
112
115
  });
113
116
  }
114
117
  continue;
@@ -199,6 +202,7 @@ export const renderTurnOptions = (
199
202
  option: key,
200
203
  supported: Object.keys(h.turnOptions).length ? Object.keys(h.turnOptions) : ["(none)"],
201
204
  detail: String(raw),
205
+ hint: hintFor(h.name, key),
202
206
  });
203
207
  }
204
208
 
@@ -145,10 +145,43 @@ export const claudeCode: HarnessDescriptor = deepFreeze({
145
145
  render: { kind: "flag-list", flags: ["--setting-sources", "project"] },
146
146
  },
147
147
  skills: {
148
+ // Phase 0 (claude-tool-interplay.md probe 4): --disable-slash-commands
149
+ // removes the Skill tool AND all skills listing - verified a full
150
+ // skills-off switch, not just command dispatch. --setting-sources
151
+ // project stays the extensions-facet spelling (settings scope).
148
152
  polarity: "disables",
149
- render: { kind: "flag-list", flags: ["--setting-sources", "project"] },
153
+ render: { kind: "flag-list", flags: ["--disable-slash-commands"] },
150
154
  },
151
155
  },
152
156
  },
153
157
  },
158
+ // Phase 0 fixtures: claude-tool-interplay.md. include is a permission
159
+ // grant (Bash, Edit stay visible under --allowedTools Read); only the
160
+ // disallow flag reshapes the model-visible set. Both flags together
161
+ // compose, deny winning on overlap. Patterns (Bash(git *)) valid in both
162
+ // lists; unknown PATTERN spellings warn on stderr, unknown exact names
163
+ // are the silent-acceptance hazard the curated vocabulary guards.
164
+ tools: {
165
+ includeFlag: "--allowedTools",
166
+ excludeFlag: "--disallowedTools",
167
+ includeIsStrictAllowlist: false,
168
+ composable: true,
169
+ builtins: [
170
+ { name: "Bash", defaultEnabled: true },
171
+ { name: "Edit", defaultEnabled: true },
172
+ { name: "Glob", defaultEnabled: true },
173
+ { name: "Grep", defaultEnabled: true },
174
+ { name: "Read", defaultEnabled: true },
175
+ { name: "Write", defaultEnabled: true },
176
+ { name: "WebFetch", defaultEnabled: true },
177
+ { name: "WebSearch", defaultEnabled: true },
178
+ { name: "Monitor", defaultEnabled: true },
179
+ { name: "Task", defaultEnabled: true },
180
+ { name: "Skill", defaultEnabled: true },
181
+ { name: "NotebookEdit", defaultEnabled: true },
182
+ { name: "LSP", defaultEnabled: true },
183
+ ],
184
+ categories: [],
185
+ denySemantics: "remove-from-set",
186
+ },
154
187
  });
@@ -114,4 +114,23 @@ export const codexCli: HarnessDescriptor = deepFreeze({
114
114
  resumeRender: null,
115
115
  },
116
116
  },
117
+ // Phase 0 fixtures: codex-tool-surface.md. No name lists anywhere -
118
+ // not on the CLI, not in config.toml. Control is feature booleans
119
+ // (reachable per-call via -c key=value / --enable/--disable FEATURE),
120
+ // sandbox, and approval policy. MCP servers do have per-tool keys
121
+ // (mcp_servers.<id>.tools.<tool>) but built-ins do not.
122
+ tools: {
123
+ includeFlag: null,
124
+ excludeFlag: null,
125
+ includeIsStrictAllowlist: false,
126
+ composable: false,
127
+ builtins: [],
128
+ categories: [
129
+ { key: "shell", disableFlag: null, configKey: "features.shell_tool" },
130
+ { key: "exec", disableFlag: null, configKey: "features.unified_exec" },
131
+ { key: "web", disableFlag: null, configKey: "web_search" },
132
+ { key: "view-image", disableFlag: null, configKey: "tools.view_image" },
133
+ ],
134
+ denySemantics: "no-lists",
135
+ },
117
136
  });