/** * The claude-code descriptor: facts about the `claude` CLI as data, verified * against claude 2.1.304 (test/fixtures/claude-2.1.304), with historical * context/decode evidence in test/fixtures/claude-2.1.288 and * claude-2.1.296, and the 00-chat-substrate spike evidence (A-001, * A-002, A-005). No process logic lives here. * * Discovery: claude 2.1.233 has no isolated instruction-file toggle. * `--bare` would stop `CLAUDE.md` discovery, but it also skips hooks, LSP, * plugin sync, auto-memory and keychain reads, and forces auth to * `ANTHROPIC_API_KEY` / `apiKeyHelper` (OAuth never read) - so a caller * authenticated via OAuth would break. The descriptor therefore offers no * `instructionFiles` facet and a call passing it must refuse. Likewise * `tools` has no discovery flag on claude and refuses. The explicit * tool-free isolation composite below accepts the bare-mode auth cost and * disables native tools too. It is separate from a granular discovery facet. * * Effort: A-002 showed `--effort bogus` warns on stderr and runs at DEFAULT * effort, exit 0, with nothing echoed in the stream. The library-side * `validateEffort` refusal is therefore load-bearing, not cosmetic. */ import { deepFreeze, type HarnessDescriptor, UUID_SHAPE } from "./descriptor.js"; import { SHARED_AUTH_MATCHERS, SHARED_LIMIT_MATCHERS } from "./matchers.js"; import { CLAUDE_TRANSCRIPT } from "./transcript/claude.js"; const STREAM_INPUT_FLAGS = ["--input-format", "stream-json"] as const; export const claudeCode: HarnessDescriptor = deepFreeze({ name: "claude", transcript: CLAUDE_TRANSCRIPT, bin: "claude", verifiedAgainst: "2.1.304", versionSource: { kind: "npm", package: "@anthropic-ai/claude-code" }, launch: { baseFlags: ["-p"], subcommands: [], promptStyle: "positional", // Passthrough placement (ADR 0003, probed 2026-09-17 on claude // 2.1.274): appended native flags parse once hcn's bare `--` is gone // (an appended `--session-id ` is honored as the session id; a // bogus flag is rejected natively as an unknown option). The tail // renders past hcn's own argv with no separator. passthrough: "after-argv", // Print mode accepts piped text. Keep ordinary argv introspection // stable while staying below per-argument limits for large requests. stdinPrompt: { argument: "", aboveBytes: 65_536 }, // A headless turn launches with the full stream-json output set so the // runner can decode identity/limits and stream token deltas; bare -p // (granularity none) is a degraded invocation this builder never emits. streamFlags: ["--output-format", "stream-json", "--verbose", "--include-partial-messages"], idFlag: "--session-id", }, resume: { // A-005: claude resumes are id-stable - the caller-assigned id survives // every resume, so there is no rotation handling. Forking is the // explicit --fork-session flag (deliberate branching) on the resume-id // path, and the default on the resume-last path (RFC-06: --continue // --fork-session, so a most-recent turn never writes the parent file). style: "flag", flag: "--resume", aliases: ["-r"], idShape: UUID_SHAPE, onMissing: "error", extraFlags: ["-p"], }, sessionMode: { // A-001: one process, many turns; `result` delimits turns; mid-turn sends // queue. --setting-sources project isolates the child from user-level // hooks (D-025). Token deltas require this exact output flag set. // sessionMode.flags is the complete flag list after the binary, so -p // lives here rather than being inherited from launch.baseFlags. // claude has two session-id flags with opposite unknown-id behaviour: // `--session-id` NAMES a fresh session (creates if missing, silent) while // `--resume` RESTORES an existing one (refuses unknown id, exit 1 // "No conversation found" - verified phase11, // test/fixtures/phase11-claude-session-resume/resume-unknown-id.stderr.txt). // `idFlag` is the naming flag (`--session-id`); `resumeFlag` is the // restoring flag (`--resume`) - distinct as data so the renderer cannot // pick the wrong one. Unknown-id behaviour reuses `resume.onMissing: // "error"` as the single source; sessionMode does not restate it. // Verified phase11 (test/fixtures/phase11-claude-session-resume): // `session-id-establish.ndjson` creates with `--session-id`, and // `resume-restores.ndjson` restores the same id with `--resume` and // recalls codeword "pomegranate". flags: [ "-p", ...STREAM_INPUT_FLAGS, "--output-format", "stream-json", "--include-partial-messages", "--verbose", "--setting-sources", "project", ], idFlag: "--session-id", resumeFlag: "--resume", input: { kind: "claude-sdk-user-message" }, turnEnd: { type: "result" }, identityProbe: null, }, output: { // --output-format/--include-partial-messages only work with --print, so // -p is part of the pin, not an accident of the builders. pins: [ { flags: ["-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages"], granularity: "token", }, ], floor: "none", flagAliases: { "--print": "-p" }, }, identity: { authority: "caller-assigned", announce: { match: { type: "system", subtype: "init" }, idField: "session_id" }, }, limitMatchers: [ // Observed phrasings from the live CLI (ported from lucid v1's limits.ts): // "You've hit your session limit · resets 6:30pm" { pattern: "you'?ve hit your session limit", flags: "i", code: "session-limit" }, // "You've hit your weekly limit · resets 2am (Asia/Bangkok)" { pattern: "you'?ve hit your weekly limit", flags: "i", code: "weekly-limit" }, ...SHARED_LIMIT_MATCHERS, ], authMatchers: [ // A detached process cannot read Keychain creds, and misreading that as // "not logged in" sends the human to redo a login that was never broken. { pattern: "oauth session expired|could not be refreshed", flags: "i", kind: "expired" }, { pattern: "failed to authenticate", flags: "i", kind: "expired" }, { pattern: "not logged in|please run \\/login", flags: "i", kind: "not-logged-in" }, ...SHARED_AUTH_MATCHERS, ], autonomy: { flag: "--dangerously-skip-permissions" }, vocabulary: { modelFlag: "--model", // https://platform.claude.com/docs/en/models/opus-5-5/overview // claude-sonnet-5-5 (issue #333): // https://platform.claude.com/docs/en/models/sonnet-5-5/whats-new-sonnet-5-5 // Haiku 5.5: https://code.claude.com/docs/en/model-config#model-aliases models: [ "claude-fable-5-1", "claude-fable-5", "claude-opus-5-5", "claude-opus-5", "claude-sonnet-5-5", "claude-sonnet-5", "claude-haiku-5-5", "claude-haiku-4-5-20251001", ], aliases: { fable: "claude-fable-5-1", opus: "claude-opus-5-5", sonnet: "claude-sonnet-5-5", haiku: "claude-haiku-5-5", }, efforts: ["low", "medium", "high", "xhigh", "max"], extensible: false, }, store: { // Verified against the A-001 fixture's memory_paths slug and real // ~/.claude/projects entries: '/', '.' -> '-'; '_' preserved. template: "{home}/.claude/projects/{cwdSlug}/{sessionId}.jsonl", cwdSlug: "dash-separators", }, nativeContextManagement: { kind: "native-session-auto-compaction", modes: ["headless-turn"], }, contextInspection: { kind: "claude-control-v1", flags: [...STREAM_INPUT_FLAGS, "--no-session-persistence", "--replay-user-messages"], forkFlag: "--fork-session", }, // RFC-06: `-c, --continue` continues the most recent conversation // (observed on 2.1.274, 2026-09-17). The fork half renders from the // single contextInspection.forkFlag, always on this path. // Claude's system records carry both edges: a `compacting` status opens // the pause and the `compact_boundary` record closes it with the counts. // A failed compaction reports through the status record's // `compact_result`. Claude has been observed sending two `compacting` // records for one compaction, so a counting consumer counts ends. compactionReporting: { source: "stream", states: ["started", "compacted", "failed"], tokens: true, }, resumeLast: { flag: "--continue", headless: true, warning: "hcn: --resume-last forks the most-recent claude session in {cwd} under a new fork id; the most recent session may be a killed, failed, or unrelated run's session; claude starts a fresh session with exit 0 when no session is resumable in {cwd}", }, stdin: "inherit", presence: { headlessMarkers: ["-p", "--print"], }, capabilities: { vision: true, images: true, streamingByMode: { "headless-turn": "token", "headless-session": "token", interactive: "message", }, session: true, }, // Escalation provenance: the `model` below is the escalation probe's // own model (what asked when instructed), not the runtime model - the // harness self-attests that on its stream and the decoder fills the // re-emitted identity's observedOn from it. escalation: { supported: true, observedOn: { harness: "claude", model: "sonnet", version: "2.1.304", date: "2026-10-20" }, }, turnOptions: { // CLAUDE_CODE_AUTO_COMPACT_WINDOW takes precedence over this flag. contextWindow: { kind: "integer", min: 100000, max: 1000000, render: { kind: "flag-value", flag: "--autocompact" }, }, // Native CLI reference: bare removes discovery; the empty built-in list and // MCP deny remove tool access. Resume and native overrides are refused. isolation: { kind: "enum", values: ["tool-free"], resumeRender: null, render: { kind: "flag-list", flags: ["--bare", "--tools", "", "--disallowedTools", "mcp__*", "--strict-mcp-config"], }, }, effort: { kind: "effort", render: { kind: "flag-value", flag: "--effort" } }, // issue #48, live-verified 2.1.235: --system-prompt replaces the built-in // prompt; --exclude-dynamic-system-prompt-sections strips the dynamic // sections (git state, directory listing) that ride even under a // replacement. hcn pairs them - a payload-stripping replacement without // the exclusion keeps injected sections. Verified live: the pair changes // model behavior (NAKED-HAIKU probe vs BASELINE-OK). systemPrompt: { kind: "prompt-text", render: { kind: "flag-value", flag: "--system-prompt", extraFlags: ["--exclude-dynamic-system-prompt-sections"], }, }, appendSystemPrompt: { kind: "prompt-text", render: { kind: "flag-value", flag: "--append-system-prompt" }, }, // Named-agent selection (map #288). Probed live on 2.1.283: --agent // composes with -p headless and with --resume (the same native session // id continues), so resume inherits the launch render. An unknown name // is claude's own native error (exit 1, before model work) whose text // lists the roster - the name passes through unvalidated beyond the // selector rule. agent: { kind: "selector", render: { kind: "flag-value", flag: "--agent" }, }, discovery: { kind: "discovery", facets: { extensions: { polarity: "disables", render: { kind: "flag-list", flags: ["--setting-sources", "project"] }, }, skills: { // Phase 0 (claude-tool-interplay.md probe 4): --disable-slash-commands // removes the Skill tool AND all skills listing - verified a full // skills-off switch, not just command dispatch. --setting-sources // project stays the extensions-facet spelling (settings scope). polarity: "disables", render: { kind: "flag-list", flags: ["--disable-slash-commands"] }, }, }, }, // --tools restricts built-ins, not MCP; explicit MCP denial overrides // existing approvals, which dontAsk alone would still honor. Web requests // retain native approval policy; this is not filesystem/network confinement. // Write pairs edit approval with native sandboxed Bash; startup must succeed. access: { kind: "access", renders: { read: { render: { kind: "flag-value", flag: "--tools", extraFlags: ["--permission-mode", "dontAsk", "--disallowedTools", "mcp__*"], }, value: "Read,Grep,Glob,WebFetch,WebSearch", argvPlacement: "after-prompt", }, write: { render: { kind: "json-settings", flag: "--settings", extraFlags: ["--permission-mode", "acceptEdits"], value: { sandbox: { enabled: true, autoAllowBashIfSandboxed: true, allowUnsandboxedCommands: false, failIfUnavailable: true, filesystem: { disabled: false }, }, }, }, }, }, }, // Persistent auto-memory (loads ~/.claude/projects//memory/ into // the system prompt each session) is disabled ONLY via env var - there // is no argv flag. Live-verified 2.1.241 (evidence: // test/fixtures/memory-dimension/): the stream-json init event carries // memory_paths.auto without the var, drops it entirely under // CLAUDE_CODE_DISABLE_AUTO_MEMORY=1, and keeps it under =false - the // var is parsed as a boolean, so "1" is the unambiguous disable. // --bare also skips auto-memory but at unacceptable cost (see the // header comment), and settings.json autoMemoryEnabled:false is a // config-file change, not a per-call surface. memory: { kind: "toggle", polarity: "disables", render: { kind: "env", name: "CLAUDE_CODE_DISABLE_AUTO_MEMORY", value: "1" }, }, }, // Phase 0 fixtures: claude-tool-interplay.md. include is a permission // grant (Bash, Edit stay visible under --allowedTools Read); only the // disallow flag reshapes the model-visible set. Both flags together // compose, deny winning on overlap. Patterns (Bash(git *)) valid in both // lists; unknown PATTERN spellings warn on stderr, unknown exact names // are the silent-acceptance hazard the curated vocabulary guards. // --tools (not these flags) is the flag that limits which built-in tools // exist; the access read preset names its closed inventory there. skills: { loadFlag: null, overridesVia: "settings-skilloverrides" }, tools: { includeFlag: "--allowedTools", excludeFlag: "--disallowedTools", includeIsStrictAllowlist: false, builtins: [ { name: "Bash", defaultEnabled: true, canonical: "shell" }, { name: "CronCreate", defaultEnabled: true, canonical: null }, { name: "CronDelete", defaultEnabled: true, canonical: null }, { name: "CronList", defaultEnabled: true, canonical: null }, { name: "DesignSync", defaultEnabled: true, canonical: null }, { name: "Edit", defaultEnabled: true, canonical: "edit" }, { name: "EnterWorktree", defaultEnabled: true, canonical: null }, { name: "ExitWorktree", defaultEnabled: true, canonical: null }, // Absent by default on macOS/Linux/WSL (search rides Bash instead); // naming either in --tools or --allowedTools restores it. { name: "Glob", defaultEnabled: false, canonical: "glob" }, { name: "Grep", defaultEnabled: false, canonical: "grep" }, { name: "LSP", defaultEnabled: true, canonical: null }, { name: "ListAgents", defaultEnabled: true, canonical: null }, { name: "ListMcpResourcesTool", defaultEnabled: true, canonical: null }, { name: "Monitor", defaultEnabled: true, canonical: null }, { name: "NotebookEdit", defaultEnabled: true, canonical: null }, { name: "PushNotification", defaultEnabled: true, canonical: null }, { name: "Read", defaultEnabled: true, canonical: "read" }, { name: "ReadMcpResourceDirTool", defaultEnabled: true, canonical: null }, { name: "ReadMcpResourceTool", defaultEnabled: true, canonical: null }, { name: "RemoteTrigger", defaultEnabled: true, canonical: null }, { name: "ReportFindings", defaultEnabled: true, canonical: null }, { name: "ScheduleWakeup", defaultEnabled: true, canonical: null }, { name: "SendMessage", defaultEnabled: true, canonical: null }, { name: "Skill", defaultEnabled: true, canonical: "skill" }, { name: "Task", defaultEnabled: true, canonical: "subagent" }, // Removed in claude 2.1.277; classified for the fixtures that list it. { name: "TaskOutput", defaultEnabled: false, canonical: null }, { name: "TaskStop", defaultEnabled: true, canonical: null }, { name: "ToolSearch", defaultEnabled: true, canonical: null }, { name: "WebFetch", defaultEnabled: true, canonical: "web-fetch" }, { name: "WebSearch", defaultEnabled: true, canonical: "web-search" }, { name: "Workflow", defaultEnabled: true, canonical: null }, { name: "Write", defaultEnabled: true, canonical: "write" }, ], categories: [], denySemantics: "remove-from-set", }, });