This file provides guidance to agents when working with code in this repository.
The root CLAUDE.md is a symlink to this file. Rules cite symbols and files by
name rather than line number (line numbers drift); the @shofer/core carve-out
means most non-host logic lives under packages/core/src/**, while host-only
code (webview, ContextProxy, activation, task/skill managers) stays under
src/**.
This file holds the rules that cross package boundaries (most do — the
webview↔host bridge and the core↔plugin seams are where the invariants live).
Rules scoped to a single directory live in that directory's own AGENTS.md
(each with a CLAUDE.md symlink): webview-ui/
(SettingsView save-gating, ChatView send/draft rules, task-state rendering,
Radix primitives) and plugins/rag-indexing/
(ignore-filter oracle, sole-indexer/search-only, index identity, embedder
concurrency lanes, submodule-aware scanning).
-
SettingsView editing rules (bind to
cachedState, save-gating for every control, the default-vs-current-profile distinction) live inwebview-ui/AGENTS.md; the storage/merge architecture isdocs/settings_overlay.md. -
Typed Settings Rule: All extension settings and secrets MUST go through
ContextProxy, notvscode.workspace.getConfiguration("shofer.*")orcontext.secrets.get/storedirectly. Add new state keys toglobalSettingsSchemainpackages/types/src/global-settings.tsand new secret keys toGLOBAL_SECRET_KEYSin the same file; access viaContextProxy.getInstance(context).getValue(key)for state andgetSecret(key)for secrets. The proxy auto-routes by key and gives one cache + one event stream for the whole extension. Bypassing it splits the source-of-truth and breaks settings-view round-tripping. -
No Ad-Hoc VS Code Config Reads Rule:
vscode.workspace.getConfiguration(...)andcontext.globalState.get/updateMUST NOT appear in feature code. Only three kinds of call sites may bypassContextProxy: (1)ContextProxyitself; (2) the registration boundary reading non-shofer settings declared inALLOWED_VSCODE_SETTINGS(e.g.terminal.integrated.inheritEnvinwebviewMessageHandler.ts); (3) extension-point discovery whose schema lives inpackage.json(e.g.arkware.privateToolProvidersinbuild-tools.ts). Note the runtime VS Code config namespace isarkware.*, notshofer.*. Everywhere else, go throughContextProxy. -
Typed Command Rule: Commands MUST be registered through the typed
CommandIdplumbing — add the id to thecommandIdsconst inpackages/types/src/vscode.tsand wire the handler insrc/activate/registerCommands.ts. Do NOT callvscode.commands.registerCommand(...)ad-hoc fromextension.tsor service constructors. UsegetCommand(id)frompackages/core/src/utils/commands.tsanywhere a prefixed command name is needed so the prefix is centralized.
-
Task State Model: Task state is a two-axis structure
TaskState = { lifecycle: TaskLifecycle, rating?: CompletionRating }defined inpackages/types/src/history.ts. Never conflate the two axes into a single string enum (the oldTaskExecutionStatemistake — values like"completed_well"). When adding a new lifecycle, updateTaskLifecycle,LIFECYCLE_VISUALinTaskSelector.tsx,sanitizeRestoredStateinTaskManager.tsif the lifecycle is transient,isTerminalLifecycle()inhistory.tsif it's terminal, anddocs/task_states.md. When adding a rating value, updateCompletionRating,RATING_VISUALinTaskSelector.tsx, andcompletionRatingSchemainhistory.ts. -
Single-Writer Persistence Rule:
TaskManager.setState(taskId, state)is the only writer oftaskStateto both in-memoryManagedTask.stateand persistedHistoryItem.taskState. Do NOT writetaskStatedirectly viaprovider.updateTaskHistory({ ..., taskState })from tools, event handlers, orShoferProvider. Pass aninitialStatetoprovider.createTask()for new tasks; route all subsequent transitions throughsetState. One choke point keeps in-memory and persisted views consistent and centralizes invariants/telemetry. -
How the webview renders task state (the
runtime?.state ?? item.taskStatefallback chain,resolveStateVisual, theLIFECYCLE_VISUAL/RATING_VISUALtables) is governed bywebview-ui/AGENTS.md— the Task State Model rule above still names the tables a new lifecycle/rating must update. -
Restore-Ordering Rule: Methods on
TaskManagerthat depend on the managed-task map being authoritative (e.g.registerBackgroundTask) must callassertRestored()at the top to prevent order-of-initialization bugs.restoreManagedTaskssets therestoredflag exactly once after rehydrating from history and runningsanitizeRestoredStateto downgrade transient lifecycles toidle. -
Preload-Before-Publish Rule: A
Taskrehydrated from aHistoryItemMUST have itsshoferMessagesandapiConversationHistorypopulated from disk BEFORE it is pushed ontoShoferProvider.shoferStack(i.e. before it becomesgetCurrentTask()). Construct withstartTask: false,await task.preloadShoferMessages()(idempotent, setshistoryPreloaded), THENaddShoferToStack(task)/ in-place swap, THENtask.startFromHistory(). Awaiting amessagesReadypromise in the SAME caller after publishing does NOT close the race — a concurrentpostStateToWebview()sees the new task withshoferMessages: []and broadcasts an empty snapshot, whichChatViewrenders as the home screen (the "task-switch home-screen flash").showTaskWithIdMUSTawait this.postStateToWebview()betweencreateTaskWithHistoryItemand thechatButtonClickeddispatch. SeeTask.preloadShoferMessagesandShoferProvider.createTaskWithHistoryItem. -
Self-Contained Events Rule: Lifecycle events (
TaskCompleted,TaskAbortedinpackages/types/src/events.ts) must carry the data needed to interpret them —TaskCompletedcarries{ rating, isSubtask },TaskAbortedcarries{ reason }. Consumers must not call back into the task instance orShoferProviderto figure out what happened. New lifecycle events include all decision-relevant fields in the payload type. -
Self-Declared Terminal State Rule: A tool that represents the agent's own terminal state (currently only
attempt_completion) MUST NOT calltask.ask(...)to gate its lifecycle event. The completionresult/rating/feedbackare produced by the agent itself — no user approval, no follow-up prompt, no waiting onyesButtonClicked. Render viatask.say("completion_result", …), persist artefacts, then synchronously callemitTaskCompletedand settask.abort = true. Using anisIdleAskhere is a bug: thestatusMutationTimeoutinTask.ask()firesTaskIdle→setState(idle)and the never-arriving response preventsemitTaskCompleted, so the rating overlay never lands. SeeAttemptCompletionTool.executeanddocs/task_states.md. -
Terminal-State Queue-Drain Rule: Because a self-declared terminal-state tool no longer calls
task.ask(…), it losesTask.ask()'s free queue-drain. The tool MUST explicitly checktask.messageQueueService.isEmpty()BEFORE the persist +emitTaskCompleted+task.abort = trueblock. If non-empty, dequeue the head (FIFO), render viatask.say("user_feedback", …),pushToolResult(formatResponse.toolResult(<user_message>…</user_message>, images)), andreturn— letting the loop continue. The complementary case (a NEW message arriving viaqueueMessageAFTER abort) is handled in the hostqueueMessagehandler (webviewMessageHandler.ts): ifcurrentTask.abort === trueat enqueue time it auto-triggerscancelAndProcessQueuedMessages(). Seedocs/message_queue.md. -
Waiting Lifecycle Rule: A task parked inside
wait(or any blocking-on-mail primitive) MUST transition to thewaitinglifecycle for the block and back on resume.waitingis distinct fromidle(not awaiting user input — it has live work elsewhere) and fromrunning(its own loop isn't advancing). It is transient and MUST be downgraded toidlebysanitizeRestoredStateinTaskManager.tson restart. Per the Task State Model rule, also add it toTaskLifecycle+LIFECYCLE_VISUAL. -
Subtask Question Routing Rule: When a child task (every task
new_taskcreates) callsask_followup_question, the question MUST route to the parent, NOT the user. It is delivered as arequestenvelope into the parent's mailbox — the parent sees it in itsenvironment_detailsdigest and answers withreply, which resolves the child's parkedask— while the child ALSO raises the ask in its own chat, so a human present there may answer instead. First answer wins and withdraws the other channel (docs/task_messaging.md). Never escalate a child's question to the webview ask UI of a DIFFERENT conversation — the user there has no context; the parent is the decision-maker.cancel_tasksis the parent-side escape hatch. The three control-plane subtask tools —new_task,attempt_completion(finishTask),cancel_tasks— share the singlealwaysAllowSubtaskstoggle incheckAutoApproval; keep them grouped so the toggle's meaning matches its label. The mailbox tools (send_message,reply,wait) sit OUTSIDE that toggle and are unconditionally approved: none of them blocks anything but the caller's own loop. -
Decide-Before-Publish Rule (auto-approval vs. the ask's first emission): When
Task.ask()publishes a COMPLETE ask, the auto-approval decision MUST already be on the message —autoApproved(andisAnswered, for an approvedtoolask) set before the persist and beforeaddToShoferMessages/updateShoferMessage. There are three such paths and all three must stay in sync: the single new complete message, thepartial: undefinedmessage, and the partial→final transition every streamed native tool call takes. Publishing first and stamping afterwards is not a cosmetic bug: locally it flickers Accept/Reject until a follow-upmessageUpdatedretracts them, and over the ShoferApi transport it hands a controller a finalized ask with no decision on it, which a controller that records asks durably turns into a pending approval nobody will ever be asked about. The decision is taken at most once per ask (decideAutoApproval's memo inTask.ask()) — a secondcheckAutoApprovalcan read provider state that disagrees with the answer already on the wire — and because every complete path returns onapprove/deny, no post-publication fallback exists to stamp the flag late. -
Emit-A-Snapshot Rule:
TaskmutatesShoferMessageobjects in place (a streamed message across its chunks, an ask when it finalizes,handleWebviewAskResponsemarking one answered). Every publication of one —emit(ShoferEventName.Message, …),postMessageToWebview,appendTaskMessage— therefore ships{ ...message }, taken at the moment the call is made, never the live reference. BothaddToShoferMessagesandupdateShoferMessageare reached after at least oneawait, and their consumers serialize later still (the SSE writer inhttp-server.ts, a task store that queues writes), so a reference publishes whatever state the object has drifted to by then. Nothing may correlate events by reference identity — usetsoraskId. -
Ordered-Publication Rule: every
ShoferEventName.Messagepublication for a task — updated AND created events — rides the per-task FIFO inTask.ts(enqueueMessagePublication), because each publication awaits variable-latency persistence (a Postgres-backed task store under load) before emitting, and un-serialized emissions REORDER: a consumer reconstructing a stream from event order (a controller's SSE relay, its delta view) then re-emits whole prefixes, and — the worst case — a finalizedcompletion_resultstill parked behind its store append is overtaken byTaskCompleted, so a controller that correctly stops reading there loses the tail of the reply. Do NOT add an emit path that bypasses the chain; a finalize path (attempt_completion's final say, an ask's partial→final transition) additionally AWAITS its publication so its event is on the wire before the caller proceeds to the turn's lifecycle events. The H29 partial-append throttle decision stays at ENQUEUE time — taken inside the queued publication it would measure store latency instead of write intent and disable itself under load.
-
Ask Categorization Rule:
ShoferAskis partitioned across exactly four state categorizers inpackages/types/src/message.ts—isIdleAsk,isInteractiveAsk,isResumableAsk,isAgentRunningAsk— describing how the loop behaves while the ask is outstanding. Every ask MUST belong to exactly one; the partition test inmessage.test.tsenforces this. The auto-approval policy (isAutoApprovableAsk) is a separate, orthogonal predicate and must NEVER be collapsed onto the state membership lists (the oldnonBlockingAsksconflation caused theattempt_completionregression that silently dropped queued messages and typed feedback). Seedocs/auto_approval.md. -
Auto-Approval Fast-Path Rule: An ask that ends a turn (anything in
idleAskssuch ascompletion_result,api_req_failed,mistake_limit_reached, …) MUST NOT be inautoApprovableAsks. The fast-path inTask.ask()returns synchronously without enteringpWaitFor, bypassing queue-drain andmessageResponsehandling — so auto-approving an idle ask (a) strands queued user messages forever and (b) lands typed feedback on an aborted task where the no-ask guard drops it. The invariantisAutoApprovableAsk(ask) ⇒ isAgentRunningAsk(ask)is asserted bymessage.test.ts. -
Tool Name Form Dual-Representation Rule: Auto-approval operates on two tool-name conventions:
ShoferSayTool.tool(camelCase, e.g."runSlashCommand") in thecheckAutoApprovalpath, andToolName(snake_case, e.g."run_slash_command") inTOOL_GROUPS. The mapping lives inSAY_TOOL_TO_NATIVE_NAMEinauto-approval/tools.ts. When adding or reclassifying a tool, verify ALL THREE: (1)TOOL_GROUPS(the group determines thealwaysAllow*toggle); (2)SAY_TOOL_TO_NATIVE_NAME(missing entry →getToolGroupForSayTool()falls through to prefix inference → likely"uncategorized"); (3) the camelCase blocks incheckAutoApproval(auto-approval/index.ts). -
No-Ask Handler Pattern Rule: Tool handlers in
packages/core/src/tools/MUST NOT calltask.ask(...)directly. UseBaseTool.askApproval("tool", completeMessage)from theToolCallbacksbag, which renders the ChatRow and gates oncheckAutoApproval(). Callingtask.ask(...)from a handler bypasses the auto-approval infrastructure and creates a second, uncontrolled path. See the Step 4 example indocs/adding-new-tools.md.
-
Native Tool Implementation Rule: Adding a native tool is a coordinated multi-file change spanning schema, types, handler, router, parser, and (when it renders UI)
ShoferSayTool+ChatRow+ i18n. The authoritative checklist isdocs/adding-new-tools.md— follow it rather than copying existing tools (which may predate conventions). Non-negotiable invariants: handlers extendBaseTool<"name">inpackages/core/src/tools/<Name>Tool.ts; approval goes throughBaseTool.askToolApproval()so even auto-approved invocations render; the tool'sToolGroupinTOOL_GROUPS(packages/types/src/tool.ts) is the single source of truth for mode filtering AND auto-approval; tools MUST NOT branch onmodeinsideexecute()(filtering happens upstream infilterNativeToolsForMode). -
Native Tool Parser Cases Rule: A new native tool MUST add cases in both switches inside
NativeToolCallParser—createPartialToolUse()(partial args) ANDparseToolCall()(complete args) — to construct typednativeArgs. Registering the tool intoolNames,TOOL_GROUPS, the schema, the handler, and the router inpresentAssistantMessage.tsis NOT enough: with no parser case,nativeArgsisundefined, the dispatcher guard rejects the call with"missing nativeArgs", andexecute()is never reached — surfacing as a generic "Provider Error / API Request Failed" with zero handler logs. Keep the two switches in lock-step; usecoerceOptionalNumber/coerceOptionalBoolean/Array.isArray(...)for optional fields and validate required strings before assigning. -
Advisory Parameter Defaults Rule: Native tool parameters described to the model as "soft"/"advisory" (e.g.
softResultLength,softTimeoutSeconnew_task) MUST be schema-optional (absent from JSON-schemarequiredinpackages/core/src/prompts/tools/native-tools/) AND have host-side defaults applied silently inexecute()— never error viasayAndCreateMissingParamError. The prompt and handler must stay coherent: erroring on a field the prompt calls optional causes a retry/churn loop. If the host genuinely needs the value, remove the advisory framing and add it torequired. SeeNewTaskTool.executefor the default + clamp-to-cap pattern. -
Streaming Path-Stabilization Rule: Tools that update chat UI inside
handlePartial()based on apath(or any string from partial-json streaming) MUST gate the update behindhasPathStabilized(block.params.path)fromBaseTool, and MUST callresetPartialState()at the end ofexecute()on both success and error paths. Skipping stabilization flickers truncated paths; skipping the reset short-circuits the next invocation on a stale path. -
Mode-Filtered Tool Exposure Rule: A tool's availability in a mode is controlled exclusively through (a) its
ToolGroupinpackages/types/src/tool.ts/mode.tsand (b) the upstreamfilterNativeToolsForMode/filterMcpToolsForMode/filterPrivateToolsForModeinbuild-tools.ts. Tools MUST NOT inspect the current mode insideexecute()— by then the filter has already authorized the call, and a second gate is a second source of truth that drifts. Seedocs/tool_access.md. -
Tool Group Count Coherence Rule: The tool-category vocabulary is open, over a closed set of 8 BUILTINS.
toolGroupsintool.tshas exactly 8 members (read,write,execute,mcp,mode,subtasks,questions,uncategorized) — theBuiltinToolGrouptype that the two exhaustive records are keyed by (TOOL_GROUPS, andGROUP_GATEinauto-approval/group-gates.ts). Any other name a tool declares is a dynamic category:ToolGroup = BuiltinToolGroup | (string & {})is the open type every declaration site takes, andtoolGroupNameSchema(a lowercase hyphen slug, ≤64 chars) is what validates it.- Adding a CATEGORY requires no coordinated change. It is minted at registration by whatever declared it — an MCP server's
_meta, anmcp.jsontoolGroupsentry, a private-tool provider'sgroup, a plugin's custom tool, a name typed into the MCP group dropdown — and gated by its entry in thealwaysAllowGroupsrecord. No enum edit, no newalwaysAllow*key, no hand-written UI row. If you find yourself editing five files to add a category, you are adding a builtin. - Adding a 9th BUILTIN is the rare coordinated change and needs a stated justification. It touches
toolGroups,TOOL_GROUPS,GROUP_GATE, a new flat key inglobalSettingsSchema(global-settings.ts), its own UI row, and the docs. A builtin earns its place only by carrying NATIVE tools or approval semantics the record must encode — a name that merely wants a toggle is a dynamic category. Consumer checklist:docs/terminology.md§10 anddocs/tool-categories.md. - Never index
TOOL_GROUPSby a name that came from config. A mode'stoolsarray, an agent's declared groups and a server's_metaare all slug-validated but open, so a dynamic name — or a typo — reaches the lookup and a bareTOOL_GROUPS[name].toolsthrows. UsegetToolGroupConfig(name), which returnsundefinedfor anything that is not a builtin.
- Adding a CATEGORY requires no coordinated change. It is minted at registration by whatever declared it — an MCP server's
-
Tool-Group Dual-Resolution Rule: A tool that is not native has its group resolved by two independent paths that MUST agree —
filterPrivateToolsForModeinbuild-tools.tsfor mode VISIBILITY, andgetToolGroupForSayToolinauto-approval/tools.tsfor AUTO-APPROVAL. What makes them agree is the registry, not a convention: whatever accepts a declared group callstoolGroupRegistry.registerToolMapping(toolName, group)(tool-groups/category-registry.ts), andgetToolGroupForSayToolconsults that mapping — after the custom-tool registry, BEFORE prefix inference. Prefix inference is the LAST RESORT and guesses only two things: abrowser-prefixed name into the dynamicbrowsercategory (registering it, so a toggle exists from the next call onward), andide_intoexecute. So abrowser_-named tool a server declared as something else is gated by what it DECLARED. Register the mapping wherever you accept a group; without it the two paths disagree silently — the tool is visible assalesforceand gated asuncategorized, its toggle on and the call still asking. -
Private-Tool Discovery Error Visibility Rule: Private-tool provider discovery in
getPrivateLmToolMeta()(build-tools.ts) MUST NOT silently discard provider-level failures. A provider whosegetDefinitionsCommandthrows (extension not installed/activated/crashed) is currently skipped with no log and no user indication, so a provider configured inarkware.privateToolProvidersthat produces no tools is undiagnosable. At minimum log to the output channel; preferably surface a warning row in chat. TheinvokeToolCommandpath inpresentAssistantMessage.tshas the same problem. -
MCP Per-Call Header Rule: A header that belongs to the RUN rather than to the host reaches an MCP server through exactly one path — the
"resolve-mcp-call-headers"plugin broadcast incallTool, carried to the transport'sfetchby theAsyncLocalStorageincall-headers.ts. Do NOT add a second one. In particular: do not mutate a transport'srequestInit.headersper call (one hub-scoped connection serves every task, so that races), do not thread a headers argument down therunMcpToolCall→callToolchain into the SDK (the SDK'ssend()accepts no headers —StreamableHTTPClientTransportreads onlyresumptionToken/onresumptiontokenfrom request options), and do not put a credential in_metainstead (it is request metadata the server logs and forwards, not an authorization header). The resolver lives in a plugin because WHICH servers deserve a credential is deployment knowledge — the Core Self-Sufficiency Rule — and its answer is always optional: absent headers must leave the call byte-for-byte what it was. -
Model Per-Request Header Rule: The same rule, one seam over. A header that belongs to the RUN rather than to the host reaches a MODEL API through exactly one path — the
"resolve-model-call-headers"plugin broadcast, resolved once per request inbuildApiHandlerand carried to each provider client's customfetchby theAsyncLocalStorageinapi/call-headers.ts. Do NOT add a second one, and in particular do NOT resolve it inside a provider:buildApiHandleris the one layer every provider and every caller (the agent loop, the condenser, prompt enhancement) passes through, so a per-provider copy is both duplication and a silent gap for the next provider added. A new provider's only obligation is to passfetchWithModelCallHeaders(ornodeFetchWithModelCallHeaders, for an SDK still typed againstnode-fetch) to its client; a client that cannot take one carries no per-request headers, which is the pre-plugin behaviour, not a bug to work around by mutatingdefaultHeaders(one handler serves every task, so that races). An answered header may never authorize: the merge refuses the credential and transport names, and the sharedfetchsets only a header the request does not already carry, so the provider's own credential always wins. Keep both of those — they are why the question can safely name no endpoint. -
Plugin-Contributed MCP Servers Re-Sync Rule:
McpHubreadsgetContributedMcpServers()in its constructor, but the shared plugin manager is installed LAZILY byShoferProvider.getPluginManager()— usually afterwards — so that read is not what makes a plugin'scontributes.mcpServerschild spawn.refreshProjectMcpServers()is, and it MUST be called wherever the plugin set becomes known or changes: the Plugins panel path (resyncAfterPluginChange) and the manager-install path (buildPluginManager). A host with no Plugins panel —shofer serve, a headless worker — reaches neither.shofer/mcp.jsonedits nor workspace-folder changes, so a missing re-sync there fails as a permanent, silent absence: the plugin loads, its declared server never exists, and nothing logs. The manager-install re-sync waits foractivateCodePlugins(), and that ordering is load-bearing, not tidiness: activation awaits each plugin'sinitializeAND its services'start, which is where a plugin publishes the process env its own server's${env:…}is declared against — connect first and the child is handed the literal placeholder, silently losing whatever the variable carried. Do not "optimize" the re-sync back to firing as soon as the manager exists. (A server declared in a config FILE against a plugin's runtime env has no such guarantee and cannot be given one — config is read before any plugin runs; the manifest route is the answer.) New discovery-dependent subsystems (skills, modes) have the same shape; wire them at the same seam. -
MCP Per-Server Config Reads The CONNECTION Rule: a server's effective definition —
toolGroups,disabledTools, transport, url — isconnection.server.config, whatever scope produced it. Do NOT re-read the writablemcp.jsonfor it. Four scopes feed the hub (org-global$SHOFER_GLOBAL_DIR/mcp.json, user~/.shofer/mcp.json, project<workspace>/.shofer/mcp.json, and plugincontributes.mcpServers), and only two of them are files a lookup can open;fetchToolsListreading the user file alone dropped the groups of every server that reaches a WORKER, so their tools resolveduncategorizedand each call raised an approval ask no headless host can answer. The one legitimate file read is the USER-OVERRIDE layer on top —updateServerToolList/setToolGroupwrite there and immediately re-list, so that layer must still win. -
MCP Call-Site Indirection Rule: The canonical
mcpHub.callTool()/mcpHub.readResource()call site (threadingtask.abortSignal) is the shared helperrunMcpToolCallinuse-mcp-shared.ts, NOT the tool classes (UseMcpToolTool.ts,accessMcpResourceTool.ts) that delegate to it. Tool files adding their own MCP call sites MUST acceptsignal?: AbortSignaland fall back totask.abortSignal.
-
Dual Cancellation-Path Rule:
Taskhas two cancellation paths that MUST NOT be confused: (a)abortTask()— destructive tear-down (user Stop, budget "kill", stream failure): setsthis.abort = true, aborts_taskAbortController, disposes. (b)cancelAndProcessQueuedMessages()— soft-cancel for Send Now: sets_softCancelForQueuedMessage = true, aborts the old controller, waits for the loop to exit, then replaces the controller and restarts with the dequeued message. New cancellation code MUST pick a variant and follow its protocol. A third path that aborts_taskAbortControllerwithout setting_softCancelForQueuedMessage = truemakes the stream catch block callabortTask()→dispose(), destroying a task that should restart. SeeTask.ts. -
Abort-Ordering Invariant: In
abortTask(),this.abort = trueMUST be set BEFORE_taskAbortController.abort()so synchronous observers see the boolean first. The stream catch block checkstask.abortto decide whether to callabortTask(); reversing the order opens a window where the signal is aborted butthis.abortis stillfalse, so the catch block skipsabortTask()and leaves a zombie task. SeeTask.ts. -
Cooperative Cancellation Rule: Long-running async work (LLM calls, queue draining, watcher loops) MUST be cancellable via an
AbortSignalthreaded through the call stack — producer creates the controller, intermediate layers forward it, the bottom layer either checkssignal.abortedbetween iterations/chunks and throwsAbortError, or registers anabortlistener that rejects its in-flight promise. Do NOT use polled boolean flags or rely on GC of dropped promises. Seedocs/cancellation.md.
-
No Backward Compatibility Unless Asked: Persisted-state schema changes do not need migrations or compatibility shims unless the user explicitly requests them. Prefer clean, simple design over compatibility code. Bump the minor version (
YinX.Y.Z) when persisted state shape changes. -
Schema-First Persistence Rule: Any new persisted shape (file on disk,
globalStatevalue,secretsentry, IPC payload) MUST be defined as a Zod schema first — in@shofer/typesfor cross-boundary shapes or a co-located*.schema.tsfor service-local ones — and reads MUST go throughschema.safeParse(raw)so corrupt/partial/stale data fails closed (returnsundefined/empty, overwritten on next write) rather than throwing or propagatingany. Pair with the Versioned Snapshot Rule for top-level containers. -
Versioned Snapshot Rule: Persisted JSON snapshots MUST include an integer
versionfield. On load, a mismatched version discards the snapshot and returns an empty value (NOT a migration), per "No Backward Compatibility Unless Asked". Cached file contents inside a snapshot MUST be hash-validated (SHA-256 of current on-disk bytes vs storedcontentHash) and dropped on mismatch orENOENT. -
Module Boundaries Rule:
@shofer/typesis the single source of truth for cross-boundary schemas (asks, lifecycle events, telemetry events, settings keys, command ids, provider settings). Anything serialized over the webview ↔ host bridge, persisted toglobalState/secrets, or sent to telemetry MUST have its shape declared as a Zod schema inpackages/types/src/and consumed via inferredz.infer<>types — never a hand-written interface duplicated per consumer. -
Core Self-Sufficiency Rule: Shofer is a self-sufficient FOSS agent and MUST stay runnable standalone (VS Code,
shofer serve, CLI) with no external service.@shofer/coreand the extension host MUST NOT read/write a database, call a SaaS control plane, or otherwise couple to deployment-specific infrastructure — in particular nothing from thearkware.aiSaaS (itsuser-console, Postgres, buckets,saas.md) may be imported or assumed by core. Core communicates only over its own seams: theShoferApitransport, events,ContextProxystate/secrets, and the filesystem. A controller/host persists whatever it needs on its side of the ShoferApi — e.g. user-console records brokered asks in its ownuser_approvals/user_questionstables; core just brokers theaskover the wire and never learns those tables exist. Any glue a specific integration needs (SaaS audit persistence, a hosted telemetry sink, org-IdP identity, remote memory) MUST be a plugin — apackages/core/src/plugins/*lifecycle hook (beforeAsk,beforeToolCall,afterToolCall, system-prompt transforms) packaged likeplugins/live-memory/— never baked into core. SeePLUGINS.mdanddocs/plugin_system.md. -
Linked-Profile Re-Init Rule: Any service that resolves and caches values from a linked API Configuration profile (e.g. a model's
contextWindow) MUST be re-initialized on any profile mutation —saveApiConfiguration,upsertApiConfiguration,renameApiConfiguration,loadApiConfiguration,loadApiConfigurationById,deleteApiConfiguration— not only when the service's own settings change. Route this through a single re-init helper at the top ofwebviewMessageHandler.ts, called from both theupdateSettingsbranch and every profile-mutator case. Resolution helpers MUST throw on failure (handler build error, missinginfo.contextWindow) so the service goes toErrorstate — NOT silently substitute a default constant — and SHOULD capture resolved-source provenance and surface it to the user so cache-vs-source mismatches are debuggable.
Webview-internal rules (ChatView send-path and draft snapshots, Radix trigger
primitives) live in webview-ui/AGENTS.md. The rules
below stay here because they bind BOTH sides of the webview↔host bridge.
-
Webview Message Routing Rule: All webview → host messages MUST be a typed
WebviewMessagevariant (in@shofer/types) dispatched from the centralwebviewMessageHandler. Do NOT add ad-hoc-shapedvscode.postMessage(...)from components, and do NOT branch onmessage.typeoutside the handler. When a case group grows large or pulls a heavy dependency, extract a sibling*MessageHandler.tsmodule (cf.skillsMessageHandler.ts) and call into it. A plugin's UI never gets IPC message types at all — it talks to its extension half over the plugin UI channel (api.request), which is why the worktree, checkpoint and changed-file messages are gone. Symmetrically, host → webview messages MUST be a typedExtensionMessagevariant. -
Exhaustive Switch Rule: Switches over discriminated unions in
@shofer/types(WebviewMessage["type"],ShoferAsk,ShoferSay,ToolName,TaskLifecycle,TelemetryEventName) MUST end with adefault:branch assigning the discriminant to anever-typed local (orassertNever(x)). This turns "forgot to handle the new variant" into a compile error. Do NOT useif (type === "a" || type === "b") … else { /* assume third */ }for unions of more than two variants. -
Shared Module Isolation Rule: Anything under
src/shared/is imported by BOTH the extension host AND thewebview-ui/bundle (via the@shofer/shared/*alias →../src/shared). These modules MUST NOT staticallyimporthost-only APIs —vscode, Node built-ins (fs,path,child_process, …), or host services (ContextProxy,TaskManager, output channels). The webview bundler can't resolvevscode; the module load throws silently and kills the React tree at startup (symptom:resolveWebviewViewsucceeds, HTML is assigned, butWelcomeView/ChatView/SettingsViewnever mount, host logs look normal). If a helper needsvscode, put it in a host-only dir and import from there — cf.syncExperimentContextKeysinsrc/activate/experimentContextKeys.ts. -
i18n String Rule: User-facing strings MUST come from locale files. In the webview,
const { t } = useAppTranslation()andt("namespace.key"); add the key first towebview-ui/src/i18n/locales/en/<namespace>.json(English is the source of truth; other locales sync from it). In extension-host code,import { t } from "./i18n"and add tosrc/i18n/locales/<lang>/. Hard-coded English in JSX orvscode.window.showXxxMessage(...)is a bug.
-
Output Channel Logging Rule: Long-form/debug logging from extension code MUST go through the shared output-channel logger (
outputChannelLogger.ts), obtained viagetOutputChannel()insrc/extension.tsand wrapped withcreateOutputChannelLogger(orcreateDualLogger). Do NOT use bareconsole.login extension-host code — the user can't see it in VS Code and log scrapers can't attribute it. Webview code MUST NOT useconsole.*either — route diagnostics back to the host viavscode.postMessageso they hit the shared channel. -
Circular-Import Lazy Logger Rule: A service file that cannot statically import
getOutputChannel()fromsrc/extension.tswithout a require-cycle (becauseextension.tstransitively imports it) MUST use a lazy dynamic import:void import("…/extension").then(({ getOutputChannel }) => { const ch = getOutputChannel(); if (!ch) return; ch.appendLine(…) }).catch(() => {}). Do NOT substituteconsole.log— that violates the Output Channel Logging Rule. See thelog()helper ingit-watcher.ts. -
Telemetry Capture Rule: All telemetry MUST go through
TelemetryService.instance.captureXxx(...)typed methods inpackages/telemetry/src/TelemetryService.ts. New event kinds go inTelemetryEventNameinpackages/types/src/telemetry.tsand get a typedcaptureXxxwrapper — do NOT pass raw event-name strings tocaptureEventor instantiateposthog/posthog-nodedirectly. Telemetry is opt-in behind BOTH theTELEMETRY_ENABLEDbuild flag and the user'sTelemetrySetting; do not bypass either. Seedocs/telemetry.md. -
Telemetry Toggle Ordering Rule: When
telemetrySettingchanges, firecaptureTelemetrySettingsChanged(prev, next)BEFOREupdateTelemetryState(false)on toggle-OFF, and AFTERupdateTelemetryState(true)on toggle-ON, so the change event is captured under the still-enabled side. Enforced bytelemetrySettingsTracking.spec.ts.
-
File Change Tracking Pattern: Any new native tool that modifies workspace files MUST call
task.fileContextTracker.captureOriginal(relPath, content)before mutation andtask.fileContextTracker.trackFileContext(relPath, "shofer_edited")after. Neither stores anything in core — they publish the file edit to thebeforeFileEdit/afterFileEditplugin hooks, which is how the bundledbasicsplugin's file-changes feature gets the baseline that makes diff and revert possible. Without both, the file is missing from the panel (capture only) or shows with diffing disabled (track only). Tools usingDiffViewProviderget this automatically; tools doing direct filesystem writes (insert_edit,sed,rename_symbol,filefor rm/mv,generate_image) must do it manually. Seeplugins/basics/docs/file-changes.md. -
Tree-Sitter Language Registration Rule: When adding a file extension to
CODEBASE_INDEX_FILE_EXTENSIONSinpackages/types/src/codebase-index.ts, add a matchingcaseinloadRequiredLanguageParsersinlanguageParser.ts. Without it,parseContentthrows"Unsupported language: <ext>"— silently dropping files from the RAG index (the bundledrag-indexingplugin) and crashinglist_code_definition_names. Extensions infallbackExtensions(supported-extensions.ts) early-return before the parser switch, making any case for them dead code. Language queries inpackages/core/src/services/tree-sitter/queries/*.tsusing#match?/#eq?MUST be validated against real code, not only fixtures. -
Indexer Policy Single-Source-of-Truth Rule: The indexer's static policy lists —
CODEBASE_INDEX_FILE_EXTENSIONSandCODEBASE_INDEX_IGNORED_DIRSincodebase-index.ts— are the sole definitions, and they stay in@shofer/typeseven though the indexer itself is a plugin: the glob service and tree-sitter (glob/constants.ts,tree-sitter/index.ts) re-export rather than redeclare them. New entries belong in@shofer/types; never inline a literal list at a call site. -
Indexer-internal rules (the
IIgnoreFiltergitignore oracle, the sole-indexer/search-only contract, index identity via_resolveIndexKeyPath, per-provider embedder concurrency lanes, submodule-aware git scanning) live inplugins/rag-indexing/AGENTS.md. -
Headless Hosts Run The Extension Rule: Do NOT assume a headless host skips extension activation.
shofer serveloads the sameextension.jsbundle viaExtensionHost.activate(), so a served host DOES register the code-index/git-index factories and construct aCodeIndexManager. Features are gated by explicit config (e.g. the rag-indexing plugin'ssearchOnly/enabledsettings), never by "the factory is unset on headless". Comments or docs claiming a feature "degrades to off on headless hosts" are wrong and have been corrected once already.
-
Skill Name Validation Rule: Skill names MUST pass
validateSkillName()from@shofer/types(regex/^[a-z0-9]+(-[a-z0-9]+)*$/, max 64 chars), enforced in bothSkillsManager.loadSkillMetadata()(discovery-time) andSkillsManager.createSkill()(creation-time) inSkillsManager.ts. Use the shared function; do NOT write a second regex or inline check. -
SkillsManager Public API Rule:
SkillsManagerexposes four method categories that MUST NOT be confused: (a) discovery —discoverSkills,getSkillsMetadata,getSkillsForMode,getSkillContent; (b) lifecycle —createSkill,deleteSkill,moveSkill; (c) file watching —setupFileWatchers; (d) internal —scanSkillsDirectory,loadSkillMetadata,validateSkillName,getSkillNameErrorMessage. A new public method needs a matchingdocs/skills.mdentry; a private helper does not. -
Skills File-Name Collision Warning: Three files are named
skills.ts—packages/types/src/skills.ts,packages/core/src/prompts/sections/skills.ts, andpackages/core/src/prompts/tools/native-tools/skills.ts. Disambiguate in comments/docs ("skills (types)", "skills (prompt)", "skills (tool)"). Do not add a fourth — choose a distinct name.
-
Task Placement Rule: Core does NOT decide where a task runs — neither the directory nor the machine. Two sibling broadcasts ask, in that order, and both take the first concrete answer:
resolveTaskCwd(resolveTaskCwd.ts) broadcasts"resolve-task-cwd"(the bundledbasicsplugin's worktrees feature,plugins/basics/src/worktrees/, answers with a per-task git worktree), andresolveTaskPlacement(src/core/webview/resolveTaskPlacement.ts) broadcasts"resolve-task-placement", which a dispatcher plugin may CLAIM by answering{ dispatched: { taskId, address?, token? } }— the task was created on another host, so core creates none and attaches to the reference instead (TaskAttachmentManager). For BOTH, a plugin that recognised the question and FAILED returns{ error }(a throw means "not my question", which would silently fall back to running here — the outcome placement exists to prevent), and the caller aborts task creation; nobody answering leaves the in-process path byte-for-byte unchanged. Do not reintroduce a worktree- or worker-shaped field onnewTask: the choice is plugin state, read at creation time. -
Singleton Re-export Rule: When a service class has no per-instance state beyond method args, co-locate a pre-constructed instance export at the definition site (e.g.
export const worktreeService = new WorktreeService()inplugins/basics/src/worktrees/worktree-service.ts) and do not instantiate the class externally. -
Export Schema Co-Evolution Rule: The JSON export subsystem (
export-json.ts) reads per-call metadata fromapi_req_startedShoferMessages whose payload (ShoferApiReqInfo) is written inTask.tsand parsed asUiApiReqStartedPayload. The three representations —ShoferApiReqInfo(write),UiApiReqStartedPayload(read),JsonExportCall(output) — MUST stay in lock-step; adding a write-side field without matching read + output keys silently drops it from exports. Seedocs/task-export.md. -
Wire-Capture Anchor Rule:
snapshotWireRequest()andsnapshotApiReqError()inTask.tsusefindLastIndexto locate the most recentapi_req_startedShoferMessage for in-place mutation. Ordering invariant: (1) emitapi_req_started, (2) optionallysnapshotWireRequest()/snapshotApiReqError(), (3)this.api.createMessage(). Inserting new ShoferMessages between steps 1 and 2 redirects the wire request/error merge into the wrong entry, corrupting exports. -
Export Resilient-Read Rule: Code reading persisted task JSON (
ui_messages.json,api_conversation_history.json,history_item.json) MUST guard missing files and parse failures:fs.statexistence check,try/catcharoundJSON.parse, fallback to an empty/zeroed default. Failing closed preserves partial export; throwing loses the whole export. SeeShoferProvider.exportTaskWithIdJsonand the Schema-First Persistence Rule.
- Protected Files Rule: Sensitive workspace config (anything in
.shofer/,.vscode/,AGENTS.md,*.code-workspace) is governed byPROTECTED_PATTERNSinShoferProtectedController.ts. New sensitive paths go there, and tool write-paths gate onisWriteProtected(relPath)rather than special-casing filenames.SHIELD_SYMBOLis the single UI marker for a protected target — no per-tool icons. Seedocs/shofer_special_files.md. (Note: the rootCLAUDE.mdis now a symlink toAGENTS.md; consider addingCLAUDE.mdtoPROTECTED_PATTERNS.)
-
Lazy Service Singleton Rule: Services with non-trivial initialization (
CodeIndexManager,TaskManager,TelemetryService, …) MUST exposestatic getInstance(context, cwd?)and be obtained through it — nevernew XxxManager(...)ad-hoc from a tool, command handler, or message handler. Modules needing them in hot paths that would otherwise cycle at activation MUST use dynamicawait import(...)at the call site (see the imports insidebuildNativeToolsArrayWithRestrictionsinbuild-tools.ts). Top-level static imports of those managers frombuild-tools.tsreintroduce the activation-order bug. -
Service Module-Split Rule: A service file that grows past ~500–700 lines and accretes orthogonal concerns (persistence, queueing, transport, accounting, watching) MUST be split into focused single-responsibility modules with a thin orchestrator owning composition + lifecycle and re-exporting the public API. Each module should be importable/testable without
vscodemocks where possible. -
Shared
ApiHandlerRule: Any service that talks to an LLM MUST go throughbuildApiHandler(providerSettings, { taskId })inapi/index.tsand consume the resultingApiHandlerinterface — never instantiate provider SDKs (@anthropic-ai/sdk,openai,@google/genai,ollama, …) directly and never write bespoke per-providerfetch(). The shared handler gives streaming, abort handling, the full provider catalog, model info/pricing, telemetry, and retries. Depend only onApiHandler+ApiStreamchunk types so a new provider becomes available everywhere by editing the provider switch in one place. Seedocs/cost-calculation-and-limits.md. -
Provider Reasoning-Preservation Rule: A provider whose models set
preserveReasoning: truein theirModelInfo(Moonshot/Kimi K2.x/K3, DeepSeek-reasoner, Z.ai GLM-4.7+, Anthropic extended-thinking) MUST use a message converter that preserves the provider's native reasoning field (reasoning_contentfor OpenAI-compatible,thinkingblocks for Anthropic) on every assistant message sent back to the API. The genericconvertToAiSdkMessages()inai-sdk.tsdoes NOT handle{type: "reasoning"}content blocks — it silently drops them, which forces the model to re-derive all prior reasoning from scratch on every tool-call round-trip, burning quota and invalidating the server-side prefix cache. Providers withpreserveReasoningMUST either (a) useBaseOpenAiCompatibleProvider(base-openai-compatible-provider.ts) with a dedicatedconvertTo<Provider>Format()that extracts reasoning from content blocks into the native field (cf.convertToMoonshotFormat,convertToZAiFormat,convertToR1Format), or (b) use a raw-SDK handler that round-trips reasoning natively. The Vercel AI SDK path (OpenAICompatibleHandler) is only safe for providers that do NOT preserve reasoning. When adding a new model to an existing provider, verify the model'spreserveReasoningflag and confirm the handler's message converter handles it — a model that starts preserving reasoning under a converter that drops it is a silent quota-burn bug.
- Split-Compiler Rule: This workspace runs two TypeScript compilers on
purpose. Type-checking (every
check-typesscript) uses the TypeScript 7 native compiler —tsgo, from the@typescript/native-previewdevDep — for the ~5× speedup. The library / API / emit path stays on TypeScript 6 (typescript@6.0.3,.bin/tsc): typescript-eslint, tsup's.d.tsgenerator, zod-to-ts, and@shofer/build'stscemit all need the classic compiler API, which TS 7 does not ship. So: acheck-typesscript invokestsgo; abuildscript that emits invokestsc(TS 6). Do not "upgrade"typescriptto 7 or renametsgo→tscincheck-types—typescript@6.0.3being pinned is correct, not stale (see the toolchain note inTODO.md). Two tsup packages (@shofer/types,@shofer/cli) carryignoreDeprecations: "6.0"because tsup injects abaseUrlwe don't own; that is scoped to them deliberately — app tsconfigs droppedbaseUrloutright.
- Test Layout Rule: Tests live in
__tests__/folders adjacent to the source they cover. Vitest is configured withglobals: trueeverywhere, so do NOT importdescribe/it/expect/vifromvitest. Naming: extension-host and CLI use*.test.ts(x)(Node env); the webview uses*.spec.ts(x)(jsdom env, with the sharedwebview-ui/vitest.setup.tsand thevscodemock atwebview-ui/src/__mocks__/vscode.ts). Test verbosity is centralized viavitest-verbosity.ts— silent-by-default with--no-silent; never wire ad-hocconsole.logfiltering per package. See.shofer/commands/test.mdfor the per-package run recipe (neverpnpm test/turbo test).
- Do not push to upstream (remote) git repo (in github). The user will do that. One reason for this is that git push is slow due to many test cases that it runs.
-
No Cross-Repo Link Rule: This repository is FOSS with a public remote, and it is consumed as a submodule of a private integrator repo. A link that climbs out of this repo (
](../../../…),](../../../../…)) resolves only in that private checkout and is broken for everyone who clones Shofer — so it MUST NOT appear in any doc here. This is not a path to correct; it is a reference to remove. Rewrite the sentence so it stands on its own, name the external thing in prose without linking it, or link an in-repo doc that covers the same ground. The two directories are the giveaway:plugins/in this repo holds PUBLIC plugins; an integrator's private plugins live in its own tree (for arkware.ai, the parent'sshofer-plugins/, which holdstemporal-worker,shofer-mesh,arkware-basics). A doc here referencing../plugins/temporal-worker/DESIGN.mdwas therefore not a typo for some correct path — no path from this repo reaches it, and the reference itself was the defect. Referencing in the other direction is fine: a private doc may link freely into this public repo. Naming a private component as an example is acceptable; making a doc here depend on one — a link, or an explanation that only parses if the reader has it — is the Core Self-Sufficiency Rule violation in documentation form. The same boundary governs secrets: a credential must never be committed here (see the rootCLAUDE.md§ Secrets in the parent). -
Documentation-Code Coherence Rule: Docs describe the current state only (no changelogs/history) and every named entity a doc references — file paths, function/class/interface/enum/type/constant names, config keys, VS Code command IDs, function signatures, interface field order, enum/union membership, quoted file-content literals, error-message text, item counts, and configuration defaults — MUST match the actual source at the time of writing. Verify with
rg/ file listing before publishing; prefer stable symbol names over line numbers (line numbers drift). Never fabricate error messages or "example output", never document dead code (a class with zero imports) as active, never state an architecture the code doesn't have, and when listing a subset of an enum qualify it ("core states", "including"). A broken path, phantom symbol, wrong default, wrong namespace (shofer.*vs the actualarkware.*), stale count, or invented behavior is a bug that wastes developer and agent time. (This single rule subsumes the many per-review doc-coherence findings that used to be listed separately.) -
Documentation Lock-Step Rule: When you change a subsystem, update its companion doc in the same change. The pairings:
Change Update in lock-step native tool added/renamed/reparametrized/regrouped docs/native_tools.md,toolDescription()inpresentAssistantMessage.ts,docs/terminology.md§9TOOL_GROUPS/ALWAYS_AVAILABLE_TOOLSintool.ts, or the built-in modes inplugins/builtin-config/plugin.jsondocs/tool-categories.md,plugins/builtin-config/docs/modes.mdunconditional/toggle-gated tool in checkAutoApprovaldocs/auto_approval.mdMessageQueueServicepublic method /QueueEventsvariantdocs/message_queue.md+QueuedMessages.tsxif it emits a webview eventnew TaskLifecycle/@shofer/typestypedocs/terminology.md§7/§12skills subsystem ( SkillsManager,SkillsTool,skillsMessageHandler, …)docs/skills.mdnew native-tool plumbing touch-point discovered the checklist AND "Gaps" section of docs/adding-new-tools.mdDoc-only changes do NOT require a version bump; parameter-shape changes do (bump minor Y). -
Dead Config/Code Rule: A
contributes.configuration.propertiesentry insrc/package.jsonwith zero references across the TypeScript source is dead — it shows in the settings UI but does nothing. Either wire it up or remove it; likewise remove or accurately label ("defined but not yet wired") any class/file with zero imports rather than documenting it as active.