/** * Option resolution: the precedence chain args > user config > built-in * profile, plus provenance. Pure - the config FILE is read by the CLI layer * and passed in as data; this layer only decides what wins. * * Launch-only: resume passes explicit turn options. Omitted settings follow * native behavior, which may use current configuration rather than saved settings. */ import type { HarnessDescriptor } from "../knowledge/descriptor.js"; import { defaultDescriptors } from "../knowledge/overrides.js"; import { DEFAULT_TURN_PROFILE, PROFILE_DEFAULT_DEFERRALS, type ProfileKey, } from "../knowledge/profile.js"; import type { TurnOptions } from "./argv.js"; import { hintFor } from "./hints.js"; import { assertIsolationCombination, ISOLATION_OVERRIDES } from "./isolation.js"; import type { QuestionMode } from "./question.js"; import { ArgvRefusalError } from "./refusal.js"; import type { ToolMapConfig } from "./tool-vocabulary.js"; import { allCanonicalNames, mergeToolMaps, validateCanonicalList } from "./tool-vocabulary.js"; import { validateAccess } from "./vocabulary.js"; export type ProvenanceTier = "arg" | "project-config" | "user-config" | "profile" | "harness"; export interface ProvenanceEntry { readonly key: string; readonly value: unknown; readonly tier: ProvenanceTier; } /** D5: the project floor caps arg grants; exceeding it refuses naming both * sets. Structured-first: fields, not prose parsing. */ export class FloorExceededError extends Error { readonly harness: string; readonly excess: readonly string[]; readonly floor: readonly string[]; constructor(harness: string, excess: readonly string[], floor: readonly string[]) { super( `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`, ); this.name = "FloorExceededError"; this.harness = harness; this.excess = excess; this.floor = floor; } } /** A dimension some tier attempted that this harness cannot express * (skip-and-report, never refuse): rendered as divergence, not failure. * The entry names the tier that set it so provenance can print which tier * (profile default, user config, project config, or an arg that reached a * harness with no spec) attempted the dimension. */ export interface UnrenderableEntry { readonly key: string; readonly tier: ProvenanceTier; } export interface ResolvedOptions { readonly options: TurnOptions; readonly provenance: readonly ProvenanceEntry[]; readonly unrenderable: readonly UnrenderableEntry[]; } export function assertAccessExclusivity( harness: HarnessDescriptor, options: Partial, explicit: (key: keyof TurnOptions) => boolean = (key) => options[key] !== undefined, ): void { if (options.access === undefined) return; const spec = harness.turnOptions.access; const claimed = spec?.kind === "access" ? spec.claims : undefined; const claimedConflict = claimed !== undefined && explicit(claimed as keyof TurnOptions); const conflict = explicit("tools") || explicit("excludeTools") || claimedConflict; if (conflict) throw new ArgvRefusalError({ issue: "mutually-exclusive-options", harness: harness.name, option: "access", supported: [ claimedConflict ? `--access or --${claimed}, not both on ${harness.name}` : "--access selects a permission preset, not a filter over --tools/--exclude-tools", ], detail: "mutual exclusion", }); } /** Expressibility per profile dimension. Dimensions whose "on" state is * the harness's own default (discovery all-on) or whose "off" state emits * nothing (autonomy false) are expressible EVERYWHERE - the profile value * resolves to "emit nothing," which every harness can do. Divergence is * reserved for dimensions that would emit a flag the harness lacks. */ const EXPRESSIBLE: Readonly boolean>> = { contextWindow: (h) => h.turnOptions.contextWindow !== undefined, effort: (h) => h.turnOptions.effort !== undefined, sandbox: (h) => h.turnOptions.sandbox !== undefined, discovery: () => true, autonomy: () => true, write: () => true, shell: () => true, // memory:false must render on claude/codex; pi's vacuous empty render // makes it expressible there too. muse (no spec) reports divergence. memory: (h) => h.turnOptions.memory !== undefined, tools: (h) => h.tools.includeFlag !== null || h.tools.excludeFlag !== null, }; /** What one parsed config file carries: the turn options it may set, plus * the keys that are not turn options - named toolsets, the raw toolMap * (harness -> canonical -> native, merged into the one ToolMap shape * here), and the wall-clock timeout. */ export type ConfigTier = Readonly, "toolMap">> & { readonly toolMap?: ToolMapConfig; readonly toolsets?: Readonly>; readonly timeout?: number; }; export interface ConfigTiers { /** ~/.config/hcn/config.json (XDG) - machine-wide defaults. */ readonly user?: ConfigTier; /** /.hcn/config.json - auto-discovered (ratified A), the ALL- * OFF tier; its `tools` key is both the default grant and the FLOOR: an * arg grant exceeding it refuses, naming both sets (D5). */ readonly project?: ConfigTier; } /** Where a resolved behaviour value came from; `default` is hcn's own. */ export type BehaviorTier = "arg" | "project-config" | "user-config" | "default"; /** The hcn-owned behaviour instructions a run resolves on every path, * launch, resume, and session alike (RFC-02 change 6): question mode, * which rides the prompt rather than the argv, and the wall-clock * timeout hcn enforces itself. Neither is a turn option, so the * launch-only turn-option resolver never sees them. */ export interface ResolvedBehavior { readonly questions: { readonly value: QuestionMode; readonly tier: BehaviorTier }; readonly timeoutSeconds: { readonly value: number | undefined; readonly tier: BehaviorTier }; } const pickTier = ( arg: TValue | undefined, project: TValue | undefined, user: TValue | undefined, fallback: TValue, ): { readonly value: TValue; readonly tier: BehaviorTier } => { if (arg !== undefined) return { value: arg, tier: "arg" }; if (project !== undefined) return { value: project, tier: "project-config" }; if (user !== undefined) return { value: user, tier: "user-config" }; return { value: fallback, tier: "default" }; }; /** Precedence arg > project > user > default, with the tier the * provenance line prints. Timeout 0 is an explicit disable. */ export const resolveBehavior = ( args: { readonly questions?: QuestionMode; readonly timeoutSeconds?: number }, tiers: ConfigTiers, ): ResolvedBehavior => { return { questions: pickTier(args.questions, tiers.project?.questions, tiers.user?.questions, "ask"), timeoutSeconds: pickTier( args.timeoutSeconds, tiers.project?.timeout, tiers.user?.timeout, undefined, ), }; }; /** Merge semantics (gap 1, resolved): config keys are scalars and lists in * schema v1 - there is nothing to deep-merge INTO - so precedence is whole- * key replacement: arg > project > user > profile. A future nested key * (per-harness sections) ships with schema v2 and its own merge rule. */ const effectiveConfig = (tiers: ConfigTiers): ConfigTier => ({ ...(tiers.user ?? {}), ...(tiers.project ?? {}), }); /** Memory precedence for SESSION spawns (ratified 2026-08-26): sessions * resolve the memory dimension like a bare launch does - arg > project > * user > profile default false - because the decision is made once at * spawn, not per turn. Pure so the precedence is unit-pinnable; the * session CLI reports the tier and passes the value to openSession. */ export interface MemoryResolution { readonly memory: boolean; readonly tier: "arg" | "project-config" | "user-config" | "profile"; } export const resolveSessionMemory = ( arg: boolean | undefined, tiers: { readonly user?: Readonly<{ memory?: boolean }>; readonly project?: Readonly<{ memory?: boolean }>; }, ): MemoryResolution => { const selected = pickTier( arg, tiers.project?.memory, tiers.user?.memory, DEFAULT_TURN_PROFILE.memory, ); return { memory: selected.value, tier: selected.tier === "default" ? "profile" : selected.tier }; }; /** Resolve the effective options for a LAUNCH. `args` is what the caller * passed explicitly (highest tier); `userConfig` the parsed config file; * the profile is the floor. Keys absent everywhere defer to the harness * and are reported with tier "harness" only when something (config or * profile) attempted them - a truly untouched dimension is nobody's * business and appears in provenance as tier "harness" with no value. */ /** A discovery value emits nothing when every facet is true (on). */ const emitsNothing = (value: unknown): boolean => typeof value === "object" && value !== null && Object.values(value as Record).every((v) => v === true); export const resolveEffectiveOptions = ( h: HarnessDescriptor, args: TurnOptions, tiers: ConfigTiers = {}, ): ResolvedOptions => { assertIsolationCombination(h, args); const provenance: ProvenanceEntry[] = []; if (args.isolation !== undefined) provenance.push({ key: "isolation", value: args.isolation, tier: "arg" }); const unrenderable: UnrenderableEntry[] = []; const config = { ...effectiveConfig(tiers) }; if (args.isolation !== undefined) { for (const key of ISOLATION_OVERRIDES) delete config[key]; } const sourceTier = (key: string): ProvenanceTier | undefined => { if (config[key as keyof TurnOptions] === undefined) return undefined; if (tiers.project?.[key as keyof TurnOptions] !== undefined) return "project-config"; if (tiers.user?.[key as keyof TurnOptions] !== undefined) return "user-config"; return undefined; }; // D5 toolset expansion: a bare --tools name matching a configured // toolset resolves to its list BEFORE the floor check, so a named set // within the floor passes and one exceeding it refuses naming the set's // members. Project toolsets win name collisions over user toolsets. const toolsets: Record = { ...(tiers.user?.toolsets ?? {}), ...(tiers.project?.toolsets ?? {}), }; let effectiveArgs = args; if ( args.tools !== undefined && args.tools.length === 1 && toolsets[args.tools[0] as string] !== undefined ) { effectiveArgs = { ...args, tools: toolsets[args.tools[0] as string] }; provenance.push({ key: "tools", value: effectiveArgs.tools, tier: "arg", }); } const resolved: Record = { ...effectiveArgs }; // Validate access value before exclusivity so invalid reports invalid-option-value, not mutual exclusion. if (resolved.access !== undefined) { const v = validateAccess(String(resolved.access)); if (!v.ok) { throw new ArgvRefusalError({ issue: "invalid-option-value", harness: h.name, option: "access", supported: ["read", "write"], detail: String(resolved.access), }); } } // The access preset displaces the turn option its spec claims (codex: // sandbox). An explicit value of that option alongside access refuses; // the profile default yields silently in the profile loop below. Read // from the descriptor, so no harness name appears here. const accessSpec = h.turnOptions.access; const claimed = accessSpec?.kind === "access" ? accessSpec.claims : undefined; assertAccessExclusivity( h, resolved, (key) => effectiveArgs[key] !== undefined || sourceTier(key) !== undefined, ); // toolMap merge per harness per canonical (project > user). The merged // shape is the one shape past this point (RFC-02 change 8). const mergedToolMap = mergeToolMaps({ user: tiers.user?.toolMap, project: tiers.project?.toolMap, }); if (Object.keys(mergedToolMap).length > 0) { resolved.toolMap = mergedToolMap; const harnessMap = mergedToolMap[h.name]; if (harnessMap) { for (const [canonical, entry] of Object.entries(harnessMap)) { provenance.push({ key: `tools.${canonical}`, value: entry.native, tier: entry.tier as ProvenanceTier, }); } } } // Lazy allCanonical build only when tools context present const needsCanonical = tiers.project?.tools !== undefined || tiers.user?.tools !== undefined || effectiveArgs.tools !== undefined || (effectiveArgs as unknown as Record).excludeTools !== undefined || Object.keys(toolsets).length > 0 || tiers.project?.toolMap !== undefined || tiers.user?.toolMap !== undefined; let allCanonical: readonly string[] | undefined; const getAllCanonical = (): readonly string[] => { if (allCanonical) return allCanonical; allCanonical = allCanonicalNames(defaultDescriptors(), mergedToolMap); return allCanonical; }; if (needsCanonical) { const ac = getAllCanonical(); validateCanonicalList(tiers.project?.tools as readonly string[] | undefined, ac, h.name); validateCanonicalList(tiers.user?.tools as readonly string[] | undefined, ac, h.name); for (const set of Object.values(toolsets)) { validateCanonicalList(set as readonly string[], ac, h.name); } } const floor = tiers.project?.tools; if (floor !== undefined && effectiveArgs.tools !== undefined) { const floorSet = new Set(floor); const excess = effectiveArgs.tools.filter((t) => !floorSet.has(t)); if (excess.length > 0) { throw new FloorExceededError(h.name, excess, [...floor]); } } // Profile is the floor: apply only where nothing above it set the key. for (const [key, value] of Object.entries(DEFAULT_TURN_PROFILE)) { if (args.isolation !== undefined && ISOLATION_OVERRIDES.some((owned) => owned === key)) { provenance.push({ key, value: "disabled (tool-free isolation)", tier: "arg" }); continue; } const argsSet = effectiveArgs[key as keyof TurnOptions] !== undefined; const tier = sourceTier(key); if (argsSet) { provenance.push({ key, value: effectiveArgs[key as keyof TurnOptions], tier: "arg" }); continue; } // RFC-05: on an effort-in-model harness only arg-tier effort enforces // and can refuse. Profile, user-config, and project-config effort // diverge with tier-and-key provenance; the harness default runs, so a // machine-wide config effort never breaks a bare cursor run. No // config-loop arm: that loop skips profile keys. if (key === "effort" && h.turnOptions.effort?.kind === "effort-in-model") { const nonArgTier: ProvenanceTier = tier ?? "profile"; const nonArgValue = tier !== undefined ? config[key as keyof TurnOptions] : value; provenance.push({ key, value: nonArgValue, tier: nonArgTier }); unrenderable.push({ key, tier: nonArgTier }); continue; } if (tier !== undefined) { provenance.push({ key, value: config[key as keyof TurnOptions], tier }); resolved[key] = config[key as keyof TurnOptions]; continue; } const deferralReason = PROFILE_DEFAULT_DEFERRALS[h.name]?.[key as ProfileKey]; if (deferralReason !== undefined) { provenance.push({ key, value: `none (${deferralReason})`, tier: "harness", }); continue; } const expressible = EXPRESSIBLE[key as ProfileKey]?.(h) ?? false; if (!expressible) { // Skip-and-report: a profile default this harness cannot express is // reported divergence, never a refusal and never silence. unrenderable.push({ key, tier: "profile" }); provenance.push({ key, value, tier: "harness" }); continue; } // Explicit access owns the grant; the profile must not add a separate tool list. if (key === "tools" && value === "all-known" && resolved.access !== undefined) { const accessTier: ProvenanceTier = effectiveArgs.access !== undefined ? "arg" : (sourceTier("access") ?? "user-config"); provenance.push({ key, value: "none (access preset)", tier: accessTier }); continue; } // The claimed option's profile default yields to a set access preset. if (key === claimed && resolved.access !== undefined) { provenance.push({ key, value: `${String(value)} (access)`, tier: "harness" }); continue; } // D13: the tools marker never renders a list. On a harness whose // include flag is a strict allowlist (pi), a rendered list would drop // every extension and MCP tool the harness registered at run time; // on a harness whose include flag is a permission grant (claude), a // rendered list would blanket-approve the default set and restore // dormant built-ins. Either way the marker emits nothing, recorded // in provenance: dormant built-ins stay off unless a caller grants // them, and a worker keeps its extensions and native defaults. if (key === "tools" && value === "all-known") { // --no-tools containment: a tier that switched discovery.tools off // must not have the profile grant switch them back on (pi reads // --tools as an enabling allowlist). The tier that turned tools off // owns the skip. const toolsOff = ( o: { readonly discovery?: TurnOptions["discovery"] } | undefined, ): boolean => o?.discovery?.tools === false; const offTier: ProvenanceTier | undefined = toolsOff(effectiveArgs) ? "arg" : toolsOff(tiers.project) ? "project-config" : toolsOff(tiers.user) ? "user-config" : undefined; if (offTier !== undefined) { provenance.push({ key, value: "none (discovery.tools off)", tier: offTier }); continue; } if (h.tools.includeIsStrictAllowlist) { provenance.push({ key, value: "none (a tools list would drop extension and MCP tools; harness default applies)", tier: "profile", }); continue; } // claude shape (issue #411): the include flag is a permission // grant, never a restriction. Rendering "all known" there would // pre-approve every default tool - a bare `Bash` grant approves // any command - and restore dormant built-ins (Glob/Grep are off // by default on macOS/Linux/WSL), changing bare-run behavior // instead of adding availability. The marker emits nothing on a // non-strict include flag; dormant built-ins stay off unless a // caller grants them, exactly as on the strict-allowlist harnesses. provenance.push({ key, value: "none (include flag only pre-approves calls, never adds tools; harness default applies)", tier: "profile", }); continue; } // Dimensions whose value reduces to "emit nothing" (autonomy false, // discovery all-on, write/shell true) stay ABSENT from the resolved // options - the harness's default already satisfies the profile, and // emitting explicit on-flags would change resume grammar and add // breakage surface for no semantic gain. Provenance still records // the tier. if ( (key === "autonomy" && value === false) || (key === "discovery" && emitsNothing(value)) || ((key === "write" || key === "shell") && value === true) ) { provenance.push({ key, value, tier: "profile" }); continue; } resolved[key] = value; provenance.push({ key, value, tier: "profile" }); } // Config keys outside the profile surface pass through at their own tier // (validated later by the same renderers as args). for (const [key, value] of Object.entries(config)) { if (key === "toolsets") continue; // expanded into args above, never a turn option if (key === "toolMap") continue; // per-canonical provenance already emitted if (key in DEFAULT_TURN_PROFILE) continue; if (effectiveArgs[key as keyof TurnOptions] !== undefined) { provenance.push({ key, value: effectiveArgs[key as keyof TurnOptions], tier: "arg" }); continue; } resolved[key] = value; const tier = sourceTier(key) ?? "user-config"; provenance.push({ key, value, tier }); } // Access fixup. Any access value on a harness with no access spec // refuses: a caller asking for a restriction must not get a full-default // run plus a stderr note. Access is not a profile key, so no // profile-tier default exists to diverge; arg, user-config, and // project-config values all refuse with the same shape the renderer // uses for explicit values. if (resolved.access !== undefined && h.turnOptions.access === undefined) { throw new ArgvRefusalError({ issue: "unsupported-option", harness: h.name, option: "access", supported: Object.keys(h.turnOptions).length ? Object.keys(h.turnOptions) : ["(none)"], detail: String(resolved.access), hint: hintFor(h.name, "access"), }); } if (resolved.access !== undefined && !provenance.some((p) => p.key === "access")) { const tier: ProvenanceTier = effectiveArgs.access !== undefined ? "arg" : (sourceTier("access") ?? "user-config"); provenance.push({ key: "access", value: resolved.access as string, tier }); } return { options: resolved as unknown as TurnOptions, provenance, unrenderable, }; };