/** * Structured argv refusals: closed issue vocabulary and the typed error * that carries harness, option, facet, and supported alternatives. The * helper builds the message from those fields so the two cannot drift - a * consumer branching on `issue` never has to parse English. */ import type { DiscoveryFacet, HarnessName, TurnOptionKey } from "../knowledge/descriptor.js"; import { deepFreeze } from "../knowledge/descriptor.js"; export const REFUSAL_ISSUES = deepFreeze([ "unsupported-option", "unsupported-option-facet", "extension-option-unavailable", "unsupported-on-resume", "invalid-option-value", "unknown-effort", "unknown-model", "invalid-env", "invalid-tool-grant", "unknown-tool-name", "mutually-exclusive-options", "prompt-flag-injection", "no-autonomy-mode", "no-session-mode", "native-settings-unavailable", "native-settings-changed", "unsupported-passthrough", ] as const); export type RefusalIssue = (typeof REFUSAL_ISSUES)[number]; /** The option a refusal names: turn-option spec keys (descriptor tables) * plus the tool-list dimensions and autonomy, which render via dedicated * descriptor fields rather than a spec table. Kept closed so consumers can * branch on it without a default arm. */ export type RefusalOption = | TurnOptionKey | "context" | "tools" | "excludeTools" | "skills" | "autonomy" | "questions" | "timeout" | "nativeSettingsFingerprint" | "resumeLast" | `discovery.${string}`; /** One helper builds the message from the structured fields so message and * fields cannot drift. Every message names an alternative, not only a * negation, so an agent can pivot without reading the descriptor. A * refusal raised before a harness is chosen (argument parsing, config) * passes no harness, and the message carries no harness fragment (RFC-02 * change 11) instead of naming one the caller never picked. */ export const buildRefusalMessage = ( issue: RefusalIssue, harness: HarnessName | undefined, option?: RefusalOption, facet?: DiscoveryFacet, supported: readonly string[] = [], detail?: string, // N3: the native binary of the descriptor that raised the refusal. Set // at the raise site (an override file can change bin); never looked up // from the defaults here, which would ignore overrides. bin?: string, ): string => { const supportedStr = supported.length > 0 ? `supported: ${supported.join(", ")}` : "supported: (none)"; const facetSuffix = facet ? ` facet ${JSON.stringify(facet)}` : ""; const optionPart = option ? ` option ${JSON.stringify(option)}${facetSuffix}` : facet ? ` facet ${JSON.stringify(facet)}` : ""; const detailSuffix = detail ? ` (${detail})` : ""; // The harness fragments: present only when a harness was chosen. const who = harness ?? "the harness"; const onHarness = harness === undefined ? "" : ` on ${harness}`; const forHarness = harness === undefined ? "" : ` for ${harness}`; switch (issue) { case "unsupported-option": return `${who} cannot express${optionPart}${detailSuffix}; ${supportedStr} - drop the option or route this work to a harness that supports it`; case "unsupported-option-facet": return `${who} cannot express discovery${facetSuffix}${detailSuffix}; ${supportedStr} - drop the facet or route this work to a harness that supports it`; // Map #300 (#303 decision 1): the spec exists but THIS machine's // harness cannot express it - the extension is absent or the probe // could not run. The message names the providing extension (via // detail) and the two alternatives; consumers branch on the issue // code to separate "this harness cannot" and "this machine // currently cannot". case "extension-option-unavailable": return `${who} cannot currently express option ${option ? JSON.stringify(option) : ""} on this machine${detailSuffix}; ${supportedStr} - install the named extension or pass the flag natively after --`; case "unsupported-on-resume": { const name = option ? JSON.stringify(option) + (facet ? `:${facet}` : "") : facet ? JSON.stringify(facet) : "option"; return `${name} cannot be expressed on resume${forHarness}${detailSuffix}; ${supportedStr} - re-launch instead of resuming or drop the option`; } case "invalid-option-value": return `invalid value for${optionPart}${onHarness}${detailSuffix}; ${supportedStr} - use a supported value instead`; case "unknown-effort": return `unknown effort${forHarness}${detail ? ` ${JSON.stringify(detail)}` : ""}; ${supportedStr} - use one of the ladder values`; case "unknown-model": return `unknown ${harness === undefined ? "" : `${harness} `}model${detail ? ` ${JSON.stringify(detail)}` : ""}; ${supportedStr} - use one of the supported models`; case "invalid-env": return `invalid env key or value${forHarness}${detailSuffix}; ${supportedStr} - keys must match ^[A-Za-z_][A-Za-z0-9_]*$ and contain no NUL`; case "invalid-tool-grant": return `tool grant${forHarness} 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`; case "unknown-tool-name": return `${who} 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`; case "mutually-exclusive-options": return `${harness === undefined ? "cannot combine" : `${harness} cannot combine`}${optionPart}${detailSuffix}; ${supportedStr} - pass exactly one of them`; case "prompt-flag-injection": return `positional prompt may not start with '-'; it would be parsed as a flag by ${who}${detailSuffix}; ${supportedStr} - remove leading '-' or prefix with a space`; case "no-autonomy-mode": // supportedStr already carries the descriptor-derived supporting // list, so a new autonomy grant (cursor --force) appears without // editing this message and the list is never repeated. return `${who} has no unattended-run flag; ${supportedStr} - drop autonomy or route to a supporting harness`; case "no-session-mode": return `${who} declares no persistent headless session mode; ${supportedStr} - use hcn run --resume `; case "native-settings-unavailable": return `saved native settings are unavailable${forHarness}${detailSuffix}; read the same native session again before retrying`; case "native-settings-changed": return `saved native settings changed${forHarness}; inspect the same native session again before retrying`; case "unsupported-passthrough": { // RFC-05: a prompt-joins harness takes post-`--` tokens into the // positional prompt as text (probe 41), so a non-empty tail refuses // before spawn. The offending tokens ride `detail`; the // stay-on-harness hint is set at the raise site. The native binary // arrives on the refusal from the descriptor in use, never from a // harness-name branch or a defaults lookup. const invoke = bin === undefined ? "invoke the harness binary directly" : `invoke ${bin} directly`; return `${who} joins tokens after the -- separator into the prompt as text${detailSuffix}; ${supportedStr} - remove the tokens after -- and re-run; to pass native flags, ${invoke}`; } default: { const exhaustive: never = issue; return `${exhaustive as string}${forHarness}${optionPart}${detailSuffix}; ${supportedStr}`; } } }; /** Raised when launch options would corrupt or subvert the spawned argv. */ export class ArgvRefusalError extends Error { readonly issue: RefusalIssue; /** The harness the refusal is about; absent when it was raised before * one was chosen (argument parsing, config loading). */ readonly harness?: HarnessName; readonly option?: RefusalOption; readonly facet?: DiscoveryFacet; readonly supported: readonly string[]; /** D7: which harnesses DO express the refused option, native spellings * included. Derived by the raise site from descriptors - absent when the * refusing layer has no descriptor set in scope. */ readonly supportedBy?: ReadonlyArray<{ harness: string; spelling: string }>; /** D8: nearest-alternative suggestion for the CURRENT harness - keeps a * scanning agent on its chosen harness instead of switching. Curatorial * data set at the raise site; absent when no hint exists. */ readonly hint?: string; /** N3: the native binary of the descriptor that raised the refusal, for * message arms that name it. Set at the raise site; absent when the * raising layer has no descriptor in scope. */ readonly bin?: string; constructor(args: { readonly issue: RefusalIssue; readonly harness?: HarnessName; readonly option?: RefusalOption; readonly facet?: DiscoveryFacet; readonly supported?: readonly string[]; readonly supportedBy?: ReadonlyArray<{ harness: string; spelling: string }>; readonly hint?: string; readonly detail?: string; readonly message?: string; readonly bin?: string; }) { const message = args.message ?? buildRefusalMessage( args.issue, args.harness, args.option, args.facet, args.supported ?? [], args.detail, args.bin, ); super(message); this.name = "ArgvRefusalError"; this.issue = args.issue; this.harness = args.harness; this.option = args.option; this.facet = args.facet; this.supported = args.supported ?? []; this.supportedBy = args.supportedBy; this.hint = args.hint; this.bin = args.bin; } }