@dungle-scrubs/harness-cli-normalizer 0.3.1 → 0.4.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.
- package/README.md +64 -86
- package/dist/cli/args.d.ts +12 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +44 -5
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/config.d.ts +25 -0
- package/dist/cli/config.d.ts.map +1 -0
- package/dist/cli/config.js +147 -0
- package/dist/cli/config.js.map +1 -0
- package/dist/cli/help.d.ts +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +17 -1
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/inspect.d.ts.map +1 -1
- package/dist/cli/inspect.js +35 -1
- package/dist/cli/inspect.js.map +1 -1
- package/dist/cli/render.d.ts.map +1 -1
- package/dist/cli/render.js +10 -1
- package/dist/cli/render.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +175 -5
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/skills-root.d.ts +4 -0
- package/dist/cli/skills-root.d.ts.map +1 -0
- package/dist/cli/skills-root.js +45 -0
- package/dist/cli/skills-root.js.map +1 -0
- package/dist/execution/failure.d.ts +24 -4
- package/dist/execution/failure.d.ts.map +1 -1
- package/dist/execution/failure.js +39 -3
- package/dist/execution/failure.js.map +1 -1
- package/dist/execution/stream-turn.d.ts +4 -0
- package/dist/execution/stream-turn.d.ts.map +1 -1
- package/dist/execution/stream-turn.js +43 -6
- package/dist/execution/stream-turn.js.map +1 -1
- package/dist/interpretation/argv.d.ts +6 -0
- package/dist/interpretation/argv.d.ts.map +1 -1
- package/dist/interpretation/argv.js +17 -19
- package/dist/interpretation/argv.js.map +1 -1
- package/dist/interpretation/hints.d.ts +22 -0
- package/dist/interpretation/hints.d.ts.map +1 -0
- package/dist/interpretation/hints.js +59 -0
- package/dist/interpretation/hints.js.map +1 -0
- package/dist/interpretation/refusal.d.ts +25 -4
- package/dist/interpretation/refusal.d.ts.map +1 -1
- package/dist/interpretation/refusal.js +16 -0
- package/dist/interpretation/refusal.js.map +1 -1
- package/dist/interpretation/resolve-options.d.ts +42 -0
- package/dist/interpretation/resolve-options.d.ts.map +1 -0
- package/dist/interpretation/resolve-options.js +162 -0
- package/dist/interpretation/resolve-options.js.map +1 -0
- package/dist/interpretation/skills-selection.d.ts +22 -0
- package/dist/interpretation/skills-selection.d.ts.map +1 -0
- package/dist/interpretation/skills-selection.js +49 -0
- package/dist/interpretation/skills-selection.js.map +1 -0
- package/dist/interpretation/support.d.ts +32 -0
- package/dist/interpretation/support.d.ts.map +1 -0
- package/dist/interpretation/support.js +107 -0
- package/dist/interpretation/support.js.map +1 -0
- package/dist/interpretation/tool-selection.d.ts +29 -0
- package/dist/interpretation/tool-selection.d.ts.map +1 -0
- package/dist/interpretation/tool-selection.js +122 -0
- package/dist/interpretation/tool-selection.js.map +1 -0
- package/dist/interpretation/turn-options.d.ts.map +1 -1
- package/dist/interpretation/turn-options.js +4 -0
- package/dist/interpretation/turn-options.js.map +1 -1
- package/dist/knowledge/claude-code.d.ts.map +1 -1
- package/dist/knowledge/claude-code.js +35 -1
- package/dist/knowledge/claude-code.js.map +1 -1
- package/dist/knowledge/codex.d.ts.map +1 -1
- package/dist/knowledge/codex.js +20 -0
- package/dist/knowledge/codex.js.map +1 -1
- package/dist/knowledge/descriptor.d.ts +39 -0
- package/dist/knowledge/descriptor.d.ts.map +1 -1
- package/dist/knowledge/muse.d.ts.map +1 -1
- package/dist/knowledge/muse.js +20 -0
- package/dist/knowledge/muse.js.map +1 -1
- package/dist/knowledge/pi.d.ts.map +1 -1
- package/dist/knowledge/pi.js +22 -0
- package/dist/knowledge/pi.js.map +1 -1
- package/dist/knowledge/profile.d.ts +16 -0
- package/dist/knowledge/profile.d.ts.map +1 -0
- package/dist/knowledge/profile.js +51 -0
- package/dist/knowledge/profile.js.map +1 -0
- package/package.json +2 -23
- package/src/cli/args.ts +64 -3
- package/src/cli/config.ts +176 -0
- package/src/cli/help.ts +17 -1
- package/src/cli/inspect.ts +34 -1
- package/src/cli/render.ts +10 -1
- package/src/cli/run.ts +203 -6
- package/src/cli/skills-root.ts +47 -0
- package/src/execution/failure.ts +58 -5
- package/src/execution/stream-turn.ts +50 -5
- package/src/interpretation/argv.ts +23 -19
- package/src/interpretation/hints.ts +99 -0
- package/src/interpretation/refusal.ts +33 -3
- package/src/interpretation/resolve-options.ts +214 -0
- package/src/interpretation/skills-selection.ts +73 -0
- package/src/interpretation/support.ts +119 -0
- package/src/interpretation/tool-selection.ts +161 -0
- package/src/interpretation/turn-options.ts +4 -0
- package/src/knowledge/claude-code.ts +35 -1
- package/src/knowledge/codex.ts +20 -0
- package/src/knowledge/descriptor.ts +39 -0
- package/src/knowledge/muse.ts +20 -0
- package/src/knowledge/pi.ts +22 -0
- package/src/knowledge/profile.ts +53 -0
- package/dist/index.d.ts +0 -8
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -8
- package/dist/index.js.map +0 -1
- package/src/index.ts +0 -7
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curated refusal hints (D8), confirmed in review 2026-08-18
|
|
3
|
+
* (test/fixtures/phase0/hints-confirmed.md is the durable record). A hint
|
|
4
|
+
* is the nearest-alternative control for the CURRENT harness, shown before
|
|
5
|
+
* the cross-harness support list so a scanning caller meets the
|
|
6
|
+
* stay-on-harness suggestion first. Wording is locked verbatim; unit tests
|
|
7
|
+
* pin every string.
|
|
8
|
+
*/
|
|
9
|
+
import type { HarnessDescriptor } from "../knowledge/descriptor.js";
|
|
10
|
+
import type { RefusalOption } from "./refusal.js";
|
|
11
|
+
|
|
12
|
+
const HINTS: Readonly<Record<string, Readonly<Record<string, string>>>> = deepFreezeSafe({
|
|
13
|
+
claude: {
|
|
14
|
+
sandbox:
|
|
15
|
+
"claude has no sandbox modes; approximate with a per-tool allowlist (--tools Read,Bash) or --disallowedTools to keep tools out, and run untrusted work in a disposable directory or container",
|
|
16
|
+
write:
|
|
17
|
+
"claude has no write toggle; keep the Write tool out with --tools that omits it, or --disallowedTools Write for the deny-complement spelling",
|
|
18
|
+
shell:
|
|
19
|
+
"claude has no shell toggle; disallow the Bash tool (--tools without Bash, or --disallowedTools Bash) and note Monitor can still run commands in headless runs",
|
|
20
|
+
maxSteps:
|
|
21
|
+
"claude has no step cap flag; bound the work in the prompt (task size, 'stop after N operations') or impose a wall-clock timeout at the caller",
|
|
22
|
+
provider:
|
|
23
|
+
"claude routes models through Anthropic only (Bedrock/Vertex via settings); use --model to pick within it - there is no separate provider selector",
|
|
24
|
+
"discovery.tools":
|
|
25
|
+
"claude has no tools-discovery toggle (tools are always compiled in); shape the tool set with --tools/--disallowedTools instead",
|
|
26
|
+
"discovery.instructionFiles":
|
|
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
|
+
},
|
|
29
|
+
codex: {
|
|
30
|
+
write:
|
|
31
|
+
"codex has no write toggle; use --sandbox read-only (config: sandbox_mode) so shell commands cannot write either",
|
|
32
|
+
shell:
|
|
33
|
+
"codex has no shell toggle; disable the shell tool via config (-c features.shell_tool=false) or use --sandbox read-only",
|
|
34
|
+
maxSteps:
|
|
35
|
+
"codex has no step cap flag; bound via sandbox policy and a caller-side timeout, or prompt-level limits",
|
|
36
|
+
provider:
|
|
37
|
+
"codex routes models through OpenAI (or --oss for local); use --model to pick within it - there is no separate provider selector",
|
|
38
|
+
"discovery.tools":
|
|
39
|
+
"codex has no tools-discovery toggle; disable tool classes via config keys (features.shell_tool, web_search) instead",
|
|
40
|
+
"discovery.instructionFiles":
|
|
41
|
+
"codex always loads AGENTS.md hierarchy; no per-call toggle exists - keep the files out of the tree or work from a directory without them",
|
|
42
|
+
"discovery.extensions":
|
|
43
|
+
"codex loads MCP servers and plugins from config; disable per-server with -c or codex mcp remove rather than a call-time toggle",
|
|
44
|
+
"discovery.skills":
|
|
45
|
+
"codex discovers skills from its skills directory; no call-time toggle - remove or move the skill files instead",
|
|
46
|
+
},
|
|
47
|
+
pi: {
|
|
48
|
+
sandbox:
|
|
49
|
+
"pi has no sandbox dimension; approximate with a minimal tool grant (--tools read,grep,find,ls) so the run cannot write or execute, or sandbox the process yourself (container, VM)",
|
|
50
|
+
write:
|
|
51
|
+
"pi has no write toggle; grant without the write tool (--tools read,bash,edit) or use --exclude-tools write",
|
|
52
|
+
shell:
|
|
53
|
+
"pi has no shell toggle; grant without the bash tool (--tools read,edit,write) or use --exclude-tools bash",
|
|
54
|
+
maxSteps:
|
|
55
|
+
"pi has no step cap flag; bound the work in the prompt or impose a wall-clock timeout at the caller",
|
|
56
|
+
},
|
|
57
|
+
muse: {
|
|
58
|
+
sandbox:
|
|
59
|
+
"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",
|
|
60
|
+
provider:
|
|
61
|
+
"muse routes models through its own API; use --model to pick within it - there is no separate provider selector",
|
|
62
|
+
"discovery.tools":
|
|
63
|
+
"muse has no tools-discovery toggle; gate execution with --disable-write/--disable-shell/--disable-web-tools",
|
|
64
|
+
"discovery.instructionFiles":
|
|
65
|
+
"muse loads rules per workspace trust; --no-foreign-personal-context excludes foreign personal rules, and withholding --trust-workspace keeps workspace rules unloaded",
|
|
66
|
+
"discovery.skills":
|
|
67
|
+
"muse scopes skills by trust like rules; --no-foreign-personal-context drops foreign skills and untrusted workspaces stay unloaded - there is no unconditional skills-off switch",
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/** deepFreeze without importing the descriptor's (which carries extra
|
|
72
|
+
* machinery); structure is plain JSON so Object.freeze all the way down. */
|
|
73
|
+
function deepFreezeSafe<T>(value: T): T {
|
|
74
|
+
if (typeof value === "object" && value !== null) {
|
|
75
|
+
for (const v of Object.values(value as Record<string, unknown>)) deepFreezeSafe(v);
|
|
76
|
+
Object.freeze(value);
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The confirmed hint for a refused option on a harness, or undefined. */
|
|
82
|
+
export const hintFor = (harness: string, option: RefusalOption): string | undefined =>
|
|
83
|
+
HINTS[harness]?.[option];
|
|
84
|
+
|
|
85
|
+
/** All hints - for the unit test that pins every string against the
|
|
86
|
+
* evidence file's count. */
|
|
87
|
+
export const allHints = (): ReadonlyArray<{ harness: string; option: string; text: string }> => {
|
|
88
|
+
const out: { harness: string; option: string; text: string }[] = [];
|
|
89
|
+
for (const [harness, table] of Object.entries(HINTS)) {
|
|
90
|
+
for (const [option, text] of Object.entries(table)) out.push({ harness, option, text });
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Convenience for raise sites that already hold the descriptor. */
|
|
96
|
+
export const hintForDescriptor = (
|
|
97
|
+
h: HarnessDescriptor,
|
|
98
|
+
option: RefusalOption,
|
|
99
|
+
): string | undefined => hintFor(h.name, option);
|
|
@@ -17,19 +17,33 @@ 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
|
+
| "skills"
|
|
37
|
+
| "autonomy"
|
|
38
|
+
| `discovery.${string}`;
|
|
39
|
+
|
|
26
40
|
/** One helper builds the message from the structured fields so message and
|
|
27
41
|
* fields cannot drift. Every message names an alternative, not only a
|
|
28
42
|
* negation, so an agent can pivot without reading the descriptor. */
|
|
29
43
|
export const buildRefusalMessage = (
|
|
30
44
|
issue: RefusalIssue,
|
|
31
45
|
harness: HarnessName,
|
|
32
|
-
option?:
|
|
46
|
+
option?: RefusalOption,
|
|
33
47
|
facet?: DiscoveryFacet,
|
|
34
48
|
supported: readonly string[] = [],
|
|
35
49
|
detail?: string,
|
|
@@ -66,6 +80,10 @@ export const buildRefusalMessage = (
|
|
|
66
80
|
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
81
|
case "invalid-tool-grant":
|
|
68
82
|
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`;
|
|
83
|
+
case "unknown-tool-name":
|
|
84
|
+
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`;
|
|
85
|
+
case "mutually-exclusive-options":
|
|
86
|
+
return `${harness} cannot combine${optionPart}${detailSuffix}; ${supportedStr} - pass exactly one of them`;
|
|
69
87
|
case "prompt-flag-injection":
|
|
70
88
|
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
89
|
case "no-autonomy-mode":
|
|
@@ -83,15 +101,25 @@ export const buildRefusalMessage = (
|
|
|
83
101
|
export class ArgvRefusalError extends Error {
|
|
84
102
|
readonly issue: RefusalIssue;
|
|
85
103
|
readonly harness: HarnessName;
|
|
86
|
-
readonly option?:
|
|
104
|
+
readonly option?: RefusalOption;
|
|
87
105
|
readonly facet?: DiscoveryFacet;
|
|
88
106
|
readonly supported: readonly string[];
|
|
107
|
+
/** D7: which harnesses DO express the refused option, native spellings
|
|
108
|
+
* included. Derived by the raise site from descriptors - absent when the
|
|
109
|
+
* refusing layer has no descriptor set in scope. */
|
|
110
|
+
readonly supportedBy?: ReadonlyArray<{ harness: string; spelling: string }>;
|
|
111
|
+
/** D8: nearest-alternative suggestion for the CURRENT harness - keeps a
|
|
112
|
+
* scanning agent on its chosen harness instead of switching. Curatorial
|
|
113
|
+
* data set at the raise site; absent when no hint exists. */
|
|
114
|
+
readonly hint?: string;
|
|
89
115
|
constructor(args: {
|
|
90
116
|
readonly issue: RefusalIssue;
|
|
91
117
|
readonly harness: HarnessName;
|
|
92
|
-
readonly option?:
|
|
118
|
+
readonly option?: RefusalOption;
|
|
93
119
|
readonly facet?: DiscoveryFacet;
|
|
94
120
|
readonly supported?: readonly string[];
|
|
121
|
+
readonly supportedBy?: ReadonlyArray<{ harness: string; spelling: string }>;
|
|
122
|
+
readonly hint?: string;
|
|
95
123
|
readonly detail?: string;
|
|
96
124
|
readonly message?: string;
|
|
97
125
|
}) {
|
|
@@ -112,5 +140,7 @@ export class ArgvRefusalError extends Error {
|
|
|
112
140
|
this.option = args.option;
|
|
113
141
|
this.facet = args.facet;
|
|
114
142
|
this.supported = args.supported ?? [];
|
|
143
|
+
this.supportedBy = args.supportedBy;
|
|
144
|
+
this.hint = args.hint;
|
|
115
145
|
}
|
|
116
146
|
}
|
|
@@ -0,0 +1,214 @@
|
|
|
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
|
+
write: () => true,
|
|
58
|
+
shell: () => true,
|
|
59
|
+
tools: (h) => h.tools.includeFlag !== null || h.tools.excludeFlag !== null,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export interface ConfigTiers {
|
|
63
|
+
/** ~/.config/hcn/config.json (XDG) - machine-wide defaults. */
|
|
64
|
+
readonly user?: Readonly<Partial<TurnOptions>>;
|
|
65
|
+
/** <git-root>/.hcn/config.json - auto-discovered (ratified A), the ALL-
|
|
66
|
+
* OFF tier; its `tools` key is both the default grant and the FLOOR: an
|
|
67
|
+
* arg grant exceeding it refuses, naming both sets (D5). */
|
|
68
|
+
readonly project?: Readonly<Partial<TurnOptions>>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Merge semantics (gap 1, resolved): config keys are scalars and lists in
|
|
72
|
+
* schema v1 - there is nothing to deep-merge INTO - so precedence is whole-
|
|
73
|
+
* key replacement: arg > project > user > profile. A future nested key
|
|
74
|
+
* (per-harness sections) ships with schema v2 and its own merge rule. */
|
|
75
|
+
const effectiveConfig = (tiers: ConfigTiers): Readonly<Partial<TurnOptions>> => ({
|
|
76
|
+
...(tiers.user ?? {}),
|
|
77
|
+
...(tiers.project ?? {}),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/** Resolve the effective options for a LAUNCH. `args` is what the caller
|
|
81
|
+
* passed explicitly (highest tier); `userConfig` the parsed config file;
|
|
82
|
+
* the profile is the floor. Keys absent everywhere defer to the harness
|
|
83
|
+
* and are reported with tier "harness" only when something (config or
|
|
84
|
+
* profile) attempted them - a truly untouched dimension is nobody's
|
|
85
|
+
* business and appears in provenance as tier "harness" with no value. */
|
|
86
|
+
/** A discovery value emits nothing when every facet is true (on). */
|
|
87
|
+
const emitsNothing = (value: unknown): boolean =>
|
|
88
|
+
typeof value === "object" &&
|
|
89
|
+
value !== null &&
|
|
90
|
+
Object.values(value as Record<string, unknown>).every((v) => v === true);
|
|
91
|
+
|
|
92
|
+
export const resolveEffectiveOptions = (
|
|
93
|
+
h: HarnessDescriptor,
|
|
94
|
+
args: TurnOptions,
|
|
95
|
+
tiers: ConfigTiers = {},
|
|
96
|
+
): ResolvedOptions => {
|
|
97
|
+
const provenance: ProvenanceEntry[] = [];
|
|
98
|
+
const unrenderable: string[] = [];
|
|
99
|
+
const config = effectiveConfig(tiers);
|
|
100
|
+
const sourceTier = (key: string): ProvenanceTier | undefined => {
|
|
101
|
+
if (tiers.project?.[key as keyof TurnOptions] !== undefined) return "project-config";
|
|
102
|
+
if (tiers.user?.[key as keyof TurnOptions] !== undefined) return "user-config";
|
|
103
|
+
return undefined;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// D5 toolset expansion: a bare --tools name matching a configured
|
|
107
|
+
// toolset resolves to its list BEFORE the floor check, so a named set
|
|
108
|
+
// within the floor passes and one exceeding it refuses naming the set's
|
|
109
|
+
// members. Project toolsets win name collisions over user toolsets.
|
|
110
|
+
const toolsets = {
|
|
111
|
+
...((tiers.user as { toolsets?: Record<string, string[]> } | undefined)?.toolsets ?? {}),
|
|
112
|
+
...((tiers.project as { toolsets?: Record<string, string[]> } | undefined)?.toolsets ?? {}),
|
|
113
|
+
};
|
|
114
|
+
let effectiveArgs = args;
|
|
115
|
+
if (
|
|
116
|
+
args.tools !== undefined &&
|
|
117
|
+
args.tools.length === 1 &&
|
|
118
|
+
toolsets[args.tools[0] as string] !== undefined
|
|
119
|
+
) {
|
|
120
|
+
effectiveArgs = { ...args, tools: toolsets[args.tools[0] as string] };
|
|
121
|
+
provenance.push({
|
|
122
|
+
key: "tools",
|
|
123
|
+
value: effectiveArgs.tools,
|
|
124
|
+
tier: "arg",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
const resolved: Record<string, unknown> = { ...effectiveArgs };
|
|
128
|
+
|
|
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.
|
|
131
|
+
const floor = tiers.project?.tools;
|
|
132
|
+
if (floor !== undefined && effectiveArgs.tools !== undefined) {
|
|
133
|
+
const floorSet = new Set(floor);
|
|
134
|
+
const excess = effectiveArgs.tools.filter((t) => !floorSet.has(t));
|
|
135
|
+
if (excess.length > 0) {
|
|
136
|
+
throw new FloorExceededError(h.name, excess, [...floor]);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Profile is the floor: apply only where nothing above it set the key.
|
|
141
|
+
for (const [key, value] of Object.entries(DEFAULT_TURN_PROFILE)) {
|
|
142
|
+
const argsSet = effectiveArgs[key as keyof TurnOptions] !== undefined;
|
|
143
|
+
const tier = sourceTier(key);
|
|
144
|
+
if (argsSet) {
|
|
145
|
+
provenance.push({ key, value: effectiveArgs[key as keyof TurnOptions], tier: "arg" });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (tier !== undefined) {
|
|
149
|
+
provenance.push({ key, value: config[key as keyof TurnOptions], tier });
|
|
150
|
+
resolved[key] = config[key as keyof TurnOptions];
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const expressible = EXPRESSIBLE[key as ProfileKey]?.(h) ?? false;
|
|
154
|
+
if (!expressible) {
|
|
155
|
+
// Skip-and-report: a profile default this harness cannot express is
|
|
156
|
+
// reported divergence, never a refusal and never silence.
|
|
157
|
+
unrenderable.push(key);
|
|
158
|
+
provenance.push({ key, value, tier: "harness" });
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
// D13: the tools marker expands per descriptor. On a harness whose
|
|
162
|
+
// default is already everything (claude), expansion emits nothing -
|
|
163
|
+
// the emit-nothing rule, recorded in provenance. On a harness with
|
|
164
|
+
// dormant built-ins (pi), it becomes the enabling include list.
|
|
165
|
+
if (key === "tools" && value === "all-known") {
|
|
166
|
+
const enabled = h.tools.builtins.filter((t) => t.defaultEnabled).length;
|
|
167
|
+
const all = h.tools.builtins.length;
|
|
168
|
+
if (enabled === all) {
|
|
169
|
+
provenance.push({ key, value: "all known (already default)", tier: "profile" });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const expanded = h.tools.builtins.map((t) => t.name);
|
|
173
|
+
resolved[key] = expanded;
|
|
174
|
+
provenance.push({ key, value: expanded, tier: "profile" });
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
// Dimensions whose value reduces to "emit nothing" (autonomy false,
|
|
178
|
+
// discovery all-on, write/shell true) stay ABSENT from the resolved
|
|
179
|
+
// options - the harness's default already satisfies the profile, and
|
|
180
|
+
// emitting explicit on-flags would change resume grammar and add
|
|
181
|
+
// breakage surface for no semantic gain. Provenance still records
|
|
182
|
+
// the tier.
|
|
183
|
+
if (
|
|
184
|
+
(key === "autonomy" && value === false) ||
|
|
185
|
+
(key === "discovery" && emitsNothing(value)) ||
|
|
186
|
+
((key === "write" || key === "shell") && value === true)
|
|
187
|
+
) {
|
|
188
|
+
provenance.push({ key, value, tier: "profile" });
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
resolved[key] = value;
|
|
192
|
+
provenance.push({ key, value, tier: "profile" });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Config keys outside the profile surface pass through at their own tier
|
|
196
|
+
// (validated later by the same renderers as args).
|
|
197
|
+
for (const [key, value] of Object.entries(config)) {
|
|
198
|
+
if (key === "toolsets") continue; // expanded into args above, never a turn option
|
|
199
|
+
if (key in DEFAULT_TURN_PROFILE) continue;
|
|
200
|
+
if (effectiveArgs[key as keyof TurnOptions] !== undefined) {
|
|
201
|
+
provenance.push({ key, value: effectiveArgs[key as keyof TurnOptions], tier: "arg" });
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
resolved[key] = value;
|
|
205
|
+
const tier = sourceTier(key) ?? "user-config";
|
|
206
|
+
provenance.push({ key, value, tier });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
options: resolved as unknown as TurnOptions,
|
|
211
|
+
provenance,
|
|
212
|
+
unrenderable,
|
|
213
|
+
};
|
|
214
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caller-directed skills allowlist rendering (issue #38). The delegating
|
|
3
|
+
* agent picks the subset from its own registry; this module turns the
|
|
4
|
+
* resolved paths into per-harness argv tokens.
|
|
5
|
+
*
|
|
6
|
+
* - pi: `-ns` (discovery off) + one `--skill <path>` per entry - the
|
|
7
|
+
* allowlist is exact: only the caller's picks load.
|
|
8
|
+
* - claude: no per-skill load flag; the registry is already present via
|
|
9
|
+
* the personal skills dir, so the allowlist renders as the complement
|
|
10
|
+
* OFF - `--settings '{"skillOverrides":{"<name>":"off",...}}'` for every
|
|
11
|
+
* known skill except the picks. Known set comes from the caller's root
|
|
12
|
+
* listing (same source that resolved the names).
|
|
13
|
+
* - codex/muse: refuse (structural) with the standard hint shape.
|
|
14
|
+
*/
|
|
15
|
+
import type { HarnessDescriptor } from "../knowledge/descriptor.js";
|
|
16
|
+
import { ArgvRefusalError } from "./refusal.js";
|
|
17
|
+
|
|
18
|
+
export const basenameOf = (p: string): string => {
|
|
19
|
+
const i = p.lastIndexOf("/");
|
|
20
|
+
return i === -1 ? p : p.slice(i + 1);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const renderSkillsSelection = (
|
|
24
|
+
h: HarnessDescriptor,
|
|
25
|
+
skills: readonly string[],
|
|
26
|
+
): readonly string[] => {
|
|
27
|
+
if (skills.length === 0) return [];
|
|
28
|
+
|
|
29
|
+
if (h.skills === null) {
|
|
30
|
+
throw new ArgvRefusalError({
|
|
31
|
+
issue: "unsupported-option",
|
|
32
|
+
harness: h.name,
|
|
33
|
+
option: "skills",
|
|
34
|
+
supported: ["caller-directed skill sets"],
|
|
35
|
+
supportedBy: [
|
|
36
|
+
{ harness: "pi", spelling: "--skill" },
|
|
37
|
+
{ harness: "claude", spelling: "skillOverrides" },
|
|
38
|
+
],
|
|
39
|
+
hint:
|
|
40
|
+
h.name === "codex"
|
|
41
|
+
? "codex discovers skills from its own directory with no call-time surface - stage the skills into $CODEX_HOME/skills or pass their content in the prompt"
|
|
42
|
+
: "muse scopes skills by workspace trust with no per-skill surface - include the skill content in the prompt or use --trust-workspace for the whole registry",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (h.skills.loadFlag !== null) {
|
|
47
|
+
// pi: discovery off so ONLY the picks load.
|
|
48
|
+
const tokens: string[] = ["-ns"];
|
|
49
|
+
for (const path of skills) tokens.push(h.skills.loadFlag, path);
|
|
50
|
+
return tokens;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// claude: complement-off via settings JSON. Names are the skill dir
|
|
54
|
+
// basenames; unknown names in knownSkills would be turned off
|
|
55
|
+
// pointlessly, so the caller passes exactly the known set.
|
|
56
|
+
return [];
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** The claude complement form, given the full known registry: every known
|
|
60
|
+
* name except the picks gets "off". Exported for the CLI layer, which owns
|
|
61
|
+
* the registry listing (an fs read - never in interpretation). */
|
|
62
|
+
export const claudeSkillOverridesArg = (
|
|
63
|
+
knownSkills: readonly string[],
|
|
64
|
+
pickedPaths: readonly string[],
|
|
65
|
+
): string[] => {
|
|
66
|
+
const picks = new Set(pickedPaths.map(basenameOf));
|
|
67
|
+
const offs: Record<string, string> = {};
|
|
68
|
+
for (const name of knownSkills) {
|
|
69
|
+
if (!picks.has(name)) offs[name] = "off";
|
|
70
|
+
}
|
|
71
|
+
const json = JSON.stringify({ skillOverrides: offs });
|
|
72
|
+
return ["--settings", json];
|
|
73
|
+
};
|
|
@@ -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
|
+
};
|