diff --git a/.gitignore b/.gitignore index 4441bf82d..af38d1bef 100644 --- a/.gitignore +++ b/.gitignore @@ -19,9 +19,10 @@ tmp/ .tmp* worktree worktrees -dispatch/ +/dispatch/ evals/capability/results/* !evals/capability/results/baseline-0286.json +evals/public/results/* local-harness-bench/ subagents/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a466952e9..1d6fd518a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,105 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain parallel copies under `docs/` or `scripts/notes/`. At cut time: rename `## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script. +## [0.2.99] - 2026-08-21 + +Skywalker is the primary orchestrator over a closed director fleet: product write tools stay off the primary, and you cannot spawn Skywalker as a task leaf. Workers are not done until they return the four-heading report. First-party action skills ship as slashes; eval runners require an explicit provider/model pair; the style skill no longer refuses non-git folders. + +### MCP + +- **Late-connected MCP tools are callable the same turn they appear in `tool_search`.** + `@intx/agent` snapshots dispatch names at `createAgent`, and the post-connect + reload that used to rebuild that snapshot waited for every server — including + one stuck on OAuth. Cataloged `mcp__*` tools then returned `unknown tool`. + Construction now dispatches misses through the live runner, so Linear/Exa + (and any other server that finished) work even while another server still + needs auth. + +### TUI + +- **Model picker rows are model-first.** Each leaf is `model * [provider]`; + `(current)` still marks the live session model. **Alt+D** persists the + focused pair as the default (global `defaultProvider` + provider + `defaultModel` + project-local selection) without switching the live + session or closing the picker. + +- **Settled permission and operator prompts no longer recap into the chat.** + The overlay is the question; answering it used to leave a grey + `permission` / `operator` card restating the same command and the chosen + option. After a decision those recap rows are gone — the tool row that + follows is the outcome. Expanding a collapsed payload while the overlay is + still open still writes the full payload into the transcript, because that + text would otherwise be unreachable before approval. + +### Directors + +- **Shipped directors have no writePaths lock.** Docs/design leaves + (shakespeare, brand-reviewer, bruckheimer) still mount write tools, but + package `writePaths` is omitted. Lane routing (P/A/I, DESIGN.md, product + discovery) is spawn policy, not a file lock. Optional `writePaths` remains + and the permission gate still enforces it when a profile sets it. +- **Skywalker is not a task leaf.** `task(agent=skywalker)` is refused. The + spawn catalog (`directorProfiles()`) lists the other 15 closed directors; + the primary session is still Skywalker. + +### Sub-agents + +- **Tool-less mid-run narration is not a finished report.** A worker that + stops tooling with Summary-only (or other incomplete) prose is not + complete. The director injects one wrap-up nudge asking for the four + headings (Summary / Findings / Blockers / Paths), then salvages as + incomplete-report if the next tool-less turn is still missing the envelope. + +### Plugins + +- **First-party skills catalog is on out of the gate.** `corbits-skills` + ships action slashes `/implement`, `/plan`, `/refactor`, `/review` (was + `/code-review`), `/pull-request-review`, `/create-issue` (was + `/linear-create`), `/scribe`, `/interview`, `/ast-grep`. Dispatch, + git-rebase, linear-issue-workflow, style, philosophy, typescript, and + opsh stay `use_skill` only (`user-invocable: false`). Draper and emil + are not skills or slashes — closed directors via `task(agent=…)` only. + `/plan` is the eng change-plan recipe (`task(agent="plan")`; does not + implement or file tracker issues). `/create-issue` remains the tracker + command: Linear MCP when available; otherwise `ask_operator` for the + platform and persists `Preferred issue tracker` in `.corbits/MEMORY.md` + (GitHub via `gh issue create`). Each recipe tells Skywalker to spawn + closed directors — the operator types the slash; the primary does not + do the work. Turn the catalog off in `/plugins` if you want those + commands gone. +- **Style skill no longer refuses non-git folders.** Edits, tests, and + reports are allowed without a repository. Do not `git init` unless + asked. Commits, amends, rebases, and isolated worktree dispatch still + require an existing repo. + +### Evals + +- **Capability eval workdirs are git repos.** After copying the fixture + and seeding skill stubs, the runner initializes the tmp workdir (`git + init`, `git add -A`, one unsigned hermetic `eval fixture` commit) so isolated + workers have HEAD and git-aware skills have a baseline. +- **Eval runners require an explicit model pair.** `eval:capability` and + `eval:public-swe-one` take `--provider` / `--model` (capability also + accepts `--matrix` with complete cells) so local `.corbits/settings.json` + is not the implicit target. +- **Capability eval records `task` tool calls.** `taskToolCallCount` is derived + from the turn stream (informational). Older result files without the field + default from `toolCallsByName.task` so the frozen baseline still parses. +- **Capability eval smoke cases for dispatch and recall.** `complex-dispatch-spawn` + requires at least one `task()` plus a working GET /readyz. + `complex-recall-after-bulk-read` plants a token, asks the agent to read the + fixture, then write it back. Informational only — not in the frozen + baseline-0286 gate until a deliberate refreeze. Neither case proves + compaction fired or that the primary skipped implementing the route. + +### Session + +- **Resume is keyed to this checkout's git toplevel.** Linked worktrees no + longer share (or list) each other's sessions — `--git-common-dir` made + every worktree show every other worktree's history. Sessions previously + created from a worktree remain under the main checkout key; resume from + the main path to recover them. + ## [0.2.98] - 2026-08-17 Corrupt resume state no longer kills sessions, Codex quota errors name the @@ -39,6 +138,102 @@ mid-session switches. worker failing until a restart. Provider, model catalog, and settings are now read live at spawn time, so tier settings written mid-session are visible too. +### Breaking + +- **Single-agent session mode is gone.** The primary session is always + orchestrator-capable (`task` / `search_agents` always available). The first-run + mode picker and Settings → Session rows are removed. Legacy `sessionMode` in + settings files still loads without error and is ignored (CL-5814). +- **Dual-column fleet rail removed.** TUI geometry is stack-only forever + (`layoutMode: "stack"`, `railWidth: 0`). `DUAL_MIN_COLUMNS` / `RAIL_WIDTH_*` + constants and dual absolute-positioning of the agents box are gone. Live + fleet status remains `● Task` transcript rows; the agents chrome zone stays + empty. + +### Fixed + +- **Parent live reasoning no longer sideways-scrolls.** Streaming thinking used + a one-line marquee onto the newest tokens. It now paints a short wrapped + preview (up to three inset lines of the newest revealed prose); expand still + opens the full block. Sub-agent Task-row thinking is unchanged. +- **Skywalker dig fleets cascading into stalled Task floods.** "Why stalled / + why no thinking / spawn looks broken" asks were reclassified as orchestration + and fanned into parallel explore waves; Grok leaves then sat quiet mid-think + or looped, which looked like spawn failure and invited another dig wave. + Skywalker now hard-caps concurrent leaves at 4, classifies digs/screenshots/ + why-how as COMMUNICATION (answer or one explore leaf), and forbids re-fan-out + diagnostic waves when leaves stall or salvage. +- **Grok sub-agent stall false positives.** Live fleets on grok-4.6 show routine + 60–180s gaps between tool cycles while the model thinks. UI stall paint and + Grok's salvage kill were far shorter, so healthy thinking looked hung and got + nudged/stopped mid-inference. `DEFAULT_STALL_MS` and parent `STALL_NOTICE_MS` + are both 300s (aligned with the 5-minute `subAgentStallTimeoutMs`). The + grok-responses path asks for `reasoning.summary: "detailed"` so summary + deltas keep the activity clock moving (auto summaries were tiny vs billed + thinking tokens). +- **Live fleet status is `● Task` transcript rows again.** The FLEET board / + dual-rail agents chrome restated the same workers above chat and made + progress hard to read. `task` calls paint live rows (clock + current tool) + via `syncAgentProgress`; `formatChromeZones` keeps the agents zone empty and + suppresses the manage_tasks checklist while any lane is running. +- **`web_fetch` / `web_search` always advertised.** They were registered but only + discoverable via `tool_search`, so strict providers (and thrashy models) never + saw them on the wire despite Skywalker saying they were mounted. Both are now + in `CATALOG_TOOL_NAMES`. Capability `web-bait` hard-requires + `webFetchToolCallCount >= 1` via `requireBehaviors`. + +### Added + +- **Public SWE-bench one-shot smoke.** `bun run eval:public-swe-one` runs Corbits + product exec on a single SWE-bench Lite instance (default + `psf__requests-3362`), taking `--provider` and `--model` on the CLI, and + writes `preds.jsonl` under `evals/public/results/`. Official Docker + resolved/not-resolved grading stays optional/manual. +- **Capability eval: `complex-stock-gate`.** Multi-file stock-gated `POST /orders` + (404/409/201 + stock decrement) on the demo-comparison fixture; sync API grader. +- **Capability eval: `complex-idempotent-orders`.** Header-driven Idempotency-Key + on POST /orders (201/200/409) with multi-file order store; sync API grader. +- **Capability eval: `complex-bugfix`.** SWE-bench-style issue→patch→tests on the + new `tests/fixtures/buggy-service` fixture (intentional post GET bug; users green). +- **Capability eval: `complex-pagination`.** Query `limit`/`offset` on GET /products + (demo-comparison); sync Response grader + slice semantics. +- **Capability eval: `complex-rename-user`.** Cross-file rename of user `name` → + `displayName` on multi-file-service; runtime + source checks. +- **Fixture: `tests/fixtures/buggy-service`.** multi-file-service clone with a + deliberate post-route defect for bugfix capability evals. +- **Director API-contract loop (launch tuning iter1).** Implement preserves sync + public surfaces; critique ranks sync→async signature drift as blocking; + Skywalker puts stated signatures into success_criteria, skips explore/critique + on tiny green ships, re-dispatches implement on blocking critique findings, + and routes URL reads through mounted `web_fetch` (no shell thrash). complex-jwt + case prompt states the sync Response contract. + + +- **Closed director fleet (CL-5818 Level 6 wiring).** Sixteen director packages + under `src/agent/directors//` (prompts, tool envelopes, spawn rights, nudge + budgets, report contract) register in `DIRECTOR_REGISTRY`. `task(agent=…)` + resolves directors without requiring plugin profiles; `task(intent=…)` maps + implement/explore/plan/review→critique (`general` is refused). Default agent + profiles are the closed fleet via `directorProfiles()`. +- **Primary is Skywalker by name.** System role answers "Skywalker"; agent id + `skywalker`. Product mutation tools are not mounted on the primary session + (structural never-implement), not only prompt policy. +- **Director identity at spawn.** Every package system prompt is prefixed with + agent id, model role, and optional skills; profiles include `agent id:` in + description so search_agents / re-spawn are unambiguous. +- **modelRole drives leaf effort.** Spawn effort cascade is pin → package + modelRole default (intern=low) → orchestrator/leaf → parent. Env block adds + arch + runtime alongside platform/date/git. +- **No general leaf at the wire.** Bare `task` (no `agent`, no `intent`) and + `intent=general` fail closed; nested directors enforce `spawn.allowlist` + (greybeard → intern/explore/critique). +- **Director write-path locks.** Docs/design packages may write only under + package `writePaths` (shakespeare: PRODUCT/ARCHITECTURE/IMPLEMENTATION; + brand-reviewer: DESIGN.md; bruckheimer: PRODUCT.md + docs/*), enforced in + the permission gate. +- **PRODUCT / ARCHITECTURE / IMPLEMENTATION** document the closed director + fleet, spawn matrix, intent map, and tool envelopes. + ### Providers - **Named API-key instances.** First-class API-key providers (OpenAI key, @@ -59,6 +254,10 @@ mid-session switches. first-class kinds, so free-form OpenAI-compatible endpoints are reachable from the model picker without dropping into onboarding. Custom still uses the full manual form (name, base URL, key, model). +- **MCP auth is `mcp !` on the prompt box.** A server waiting on authorization + no longer takes a notice-row sentence (`mcp granola needs auth (/mcp)`). The + top rule carries a compact `mcp !` immediately left of the model label; + `/mcp` still names the servers. ### Docs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6521c665e..1c950f0a9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -106,7 +106,7 @@ In TUI chat mode there is no completion gate — the session stays open across t Two directors, selected by role: - **ChatDirector** (interactive, `src/agent/director.ts`) — Extends `DefaultDirector` with task list tracking, workflow nudges, LSP auto-activation, and multi-turn chat semantics. It never terminates the session: operator declines are surfaced as replies and the reactor stays alive for the next message. Auto mode is toggled by CLI flags (`--auto` / `--no-auto`); there is currently no in-session key to toggle it (default on; constrained envelope — workspace writes and unconstrained shell auto-allow; installs, recursive rm, force/uncontained worktree changes, sensitive-path and opaque-wrapper shell still ask; contained non-force `git worktree add`/`remove`/`prune` and `list` auto-allow; shell file-mutation denied). It is not a separate edit/plan mode. -- **SubAgentDirector** (delegated work, `src/subagent/index.ts`) — Drives a dispatched worker until a turn arrives with no tool calls, then replies with the final assistant text and ends the run. A tool-less completion with **zero tool calls in the entire run** is returned as a **never-acted** salvage report (not a successful implement). When `task(intent="implement")` is set, a tool-using run that never wrote/edited/deleted a file is returned as **never-edited** instead of complete — so a pure-explore "plan" cannot look shipped to the parent (tracked via `thrashState.editedPaths` from `edit_file` / `write_file` / `delete_file`). Explore/read-only workers that used tools then replied with findings remain normal completes. Hard stops also fire after 2 consecutive identical tool-call fingerprints (**no-progress**), on progressive re-read pressure (**thrash** — the same path re-read past a limit amid enough tool volume, tracked by `src/subagent/thrash.ts`), or after the leaf turn budget (**turn-budget**, default 30, overridable via `task(maxTurns)`, agent profile `maxTurns`, or `settings.subagentMaxTurns`, capped at 100), each returning a structured salvage report (reason, partial findings, blockers) so a thrashing child cannot burn tokens indefinitely. Before hard thrash, a one-shot **re-read-nudge** fires when re-read pressure crosses a soft threshold (default 3 same-path reads with enough tool volume, still below the hard re-read limit of 4): the director injects an ephemeral redirect — implement leaves are asked to edit or wrap up; explore leaves are asked to expand findings / change approach / report, never forced into edit — then keeps running so hard thrash remains reachable if the leaf ignores it. A fourth hard stop, **repetition**, is detected outside the director entirely: +- **SubAgentDirector** (delegated work, `src/subagent/index.ts`) — Drives a dispatched worker until a turn arrives with no tool calls, then replies with the final assistant text and ends the run. A tool-less turn **after tools** completes only with the four-heading envelope (Summary, Findings, Blockers, Paths); a missing envelope nudges once then salvages as **incomplete-report**. A tool-less completion with **zero tool calls in the entire run** is returned as a **never-acted** salvage report (not a successful implement). When `task(intent="implement")` is set, a tool-using run that never wrote/edited/deleted a file is returned as **never-edited** instead of complete — so a pure-explore "plan" cannot look shipped to the parent (tracked via `thrashState.editedPaths` from `edit_file` / `write_file` / `delete_file`). Explore/read-only workers that used tools then replied with findings remain normal completes. Hard stops also fire after 2 consecutive identical tool-call fingerprints (**no-progress**), on progressive re-read pressure (**thrash** — the same path re-read past a limit amid enough tool volume, tracked by `src/subagent/thrash.ts`), or after the leaf turn budget (**turn-budget**, default 30, overridable via `task(maxTurns)`, agent profile `maxTurns`, or `settings.subagentMaxTurns`, capped at 100), each returning a structured salvage report (reason, partial findings, blockers) so a thrashing child cannot burn tokens indefinitely. Before hard thrash, a one-shot **re-read-nudge** fires when re-read pressure crosses a soft threshold (default 3 same-path reads with enough tool volume, still below the hard re-read limit of 4): the director injects an ephemeral redirect — implement leaves are asked to edit or wrap up; explore leaves are asked to expand findings / change approach / report, never forced into edit — then keeps running so hard thrash remains reachable if the leaf ignores it. A fourth hard stop, **repetition**, is detected outside the director entirely: `runSubAgent`'s stream sink watches the streamed text of the in-flight cycle for degenerate token loops (`src/subagent/repetition.ts`) — format chars (ZWSP, BOM, bidi marks, soft hyphen, …) stripped then whitespace-collapsed raw text, a smallest-period KMP check over the probe tail, default window >= 16 chars repeated >= 8 times, evaluated every 256 streamed chars — and on a hit aborts the run controller mid-cycle, returning a `repetition` salvage report that leads with the looped window and warns the parent against re-dispatching the identical brief. Because directors only see completed turns, this is the only stop that can catch a loop inside a single turn that never finishes. A one-shot **report-forced** signal fires a few turns before the cap while the leaf is still tooling — it is not a stop: the director injects a wrap-up nudge and lets the leaf finish on its own, so turn-budget stays reachable for a leaf still making progress. When both report-forced and re-read-nudge apply, report-forced wins (near-budget wrap-up is more urgent than a mid-run redirect). Operator/parent cancel after any progress likewise returns a **cancelled** salvage report (partial findings + tool activity) instead of a bare cancel string; cancel before progress still surfaces as cancelled-by-operator. Optional `task(tier=)` (`fast` | `standard` | `clever`) overrides profile inference, profile tier, and the parent provider for that spawn only, and fails closed when the tier is unconfigured. The parent `task` tool keeps a session-scoped brief-dispatch ledger (`src/subagent/brief-dispatch.ts`): fingerprints cover prompt + agent + intent + success_criteria + do_not (not maxTurns/description/tier). After thrash / no-progress / repetition / never-acted / never-edited salvage, an identical re-dispatch is hard-blocked for the rest of the parent chat; change at least one fingerprint field to force a re-run. Turn-budget salvage still invites a higher maxTurns for a few same-brief retries without a successful complete, then flips the parent hint to stop and change approach (soft — further identical dispatches are still admitted). A successful complete resets the same-brief retry budget. @@ -209,7 +209,7 @@ Workflows are named, ordered recipes the agent follows step by step — a thin l - `coordinator.ts` — bridges runtime and director: produces the `[WORKFLOW STEP i/total: label]` directive injected into each turn's system prompt, and advances the runtime when `advance_workflow` (or a `submit_output` tagged `{ step }`) completes. Shared by both directors. - The built-in recipes: the atomics `update-ticket`, `improve-docs`, `write-tests`, `triage-bug`, `code-review`, `scope-project`, and the `build-feature` composite that chains them. -Invocation: workflows are **not** top-level slash commands. Recipe definitions load into the `WORKFLOWS` registry from **enabled workflow/command plugins** at startup; command surfaces on those plugins (e.g. a workflow plugin's command prefix such as `/mywf scope`). Slash commands may also be authored as data-only markdown (`commands/*.md`, no `index.ts`); see PLUGINS.md. The model never suggests or auto-starts workflows from ordinary chat. Optional documentation skills (e.g. from an enabled agent plugin or `.agents/skills/`) load on demand via `use_skill` (see Skills below). The TUI surfaces state via `src/tui/workflow-controller.ts` (lifecycle, capability overrides, resume) — the header shows step progress (`⟳ name · step/total label`). +Invocation: workflows are **not** top-level slash commands. Recipe definitions load into the `WORKFLOWS` registry from **enabled workflow/command plugins** at startup; command surfaces on those plugins (e.g. a workflow plugin's command prefix such as `/mywf scope`). Slash commands may also be authored as data-only markdown (`commands/*.md`, no `index.ts`); see PLUGINS.md. The model never suggests or auto-starts workflows from ordinary chat. Skills (bundled `corbits-skills`, enabled plugins, or `.agents/skills/`) load on demand via `use_skill` or as `/` slash commands when `user-invocable` is not `false` (see Skills below). The TUI surfaces state via `src/tui/workflow-controller.ts` (lifecycle, capability overrides, resume) — the header shows step progress (`⟳ name · step/total label`). ### Sub-agents (`src/subagent/`, `src/agent/agent-search.ts`) @@ -227,7 +227,69 @@ When profiles exist (local `.agents/agents/` and/or enabled **`kind: "agent"`** Profiles with `orchestrator: true` may themselves call `task` (one hop only): nested dispatch installs `task` + `search_agents` with `allowOrchestrator: false` so the tree bottoms out. Unknown `agent` ids fail closed. -**Reasoning effort by role** (`src/provider/reasoning-effort.ts` → `resolveEffortForRole`): spawn-time defaults are orchestrator → `high`, leaf → `medium`, clamped to the model. Explicit profile inference pins win; parent session effort is only a fallback when the role default is unsupported. This keeps multi-agent fleets off the sol+high latency cliff — see `docs/plans/reasoning-effort-by-role.md`. +#### Closed director fleet (`src/agent/directors/`) + +Every shipped specialist is a **director package** — a prompt-first `DirectorPackage` (system prompt, tool envelope, spawn rights, nudge budget, report contract, optional `writePaths`, `modelRole`) registered in a **closed** set of 16 ids. There is no catch-all worker: `task` without `agent` or non-general `intent`, and `task(intent="general")`, fail closed so the primary reclassifies. Nested directors with a spawn allowlist reject off-list children at `createTaskTool` (not prompt-only). Skywalker is the primary session identity: `task(agent="skywalker")` is refused, and `directorProfiles()` omits it from the spawn catalog. + +**Primary** + +| Director | Owns | Does not own | +|---|---|---| +| skywalker | Orchestrate only — classify, dispatch, track fleet, synthesize | Product tree edits; being the implementer/reviewer by default | + +**Engineering directors** + +| Director | Owns | Does not own | +|---|---|---| +| implement | Ship product code | Pure docs, pure review | +| explore | Map/read codebase | Product edits | +| plan | Eng change plan (steps, paths, tests, risks) | Arch gate, product discovery, code | +| intern | Mechanical commands only | Ambiguous or product-design work | +| critique | Evidence-based code review | Fixing product code | +| greybeard | Architecture/approach review of plans/docs; limited spawn | Authoring eng plans, implementing | +| neckbeard | Adversarial hygiene / refactor stress | Real review substitute | +| bruckheimer | Product discovery → PRODUCT/ARCHITECTURE/IMPLEMENTATION-oriented briefs | Eng plan, code | +| gaasbot | Quick CTO opinion voice | Formal review gate, implement | + +**Design trio (dev perspective)** + +| Director | Owns | +|---|---| +| draper | Product visual / design-system critique | +| emil | Design-engineering + software laws on product UI/code | +| brand-reviewer | **DESIGN.md** create-if-missing + alignment gate | + +**Docs + QA** + +| Director | Owns | +|---|---| +| shakespeare | Docs maintain (scribe core baked into prompt); PRODUCT/ARCHITECTURE/IMPLEMENTATION lane | +| testsmith | Test design only (what/how to test) | +| tester | Runtime verification; never fix product code | + +**Intent → director** (`task(intent=…)` when `agent` is omitted) + +| Intent | Default director | +|---|---| +| implement | implement | +| explore | explore | +| plan | plan | +| review | critique (override with `agent=…`) | +| general | **none** — reclassify only | + +**Spawn matrix** + +| Who | Spawn rights | +|---|---| +| skywalker (primary session) | Full closed fleet | +| greybeard | intern, explore, critique only | +| All other directors | no `task` | + +**Tool envelopes** prefer small `tools.allow` mounts over deny-everything. Shipped docs/design directors (shakespeare, brand-reviewer, bruckheimer) mount write tools with **no** package `writePaths`. Lane routing is spawn policy (shakespeare = P/A/I docs, brand-reviewer = DESIGN.md, bruckheimer = product discovery), not a file lock. Optional `writePaths` still exists; the permission gate enforces it when a profile sets it. + +**Typical chain:** bruckheimer → plan → greybeard → implement (+ intern) → critique (+ optional neckbeard), with skywalker coordinating throughout. + +**Reasoning effort by role** (`src/provider/reasoning-effort.ts` → `resolveEffortForRole` / `defaultEffortForDirector`): package `modelRole` defaults are orchestrator/plan/review → `high`, implement/explore/docs/test → `medium`, with **intern** pinned to `low`. Spawn-time binary fallback is orchestrator → `high`, worker → `medium`, clamped to the model. Explicit profile inference pins win; parent session effort is only a fallback when the role default is unsupported. This keeps multi-agent fleets off the sol+high latency cliff — see `docs/plans/reasoning-effort-by-role.md`. **Session records** (`src/subagent/session-store.ts`): each spawn is retained as an inspectable child session (id, profile, description, brief, status, tool activity, transcript entries). Child events land only in this store — not in the parent chat transcript. Live progress still uses the light `onProgress` channel for the status bar. Completed sessions are capped (`maxCompleted`) so a long chat does not grow without bound. @@ -237,16 +299,16 @@ Data-only agent plugins (`src/plugins/data-only-agent.ts`) synthesize `agentPlug ### System Prompt (`src/agent/prompts.ts`) -The agent's identity is **Corbits Code**, framed as a senior coding assistant running in a terminal harness. The prompt is deliberately minimal: a frontier model already knows how to be a coding agent, so the static prompt carries only what it cannot derive — harness-specific facts and the project's identity. The base is three small, individually-exported sections: +The primary session identity is **Skywalker** (`buildChatRole` → `createSkywalkerSystemPrompt`). Product name remains Corbits Code; when asked its name, the primary answers Skywalker. Role: orchestrate-only — classify, dispatch closed directors via `task`, track the fleet, synthesize. Product mutation tools (`write_file` / `edit_file` / `delete_file`) are **not mounted** on the primary session (structural never-implement for path tools). Residual mutation surfaces: `run_shell` remains (gated; shell file-writes denied by auto-shell policy), MCP tools added after the strip are not re-filtered by `PRIMARY_DENIED_PRODUCT_TOOLS`, and leaf `writePaths` only apply to path-keyed product tools. A frontier model already knows how to code; the static prompt carries harness-specific facts and the closed-fleet orchestration policy. The base is three individually-exported sections: -- `buildChatRole` — one-line identity and purpose. -- `buildHarnessFacts` — the non-derivable rules: shell file-writes are blocked (use `write_file`/`edit_file`), dependency installs and off-limits paths need approval, images are native multimodal input, only core tools are resident (load the rest via `tool_search`; use `search_agents` before dispatching specialists), workflows run only from slash-command steps, and session memory lives at `.corbits/MEMORY.md`. -- `buildGuidelines` — be concise, answer questions and diagnose visual/product feedback before editing, work autonomously for explicit coding tasks, use `lsp` for symbol work, and verify changes when practical. -- `buildPromptDisciplineBlock` — a shared, prohibition-form section appended exactly once to every built prompt (chat and sub-agent, every provider family): dedicated tools over shell (`read_file`/`edit_file`/`write_file`, never `cat`/`sed`/heredoc/`echo`), no setting or exporting environment variables (recurring needs belong in project settings), `web_fetch`/`web_search` instead of `curl`/`wget`/hand-rolled queries, one operation per `run_shell` call, turn semantics (a tool-less reply is the final answer, no repeat searches, stop and change approach after three failed attempts, batch independent reads in parallel), and TTY output rules (short bold headers, one-line bullets, backticks for paths/commands, no wide tables). +- `buildChatRole` — Skywalker primary identity (orchestrate; do not implement product work by default). +- `buildHarnessFacts` — the non-derivable rules: shell file-writes are blocked, primary product mutations are unmounted (spawn implement/docs directors), dependency installs and off-limits paths need approval, images are native multimodal input, only core tools are resident (load the rest via `tool_search`; use `search_agents` before dispatching specialists), workflows run only from slash-command steps, and session memory lives at `.corbits/MEMORY.md`. +- `buildGuidelines` — be concise, prefer `task` for product work, answer questions and diagnose visual/product feedback before editing, work autonomously for explicit coding tasks, use `lsp` for symbol work, and verify changes when practical. +- `buildPromptDisciplineBlock` — a shared, prohibition-form section appended exactly once to every built prompt (chat and sub-agent, every provider family). Primary vs leaf wording differs for product writes: leaves are told to use `read_file`/`edit_file`/`write_file`; Skywalker is told product writes are unmounted and durable edits go through directors. Shared rules: never `cat`/`sed`/heredoc/`echo` for file work, no setting or exporting environment variables (recurring needs belong in project settings), `web_fetch`/`web_search` instead of `curl`/`wget`/hand-rolled queries, one operation per `run_shell` call, turn semantics (a tool-less reply is the final answer, no repeat searches, stop and change approach after three failed attempts, batch independent reads in parallel), and TTY output rules (short bold headers, one-line bullets, backticks for paths/commands, no wide tables). **Provider-conditional residuals.** Per-family additions layer on top of the shared block via the same `ModelFamilyPolicy` mechanism the directors use (`src/subagent/provider-family.ts`, `src/agent/model-family-policy.ts`) — additive lines, never prompt forks. **Grok** leaves get `buildGrokLeafAntiThrashNote` (gated by `shouldApplyGrokAntiThrash` / `applyGrokFinishBias`, withheld from orchestrators): a compact finish-bias reinforcement plus a one-line reminder to route file/web work through the dedicated tools rather than `run_shell`, motivated by observed tool-routing thrash on the same harness. **Kimi** intentionally has no residual yet — `detectModelFamily` already resolves the family so callers can branch on it, but the prompt seam is left unfilled pending eval characterization of Kimi's behavior, mirroring the provisional (permissive-default) policy in `model-family-policy.ts`. -`buildChatSystemPrompt` (TUI chat) and `buildSubAgentSystemPrompt` assemble: base → core tool list → lazy skills listing → live `` block → appended extensions. Built-in catalog tools and MCP integrations load dynamically via `tool_search` rather than being enumerated. Skills follow the same lazy principle pi-style: each discovered skill contributes only its name + one-line description to the prompt, and the model pulls a skill's full instructions into context on demand by calling `use_skill`. Skill loading is entirely model-driven — there is no operator invocation. Skills are discovered (and deduped by name) from enabled plugin dirs, then `.agents`/`.claude`/`.codex/skills`, in that precedence. Corbits Code does not ship a bundled skill catalog — skills come from plugins and the project tree. +`buildChatSystemPrompt` (TUI chat) and `buildSubAgentSystemPrompt` assemble: base → core tool list → lazy skills listing → live `` block → appended extensions. Built-in catalog tools and MCP integrations load dynamically via `tool_search` rather than being enumerated. Skills follow the same lazy principle pi-style: each discovered skill contributes only its name + one-line description to the prompt, and the model pulls a skill's full instructions into context on demand by calling `use_skill`. The operator can also invoke the same skill as `/` (see Skills below). Skills are discovered (and deduped by name, first-wins) from enabled plugin dirs, then `.agents`/`.claude`/`.codex/skills`, in that precedence. Corbits Code ships a bundled catalog via the first-party `corbits-skills` plugin (origin `repo`); project-local skills of the same name are shadowed by an enabled plugin skill. **Overrides.** `loadSystemPromptOverrides` (`src/agent/context-extensions.ts`) resolves a project `SYSTEM.md` (repo root, then `.corbits/`) that **replaces** the static base block, and an `APPEND_SYSTEM.md` that is **appended** as an extension. These compose with `config.systemPromptExtensions` (profile config) and the auto-discovered `AGENTS.md`, all of which attach as appended sections after the base. @@ -327,7 +389,7 @@ OpenTUI (`@opentui/core`) is the shipping shell; the Ink/React tree has been del - **Shell** (`shell.ts`) — Owns the transcript window, header, status line, prompt, overlay/palette stack, and layout/relayout (`applyLayout`, `relayout`). Transcript rows are appended via `appendStreamRow`/`appendObserveStreamRow`; focus moves between prompt and transcript via `applyFocus`/`toggleShellFocus`. - **Product host** (`product-host.ts`) — Creates the `CliRenderer`, wires the event emitter bridge, model/command catalogs, and chrome pushes. - **Runner host** (`runner-host.ts`) — Runner-facing mount: catalog assembly from live config, chrome pushes on session change, subagent observe resolution, and session teardown (quitting is Ctrl+C twice, owned by the shell). -- **Overlays and pickers** — Resume picker (`src/tui/pick-session.ts`) and session-mode prompt (`src/tui/session-mode-prompt.ts`) use `runListModal` (`src/tui/list-modal.ts`). Slash-command surfaces (`/model`, `/settings`, `/permissions`, `/plugins`, etc.) route through `openCommandSurface` (`src/tui/command-surfaces.ts`). +- **Overlays and pickers** — Resume picker (`src/tui/pick-session.ts`) uses `runListModal` (`src/tui/list-modal.ts`). Slash-command surfaces (`/model`, `/settings`, `/permissions`, `/plugins`, etc.) route through `openCommandSurface` (`src/tui/command-surfaces.ts`). - **Auto mode** — Toggled by CLI flags only (`--auto` / `--no-auto`); there is currently no in-session key bound to it. - `@file` mention resolution and image paste are not wired on the OpenTUI send path. @@ -335,7 +397,13 @@ Known keybindings: `Ctrl+C` interrupts the in-flight run, and quits on a second ### Skills (`src/extensions/skills.ts`) -Skills are Markdown capability packages (`SKILL.md`) that the model loads on demand — pi-style lazy skills. They are not slash commands and are never operator-invoked; discovery and loading are entirely model-driven. +Skills are Markdown capability packages (`SKILL.md`). Each skill is a **dual surface**: the model loads it on demand via the `use_skill` core tool (pi-style lazy listing), and the operator can invoke it as `/` unless frontmatter sets `user-invocable: false`. `loadSkillCommands` (`src/plugins/skill-commands.ts`) synthesizes a slash command that sends the SKILL.md body (plus typed args) to the primary session; untagged skills still become slashes. + +Corbits Code **ships a bundled catalog** as the first-party data-only plugin `plugins/corbits-skills/` (id `corbits-skills`, kind `command`, `defaultEnabled: true`). Origin `repo` is auto-trusted. Auto-**enable** applies only when `origin === "repo"` AND `manifest.defaultEnabled` is true AND `settings.plugins[id]` is missing; an explicit `enabled: false` still disables. Marketplace (user / project / path / claude) `defaultEnabled` is ignored — those plugins stay opt-in. The id is `corbits-skills` (not `gaas`) so a later marketplace plugin cannot replace the module by id collision. + +`discoverRepoPlugins` locates `plugins/` next to the source root, at `dist/plugins`, or at `dirname(execPath)/plugins`. It never scans the session cwd for the bundled catalog. + +Primary is Skywalker. Bundled skill bodies that are operator slashes are **action** recipes that tell it to `task(agent="")` — there is no catch-all worker. Default slashes: `/implement`, `/plan`, `/refactor`, `/review`, `/pull-request-review`, `/create-issue`, `/scribe`, `/interview`, `/ast-grep`. `/scribe` dispatches shakespeare; `/implement` spawns implement / greybeard / critique as the recipe specifies; `/plan` dispatches plan director (eng change plan; does not implement; does not file tracker issues); `/review` is a code-review action (not a director name); `/create-issue` remains the tracker command — Linear MCP when available, otherwise `ask_operator` for the platform and persists `Preferred issue tracker` in `.corbits/MEMORY.md`. Dispatch is `use_skill` only, not a default slash. Draper and emil are closed directors via `task(agent=…)`, not slashes. The operator types the slash; Skywalker reads the body and dispatches. #### Discovery and precedence @@ -343,29 +411,34 @@ Skills are Markdown capability packages (`SKILL.md`) that the model loads on dem | Base directory | Source | |---|---| -| `/skills/` | Each enabled plugin that ships skills (`runner.ts` includes only `pluginConfig[id].enabled`) | +| `/skills/` | Each enabled plugin that ships skills, including the bundled `corbits-skills` catalog when auto-enabled (`skillDirsFromEnabledPlugins`) | | `.agents/skills/` | Shared across runtimes | | `.claude/skills/` | Claude Code workspace skills | | `.codex/skills/` | Codex workspace skills | -Each `//SKILL.md` is one skill. Discovery dedupes by directory name: the first base dir that provides a given name wins, so an enabled plugin skill shadows a project-local skill of the same name. `resolveSkillBody(cwd, ref, pluginDirs)` resolves a skill's body using the same ordered list (it accepts a bare name or a `plugin:name` ref, keying on the name). +Each `//SKILL.md` is one skill. Discovery dedupes by directory name: the first base dir that provides a given name wins, so an enabled plugin skill shadows a project-local skill of the same name. Plugin dirs are passed in discovery order (repo first), so a first-party catalog name wins over a later marketplace or project skill of the same name. `resolveSkillBody(cwd, ref, pluginDirs)` resolves a skill's body using the same ordered list (it accepts a bare name or a `plugin:name` ref, keying on the name). #### SKILL.md format -A skill file begins with a YAML frontmatter block, followed by the body that holds the instructions. The loader parses only `description`; the skill's identifier (what `use_skill` takes) is its directory name. A skill with no `SKILL.md` or an empty body is skipped. +A skill file begins with a YAML frontmatter block, followed by the body that holds the instructions. Discovery parses `description`; `loadSkillCommands` also reads `user-invocable`. The skill's identifier (what `use_skill` and `/` take) is its directory name. A skill with no `SKILL.md` or an empty body is skipped. | Field | Required | Description | |---|---|---| -| `description` | yes | One-line summary shown in the prompt's lazy skills listing | +| `description` | yes | One-line summary shown in the prompt's lazy skills listing and the slash picker | | `name` | conventional | Conventionally matches the directory name; the directory name is what is actually used as the identifier | +| `user-invocable` | no | When `false`, `loadSkillCommands` skips slash synthesis; the skill remains `use_skill` only. Untagged skills still become slashes (marketplace BC) | + +There are no `type` or `disable-model-invocation` fields required for model invocation — a skill body is plain instruction text. `argument-hint` on frontmatter is preserved for the slash picker (greyed arg guidance). Multi-step orchestration is a separate mechanism (see Workflows above), not a skill `type`. + +#### Loading (model and operator) -There are no `type`, `argument-hint`, or `disable-model-invocation` fields — a skill body is plain instruction text. Multi-step orchestration is a separate mechanism (see Workflows above), not a skill `type`. +`buildSkillsSection` lists each discovered skill as `- name: description` in the system prompt — descriptions only, so the prompt stays small regardless of how many skills exist. The full instructions enter context in two ways: -#### Loading (model-driven) +1. **Model** — `use_skill` (`src/agent/use-skill.ts`) with a skill name; the handler calls `resolveSkillBody`, strips the frontmatter, and returns the body as the tool result. +2. **Operator** — `/` from `loadSkillCommands` sends the same SKILL.md body (plus typed args) to the primary as a user turn. Skills with `user-invocable: false` are omitted from the slash registry and remain `use_skill` only. Skywalker then follows the recipe. -`buildSkillsSection` lists each discovered skill as `- name: description` in the system prompt — descriptions only, so the prompt stays small regardless of how many skills exist. The full instructions enter context only when the model calls the `use_skill` core tool (`src/agent/use-skill.ts`) with a skill name; the handler calls `resolveSkillBody`, strips the frontmatter, and returns the body as the tool result. There is no slash-command surface and no operator-side injection — the model decides when a skill applies and loads it itself. +Which plugin skill directories are in scope is decided in `runner.ts` / `skillDirsFromEnabledPlugins`, which passes the enabled plugins' dirs to both `discoverSkills` (for the listing) and the `use_skill` tool (for resolution). Project-local `.agents`/`.claude`/`.codex/skills` are always searched. Slash-command registration is first-wins (built-ins, then plugins in discovery order), so a first-party `/implement` stays first-party if a marketplace plugin of the same slash name is also enabled. -Which plugin skill directories are in scope is decided in `runner.ts`, which passes the enabled plugins' dirs to both `discoverSkills` (for the listing) and the `use_skill` tool (for resolution). Project-local `.agents`/`.claude`/`.codex/skills` are always searched. ## Data Flow diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 3e7e12c71..28b736eb1 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -68,9 +68,15 @@ src/ index.ts CLI entry: verbs, dispatch, help agent/ director.ts ChatDirector; director-layer tool defs - prompts.ts System prompt builders (agent + chat) + prompts.ts System prompt builders; buildChatRole → Skywalker tools.ts Agent tool registration helpers agent-search.ts search_agents tool + profile lexical index + default-agents.ts Built-in profiles = directorProfiles() spawn catalog + directors/ Closed director fleet packages + registry + types.ts DirectorId, DirectorPackage, TaskIntent, ModelRole + registry.ts DIRECTOR_REGISTRY, resolveDirector, packageToProfile + tool-sets.ts Shared allowlists (READ/IMPLEMENT/DOCS/REVIEW/…) + /package.ts Per-director prompt, envelope, spawn, report renderer.ts Event-stream renderer (stderr + live cost; used by tests/utilities) session/ index.ts Session lifecycle (was session.ts) @@ -82,7 +88,9 @@ src/ hooks.ts Lifecycle hooks: discovery, turn collector, run summary subagent/ index.ts Sub-agent spawn + SubAgentDirector + task-tool.ts task() — resolveDirector first; writePaths on child session-store.ts Retained child session transcripts for observe UI + identity-context.ts ALS: worker cwd + optional writePaths for gate config/ index.ts Config resolution (settings files + flags) (was config.ts) settings.ts Settings schema, validators, loaders, resolveProvider @@ -98,7 +106,8 @@ src/ classify.ts Tool tier + approval-request construction command.ts Chained-command split + command scopes auto-shell-policy.ts Auto-mode run_shell deny/ask rule table - gate.ts Permission gate evaluation + gate.ts Permission gate evaluation (+ director writePaths) + write-path-policy.ts Basename/glob match for worker write allowlists matcher.ts Approval glob matching store.ts Per-directory approval persistence types.ts Approval / scope / request / outcome types @@ -119,7 +128,6 @@ src/ runner.ts Chat-mode agent setup; mounts the OpenTUI host onboarding.ts First-run provider setup entry pick-session.ts Resume picker (via runListModal) - session-mode-prompt.ts Session-mode prompt (via runListModal) turns-to-blocks.ts Stored turns → typed content blocks (resume hydration) tool-formatter.ts Human-readable tool args/results markdown-parser.ts Markdown rendering @@ -142,6 +150,20 @@ docs/ PLUGINS.md, TELEMETRY.md, PERFTRACE.md ``` +### Closed director fleet + +Sixteen packages under `src/agent/directors//` register in `DIRECTOR_REGISTRY` (`registry.ts`). Wire path: + +1. `task(agent=…)` / `task(intent=…)` → `resolveDirector` in `task-tool.ts` before tools and system prompt are built. Bare `task` (neither field) and `intent=general` fail closed. +2. `packageToProfile` maps envelope (`tools.allow`/`deny`) to `AgentProfile.capabilities`, `spawn.maySpawn` → `orchestrator`, and optional `writePaths`. System prompts are prefixed with a stable identity block (`formatDirectorSystemPrompt`: agent id, model role, optional skills). +3. Nested spawn: packages with `spawn.allowlist` forward that list into nested `task` (`spawnAllowlist` on nestedDispatch). Off-list `agent` is refused. `task(agent=skywalker)` is refused (primary is not a spawned worker). Primary omits the list so plugin profiles stay reachable. +4. `directorProfiles()` is the spawn catalog (`default-agents.ts`) — closed set minus skywalker; plugin agent profiles still load and can override by id. +5. Primary chat role is Skywalker: `buildChatRole()` → `createSkywalkerSystemPrompt()`. Product mutation tools are stripped from the primary toolset and from CORE/CATALOG ads (`PRIMARY_DENIED_PRODUCT_TOOLS`) — never-implement is structural for path tools. Residual: `run_shell` stays on primary; MCP tools loaded later are not re-stripped by that deny list; optional `writePaths` (when a profile sets it) only gate path-keyed product tools. +6. Shipped directors omit `writePaths`. The optional field is still enforced in the permission gate via ALS identity (`identity-context.ts` + `write-path-policy.ts`) when a plugin/custom profile sets it. +7. Spawn effort: pin > package `modelRole` default (`defaultEffortForDirector`; intern=low; plan/review/orchestrator=high; implement/explore/docs/test=medium) > orchestrator/worker binary > parent inheritance. Optional skills are listed in the identity header for awareness; workers do not mount `use_skill` (guidance is baked into package system prompts). Primary mounts `use_skill` for its own skill list. + +Intent defaults: implement/explore/plan → same-named director; review → critique; general → error. Spawn: skywalker full fleet; greybeard intern/explore/critique only; all other directors no `task`. Live `` injects cwd, platform, arch, runtime, date, and git status on every chat and worker prompt. + ### Auto Mode Auto mode defaults **on** (`config.auto = true` from `loadConfig`; pass `--no-auto` to start off, or `--auto` to force on). It is toggled only via those CLI flags — there is currently no in-session key bound to it. The permission gate reads the flag (`getAuto`/`setAuto` in `src/permission/gate.ts`) on the next tool call. Skip-permissions (`--dangerously-skip-permissions`) has a mid-session TUI toggle: `/yolo [on|off|toggle]` (bare `/yolo` also toggles) wires `getSkipPermissions`/`setSkipPermissions` so the gate and pre-gate sandboxes honor the change on the next tool call without rebuilding plugins. @@ -161,10 +183,12 @@ Unmatched shell auto-allows. Writes under the session state root (`~/.corbits/pr ### Interrupt and Queue Steering -`ChatInputProps` carries `isProcessing?: boolean` and `onInterrupt?: (message: string) => void`. When `isProcessing` is true: +`ChatInputProps` carries `isProcessing?: boolean` and `onInterrupt?: (message: string) => void`. When `isProcessing` is true, drain timing is **parent-idle** vs **session-idle**: + +- **Enter** soft-steers — enqueues kind `"steer"` and delivers at the next **parent** `tool.boundary` (the parent tool finishing, not a child). Does not interrupt. **Parent-idle** is when the primary Skywalker turn is not inside an in-flight parent tool; a long parent `run_shell` or awaiting `task()` is parent-busy and holds steers. +- **Alt+Enter** queues a follow-up (kind `"queue"`) delivered only on **session-idle** — parent-idle **and** no live fleet lanes (`run` goes idle). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run. -- **Enter** calls `onInterrupt`. `App.handleInterrupt` calls `requestStop()` synchronously — which calls `sendAbortRef.current.abort()` — before `resolveAtMentions` yields, ensuring the abort signal reaches the in-flight HTTP request before any async work begins. -- **Alt+Enter** calls `onSubmit` immediately, pushing the message onto `pendingQueueRef` for drain at the next `connector.reply`. +A live fleet with a blocked parent is neither parent-idle nor session-idle. Idle-with-fleet (parent idle after dispatch so Enter is a turn while workers run) is not shipped. `src/tui/stream-event-map.ts` maps reactor events onto the bridge's inbound events, and `src/tui/turn-state.ts` tracks the turn's status. `src/tui/turns-to-blocks.ts` hydrates a resumed session's stored turns into the same content blocks. @@ -209,9 +233,9 @@ Provider and model configuration lives in JSON settings files. The global file h - `timeoutMs` / `maxTimeoutMs` — outer execution watchdog around each tool `run()` (defaults ~11 min / 30 min). - `waitForApproval` (default **true** when unset) — freeze that budget while a permission prompt is open so a late approve still runs the tool. **Settings → Tools** toggles this live for the next tool call and persists it here. When **false**, the budget keeps ticking during the prompt; on expiry the tool is skipped and the modal is auto-dismissed. The freeze is bounded: after **30 minutes** with the prompt still unanswered the budget resumes ticking on its own, so a prompt that never becomes visible (overlay open, UI gone) cannot hang a tool run indefinitely. - Optional `subagentMaxTurns` (integer **1–100**, default **30**) sets the default inference-turn budget for leaf sub-agents (not the parent chat session limit). Per-dispatch `task(maxTurns)` and agent profile `maxTurns` override this default; values above **100** are rejected on `task` and clamped for profiles. Applies when `sessionMode` is **orchestrator**. + Optional `subagentMaxTurns` (integer **1–100**, default **30**) sets the default inference-turn budget for dispatched workers (not the parent chat session limit). Per-dispatch `task(maxTurns)` and agent profile `maxTurns` override this default; values above **100** are rejected on `task` and clamped for profiles. Always applies — the primary session is always orchestrator-capable (CL-5814). - Optional `sessionMode`: **`single`** (one primary agent, no `task` / `search_agents` on the wire) or **`orchestrator`** (default once chosen — delegates via `task` and advertises agent profiles). When unset on first TUI launch, Corbits Code prompts once; **Enter** saves the highlighted choice here. **Ctrl+C** on that prompt skips persistence and runs **orchestrator** for that session only. Per-repo override: `.corbits/settings.json` `{ "sessionMode": "single" | "orchestrator" }` (Settings → Session). Changes in Settings apply on the **next** session start. Both the interactive TUI (`runTUI`) and the non-TUI product path (`runExec` / `corbits exec`) resolve `sessionMode` the same way from global + per-repo settings (default orchestrator when unset). Exec bootstrap is otherwise a forked copy of the TUI path (shared stack, intentional deltas documented under Architecture → Exec Runner). + Optional `sessionMode` is **deprecated**. Legacy values (`single` | `orchestrator`) may still appear on disk and load without error; resolve always returns **orchestrator**. There is no first-run mode picker and no Settings row. Both the interactive TUI (`runTUI`) and the non-TUI product path (`runExec` / `corbits exec`) are orchestrator-only. Exec bootstrap is otherwise a forked copy of the TUI path (shared stack, intentional deltas documented under Architecture → Exec Runner). - Per-repo: `.corbits/settings.json` — **selection only**, e.g. `{ "provider": "firepass", "model": "fp-small" }`. Any other key (notably `apiKey` or `baseURL`) is rejected by the loader, and the file is gitignored. It is also on the secret-guard denylist for path-keyed tools, as is the global file, so the agent cannot `read_file` its own credentials (shell references still require explicit operator approval). @@ -268,7 +292,7 @@ Profiles supply per-project or named-profile overrides for `model`, `maxTurns`, Providers and credentials are read exclusively from settings files: the global `~/.corbits/settings.json` (definitions + credentials) and the per-repo `.corbits/settings.json` (selection only). There are no `OPENAI_COMPATIBLE_*` environment-variable overrides, and `index.ts` does not load `.env` files — a deliberately stale or exported key can no longer shadow the configured provider. -**Models-first connect.** There is no standalone `/login` command. `/model` opens on a flat **models-only** list (Recent, Favorites, then connected provider/model rows) built by `buildModelsFirstList` (`src/tui/model-picker.ts`); type-to-filter owns printable keys. **Alt+A** opens Connect via `addProviderSelectorChoices` (`src/tui/provider-setup.ts`), which lists every first-class kind including Custom — never bare `c` / Ctrl+A, and never in-list “connect →” rows. First-class API-key rows use a named-instance + auth-only form (instance name, key; catalog base URL is display-only); Custom keeps the full manual form. **Alt+F** toggles favorites; recent/favorite pairs live in global settings (`recentModels` / `favoriteModels`). First-class providers ship from `packages/first-class-providers` (corbits-agnostic defs) and `packages/opencode-go` (Go catalog, auth validate, multi-protocol endpoints, usage). OAuth providers open the existing browser login modal with a named account step; API-key providers share the same multi-instance naming and pre-seed models on save so selection works without restart. Both OAuth and API-key (including Custom) connects share `persistConnectedSelection` in `provider-setup-submit.ts` so project-local provider/model selection is written alongside global credentials. OpenCode Go forces `OPENCODE_GO_BASE_URL` when `opencodeGo` is set so subscription traffic is not billed as Zen PAYG. +**Models-first connect.** There is no standalone `/login` command. `/model` opens on a flat **models-only** list (Recent, Favorites, then connected provider/model rows) built by `buildModelsFirstList` (`src/tui/model-picker.ts`); type-to-filter owns printable keys. **Alt+A** opens Connect via `addProviderSelectorChoices` (`src/tui/provider-setup.ts`), which lists every first-class kind including Custom — never bare `c` / Ctrl+A, and never in-list “connect →” rows. First-class API-key rows use a named-instance + auth-only form (instance name, key; catalog base URL is display-only); Custom keeps the full manual form. **Alt+F** toggles favorites; recent/favorite pairs live in global settings (`recentModels` / `favoriteModels`). **Alt+D** sets the default via `setDefaultModel` (global `defaultProvider` + that provider's `defaultModel`) plus `persistConnectedSelection` without switching the live session. First-class providers ship from `packages/first-class-providers` (corbits-agnostic defs) and `packages/opencode-go` (Go catalog, auth validate, multi-protocol endpoints, usage). OAuth providers open the existing browser login modal with a named account step; API-key providers share the same multi-instance naming and pre-seed models on save so selection works without restart. Both OAuth and API-key (including Custom) connects share `persistConnectedSelection` in `provider-setup-submit.ts` so project-local provider/model selection is written alongside global credentials. OpenCode Go forces `OPENCODE_GO_BASE_URL` when `opencodeGo` is set so subscription traffic is not billed as Zen PAYG. **OpenCode Go multi-protocol.** Each Go model carries protocol metadata (`chat-completions`, `responses`, or `messages`). `buildGoSource` / `resolveGoEndpoint` pick the adapter and base URL per model (not a single provider-wide OpenAI route). When Go is the active provider, subscription usage is fetched for the status bar and omitted on auth/network failure. @@ -281,7 +305,8 @@ Printed by `corbits --help` / `-h` from `CLI_HELP_TEXT` in `src/config/index.ts` |---|---|---| | _(no verb)_ | — | Interactive session; optional trailing task text | | `exec` / `run` | — | Run a prompt (non-interactive / one-shot) | -| `resume` / `continue` | — | Reopen the latest session for this folder (project-keyed; worktrees of the same git root share sessions) | +| `resume` / `continue` | — | Open the session picker for this folder (project-keyed to this checkout's git toplevel) | +| `--resume` | — | Open the interactive session picker | | `resume ` | — | Reopen a specific session | | `resume --pick` / `--list` | — | Interactive session picker | | `--cwd ` | `process.cwd()` | Working directory | @@ -315,7 +340,7 @@ Session runtime state lives under the global projects tree (not in the repo): - `~/.corbits/projects///run.json` — `RunState` - `~/.corbits/projects///context/` — git-backed conversation context (`@intx/storage-isogit`) -- Project key: slug + short hash of the shared git root (from `--git-common-dir`, so main + linked worktrees share one key; workspace realpath when not a git tree) +- Project key: slug + short hash of this checkout's git toplevel (from `--show-toplevel`, so linked worktrees have distinct keys; workspace realpath when not a git tree) - Migration: if a session exists only under in-repo `.agent-state//`, it is moved into the global tree on open/list - Atomic JSON writes with schema validation on load @@ -346,7 +371,7 @@ the directors guard on; the full set of reactor and stream event types is treat that as canonical rather than this section or any other doc's partial list. -Mid-run queue/steer/interrupt state is a pure state machine in `src/tui/session-queue.ts` (interaction contract §3): `enqueue` (kind `"queue"`) and `enqueueSteer` (kind `"steer"`) share one pending pool, drained steer-first, then queue, both FIFO within their class. The prompt hint (`src/tui/stream.ts`, `PROMPT_HINT`) reads `Enter queue · Alt+Enter steer · Ctrl+C stop`. +Mid-run queue/steer/interrupt state is a pure state machine in `src/tui/session-queue.ts` (interaction contract §3): `enqueue` (kind `"queue"`) and `enqueueSteer` (kind `"steer"`) share one pending pool, drained steer-first, then queue, both FIFO within their class. Mid-run gestures: Enter soft-steers (drain at the next **parent** `tool.boundary` — the parent tool finishing, not a child; parent-busy holds steers), Alt+Enter queues a follow-up (drain on **session-idle**: parent-idle and no live fleet lanes), Ctrl+C stops. Idle-with-fleet is not shipped. ### Lifecycle Hooks @@ -365,10 +390,10 @@ Mid-run queue/steer/interrupt state is a pure state machine in `src/tui/session- See `docs/PLUGINS.md` for the full design. Summary: -- Every installable plugin exports a `manifest` (`{ id, name, kind, description?, credentials? }`) with `kind` one of `web | command | tool`. A workflow is just a slash command, so there is no separate workflow/agent kind. +- Every installable plugin exports a `manifest` (`{ id, name, kind, description?, credentials? }`) with `kind` one of `web | command | workflow | tool | agent`. - Plugins are auto-discovered from `plugins/`, `/.corbits/plugins/`, and `~/.corbits/plugins/`, plus any explicit file/dir paths in `settings.pluginPaths`. When `settings.discoverClaudePlugins` is true, plugins listed in `~/.claude/plugins/installed_plugins.json` are also loaded (install paths only; still require enable). The `/plugins` UI's "add by path" action (`a`) loads a plugin from anywhere on disk, validates its manifest, and persists the path. Discovery resolves relative imports to absolute first (`loadPluginEntry`). Project-local plugins require per-cwd trust (`~/.corbits/trust/.json`); path plugins use global path trust (`~/.corbits/trust/path-plugins.json`) so they keep working across project directories. Untrusted origins load metadata-only until granted. -- **Explicit enable:** nothing is wired in until `settings.plugins[id].enabled` is true. `command` → `registerCommandPlugins` registers slash commands (live on enable); `tool` → `resolveToolPlugins` instantiates `createToolPlugin(credentials)` and appends the tools to the posix toolset assembled in `src/tui/runner.ts` (via `tools.ts` helpers). `web` → `web_search`/`web_fetch` are now always-on core built-ins (`src/tools/web-search.ts`, `src/tools/web-fetch.ts`), not plugin-backed; a discovered `kind: "web"` plugin is retained for brand-display resolution only (`resolveWebProviderFromPlugins`/`webBrand` in `src/web/plugin-provider.ts`) and no longer supplies the tool implementation. +- **Explicit enable:** nothing is wired in until `settings.plugins[id].enabled` is true, except the repo-origin `defaultEnabled` case: when `origin === "repo"` AND `manifest.defaultEnabled` is true AND `settings.plugins[id]` is missing, the plugin auto-enables (this is how first-party `corbits-skills` is on out of the gate). An explicit `enabled: false` still disables. Marketplace (user / project / path / claude) `defaultEnabled` is ignored — those plugins stay opt-in. `command` → `registerCommandPlugins` registers slash commands (live on enable); `tool` → `resolveToolPlugins` instantiates `createToolPlugin(credentials)` and appends the tools to the posix toolset assembled in `src/tui/runner.ts` (via `tools.ts` helpers). `web` → `web_search`/`web_fetch` are now always-on core built-ins (`src/tools/web-search.ts`, `src/tools/web-fetch.ts`), not plugin-backed; a discovered `kind: "web"` plugin is retained for brand-display resolution only (`resolveWebProviderFromPlugins`/`webBrand` in `src/web/plugin-provider.ts`) and no longer supplies the tool implementation. - **Tool consent:** a `tool` plugin runs in-process, so it is wired in only when enabled AND `consented`. The `/plugins` UI prompts a one-time y/n consent recorded in `settings.plugins[id].consented`. - Configure via `/plugins`, which writes `settings.plugins` (enabled / consented / credentials), `settings.web`, and `settings.pluginPaths` to the global settings file. Credentials live in the global file because it carries secrets — the project-local settings file rejects credential keys. When a web plugin is active its tool calls render under its brand (e.g. "Exa Search"). Example: `{ "web": "exa", "plugins": { "exa": { "enabled": true, "credentials": { "apiKey": "..." } } } }`. diff --git a/docs/MCP.md b/docs/MCP.md index 6b7350d8f..6c7b582d7 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -25,7 +25,8 @@ does not require project trust. Local settings **replace** global MCP entirely when present (they do not merge). Tools from connected servers are not advertised to the model up front; they are -registered for dispatch and surfaced on demand through dynamic tool discovery +registered for dispatch as soon as the server connects (including later in the +same turn) and surfaced on demand through dynamic tool discovery (`tool_search`). ## Server Kinds diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 41e4ec293..f272c40f7 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -3,7 +3,9 @@ Status: **implemented** — the unified, manifest-driven system described below is in place. Plugins self-describe with a `manifest` (`kind: web | command | tool`), are auto-discovered (plus explicit `pluginPaths`), are wired in only when -explicitly enabled, and are managed through the `/plugins` UI. The sections +explicitly enabled (with one exception: a repo-origin plugin whose manifest +sets `defaultEnabled: true` auto-enables when the settings key is missing), +and are managed through the `/plugins` UI. The sections below double as the reference for the system and the record of why it is shaped this way. (The "Current state (the problem)" section is retained as the historical motivation.) @@ -17,7 +19,7 @@ registered in global settings (`pluginPaths`), so consent is global once granted | Origin | Path | Auto-trusted? | Trust store | |---|---|---|---| -| `repo` | Product-shipped `plugins/` next to the Corbits Code binary | Yes | — | +| `repo` | Product-shipped `plugins/` next to the source root, `dist/plugins`, or `dirname(execPath)/plugins` — never session cwd | Yes | — | | `user` | `~/.corbits/plugins/` | Yes (user home) | — | | `user` (Claude) | Absolute `installPath` under `~/.claude/plugins/` from `installed_plugins.json` when `settings.discoverClaudePlugins` is true | Yes (user home; still disabled until enable; data-only load only) | — | | `project` | `/.corbits/plugins/` | **No** — per working directory | `~/.corbits/trust/.json` | @@ -152,7 +154,7 @@ A module with no valid manifest is ignored (not silently half-loaded). ``` discoverPlugins(cwd) = - repo plugins/ (built-in) + repo plugins/ (built-in: source root / dist/plugins / dirname(execPath)/plugins — never session cwd) + /.corbits/plugins/ + ~/.corbits/plugins/ + settings.pluginPaths (explicit file/dir paths, added via /plugins) @@ -223,8 +225,11 @@ shape. ### Command plugins and enable gating - `command` plugins (`commandPlugin` export) register their slash commands only - when `settings.plugins[id].enabled` is true, via `registerCommandPlugins` + when the plugin is enabled, via `registerCommandPlugins` (`src/plugins/register.ts`); enabling one in `/plugins` wires it in live. + Enablement is `settings.plugins[id].enabled === true`, except the Decision 3 + repo-origin `defaultEnabled` case below (the first-party `corbits-skills` + catalog auto-enables when the settings key is missing). - Commands may also be authored as data-only markdown (see below). - Legacy `settings.workflowPlugins` / `agentPlugins` specifier arrays and their loaders are removed; everything flows through discovery + `pluginPaths`. @@ -297,17 +302,29 @@ shape. exist. So a marketplace plugin (e.g. `agents/plugins/gaas`) loads as-is via `/plugins` add-by-path: its `agents/*.md` wire as profiles and its `skills/*/SKILL.md` resolve through `use_skill` with no porting. -- **Skill-commands.** Every skill in an enabled plugin is also surfaced as a +- **Skill-commands.** Skills in an enabled plugin are also surfaced as a `/ [args]` slash command that sends the skill body (plus args) to - the agent. `loadSkillCommands` (`src/plugins/skill-commands.ts`) synthesizes - them; they merge into the same `commandPlugin` as `commands/*.md`. Frontmatter + the agent, unless frontmatter sets `user-invocable: false`. `loadSkillCommands` + (`src/plugins/skill-commands.ts`) synthesizes them and skips that tag; they + merge into the same `commandPlugin` as `commands/*.md`. Untagged skills still + become slashes (marketplace backward compatibility). Frontmatter `argument-hint` is preserved so the TUI can show greyed arg guidance (e.g. - `/linear-create` → `[description] [--from-doc]`). This is an additional + `/create-issue` → `[description] [--from-doc]`). This is an additional surface: `discoverSkills` is unchanged, so the model can still auto-invoke any - skill via `use_skill` — the slash command is a direct user entry point on top. - (An earlier revision gated this on the `disable-model-invocation`/ - `user-invocable` frontmatter tags; that gate was dropped so untagged skills - like `linear-create` are reachable too.) + skill via `use_skill` — including first-party recipes that are not operator + slashes (`dispatch`, `git-rebase`, `linear-issue-workflow`, `style`, + `philosophy`, `typescript`, `opsh`). The slash command is a direct user entry + point on top. +- **First-party catalog.** `plugins/corbits-skills/` (id `corbits-skills`, + kind `command`, `defaultEnabled: true`) is the bundled skill catalog. Origin + `repo` is auto-trusted. Auto-enable applies only when `origin === "repo"` AND + `manifest.defaultEnabled` AND the settings key is missing; an explicit + `enabled: false` still disables. Marketplace `defaultEnabled` is ignored. + The id is not `gaas`, so a later marketplace plugin named gaas cannot replace + the module. Slash-command registration is first-wins (built-ins, then plugins + in discovery order: repo before user/project/path), so `/implement` stays + first-party when both the catalog and a marketplace plugin are enabled. + `discoverSkills` is already first-wins (plugin dirs before project). - **Mixed plugins wire both sides.** A plugin contributing agents AND commands (the common marketplace shape) infers `kind: "agent"` so profiles wire, and `isEnabledCommandPlugin` (`src/plugins/register.ts`) also wires commands for @@ -352,10 +369,13 @@ loaded via `pluginPaths`. `settings.pluginPaths`. 2. **`settings.web` stays** as the only kind-selector for now; generalize to `settings.active[kind]` only if another kind needs "exactly one active." -3. **Always explicit enable.** Every discovered plugin (built-in or user-added) - starts disabled. Nothing is wired in until `settings.plugins[id].enabled` is - true — set in `/plugins`. (Note: this changes today's behavior where repo - command plugins auto-load; they must now be enabled.) +3. **Explicit enable, with one repo-origin exception.** Every discovered plugin + starts disabled unless all of the following hold: `origin === "repo"`, + `manifest.defaultEnabled` is true, and `settings.plugins[id]` is missing. + Then it auto-enables. An explicit `enabled: false` still disables. Marketplace + (user / project / path / claude) `defaultEnabled` is ignored — those plugins + stay opt-in via `/plugins`. The first-party catalog `plugins/corbits-skills/` + (id `corbits-skills`) is the plugin this exception exists for. 4. **Tool plugins require explicit consent.** Enabling a `kind: "tool"` plugin prompts a one-time confirmation in `/plugins` before its tools are wired in (they run in-process — the highest-trust surface). Consent is recorded in diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 9f59ff64b..73bbedc6e 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -2,8 +2,13 @@ ## What It Is +Corbits is a local coding harness: it runs multi-agent fleets that implement, +verify, and land software — with progress and cost always visible to the +operator. + A single-process coding agent CLI that autonomously implements features in a codebase. It reads files, writes code, runs tests, and submits work — driven by a deterministic event loop rather than a chat transcript. The agent is backed by an OpenAI-compatible LLM and built on Interchange primitives. It runs as a full-screen terminal UI by default, or as a non-TUI `exec` path for scripts and CI. + ## Why It Exists Existing coding agents stall. They get stuck in thinking loops, read files endlessly without writing, drift from their own plans, or forget to signal completion. The user watches a "Thinking..." spinner and hopes. This tool replaces the chat interface with a deterministic event loop that enforces progress and makes every action — and its cost — visible. @@ -16,7 +21,7 @@ That distinction sets priority. **The harness is core and cannot be swapped in later**, because everything runs inside it. Fleet events waking the director, continuous dispatch while capacity is free, unprompted reporting, aggregated health, a bound grounded in real cost rather than a turn count. This is the part no one can hand us and the part a competitor cannot copy from a directory of prompts. -**Agent personas and skills are content.** They define who gets dispatched and to what standard. They are valuable, they are swappable, and they can ship as a directory long before any packaging system exists. The default engineering set is built in and always enabled — not an optional install, not something an operator has to discover. +**Agent personas and skills are content.** They define who gets dispatched and to what standard. They are valuable, they are swappable, and they ship as the first-party `corbits-skills` plugin — on by default, disable-able in `/plugins`. The default action set (`/implement`, `/plan`, `/refactor`, `/review`, `/pull-request-review`, `/create-issue`, `/scribe`, `/interview`, `/ast-grep`) is not an optional install and not something an operator has to discover. **Distribution is packaging for content**, and content is not the constraint. A catalog and an install surface matter eventually; they do not gate anything the product is actually judged on. @@ -37,8 +42,8 @@ The evidence is in how the product fails today: the personas already produce exc 5. **Resume capability** — Runs persist to a git-backed store and resume from the last point after interruption. 6. **Legible loop** — A live event log, working-tree diff panel, plan tracker, and real-time cost meter show what happened, when, and why. 7. **Operator-in-the-loop** — The agent can call `ask_operator` to pause and ask a clarifying question; the operator answers from a modal (TUI) or via stdin when the product agent runs under `corbits exec`. -8. **Mid-run steering** — Two modes while the agent is running: **Enter** queues the message for delivery at the next turn boundary without stopping the current run; **Alt+Enter** steers by interrupting the current run immediately and starting a new turn with your message. **Ctrl+C** stops the run outright. A badge on the input shows the count of queued messages. A hint line in the input area (`Enter queue · Alt+Enter steer · Ctrl+C stop`) makes the options discoverable. -9. **Session mode (TUI)** — **Single-agent** keeps one primary loop on the wire (no `task` / `search_agents` tools). **Orchestrator** is for chatting with the top agent while it delegates via `task` and manages parallel sub-agents. On first launch, Corbits Code asks once; **Enter** saves to global settings (highlight defaults to single-agent; **Ctrl+C** skips save, runs orchestrator this session only, and the prompt returns on later launches until you save). **Settings → Session** can change global or per-repo defaults, but mode takes effect on the **next** session start (unlike `/model` provider switches). The `exec` path uses the same `sessionMode` resolution as the TUI (global + per-repo settings; defaults to orchestrator when unset). +8. **Mid-run steering** — Two modes while the agent is running, keyed to **whose** idle. **Parent-idle** is when the primary Skywalker turn is not inside an in-flight parent tool; **session-idle** is parent-idle **and** no live fleet lanes. **Enter** soft-steers — delivers at the next **parent** `tool.boundary` without stopping the current run; a long parent `run_shell` or an awaiting `task()` is parent-busy, so Enter is a queued steer, not a new turn. **Alt+Enter** queues a follow-up delivered only on session-idle (`run` goes idle; does not interrupt). Session-idle Alt+Enter is a no-op. A live fleet with a blocked parent is neither parent-idle nor session-idle. Idle-with-fleet (parent goes idle after dispatch so Enter is a turn while workers run) is not shipped. **Ctrl+C** stops the run outright. The notice row shows distinct `steer N` / `follow-up M` badges; when steers are pending and a parent tool has been in flight a few seconds, the notice names that command. Shortcuts are listed in `/help` (`Enter` soft-steer · `Alt+Enter` follow-up · `Ctrl+C` stop). +9. **Orchestrator-only (TUI + exec)** — The primary session is always the orchestrator: it can act directly and delegates via `task` / `search_agents`. Long jobs belong on workers — a parent that runs them itself stays parent-busy and holds Enter steers. Single-agent session mode, the first-run mode picker, and Settings → Session are gone (CL-5814). Legacy `sessionMode` values on disk are ignored. ## User Experience @@ -72,7 +77,9 @@ Local multi-model capability checks use this path (`bun run eval:capability`); s $ corbits resume ``` -Continues from the last saved state in the working directory. +Opens a picker of saved conversations for the working directory. Plain +`corbits` always starts a fresh conversation; `corbits resume ` +is the direct, explicit resume path. ## Safety Model @@ -92,7 +99,9 @@ Continues from the last saved state in the working directory. The TUI has an extensible slash-command framework. Built-ins include `/help` (shortcut + command overlay), `/model` (models-only picker for connected accounts; **Alt+A** adds a provider), `/settings`, `/permissions`, `/plugins`, `/clear`, `/new`, `/mcp`, and `/yolo` (mid-session twin of `--dangerously-skip-permissions`; `/yolo [on|off|toggle]`, bare `/yolo` toggles), plus a `/` command per available workflow. Plugins can register additional commands. -Providers are **models-first**: there is no standalone `/login` command. `/model` opens a **models-only list** (Recent, Favorites, then connected provider/model rows) — type-to-filter owns printable keys, so Connect is never a bare letter. **Alt+A** opens a dedicated add-provider selector over every first-class kind (OpenAI dual-path ChatGPT OAuth or API key, xAI, OpenCode Zen, Anthropic, Google, OpenCode Go, Z.AI Coding Plan, Custom), each annotated with its live account count and never filtered out for “already connected.” **Alt+F** toggles favorite on the highlighted model. Advanced provider drill-down (edit/delete/tiers) stays on the advanced surface, not a bare printable key while the model list is filtering. OAuth providers open their existing browser login with a named account step so multiple accounts per kind coexist (`codex/work`, …). API-key providers use the same named-instance step before the key (auth-only form: instance name + key + fixed catalog base URL), so personal and team keys land as distinct catalog rows (`openai/default`, `anthropic/work`, …); reusing a name re-keys that instance after confirm. Custom remains a free-form single endpoint (full manual form). Successful connect refreshes the catalog and reopens the model list focused on the new account’s default model. OpenCode Go routes each model by its protocol metadata (chat completions, OpenAI responses, or Anthropic messages) and can show subscription usage in the status bar when active (rolling 5h / weekly / monthly windows when the usage API responds; omitted on auth or network failure). When Go returns a quota or rate-limit error — including some HTTP 400 responses that carry limit payloads — Corbits classifies them so quota aborts cleanly and short provider rate limits remain retryable. On a free-tier or subscription quota hit, wait for the window to reset or use OpenCode Zen free models. +**Default skills** exist out of the gate as first-party slash **actions**, not director names: `/implement`, `/plan`, `/refactor`, `/review`, `/pull-request-review`, `/create-issue`, `/scribe`, `/interview`, `/ast-grep`. Each one is a Skywalker recipe — the slash sends the skill body to the primary, which then `task(agent="")`. `/scribe` → shakespeare; `/implement` spawns implement / greybeard / critique as the recipe specifies; `/plan` → plan director (eng change plan: files, AC, non-goals, risks, ordered steps; does not implement); `/review` is a code-review action. `/create-issue` remains the tracker command: Linear MCP when available; otherwise it `ask_operator`s for the platform (GitHub etc.) and persists `Preferred issue tracker` in `.corbits/MEMORY.md` (GitHub via `gh issue create`). Dispatch is not a default slash — it stays `use_skill` only, along with git-rebase, linear-issue-workflow, style, philosophy, typescript, and opsh (`user-invocable: false`). Draper and emil are not slashes; they remain closed directors via `task(agent=…)`. There is no catch-all worker. Slash names are also available to the model via `use_skill`. Disable the catalog in `/plugins` (`corbits-skills`) if you want them gone. + +Providers are **models-first**: there is no standalone `/login` command. `/model` opens a **models-only list** (Recent, Favorites, then connected provider/model rows) — type-to-filter owns printable keys, so Connect is never a bare letter. **Alt+A** opens a dedicated add-provider selector over every first-class kind (OpenAI dual-path ChatGPT OAuth or API key, xAI, OpenCode Zen, Anthropic, Google, OpenCode Go, Z.AI Coding Plan, Custom), each annotated with its live account count and never filtered out for “already connected.” **Alt+F** toggles favorite on the highlighted model. **Alt+D** persists the highlighted pair as the default without switching the live session. Advanced provider drill-down (edit/delete/tiers) stays on the advanced surface, not a bare printable key while the model list is filtering. OAuth providers open their existing browser login with a named account step so multiple accounts per kind coexist (`codex/work`, …). API-key providers use the same named-instance step before the key (auth-only form: instance name + key + fixed catalog base URL), so personal and team keys land as distinct catalog rows (`openai/default`, `anthropic/work`, …); reusing a name re-keys that instance after confirm. Custom remains a free-form single endpoint (full manual form). Successful connect refreshes the catalog and reopens the model list focused on the new account’s default model. OpenCode Go routes each model by its protocol metadata (chat completions, OpenAI responses, or Anthropic messages) and can show subscription usage in the status bar when active (rolling 5h / weekly / monthly windows when the usage API responds; omitted on auth or network failure). When Go returns a quota or rate-limit error — including some HTTP 400 responses that carry limit payloads — Corbits classifies them so quota aborts cleanly and short provider rate limits remain retryable. On a free-tier or subscription quota hit, wait for the window to reset or use OpenCode Zen free models. `/model` opens a dedicated full-screen modal — the single place agent configuration lives. The default view is models-only (Recent / Favorites / connected models); add-provider, tiers, and profiles remain reachable from the same surface without in-list “connect →” rows. A switch applies to the running session immediately (no restart), and can be saved as this project's default (written to the per-repo selection file). Recent and favorite model pairs are stored in global settings (no credentials). @@ -129,21 +138,30 @@ Providers and models are configured in `~/.corbits/settings.json` (holds provide ## Optional Capabilities (plugins) -Capabilities beyond the core toolset are opt-in plugins, enabled per workspace through the `/plugins` UI — nothing is wired in until enabled. +Capabilities beyond the core toolset are opt-in plugins, enabled per workspace through the `/plugins` UI — nothing is wired in until enabled, except the first-party skills catalog (`corbits-skills`), which is on by default and can be turned off in `/plugins`. - **Web search and fetch** — `web_search`/`web_fetch` are always-on built-in core tools, no plugin or API key required. `web_fetch` runs in-process (Bun native `fetch()`) with SSRF guarding, a 5 MB response cap, and HTML-to-markdown conversion; `web_search` calls a keyless hosted MCP provider (Exa by default, Parallel optional). See `docs/ARCHITECTURE.md` for details. ## Multi-agent (sub-agents) -In the TUI, sub-agents are available when **session mode** is **orchestrator** (see value prop #9); **single-agent** mode removes the `task` and `search_agents` tools from the primary session. +The primary session is always **orchestrator** (single-agent mode is gone). Its identity is **Skywalker** (product name remains Corbits Code; when asked its name, answer Skywalker): classify work, dispatch a **closed fleet of 16 directors**, track the fleet, and synthesize. Product mutation tools (`write_file` / `edit_file` / `delete_file`) are not mounted on the primary session — implement and docs workers own durable writes. Residual mutation surfaces remain: `run_shell` stays on the primary (gated; shell file-writes are denied), MCP tools loaded after the primary strip are not re-denied by name. Shipped directors have no package `writePaths`; the optional field still constrains path-keyed product tools (not shell) when a profile sets it. Yolo / skip-permissions still bypasses the write-path gate when enabled. Operator slash recipes (`/implement`, `/plan`, `/refactor`, `/review`, `/pull-request-review`, `/create-issue`, `/scribe`, `/interview`, `/ast-grep`) tell Skywalker which directors to spawn; they do not run the work on the primary. + +| Lane | Directors | +|---|---| +| Primary | skywalker | +| Eng | implement, explore, plan, intern, critique, greybeard, neckbeard, bruckheimer, gaasbot | +| Design | draper, emil, brand-reviewer | +| Docs / QA | shakespeare, testsmith, tester | + +There is **no catch-all worker**. `task` requires `agent=…` or a non-general `intent` (implement/explore/plan/review→critique); bare dispatch and `intent=general` are refused. Named `task(agent=…)` selects a director package without requiring a plugin profile, except `skywalker` which is the primary session identity and is refused as a spawned worker. Nested spawn is runtime-enforced: only skywalker (full fleet allowlist) and greybeard (intern/explore/critique) may spawn; other workers have no `task`. Primary omits an allowlist so plugin profiles remain reachable from the main session. -Corbits Code can fan work out to short-lived **sub-agents** — child agents with their own loop, tools, and checklist — while the primary session stays focused. +Corbits Code fans work out to short-lived **sub-agents** — child agents with their own loop, tools, and checklist — while the primary session stays focused. - **Agents** are runtime entities (primary session or child). - **Tasks** are checklist items owned by one agent via `manage_tasks`. - **Sub-agents** are spawned with the `task` tool (wire name kept; meaning is "spawn a child agent," not "add a checklist item"). -Dispatch uses a structured brief (context / goal / optional goals seed) and returns a structured report. The TUI Agents strip shows who is running; live tool progress updates the status bar without dumping the child transcript into the parent chat. Leaf workers hard-stop after 2 consecutive identical tool calls, when their inference-turn budget is exhausted (default 30; parent can pass `maxTurns` per dispatch; profiles and global settings can raise the default; cap 100), when they finish without ever using tools (never-acted salvage — planning/prose only is not a successful implement), or when `intent=implement` finishes after tools but without any file write/edit/delete (never-edited salvage — a pure-explore plan is not a successful implement). Progressive re-read thrash also hard-stops a leaf that keeps re-reading the same path past a limit; before that hard stop, a soft mid-run nudge asks implement leaves to edit or wrap up (explore leaves: expand findings / change approach — never forced to edit). Each hard stop returns a salvage report so a runaway or idle child cannot quietly burn a large token budget or look done after prose alone. +Dispatch uses a structured brief (context / goal / optional goals seed) and returns a structured report. The TUI Agents strip and fleet board show who is running; live tool progress updates the status bar without dumping the child transcript into the parent chat. Workers hard-stop after 2 consecutive identical tool calls, when their inference-turn budget is exhausted (default 30; parent can pass `maxTurns` per dispatch; profiles and global settings can raise the default; cap 100), when they finish without ever using tools (never-acted salvage — planning/prose only is not a successful implement), or when `intent=implement` finishes after tools but without any file write/edit/delete (never-edited salvage — a pure-explore plan is not a successful implement). Progressive re-read thrash also hard-stops a worker that keeps re-reading the same path (or the same grep) past a limit. Look *volume* is not a stop — an implement may read hundreds of files before the first edit. Before a hard stop, a soft mid-run nudge asks implement workers to edit or wrap up (explore workers: expand findings / change approach — never forced to edit). Each hard stop returns a salvage report so a runaway or idle child cannot quietly burn a large token budget or look done after prose alone. The parent tracks same-brief fingerprints for the session (`src/subagent/brief-dispatch.ts`): after thrash / no-progress / repetition / never-acted / never-edited salvage, an identical re-dispatch is refused — change prompt, agent, intent, success_criteria, and/or do_not to unlock a new run (`maxTurns` or tier alone does not). Turn-budget salvage still allows a few same-brief retries with a higher `maxTurns`, then flips the parent hint to stop and change approach; a successful complete resets the same-brief retry budget. ## Roadmap (planned, not yet shipped) diff --git a/docs/TUI.md b/docs/TUI.md index 79eb5afe5..2b8213c00 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -38,12 +38,16 @@ row at a time down to its 3-row base — never the transcript Horizontally, every surface sits inside one shared gutter (`resolveSideMargin`, `src/tui/geometry/margins.ts`) so the shell reads -as a single column of content rather than stacked panes. The gutter is one -column per side at every width that can afford it, and zero below -`MARGIN_MIN_COLUMNS` (40), where every column belongs to content. There is no -middle tier: one column is already enough to keep content off the frame edge, -which is the gutter's entire job, and anything wider only read as excess air on -a wide pane. The gutter costs no rows. +as a single column of content rather than stacked panes. +`resolveGeometry` always returns `layoutMode: "stack"` — full-width +y-stack, no dual-column rail. The live agents zone stays empty; fleet +status paints as `● Task …` transcript rows instead (see Agents below). +The side gutter is one column per side at every width that can afford it, +and zero below `MARGIN_MIN_COLUMNS` (40), where every column belongs to +content. There is no middle tier: one column is already enough to keep +content off the frame edge, which is the gutter's entire job, and +anything wider only read as excess air on a wide pane. The gutter costs +no rows. Vertically, the same file keeps content off the top and bottom edges with one blank row each: `TOP_PAD_ROWS` above the first transcript row, and @@ -62,7 +66,9 @@ assistant/tool rows are unchanged. The prompt box's border carries the metadata that would otherwise cost a titlebar row: the model label sits right-aligned in the top rule as -`profile · model · effort` (empty segments omitted); the brand +`profile · model · effort` (empty segments omitted), and a +compact `mcp !` sits immediately left of it when any MCP server still needs +authorization (`/mcp` is the surface that names them); the brand lockup sits at the left of the bottom rule with the working directory and git branch at its right (`AppShell.promptTopRule` / `promptBottomRule`, `src/tui/shell.ts`). Both rules cost zero transcript rows because they @@ -73,7 +79,7 @@ activity word — never the raw tool, MCP server, or plugin identifier that is actually executing. `resolveTurnLabel` (`src/tui/session-chrome.ts`) maps execution onto the closed set `ACTIVITY_STATES` exported from that module (`thinking`, `planning`, `researching`, `building`, `working`, -`waiting`, `orchestrating`, `stalled`, `stopping`); that export is the source +`waiting`, `stalled`, `stopping`); that export is the source of truth for what the slot can say, not this list. It is led by a single density cell (`rampPulse`, `src/tui/ramp.ts`). The cell, not the word, is what says whether the session is healthy, and it carries four states: @@ -99,7 +105,7 @@ While sub-agents are running, the slot reports the *fleet*, not the parent. rank it above the parent's own stall clock: with live lanes the parent is idle by design, so its silence says nothing about whether the session is progressing, and reporting it was how a session with every lane wedged still -read as `working`. A fleet with no stalled lane reads `orchestrating`; one +read as `working`. A fleet with no stalled lane reads `working`; one stalled lane makes the whole indicator read `stalled`, which is the state that should pull an operator's eye to the panel. A blocked gate and a stopping turn still outrank the fleet. With zero running sub-agents the roll-up is empty and @@ -133,22 +139,20 @@ removed line), not a decision marker, and no decision-marker shares that row. ## The live task list panel -The `task` chrome zone renders a standing panel above the transcript, one row -per task the task tool has written (`manage_tasks`) — distinct from the -`agents` panel below it. A task is a unit of work with a status; an agent is -an executor with its own context and transcript. The two are never merged -into one panel: `formatTasksPanel` (`src/tui/chrome-state.ts`) and -`formatAgentsPanel` are separate formatters feeding separate zones with -separate row types (`TaskPanelRow` vs. `AgentPanelRow`). - -Each row shows a bracket status marker (`[ ]` todo, `[~]` doing, `[x]` done, -`[-]` cancelled) ahead of the title. Terminal tasks still render — the panel -is a live list of work, not just what remains — so an operator watching it -sees a task move to `[x]` rather than have it silently vanish. The panel is -bounded to `TASKS_PANEL_MAX_VISIBLE` rows, same shape as the agents panel: a -longer list degrades to a trailing `+N more` row rather than growing the zone -without limit, and it shrinks one row at a time under space pressure -(`COLLAPSE_ORDER` in `geometry/zones.ts`) rather than vanishing in one step. +**Parked pending rebuild.** `formatChromeZones` (`src/tui/chrome-state.ts`) +always returns `{ task: null, agents: null }` — neither the checklist strip nor +the agents/fleet board auto-paints. Live work stays on transcript `● Task …` +rows (see below). `formatTasksPanel` / `formatAgentsPanel` remain for a future +rebuild; demos and shell tests may still feed preformatted rows via +`setChromeZones` directly, and Alt+T (`toggleTasksPanel`) still toggles the +shell's hidden flag for those manual paints. + +A task is a unit of work with a status; an agent is an executor with its own +context and transcript. They are never merged into one panel. When the +checklist strip is rebuilt, each row will show a bracket status marker (`[ ]` +todo, `[~]` doing, `[x]` done, `[-]` cancelled) ahead of the title, bounded to +`TASKS_PANEL_MAX_VISIBLE` with a trailing `+N more` under overflow, and +shrinking via `COLLAPSE_ORDER` in `geometry/zones.ts`. Two independent mechanisms keep the task panel from ever costing the prompt box a row on a short terminal, and they guarantee different things. @@ -163,160 +167,56 @@ mechanism substitutes for the other: the cap bounds the prompt's own growth on any terminal, tall or short; the collapse order bounds what other zones are allowed to take from it once the transcript floor is at risk. -The panel is toggleable independent of its live data: `toggleTasksPanel` -(bound to Alt+T) flips a hidden flag held on the shell for its lifetime — in -memory only, nothing written to storage — while -the live task list keeps updating underneath it. Un-hiding shows the current -list, not a stale snapshot from before the hide. Hidden or empty, the zone -costs zero rows. +The panel stays **hidden by default** (CL-5847): a fresh shell does not paint +the checklist. `toggleTasksPanel` (bound to Alt+T) opts in for the shell's +lifetime — it flips a hidden flag held on the shell in memory only — so demos +and tests that call `setChromeZones` with task rows can still show them. +Because `formatChromeZones` parks auto-paint, Alt+T alone does not surface a +live `manage_tasks` list today. The task tool writes state through `ChatDirectorImpl` (`src/agent/director.ts`), which calls `onTasksChange` on every `manage_tasks` tool call and on session -resume (`restoreTasks`). The runner forwards that into the OpenTUI host via -`RunnerHostDeps.chrome`/`subscribeChrome` (`src/tui/runner-host.ts`): -`subscribeChrome` is a required dependency, not optional. The production -caller has always passed a real subscription, so this did not fix an -observed break; it closes a shape that could have been omitted and would -still have type-checked — the same "callback that types fine when absent" -hazard this feature's own callback (`onTasksChange`) is named after in the -tracking issue. `runner-host.test.ts` drives a live `subscribeChrome` notify -end to end and asserts the panel actually repaints, so an omission would now -fail a test as well as the type checker. - -## The live agents panel - -The `agents` chrome zone renders a standing panel above the transcript, one -row per currently-running sub-agent. Each row reads -`agentId: description · elapsed · tool`, sourced from the same -`agentProgress()` clock/tool/stall computation used to trail a task row in the -transcript (`src/tui/agent-progress.ts`); the panel does not compute -progress a second way. Past one running agent the panel is led by a fleet -summary row (`N agents`, plus `· N stalled` or `· in tools`), counted from the -same lane states the rows below render, so header and rows can never disagree. -The zone reserves `AGENTS_PANEL_MAX_VISIBLE + 2` rows to hold that summary, the -lanes, and the `+N more` trailer together — clipping the last of the three -would drop the fold-away count at exactly the fan-out where it is the only -thing reporting the hidden lanes. - -`laneState()` is the single definition of what a lane is doing, and every -surface consumes it rather than comparing timestamps itself. It returns one of -three states: - -| Lane state | Means | Row reads | -|---|---|---| -| `working` | activity within `DEFAULT_STALL_MS` | `· 2:34 · grep` | -| `in_tool` | silent, but a tool call is outstanding and under `IN_TOOL_STALL_MS` | `· 2:34 · run_shell 1:30` | -| `stalled` | silent with nothing outstanding to explain it | `· 2:34 · quiet 0:45 · stalled` | - -`in_tool` is what makes the surface honest. A worker inside one long tool call -emits no events for the entire execution, so silence alone cannot separate a -wedged reactor from a ten-minute test run — and it did not: a fleet whose lanes -were all running shell commands flipped to `stalled` in lockstep while every -one of them was working. `currentToolStartedAt` on the sub-agent session store -(`src/subagent/session-store.ts`) is the fact that separates them; only the -store sets it, because only the store observes a call ending. - -The store keys outstanding calls by call id (`outstandingTools`) and reports -the oldest live one — the call that explains the longest silence. It cannot -collapse to a single scalar: the reactor runs parallel calls concurrently, so a -fast grep finishing beside a ten-minute shell command would retire the shell -command's clock and reproduce the original defect on one lane. A result whose -call id was never seen to start retires nothing. - -`currentToolStartedAt` is a **required** field on every type between the store -and a surface. There are four hand-written mapping hops on the live path, and -a hop that drops it silently reclassifies a busy lane as stalled — which is how -this shipped broken once, caught only by running a real fleet. Required makes -that a compile error rather than a misclassification; `chrome-state.test.ts` -also asserts the panel and the transcript row agree on a live example. - -`in_tool` is bounded, not terminal. A call outstanding longer than -`IN_TOOL_STALL_MS` (10 minutes) escalates to `stalled` regardless, so a wedged -build, a shell blocked on stdin, or a deadlocked child eventually surfaces -instead of reading as busy forever. **Within that window those failures are -genuinely invisible to the stall signal** — the honest trade for not crying -stall over every real test suite. The per-row tool clock climbing is the signal -a human can read in the meantime, which is why the row shows it. The same bound -backstops calls that never report a result at all: the reactor's -approval-suspend path emits no completion, so a before-tool extension returning -suspend would otherwise leave a call outstanding permanently. Nothing registers -such an extension today. - -The number beside a lane's state always explains that state. A healthy lane -shows its lifetime; a lane stuck in one tool also shows how long that tool has -run; a stalled lane also shows how long it has been silent. Reading a lifetime -clock next to the word `stalled` was the original defect — the number the -operator watched climbing was unrelated to the word beside it. - -The panel is bounded to `AGENTS_PANEL_MAX_VISIBLE` rows -(`src/tui/geometry/zones.ts`); a larger fan-out degrades to a trailing -`+N more` row rather than growing the zone — and therefore the chrome -budget — without limit. Its height is requested from the geometry resolver -like every other zone, never guessed: the caller passes the exact row count it -is about to render (`ZoneVisibility.agents: boolean | number`), and the -resolver clamps it to the zone's registered max. Agents that have reached a -terminal state (done/failed/cancelled) do not occupy a row; zero running -agents is zero rows and zero chrome. `observe` mode overrides the panel with a -single `observe: ` line instead of per-agent rows. - -Which agents survive a fan-out past `AGENTS_PANEL_MAX_VISIBLE`, and the order -those survivors render in, are two different questions with two different -answers (`formatAgentsPanel` in `chrome-state.ts`). Selection — which N -agents are shown before the rest fold into `+N more` — keys on staleness -(`lastActivityAt`), so the agent most likely to be stalled is guaranteed a -row rather than the caller's feed order (which sorts running sessions -newest-first) silently hiding it. Presentation — the order the surviving -rows paint in — keys on `startedAt` instead: `lastActivityAt` changes on -every tool event, so sorting the visible rows by it would reshuffle the -panel on every repaint. `startedAt` is stable for the life of a running -agent, with `agentId` as a tiebreak for a simultaneous fan-out. - -Under space pressure, the zone shrinks one row at a time toward 1 rather -than collapsing straight to 0 (`COLLAPSE_ORDER` treats it like `progress`, -not like the single-row `task` strip) — a 1-row panel still carries -the stalest agent plus its `+N more` trailer, so it stays meaningful all -the way down. Only once every other collapsible zone ahead of it in -`COLLAPSE_ORDER` and the panel itself are exhausted does it reach 0, the -same last-resort floor every other optional zone shares. +hydrate. `manage_tasks` calls paint no transcript rows; with chrome strips +parked, that list has no standing chrome surface until rebuild. + +## Live sub-agent rows (Task tool) + +Live workers paint as pending `task` tool rows in the transcript — the +operator-preferred Amp/Codex-style lines: + +``` +● Task Design Lab interview 1:07 · AskUserQuestion +● Task UI variations 0:59 · write_file +``` + +`runtime-bridge` paints each `task` call as a stream row and rewrites it in +place via `syncAgentProgress` / `agentProgress` (elapsed clock, current tool, +stall marker). There is no standing FLEET board and no dual-rail agents chrome: +`formatChromeZones` always returns both zones null (`task` and `agents`), and +geometry is stack-only (`layoutMode: "stack"`, `railWidth: 0`). Checklist and +agents strips are parked pending rebuild; Alt+T / direct `setChromeZones` may +still paint for demos and tests. ### Unprompted fleet reports -The agents panel is a standing picture of what is running right now; it says -nothing when a lane finishes, stalls, or fails unless the operator interrupts -to ask. `src/subagent/fleet-report.ts` closes that gap with its own channel: -a system-notice line, pushed into the transcript through the same -`surfaceSystemNotice` path as any other system row, the moment a lane -transition is worth saying. It does not touch the panel's rows or its -`laneState()` computation — it reads the same sub-agent session store the -panel reads, and calls the same `agentProgress()` stall definition -(`isStalled`) so the two surfaces never disagree about whether a lane is -stalled, only about *when* they say so: the panel shows it continuously, -the notice announces the transition once. - -Store changes drive it directly, so a lane finishing or failing lands the -moment it happens. A `FLEET_REPORT_SETTLE_MS` (400ms) timer lets a parallel -dispatch that lands as N store changes settle into one observation instead -of N lines. Quiet detection is separate: `FLEET_STALL_POLL_MS` (5s) re-runs -observation so a lane that went quiet with no further store event is still -announced once. Past `COALESCE_ABOVE` (3) changes in one observation the -individual lines collapse into a single tally (`"9 done, 3 failed"`); below -that threshold each change gets its own line. The one case both the fleet -going idle and a coalesced tally would otherwise say the same thing — -all changes are terminal and the tally alone already says "N done, N -failed" — the idle line replaces the tally instead of repeating it with -"— nothing running" tacked on. - -Outcomes and errors are clipped to `OUTCOME_CHARS`/`MAX_UPDATE_CHARS` on the -same "one update is one row, never wrapped" rule the panel's rows follow. -`fleetDigest()` is the on-demand counterpart: the same picture in one line, -answering "where is the fleet" without an interrupt, for `/status` or an -operator question mid-run. +Parent prose owns success narratives. Transcript fleet notices exist only for +attention live Task rows cannot keep: a lane **failed** while other work is +still running, and **one** dry-fleet line when the last lane finishes +(`N done · nothing running`). Per-lane `done — summary` walls and live +`dispatched` re-announcements are never printed. + +`src/subagent/fleet-report.ts` is pure: it reads the same sub-agent session +store and the same `agentProgress()` stall definition. Store changes drive it; +a `FLEET_REPORT_SETTLE_MS` (400ms) timer lets a parallel dispatch settle into +one observation. Quiet detection uses `FLEET_STALL_POLL_MS` (5s). Past +`COALESCE_ABOVE` (3) attention events in one observation, lines collapse into +a single tally. Errors clip to `OUTCOME_CHARS`/`MAX_UPDATE_CHARS` on the +"one update is one row" rule. `fleetDigest()` is the on-demand counterpart +for `/status` or an operator question mid-run. ## How pop-ups should feel A blocking surface (permissions, an operator question, the model/provider -picker, settings, help, the `/` command list, …) shares one overlay host and -one height path — there is no second modal stack with independent row accounting (`src/tui/geometry/resolve.ts`, `src/tui/shell.ts:openListOverlay`). Opening a second surface either replaces the one that was open or stacks over it; either way Escape always @@ -343,6 +243,16 @@ because an earlier version could abandon the awaited promise on Escape and leave the session parked with no recovery path short of killing the process; Escape must always settle the promise it is dismissing. +Once a permission or operator prompt is answered — or cancelled, timed out, +or auto-settled by a grant / abort / teardown — it leaves the screen and +does **not** replay the request, the command, or the chosen option into the +transcript. The overlay is the question; the tool row that follows is the +outcome. Grey `permission` / `operator` recap cards restated the same ask +after it was already decided. Expanding a collapsed payload while the +overlay is open still writes the full payload into the scrollable +transcript, because that text would otherwise be unreachable before +approval. + The decision surfaces (permission approval, operator question) are the one framed content in the shell, and they are shaped rather than merely listed (`src/tui/overlay-body.ts`): a dithered header (`░▒▓`) carries the @@ -372,16 +282,25 @@ are painted at the geometry resolver's shared `contentWidth` leading marker column and no per-row kind column; the selected row is marked by text color only (`paintPaletteList` in `shell.ts`: "the highlighted row already stands out by sitting under the cursor, so a leading `>` and a grey -block would both be saying the same thing twice"). The list also paints with -no title rule — the filter row (`> query`) directly under the box already -shows what was typed, so a second header line would say nothing new -(`repaintPalette`). +block would both be saying the same thing twice"). Rows stay name-only +(`/help`, `/model`); the focused command's registry description paints in the +shared two-line description zone under the list (`openListOverlay({ describe })`, +`paintDescriptionZone` in `shell.ts`). A missing or blank `description` still +reserves the zone (rule plus two blank lines); it does not collapse. Built-ins +have copy; this is the empty-description edge. The list also paints with no +title rule — slash-popup query lives in the prompt, so an orphan `>` filter +row under the box would be chrome that says nothing the prompt isn't already +showing (`repaintPalette`). ## Slash commands and pickers `/` at an empty prompt opens the command list, narrowed by name prefix as -more is typed; Tab completes the name so arguments can be typed, Enter runs -it. Every entry is backed by the live command registry +more is typed (`cmd.id` in `openSlashCommands`); Tab completes the name so +arguments can be typed, Enter runs it. The query lives in the prompt — list +chrome is in How selectors should work above. When the prefix matches +nothing, the overlay closes and the prompt is left as typed: `/` then `z` +with no `z…` command vanishes the list and leaves `/z`. Slash never paints +a `(no matches)` row. Every entry is backed by the live command registry (`src/tui/command-catalog.ts:commandItemsFromRegistry`) — there is no separate palette overlay and no shell-owned action outside the registry. The overlay this reuses is still internally called `"palette"` (`shell.ts`'s @@ -420,10 +339,13 @@ known, accepted cost of the badge rather than an oversight — see The model/provider picker is one flat, type-to-filter list (`src/tui/product-host.ts` + `openModelPickerOverlay({ typeToFilter: true })`): recent and favorite provider+model pairs sit at the top, then every -`provider / model` leaf from the catalog. Typing narrows the list in place -(printable keys claimed by the filter row, same pattern as the command -palette); Enter selects. Escape closes the picker. The row matching the -session's live active model gets a `(current)` suffix. Alt+F on a model row +`model * [provider]` leaf from the catalog. Typing narrows the list in place +(printable keys claimed by the picker's own `>` filter row); Enter selects +for this session. Escape closes the picker. The row matching the session's +live active model gets a `(current)` suffix. **Alt+D** persists the focused +pair as the default (global `defaultProvider` + that provider's `defaultModel` ++ project-local selection) without switching the live session or closing the +picker. Alt+F on a model row still toggles favorite when a favorite hook is wired. While type-to-filter is active, bare `j`/`k` type into the filter rather than moving the highlight — use arrow keys (or the filtered list's navigation) to move. @@ -472,23 +394,28 @@ Enter and Shift+Enter, so on those Shift+Enter silently does nothing — driven live, this is exactly what happens, not a hypothetical. Ctrl+Enter/Ctrl+J are the chord to point an operator at when Shift+Enter doesn't respond. -### Queue-and-steer vs. stop-and-reinject - -There used to be two gestures that both waited for a run to reach a turn -boundary before delivering — a bug in its own right, since an operator had no -way to tell them apart from the result. There are now two gestures with two -different effects: - -- **Enter, mid-run** — queues the message and delivers it at the next turn - boundary, where it steers the run. The queued row in the transcript says - `[will steer next]` while pending and `[steering]` once delivered, so the - operator sees what will happen to it, not just a badge count - (`submitPrompt`, `drainAtBoundary` in `runtime-bridge.ts`). -- **Alt+Enter, mid-run** — stops the run immediately, without waiting for a - boundary, and restarts from this message. A `stop — restarting from your - message` system row and a `[restarted here]` user row mark the cut. Idle, - or with an empty prompt, Alt+Enter does nothing — there is nothing to stop - or restart from. +### Soft steer vs. follow-up + +Two mid-run gestures, two delivery times (CL-6290): + +- **Enter, mid-run** — soft steer: enqueues kind `"steer"` and delivers at the + next **parent** `tool.boundary` (the parent tool finishing, not a child). A + long parent `run_shell` or an awaiting `task()` is parent-busy and holds + steers. The transcript row says `[will steer next]` while pending and + `[steering]` once delivered (`submitPrompt`, `drainSteersAtBoundary` in + `runtime-bridge.ts`). +- **Alt+Enter, mid-run** — follow-up: enqueues kind `"queue"` and delivers + only on **session-idle** (parent-idle and no live fleet lanes). Does not + interrupt or reinject. The transcript row says `[will follow up]` while + pending and `[following up]` once delivered. Idle, or with an empty prompt, + Alt+Enter does nothing — there is nothing to wait for. (Internal `"reinject"` + remains in the submit API for tests; no product chord wires it.) + +When `steer > 0` and a parent tool has been in flight ≥ `STEER_WAIT_NOTICE_MS` +(3s), the notice row adds `waiting on ` (e.g. `waiting on run_shell`). +Follow-up-only does not; a sub-threshold in-flight tool does not. Delivery is +unchanged. Idle-with-fleet is not shipped — Enter stays a queued steer until +the parent tool finishes, not a new turn while workers run. Interrupting (Ctrl+C) never discards a queued or steered message. It used to — the transcript literally said `interrupt — discarded N pending`, and an @@ -499,17 +426,19 @@ at the interrupt itself (`doInterrupt` drains after `port.interrupt()`), not left waiting on an idle event the stop may never produce (`interrupt` in `session-queue.ts` no longer clears `items`). -**Sub-agent lanes on redirect.** Both Ctrl+C and Alt+Enter interrupt by -closing the underlying agent (`runner.ts`'s `interrupt()` — "the only thing -that aborts the reactor mid-inference"). That close cascades: it aborts the -shared operation signal the `task` tool was given, which the tool forwards to -the child agent's own controller, so an in-flight sub-agent dispatch is -aborted along with the parent's turn and reports back as cancelled by the -operator rather than being left to finish silently detached -(`src/subagent/task-tool.ts`). Redirecting the parent — by either gesture — -is a decision to stop the fleet it dispatched too, not just the parent's own -turn; there is no path today to redirect the parent while leaving running -lanes alone. +**Sub-agent lanes on redirect.** Soft steer (Enter mid-run) and follow-up +(queued drain) leave running workers alone — they never call +`runner.ts`'s `interrupt()`, so the parent's operation signal stays live and +in-flight `task` dispatches keep running. Ctrl+C is the explicit fleet +teardown: `doInterrupt` → `port.interrupt()` → `currentAgent.close()` aborts +the shared operation signal the `task` tool was given, which the tool +forwards to the child agent's own controller, so an in-flight sub-agent +dispatch is aborted along with the parent's turn and reports back as +cancelled by the operator rather than being left to finish silently detached +(`src/subagent/task-tool.ts`). `/clear` and session exit still call +`subAgentSessions.cancelAll` for an explicit session-wide cancel; that path +is separate from interrupt and must stay off the soft-steer / follow-up +gestures. Up/Down are caret motion first inside a multi-line buffer. History recall only fires when the caret is already at the first or last wrapped row of the @@ -555,12 +484,12 @@ Ctrl+C interrupts a busy run (or clears a non-empty idle prompt); a second Ctrl+C within a 2-second window (`CTRL_C_EXIT_WINDOW_MS`) quits — this replaced an Ink-era yes/no exit-confirm modal with the same intent (an explicit second confirmation) without adding a modal (`handleCtrlC`, -`shell.ts`). See "Queue-and-steer vs. stop-and-reinject" above for the two +`shell.ts`). See "Soft steer vs. follow-up" above for the two mid-run gestures and what interrupting does to sub-agent lanes. The interrupt keeps whatever is sitting in the queue rather than discarding it — the operator typed those messages meaning them delivered, not meaning "cancel this run and also throw away what I typed"; the transcript row says so -(`"interrupt — N pending kept"`). Kept items are handed over at the +(`"N pending kept"`). Kept items are handed over at the interrupt itself (`doInterrupt` in `runtime-bridge.ts` drains after `port.interrupt()`), serialized behind the agent rebuild the stop starts — a stop does not reliably produce an idle event to drain against later. diff --git a/docs/VENDORING.md b/docs/VENDORING.md index 09285dba7..7b2f028ed 100644 --- a/docs/VENDORING.md +++ b/docs/VENDORING.md @@ -23,9 +23,9 @@ points straight at `./src/*.ts` files rather than a `dist/` build. | Package | Vendor path | License | Synced from upstream commit | Retrieved | Local patches | |---|---|---|---|---|---| -| `@intx/inference` | `vendor/intx-inference/` | LGPL-2.1-only | `cd7c5a37747dc39713d1efd24296ea861e6ac82a` | 2026-08-08 | Yes — see `vendor/intx-inference/PATCHES.md` | -| `@intx/types` | `vendor/intx-types/` | LGPL-2.1-only | `cd7c5a37747dc39713d1efd24296ea861e6ac82a` | 2026-08-08 | None — verbatim | -| `@intx/storage-isogit` | `vendor/intx-storage-isogit/` | LGPL-2.1-only | `cd7c5a37747dc39713d1efd24296ea861e6ac82a` | 2026-08-08 | None — verbatim | +| `@intx/inference` | `vendor/intx-inference/` | LGPL-2.1-only | `ad0f99e7977b3ad4f28d8cc8d446ac52a4a2d685` | 2026-08-10 | Yes — see `vendor/intx-inference/PATCHES.md` | +| `@intx/types` | `vendor/intx-types/` | LGPL-2.1-only | `ad0f99e7977b3ad4f28d8cc8d446ac52a4a2d685` | 2026-08-10 | None — verbatim | +| `@intx/storage-isogit` | `vendor/intx-storage-isogit/` | LGPL-2.1-only | `ad0f99e7977b3ad4f28d8cc8d446ac52a4a2d685` | 2026-08-10 | None — verbatim | The license column records what each package declares in its own `package.json`; the corresponding `LICENSE` file travels with every vendored diff --git a/evals/capability/README.md b/evals/capability/README.md index ad375b7c5..678301eba 100644 --- a/evals/capability/README.md +++ b/evals/capability/README.md @@ -2,16 +2,28 @@ Local, multi-model capability checks against the **product** agent path (`corbits exec`), not the scripted integration harness. +**Inspired by** patterns from [SWE-bench](https://www.swebench.com/) (issue → patch → tests), [Terminal-Bench](https://www.tbench.ai/) (agent shell workflows), and [LiveCodeBench](https://livecodebench.github.io/) (coding task suites). This suite uses small hermetic fixtures and `verify.sh` graders; it does **not** run those external harnesses. + ## What this measures Whether a real model + our directors/tools can complete small coding tasks on fixture repos. Graders are objective shell scripts (`verify.sh`) — pass/fail, not LLM-as-judge. +Eval workdirs are initialized as git repositories (HEAD exists) so isolated workers and git-aware skills have a baseline. + One run can **try different things**: multiple cases × multiple provider/model variants (matrix), with every product-path metric we can record written into the results JSON. | Tier | Case | Fixture | Intent | |------|------|---------|--------| | simple | `simple-health` | `tests/fixtures/multi-file-service` | Single-file route + test | -| complex | `complex-jwt` | `tests/fixtures/demo-comparison` | Multi-file auth middleware + tests | +| complex | `complex-jwt` | `tests/fixtures/demo-comparison` | Multi-file auth middleware + tests (sync API contract) | +| complex | `complex-stock-gate` | `tests/fixtures/demo-comparison` | Multi-file stock-gated orders + mutable state | +| complex | `complex-idempotent-orders` | `tests/fixtures/demo-comparison` | Idempotency-Key header + multi-file order store | +| complex | `complex-bugfix` | `tests/fixtures/buggy-service` | Issue→patch→tests: fix failing post GET without breaking users | +| complex | `complex-pagination` | `tests/fixtures/demo-comparison` | Multi-file feature: query pagination on GET /products | +| complex | `complex-rename-user` | `tests/fixtures/multi-file-service` | Refactor/rename user `name` → `displayName` across files | +| complex | `complex-dispatch-spawn` | `tests/fixtures/multi-file-service` | Dispatch GET /readyz via `task`; grader checks the route, not that the primary skipped DIY | +| complex | `complex-recall-after-bulk-read` | `tests/fixtures/large-read` | Read many fixture files then write the planted token; does not assert compaction fired | + | bait | `loop-bait` | `tests/fixtures/large-read` | Open-ended research; catches repeated-search loops | | bait | `web-bait` | `tests/fixtures/web-note` | Fetch from a hermetic local HTTP page; catches curl/wget instead of `web_fetch` | | bait | `env-bait` | `tests/fixtures/env-config-build` | Build configured via file; catches `FOO=bar cmd` env prefixes | @@ -72,6 +84,7 @@ shell parser. | `maxChainSegmentsPerCommand` | largest chain in one command | lower is better | | `networkCommandCount` | segments invoking curl/wget/nc/... | lower is better | | `webFetchToolCallCount` | `web_fetch` tool calls (0 when the tool is absent or unused) | informational | +| `taskToolCallCount` | `task` tool calls (0 when the tool is absent or unused) | informational | | `editViaShellCount` | sed/perl/awk `-i` edits or heredoc writes | lower is better | | `repeatedSearchCount` | tool calls repeating an earlier call's name with normalized-equal arguments | lower is better | | `longestToolOnlyStreak` | longest run of assistant turns with tool calls and no text | lower is better | @@ -80,35 +93,40 @@ shell parser. ## Prerequisites -- Configured provider (same as interactive `corbits`) +- A configured provider matching the CLI `--provider` / `--model` (or `--matrix` cells) - Network access for inference - Bun Evals default to `--dangerously-skip-permissions` so the agent can write without a human at the gate. Override with `--ask-permissions` if you want the non-interactive deny path. +## Provider/model + +CLI `--provider ` and `--model ` are required for every run, including `--dry-run`. Alternatively, pass `--matrix` with complete `provider:model` cells. Local `.corbits/settings.json` is never the implicit eval target. + ## Run ```bash -# All cases with the configured default provider/model -bun run eval:capability +# All cases — explicit provider/model required +bun run eval:capability -- --provider --model # One case, explicit model -bun run eval:capability -- --case simple-health --provider xai/thegreataxios --model grok-4.5 +bun run eval:capability -- --case simple-health --provider xai --model grok-4.5 # Multi-model matrix (cases × variants) bun run eval:capability -- \ - --matrix "xai/thegreataxios:grok-4.5,openai:gpt-4.1" \ + --matrix "xai:grok-4.5,openai:gpt-4.1" \ --out evals/capability/results/matrix.json # Labeled variants bun run eval:capability -- --matrix "fast=xai:grok-4.5,strong=openai:gpt-4.1" # Baseline improve/regress (keys by variantId::caseId) -bun run eval:capability -- --out evals/capability/results/run2.json \ +bun run eval:capability -- --provider --model \ + --out evals/capability/results/run2.json \ --baseline evals/capability/results/run1.json # Gate run: 5 repeats per cell against the frozen baseline -bun run eval:capability -- --repeats 5 \ +bun run eval:capability -- --provider --model --repeats 5 \ --out evals/capability/results/candidate.json \ --baseline evals/capability/results/baseline-0286.json ``` @@ -118,8 +136,8 @@ bun run eval:capability -- --repeats 5 \ Any change intended to shift agent behavior (prompts, directors, tools) is confirmed here, not by anecdote: -1. Run the suite with `--repeats 5` (repeats smooth model variance; a single - run of a bait case proves nothing). +1. Run the suite with `--provider` / `--model` (or `--matrix`) and `--repeats 5` + (repeats smooth model variance; a single run of a bait case proves nothing). 2. Compare against the frozen baseline (`evals/capability/results/baseline-0286.json`) with `--baseline`. 3. Read the verdicts: any pass-rate change per cell is significant; behavior @@ -137,8 +155,8 @@ Flags: | Flag | Meaning | |------|---------| | `--case ` | Case id or `all` (default) | -| `--provider` / `--model` | Single-variant override via `loadConfig` | -| `--matrix ` | Multi-variant: `p:m,p2:m2` or `label=p:m` (comma-separated) | +| `--provider ` / `--model ` | Required unless `--matrix`. Single-variant via `loadConfig`. Not inferred from local settings | +| `--matrix ` | Alternative to `--provider`/`--model`. Multi-variant: `p:m,p2:m2` or `label=p:m` (comma-separated). Every cell must include both sides | | `--config ` | Settings file override (CI injection) | | `--out ` | Write machine-readable results JSON | | `--baseline ` | Compare this run to a prior results file (improve/regress + metric deltas) | @@ -147,7 +165,7 @@ Flags: | `--agent-timeout-ms ` | Wall-clock limit for `runExec` (default `600000`, env `CORBITS_EVAL_AGENT_TIMEOUT_MS`) | | `--verify-timeout-ms ` | Wall-clock limit for `verify.sh` (default `120000`, env `CORBITS_EVAL_VERIFY_TIMEOUT_MS`) | | `--repeats ` | Runs per case×variant cell (default `1`; gate runs use `5`, baseline freezes `3`). Results record every repeat plus per-cell aggregates | -| `--dry-run` | Load cases × variants and print plan; no inference | +| `--dry-run` | Load cases × variants and print plan; no inference. Still requires `--provider`/`--model` or `--matrix` | ## Case format @@ -169,6 +187,7 @@ verify.sh # objective grader (exit 0 = pass) - `verify` — grader filename (default `verify.sh`) - `bait` — optional `{ metric, threshold }` marking the behavior metric this case reproduces (see the bait table above) - `httpFixture` — when `true`, the runner starts a hermetic HTTP server on `127.0.0.1` (ephemeral port, per-run token), substitutes `{{HTTP_URL}}` in the prompt, and passes `EVAL_HTTP_URL` / `EVAL_HTTP_TOKEN` to `verify.sh`. The server is stopped when the case run ends — nothing external is contacted +- `requireBehaviors` — optional `[{ metric, min?, max? }]`. After the run, each listed metric must fall in range or the case fails. Missing capture with a non-empty list fails closed ## Results JSON (v3) @@ -233,6 +252,7 @@ verify.sh # objective grader (exit 0 = pass) "maxChainSegmentsPerCommand": 2, "networkCommandCount": 0, "webFetchToolCallCount": 0, + "taskToolCallCount": 0, "editViaShellCount": 0, "repeatedSearchCount": 0, "longestToolOnlyStreak": 2, diff --git a/evals/capability/behaviors.test.ts b/evals/capability/behaviors.test.ts index 060b2c32b..52da053d4 100644 --- a/evals/capability/behaviors.test.ts +++ b/evals/capability/behaviors.test.ts @@ -155,6 +155,35 @@ describe("deriveBehaviorMetrics", () => { expect(metrics.webFetchToolCallCount).toBe(0); }); + test("counts task tool calls separately", () => { + const metrics = deriveBehaviorMetrics( + summary([ + turn({ + toolCalls: [{ name: "task", arguments: { intent: "implement", prompt: "add /readyz" } }], + }), + turn({ toolCalls: [{ name: "web_fetch", arguments: { url: "http://x" } }] }), + ]), + ); + expect(metrics.taskToolCallCount).toBe(1); + expect(metrics.webFetchToolCallCount).toBe(1); + }); + + test("task count is 0 when the tool is never called", () => { + const metrics = deriveBehaviorMetrics(summary([shellTurn("ls")])); + expect(metrics.taskToolCallCount).toBe(0); + }); + + test("does not collide distinct name+argument fingerprints", () => { + // Concatenating name + JSON args would make tool1/23 and tool12/3 identical. + const metrics = deriveBehaviorMetrics( + summary([ + turn({ toolCalls: [{ name: "tool1", arguments: 23 }] }), + turn({ toolCalls: [{ name: "tool12", arguments: 3 }] }), + ]), + ); + expect(metrics.repeatedSearchCount).toBe(0); + }); + test("counts shell edits via sed -i and heredoc", () => { const metrics = deriveBehaviorMetrics( summary([shellTurn("sed -i '' 's/-/=/g' src/banner.ts && cat > note.md << EOF")]), @@ -206,6 +235,8 @@ describe("deriveBehaviorMetrics", () => { test("empty run yields zeroed metrics", () => { const metrics = deriveBehaviorMetrics(summary([])); expect(metrics.shellCommandCount).toBe(0); + expect(metrics.webFetchToolCallCount).toBe(0); + expect(metrics.taskToolCallCount).toBe(0); expect(metrics.longestToolOnlyStreak).toBe(0); expect(metrics.toolCallsByName).toEqual({}); }); @@ -240,4 +271,18 @@ describe("parseBehaviorMetrics", () => { expect(parseBehaviorMetrics(undefined)).toBeNull(); expect(parseBehaviorMetrics({ shellCommandCount: "many" })).toBeNull(); }); + + test("defaults missing taskToolCallCount from the per-name map", () => { + const metrics = deriveBehaviorMetrics( + summary([turn({ toolCalls: [{ name: "task", arguments: {} }] })]), + ); + const { taskToolCallCount: _dropped, ...legacy } = metrics; + expect(parseBehaviorMetrics(legacy)?.taskToolCallCount).toBe(1); + }); + + test("defaults missing taskToolCallCount to 0 when task was never called", () => { + const metrics = deriveBehaviorMetrics(summary([shellTurn("ls")])); + const { taskToolCallCount: _dropped, ...legacy } = metrics; + expect(parseBehaviorMetrics(legacy)?.taskToolCallCount).toBe(0); + }); }); diff --git a/evals/capability/behaviors.ts b/evals/capability/behaviors.ts index 90c2a8b4a..a904f4e08 100644 Binary files a/evals/capability/behaviors.ts and b/evals/capability/behaviors.ts differ diff --git a/evals/capability/cases/complex-bugfix/case.json b/evals/capability/cases/complex-bugfix/case.json new file mode 100644 index 000000000..ae2851e42 --- /dev/null +++ b/evals/capability/cases/complex-bugfix/case.json @@ -0,0 +1,9 @@ +{ + "id": "complex-bugfix", + "tier": "complex", + "title": "Fix failing post GET without breaking users", + "fixture": "tests/fixtures/buggy-service", + "prompt": "Bug report: the service ships with a failing post suite. `bun test` currently fails on post routes while user routes pass.\n\nObserved behavior:\n- GET /posts (list) still returns the post list.\n- GET /posts/:id does not return the post for a known id such as p1. The response is wrong — callers that expect a post (id, title, body, authorId) do not get that shape.\n- GET /users and GET /users/:id continue to work.\n\nExpected behavior (also encoded in tests/post.test.ts):\n- GET /posts/p1 returns the post { id: \"p1\", title: \"Hello\", body: \"World\", authorId: \"u1\" } as a JSON string via handleRequest.\n- GET /posts/p2 returns the second post.\n- Unknown post ids return {\"error\":\"not found\"}.\n- User routes must remain green.\n\nPlease locate and fix the defect so `bun test` is fully green. Keep the public handleRequest(method, path) → string API (sync string responses). Do not break users. Prefer a minimal correct fix over a rewrite.", + "maxTurns": 30, + "verify": "verify.sh" +} diff --git a/evals/capability/cases/complex-bugfix/verify.sh b/evals/capability/cases/complex-bugfix/verify.sh new file mode 100755 index 000000000..665835a10 --- /dev/null +++ b/evals/capability/cases/complex-bugfix/verify.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# Behavioral grader: buggy-service post GET fixed; all tests green. +# +# Contract: +# handleRequest(method, path) → string (JSON) (sync) +set -euo pipefail + +if [[ ! -f package.json ]]; then + echo "FAIL: package.json missing in workdir" + exit 1 +fi + +bun -e ' +import { existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { resolve } from "node:path"; + +const entryCandidates = ["./src/index.ts", "./src/index.js"]; +let mod = null; +for (const c of entryCandidates) { + if (!existsSync(resolve(c))) continue; + try { + mod = await import(pathToFileURL(resolve(c)).href); + break; + } catch (e) { + console.error("import failed for", c, e); + } +} +if (mod === null || typeof mod.handleRequest !== "function") { + console.error("FAIL: handleRequest not importable from src/index"); + process.exit(1); +} + +const handle = mod.handleRequest; + +function assertSync(label, res) { + if (res != null && typeof res.then === "function") { + console.error(`FAIL: ${label} — returned a Promise; must stay sync`, res); + process.exit(1); + } +} + +function call(method, path) { + let res; + try { + res = handle(method, path); + } catch (e) { + console.error("FAIL: handleRequest threw", e); + process.exit(1); + } + assertSync(`${method} ${path}`, res); + if (typeof res !== "string") { + console.error(`FAIL: ${method} ${path} must return a string, got`, typeof res, res); + process.exit(1); + } + return res; +} + +// 1) GET /posts/p1 returns post shape, not user +const p1raw = call("GET", "/posts/p1"); +let p1; +try { + p1 = JSON.parse(p1raw); +} catch { + console.error("FAIL: /posts/p1 not JSON", p1raw); + process.exit(1); +} +if (p1.id !== "p1" || p1.title !== "Hello" || p1.body !== "World" || p1.authorId !== "u1") { + console.error("FAIL: /posts/p1 wrong post payload", p1); + process.exit(1); +} +if (p1.email !== undefined || p1.name !== undefined) { + console.error("FAIL: /posts/p1 looks like a user shape", p1); + process.exit(1); +} + +// 2) GET /posts/p2 +const p2raw = call("GET", "/posts/p2"); +let p2; +try { + p2 = JSON.parse(p2raw); +} catch { + console.error("FAIL: /posts/p2 not JSON", p2raw); + process.exit(1); +} +if (p2.id !== "p2" || p2.title !== "Second") { + console.error("FAIL: /posts/p2 wrong payload", p2); + process.exit(1); +} + +// 3) Unknown post +const missing = JSON.parse(call("GET", "/posts/missing")); +if (missing.error !== "not found") { + console.error("FAIL: unknown post should be not found", missing); + process.exit(1); +} + +// 4) Users still work +const u1 = JSON.parse(call("GET", "/users/u1")); +if (u1.name !== "Alice") { + console.error("FAIL: users broken after fix", u1); + process.exit(1); +} + +// 5) Full suite green +const test = spawnSync("bun", ["test"], { encoding: "utf8" }); +if (test.status !== 0) { + console.error(test.stdout || ""); + console.error(test.stderr || ""); + console.error("FAIL: bun test failed"); + process.exit(1); +} + +console.log("PASS: post GET returns post shape, users green, bun test green"); +' diff --git a/evals/capability/cases/complex-dispatch-spawn/case.json b/evals/capability/cases/complex-dispatch-spawn/case.json new file mode 100644 index 000000000..6f143d6af --- /dev/null +++ b/evals/capability/cases/complex-dispatch-spawn/case.json @@ -0,0 +1,10 @@ +{ + "id": "complex-dispatch-spawn", + "tier": "complex", + "title": "Dispatch /readyz implementation via task", + "fixture": "tests/fixtures/multi-file-service", + "prompt": "Add GET /readyz returning JSON {\"ready\":true} via handleRequest, plus a unit test. You MUST dispatch the implementation with the task tool (intent implement). Do not implement the route yourself in the primary session. After the leaf returns, confirm the route works.", + "maxTurns": 40, + "verify": "verify.sh", + "requireBehaviors": [{ "metric": "taskToolCallCount", "min": 1 }] +} diff --git a/evals/capability/cases/complex-dispatch-spawn/verify.sh b/evals/capability/cases/complex-dispatch-spawn/verify.sh new file mode 100755 index 000000000..f7415725e --- /dev/null +++ b/evals/capability/cases/complex-dispatch-spawn/verify.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Behavioral grader: readyz endpoint must return ready; fixture tests must pass. +# Workdir is the fixture copy (eval runner sets cwd). +# Existing /health is not required. +set -euo pipefail + +if [[ ! -f package.json ]]; then + echo "FAIL: package.json missing in workdir" + exit 1 +fi + +bun -e ' +import { existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { resolve } from "node:path"; + +const candidates = [ + "./src/index.ts", + "./src/index.js", + "./src/routes/readyz.ts", + "./src/routes/readyz.js", +]; + +let mod = null; +let loaded = ""; +for (const c of candidates) { + const abs = resolve(c); + if (!existsSync(abs)) continue; + try { + mod = await import(pathToFileURL(abs).href); + loaded = c; + break; + } catch { + // keep trying + } +} +if (mod === null) { + console.error("FAIL: could not import fixture entry (src/index or readyz route)"); + process.exit(1); +} + +function bodyLooksReady(body) { + if (body == null) return false; + if (typeof body === "string") { + try { + const j = JSON.parse(body); + if (j && (j.ready === true || /\breadyz?\s+ok\b/i.test(JSON.stringify(j)))) return true; + } catch { + /* plain string */ + } + return /\breadyz?\s+ok\b/i.test(body); + } + if (typeof body === "object") { + if (body.ready === true) return true; + if (body.body !== undefined) return bodyLooksReady(body.body); + } + return false; +} + +const handle = + typeof mod.handleRequest === "function" + ? mod.handleRequest + : typeof mod.handleReadyz === "function" + ? mod.handleReadyz + : null; + +if (handle === null) { + console.error("FAIL: no handleRequest/handleReadyz export in", loaded); + process.exit(1); +} + +let res; +try { + res = handle.length >= 2 ? handle("GET", "/readyz") : handle(); +} catch (e) { + console.error("FAIL: handler threw:", e); + process.exit(1); +} + +const status = typeof res === "object" && res !== null && "status" in res ? res.status : 200; +const body = typeof res === "object" && res !== null && "body" in res ? res.body : res; + +if (status !== 200) { + console.error("FAIL: expected status 200, got", status, "body=", body); + process.exit(1); +} +if (!bodyLooksReady(body)) { + console.error("FAIL: body does not indicate ready:", body); + process.exit(1); +} + +const test = spawnSync("bun", ["test"], { encoding: "utf8" }); +if (test.status !== 0) { + console.error(test.stdout || ""); + console.error(test.stderr || ""); + console.error("FAIL: bun test failed"); + process.exit(1); +} +console.log("PASS: /readyz returns ready and bun test green"); +' diff --git a/evals/capability/cases/complex-idempotent-orders/case.json b/evals/capability/cases/complex-idempotent-orders/case.json new file mode 100644 index 000000000..0ed5c731e --- /dev/null +++ b/evals/capability/cases/complex-idempotent-orders/case.json @@ -0,0 +1,9 @@ +{ + "id": "complex-idempotent-orders", + "tier": "complex", + "title": "Idempotent POST /orders via Idempotency-Key header", + "fixture": "tests/fixtures/demo-comparison", + "prompt": "Add idempotent order creation. Extend handleRequest so the signature is handleRequest(method, path, body?, headers?: Record) and it remains synchronous returning a plain Response { status, body } — never async and never a Promise. For POST /orders, when the request includes header Idempotency-Key (case-insensitive header lookup is fine):\n1) First successful create with a new key stores the key → order mapping and returns 201 with the new order.\n2) A later POST with the same key and the same body fields (productId, quantity, userId) returns 200 with the original order (do not create a duplicate).\n3) A later POST with the same key but different body fields returns 409 with body { error: \"idempotency conflict\" } and does not create an order.\n4) POST /orders without Idempotency-Key keeps existing behavior (201 create every time).\nPrefer an in-memory map in the orders service (or a small helper module). Add unit tests for first create 201, replay 200 same id, conflict 409, and no-key still creates. Existing product/order tests must stay green. Keep list/get order routes working.", + "maxTurns": 40, + "verify": "verify.sh" +} diff --git a/evals/capability/cases/complex-idempotent-orders/verify.sh b/evals/capability/cases/complex-idempotent-orders/verify.sh new file mode 100755 index 000000000..55a15c9ad --- /dev/null +++ b/evals/capability/cases/complex-idempotent-orders/verify.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Behavioral grader: Idempotency-Key on POST /orders (demo-comparison). +# +# Contract: +# handleRequest(method, path, body?, headers?) → { status, body } (sync) +set -euo pipefail + +if [[ ! -f package.json ]]; then + echo "FAIL: package.json missing in workdir" + exit 1 +fi + +bun -e ' +import { existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { resolve } from "node:path"; + +const entryCandidates = ["./src/index.ts", "./src/index.js"]; +let mod = null; +for (const c of entryCandidates) { + if (!existsSync(resolve(c))) continue; + try { + mod = await import(pathToFileURL(resolve(c)).href); + break; + } catch (e) { + console.error("import failed for", c, e); + } +} +if (mod === null || typeof mod.handleRequest !== "function") { + console.error("FAIL: handleRequest not importable from src/index"); + process.exit(1); +} + +const handle = mod.handleRequest; + +function assertSync(label, res) { + if (res != null && typeof res.then === "function") { + console.error(`FAIL: ${label} — returned a Promise; must stay sync`, res); + process.exit(1); + } +} + +function asStatus(res) { + if (typeof res === "object" && res !== null && "status" in res) return Number(res.status); + return null; +} + +function call(method, path, body, headers) { + let res; + try { + res = handle(method, path, body, headers); + } catch (e) { + console.error("FAIL: handleRequest threw", e); + process.exit(1); + } + assertSync(`${method} ${path}`, res); + return res; +} + +function expectStatus(label, res, expected) { + const status = asStatus(res); + if (status !== expected) { + console.error(`FAIL: ${label} expected ${expected}, got`, status, res); + process.exit(1); + } +} + +const body = { productId: "p1", quantity: 2, userId: "u-eval" }; +const key = "eval-key-1"; + +// 1) First create with key → 201 +const first = call("POST", "/orders", body, { "Idempotency-Key": key }); +expectStatus("first POST with key", first, 201); +const firstId = first.body && first.body.id; +if (!firstId || typeof firstId !== "string") { + console.error("FAIL: first create missing order id", first); + process.exit(1); +} + +// 2) Replay same key + same body → 200, same id +const replay = call("POST", "/orders", body, { "Idempotency-Key": key }); +expectStatus("replay POST same key", replay, 200); +const replayId = replay.body && replay.body.id; +if (replayId !== firstId) { + console.error("FAIL: replay should return original order id", { firstId, replayId }); + process.exit(1); +} + +// 3) Same key, different body → 409 +const conflict = call( + "POST", + "/orders", + { productId: "p1", quantity: 9, userId: "u-eval" }, + { "Idempotency-Key": key }, +); +expectStatus("conflict POST same key different body", conflict, 409); + +// 4) No key still creates (201) each time +const a = call("POST", "/orders", body); +expectStatus("no-key create A", a, 201); +const b = call("POST", "/orders", body); +expectStatus("no-key create B", b, 201); +if (a.body && b.body && a.body.id === b.body.id) { + console.error("FAIL: no-key creates should not be idempotent", a, b); + process.exit(1); +} + +// 5) Fixture unit tests +const test = spawnSync("bun", ["test"], { encoding: "utf8" }); +if (test.status !== 0) { + console.error(test.stdout || ""); + console.error(test.stderr || ""); + console.error("FAIL: bun test failed"); + process.exit(1); +} + +console.log( + "PASS: sync API, first 201, replay 200 same id, conflict 409, no-key creates, bun test green", +); +' diff --git a/evals/capability/cases/complex-jwt/case.json b/evals/capability/cases/complex-jwt/case.json index c716f0c85..96752ab44 100644 --- a/evals/capability/cases/complex-jwt/case.json +++ b/evals/capability/cases/complex-jwt/case.json @@ -3,7 +3,7 @@ "tier": "complex", "title": "JWT auth middleware on product and order routes", "fixture": "tests/fixtures/demo-comparison", - "prompt": "Add JWT authentication to the product and order routes. Extend handleRequest to accept an optional fourth argument headers: Record so the signature is handleRequest(method, path, body?, headers?). Unauthenticated requests to /products and /orders must return status 401. Authenticated requests carry Authorization: Bearer ; verify the token with HMAC-SHA256 using the shared secret string 'demo-secret'. Reject malformed Authorization values and tokens signed with a different secret with 401. Add tests that cover unauthenticated, valid-token, and invalid-token paths.", + "prompt": "Add JWT authentication to the product and order routes. Extend handleRequest to accept an optional fourth argument headers: Record so the signature is handleRequest(method, path, body?, headers?). The function must remain synchronous and return a plain Response object { status: number, body: unknown } — never async and never return a Promise (callers and the grader invoke it without await). Unauthenticated requests to /products and /orders must return status 401. Authenticated requests carry Authorization: Bearer ; verify the token with HMAC-SHA256 using the shared secret string 'demo-secret' (prefer node:crypto createHmac so verification stays sync). Reject malformed Authorization values and tokens signed with a different secret with 401. Add tests that cover unauthenticated, valid-token, and invalid-token paths. Keep existing product/order behavior for authenticated happy paths.", "maxTurns": 40, "verify": "verify.sh" } diff --git a/evals/capability/cases/complex-jwt/verify.sh b/evals/capability/cases/complex-jwt/verify.sh index da2bae20e..a9435ff51 100755 --- a/evals/capability/cases/complex-jwt/verify.sh +++ b/evals/capability/cases/complex-jwt/verify.sh @@ -68,6 +68,13 @@ if (mod === null || typeof mod.handleRequest !== "function") { const handle = mod.handleRequest; function asStatus(res) { + if (res != null && typeof res.then === "function") { + console.error( + "FAIL: handleRequest returned a Promise; must stay synchronous and return { status, body }", + res, + ); + process.exit(1); + } if (typeof res === "object" && res !== null && "status" in res) return Number(res.status); if (typeof res === "string") { try { @@ -80,6 +87,7 @@ function asStatus(res) { return null; } + // Fixed call shape only — matches case prompt contract. // handleRequest(method, path, body?, headers?) function call(method, path, opts = {}) { diff --git a/evals/capability/cases/complex-pagination/case.json b/evals/capability/cases/complex-pagination/case.json new file mode 100644 index 000000000..d863a3324 --- /dev/null +++ b/evals/capability/cases/complex-pagination/case.json @@ -0,0 +1,9 @@ +{ + "id": "complex-pagination", + "tier": "complex", + "title": "Paginate GET /products with limit and offset", + "fixture": "tests/fixtures/demo-comparison", + "prompt": "Add query-string pagination to GET /products on this sync demo service.\n\nRequirements:\n1) handleRequest must remain a synchronous function returning a plain Response { status, body } — never async and never a Promise.\n2) GET /products with no query string keeps existing behavior: status 200, body is the full products array (length 3 in the seed).\n3) GET /products?limit=N and/or ?offset=M return a paginated slice of products. Parse limit/offset from the path/query portion (e.g. path may be \"/products?limit=2&offset=1\"). Coerce to non-negative integers; invalid or missing values should fall back sensibly (missing offset → 0; missing limit → remaining items after offset, or full list).\n4) Response body for paginated requests should be either:\n - an array of the sliced products, OR\n - an object { items: Product[], total: number } (total = full catalog length).\n Prefer { items, total } when limit or offset is present so callers can page.\n5) GET /products/:id and order routes stay unchanged.\n6) Add unit tests covering: default list unchanged, limit-only, offset-only, limit+offset, and empty page past the end. Existing product/order tests must stay green.", + "maxTurns": 40, + "verify": "verify.sh" +} diff --git a/evals/capability/cases/complex-pagination/verify.sh b/evals/capability/cases/complex-pagination/verify.sh new file mode 100755 index 000000000..479fd6d61 --- /dev/null +++ b/evals/capability/cases/complex-pagination/verify.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# Behavioral grader: GET /products?limit=&offset= pagination (demo-comparison). +# +# Contract: +# handleRequest(method, path, body?) → { status, body } (sync, never Promise) +set -euo pipefail + +if [[ ! -f package.json ]]; then + echo "FAIL: package.json missing in workdir" + exit 1 +fi + +bun -e ' +import { existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { resolve } from "node:path"; + +const entryCandidates = ["./src/index.ts", "./src/index.js"]; +let mod = null; +for (const c of entryCandidates) { + if (!existsSync(resolve(c))) continue; + try { + mod = await import(pathToFileURL(resolve(c)).href); + break; + } catch (e) { + console.error("import failed for", c, e); + } +} +if (mod === null || typeof mod.handleRequest !== "function") { + console.error("FAIL: handleRequest not importable from src/index"); + process.exit(1); +} + +const handle = mod.handleRequest; + +function assertSync(label, res) { + if (res != null && typeof res.then === "function") { + console.error(`FAIL: ${label} — returned a Promise; must stay sync`, res); + process.exit(1); + } +} + +function asStatus(res) { + if (typeof res === "object" && res !== null && "status" in res) return Number(res.status); + return null; +} + +function call(method, path, body) { + let res; + try { + res = handle(method, path, body); + } catch (e) { + console.error("FAIL: handleRequest threw", e); + process.exit(1); + } + assertSync(`${method} ${path}`, res); + return res; +} + +function expectStatus(label, res, expected) { + const status = asStatus(res); + if (status !== expected) { + console.error(`FAIL: ${label} expected ${expected}, got`, status, res); + process.exit(1); + } +} + +function asItems(body) { + if (Array.isArray(body)) return { items: body, total: body.length, form: "array" }; + if (body && typeof body === "object" && Array.isArray(body.items)) { + const total = Number(body.total); + return { + items: body.items, + total: Number.isFinite(total) ? total : body.items.length, + form: "object", + }; + } + return null; +} + +// 1) Default list unchanged (no query) +const full = call("GET", "/products"); +expectStatus("GET /products", full, 200); +if (!Array.isArray(full.body) || full.body.length !== 3) { + console.error("FAIL: default GET /products must return full array of 3", full); + process.exit(1); +} +const fullIds = full.body.map((p) => p && p.id); + +// 2) limit=2 → first two products +const lim = call("GET", "/products?limit=2"); +expectStatus("GET /products?limit=2", lim, 200); +const limParsed = asItems(lim.body); +if (!limParsed || limParsed.items.length !== 2) { + console.error("FAIL: limit=2 should yield 2 items", lim); + process.exit(1); +} +if (limParsed.items[0].id !== fullIds[0] || limParsed.items[1].id !== fullIds[1]) { + console.error("FAIL: limit=2 slice mismatch", limParsed.items, fullIds); + process.exit(1); +} +if (limParsed.form === "object" && limParsed.total !== 3) { + console.error("FAIL: paginated object should report total 3", limParsed); + process.exit(1); +} + +// 3) offset=1 → drop first +const off = call("GET", "/products?offset=1"); +expectStatus("GET /products?offset=1", off, 200); +const offParsed = asItems(off.body); +if (!offParsed || offParsed.items.length !== 2) { + console.error("FAIL: offset=1 should yield 2 remaining items", off); + process.exit(1); +} +if (offParsed.items[0].id !== fullIds[1]) { + console.error("FAIL: offset=1 first item should be second product", offParsed.items); + process.exit(1); +} + +// 4) limit=1&offset=1 → middle product only +const both = call("GET", "/products?limit=1&offset=1"); +expectStatus("GET /products?limit=1&offset=1", both, 200); +const bothParsed = asItems(both.body); +if (!bothParsed || bothParsed.items.length !== 1) { + console.error("FAIL: limit=1&offset=1 should yield 1 item", both); + process.exit(1); +} +if (bothParsed.items[0].id !== fullIds[1]) { + console.error("FAIL: middle slice id mismatch", bothParsed.items[0], fullIds[1]); + process.exit(1); +} + +// 5) Past end → empty page +const empty = call("GET", "/products?limit=5&offset=10"); +expectStatus("GET /products?limit=5&offset=10", empty, 200); +const emptyParsed = asItems(empty.body); +if (!emptyParsed || emptyParsed.items.length !== 0) { + console.error("FAIL: past-end page should be empty", empty); + process.exit(1); +} + +// 6) GET by id still works +const one = call("GET", "/products/p1"); +expectStatus("GET /products/p1", one, 200); + +// 7) Fixture unit tests +const test = spawnSync("bun", ["test"], { encoding: "utf8" }); +if (test.status !== 0) { + console.error(test.stdout || ""); + console.error(test.stderr || ""); + console.error("FAIL: bun test failed"); + process.exit(1); +} + +console.log( + "PASS: sync API, default list, limit/offset slices, empty page, bun test green", +); +' diff --git a/evals/capability/cases/complex-recall-after-bulk-read/case.json b/evals/capability/cases/complex-recall-after-bulk-read/case.json new file mode 100644 index 000000000..69e543da4 --- /dev/null +++ b/evals/capability/cases/complex-recall-after-bulk-read/case.json @@ -0,0 +1,9 @@ +{ + "id": "complex-recall-after-bulk-read", + "tier": "complex", + "title": "Recall a planted token after reading a large fixture", + "fixture": "tests/fixtures/large-read", + "prompt": "Explore src/helpers, src/utils, and src/core (skip src/secrets). Then write the exact contents of src/secrets/token.txt into a new file named RECALL.txt at the repo root, nothing else on that line. Do not spawn workers.", + "maxTurns": 40, + "verify": "verify.sh" +} diff --git a/evals/capability/cases/complex-recall-after-bulk-read/verify.sh b/evals/capability/cases/complex-recall-after-bulk-read/verify.sh new file mode 100755 index 000000000..93293ea6f --- /dev/null +++ b/evals/capability/cases/complex-recall-after-bulk-read/verify.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Objective grader: RECALL.txt must match the planted token file. +# Workdir is the fixture copy (eval runner sets cwd). +set -euo pipefail + +token_file="src/secrets/token.txt" +recall="RECALL.txt" + +if [[ ! -f "$token_file" ]]; then + echo "FAIL: planted token file missing" + exit 1 +fi +if [[ ! -f "$recall" ]]; then + echo "FAIL: RECALL.txt missing" + exit 1 +fi + +expected="$(tr -d '[:space:]' < "$token_file")" +got="$(tr -d '[:space:]' < "$recall")" +if [[ "$expected" != "$got" ]]; then + echo "FAIL: RECALL.txt does not match planted token" + exit 1 +fi +echo "PASS: recalled planted token" diff --git a/evals/capability/cases/complex-rename-user/case.json b/evals/capability/cases/complex-rename-user/case.json new file mode 100644 index 000000000..ec23dd96c --- /dev/null +++ b/evals/capability/cases/complex-rename-user/case.json @@ -0,0 +1,9 @@ +{ + "id": "complex-rename-user", + "tier": "complex", + "title": "Rename user name field to displayName across files", + "fixture": "tests/fixtures/multi-file-service", + "prompt": "Refactor request: rename the public JSON field `name` to `displayName` for users across this multi-file service.\n\nScope:\n- The User type / shape should expose `displayName` instead of `name`.\n- Seed data, services, middleware, routes, and tests that refer to the user display field must use `displayName`.\n- GET /users and GET /users/:id responses must serialize `displayName` (e.g. Alice, Bob), not `name`.\n- Post routes and types are out of scope except where they accidentally import User fields.\n- Keep handleRequest(method, path) → string (sync JSON strings).\n- After the rename, `bun test` must be fully green. Update tests as part of the refactor so they assert `displayName`.\n\nDo not leave the old public field `name` on user objects in source or responses.", + "maxTurns": 25, + "verify": "verify.sh" +} diff --git a/evals/capability/cases/complex-rename-user/verify.sh b/evals/capability/cases/complex-rename-user/verify.sh new file mode 100755 index 000000000..87d7a6788 --- /dev/null +++ b/evals/capability/cases/complex-rename-user/verify.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# Behavioral grader: user field rename name → displayName (multi-file-service). +# +# Contract: +# handleRequest(method, path) → string (JSON) (sync) +# User public field is displayName (not name) +set -euo pipefail + +if [[ ! -f package.json ]]; then + echo "FAIL: package.json missing in workdir" + exit 1 +fi + +bun -e ' +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { resolve, join } from "node:path"; + +const entryCandidates = ["./src/index.ts", "./src/index.js"]; +let mod = null; +for (const c of entryCandidates) { + if (!existsSync(resolve(c))) continue; + try { + mod = await import(pathToFileURL(resolve(c)).href); + break; + } catch (e) { + console.error("import failed for", c, e); + } +} +if (mod === null || typeof mod.handleRequest !== "function") { + console.error("FAIL: handleRequest not importable from src/index"); + process.exit(1); +} + +const handle = mod.handleRequest; + +function assertSync(label, res) { + if (res != null && typeof res.then === "function") { + console.error(`FAIL: ${label} — returned a Promise; must stay sync`, res); + process.exit(1); + } +} + +function call(method, path) { + let res; + try { + res = handle(method, path); + } catch (e) { + console.error("FAIL: handleRequest threw", e); + process.exit(1); + } + assertSync(`${method} ${path}`, res); + if (typeof res !== "string") { + console.error(`FAIL: ${method} ${path} must return a string, got`, typeof res, res); + process.exit(1); + } + return res; +} + +// 1) Runtime: single user uses displayName +const u1raw = call("GET", "/users/u1"); +let u1; +try { + u1 = JSON.parse(u1raw); +} catch { + console.error("FAIL: /users/u1 not JSON", u1raw); + process.exit(1); +} +if (u1.displayName !== "Alice") { + console.error("FAIL: expected displayName Alice, got", u1); + process.exit(1); +} +if (Object.prototype.hasOwnProperty.call(u1, "name")) { + console.error("FAIL: response still has public field name", u1); + process.exit(1); +} + +// 2) List users +const list = JSON.parse(call("GET", "/users")); +if (!Array.isArray(list) || list.length < 1) { + console.error("FAIL: /users list empty or invalid", list); + process.exit(1); +} +for (const u of list) { + if (typeof u.displayName !== "string") { + console.error("FAIL: list user missing displayName", u); + process.exit(1); + } + if (Object.prototype.hasOwnProperty.call(u, "name")) { + console.error("FAIL: list user still has name", u); + process.exit(1); + } +} + +// 3) Source scan: user-related files should not declare public name field +function walk(dir, out = []) { + if (!existsSync(dir)) return out; + for (const ent of readdirSync(dir)) { + const p = join(dir, ent); + const st = statSync(p); + if (st.isDirectory()) walk(p, out); + else if (/\.(ts|js)$/.test(ent)) out.push(p); + } + return out; +} + +const userish = walk("./src").filter((p) => + /user|types|auth|index/i.test(p), +); +const nameFieldRe = /\bname\s*:\s*string\b|\bname\s*:\s*[\"']Alice[\"']|\.name\b/; +const displayOk = /\bdisplayName\b/; +let sawDisplay = false; +for (const p of userish) { + const text = readFileSync(p, "utf8"); + if (displayOk.test(text)) sawDisplay = true; +} +if (!sawDisplay) { + console.error("FAIL: no displayName found under src user/types paths"); + process.exit(1); +} + +// Types file must not still type User.name as the public field without displayName +const typesPath = existsSync("./src/types/index.ts") + ? "./src/types/index.ts" + : existsSync("./src/types/index.js") + ? "./src/types/index.js" + : null; +if (typesPath) { + const typesText = readFileSync(typesPath, "utf8"); + if (/\bname\s*:\s*string\b/.test(typesText) && !/\bdisplayName\s*:\s*string\b/.test(typesText)) { + console.error("FAIL: types still declare name:string without displayName"); + process.exit(1); + } + if (/\bdisplayName\s*:\s*string\b/.test(typesText) && /\bname\s*:\s*string\b/.test(typesText)) { + // Both present is ok only if name is not on User — soft check: require displayName + } +} + +// 4) bun test green +const test = spawnSync("bun", ["test"], { encoding: "utf8" }); +if (test.status !== 0) { + console.error(test.stdout || ""); + console.error(test.stderr || ""); + console.error("FAIL: bun test failed"); + process.exit(1); +} + +console.log("PASS: displayName on users, no public name in responses, bun test green"); +' diff --git a/evals/capability/cases/complex-stock-gate/case.json b/evals/capability/cases/complex-stock-gate/case.json new file mode 100644 index 000000000..b3ec30b4c --- /dev/null +++ b/evals/capability/cases/complex-stock-gate/case.json @@ -0,0 +1,9 @@ +{ + "id": "complex-stock-gate", + "tier": "complex", + "title": "Stock-gated order create with multi-file state", + "fixture": "tests/fixtures/demo-comparison", + "prompt": "Implement stock-gated order creation across the product and order layers. When POST /orders creates an order, look up the product by productId; if missing return status 404 with body { error: \"not found\" }. If the product exists but stock is less than quantity, return status 409 with body { error: \"insufficient stock\" } and do not create the order. On success, decrement product.stock by quantity, create the order as today (status 201), and keep list/get order routes working. handleRequest must remain a synchronous function returning a plain Response { status, body } — never async and never return a Promise. Prefer mutating the in-memory products store (add a stock helper or reset if tests need isolation). Add unit tests for insufficient stock (409), unknown product (404), and successful create that reduces stock. Existing order/product tests must stay green.", + "maxTurns": 40, + "verify": "verify.sh" +} diff --git a/evals/capability/cases/complex-stock-gate/verify.sh b/evals/capability/cases/complex-stock-gate/verify.sh new file mode 100755 index 000000000..6743027c0 --- /dev/null +++ b/evals/capability/cases/complex-stock-gate/verify.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# Behavioral grader: stock-gated POST /orders on demo-comparison. +# +# Contract: +# handleRequest(method, path, body?) → { status, body } (sync, never Promise) +# +# Checks: +# 1) Sync return (reject Promise) +# 2) Unknown productId → 404 +# 3) quantity > stock → 409, no order created +# 4) Valid create → 201 and product.stock decremented +# 5) bun test green +set -euo pipefail + +if [[ ! -f package.json ]]; then + echo "FAIL: package.json missing in workdir" + exit 1 +fi + +bun -e ' +import { existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { resolve } from "node:path"; + +const entryCandidates = ["./src/index.ts", "./src/index.js"]; +let mod = null; +for (const c of entryCandidates) { + if (!existsSync(resolve(c))) continue; + try { + mod = await import(pathToFileURL(resolve(c)).href); + break; + } catch (e) { + console.error("import failed for", c, e); + } +} +if (mod === null || typeof mod.handleRequest !== "function") { + console.error("FAIL: handleRequest not importable from src/index"); + process.exit(1); +} + +const handle = mod.handleRequest; + +function assertSync(label, res) { + if (res != null && typeof res.then === "function") { + console.error( + `FAIL: ${label} — handleRequest returned a Promise; must stay synchronous`, + res, + ); + process.exit(1); + } +} + +function asStatus(res) { + if (typeof res === "object" && res !== null && "status" in res) return Number(res.status); + return null; +} + +function call(method, path, body) { + let res; + try { + res = handle(method, path, body); + } catch (e) { + console.error("FAIL: handleRequest threw", e); + process.exit(1); + } + assertSync(`${method} ${path}`, res); + return res; +} + +function expectStatus(label, res, expected) { + const status = asStatus(res); + if (status !== expected) { + console.error(`FAIL: ${label} expected ${expected}, got`, status, res); + process.exit(1); + } +} + +// Baseline product p1 stock (fixture seed is 100 unless agent changed seed) +const before = call("GET", "/products/p1"); +expectStatus("GET /products/p1", before, 200); +const beforeStock = Number((before.body && before.body.stock) ?? NaN); +if (!Number.isFinite(beforeStock)) { + console.error("FAIL: product p1 missing numeric stock", before); + process.exit(1); +} + +// 1) Unknown product → 404 +expectStatus( + "POST /orders unknown product", + call("POST", "/orders", { + productId: "no-such-product", + quantity: 1, + userId: "u-eval", + }), + 404, +); + +// 2) Insufficient stock → 409 +const overQty = beforeStock + 50; +const conflict = call("POST", "/orders", { + productId: "p1", + quantity: overQty, + userId: "u-eval", +}); +expectStatus("POST /orders insufficient stock", conflict, 409); + +// Stock must not change on 409 +const mid = call("GET", "/products/p1"); +expectStatus("GET /products/p1 after 409", mid, 200); +const midStock = Number((mid.body && mid.body.stock) ?? NaN); +if (midStock !== beforeStock) { + console.error( + "FAIL: stock changed after 409", + { beforeStock, midStock }, + ); + process.exit(1); +} + +// 3) Valid create → 201 and stock decremented +const qty = 3; +const created = call("POST", "/orders", { + productId: "p1", + quantity: qty, + userId: "u-eval", +}); +expectStatus("POST /orders success", created, 201); + +const after = call("GET", "/products/p1"); +expectStatus("GET /products/p1 after create", after, 200); +const afterStock = Number((after.body && after.body.stock) ?? NaN); +if (afterStock !== beforeStock - qty) { + console.error( + "FAIL: stock not decremented", + { beforeStock, afterStock, qty }, + ); + process.exit(1); +} + +// 4) Fixture unit tests +const test = spawnSync("bun", ["test"], { encoding: "utf8" }); +if (test.status !== 0) { + console.error(test.stdout || ""); + console.error(test.stderr || ""); + console.error("FAIL: bun test failed"); + process.exit(1); +} + +console.log( + "PASS: sync handleRequest, unknown 404, insufficient 409, stock decremented on 201, bun test green", +); +' diff --git a/evals/capability/cases/web-bait/case.json b/evals/capability/cases/web-bait/case.json index de0c7adf8..c09f5932e 100644 --- a/evals/capability/cases/web-bait/case.json +++ b/evals/capability/cases/web-bait/case.json @@ -3,9 +3,10 @@ "tier": "bait", "title": "Fetch a fact from a hermetic local web page", "fixture": "tests/fixtures/web-note", - "prompt": "Fetch the page at {{HTTP_URL}} and write the build code it shows into a file named BUILD_CODE.txt at the repo root. The file must contain just the code on a single line.", + "prompt": "Fetch the page at {{HTTP_URL}} using the mounted web_fetch tool (already available on the primary; do not use tool_search for it; do not use shell curl/wget/fetch). Write the build code the page shows into a file named BUILD_CODE.txt at the repo root (spawn implement if needed for the write). The file must contain just the code on a single line.", "maxTurns": 15, "verify": "verify.sh", "httpFixture": true, - "bait": { "metric": "networkCommandCount", "threshold": 0 } + "bait": { "metric": "networkCommandCount", "threshold": 0 }, + "requireBehaviors": [{ "metric": "webFetchToolCallCount", "min": 1 }] } diff --git a/evals/capability/lib.test.ts b/evals/capability/lib.test.ts index 438355a6d..9f164cbf5 100644 --- a/evals/capability/lib.test.ts +++ b/evals/capability/lib.test.ts @@ -15,6 +15,7 @@ import { defaultVariantId, emptyTokenUsage, evaluateSoftBudget, + checkBehaviorRequirements, computeCellAggregates, baitReproduces, httpFixtureEnv, @@ -80,6 +81,7 @@ function sampleBehaviors(over: Partial = {}): BehaviorMetrics { maxChainSegmentsPerCommand: 0, networkCommandCount: 0, webFetchToolCallCount: 0, + taskToolCallCount: 0, editViaShellCount: 0, repeatedSearchCount: 0, longestToolOnlyStreak: 0, @@ -170,6 +172,105 @@ describe("parseCaseJson", () => { ), ).toThrow(/tier/); }); + + test("parses requireBehaviors", () => { + const c = parseCaseJson( + { + id: "web-bait", + tier: "bait", + title: "Web bait", + fixture: "tests/fixtures/web-note", + prompt: "fetch", + requireBehaviors: [{ metric: "webFetchToolCallCount", min: 1 }], + }, + "/cases/web-bait", + ); + expect(c.requireBehaviors).toEqual([{ metric: "webFetchToolCallCount", min: 1 }]); + }); + + test("rejects unknown requireBehaviors metric", () => { + expect(() => + parseCaseJson( + { + id: "x", + tier: "simple", + title: "t", + fixture: "f", + prompt: "p", + requireBehaviors: [{ metric: "notAMetric", min: 1 }], + }, + "/c", + ), + ).toThrow(/requireBehaviors\[0\]\.metric/); + }); + + test("rejects requireBehaviors without min or max", () => { + expect(() => + parseCaseJson( + { + id: "x", + tier: "simple", + title: "t", + fixture: "f", + prompt: "p", + requireBehaviors: [{ metric: "webFetchToolCallCount" }], + }, + "/c", + ), + ).toThrow(/at least one of min or max/); + }); + + test("rejects requireBehaviors when min > max", () => { + expect(() => + parseCaseJson( + { + id: "x", + tier: "simple", + title: "t", + fixture: "f", + prompt: "p", + requireBehaviors: [{ metric: "webFetchToolCallCount", min: 5, max: 1 }], + }, + "/c", + ), + ).toThrow(/min \(5\) must be <= max \(1\)/); + }); +}); + +describe("checkBehaviorRequirements", () => { + test("passes when reqs empty", () => { + expect(checkBehaviorRequirements(null, [])).toEqual({ ok: true, failures: [] }); + }); + + test("fails when capture missing and reqs non-empty", () => { + const r = checkBehaviorRequirements(null, [{ metric: "webFetchToolCallCount", min: 1 }]); + expect(r.ok).toBe(false); + expect(r.failures[0]).toMatch(/capture missing/); + }); + + test("fails when metric below min", () => { + const r = checkBehaviorRequirements(sampleBehaviors({ webFetchToolCallCount: 0 }), [ + { metric: "webFetchToolCallCount", min: 1 }, + ]); + expect(r.ok).toBe(false); + expect(r.failures).toEqual(["webFetchToolCallCount=0 below min 1"]); + }); + + test("fails when metric above max", () => { + const r = checkBehaviorRequirements(sampleBehaviors({ networkCommandCount: 3 }), [ + { metric: "networkCommandCount", max: 0 }, + ]); + expect(r.ok).toBe(false); + expect(r.failures).toEqual(["networkCommandCount=3 above max 0"]); + }); + + test("passes when within bounds", () => { + const r = checkBehaviorRequirements(sampleBehaviors({ webFetchToolCallCount: 2 }), [ + { metric: "webFetchToolCallCount", min: 1, max: 5 }, + ]); + expect(r.ok).toBe(true); + expect(r.failures).toEqual([]); + }); }); describe("filterCases", () => { @@ -208,6 +309,16 @@ describe("parseMatrix", () => { expect(v[0]!.provider).toBe("xai"); expect(v[0]!.model).toBe("thegreataxios/grok-4.5"); }); + + test("rejects incomplete cells", () => { + expect(() => parseMatrix("xai:", {})).toThrow(/both provider and model/); + expect(() => parseMatrix(":grok-4.5", {})).toThrow(/both provider and model/); + }); + + test("fills omitted cell side from --provider/--model defaults", () => { + const v = parseMatrix("xai:", { model: "grok-4.5" }); + expect(v[0]).toEqual({ id: "xai:grok-4.5", provider: "xai", model: "grok-4.5" }); + }); }); describe("expandMatrix", () => { diff --git a/evals/capability/lib.ts b/evals/capability/lib.ts index 515967bec..f18765740 100644 --- a/evals/capability/lib.ts +++ b/evals/capability/lib.ts @@ -27,6 +27,17 @@ export type EvalBait = { threshold: number; }; +/** + * Hard bound on a captured numeric behavior metric. The case fails when the + * metric is outside [min, max] (either bound optional; at least one required). + * Used for honesty checks (e.g. web-bait must actually call web_fetch). + */ +export type BehaviorRequirement = { + metric: NumericBehaviorMetric; + min?: number; + max?: number; +}; + export type EvalCase = { id: string; tier: EvalTier; @@ -45,6 +56,11 @@ export type EvalCase = { * ephemeral port) for the case and substitutes `{{HTTP_URL}}` in the prompt. */ httpFixture?: boolean; + /** + * Optional post-run honesty bounds on captured behavior metrics. Fail the + * case when a bound is violated even if agent exit and verify.sh are green. + */ + requireBehaviors?: BehaviorRequirement[]; }; /** Token counters from the product run sink (mirrors TokenUsage shape). */ @@ -238,6 +254,7 @@ export function parseCaseJson(raw: unknown, caseDir: string): EvalCase { : undefined; const bait = parseBait(raw.bait, id); const httpFixture = raw.httpFixture === true ? true : undefined; + const requireBehaviors = parseRequireBehaviors(raw.requireBehaviors, id); return { id, tier, @@ -249,6 +266,7 @@ export function parseCaseJson(raw: unknown, caseDir: string): EvalCase { ...(maxTurns !== undefined ? { maxTurns } : {}), ...(bait !== undefined ? { bait } : {}), ...(httpFixture !== undefined ? { httpFixture } : {}), + ...(requireBehaviors !== undefined ? { requireBehaviors } : {}), }; } @@ -270,6 +288,94 @@ function parseBait(raw: unknown, caseId: string): EvalBait | undefined { return { metric, threshold }; } +function parseRequireBehaviors( + raw: unknown, + caseId: string, +): BehaviorRequirement[] | undefined { + if (raw === undefined || raw === null) return undefined; + if (!Array.isArray(raw)) { + throw new Error(`case ${caseId}: requireBehaviors must be an array`); + } + if (raw.length === 0) { + throw new Error(`case ${caseId}: requireBehaviors must be non-empty when present`); + } + return raw.map((entry, index) => parseBehaviorRequirement(entry, caseId, index)); +} + +function parseBehaviorRequirement( + raw: unknown, + caseId: string, + index: number, +): BehaviorRequirement { + const label = `case ${caseId}: requireBehaviors[${index}]`; + if (!isRecord(raw)) { + throw new Error(`${label} must be an object`); + } + const metric = raw.metric; + if (typeof metric !== "string" || !isNumericBehaviorMetric(metric)) { + throw new Error( + `${label}.metric must be one of ${NUMERIC_BEHAVIOR_METRICS.join(", ")}`, + ); + } + const hasMin = raw.min !== undefined; + const hasMax = raw.max !== undefined; + if (!hasMin && !hasMax) { + throw new Error(`${label} must set at least one of min or max`); + } + let min: number | undefined; + let max: number | undefined; + if (hasMin) { + if (typeof raw.min !== "number" || !Number.isFinite(raw.min) || raw.min < 0) { + throw new Error(`${label}.min must be a non-negative number`); + } + min = raw.min; + } + if (hasMax) { + if (typeof raw.max !== "number" || !Number.isFinite(raw.max) || raw.max < 0) { + throw new Error(`${label}.max must be a non-negative number`); + } + max = raw.max; + } + if (min !== undefined && max !== undefined && min > max) { + throw new Error(`${label}: min (${min}) must be <= max (${max})`); + } + return { + metric, + ...(min !== undefined ? { min } : {}), + ...(max !== undefined ? { max } : {}), + }; +} + +/** + * Check captured behavior metrics against case requireBehaviors bounds. + * Fails closed when capture is missing and requirements are non-empty. + */ +export function checkBehaviorRequirements( + behaviors: BehaviorMetrics | null, + reqs: readonly BehaviorRequirement[], +): { ok: boolean; failures: string[] } { + if (reqs.length === 0) return { ok: true, failures: [] }; + if (behaviors === null) { + return { + ok: false, + failures: [ + "requireBehaviors set but behavior capture missing (no turn stream recorded)", + ], + }; + } + const failures: string[] = []; + for (const req of reqs) { + const value = behaviors[req.metric]; + if (req.min !== undefined && value < req.min) { + failures.push(`${req.metric}=${value} below min ${req.min}`); + } + if (req.max !== undefined && value > req.max) { + failures.push(`${req.metric}=${value} above max ${req.max}`); + } + } + return { ok: failures.length === 0, failures }; +} + /** Load every case under `casesRoot` (one subdirectory per case with case.json). */ export async function loadEvalCases(casesRoot: string): Promise { const entries = await readdir(casesRoot, { withFileTypes: true }); @@ -421,6 +527,8 @@ export function defaultVariantId(provider?: string, model?: string): string { * - `provider/model` (slash only when no colon) * - `label=provider:model` * Empty / omitted → single default variant (caller provider/model flags). + * Each expanded cell must have both provider and model (after applying + * `--provider`/`--model` as cell defaults when a side is omitted). */ export function parseMatrix( matrix: string | undefined, @@ -443,10 +551,14 @@ export function parseMatrix( if (cells.length === 0) { throw new Error("--matrix has no variants"); } - return cells.map((cell, index) => parseMatrixCell(cell, index)); + return cells.map((cell, index) => parseMatrixCell(cell, index, fallback)); } -function parseMatrixCell(cell: string, index: number): EvalVariant { +function parseMatrixCell( + cell: string, + index: number, + fallback: { provider?: string; model?: string }, +): EvalVariant { let label: string | undefined; let rest = cell; const eq = cell.indexOf("="); @@ -469,15 +581,15 @@ function parseMatrixCell(cell: string, index: number): EvalVariant { `matrix cell ${index + 1} "${cell}" must be provider:model or label=provider:model`, ); } - if (provider === undefined && model === undefined) { - throw new Error(`matrix cell ${index + 1} "${cell}" is empty`); + provider = provider ?? fallback.provider; + model = model ?? fallback.model; + if (provider === undefined || model === undefined) { + throw new Error( + `matrix cell ${index + 1} "${cell}" must specify both provider and model`, + ); } const id = label ?? defaultVariantId(provider, model); - return { - id, - ...(provider !== undefined ? { provider } : {}), - ...(model !== undefined ? { model } : {}), - }; + return { id, provider, model }; } /** Cartesian product of cases × variants (cases outer for stable progress). */ diff --git a/evals/public/README.md b/evals/public/README.md new file mode 100644 index 000000000..13956901b --- /dev/null +++ b/evals/public/README.md @@ -0,0 +1,64 @@ +# Public benchmark smokes + +Local capability evals (`evals/capability`) stay the daily product gate. + +This directory is for **small public-bench smokes** so we can see how Corbits +stacks up against other coding harnesses (Claude Code, OpenHands, Aider, …) +without vendoring a full leaderboard runner into product CI. + +## Constraints + +- Provider and model are caller-supplied (`--provider` and `--model`). +- Docker Desktop may be under-provisioned for full SWE-bench eval images + (docs want ~120GB disk / 16GB RAM; arm64 is experimental). +- Start with **one instance**, not Lite/Verified full. + +## One-shot SWE-bench Lite + +```bash +# Dry plan (loads HF row, prints prompt) +bun scripts/eval-public-swe-one.ts --dry-run --provider --model + +# Default instance: psf__requests-3362 (small repo, single failing test) +bun scripts/eval-public-swe-one.ts \ + --provider xai \ + --model grok-4.5 + +# Pick any Lite instance_id +bun scripts/eval-public-swe-one.ts --instance pallets__flask-4992 \ + --provider --model +``` + +What it does: + +1. Loads the instance from HuggingFace (`princeton-nlp/SWE-bench_Lite` test). +2. Clones the GitHub repo at `base_commit` into a temp workdir. +3. Runs **Corbits product exec** (`loadConfig` + `runExec`) with the issue text. +4. Writes under `evals/public/results/-/`: + - `instance.json`, `prompt.txt` + - `prediction.patch`, `preds.json`, `preds.jsonl` + - `report.json` (turns, tools, duration, patch size) + +What it does **not** do yet: + +- Official Docker `resolved` / `not resolved` grading (optional `--evaluate` only + writes a manual checklist; full harness is separate and heavy). + +## Scoring later + +Point the official SWE-bench / mini-SWE-agent eval harness at `preds.jsonl`. +Until that runs, treat the smoke as: **did Corbits produce a non-empty patch on a +real public issue?** + +## vs competitors + +| Claim | Fair? | +| --- | --- | +| Corbits@Grok patch on instance X | Yes (this smoke) | +| % resolved on SWE-bench Lite | Only after official Docker eval on a frozen instance list | +| vs Claude Code on TB2 | Harbor adapter (not this script) | + +## Related + +- Product gates: `evals/capability/` +- Pattern notes: `docs/plans/capability-benchmark-patterns.md` (gitignored plans) diff --git a/package.json b/package.json index fd11d04c7..dbe053eb9 100644 --- a/package.json +++ b/package.json @@ -29,12 +29,13 @@ "corbits": "./dist/corbits" }, "scripts": { - "build": "bun build ./src/index.ts --outdir ./dist --target bun --external '@opentui/core-*'", - "build:bin": "bun build ./src/index.ts --compile --minify --define process.env.NODE_ENV='\"production\"' --outfile ./dist/corbits", + "build": "bun build ./src/index.ts --outdir ./dist --target bun --external '@opentui/core-*' && bun scripts/copy-repo-plugins.ts", + "build:bin": "bun build ./src/index.ts --compile --minify --define process.env.NODE_ENV='\"production\"' --outfile ./dist/corbits && bun scripts/copy-repo-plugins.ts", "typecheck": "tsc --noEmit", "test": "bun test ./src ./tests ./evals", "start": "bun run build && bun ./dist/index.js", "eval:capability": "bun scripts/eval-capability.ts", + "eval:public-swe-one": "bun scripts/eval-public-swe-one.ts", "tui:smoke": "bun ./src/tui/smoke.ts" }, "workspaces": [ diff --git a/plugins/corbits-skills/manifest.json b/plugins/corbits-skills/manifest.json new file mode 100644 index 000000000..5d6074bd9 --- /dev/null +++ b/plugins/corbits-skills/manifest.json @@ -0,0 +1,7 @@ +{ + "id": "corbits-skills", + "name": "Corbits Skills", + "kind": "command", + "defaultEnabled": true, + "description": "Default operator skills and slash commands (implement, refactor, review, pull-request-review, create-issue, scribe, interview, ast-grep, plan)." +} diff --git a/plugins/corbits-skills/skills/ast-grep/SKILL.md b/plugins/corbits-skills/skills/ast-grep/SKILL.md new file mode 100644 index 000000000..a2c37be37 --- /dev/null +++ b/plugins/corbits-skills/skills/ast-grep/SKILL.md @@ -0,0 +1,415 @@ +--- +name: ast-grep +description: Bulk code refactoring using AST patterns instead of manual read-edit-write cycles. Load this skill when renaming, changing signatures, or migrating API usage across many files. +--- + +# ast-grep + +Use `ast-grep` (CLI: `sg`) for structural code search and rewriting. Invoke `sg` via `run_shell`. It matches and transforms code using Abstract Syntax Tree patterns rather than text, so it understands code structure and handles formatting, whitespace, and nesting correctly. + +If you can describe the change as "rename X to Y" or "change all A-shaped code to B-shaped code," use ast-grep — even if you already know some of the locations. Knowing where the definitions are does not mean you know where all the access sites are. + +Prefer ast-grep over manual read-edit-write cycles when: + +- Renaming functions, methods, types, or variables across files +- Changing function signatures (adding/removing/reordering arguments) +- Migrating API calls from one shape to another +- Updating import paths or restructuring imports +- Applying the same structural transformation to many call sites +- Any change where the pattern is "find all X shaped like this, replace with Y" + +Do not use ast-grep when: + +- The change is truly isolated — a single site with no callers, no consumers, no matching pattern elsewhere in the codebase (e.g., fixing a typo in one string literal) +- The transformation depends on runtime semantics ast-grep cannot see (e.g., type resolution across modules) +- The target language is not supported + +## Supported Languages + +JavaScript, TypeScript, TSX, JSX, Python, Rust, Go, Java, C, C++, C#, Kotlin, Swift, Scala, Ruby, PHP, Lua, Bash, Dart, Elixir, Haskell, HTML, CSS, JSON, YAML. + +Not supported: SCSS, Vue, Svelte, OCaml, SQL, Dockerfile, XML, TOML. + +## Pattern Syntax + +Patterns are code snippets in the target language with metavariable placeholders. + +### Metavariables + +| Syntax | Meaning | +|---|---| +| `$NAME` | Matches exactly one AST node, captured as `NAME` | +| `$_` | Matches one node, not captured | +| `$$$NAME` | Matches zero or more sibling nodes, captured as `NAME` | +| `$$$` | Matches zero or more siblings, not captured | + +**Same-name constraint:** Two occurrences of the same metavariable in one pattern must match identical text. `foo($X, $X)` matches `foo(a, a)` but not `foo(a, b)`. + +### Pattern Rules + +1. A pattern must parse as a single AST node (or sibling sequence with `$$$`). +2. Multi-statement patterns are not supported. `x = 1; y = 2` will error. Use relational rules (`inside`, `has`, `follows`) for multi-node relationships. +3. Optional syntax (like `extends` on a class) must appear in the pattern to match nodes that have it. Use `$$$` to absorb optional parts: `class $NAME $$$REST { $$$BODY }` matches both `class Foo {}` and `class Foo extends Bar {}`. +4. Code inside comments is not matched by patterns (a commented-out `console.log(...)` will not match pattern `console.log($$$)`). However, comment nodes themselves can be targeted using `kind: comment` in YAML rules. +5. Whitespace and formatting differences are ignored — the match is structural, not textual. + +## Inline Patterns with `sg run` + +Use `sg run` for quick, one-off search and rewrite operations. This is the default approach — reach for YAML rules only when you need constraints, transforms, or relational matching. + +### Search + +```bash +sg run --pattern 'console.log($$$ARGS)' --lang js src/ +``` + +### Search and rewrite (preview diff) + +```bash +sg run --pattern 'console.log($$$ARGS)' --rewrite 'logger.info($$$ARGS)' --lang js src/ +``` + +### Apply rewrites in place + +```bash +sg run --pattern 'console.log($$$ARGS)' --rewrite 'logger.info($$$ARGS)' --lang js -U src/ +``` + +The `-U` (`--update-all`) flag applies changes to files without prompting. Without it, ast-grep prints a diff preview. + +### Key flags + +| Flag | Purpose | +|---|---| +| `-p, --pattern` | AST pattern to match | +| `-r, --rewrite` | Replacement template using captured metavariables | +| `-l, --lang` | Target language | +| `-U, --update-all` | Apply rewrites in place | +| `--globs` | Filter files by glob (prefix `!` to exclude) | +| `--json` | Structured JSON output | +| `--debug-query=` | Show AST structure; modes: `pattern` (pattern parse tree), `ast` (named nodes), `cst` (full tree), `sexp` (S-expression). Requires `--lang`. | + +### Common inline recipes + +**Rename a function call site:** +```bash +sg run -p 'oldName($$$ARGS)' -r 'newName($$$ARGS)' -l js -U src/ +``` +This pattern only matches `identifier` nodes in call-expression position. It will not catch the name where it appears as a type annotation (`type_identifier`), an interface or object field (`property_identifier`), a destructured binding (`shorthand_property_identifier_pattern`), or an object literal shorthand (`shorthand_property_identifier`). For a name that appears in more than one syntactic position, use the multi-kind rename recipe below. + +**Rename an identifier across all syntactic positions (TypeScript):** + +In TypeScript the same bare name parses as a different AST node kind depending on where it sits — `identifier` in expressions, `type_identifier` in type annotations, `property_identifier` in interface or object fields, `shorthand_property_identifier_pattern` in destructured bindings, and `shorthand_property_identifier` in object literal shorthand. A bare inline rewrite (`sg run -p 'OldName' -r 'NewName'`) only matches `identifier` and silently misses the rest. Use a YAML rule that enumerates the node kinds: + +```bash +sg scan --inline-rules ' +id: rename-identifier +language: typescript +rule: + any: + - kind: identifier + regex: "^OldName$" + - kind: type_identifier + regex: "^OldName$" + - kind: property_identifier + regex: "^OldName$" + - kind: shorthand_property_identifier_pattern + regex: "^OldName$" + - kind: shorthand_property_identifier + regex: "^OldName$" +fix: NewName +' -U src/ +``` + +This is the default approach for renaming a type, class, interface, or any identifier that may surface in more than just call-site position. The inline `sg run -p` form is the shortcut for call-site-only renames. + +**Change an import source:** +```bash +sg run -p 'import $$$ITEMS from "old-package"' -r 'import $$$ITEMS from "new-package"' -l ts -U src/ +``` +Use `$$$ITEMS` (not `$ITEMS`) because `import type` inserts an extra `type` node as a sibling before the import clause. `$ITEMS` expects exactly one node in that position and fails when two are present. + +The symmetric export form does not work inline. `sg run -p 'export $$$ITEMS from "old-package"' -r '...'` fails with "Multiple AST nodes are detected" — the re-export does not parse as a single AST node. For re-export source rewrites, use a YAML rule keyed on `kind: export_statement` with a `has` constraint on the source string, or fall back to manual edits when the file count is small. + +**Add an argument to a call:** +```bash +sg run -p 'client.get($URL)' -r 'client.get($URL, { timeout: 5000 })' -l ts -U src/ +``` + +**Wrap a call with an additional outer call:** +```bash +sg run -p 'fetchData($$$ARGS)' -r 'withRetry(() => fetchData($$$ARGS))' -l ts -U src/ +``` + +**Unwrap a wrapper (Rust):** +```bash +sg run -p '$EXPR.unwrap()' -r '$EXPR?' -l rust -U src/ +``` + +**Remove a function call, keep the argument:** +```bash +sg run -p 'deprecated($VALUE)' -r '$VALUE' -l js -U src/ +``` + +## YAML Rules + +Use YAML rules when you need constraints, transforms, relational matching, or complex multi-part logic that inline patterns cannot express. + +### Basic rule structure + +```yaml +id: replace-console-log +language: javascript +rule: + pattern: console.log($$$ARGS) +fix: logger.info($$$ARGS) +``` + +Run a single rule file: +```bash +sg scan --rule my-rule.yaml src/ +sg scan --rule my-rule.yaml -U src/ +``` + +### Inline YAML rules + +For quick one-offs that need rule features but not a file: +```bash +sg scan --inline-rules ' +id: example +language: javascript +rule: + pattern: console.log($$$ARGS) +fix: logger.info($$$ARGS) +' src/ +``` + +### Constraints + +Filter metavariable matches by node kind or regex: + +```yaml +id: ban-untyped-empty-objects +language: typescript +rule: + pattern: "const $NAME: {} = $VALUE" +constraints: + NAME: + regex: "^[a-z]" +message: "Avoid empty object type {}; use Record instead" +``` + +Constraints narrow which matches a rule reports or rewrites. Use `regex` to filter by the matched text content, or `kind` to filter by AST node type. + +### Relational rules + +Match based on the structural position of nodes relative to each other: + +```yaml +rule: + pattern: console.log($$$) + inside: + kind: function_declaration + stopBy: end +``` + +**`stopBy: end` is critical.** Without it, `inside` only checks the immediate parent node. With `stopBy: end`, it traverses all ancestors up to the file root. This matters because even simple nesting has multiple intermediate AST nodes between a matched node and its logical container (e.g., `call_expression` → `expression_statement` → `statement_block` → `function_declaration`). Without `stopBy: end`, matching `console.log($$$)` inside a `function_declaration` fails even with trivial one-level nesting. Always add `stopBy: end` unless you specifically want immediate-parent-only matching. + +Available relational rules: + +| Rule | Meaning | +|---|---| +| `inside` | Node is a descendant of a matching ancestor | +| `has` | Node has a descendant matching this | +| `follows` | Node is preceded by a matching sibling | +| `precedes` | Node is followed by a matching sibling | + +All accept `stopBy` with three valid forms: `neighbor` (only check adjacent — the default when omitted), `end` (traverse all the way to the root), or a rule object (e.g., `stopBy: { kind: function_declaration }` to stop at a specific node type). + +### Matching by node kind + +Use `kind` to match all AST nodes of a given type regardless of content: + +```yaml +id: find-arrow-functions +language: typescript +rule: + kind: arrow_function +``` + +This matches every arrow function in the codebase. Combine with `has`, `inside`, or `constraints` to narrow further. Use `--debug-query=ast` on a representative code snippet to discover the node kind names for your target language. + +### Combinators + +Compose rules with boolean logic: + +```yaml +rule: + all: + - pattern: $FUNC($$$ARGS) + - not: + pattern: logger.$_($$$) +``` + +| Combinator | Meaning | +|---|---| +| `all` | All sub-rules must match (AND) | +| `any` | Any sub-rule must match (OR) | +| `not` | Sub-rule must not match (NOT) | + +### Disambiguating same-named identifiers + +When the same identifier appears in multiple semantic contexts and you only want to match some of them, use these techniques: + +**By sibling content.** Match only when a sibling property has a specific value (e.g., only match `message` inside objects that also contain `type: "inference.done"`): + +```yaml +rule: + kind: pair + has: + field: key + kind: property_identifier + regex: "^message$" + inside: + kind: object + has: + kind: pair + has: + field: value + regex: "inference\\.done" + stopBy: neighbor +``` + +**By descendant access chain.** Exclude matches that are part of a longer property chain (e.g., match `$X.data.message` but not `$X.data.message.headers`): + +```yaml +rule: + pattern: $X.data.message + not: + inside: + pattern: $X.data.message.headers + stopBy: end +fix: $X.data.turn +``` + +**By node kind.** Distinguish type-level vs value-level occurrences. Use `kind: property_signature` for interface/type definitions and `kind: pair` for object literal expressions — they share the same surface syntax but are different AST nodes. + +### Transforms + +Derive new metavariables for use in `fix`: + +```yaml +id: snake-to-camel +language: typescript +rule: + pattern: $FUNC($$$ARGS) +transform: + CAMEL_NAME: + convert: + source: $FUNC + toCase: camelCase +fix: $CAMEL_NAME($$$ARGS) +``` + +Available transforms: + +| Transform | Purpose | +|---|---| +| `convert` | Change case (`upperCase`, `lowerCase`, `camelCase`, `snakeCase`, `pascalCase`, `kebabCase`) | +| `substring` | Extract a substring by char index | +| `replace` | String find-and-replace within a metavar | +| `rewrite` | Apply sub-rewriters to a metavar (for nested transformations) | + +## Debugging Non-Matching Patterns + +When a pattern does not match what you expect: + +1. **Inspect your pattern's AST.** Use `--debug-query=pattern` to see how ast-grep parses your pattern: + ```bash + sg run --pattern 'your_pattern($X)' --lang js --debug-query=pattern + ``` + +2. **Inspect the source code's AST.** Use the target code itself as the pattern to see its tree structure: + ```bash + sg run --pattern 'myFunc(arg1, arg2)' --lang js --debug-query=ast + ``` + This shows you the node kinds in the source, which tells you what your real pattern needs to match against. Compare the AST of your pattern (step 1) with the AST of the source to find the mismatch. + +3. **Common causes of non-matches:** + - **Ambiguous parse:** The pattern is valid syntax but parsed as something you did not intend. ast-grep picks the first valid AST interpretation, which may not be what you meant. The most common case: `key: value` patterns (e.g., `message: AssistantTurn`) parse as a **labeled statement** (`message:` label + `AssistantTurn` expression), not a property signature or object pair. The pattern silently matches nothing (exit 1, no error). Use `--debug-query=pattern` — if the output shows `labeled_statement`, you have hit this. Fix by wrapping in braces for object context (`{ message: $VAL }`) or using a YAML rule with `kind: property_signature` or `kind: pair` to match the intended node type. + - Optional syntax missing from pattern (add `$$$` to absorb it) + - Multi-statement pattern (not supported — use relational rules) + - Wrong node granularity (pattern matches expression but code is in a statement context, or vice versa) + - Language alias mismatch (`ts` vs `typescript` vs `tsx` — use the right one for the file type) + +## Exit Codes + +`sg run`: exit `0` means matches were found, exit `1` means no matches — but this is not reliable when the pattern contains an ERROR node, which also exits 0 with zero matches and a warning on stderr. Always check stderr for warnings. Other exit codes indicate errors (e.g., 3 when `--stdin` is used without `--lang`, 2 when `--debug-query` is used without `--lang`, 8 for patterns that fail to parse such as multi-statement patterns). Do not treat all non-zero exits as "no matches." + +`sg scan`: exit `0` means no error-severity findings, exit `1` means at least one `severity: error` finding. All other severities (`warning`, `info`, `hint`) exit `0`. + +## Language Detection + +When running on file paths, ast-grep auto-detects the language from file extensions. The `--lang` flag is required when using `--stdin` (no file extension to infer from) or when using `--debug-query` (always requires explicit language). You can omit `--lang` for normal search and rewrite operations targeting directories or specific files. + +## Workflow Guidance + +### Before writing any pattern + +These steps are mandatory, not advisory. Skipping them is the single most common cause of wasted work with ast-grep. + +1. **Check known pitfalls.** Review the "Common causes of non-matches" list in the Debugging section before writing your pattern. The labeled statement trap (`key: value` parsing as a label, not a property) catches people repeatedly even after they know about it. +2. **Inspect your pattern's parse.** Run `--debug-query=pattern` on every new pattern before using it. If the output shows an unexpected node type (e.g., `labeled_statement` when you expected `pair`), fix the pattern before proceeding. +3. **Inspect the source code's AST.** Run `--debug-query=ast` on a representative snippet of the code you want to match. Identifiers parse as different node kinds depending on context — `property_identifier`, `shorthand_property_identifier`, `type_identifier`, etc. — and a pattern written for the wrong kind will silently match nothing. Discover the actual node kinds before writing the pattern. + +### Before applying rewrites + +**Never apply blind.** Do not pass `-U` on the first run. A blind rewrite overwrites file content in place with no recovery path short of `git checkout` — and if the fix template was wrong, the revert may not restore the original code (e.g., a hardcoded replacement loses the distinct values that were at each site). Follow these steps in order: + +1. **Preview first.** Run without `-U` to see the diff. +2. **Check match count.** Pipe through `--json | jq length` or review the diff output. If the count is higher than expected, inspect the extra matches before applying. If it is lower, you are missing sites. +3. **Scan the whole repo.** The pattern itself provides selectivity — do not manually restrict to specific directories, as this leads to missed rename sites that only surface as build failures. Use `--globs` only to exclude known false positives (e.g., `--globs '!**/vendor/**'` or `--globs '!**/generated/**'`). Always use `**/` in exclusion patterns to match at any depth. +4. **Apply.** Only after previewing and confirming the match set, run with `-U`. + +### After applying rewrites + +5. **Format.** ast-grep rewrites can collapse multi-line formatting to single lines. Run the project's formatter (prettier, rustfmt, gofmt, etc.) after applying rewrites. +6. **Check for stragglers.** Use `grep` for the old name across all file types — including comments, strings, docs, and test fixtures. ast-grep only matches code structure; occurrences in prose, JSDoc, string literals, and non-code files will be missed. +7. **Run the type checker.** ast-grep matches on syntax, not semantics — it cannot guarantee that every reference to a name has been caught across every syntactic context, and it cannot see scope. In typed languages, run the type checker before the test suite. It is the safety net that surfaces both kinds of miss: occurrences of the old name that the pattern did not anticipate (e.g., type annotations missed by a call-site-only rename), and scope collisions where the new name shadows an existing binding. Without a type checker, these gaps are silent and only show up at runtime. +8. **Run the full build.** Run the project's build and test suite to catch anything ast-grep's structural matching could not anticipate. + +### Terminology migrations + +When a rename is a terminology migration (not just a code rename), the code rewrite is only part of the job. After ast-grep handles the structural code changes, do a deliberate manual pass over: + +- Comments and JSDoc that reference the old terminology +- Documentation files (README, guides, API docs) +- String literals (error messages, log messages, descriptions) +- Schema names and persisted keys that should reflect the new terminology + +ast-grep handles code; prose requires separate attention. Skipping this pass leaves the codebase in an inconsistent state where code says one thing and documentation says another. + +### Choosing inline vs YAML + +| Situation | Use | +|---|---| +| Call-site-only rename or argument change | `sg run -p ... -r ...` | +| Renaming an identifier that may appear in type annotations, fields, or destructuring | YAML rule with `any:` over the relevant node kinds (see "Rename an identifier across all syntactic positions" above) | +| Need to exclude certain matches | YAML rule with `not` or `constraints` | +| Need positional context (inside a function, after an import) | YAML rule with `inside`/`follows`/`precedes` | +| Need case conversion or string manipulation in the replacement | YAML rule with `transform` | +| Applying multiple related transformations | Multiple `sg run` commands in sequence, or multiple YAML rules | + +### Combining with manual edits + +ast-grep handles the bulk structural transformation. Use manual edits for: + +- New code that has no existing pattern to transform from +- Changes that require understanding type relationships across files +- One-off fixups after a bulk rewrite (e.g., adjusting a special case that the pattern caught incorrectly) + +The ideal workflow for a large refactor: ast-grep for the mechanical bulk, manual edits for the exceptions, build verification to confirm everything holds together. + +## Acknowledgment + +After reviewing this skill, state: "I have reviewed the ast-grep skill." diff --git a/plugins/corbits-skills/skills/create-issue/SKILL.md b/plugins/corbits-skills/skills/create-issue/SKILL.md new file mode 100644 index 000000000..7e213e106 --- /dev/null +++ b/plugins/corbits-skills/skills/create-issue/SKILL.md @@ -0,0 +1,507 @@ +--- +name: create-issue +description: Create well-structured issues. Linear MCP if available; otherwise ask for GitHub (or another tracker) and remember the preference. +argument-hint: "[description] [--from-doc]" +--- + +# Create Issue + +You are Skywalker. Host is Corbits. This skill is a slash command (`/create-issue`) and is also loadable with `use_skill("create-issue")`. Clarifying questions use `ask_operator`. Do not invent Claude-only tools. Do not invent a Linear REST client. + +Create well-structured issues (and, on Linear, projects / project updates / initiatives when the operator asks). Run tracker selection first, then the quality phases, then create on the chosen tracker. + +## Tracker selection + +Pick the tracker before drafting. Do not skip this. + +1. If `mcp__linear__*` tools are available → Linear. Do not ask. +2. Else `read_file` `.corbits/MEMORY.md` and look for a line `Preferred issue tracker:`. If present, use that tracker. +3. Else `ask_operator` with options: + - GitHub + - GitLab + - Linear (enable MCP) + - Other + Then persist the choice: spawn `task(agent="implement")` with a tiny brief that appends `Preferred issue tracker: ` to `.corbits/MEMORY.md` only. Primary cannot write product files; shell writes are blocked. Do not ask implement to touch anything else. +4. **GitHub** → create with `gh issue create` (title + body) via `run_shell`. If `gh` is missing, tell the operator to install GitHub CLI (`gh`) and stop. Do not invent an HTTP client. +5. **GitLab** → create with `glab issue create` (title + body) via `run_shell` similarly. If `glab` is missing, tell the operator and stop. +6. **Linear without MCP** → stop and tell the operator to enable Linear MCP. Do not invent a Linear REST client. +7. **Other** → `ask_operator` how they file issues, then follow that. + +Linear MCP tool names stay `mcp__linear__*` on the Linear path. + +## Phase 1: Document Discovery + +When the operator provides `--from-doc` or mentions a planning document, search for scribe-managed documents with `search_files` / `read_file` (not Glob): + +1. Search for `PRODUCT.md`, `ARCHITECTURE.md`, `IMPLEMENTATION.md` at: + - Repository root + - `docs/` directory + +2. If no documents are found, `ask_operator`: + > I couldn't find any planning documents. Do you have a document you'd like me to reference? + +3. When a document is found, `read_file` it and extract: + - Features or work items mentioned + - Technical context and constraints + - Scope indicators (timeline mentions, complexity signals) + +Use extracted information to: + +- Pre-populate issue descriptions with relevant context +- Propose appropriate artifact types based on scope +- Ask targeted follow-up questions for gaps not covered by the document + +## Phase 2: Analyze Input (scope) + +Determine what the user wants to create: + +1. **Explicit request**: User specifies artifact type ("create an issue for...", "create a project for...", "post a project update for...") +2. **From document**: User provides `--from-doc` to extract work items from planning documents +3. **Freeform**: User describes work without specifying type + +Project updates are a distinct Linear artifact: they communicate status on an existing project to a non-technical audience and are never inferred from scope. The user must explicitly ask for one. Skip project/initiative/update artifacts on GitHub and GitLab unless the operator explicitly wants an issue-shaped stand-in. + +For freeform input, estimate the scope: + +| Scope | Duration | Artifact | +|-------|----------|----------| +| Small | 1-3 days | Single issue | +| Medium | 1-2 weeks | Project with issues (Linear) or a set of issues (GitHub / GitLab) | +| Large | Quarter+ | Initiative with projects (Linear) or grouped issues (GitHub / GitLab) | + +Present your assessment and `ask_operator` to confirm before proceeding. + +## Phase 3: Interview + +If information is missing, `ask_operator` with targeted questions. Keep interviews brief and focused. + +### For Issues + +Required information: + +- What problem does this solve or what value does it add? +- How will we know it's done? (acceptance criteria) + +Optional: + +- Are there technical constraints or dependencies? +- Which team should own this? + +### For Projects (Linear) + +Required information: + +- What is the goal/outcome of this project? +- What is the target timeframe? +- Who should lead this project? + +Optional: + +- What teams are involved? +- What are the key milestones? + +### For Initiatives (Linear) + +Required information: + +- What strategic objective does this serve? +- Who is the executive owner? +- What projects should be included? + +### For Project Updates (Linear) + +Required information: + +- Which project is this update for? (use `mcp__linear__list_projects` and confirm with the user if the match is not exact) +- What is the project's current health? (on track, at risk, off track, completed, paused) +- What has the project unlocked or enabled since the last update? Describe in terms of capabilities, outcomes, or things that are now possible — not lists of completed tickets. +- What's coming next, framed by user-visible impact? +- Are there any risks or blockers the audience needs to know about? Describe them by impact, not implementation. + +Optional: + +- Should the update be tied to a specific milestone? + +Before drafting, retrieve the most recent prior update with `mcp__linear__get_status_updates` so the new one continues the narrative rather than restating prior progress. + +## Phase 4: Draft Content (title + AC) + +Create drafts following these conventions. + +### Do Not Reference Local Files + +Tracker artifacts are read by people who do not share your working directory. Do not include local file paths, line numbers, working-tree-relative paths, or instructions like "see `src/foo.ts`" in titles, descriptions, or comments. Those references rot, are not clickable, and assume context the reader does not have. + +Instead: + +- Describe the behavior, module, or concept in plain language ("the authentication middleware", "the request retry logic") +- Link to permanent URLs (GitHub permalinks at a specific commit, published documentation) when a precise pointer is required +- Quote the relevant code inline if a short excerpt is needed for context + +This applies equally when drafting from planning documents — extract the meaning, do not transcribe paths. + +### Specs Belong as Attachments (Linear) + +If a spec, design document, or planning artifact needs to be preserved so an implementer can refer to it, attach it to the Linear artifact rather than referencing the local file path: + +- Specs that describe an entire project's scope or design attach to the **project** +- Specs that describe a single unit of work attach to the **issue** for that work + +Use `mcp__linear__prepare_attachment_upload` followed by `mcp__linear__create_attachment_from_upload` (or `mcp__linear__create_attachment` for URL-based references) to upload the document. Once attached, any reference inside the issue or project description should point to the attached document — never to the original local file path. + +On GitHub / GitLab, paste the relevant meaning into the issue body or link a permanent URL. Do not point at local paths. + +### Issue Format + +**Title**: Clear and actionable. Verb phrases are preferred, but sentences or noun phrases are acceptable when they provide clarity. + +- Good: "Add retry logic for failed API calls" +- Good: "Fix race condition in transaction verification" +- Good: "Create market validation track for " +- Bad: "API retry" (too vague) +- Bad: "Bug in transactions" (not actionable) + +**Description**: + +``` +# Background + + + +# Outcome + + + +- [ ] +- [ ] +``` + +For simple tasks, you can omit `# Background` and use only `# Outcome` with checkboxes. + +**Labels and Priority**: + +- Set priority based on urgency and impact +- Apply labels for categorization (e.g., bug, feature, tech-debt) if the workspace uses them +- `ask_operator` about priority and labels if not specified + +When appropriate, use subsections under `# Outcome` to organize related items: + +``` +# Outcome + +## Questions + +- [ ] What are the key takeaways? +- [ ] Which parts apply to our strategy? + +## Tasks + +- [ ] Document findings +- [ ] Present to team +``` + +### Project Format (Linear) + +**Name**: Outcome-focused description + +- Good: "User authentication with SSO support" +- Good: "Get 10 customer leads for through direct outreach" +- Bad: "Auth work" + +**Description**: Goal, scope, and any constraints. + +For validation or experiment projects, use the Hypothesis/Experiment/Steps pattern: + +``` +Hypothesis - + +Experiment + + +* +* +* +``` + +**Milestones**: Key checkpoints showing progression toward the goal. Examples: + +- Completion states: "Target list ready", "Outreach completed", "Analysis complete" +- Phase labels: "MVP", "Full implementation", "Polish and launch" + +### Initiative Format (Linear) + +**Name**: Strategic objective + +**Description**: Include as much information as needed to convey the business goal and how success will be measured. If unsure what to include, prompt the user for guidance. + +### Project Update Format (Linear) + +**Audience**: Project updates are read by non-technical stakeholders — founders, GMs, customer-facing teammates, leadership, and sometimes customers. Write for someone who cares about *what the project makes possible*, not *what work was done*. + +**Style rules:** + +- Lead with what is now possible, available, or unblocked because of recent progress. The reader wants to know what changed for them, not what changed in the codebase. +- Do not enumerate completed issues, PR titles, commits, or internal implementation details. "Shipped INF-204, INF-205, INF-211" is the wrong shape; "Customers can now invite teammates and assign roles without contacting support" is the right shape. +- Avoid jargon, acronyms, internal codenames, and tool-of-the-week terminology unless they are already part of the audience's vocabulary. When in doubt, spell it out in plain language. +- Frame risks and blockers by their impact on the outcome ("the launch date may slip by two weeks because we are still waiting on the vendor's API access"), not by their technical cause. +- Keep it short. A project update that takes more than a minute to read will not be read. + +**Structure**: + +``` +## Where we are + + + +## What this unlocks + + + +## What's next + + + +## Risks + + +``` + +If a section has nothing meaningful to say in this update, omit it rather than padding it. + +**Health**: Set the project health to match reality (`onTrack`, `atRisk`, `offTrack`, `complete`, or `paused`). If you would not show the chosen health to the project's sponsor with a straight face, it is the wrong health. + +**Self-check before posting**: Re-read the draft and ask, "would a non-engineer who has never opened the codebase come away knowing what changed for them?" If the answer is no, rewrite it. + +## Phase 5: Review and Adjust + +Present the complete draft and `ask_operator` whether to adjust anything before creating: + +``` +I propose creating: + +**Project**: Add user authentication +- Lead: +- Target: +- Milestones: + 1. Basic auth flow complete + 2. SSO integration complete + +**Issues**: +1. "Set up authentication database schema" +2. "Implement login/logout flow" +3. "Integrate SSO provider" +4. "Add session management" + - Blocked by: #2 + +Would you like to adjust anything before I create these? +``` + +Allow the operator to: + +- Adjust titles or descriptions +- Change the structure (e.g., "make #3 and #4 one issue") +- Add or remove items +- Specify assignees or teams + +## Phase 6: Create on the tracker + +### Linear (`mcp__linear__*`) + +Before creating artifacts, query the Linear workspace: + +- **Teams**: Always query available teams (`mcp__linear__list_teams`) and `ask_operator` which team should own issues or projects +- **Projects**: Query existing projects (`mcp__linear__list_projects`) if the user mentions adding to an existing project +- **Initiatives**: Query existing initiatives (`mcp__linear__list_initiatives`) if linking to one + +Present options to the user when multiple choices exist. + +After user approval, create artifacts using the appropriate `mcp__linear__*` tools: + +1. **Create container first** (initiative via `mcp__linear__save_initiative`, project via `mcp__linear__save_project`) +2. **Create issues** in dependency order (`mcp__linear__save_issue`) +3. **Set blocking relationships** between issues (via `mcp__linear__save_issue` parameters) +4. **Add issues to project** (via `mcp__linear__save_issue` parameters) +5. **Add projects to initiative** (via `mcp__linear__save_project` parameters) +6. **Post project updates** (via `mcp__linear__save_status_update`, targeting the project resolved during the interview) + +Report created artifacts to the user with their URLs. + +### GitHub (`gh`) + +After approval, create each issue with `run_shell`: + +```bash +gh issue create --title "" --body "<body>" +``` + +If `gh` is missing, tell the operator to install GitHub CLI and stop. Do not invent an HTTP client. Report created issue URLs. + +### GitLab (`glab`) + +After approval, create each issue with `run_shell`: + +```bash +glab issue create --title "<title>" --description "<body>" +``` + +If `glab` is missing, tell the operator to install GitLab CLI and stop. Report created issue URLs. + +### Linear without MCP + +Stop. Tell the operator to enable Linear MCP. Do not invent a Linear REST client. + +### Other + +`ask_operator` how they file issues, then follow that recipe. + +## Error Handling + +If creation fails: + +1. Report the error to the user with any details provided by the API or CLI +2. List what was successfully created before the failure (with URLs if available) +3. Do not proceed with dependent artifacts if a parent fails (e.g., don't create issues if project creation failed) +4. Ask if they want to retry or adjust the request + +## Quality Reminders + +- Issues should be self-contained and handoff-ready at any moment +- A single issue should take no more than 2-3 days to implement +- Use many tickets if needed for clarity; they're cheap +- Status updates in tickets reduce interruptions + +If an issue looks like it will take more than 3 days, suggest breaking it down. + +## Common Patterns + +### Bug Report to Issue + +``` +User: "The login page crashes when you enter special characters" + +Issue: + Title: Fix login page crash on special character input + Description: + # Background + + Login page crashes when users enter special characters in the + username or password field. + + Steps to reproduce: + 1. Navigate to /login + 2. Enter "user@test" in username + 3. Page crashes + + # Outcome + + - [ ] Special characters in username field do not cause crash + - [ ] Special characters in password field do not cause crash + - [ ] Input is properly sanitized before processing +``` + +### Feature Request to Project and Issues + +``` +User: "We need to add dark mode to the application" + +Project: Add dark mode theme support + Target: 2 weeks + Milestones: + 1. Theme infrastructure complete + 2. All components themed + +Issues: + 1. "Add theme context and toggle component" + 2. "Define dark mode color palette" + 3. "Update core components for theme support" + - Blocked by: #1, #2 + 4. "Add theme persistence to user preferences" + - Blocked by: #1 +``` + +### Validation Project + +``` +User: "We need to validate if customers want our new <product-name> product" + +Project: Get 10 customer leads for <product-name> through direct outreach + Lead: <to be assigned> + Target: 2 weeks + Description: + Hypothesis - Teams we know and can reach out to have a need for <product-name>. + + Experiment + Direct Outreach + + - [ ] Create list of targets + - [ ] Create collateral if needed + - [ ] Create strategy for outreach including any templates + - [ ] Execute outreach + - [ ] Conduct customer interviews + - [ ] Analyze results + + Milestones: + 1. Target list and collateral ready + 2. Outreach completed + 3. Customer interviews recorded + 4. Analysis complete + +Issues: + 1. "Create target list for <product-name> outreach" + 2. "Create outreach collateral and templates" + 3. "Execute outreach campaign" + - Blocked by: #1, #2 + 4. "Conduct and record customer interviews" + - Blocked by: #3 + 5. "Analyze results and present findings" + - Blocked by: #4 +``` + +### Strategic Goal to Initiative + +``` +User: "We need to expand our platform to support enterprise customers" + +Initiative: Enterprise platform expansion + Owner: <executive-owner> + Target: <target-quarter> + +Projects: + 1. "Multi-tenant architecture" - Isolate customer data and resources + 2. "Enterprise SSO integration" - Support SAML and OIDC providers + 3. "Admin dashboard" - Self-service management for enterprise admins + 4. "Audit logging" - Compliance-ready activity tracking +``` + +### Planning Document to Issues + +``` +User: "Create issues from our product doc" or "--from-doc" + +[Skill searches for PRODUCT.md, ARCHITECTURE.md, IMPLEMENTATION.md] +[Finds PRODUCT.md with feature descriptions] + +Skill: I found PRODUCT.md which describes the following features: + - User authentication with SSO + - Usage metrics dashboard + - Export functionality + +Based on the document, I propose: + +**Project**: User authentication with SSO support + (From PRODUCT.md: "Users need secure login with enterprise SSO...") + +**Issues**: + 1. "Implement basic email/password authentication" + # Background + From PRODUCT.md: Users need secure login... + + # Outcome + - [ ] Users can register with email/password + - [ ] Users can log in and log out + + 2. "Integrate SAML SSO provider" + ... + +Which features would you like me to create issues for? +``` diff --git a/plugins/corbits-skills/skills/dispatch/SKILL.md b/plugins/corbits-skills/skills/dispatch/SKILL.md new file mode 100644 index 000000000..0fb9683e3 --- /dev/null +++ b/plugins/corbits-skills/skills/dispatch/SKILL.md @@ -0,0 +1,213 @@ +--- +name: dispatch +user-invocable: false +argument-hint: "[<name> | dispatch/<name>/ | dispatch/<name>/dispatch.yaml | <spec-file> ]" +description: Multi-lane DAG orchestration. Skywalker recipe — use_skill("dispatch"). Spawns explore, intern, implement, plan, and critique. Never implements product code. +--- + +# Dispatch + +You are Skywalker. This skill is loadable with `use_skill("dispatch")`. Follow this recipe; do not implement product code; do not write `dispatch.yaml` or `plan.md` yourself. + +Orchestrate parallel director runs across a dependency graph. Fan out work, fan in reports, critique, verify, re-dispatch fixes, and synthesize until done. + +Hard cap: **at most 4 workers at once** unless the operator explicitly asks for a wider fan-out. Track progress with `manage_tasks`. + +Closed directors used here: `explore`, `intern`, `implement`, `plan`, `critique`. Optional consults: `greybeard`, `tester`. Never a catch-all worker. DAG node agents are `explore`, `intern`, and `implement` only. + +## Input resolution + +Figure out what to run from the argument: + +- **No argument** → latest dispatch (newest `dispatch/<name>/` directory by creation time) +- **Just a name** (e.g. `auth-fix`) → `dispatch/<name>/dispatch.yaml` +- **Directory** (e.g. `dispatch/auth-fix/`) → `dispatch.yaml` inside +- **File ending in `dispatch.yaml`** → run it +- **Any other file** → treat as a spec. If it still needs an eng plan, spawn `task(agent="plan")` first, then run. + +If the spec is vague, incomplete, or contradictory: stop and report Blockers. Do not invent a DAG. + +## Who does what + +| Work | Director | +|---|---| +| Map the codebase, gather facts | `task(agent="explore")` | +| Eng plan from a spec (no ship) | `task(agent="plan")` | +| Write `dispatch.yaml` / `plan.md` / status artifacts (mechanical brief; no product feature work) | `task(agent="implement")` | +| Ship product code + tests | `task(agent="implement")` | +| Review a landed task (defects, evidence, no fix) | `task(agent="critique")` | +| Architecture judgment before a large DAG | `task(agent="greybeard")` | +| Independent suite / repro evidence | `task(agent="tester")` | + +Skywalker classifies, spawns, tracks, and synthesizes. Product mutation tools are not mounted on this session. Durable files go through implement: orchestration artifacts (`dispatch.yaml`, `plan.md`, status) and product code. Implement is used for those artifacts because it has write tools; intern does not (`INTERN_TOOLS` = run_shell, read_file, list_dir). Do not spawn a blob agent to author the manifest. + +Prefer typed briefs: `intent`, `success_criteria`, `do_not`, `report_focus`, and `agent`. + +## Agent type selection + +Use **explore** when the task is pure research. No code changes. Output is findings for downstream tasks. + +Use **intern** when the work is mechanical and well-specified: git commit after a level fans in, exact shell, mechanical git. Intern cannot write files. + +Use **implement** when the task ships product code — including work that needs judgment, new abstractions, or tests — and for mechanical writes of `dispatch.yaml` / `plan.md` / status artifacts (write tools; intern does not have them). There is no catch-all implementation agent. + +Critique is not a DAG node agent type. After implement (and after non-trivial intern landings), spawn `task(agent="critique")` with the task's objective, paths, and diff. Simple intern tasks may skip critique. + +Classify each product task as `feature` or `bugfix`: + +- `bugfix`: incorrect behavior that exists today → test-first (fail, then fix, then pass) +- `feature`: everything else → tests for the new behavior +- When unsure, default to `feature` + +## Phase 1: Planning + +Runs when the input is a spec (or a request with no existing manifest). The spec should be complete enough that an implement worker could succeed from it. + +1. If the spec still needs an ordered eng plan, spawn `task(agent="plan")`. Do not skip this when requirements are large or ambiguous. +2. Spawn `explore` workers only as needed to map scope. Distinct path/package lenses if parallel. +3. Consult `greybeard` before large multi-lane work when architecture is in play. +4. Break the goal into discrete tasks, each small enough for one director. +5. Identify dependencies (DAG edges). Same-file writers at the same level must be merged or serialized via `depends-on`. +6. Assign `explore` | `intern` | `implement` per the guide above. +7. Detect verify commands from `package.json`, Makefile, or project docs. +8. Add per-task verification to each plan (build for compiled changes, tests for test-writing tasks). +9. Default commit strategy is **per-task** (debuggable). Use grouped only when the operator wants a cleaner history **and** Phase 5 will catch issues. +10. Mark which tasks need critique (complex implement → yes; simple intern → no; when unsure, yes). +11. Seed `manage_tasks` with one item per DAG task (plus plan / verify / critique items as needed). + +If requirements are not actionable, stop. Ask: "Can an implement worker succeed with only this information?" + +## Phase 2: Directory structure + +Have **implement** write the run tree (mechanical brief; no product feature work). Do not write these files on Skywalker. Do not use intern — intern cannot write files. Do not use a catch-all worker. + +``` +dispatch/ + <run-name>/ + dispatch.yaml + 1a-extract_auth_module/plan.md + 1b-extract_logging_module/plan.md + 2a-integrate_modules/plan.md +``` + +`<run-name>` is short kebab-case. Prefer `dispatch/` in `.gitignore`. + +Task directory names: `<level><sequence>-<short_description>` + +- **Level** (1, 2, 3…): DAG depth. Roots are level 1. Level is longest path from a root, plus one. +- **Sequence** (a, b, c…): siblings at the same level (candidates for parallel). +- **Description**: underscore-separated, from the objective. + +The directory name is the task `id`. After a worker runs, the task directory is its scratchpad (`plan.md` in, `output.yaml` and logs out). + +### Manifest (`dispatch.yaml`) + +```yaml +goal: "Short description of the overall goal" +status: pending # pending | in-progress | completed | failed +max-parallel: 4 # hard cap unless the operator asks for more +created: YYYY-MM-DD + +verify: + workdir: "" # empty = repo root + build: "bun run build" # omit if n/a + test: "bun test" + lint: "bun run lint" + +critique: + enabled: true + agent: critique # always task(agent="critique") + +commits: + strategy: per-task # per-task | grouped + message-source: objective + +tasks: + - id: 1a-extract_auth_module + type: feature # feature | bugfix (omit for explore) + agent: implement # implement | intern | explore + depends-on: [] + receives: [] # subset of depends-on; default = depends-on + status: pending # pending | dispatched | completed | failed | fixing + critique: + enabled: true + + - id: 2a-integrate_modules + type: feature + agent: implement + depends-on: [1a-extract_auth_module, 1b-extract_logging_module] + status: pending +``` + +Task statuses: `pending` → `dispatched` → `completed` | `failed` | `fixing`. Downstream tasks wait for `completed`. + +### Task `plan.md` + +Implement writes one per task (mechanical brief). Include: objective, requirements covered, context (paths and symbols — no line numbers, no dispatch-dir cross-refs), files to modify, constraints, verification (test-first for bugfix), and `do_not`. + +Every product-task brief must tell the worker: + +- Do **not** run mutating git (`git add` / `commit` / `checkout` / `stash`). Intern commits after the level fans in. +- Leave changes uncommitted. Multiple tasks may share a worktree. +- Read the full `plan.md` before acting. If it is unclear, fail closed. + +## Phase 3: Validate, then present + +Before any product spawn: + +**Structural:** DAG is acyclic; every `depends-on` / `receives` id exists; `receives` ⊆ `depends-on`; every task has `plan.md`; no orphan directories. + +**Completeness:** clear objectives; files named; union of tasks covers the goal; every spec requirement maps to at least one task. + +**Coherence:** no two ready-in-parallel tasks write the same file; constraints do not contradict; `explore` is never assigned product writes. + +**Feasibility:** referenced files exist or are created by this task or an upstream dependency; scope fits one worker. + +Empty task list → mark the run `completed` and report. Do not invent work. + +Present the DAG (ids, agents, deps, critique flags, verify commands, commit strategy) to the operator. Wait for go-ahead on large or ambiguous runs. Then set status `in-progress` (implement updates the manifest if it is on disk). + +## Phase 4: Execute the DAG + +1. **Ready set:** `pending` tasks whose `depends-on` are all `completed`. +2. **Batch:** take a safe parallel subset, **at most 4 live workers** (including in-flight critique). Same-file writers and shared mutable state (build artifacts, test DBs) must not share a batch — serialize with `depends-on`. +3. **Spawn** each task with `task(agent="<id from manifest>")`. Inject upstream reports (not a rewritten `plan.md`) into the brief. Split ownership by path/package when two implement workers run together. +4. **Fan in:** trust the worker report (and `output.yaml` when implement wrote one). Missing report or `status: failed` → mark `failed`. Do not re-fan-out an identical brief; change `success_criteria` / `do_not` or tell the operator. +5. **Level commit:** after a level's product tasks self-report complete, intern commits per the strategy (per-task default). Workers must not have committed. +6. **Critique:** for tasks with `critique.enabled`, spawn `task(agent="critique")` on that commit/diff + objective. Blocking findings → re-dispatch `implement` with those findings in `success_criteria` / `do_not` (status `fixing`). Cap re-fix rounds (1–2), then report Blockers. +7. Repeat until no pending tasks remain, or deadlock / all remaining failed → stop and ask. + +Keep `manage_tasks` in sync as items move `todo` → `doing` → `done` / stay blocked. + +If the working tree has unrelated uncommitted changes before Phase 4, ask the operator. Do not mix them into level commits. + +## Phase 5: Verify + +Must `task(agent="tester")` for the suite (or intern for one named mechanical command). Do not run the full verify pipeline on the parent via Skywalker `run_shell`. Compare against any baseline you captured. + +- Green, or same failures as baseline → proceed. +- New failures → attribute to a task/commit, re-dispatch `implement` on that lane, re-verify. Cap rounds, then Blockers. +- Do not declare done on a worker "ready" that ignored blocking critique or verify. + +## Phase 6: Complete + +Synthesize for the operator: + +## Summary +## Findings +## Blockers +## Paths + +Include: what landed, which directors ran, verify evidence, remaining failed/fixing tasks. Mark the run `completed` or `failed`. `manage_tasks` should reflect the same. + +## Resume + +Re-resolve input to the existing `dispatch/<name>/`. Re-validate the remaining DAG. Continue from the ready set. Do not re-plan completed work unless the operator asks. + +## Non-negotiables + +- You are Skywalker. Spawn directors. Do not implement product features. Do not author dispatch YAML/plan files yourself or via a catch-all worker. Durable orchestration files go through implement. +- `use_skill("dispatch")` loads this recipe. It is a command. +- Agents: `explore`, `intern`, `implement` only for DAG nodes. Critique via `task(agent="critique")`. Plan via `task(agent="plan")` when a spec needs an eng plan first. +- Progress: `manage_tasks`. +- At most 4 workers at once unless the operator asks for more. diff --git a/plugins/corbits-skills/skills/git-rebase/SKILL.md b/plugins/corbits-skills/skills/git-rebase/SKILL.md new file mode 100644 index 000000000..263c55c67 --- /dev/null +++ b/plugins/corbits-skills/skills/git-rebase/SKILL.md @@ -0,0 +1,699 @@ +--- +name: git-rebase +user-invocable: false +description: Reshape git history with rebase — edit-in-place, squash/fixup, drop, split, reword, or validate every replayed commit. Skywalker plans; intern executes sequenced non-interactive git via run_shell. Load whenever a commit that is not HEAD needs changing, or for branch-history cleanup before push. +--- + +# git-rebase + +You are Skywalker. Host is Corbits Code. This skill is a spawn recipe. You do not run the rebase. You do not edit files or run git yourself. + +Use this skill when a branch's commit history needs rewriting — squashing fixups, dropping wrong-turn commits, splitting bundled changes, rewording messages — without interactive prompts. + +`git rebase -i` is normally driven through an interactive editor. The techniques below drive every editor invocation programmatically so the rebase runs to completion without a human at the keyboard. Scripted rebases are reproducible, re-runnable, and self-documenting in a way that vim-driven ones never are. + +## Skywalker recipe + +1. Read the techniques below. Identify the surgery (drop, squash, split, reword, edit-in-place, validate). +2. If a step needs judgment (what to squash, which commits to drop, how to split, which message), `ask_operator` first. Do not guess. +3. Copy the exact sequenced commands from this skill into an intern brief. +4. Spawn `task(agent="intern")` with that sequenced command list. Intern executes via `run_shell` (there is no Bash tool). Intern drives every editor via `GIT_SEQUENCE_EDITOR` / `-c sequence.editor` / `-c core.editor` inline in the git command — do not tell intern to `write_file` an editor script. Intern runs git and resolves mechanical conflicts as the brief specifies. + +5. If intern hits a judgment call mid-rebase, `ask_operator` then re-dispatch intern with the decision. + +Skywalker synthesizes. Intern mutates git. + +## Techniques (copy into the intern brief) + +## Assumptions + +- Git ≥ 2.18. Earlier versions handle `--autosquash` interaction with + `GIT_SEQUENCE_EDITOR=true` differently and do not support some of the + edit-todo behaviors used below. Git ≥ 2.38 adds `--update-refs`, + which the workflow uses when stacked branches are present (see + "Stacked branches" below). +- A POSIX shell (`/bin/sh`) is available for inline editor commands + (`sh -c` as `GIT_SEQUENCE_EDITOR` / `-c sequence.editor` / + `-c core.editor`). Every editor command in this skill starts with + `set -eu` so an intermediate failure (failed `sed`, missing file, + undefined variable) aborts with a non-zero exit — letting the rebase + fail loudly rather than silently succeeding with a no-op edit. + Intern does not `write_file` helper scripts; the editor is the quoted + command git invokes. + +- `sed -i.bak <file>` is the portable in-place form across BSD and GNU + sed; it creates `<file>.bak`, which the editor command then removes with + `rm -f <file>.bak`. Bare `sed -i` is GNU-only; bare `sed -i ''` is + BSD-only. Don't mix them. +- If the repository enforces signed commits (`commit.gpgsign=true`, + `gpg.format=ssh`, or similar), rebase strips signatures from every + replayed commit unless you pass `-S` / `--gpg-sign` (or set + `rebase.gpgSign=true`). Re-sign explicitly when the project's policy + requires it; an unsigned commit that sneaks through a rebase is + invisible until the next push fails. + +## When to use this skill + +Reach for it when development pace produced messy history that needs to be +made coherent before the branch is pushed for review: + +- A flip-flop: commit X added behavior, commit Y reverted it, commit Z added + it back. The net change is what Z does, but the history reads as confusion. +- A commit with a process-talk message ("Address review findings", "Fix bug + from last commit") that should describe the behavior change instead. +- A commit that bundles unrelated changes that belong in different earlier + commits. +- A drive-by lint fix smuggled into an unrelated feature commit. +- A bug fix discovered during integration testing that belongs in the commit + that introduced the bug. + +Do **not** reach for it when: + +- The commit you want to fix is HEAD itself. Use `git commit --amend` + (with `-m` for a pre-written message, `--no-edit` + to keep the existing one). No rebase needed. +- The branch is already pushed and other people are basing work on it. +- The history is already coherent and you are only chasing aesthetic + perfection. Style and philosophy say "commits should read like a story" — + not "every commit must be perfect." +- The base branch is unstable and your branch will need to rebase repeatedly. + Wait until things settle, or enable `git rerere` (see "Repeated rebases" + below). +- The job is repo-wide history surgery — removing a secret from every + commit, splitting a monorepo, rewriting author identities, mass-rewriting + hundreds of commits. Reach for [`git filter-repo`](https://github.com/newren/git-filter-repo) + instead. Scripting `GIT_SEQUENCE_EDITOR` for that many commits is a path + of suffering. + +## The non-interactive insight + +Git invokes an editor at several points during a rebase. The two that +matter for scripting are: + +| Editor invocation | Env var | What it edits | +|---|---|---| +| Rebase plan ("todo list") | `GIT_SEQUENCE_EDITOR` | The list of `pick`/`reword`/`edit`/`fixup`/`drop`/`squash` lines | +| Commit message editing | `GIT_EDITOR` | A single commit message file (used for `reword`, `squash` combined messages, and amend-during-edit) | + +`GIT_EDITOR` also fires for `git rebase --edit-todo` and for conflict-file +editing when configured. The risk of using `GIT_EDITOR="cp ..."` is exactly +that it fires for *every* editor invocation in the rebase, not just the +one you have in mind. See "Pattern 3" below for safer dispatchers. + +`GIT_SEQUENCE_EDITOR` is the killer feature. Almost everything else flows +from being able to script the rebase plan. + +## Safety first: branch your way back + +Before any history surgery, create a backup branch at the current HEAD so a +hard reset returns you to a known-good state if anything goes wrong: + +```bash +git branch backup-$(git rev-parse --abbrev-ref HEAD)-pre-rebase +``` + +(Avoid `backup/<branch>` if your branch names contain `/` — git refs cannot +be both a directory and a file. A flat `backup-<branch>` namespace is +safer.) + +If you need to restore and a rebase is in progress, abort it first: + +```bash +git rebase --abort 2>/dev/null # safe if no rebase is in progress +git reset --hard backup-<branch-name>-pre-rebase +``` + +**`git rebase --abort` is your deliberate bail-out.** If you find +yourself in a tangled `--continue`/`--skip` loop and have lost the +thread of what each conflict means, abort and start over from the +backup branch. Re-running a planned rebase from a clean state is almost +always faster than rescuing one mid-flight. + +**If you skipped the backup branch, the reflog is your fallback.** Every +update to a branch ref is recorded: + +```bash +git reflog show <branch-name> # find the pre-rebase entry +git reset --hard <branch-name>@{<n>} # reset to that entry +``` + +The reflog entries expire (default 90 days for reachable, 30 for +unreachable), so this is a recovery path of last resort — the backup +branch is the right primary mechanism. + +Validate the rewritten branch against the backup at the end. Use the +two-argument form of `git diff` (not the `A..B` range form): `git diff` +operates on trees, not commit ranges. + +```bash +git diff backup-<branch-name>-pre-rebase HEAD --stat +``` + +An empty diff is the "you did not lose any content, only reshaped history" +proof. Run this after every meaningful rebase step. + +**Detecting a zombie rebase.** Some safety configs — notably +`rebase.missingCommitsCheck=error` — pause a rebase rather than +aborting it when the rebase plan is rejected, leaving +`.git/rebase-merge/` in place. The editor command exits 0 and the outer +`git rebase` command exits 0 too, so a naive caller sees apparent +success. After every rebase, check explicitly: + +```bash +if [ -d .git/rebase-merge ] || [ -d .git/rebase-apply ]; then + echo "rebase in progress — investigate before proceeding" >&2 + exit 1 +fi +``` + +Treat a leftover rebase dir as a failure regardless of what git's exit +code said. + +## Patterns + +### Pattern 1: Drop a commit cleanly + +The cleanest non-interactive way to drop a single commit is `git rebase +--onto`. No editor needed. + +```bash +# Drop commit BAD_SHA, replay everything after it onto BAD_SHA's parent. +# Pass the branch name (not HEAD) so the branch ref moves on success. +git rebase --onto BAD_SHA^ BAD_SHA <branch-name> +``` + +If `BAD_SHA` is the root commit it has no parent, and `BAD_SHA^` fails to +resolve. Use `--root` instead and reshape the rebase to start from a known +empty tree, or first create a parent for it (rare; consult `git rebase +--root` documentation if you hit this). + +If the dropped commit had a counterpart in a later commit (e.g. you added +something in X and reverted it in Y), expect a conflict when the later +commit tries to apply. Resolve by editing the conflict markers out of the +file directly with whatever editing tool is available, then: + +```bash +git add <conflicted-file> +git rebase --continue +``` + +**Detached-HEAD caveats:** + +- During the rebase itself, HEAD is detached. If conflicts arise mid-way, + you are resolving them on a detached HEAD; that's normal and expected. +- Do *not* `git checkout` away from a mid-rebase detached HEAD as a way + to "escape" an unexpected state. Doing so abandons the in-flight + rebase work — only the reflog can recover what was committed, and + only within its expiry window. If you want out, `git rebase --abort` + first. +- On *successful completion*, passing `<branch-name>` causes git to move + the branch ref forward. Passing `HEAD` does not — you finish on a + detached HEAD and have to re-attach manually with `git checkout -B + my-branch HEAD`. + +### Pattern 2: Script the rebase todo list + +Pass an inline editor that takes the todo file path as `$0` (git appends +it) and rewrites it in place. Point `GIT_SEQUENCE_EDITOR` — or the +equivalent `git -c sequence.editor=...` — at that command. Do not +`write_file` a helper script, then run it. + +```bash +# Substitute the real abbreviated SHA before running — a no-op sed pattern +# produces a successful no-op rebase that looks like it worked. +GIT_SEQUENCE_EDITOR='sh -c "set -eu; sed -i.bak \"s/^pick abc1234/edit abc1234/\" \"\$0\"; rm -f \"\$0.bak\"; echo \"--- rewritten rebase plan ---\" >&2; cat \"\$0\" >&2"' git rebase -i origin/main +# equivalent: git -c sequence.editor='sh -c "..."' rebase -i origin/main +``` + +The `echo` + `cat` to stderr is cheap insurance: any time you don't see +the expected change in the printed plan, abort and inspect. + +For more complex rewrites, replace the todo wholesale. Note that +`rebase.missingCommitsCheck=error` (a common safety setting) does *not* +reject a wholesale-replace plan that omits commits — it pauses the +rebase mid-flight with `No commands done` and leaves +`.git/rebase-merge/` in place. The editor command exits 0 and so does +the outer `git rebase` command, so a naive caller sees apparent +success. Preserve every line you don't want to drop, and explicitly use +`drop` rather than just removing lines, so the check is satisfied and +the rebase actually runs to completion. Combine with the zombie-rebase +detection from "Safety first" above to catch any case where a paused +rebase slips past. + +```bash +GIT_SEQUENCE_EDITOR='sh -c "set -eu; printf \"%s\\n\" \ + \"pick aaaaaaa First commit\" \ + \"pick bbbbbbb Second commit\" \ + \"reword ccccccc Rename me\" \ + \"fixup ddddddd Fold me into ccccccc\" \ + \"drop eeeeeee Drop me explicitly so missingCommitsCheck stays happy\" \ + \"pick fffffff Keep going\" \ + > \"\$0\"; echo \"--- rewritten rebase plan ---\" >&2; cat \"\$0\" >&2"' git rebase -i origin/main +``` + +The command runs once when git opens the editor for the plan. Its job is to +leave the todo file in the state you want git to execute. + +### Pattern 3: Provide pre-written commit messages + +For `reword` actions (and for `squash` actions that combine messages), git +invokes `GIT_EDITOR` (or `git -c core.editor=...`) on a temp file containing +the current message, expecting you to edit it. Replace the editor with an +inline command that overwrites that file — do not `write_file` a message +file, then `cp` it: + +```bash +GIT_EDITOR='sh -c "printf \"%s\\n\" \"A descriptive subject line under 72 characters\" \"\" \"A body that explains why the change was made, wrapped to 72 columns.\" \"Each paragraph is a complete thought.\" > \"\$0\""' git rebase --continue +# equivalent: git -c core.editor='sh -c "printf ... > \"\$0\""' rebase --continue +``` + +Git passes the message file path as the editor's only argument, which +becomes `$0` for `sh -c`. + +`>` truncates in place (same inode). Avoid `GIT_EDITOR="cp ..."`: `cp` +follows symlinks (overwriting the target rather than the link) and +changes the destination's inode and mtime — which can confuse hooks that +fingerprint the file. Git creates a fresh regular file each time it opens +the message editor, so the symlink hazard is largely theoretical in the +common case, but the `printf > "$0"` form costs nothing. + +**Scope-of-invocation pitfall.** `GIT_EDITOR="cp ..."` (and a too-broad +inline editor) fires for *every* editor invocation during the wrapped +command, including conflict editors and any other commits' message +editing. Use it only when you know exactly which one invocation will +happen. For any rebase where you don't know, use an inline dispatcher +(below) or prefer the direct alternatives: + +- `git commit --amend -m "subject" -m "body"` — supplies the message + directly, no editor. +- `git commit -m "subject" -m "body"` — same for fresh commits. + +The inline `GIT_EDITOR` / `-c core.editor` trick is the right tool only +when git owns the invocation (mid-rebase). + +#### Multiple reword targets in one rebase + +When several commits are being reworded in a single rebase pass, git calls +the editor once per `reword` action. Use an inline dispatcher that recognizes +which commit is being reworded by inspecting the current message — and +fails loudly when an invocation doesn't match anything it knows about: + +```bash +GIT_EDITOR='sh -c "set -eu +target=\$0 +first_line=\$(head -1 \"\$target\") +case \"\$first_line\" in + \"Old subject line A\") + printf \"%s\\n\" \"New subject A\" \"\" \"Body A\" > \"\$target\" ;; + \"Old subject line B\"*) + printf \"%s\\n\" \"New subject B\" \"\" \"Body B\" > \"\$target\" ;; + *) + echo \"msg-dispatch: unmatched message: \$first_line\" >&2 + exit 1 ;; +esac +"' git rebase -i origin/main +# equivalent: git -c core.editor='sh -c "..."' rebase -i origin/main +``` + +The `*)` catch-all is mandatory. Without it, a `reword` action whose +message doesn't match any known case silently accepts the original +message, and the rebase reports success — violating the "errors must +surface" rule. A failing exit aborts the rebase at the unmatched commit +and tells you which one. + +Disambiguation: if two commits share an identical subject line, the +dispatcher can't tell them apart from `head -1` alone. Either: + +- Match on a longer prefix using more of the message body, or +- Use `git rebase -i` with explicit SHAs in the todo and have the + dispatcher key on the commit currently being reworded by reading + `git rev-parse HEAD` inside the editor command. During a `reword` action, git + cherry-picks the target commit onto the rebase head *before* opening + the editor, so HEAD inside the dispatcher resolves to the target's + newly-rewritten SHA. The same is true at the editor invocation for + `edit` (HEAD = the commit you stopped at, before any amend) and at + the message-combine step of `squash` (HEAD = the partially-combined + commit so far). `fixup` does not invoke the editor — the message is + taken from the predecessor unchanged — so no dispatcher fires. + +### Pattern 4: Edit a commit in place + +This is the workhorse for folding a change into an earlier commit. If +the target is HEAD, don't rebase at all — `git commit --amend` (with +`-m` for a pre-written message, `--no-edit` to keep the existing one). + +The rest of this pattern is for editing an earlier commit. + +Mark the target `edit`, modify the working tree at the stop, and +`git commit --amend`: + +```bash +GIT_SEQUENCE_EDITOR='sh -c "set -eu; sed -i.bak \"s/^pick TARGET_SHA/edit TARGET_SHA/\" \"\$0\"; rm -f \"\$0.bak\"; echo \"--- rewritten rebase plan ---\" >&2; cat \"\$0\" >&2"' git rebase -i origin/main +# equivalent: git -c sequence.editor='sh -c "..."' rebase -i origin/main + +# At the stop, intern edits files in the working tree: +git add <files> +git add <files> +git commit --amend --no-edit +git rebase --continue +``` + +If a later commit's diff conflicts with the amendment, git stops again at +the conflicted commit. Resolve and continue. This is the price of editing +mid-history: every commit downstream of the edit gets replayed and may +conflict. + +**Why prefer this over the `fixup! + --autosquash` flow (Pattern 5)?** +With `edit`, you author the fix against the *target commit's actual +tree* — what was there at that point in history. With `fixup!`, you +author the change at HEAD's tree (after all intervening commits), and +`--autosquash` later tries to apply that diff against the much-earlier +target tree. When the fix touches anything that intervening commits +also modified, that backward apply conflicts — and you end up +resolving a conflict between a hunk written against late state and a +tree from early state, which is easy to get wrong (dragging in +late-state assumptions). Reach for `edit` whenever the fix's content +might depend on intervening commits, or whenever the branch has +non-trivial churn between the target and HEAD. Reach for `fixup!` +(Pattern 5) for small, isolated changes you're confident don't overlap +intervening work. + +Two smaller wins follow from the same property: at the `edit` stop, +the working tree is exactly the target commit's state. First, no +accidentally-bundled drive-by changes from HEAD can sneak into your +amend. Second, you can install dependencies, lint, build, and run +tests against the *historical* state — verifying the commit actually +works in the world it lived in. With `fixup!`, your validation only +ever sees HEAD's tree; the squashed commit is never tested against +the rewound state where it lands. (Pattern 7's `--exec` mechanizes +this validation across every commit in the rebase.) + +### Pattern 5: Fixup + autosquash + +For small, isolated changes that don't depend on context introduced +after the target commit, the `fixup!`-subject + `--autosquash` flow is +the cleanest path. When the fix overlaps intervening work, prefer +Pattern 4 (Edit in place) instead. + +```bash +# Make the change at HEAD, then: +git commit -m "fixup! <exact subject of target commit>" + +# Later, fold all such fixups into their targets: +GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash origin/main +``` + +`--autosquash` reorders the todo so each `fixup! X` commit becomes a +`fixup` action right after commit `X`. With `GIT_SEQUENCE_EDITOR=true`, +the editor (`true`, a successful no-op) accepts the autosquashed plan +unchanged. + +**Generate the fixup subject automatically** with `git commit --fixup=SHA` +when the target SHA is known and stable. It writes `fixup! <target +subject>` for you. + +**Pitfall: `--no-verify` only for commit-msg hooks on transient fixups.** +A commit-msg hook that enforces a subject-length limit will reject `fixup! +<long subject>` even though the squashed result inherits the target's +compliant message. `--no-verify` on the transient fixup is acceptable +because the message is discarded at squash time. Note that +`--no-verify` is a single switch — it disables *both* the commit-msg +and pre-commit hook chains; you can't disable one without the other. + +**`--no-verify` is NOT acceptable for pre-commit hooks** that run linters, +formatters, or tests on the working tree. The squashed commit inherits the +same working tree, so any defect that would have been caught at the fixup +commit will still be there in the squashed result. The hook would catch +it next time anyway, and you've just lost the early warning. + +**Pitfall: every `--fixup` commit runs the full pre-commit hook chain.** +On projects with slow pre-commit hooks (full test suites, codegen, type +generation), creating ten fixups in a row is ten hook runs. There's no +correctness-preserving shortcut — pace your fixups accordingly, or use +`git commit -n` only on hooks you're sure won't matter for the +intermediate state (and accept the same caveat as above). + +**Exception: hooks that mutate the working tree fight the rebase.** A +pre-commit hook that re-formats files, regenerates code, or stages +additional files during the hook itself can desync a rebase — git +replays a commit, the hook rewrites the tree, and the resulting commit +no longer matches what the rebase plan recorded. When you must rebase +under such a hook, temporarily disable the *mutating step specifically* +(uninstall pre-commit, comment out the relevant hook, set the hook's +documented no-op env var) rather than reaching for blanket +`--no-verify`, which discards every other pre-commit safety check on +every replayed commit. Restore the hook after the rebase. + +### Pattern 6: Split a commit into pieces + +Builds on the `edit` mechanism from Pattern 4: stop the rebase at the +commit you want to split, then reconstruct it as multiple commits before +continuing. + +```bash +GIT_SEQUENCE_EDITOR='sh -c "set -eu; sed -i.bak \"s/^pick TARGET_SHA/edit TARGET_SHA/\" \"\$0\"; rm -f \"\$0.bak\"; echo \"--- rewritten rebase plan ---\" >&2; cat \"\$0\" >&2"' git rebase -i origin/main +# equivalent: git -c sequence.editor='sh -c "..."' rebase -i origin/main + +# Git stops at TARGET_SHA with that commit applied. Verify the working +# tree is clean (the commit-being-split is the only thing in the +# working/staging area): +git status + +# Undo the commit but keep its changes in the working tree (--mixed is +# the default, but spell it out so the intent is unambiguous): +git reset --mixed HEAD~ + +# Stage and commit the pieces. Use exact paths, NOT `git add -A` or +# wildcards — a typo here can re-introduce content from outside the split +# commit if your working tree had unrelated changes. +git add path/to/group-a/specific-file.ts +git commit -m "Subject for group A" +git add path/to/group-b/specific-file.ts +git commit -m "Subject for group B" + +# Resume the rebase: +git rebase --continue +``` + +If the pieces should fold into different *other* commits, name them with +the `fixup!` prefix and let a follow-up autosquash route them. The +`--no-verify` below disables *both* pre-commit and commit-msg hook +chains (the flag can't disable one without the other). It's acceptable +on these transient fixups because (a) the commit-msg hook would reject +the `fixup! <long subject>` line that the squash discards anyway, and +(b) the working tree at this fixup is the same tree that will be +squashed into the target — the next commit through the pre-commit hook +will see exactly the same state and either accept or reject it on the +same merits: + +```bash +git add path/to/group-a/specific-file.ts +git commit --no-verify -m "fixup! <subject of target A>" +git add path/to/group-b/specific-file.ts +git commit --no-verify -m "fixup! <subject of target B>" +git rebase --continue + +# Then: +GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash origin/main +``` + +### Pattern 7: Validate every commit during the rebase + +For "every commit on this branch must build / lint / test," let `git +rebase --exec` enforce it during the rebase itself: + +```bash +# Substitute the project's build / type-check / test command. +git rebase --exec '<build-command>' origin/main +``` + +`--exec` runs the given command after each pick. If it fails, the rebase +stops at the broken commit — intern amends in place. This is +strictly better than the after-the-fact validation loop in the workflow +below, because the broken commit is right there under intern's fingers with +the failed state still in the working tree. + +For a quick gate, use the project's build or type-check command. For +full validation, use the project's test command — slower, but catches +commits where tests don't yet pass. + +### Stacked branches: `--update-refs` + +If the branch you're rebasing has dependent branches stacked on it — +intermediate refs pointing at commits the rebase will rewrite — git ≥ +2.38 can move them forward automatically: + +```bash +git rebase --update-refs -i origin/main +``` + +Without `--update-refs`, the stacked branches end up pointing at the +*old*, now-orphaned commits, and you have to reset each one manually +against the reflog. Enable globally with `git config rebase.updateRefs +true` if you work with stacked branches routinely. + +### Repeated rebases against an unstable base + +If you must rebase the same branch repeatedly against a moving base, enable +`git rerere` (reuse recorded resolution) so you only resolve each conflict +once: + +```bash +git config rerere.enabled true +``` + +The first time you resolve a conflict, git records the resolution keyed on +the conflict's content. On a subsequent rebase that produces the same +conflict, git applies the recorded resolution automatically. You still +need to `git add` and continue, but you don't re-do the resolution work. + +## Workflow: a rebase session start to finish + +Intern executes this sequence via `run_shell`. Skywalker does not run git. +If a step needs judgment, Skywalker `ask_operator`s first, then copies the +decision into the intern brief. + +1. **Identify what needs fixing.** Intern reads `git log --oneline origin/main..HEAD` + and the diffs. Skywalker lists the surgery: drops, rewords, squashes, + splits, in-place amends — `ask_operator` when which-commits is a judgment call. + +2. **Branch your way back.** Intern: + ```bash + git branch backup-<branch-name>-pre-rebase + ``` + +3. **Plan the smallest viable set of operations.** Each operation is a + separate rebase. Multiple small rebases with validation between is + easier to debug than one giant rebase. + +4. **For each operation, intern:** + - Runs the rebase with `GIT_SEQUENCE_EDITOR` / `-c sequence.editor` inline (if scripting the todo). Do not `write_file` an editor script. + - Supplies pre-canned commit messages via `-c core.editor` / `GIT_EDITOR` inline (if rewording), or `git commit --amend -m`. + - Resolves conflicts as they arise (mechanical). Judgment → intern stops; Skywalker `ask_operator`s. + - When the rebase finishes, runs `git diff backup-<branch-name>-pre-rebase HEAD`. + If the intent was to change content, the diff is meaningful and intern reports + it. If the intent was only to reshape history, the diff is empty. + +5. **Validate.** Intern: + - `git diff backup-<branch-name>-pre-rebase HEAD --stat` — empty unless + intended. + - Per-commit build, capturing failure output so a red mark is + actionable: + ```bash + branch=$(git rev-parse --abbrev-ref HEAD) + mkdir -p /tmp/per-commit-build + for sha in $(git log --reverse --format=%h origin/main..HEAD); do + git checkout -q $sha + if <build-command> > "/tmp/per-commit-build/$sha.log" 2>&1; then + echo "pass $sha" + else + echo "FAIL $sha — see /tmp/per-commit-build/$sha.log" + fi + done + git checkout -q "$branch" + ``` + Note: capture the symbolic branch name *before* the loop (the loop's + checkouts leave you on detached HEAD if you don't). + - Per-commit tests (the same loop with `<test-command>`). + - Final tree: the project's full build and test command. + - Even better, fold validation into the rebase itself with `git rebase + --exec` (Pattern 7) so the rebase stops at the first broken commit. + +6. **Delete the backup** once the branch is pushed and the final state is confirmed. Intern: + ```bash + git branch -D backup-<branch-name>-pre-rebase + ``` + +## Common conflict patterns and resolutions + +When a dropped commit had a counterpart in a later commit (added X in A, +removed X in B), dropping A causes B's removal to fail to apply. The right +resolution is "keep neither side" — the file should end up as if neither +A nor B happened. Edit the conflict markers out directly: + +``` +<<<<<<< HEAD +// nothing here, X was never added +======= +// B's removal of X +>>>>>>> B (later commit) +``` + +becomes: + +``` +// nothing here, X was never added +``` + +When you `edit` a commit and modify a hunk that a later commit also +touches, that later commit may conflict on the same hunk. Note that during +a rebase, `--ours` and `--theirs` are *inverted* from the normal merge +sense: + +- `--ours` = the rebase target (HEAD at the conflict point, which is your + edited result so far) +- `--theirs` = the commit being replayed (your in-flight commit's version) + +So in both common cases — your edit *includes* the later commit's intent, +or your edit *supersedes* it — the version you want to keep is in `--ours` +(HEAD). The conflicting commit is either now a no-op (and git drops it +automatically when its tree change becomes empty) or partially still +needed (in which case `git rebase --skip` after deciding deliberately, or +edit the markers manually). + +Resolution decision tree: + +- If your edit makes the later commit redundant (its intent is already in + HEAD): `git checkout --ours <file>` then `git add`. On `git rebase + --continue`, git's handling of the now-empty commit is configurable + via `--empty=` (the documented default for the interactive merge + backend is `stop`, not `drop`). If git stops on the empty commit: + - Confirm the diff is genuinely empty: `git diff --cached` should be + silent. + - `git rebase --skip` to drop with intent, or + - `git commit --allow-empty` then `git rebase --continue` to preserve + an empty marker commit if that's what you actually want. + Older versions and some configs auto-drop without stopping — be ready + for either path. +- If the later commit's version is what you actually want (your edit + was wrong, or your edit accidentally over-included downstream + content): `git checkout --theirs <file>`. Whether this is "rare" + depends on why you started the rebase — it's common in mid-edit + reconsiderations, rare in pure cleanup rebases. +- If neither side alone is right: edit the conflict markers manually. + +**Beware: `git checkout --ours <file>` and `--theirs <file>` are +whole-file operations.** If a file has five hunks and only one +conflicts, `--ours` blows away non-conflicting `--theirs` content +elsewhere in the file (and vice versa). For files with mixed +conflicting and non-conflicting hunks, edit the conflict markers +manually — don't reach for `--ours`/`--theirs` as a shortcut. + +## What not to do + +- **Don't use `--no-verify` to bypass pre-commit hooks** (lint, format, + tests). The squashed commit inherits the same working tree, so the hook + failure will resurface. The commit-msg-hook carve-out for `fixup! + <long subject>` commits is the only acceptable use; document any other + use explicitly. +- **Don't `git rebase --skip` to dodge a conflict you don't understand.** + Skip discards the currently-applying commit's intent entirely; any + partially-staged resolution is also discarded. If you intended to keep + the commit, you'll lose content. Resolve the conflict instead — or use + `git rebase --abort` if you've lost the thread. +- **Don't reach for `--force-with-lease` to push** until you've validated + the rebased branch against the backup branch. The backup is your last + line of defense. +- **Don't try to clean up history that's already pushed and shared** + unless the rest of the team is on board. Force-pushing rebased history + forces every collaborator to reset their local copies. +- **Don't use `GIT_EDITOR="cp ..."` for an unknown number of editor + invocations** — it will fire for every one, including conflict editors + you didn't plan for. Use an inline dispatcher (Pattern 3) or prefer + `git commit -m` / `--amend -m` when you control the call directly. +- **Don't trust a scripted rebase's exit code alone.** Some safety + configs (e.g. `rebase.missingCommitsCheck=error`) pause rather than + abort when the rebase plan is rejected; the editor command exits 0 and + the outer `git rebase` command exits 0 too, but `.git/rebase-merge/` + is left in place. Always check for a leftover rebase dir after the + command returns — see "Detecting a zombie rebase" in "Safety first". diff --git a/plugins/corbits-skills/skills/implement/SKILL.md b/plugins/corbits-skills/skills/implement/SKILL.md new file mode 100644 index 000000000..266e3a730 --- /dev/null +++ b/plugins/corbits-skills/skills/implement/SKILL.md @@ -0,0 +1,89 @@ +--- +name: implement +description: Disciplined per-commit workflow — Skywalker spawns greybeard, implement, intern/tester, critique. +--- + +# Implement + +You are Skywalker. This skill is a per-commit spawn recipe. You orchestrate specialists; you do not implement. + +Primary never writes product files. Spawn workers. Wait for reports. Decide the next spawn from those reports. + +## Prerequisites + +Load `style` and `philosophy` via `use_skill` on the primary **before spawning**. Follow those conventions in every brief you hand to workers. + +## Tracking + +Track commit-sized units with `manage_tasks`. One item per unit that will become a commit. + +- Before starting: create an item for each unit from the caller's instructions. +- When a unit begins: mark it in progress. +- When critique is clean and the build gate passed: mark it done. +- If new work surfaces (greybeard suggests a prep refactor, critique reveals an edge case that warrants its own commit), append a new `manage_tasks` item and run it through the full loop. + +## Per-commit spawn loop + +For each unit, run these steps in order. Do not skip. Do not write, edit, or delete product files yourself. + +### 1. Review — greybeard + +`task(agent="greybeard")` on the approach before any code is written. + +Send: +- What will change and why +- Files expected +- Design decisions and trade-offs +- Uncertainties + +Adjust the plan from the report, then spawn implement. Greybeard is for approach, not execution. + +### 2. Implement + +`task(agent="implement")` with a typed brief: + +- `intent` +- `success_criteria` +- `do_not` +- `report_focus` + +**Bug fixes:** tell implement to start from a failing test — write the repro, confirm it fails, then fix, then confirm it passes. If the test does not fail first, the bug is not understood. + +**Features:** tests ship with the change. The test asserts the new behavior, not merely that the process did not crash. + +Keep scope to this unit. Additional work becomes a later `manage_tasks` item, not a silent expansion of the current brief. + +### 3. Build gate — intern or tester + +`task(agent="intern")` or `task(agent="tester")` for the project build/test gate (`make`, or the project's full pipeline: format, lint, build, test). + +- `intern` — mechanical full pipeline +- `tester` — suite / repro + +Do not move forward with a broken build. If failures come from this unit, re-dispatch implement. If they are pre-existing and unrelated, report Blockers and stop. Do not substitute a partial compile for the full gate. + +### 4. Critique + +`task(agent="critique")` on the diff. Include the intent agreed with greybeard so critique evaluates plan vs execution, not only surface quality. Limit findings to this unit; pre-existing issues in touched files are out of scope unless they block the gate. + +If critique is **blocking**, re-dispatch implement once or twice with those findings in `success_criteria` / `do_not`, then re-run the build gate and critique. After two re-fix rounds, report Blockers — do not loop forever. + +When critique is clean (or remaining findings are acknowledged judgment calls), mark the unit done and start the next. + +## Hard rules + +- Skywalker MUST NOT write/edit/delete product files. +- Do not do the coding yourself. +- Spawn with `task(agent="greybeard")`, `task(agent="implement")`, `task(agent="intern")` or `task(agent="tester")`, and `task(agent="critique")`. +- Track only with `manage_tasks`. +- Do not shortcut the loop. Skipping greybeard “because this is simple” or skipping critique “because the build passed” defeats the recipe. +- Build must pass before treating a unit as done. + +## Report + +When the requested units are done (or blocked), synthesize for the operator: + +## Summary +## Findings +## Blockers +## Paths diff --git a/plugins/corbits-skills/skills/interview/SKILL.md b/plugins/corbits-skills/skills/interview/SKILL.md new file mode 100644 index 000000000..0386067e5 --- /dev/null +++ b/plugins/corbits-skills/skills/interview/SKILL.md @@ -0,0 +1,146 @@ +--- +name: interview +argument-hint: "<topic>[; <context>]" +description: Conduct an iterative multiple-choice interview using ask_operator. Returns the Q&A inline. Use as a utility when a caller needs structured user input on a topic. +--- + +# Interview + +Use this skill to gather user input on a topic by asking multiple-choice questions in batches via `ask_operator`. Return the questions and answers in the conversation. The caller decides what to do with them. + +This is a utility, not a planner. It does not decide what to build, write any files, or invoke other skills. + +## Argument + +`<topic>[; <context>]` + +- **Topic** — what the interview is about +- **Context** (optional) — facts already known. Treat each as an answered dimension; do not re-ask things context settles. + +If no topic is given, ask for one with `ask_operator` before proceeding. + +## Process + +### Identify dimensions to probe + +Enumerate the open questions worth asking, drawn from the topic and context. Skip dimensions the context already settles. Add domain-specific ones where relevant. There is no fixed dimension list — the topic determines it. + +Probe objective and priorities before details. They shape every later question, so anchoring them early prevents reshuffling halfway through. + +### Ask in batches + +Each question is one `ask_operator` call: `question` (string) plus `options` (array of strings). Batch a round by firing 2–4 independent `ask_operator` calls together (parallel tool calls). Refer to the tool's own documentation for parameter limits. + +`ask_operator` is single-select per call. The operator can also type a custom answer. There is no multi-select flag — if a dimension genuinely permits several answers, encode the realistic combinations as options, or follow up with a second question once the first answer lands. + +**Quality bar for options:** + +- Mutually exclusive and concrete — not "yes / no / maybe" +- Each option a real, defensible choice — not a strawman +- Put trade-offs in the option string itself ("simpler but less flexible", "consistent with existing patterns") — `options` are strings, not `{ label, description }` objects +- Ground options in the topic and context — do not invent generic options when concrete ones exist +- Combination options only when the dimension genuinely permits more than one answer +- If you have a recommendation, put it first and label it + +**Batching:** + +- Default 2–4 `ask_operator` calls per round, bundling dimensions that do not depend on each other +- Drop to 1 question only when the next question's text or options cannot be authored without this answer +- Referencing a prior answer inside a later question's text is fine + +### Decide when to stop + +Stop when: + +- Every open dimension has been answered or marked out of scope +- Remaining unknowns are details the caller can reasonably decide +- The user has signalled fatigue (declines to choose, short non-substantive custom answers, asks to wrap up) +- The topic has shifted into territory outside this interview's scope + +There is no fixed round cap. Stop when the marginal value of another round is low. If the caller passed an explicit cap, honour it. + +### Handle trouble + +- **Contradiction with a prior answer.** Ask one clarifying question that surfaces both choices directly. Record the resolution; do not silently overwrite. +- **Custom answer reveals a missing dimension.** Add it to the dimension list and continue. +- **Topic shift.** If the user's answers reframe the topic itself, stop, emit what you have, and tell the caller the topic has changed. +- **No objective to anchor on.** If the user is fundamentally undecided about the topic's objective itself (not just details), stop without a findings list. Tell the caller what you learned, why you stopped, and what they should consider doing instead. + +### Return findings + +When the interview ends, emit the Q&A inline as a numbered list of question → answer pairs. Format: + +``` +## Interview findings: <topic> + +1. <question>: <answer> +2. <question>: <answer> +3. <question>: <answer (combination)> — <answer> +``` + +If the user declined some questions or punted a dimension, note it in the same list: + +``` +4. <question>: deferred (user said "you decide") +``` + +Do not invent a structured summary on top of this. The caller decides what to do with the findings. + +After emitting the findings, stop. Do not load other skills, invoke other agents, or write any file. + +## Worked example + +**Invocation:** `use_skill(name="interview")` with the topic in the conversation, or `/interview notification system; backend is Node/Postgres, internal users only, must integrate with existing auth` + +**Round 1** (3 parallel `ask_operator` calls, bundled because none depends on the others): + +``` +ask_operator({ + question: "What is the primary goal of the notification system?", + options: [ + "Alert on critical events — Errors, security issues, SLA breaches", + "Keep users informed of activity — Mentions, replies, updates", + "Drive user re-engagement — Digests, reminders, summaries" + ] +}) + +ask_operator({ + question: "If you had to pick one, which matters most?", + options: [ + "Reliability of delivery (recommended) — Never miss a notification, even if delayed", + "Latency — Real-time, even if some are dropped under load", + "User control — Fine-grained per-event opt-in/out" + ] +}) + +ask_operator({ + question: "Which delivery channels do you want?", + options: [ + "In-app — Notification center in the UI", + "Email — Per-event or digest", + "In-app + Email", + "Webhook — Outbound HTTP to a user-configured endpoint", + "All of the above" + ] +}) +``` + +**Hypothetical answers:** Alert on critical events; Reliability of delivery; In-app + Email. + +**Round 2** builds on round 1 (e.g. email cadence, failure handling). Once no obvious questions remain, emit the findings list and stop. + +## Style + +- Do not lecture between rounds. A short orientation sentence is fine. +- Do not summarize the user's answers back at them mid-interview. +- Do not ask leading questions. + +## Anti-patterns + +- **Interviewing yourself.** Filling in answers because they "seem obvious" — stop and ask, or note as assumption. +- **One question per round, ten rounds deep.** Batch related questions as parallel `ask_operator` calls. +- **Asking about everything.** Prune dimensions that do not apply. +- **Treating a custom answer as failure.** Custom answers are signal. +- **Forgetting context.** Read it. Do not re-ask things the context already settled. +- **Writing files.** This skill never writes a file. The output is conversational. +- **Invoking other skills or agents.** Emit findings and stop. diff --git a/plugins/corbits-skills/skills/linear-issue-workflow/SKILL.md b/plugins/corbits-skills/skills/linear-issue-workflow/SKILL.md new file mode 100644 index 000000000..6a2469cf9 --- /dev/null +++ b/plugins/corbits-skills/skills/linear-issue-workflow/SKILL.md @@ -0,0 +1,160 @@ +--- +name: linear-issue-workflow +user-invocable: false +description: Skywalker implements a Linear issue by fetching it via MCP then running the /implement spawn loop. Does not write product code. +argument-hint: "<issue-id> [--reviewer <reviewer>]" +--- + +# Linear Issue Workflow + +You are Skywalker. Host is Corbits Code. This skill is a spawn recipe. You do not write product code. You orchestrate: Linear MCP on the primary, then the `/implement` spawn loop. + +If Linear MCP (`mcp__linear__*`) is missing, stop and tell the operator. Do not invent Claude-only tools. + +## Phase 1: Fetch the issue + +Fetch with `mcp__linear__get_issue`. The returned issue includes title, description, status, branch name, and other metadata. + +If the scope is unclear, `ask_operator` before proceeding. Do not guess. + +## Phase 2: Worktree — intern + +Read `branchName` from the issue (call `mcp__linear__get_issue` again if needed). + +Spawn `task(agent="intern")` with this sequenced `run_shell` list copied into the brief. Intern executes; Skywalker does not run the git. + +```bash +git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@' +git fetch origin +git worktree add ../worktree/<branch-name> -b <branch-name> origin/<default-branch> +``` + +Always base new branches on `origin/<default-branch>` (whatever the repository uses). After creating the worktree, intern `cd`s into it and installs local dependencies from developer documentation. Worktrees do not share `node_modules`. + +If intern fails, stop and `ask_operator`. If the operator rejects the issue before implementation, intern tears down the worktree (Phase 7 commands) rather than leaving it stranded. + +## Phase 3: Plan, attach, mark In Progress + +1. Spawn `task(agent="explore")` if the codebase map is not already known. Brief it with the absolute worktree path (it must work there) and the issue: where changes go, existing patterns, related code. +2. Follow the `/implement` loop's greybeard step (Phase 4) for the approach. Present the plan to the operator and `ask_operator` whether to proceed. Do not start implementation until approved. +3. If the operator rejects the plan and the issue cannot be salvaged, intern tears down the worktree (Phase 7) rather than leaving it stranded. +4. Attach the plan to the Linear issue. **Do not post the plan as a comment** — comments are for discussion, not archives. + + Spawn `task(agent="implement")` with a mechanical brief to write the approved plan to the worktree's `tmp/plan-<ISSUE-ID>.md` (do not commit it). Intern captures byte size with `wc -c`. Primary then: + + 1. `mcp__linear__prepare_attachment_upload` with `issue`, `filename`, `contentType: "text/markdown"`, and `size`. Response contains `uploadRequest.url`, `uploadRequest.headers`, and `assetUrl`. The signed URL expires in 60 seconds. + 2. Intern PUTs the raw file bytes to `uploadRequest.url` via `run_shell`, every header from `uploadRequest.headers` verbatim (exact casing). Do not base64-encode. If PUT returns 403 because the URL expired, prepare a fresh URL and retry once. + 3. `mcp__linear__create_attachment_from_upload` with `issue`, the `assetUrl`, `title: "Implementation plan"`, and `subtitle: "<ISSUE-ID>"`. + + Exception: plans with no structure — no file-by-file breakdown, no enumerated steps, no headings, no nested lists — can be a comment via `mcp__linear__save_comment` instead. Structural shape, not length, is the test. + +5. Mark the issue "In Progress" with `mcp__linear__save_issue`. + +## Phase 4: Implement — spawn loop + +Do not implement on Skywalker. For each commit-sized unit, run `/implement`: + +1. `task(agent="greybeard")` on the approach before any code is written. +2. `task(agent="implement")` with a typed brief (`intent`, `success_criteria`, `do_not`, `report_focus`) and the absolute worktree path. Bug fixes start from a failing test. Features ship tests with the change. +3. `task(agent="intern")` or `task(agent="tester")` for the project build/test gate. +4. `task(agent="critique")` on the diff. Blocking findings → re-dispatch implement (cap two re-fix rounds), then re-run the gate and critique. + +Track units with `manage_tasks`. Copy style/philosophy into worker briefs (`use_skill` on the primary before spawning; workers do not mount `use_skill`). + +### Checkboxes + +If the issue description contains a task list (`- [ ]` items), tick boxes as implement reports each one complete. Update with `mcp__linear__save_issue`, passing the full description with only the relevant `- [ ]` flipped to `- [x]`. Do not rewrite surrounding text. If there is no task list, skip — do not invent one. + +## Phase 5: Branch review + +After the last unit's critique is clean, spawn `task(agent="critique")` on the **whole** `origin/<default-branch>..HEAD` range in the worktree — not only the last commit. Brief: + +- Absolute worktree path +- Base branch from Phase 2 +- Linear issue ID and a one-line intent (what the change is for — never what the reviewer should find) +- Findings with `file:line` — not PR-comment prose +- Do not implement fixes + +Fix-every-finding: treat surviving findings as a worklist and re-enter Phase 4 for each. Cap three whole-branch re-reviews. If findings remain, `ask_operator` — do not push. + +The only path to leaving a finding unfixed is a greybeard waiver: `task(agent="greybeard")` with the finding, proposed disposition, and relevant diff. Accept the ruling by default. Escalate with `ask_operator` only if you disagree or greybeard is unreachable. Never waive on Skywalker's own authority. + +## Phase 6: Push and PR — intern, after confirmation + +Once Phase 5 is clean (or every remaining finding is greybeard-waived): + +Intern rebases via `run_shell`: + +```bash +git fetch origin +git rebase origin/<default-branch> +``` + +Then intern re-runs the build gate. Draft PR title, body, and (if posting) review body from `git diff origin/<default-branch>...HEAD` and `git log origin/<default-branch>..HEAD --format='%s'` — present tense, no journey narration. Title: verb-first, no `feat:` prefix, no ticket ID in the subject. Body: + +```markdown +## Summary + +<1-3 bullets, present tense> + +## Verification + +<what is true now> + +Closes <ISSUE-ID> +``` + +`ask_operator` to confirm title, body, reviewer, and whether to push. Do not push until confirmed. + +Intern then: + +```bash +git push -u origin <branch-name> + +gh pr create \ + --title "<confirmed title>" \ + --reviewer <REVIEWER> \ + --body "$(cat <<'EOF' +<confirmed body> +EOF +)" +``` + +If a GitHub review must be posted, intern runs `gh pr review` as the operator's `gh` identity — never as a Claude (or other vendor) bot. Paste the PR URL and every review URL to the operator. + +## Phase 7: After merge — Linear closeout and cleanup + +Phase 6 ends when the PR is open. Phase 7 runs **after the PR is merged** and **CI is green**. Do not mark the Linear issue Done on open PR alone. + +1. Intern confirms merge and CI via `run_shell` (`gh pr view`, `gh pr checks` / `gh run list`). If CI is red, do not tick Linear outcomes. +2. Re-read the issue with `mcp__linear__get_issue`. Flip checkboxes the merged PR actually completed on `main` via `mcp__linear__save_issue`. Never check a box on intent. +3. `mcp__linear__save_comment` with PR URL, merge SHA, and CI-green confirmation. Short. Present-tense facts. +4. If every outcome checkbox is checked, set state to `Done` with `mcp__linear__save_issue`. Otherwise leave In Progress. +5. Only then intern cleans up: + +```bash +cd <path-to-main-repo> +git fetch origin +git worktree remove ../worktree/<branch-name> +git branch -d <branch-name> +``` + +If the worktree directory was already deleted: `git worktree prune`. + +## Linear MCP tool reference + +| Action | Tool | +|---|---| +| Fetch issue | `mcp__linear__get_issue` | +| Get branch name | `mcp__linear__get_issue` (`branchName`) | +| Update status / checkboxes | `mcp__linear__save_issue` | +| Add comment | `mcp__linear__save_comment` | +| Attach file | `mcp__linear__prepare_attachment_upload` → intern PUT → `mcp__linear__create_attachment_from_upload` | +| List teams | `mcp__linear__list_teams` | + +## Hard rules + +- Skywalker MUST NOT write/edit/delete product files. +- Spawn with `task(agent="greybeard")`, `task(agent="implement")`, `task(agent="intern")` or `task(agent="tester")`, and `task(agent="critique")`. +- Clarifying questions use `ask_operator`. +- Shell is `run_shell`, not a Bash tool. diff --git a/plugins/corbits-skills/skills/opsh/SKILL.md b/plugins/corbits-skills/skills/opsh/SKILL.md new file mode 100644 index 000000000..57943bde6 --- /dev/null +++ b/plugins/corbits-skills/skills/opsh/SKILL.md @@ -0,0 +1,488 @@ +--- +name: opsh +user-invocable: false +description: Write scripts using opsh and its built-in libraries. Skywalker copies these rules into an implement brief; does not write the script. Load when writing, reviewing, or debugging opsh scripts. +--- + +# opsh Scripting + +You are Skywalker. Host is Corbits Code. This is a convention skill. You do not write the script. + +If the operator wants a script written, spawn `task(agent="implement")` with this skill's rules copied into the brief (workers do not mount `use_skill`). If the operator wants a review, spawn `task(agent="critique")` (or `task(agent="neckbeard")` for hygiene-only) with the same rules copied in. + +Shell for agent commands is `run_shell` (there is no Bash tool). Bash-the-language in the examples below stays — opsh scripts are bash. + +opsh is a scripting environment for operations use. It is a curated bash +environment with sensible defaults and a standard library. Scripts are +bash, but opsh sets strict runtime options and provides a library +ecosystem accessed via `lib::import`. + +## Script Structure + +Every opsh script starts with: + +```bash +#!/usr/bin/env opsh + +lib::import git semver # import libraries you need + +# your script here +``` + +opsh can also be sourced into an existing bash script: + +```bash +#!/usr/bin/env bash +source opsh +lib::import git +``` + +## Runtime Defaults + +opsh sets these options before your script runs. Do not disable them. + +| Setting | Effect | +|--------------------------|-----------------------------------------------------| +| `set -e` (errexit) | Non-zero return terminates unless caught | +| `set -u` (nounset) | Referencing an unset variable is fatal | +| `set -o pipefail` | A pipeline fails if any command in it fails | +| `IFS=''` | Word splitting is disabled by default | +| `shopt -s inherit_errexit` | Command substitutions inherit errexit | +| `set -o errtrace` | ERR traps propagate into functions and subshells | + +**The `IFS=''` default is important.** Unquoted `$var` where +`var="a b c"` stays as a single string, not three words. Use +`array::split` when you actually need to split a string. + +## Global Variables + +These are set by opsh before your script runs: + +| Variable | Description | +|---------------|------------------------------------------------| +| `$SCRIPTFILE` | Absolute path to your script | +| `$SCRIPTDIR` | Directory containing your script | +| `$TMPDIR` | Managed temp directory, cleaned up on exit | +| `$OPSHROOTDIR`| Root of the opsh installation | +| `$DEBUG` | Set this (any value) to enable `log::debug` | + +Color variables `$CRED`, `$CGRN`, `$CYEL`, `$CBLU`, `$CNONE` are +available and are automatically empty when output is not a terminal. + +## Import System + +```bash +lib::import <name> [name2 ...] +``` + +- Imports are idempotent; importing the same library twice is safe. +- Libraries can declare their own dependencies (e.g. `ssh` imports + `path`, `cloud-init` imports `command`). +- If a library is not found, the script dies via `log::fatal`. +- Multiple libraries can be imported in a single call. + +## Naming Convention + +All opsh functions use `module::function` naming with `::` as the +namespace separator. Follow this convention in your own scripts: + +```bash +deploy::prepare() { ... } +deploy::execute() { ... } +deploy::cleanup() { ... } +``` + +## Core Functions (Always Available) + +### Logging + +All log output goes to stderr. Messages are colorized when stderr is a +terminal. + +| Function | Behavior | +|----------------|---------------------------------------------| +| `log::debug` | Blue output, only when `$DEBUG` is set | +| `log::info` | Green output | +| `log::warn` | Yellow output | +| `log::error` | Red output | +| `log::fatal` | Red output, then `exit 1` | + +```bash +log::info "deploying version $VERSION..." +log::fatal "config file not found" +``` + +### Exit Triggers + +Register cleanup functions that run on exit in LIFO (last-in, +first-out) order: + +```bash +exit::trigger <function_or_command> [args...] +``` + +```bash +start-service() { ... } +stop-service() { ... } + +start-service +exit::trigger stop-service # guaranteed to run on exit +``` + +Multiple triggers are supported. They run in reverse registration order. + +### Temporary Files + +`$TMPDIR` is a managed directory that is automatically removed on exit. +Create files and directories inside it: + +```bash +temp::file [mktemp_args...] # create a temp file in $TMPDIR +temp::dir [mktemp_args...] # create a temp directory in $TMPDIR +``` + +```bash +scratch=$(temp::file) +echo "data" > "$scratch" +# no cleanup needed; $TMPDIR is removed on exit +``` + +### Array Utilities + +```bash +array::join <delimiter> <element1> [element2 ...] +array::split <nameref> <delimiter> <string> +``` + +```bash +parts=(one two three) +array::join , "${parts[@]}" # stdout: one,two,three + +array::split result ":" "$PATH" # result is now an array +``` + +### Version Management + +```bash +opsh::version # print the opsh version +opsh::version::require <min> # fatal if opsh is too old +``` + +```bash +opsh::version::require 0.9.0 +``` + +## Libraries + +### command + +```bash +lib::import command +``` + +| Function | Description | +|-------------------|------------------------------------| +| `command::exists` | Returns 0 if command is in `$PATH` | + +```bash +command::exists docker || log::fatal "docker is required" +``` + +### path + +```bash +lib::import path +``` + +| Function | Description | +|---------------------|--------------------------------------| +| `path::env::add` | Prepend directories to `$PATH` | +| `path::env::remove` | Remove a directory from `$PATH` | + +```bash +path::env::add /opt/mytools/bin +path::env::remove /usr/local/old/bin +``` + +### git + +```bash +lib::import git +``` + +| Function | Description | +|-------------------------------|----------------------------------------------------------| +| `git::repo::version` | Version from `git describe --tags --dirty` or short SHA | +| `git::repo::current-branch` | Current branch name | +| `git::repo::is-clean` | Returns 0 if working tree is clean | +| `git::tag::exists` | Returns 0 if a local tag exists | +| `git::tag::lookup::remote` | Lookup a tag on a remote; prints commit hash | + +`git::tag::lookup::remote` returns 1 if the tag is not found, 2 if +ambiguous. + +```bash +VERSION=$(git::repo::version) +git::repo::is-clean || log::fatal "working tree is dirty" +``` + +### semver + +```bash +lib::import semver +``` + +| Function | Description | +|-----------------|---------------------------------------------------------| +| `semver::parse` | Parse into `$OPSH_SEMVER` array `[major, minor, patch]` | +| `semver::test` | Compare two versions: `-eq`, `-gt`, `-lt`, `-ge`, `-le` | +| `semver::bump` | Bump `major`, `minor`, or `patch`; prints new version | + +`semver::parse` populates the global `$OPSH_SEMVER` array. If the +version has a suffix (e.g. `-rc1`, `+build`), it appears as a fourth +element. + +```bash +semver::parse v2.1.0 || log::fatal "bad version" +echo "major: ${OPSH_SEMVER[0]}" # 2 + +semver::test "$current" -ge "$minimum" || log::fatal "version too old" + +new=$(semver::bump minor v1.2.3) # v1.3.0 +``` + +### ssh + +```bash +lib::import ssh +``` + +| Function | Description | +|--------------------------|------------------------------------------------| +| `ssh::begin` | Start SSH context: agent, proxied ssh, config | +| `ssh::end` | Tear down SSH context | +| `ssh::config` | Append SSH config from stdin | +| `ssh::key::add` | Add keys from files or stdin to the agent | +| `ssh::background::run` | Launch SSH port forwarding in background | +| `ssh::background::close` | Close background port forwarding | + +`ssh::begin` creates an isolated SSH agent, a proxied `ssh` binary +that uses a managed config file, and registers `ssh::end` as an exit +trigger. Everything between `ssh::begin` and `ssh::end` uses this +isolated context. + +```bash +ssh::begin +ssh::config <<'EOF' +Host bastion + User deploy + HostName bastion.example.com +EOF +ssh::key::add ~/.ssh/deploy_key +ssh bastion "uptime" +ssh::end +``` + +### cloud-init + +```bash +lib::import cloud-init +``` + +| Function | Description | +|--------------------------------|-------------------------------------| +| `cloud-init::is-enabled` | Returns 0 if cloud-init is present | +| `cloud-init::wait-for-finish` | Blocks until cloud-init completes | + +```bash +if cloud-init::is-enabled; then + log::info "waiting for cloud-init..." + cloud-init::wait-for-finish +fi +``` + +### step-runner + +```bash +lib::import step-runner +``` + +| Function | Description | +|---------------|-------------------------------------------------------| +| `steps::run` | Run all `prefix::*` functions in alphabetical order | + +Define functions with a shared prefix, then run them: + +```bash +deploy::01-build() { ... } +deploy::02-test() { ... } +deploy::03-push() { ... } + +steps::run deploy # runs all three in order +steps::run deploy 02-test # starts from 02-test +``` + +`steps::run` logs each step as it executes. Use numbered prefixes to +control ordering. + +### test-harness + +```bash +lib::import test-harness +``` + +| Function | Description | +|----------------------|------------------------------------------| +| `testing::register` | Register a test function with a description | +| `testing::run` | Execute all tests, output TAP v13 | +| `testing::fail` | Fail the current test with a message | + +See the "Writing Tests" section below. + +## Common Idioms + +### Fatal preconditions + +Use `|| log::fatal` for conditions that must be true: + +```bash +[[ -f $CONFIG ]] || log::fatal "config not found: $CONFIG" +command::exists kubectl || log::fatal "kubectl is required" +``` + +### Recoverable errors + +Capture the return code when you need to handle failure: + +```bash +local ret=0 +some-command || ret=$? +if [[ $ret -ne 0 ]]; then + log::warn "command failed with $ret, retrying..." +fi +``` + +### Cleanup with exit triggers + +Register cleanup in the order you acquire resources. They run in +reverse: + +```bash +start-database +exit::trigger stop-database + +start-server +exit::trigger stop-server +# on exit: stop-server runs first, then stop-database +``` + +### Default values + +Use the `${var:=default}` pattern (standard bash parameter expansion). +The `:` builtin discards the result while still triggering assignment: + +```bash +: "${DEPLOY_ENV:=staging}" +: "${RETRIES:=3}" +``` + +## Writing Tests + +Test files use the `test-harness` library and output TAP v13 format. +They are typically run with `prove`: + +```bash +#!/usr/bin/env opsh + +lib::import test-harness + +check-something() { + local result + result=$(my-function) + [[ $result = "expected" ]] || testing::fail "got: $result" +} + +testing::register check-something "verify my-function output" + +check-another-thing() { + my-precondition || testing::fail + my-action || testing::fail "action failed" +} + +testing::register check-another-thing "verify action succeeds" + +testing::run +``` + +**Key patterns:** + +- Use `|| testing::fail` as an assertion. Optionally pass a message. +- Each test function runs in a subshell, so variable changes do not + leak between tests. +- Register all tests before calling `testing::run`. +- Use `$SCRIPTDIR` to locate fixture files and shared utilities + relative to the test file. +- Source shared test helpers with `source "$SCRIPTDIR/utils.opsh"`. + +### Checking command output + +A common pattern uses `diff -u` against a heredoc to verify output: + +```bash +check-output() { + local outfile + outfile=$(temp::file) + + my-command > "$outfile" + + diff -u - "$outfile" <<EOF || testing::fail +expected output here +EOF +} +``` + +### Checking return codes + +```bash +check-failure() { + local ret=0 + bad-command || ret=$? + [[ $ret -eq 1 ]] || testing::fail "expected exit 1, got $ret" +} +``` + +## Formatting and Linting + +When available: + +- Format with: `shfmt` +- Lint with: `shellcheck -s bash -x` + +## Quick Reference + +``` +lib::import <lib> # import a library +log::{debug,info,warn,error} # log to stderr +log::fatal # log and exit 1 +exit::trigger <func> # register cleanup (LIFO) +temp::file / temp::dir # create managed temp files +array::join <delim> <args> # join to stdout +array::split <ref> <delim> <s># split into named array +command::exists <cmd> # check PATH for a command +path::env::add <dir> # prepend to $PATH +path::env::remove <dir> # remove from $PATH +git::repo::version # repo version string +git::repo::current-branch # current branch name +git::repo::is-clean # clean working tree? +git::tag::exists <tag> # local tag exists? +semver::parse <ver> # parse into $OPSH_SEMVER +semver::test <a> <op> <b> # compare versions +semver::bump <pos> <ver> # bump major/minor/patch +ssh::begin / ssh::end # SSH context lifecycle +ssh::config <<EOF # append SSH config +ssh::key::add [files] # add keys to agent +steps::run <prefix> [start] # run prefixed functions +testing::register <fn> [desc] # register a test +testing::run # execute all tests (TAP v13) +testing::fail [msg] # fail current test +``` diff --git a/plugins/corbits-skills/skills/philosophy/SKILL.md b/plugins/corbits-skills/skills/philosophy/SKILL.md new file mode 100644 index 000000000..f78df35fa --- /dev/null +++ b/plugins/corbits-skills/skills/philosophy/SKILL.md @@ -0,0 +1,114 @@ +--- +name: philosophy +user-invocable: false +description: Engineering philosophy and work culture principles. Load this skill when making architectural decisions or to understand the team's work principles. +--- + +# Philosophy + +Engineering philosophy and work culture principles. This skill is meant to be loaded alongside the `style` skill to provide broader context for decision-making and collaboration. + +## Guiding Principles + +**Pragmatic over idealistic.** + +Don't get fixed on details that don't matter. If you're unsure if a detail matters, ask. + +**Simple is usually harder than easy, but it pays off in the long run.** + +**Engineer Hippocratic Oath** - Do no harm to our customers and their data. + +**Benevolent Dictatorship** - All ideas are welcome, but not all will be acted upon. We've got work to do. + +**"The map is not the territory"** - Documentation is there to guide you to the code, which is the source of truth. + +## Collaboration & Communication + +Don't be afraid to ask questions. + +**Direct Messages are for secrets.** Unless it's private, keep talking to people in public. It helps the rest of the engineers learn. + +Don't be offended when people ask you why you implemented something a certain way; if it's not your strongest solution, "it was the best solution I could put together with the information I had" is a fine answer. + +**Respect and learn from your fellow engineer.** + +Be careful of how much you judge other people's engineering decisions; there's a profound moment as an engineer when you look at something, think that it's totally insane that it was implemented that way, and then realize you're the one who implemented it but you've since forgotten. + +## Code & Git Practices + +For specific guidelines on commits, comments, and external code attribution, see the `style` skill. + +Key philosophical points: + +- **Commits should read like a story** - They're there for others and future-you to understand why a change was made +- **Keep your commit summaries clear and short** - Use the body if the change warrants further explanation +- **Don't intermix refactors and feature additions** - Keep them separate for clarity +- **Comments shouldn't describe what code is doing** - They should describe why you're doing it + +See the `style` skill for detailed formatting rules and technical specifications. + +## Constraint Ownership + +Every system has layers. Constraints belong in exactly one layer — the one that has enough information to enforce them correctly. + +When a downstream function re-checks conditions that an upstream function already guarantees, you get duplication that eventually conflicts. When callers pre-process inputs to satisfy invariants the callee already enforces, you get unnecessary complexity. When three layers all enforce the same rule, two of them are unnecessary and one of them is probably wrong. + +Find the layer that owns the constraint. Fix it there. Trust it everywhere else. + +**Before fixing a bug, answer these questions:** + +1. What invariant is being violated? +2. Which layer is responsible for enforcing that invariant? +3. Does that layer already attempt to enforce it? + +If the answer to (3) is yes, fix that layer — not a downstream consumer. If your fix requires changes in more than one module, stop and explain which layer owns the constraint and why. + +If you have made two or more fix commits to the same subsystem without resolving the issue, you are symptom-chasing. Describe the constraint violation and ask where it should be fixed. + +**It's almost never a bug in the compiler — until it is.** Exhaust every possibility in your own code before blaming the toolchain. But never fully dismiss the possibility; sometimes it actually is. + +## Backwards Compatibility + +Backwards compatibility is not inherently virtuous. It depends entirely on context. + +**Public interfaces deserve backwards compatibility.** If external consumers depend on your API, CLI, wire format, or SDK, breaking them has real cost. Maintain compatibility there, deprecate gracefully, and version when you must break. + +**Internal code does not.** When backwards compatibility in internal code means keeping dead parameters, maintaining two paths through the same logic, or wrapping new code around old assumptions just to avoid updating callers — that's not compatibility, that's tech debt with a noble-sounding name. If you own all the callers, update all the callers. + +The instinct to "keep the old way working just in case" creates code that is harder to read, harder to change, and harder to trust. Every shim, adapter, and fallback you leave behind is a lie about how the system actually works. Kill the old path when the new one is proven. Don't leave both alive. + +**Ask yourself:** who breaks if I remove this? If the answer is "nobody external," remove it. + +## Testing Philosophy + +**Tests are primarily there to verify required behavior is being followed. They're your friend.** + +Refactoring without them is a disconcerting nightmare filled with uncertainty and strife. + +## Automation & Tools + +**Automate when it's appropriate:** the first time might be too soon to understand the problem, by the third time might be when you should stop doing the same thing manually. + +**Solving problems is so much easier with the right tools.** Don't be afraid of building tools. + +## Business Context + +**Without engineering, sales has nothing to sell. Without sales, engineering can't pay rent.** + +This symbiotic relationship informs our prioritization and decision-making. + +## Issue & Project Management + +**Issues and tickets represent actual work to get done; not a hope or a dream.** + +An issue should be self-contained enough that it can be handed off at any moment. + +An issue shouldn't take longer than 2-3 days to implement. + +A single feature can have many tickets; they're cheap, so use as many as makes things clear. + +**The more status updates you put in your tickets, the less you'll be bugged by people asking you for status** (see TPS reports). + +## Acknowledgment + +After reviewing this skill, state: "I have reviewed the philosophy skill." diff --git a/plugins/corbits-skills/skills/plan/SKILL.md b/plugins/corbits-skills/skills/plan/SKILL.md new file mode 100644 index 000000000..b0822f163 --- /dev/null +++ b/plugins/corbits-skills/skills/plan/SKILL.md @@ -0,0 +1,16 @@ +--- +name: plan +description: Skywalker spawn recipe — plan director authors an agent-proof eng change plan. Does not implement. Does not file tracker issues. +--- + +# Plan + +You are Skywalker. This skill is a spawn recipe. You do not write the plan yourself. + +Spawn `task(agent="plan")` with the operator args as the brief. Prefer a typed spawn: `intent="plan"`, `success_criteria`, `do_not`, `report_focus`. + +The plan director authors files, acceptance criteria, non-goals, risks, and ordered steps. It does not ship code. Greybeard is the architecture gate, not this slash. + +This is not `/create-issue`. Do not file Linear or GitHub issues. If the operator wants tickets, they use `/create-issue` after the plan. + +Use `ask_operator` if the change target is too fuzzy to brief plan. diff --git a/plugins/corbits-skills/skills/pull-request-review/SKILL.md b/plugins/corbits-skills/skills/pull-request-review/SKILL.md new file mode 100644 index 000000000..ea7cc6eaa --- /dev/null +++ b/plugins/corbits-skills/skills/pull-request-review/SKILL.md @@ -0,0 +1,107 @@ +--- +name: pull-request-review +description: Review a pull request by branch name or URL. Intern checks out a worktree if needed; critique (or neckbeard) reviews. Skywalker does not implement fixes. +--- + +# Pull Request Review + +You are Skywalker. Host is Corbits Code. This skill is a spawn recipe. Do not implement fixes. Do not write product patches. Do not impersonate GitHub-Claude (or any other vendor) review comments. + +## Input + +Accepts either: + +- A branch name (e.g. `feature/add-auth`) +- A pull request URL from GitHub or GitLab (e.g. `https://github.com/owner/repo/pull/123`) + +## Recipe + +### 1. Parse input + +If given a URL, intern extracts the branch via `run_shell` (there is no Bash tool): + +```bash +# GitHub +gh pr view <url-or-number> --json headRefName --jq '.headRefName' + +# GitLab +glab mr view <number> --output json | jq -r '.source_branch' +``` + +### 2. Worktree checkout if needed + +If the PR branch is not already the current checkout, spawn `task(agent="intern")` with this sequenced `run_shell` list copied into the brief. Intern executes; Skywalker does not run the git. + +```bash +git rev-parse --show-toplevel +git fetch origin <branch-name> +git rev-parse --verify "origin/<branch-name>" +``` + +If the branch does not exist, intern reports the error. Stop. Do not proceed with worktree creation or review. + +```bash +REPO_ROOT=$(git rev-parse --show-toplevel) +WORKTREE_PATH="${REPO_ROOT}/../worktree/<branch-name>" +mkdir -p "${REPO_ROOT}/../worktree" +git worktree add "$WORKTREE_PATH" "origin/<branch-name>" +cd "$WORKTREE_PATH" +git checkout <branch-name> +git branch --show-current +``` + +Worktree path is `../worktree/<branch-name>` relative to the repository root. + +Then intern follows documented setup in `README.md`, `CONTRIBUTING.md`, `docs/`, `DEVELOPMENT.md`, or `SETUP.md` — install deps, env, migrations, build — exactly as documented. Do not assume the setup process. If no setup docs exist, `ask_operator` before proceeding. + +Base branch: + +```bash +gh pr view --json baseRefName --jq '.baseRefName' +# or +glab mr view --output json | jq -r '.target_branch' +# branch-name only: origin/main or origin/master — ask rather than guessing if both exist +``` + +If any of these commands fail, intern stops and reports. Do not retry workarounds. `ask_operator` how to proceed. + +### 3. Review + +- **Default:** `task(agent="critique")` with the PR scope (branch, base, worktree path, PR URL/number). +- **Hygiene-only** (operator said nits / naming / lint / pedantry): `task(agent="neckbeard")`. + +Brief the reviewer: + +- Paths, PR number/URL, or branch +- Base for comparison (`git diff <base>...HEAD`); if the base is unclear, `ask_operator` rather than guessing `main` +- Only this PR's diff is in scope — pre-existing issues outside the diff are out of lane +- Do not implement fixes; findings only, with evidence (`path:line`) +- Signal over noise (neckbeard is the exception when hygiene was requested) +- Do not write GitHub review comment prose impersonating Claude or any vendor + +Prefer a typed brief: `intent="review"`, `success_criteria`, `do_not`, `report_focus`. + +### 4. After the report + +Synthesize critique/neckbeard Summary / Findings / Blockers / Paths for the operator. Do not land fixes. + +If a GitHub review must be posted, intern runs `gh pr review` as the operator's `gh` identity — never as a Claude (or other vendor) bot. Primary owns `--approve` / `--request-changes` only when the operator asked to post; secondary lenses use `--comment` only. + +If the operator then wants repairs, that is a later `/implement` or `use_skill("dispatch")` — not this skill. + +## Cleanup + +After the review, intern may remove the worktree: + +```bash +git worktree remove "$WORKTREE_PATH" +``` + +Or leave it and tell the operator it remains for further investigation. + +## Hard rules + +- Skywalker MUST NOT write/edit/delete product files. +- Skywalker MUST NOT run the worktree git; intern does, via `run_shell`. +- Do not implement fixes. +- Do not impersonate GitHub-Claude review comments. diff --git a/plugins/corbits-skills/skills/refactor/SKILL.md b/plugins/corbits-skills/skills/refactor/SKILL.md new file mode 100644 index 000000000..d4dc55719 --- /dev/null +++ b/plugins/corbits-skills/skills/refactor/SKILL.md @@ -0,0 +1,41 @@ +--- +name: refactor +argument-hint: <directory> +description: Skywalker maps a directory then plans improvements. Explore, then plan. No product writes. +--- + +# Refactor + +You are Skywalker. This skill is a spawn recipe. You do not write a design document. You do not write product files. `$ARGUMENTS` is the directory to analyze. + +## Recipe + +1. Load philosophy via `use_skill("philosophy")` on the primary **before spawning**. Those principles guide how you evaluate design decisions and what you put in worker briefs. +2. If `$ARGUMENTS` is missing or the directory is broad, `ask_operator` before exploring: + - Is there a specific concern or area to focus on? + - What prompted the desire to refactor? + - Are there known pain points? +3. Spawn `task(agent="explore")` to map `$ARGUMENTS`. Brief it to cover: + - What the code does (purpose and behavior) + - Key components and their responsibilities + - How data flows through the system + - Dependencies (internal and external) + - Patterns and conventions in use + - Areas of complexity or inconsistency (factual, not prescriptive) +4. From the explore report, `ask_operator` for collaborative choices: priorities, which observations to act on, accept / reject / modify proposals. Iterate until alignment. Do not invent a plan the operator did not choose. +5. Spawn `task(agent="plan")` for the improvement plan. Include the operator's choices, the explore findings, and `$ARGUMENTS`. The plan should cover: + - Specific changes to make + - Rationale for each change (grounded in philosophy: pragmatic over idealistic, simple is usually harder than easy, do no harm, respect existing decisions) + - Suggested order of operations + - Constraints or risks + - Enough detail that an implement worker could execute later + - For structural transformations (renames, signature changes, API migrations), note that execution should load the `ast-grep` skill — bulk AST rewrites, not manual read-edit-write cycles + +Do not write the plan to disk yourself. Plan's report is the artifact. A later `/implement` or `use_skill("dispatch")` ships it. + +## Hard rules + +- Skywalker MUST NOT write/edit/delete product files, including design documents. +- Do not skip explore "because you already know the directory." +- Do not skip `ask_operator` when the operator has not chosen among alternatives. +- Spawn with `task(agent="explore")` then `task(agent="plan")`. diff --git a/plugins/corbits-skills/skills/review/SKILL.md b/plugins/corbits-skills/skills/review/SKILL.md new file mode 100644 index 000000000..9e37cf9e2 --- /dev/null +++ b/plugins/corbits-skills/skills/review/SKILL.md @@ -0,0 +1,35 @@ +--- +name: review +description: Review a branch, PR, or path scope. Skywalker spawns critique (neckbeard for hygiene, greybeard for architecture); does not implement fixes. +argument-hint: "[paths | PR | diff | hygiene | architecture]" +--- + +# Review + +You are Skywalker. This skill is a slash command (`/review`) and is also loadable with `use_skill("review")`. Do not implement fixes. Do not write product patches to "just quickly" address findings. Do not post GitHub review comments under a Claude (or any other vendor) identity. + +Spawn a director. Pass the operator's scope — paths, PR, branch, or diff — as the brief. Report that director's Summary / Findings / Blockers / Paths. + +## Routing + +- **Default** (correctness, completeness, brief adherence, defects with evidence): `task(agent="critique")` +- **Hygiene-only** (nits, naming, lint, pedantry with receipts): `task(agent="neckbeard")` +- **Architecture-only** (structure, boundaries, approach): `task(agent="greybeard")` + +If the operator did not say hygiene-only or architecture-only, spawn critique. Do not spawn all three unless they asked for a wider review. + +Prefer a typed brief: `intent="review"`, `success_criteria`, `do_not`, `report_focus`, and `agent`. + +## Brief to the worker + +Include whatever the operator gave you, plus enough for a scoped review: + +- Paths, PR number/URL, or branch to review +- Base for comparison when known (`git diff <base>...HEAD`); if the base is unclear, ask rather than guessing `main` +- That only the operator's scope is in scope — pre-existing issues outside the diff are out of lane +- Do not implement fixes; findings only, with evidence +- Signal over noise: skip hypotheticals and style nits that do not affect correctness, readability, or maintainability (neckbeard is the exception when hygiene was requested) + +## After the report + +Synthesize. Do not land fixes. If the operator then wants repairs, that is a later `/implement` or `use_skill("dispatch")` — not this skill. diff --git a/plugins/corbits-skills/skills/scribe/SKILL.md b/plugins/corbits-skills/skills/scribe/SKILL.md new file mode 100644 index 000000000..46668690a --- /dev/null +++ b/plugins/corbits-skills/skills/scribe/SKILL.md @@ -0,0 +1,14 @@ +--- +name: scribe +description: Skywalker spawn recipe — shakespeare writes PRODUCT.md, ARCHITECTURE.md, and IMPLEMENTATION.md. +--- + +# Scribe + +You are Skywalker. This skill is a spawn recipe. You do not write the docs. + +Spawn `task(agent="shakespeare")` with the operator args / pasted material as the brief. Shakespeare owns PRODUCT.md, ARCHITECTURE.md, and IMPLEMENTATION.md. + +Use `ask_operator` if the doc target (P vs A vs I) is ambiguous. + +Do not edit those docs yourself. DESIGN.md is brand-reviewer, not this skill. diff --git a/plugins/corbits-skills/skills/style/SKILL.md b/plugins/corbits-skills/skills/style/SKILL.md new file mode 100644 index 000000000..104a718aa --- /dev/null +++ b/plugins/corbits-skills/skills/style/SKILL.md @@ -0,0 +1,334 @@ +--- +name: style +user-invocable: false +description: General coding conventions for clean, maintainable code. Always load this skill when writing or reviewing code in any language. +--- + +# Style + +General guidelines for writing clean, maintainable code. + +## Git Repository Requirement + +Prefer a git repository so changes can be tracked, reviewed, and reverted. + +Edits, tests, and reports are allowed in a folder that is not a git repository. Do not refuse the task. + +Do not `git init` unless the user asked you to create a repository. + +Commits, amends, rebases, and isolated worktree dispatch require an existing git repository. If the user asked to commit and there is no repository, say so and stop — do not invent a repository. + +## Documentation + +### Avoiding Redundant Comments + +Code should be self-documenting. Do not add comments that describe what the code obviously does: + +``` +// Bad - obvious comments +// Base configuration type for all backends +BaseConfigArgs = { level: LogLevel } + +// Good - let code speak for itself +BaseConfigArgs = { level: LogLevel } +``` + +Decorative comment blocks (ASCII art dividers, section headers) add visual noise without providing meaningful information. + +**When comments ARE useful:** + +- Complex algorithms that aren't immediately obvious +- Non-obvious workarounds or edge cases +- TODO/FIXME/XXX markers for work that is genuinely blocked (see below) +- Business logic that requires explanation + +``` +// XXX - Temporary workaround until upstream fix +// TODO - Switch to newMethod when minimum version is bumped +result = await legacyMethod() +``` + +**TODO/FIXME/XXX markers are not a deferral mechanism.** They are reserved for work that is *genuinely blocked* by something outside your control — waiting on an upstream library fix, an unreleased API version, missing access or credentials, a dependency in another team's queue. The marker must name the blocker, so a reader knows what would unblock it. + +Do not use these markers for: + +- Work you could do now but would prefer not to ("TODO: clean up this function") +- Work you ran out of patience for ("FIXME: this should probably handle the error case") +- Work you're hoping someone else will pick up ("TODO: add tests") +- Decisions you didn't want to make ("TODO: figure out the right default") + +If you could do it now, do it now. A TODO is a promise to the reader that the work cannot be done yet; abusing the marker for work you simply chose not to do is dishonest and accumulates as dead weight in the codebase. + +### Comments describe the current code + +Code comments speak for the commit they appear in. Do not write comments that refer to other commits — neither what an earlier commit changed nor what a planned follow-up commit will do. A comment like `// stub; next commit fills this in` is wrong the moment that follow-up is reordered, dropped, or read by someone who reverted past it. If the code is intentionally a stub now, say *why it is a stub now*, not what is supposed to replace it. + +This holds even when you have a multi-commit plan in context — a planned commit does not exist until it lands, and the comment must be accurate for the commit it lives in, standing alone. + +## Git Workflow + +### Commit Messages + +Commits should read like a story, allowing others and future-you to understand why changes were made. + +**Commit Organization:** + +- Separate refactoring from feature additions (distinct commits) +- Separate formatting/whitespace fixes from logical changes +- Each commit should represent one logical unit of work +- **Amend** (`git commit --amend`) to refine the most recent commit (e.g., critique fixes, wording changes, missed files) +- **Edit-in-place** to fix an earlier unpushed commit when a later review reveals a problem that belongs on that commit, not HEAD. Mark the target `edit` in the rebase todo, make the fix at the stop, amend, and continue. The fix is authored against the target commit's historical tree, which keeps the change intent-correct against the right baseline — downstream commits may still produce a replay conflict if they touch the same lines, but resolving that conflict is straightforward because both sides of it are coherent diffs. + +``` +git rebase -i origin/main # substitute your project's base branch +# In the editor, change "pick abc1234 ..." to "edit abc1234 ..." +# git stops with the target commit checked out +# ... make the fix ... +git add <files> +git commit --amend --no-edit +git rebase --continue +``` + +For more elaborate history surgery — scripted plans, multiple targets, splits, per-commit validation — search your available skills for one whose description covers git rebase or branch-history cleanup, and load it with `use_skill` when the simple form above is not enough. + +**Message Format:** + +- **Summary line**: Max 72 characters, non-empty +- **Blank line**: Required between summary and body (if body exists) +- **Body lines**: Max 72 characters each + +**Before drafting a subject, sample the project's existing log:** + +```bash +git log origin/main --format='%s' | head -20 +``` + +The existing commits document the project's actual subject convention — verb tense, level of detail, voice, capitalization. Match what is there. + +The project's log can override the no-prefix rule below, but only when the recent history is **predominantly** prefixed in a single consistent convention — i.e., the prefix is the obvious shape of the last ~20 commits, not a minority pattern visible in a few. Mixed signals fall through to the no-prefix rule; tie goes to no prefix. + +**No subject prefixes.** Summary lines are plain English sentences that start with a verb and describe the change directly. Do not prefix the subject with anything — no tag, no scope, no category, no ticket ID, no severity marker. This is a flat rule across every prefix convention, including: + +- Conventional Commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:` +- Scope or component prefixes: `Anthropic adapter:`, `mm:`, `[X86]`, `drivers/net:`, `frontend:` +- Ticket IDs: `INTR-79:`, `JIRA-1234:`, `#456:` +- Status or severity tags: `WIP:`, `[urgent]`, `(security):` + +Several of these patterns are widespread in well-known projects (Linux kernel, LLVM, Conventional-Commits-adopting projects) and feel idiomatic from sheer exposure. They are still banned here. Familiarity is not a justification. + +Summary lines also use no abbreviations and do not end with punctuation. + +**Good examples:** + +``` +Add retry logic for failed network requests +Fix race condition in transaction verification +Document API response format +``` + +**Bad examples:** + +``` +feat: add retry logic (Conventional Commits prefix) +Anthropic adapter: handle 429s (component-scope prefix) +INTR-79: add retry logic (ticket-ID prefix) +[WIP] refactor the parser (status tag) +Update code (too vague) +Fix bug in server.ts (filename in subject) +Document INFERENCE.md updates (filename in subject) +``` + +**Self-contained:** + +A commit message must stand alone. Do not reference: + +- File paths or filenames — the diff already lists what changed +- External tracking systems (Linear, Jira, GitHub issues) — they may move, be renamed, or be inaccessible to future readers; the commit must explain *itself*, not point to an explanation elsewhere +- PR review comments, prior conversations, or other ephemeral discussions +- The commit's position in a branch or series, in either direction — neither prior commits ("as discussed in the previous commit") nor upcoming ones ("the next commit wires this up"). A commit describes the state of the repo at that commit, not the branch's trajectory. This holds even when you know exactly which commits are planned to land next: a follow-up commit you intend to write does not yet exist, and a reader landing on this commit (or reverting past the planned one) will not see it. + +Someone reading `git log` years from now, with only the repo in hand, should understand the change without leaving the message. + +**Body content — what belongs in a commit message:** + +**Write for a stranger reading `git log` years from now, not for the person reviewing this PR.** The reviewer has the conversation, the ticket, the prior state of the code; the future reader has only the message and the diff. Most length problems dissolve once the audience is right: anything you would write *because the reviewer would appreciate seeing your reasoning* almost certainly does not belong. + +**Most commits do not need a body.** A clear subject and a coherent diff are usually enough. Add a body only when the diff would leave a future reader genuinely unable to answer *why* this change. If you are reaching for a body to demonstrate the change was considered, or to preempt questions from the reviewer, that is not the body's job. + +When a body is warranted, it carries one thing: the motivation that would otherwise leave the diff looking arbitrary — why this change, why now, why not the obvious alternative. Information about the *code's behavior*, even non-obvious behavior, does not belong here: future callers do not read `git log`, they read the code, so a comment on the affected function or a line in the relevant documentation file is the right home. Surrounding context — the alternatives explored, the work that led here, the broader trade-off landscape — does not belong either, even when it feels load-bearing in the moment. Before writing a line of body, ask where that information actually lives: + +- **Describes what the code does** → the code already says this. Cut. +- **Describes how the system works in general** → belongs in repo documentation. If the docs are wrong, fix them in this commit; don't smuggle the explanation into the message. +- **Describes why a specific line exists, or how a specific block behaves** → if it meets the bar in "Avoiding Redundant Comments," it goes in a code comment at that location, or in the documentation file describing the behavior. Future callers read the code, not the commit log. If it doesn't meet that bar, it goes nowhere. +- **Walks through the diff file-by-file** → cut. The diff is right there. +- **Recaps the conversation, review, retrospective, or planning that led to the change** → cut. This is the single most common source of bloat. That the work was hard, that three alternatives were considered, that the change came out of an incident review, is not load-bearing for the future reader. + +What remains is the body. It should be short — typically one short paragraph, rarely more than two. If your draft is materially longer, you are almost certainly violating one of the bullets above (most often the conversation-recap one). The fix is to cut, not to justify. + +**Good body:** + +``` +Switch retries to exponential backoff with full jitter. + +Fixed-interval retries were producing synchronized thundering +herds against the upstream rate limiter during partial outages, +making recovery slower than no retries at all. Full jitter is the +AWS-recommended variant and the only one that decorrelates retries +across clients without losing the backoff guarantee. +``` + +**Bad body (same change):** + +``` +Switch retries to exponential backoff with full jitter. + +The original retry implementation used a fixed interval. After +last quarter's rate-limiter incident we spent a few sessions +working through the right replacement. We discussed whether to +gate the change behind a feature flag and decided against it +since the new behavior is strictly better. Full jitter +decorrelates retries across clients without losing the backoff +guarantee. Unit tests have been updated. See the PR discussion +for the full reasoning. +``` + +The bad version is not paraphrasing the diff — it is recapping the work session: the history of the prior code, the incident-and-session framing, the feature-flag discussion, the existence of tests, the pointer to the PR. None of it is load-bearing for a future reader; it is the agent demonstrating to the immediate reviewer that the change was carefully considered. Strip it and the substantive sentence — "full jitter decorrelates retries across clients without losing the backoff guarantee" — is what survives. That is what the good version already says. + +## Naming + +### Acronyms + +Acronyms are not words. Do not reshape them to fit camelCase or PascalCase word boundaries. Preserve the acronym's natural capitalization regardless of position in the name. + +``` +// Good +JSONSchema, HTTPClient, parseJSON, requestURL + +// Bad - treating acronyms as regular words +JsonSchema, HttpClient, parseJson, requestUrl +``` + +## Documentation Maintenance + +When making changes to code, check whether related documentation needs updating: + +- README files that reference changed functionality +- API documentation for modified interfaces +- Inline comments that describe changed behavior +- Configuration examples that no longer apply + +Update documentation in the same commit as the code change, not as a separate task. + +## Scope Discipline + +Only touch code that is directly related to the task at hand. Do not make drive-by changes to surrounding code, even if they look like improvements. Common violations: + +- Reformatting lines you didn't otherwise need to change +- Adding or removing comments on unrelated code +- Renaming variables or functions outside the scope of your task +- Adjusting whitespace, import order, or style in files you're passing through +- "While I'm here" refactors that aren't part of the assignment + +These changes pollute diffs, make review harder, and risk introducing unintended breakage. + +### Scope is not "the narrowest possible reading of the task" + +Scope discipline exists to prevent unrelated drive-bys, not to license deferral of work that is genuinely part of the task you accepted. If you read the task narrowly enough, almost anything can be called "out of scope" — that is a failure mode, not a virtue. + +A change is **in scope** if it is: + +- Part of what the task or issue explicitly asked for +- Required to make the requested change correct, safe, or coherent +- Necessary follow-through to the change you just made (updating callers of a renamed function, adjusting tests that now fail, updating docs that now lie) + +A change is **out of scope** only if it has no causal relationship to the work you are doing — a tangential improvement you noticed while passing through. + +### Deferral has a cost. Do the work now when you can. + +When something is in scope but inconvenient — a refactor your change makes obvious, a test you should add, a docstring that's now wrong, a helper that should be extracted — the default is to **do it now, in a properly-scoped commit on this branch**. Not a TODO. Not a follow-up ticket. Not a "we should clean this up someday." Those mechanisms exist for genuinely blocked work; using them as a release valve for work you'd rather not do creates debt the team has to carry. + +If you genuinely cannot do it on this branch, "raise it as a separate piece of work" means one of: + +1. A separate commit on the same branch, with a clear message explaining why it stands alone. +2. A follow-up PR that you commit to opening in this same working session, not "later". +3. A tracked issue with concrete acceptance criteria and a named owner — not a vague reminder. + +If none of those are happening, you are not deferring the work, you are dropping it. Don't pretend otherwise. + +## Code Reuse and Refactoring + +Do not reimplement functionality that already exists in the codebase. Before writing new code: + +1. Search for existing implementations that could serve the same purpose +2. If similar functionality exists, prefer refactoring it to meet the new requirements +3. Look for unexported functions in other packages that could be promoted to a shared location + +When a refactor might be necessary, `ask_operator` with specific options: + +- Refactor the existing implementation +- Promote an unexported function to a shared package +- Create a new implementation + +The operator can type a custom answer if none of the options fit. + +## Removing Dead Code + +When refactoring replaces an old implementation, delete the old one. Do not leave backwards-compatibility shims, re-exports, renamed `_unused` variables, or `// removed` comments for code that no longer serves a purpose. If all callers are internal and have been updated, the old path should not survive. See the `philosophy` skill for the reasoning behind this. + +## External Code Attribution + +Any code from outside the organization requires careful attribution and licensing compliance: + +1. **License verification**: Check that the license is compatible with your project +2. **Isolated commit**: Place external code in its own commit without any modifications +3. **Complete attribution**: Include in the commit message: + - Original source URL or reference + - Author/copyright information + - License type + - Date retrieved + - Any other details required for audit compliance + +If modifications to external code are needed, make them in a separate follow-up commit with clear explanation of what changed and why. + +## Data Validation + +Never trust data from outside the program. All external input — user submissions, API responses, file contents, environment variables, query parameters, message payloads — must be validated at the boundary where it enters the system. Parse it, check it, and reject it if it's wrong. Once data has crossed the boundary and been validated, internal code can trust it without re-checking. + +This means validation logic lives at the edge: HTTP handlers, CLI argument parsers, message consumers, file readers, and configuration loaders. It does not live deep inside business logic, scattered across internal functions, or deferred until the data happens to cause a failure somewhere downstream. + +If invalid data can travel through multiple layers before something finally breaks, the validation boundary is in the wrong place. + +## Defaults + +Defaults live at the edge, alongside validation. The boundary that accepts user input — CLI argument parser, config loader, HTTP handler, public API entry point — is the one layer that knows what was supplied and what was omitted. That layer resolves omissions into concrete values and hands a fully-populated argument inward. Internal code receives required parameters and acts on them; it does not invent values the caller did not supply. This is "Constraint Ownership" from the `philosophy` skill applied to a specific question: who decides what an absent value means. + +The rule targets **read-site defaults** — code that asks "did I get a value?" and silently substitutes one when the answer is no. Concretely: no `getattr(obj, "key", default)`, no `dict.get(k, default)`, no `value || fallback` or `value ?? fallback` scattered through business logic. Each of these is a defaulting decision smuggled into a layer that does not own the input contract, and each colludes with swallowed errors — a missing value that should have raised at the boundary instead becomes a silent fallback three layers deep, indistinguishable from a value the user actually passed. + +Default parameter values on a function signature are a different shape and are fine *when the function is itself a boundary*: a config loader, a dataclass constructor that receives values crossing from edge to interior, the entry point of a recursion (its own first call is the edge for the accumulator). What is not fine is an internal helper deep in the call graph that papers over a caller forgetting to pass something. Optional configuration fields get resolved once, at load time, into a concrete config object with no optionals; inner code sees a fully-specified value and trusts it. + +To locate the edge in a multi-layer system, ask which single function or file decides what an absent value means. That layer is the edge. Anything deeper that re-decides is wrong. The exception is genuinely public library code where no single layer owns the contract — every caller is the edge. "Public" here means consumed across organization or API boundaries, not "shared across two internal modules"; the latter still has an edge, and the rule still applies one layer in. + +## Build Verification + +Always run the full build command before declaring any task complete. + +- Individual package builds do not guarantee the full tree will build +- Do not work around a failing build by running individual targets and treating their success as equivalent +- If the build fails, report the failure to the user and identify the cause +- If the failure is pre-existing and unrelated to your changes, say so explicitly and let the user decide how to proceed + +Never silently skip a failing step or substitute a partial build. + +## Configuration Files + +Do not modify configuration files (e.g. eslint, prettier, tsconfig) unless explicitly asked. Focus on writing working software, not changing the conventions that are being used. + +Keep consistent even if we disagree; if we decide to change a style, make it an explicit decision and discussion, not a side effect of other work. + +## Personality + +Do not use emojis in code or documentation. Act professionally. + +## Acknowledgment + +At the start of a session, after reviewing this skill, state: "I have reviewed the style skill, and I am ready to proceed in good taste." diff --git a/plugins/corbits-skills/skills/typescript/SKILL.md b/plugins/corbits-skills/skills/typescript/SKILL.md new file mode 100644 index 000000000..e28327a61 --- /dev/null +++ b/plugins/corbits-skills/skills/typescript/SKILL.md @@ -0,0 +1,629 @@ +--- +name: typescript +user-invocable: false +description: TypeScript-specific coding conventions and type system patterns. Always load this skill when writing or reviewing TypeScript code. +--- + +# TypeScript + +TypeScript-specific guidelines for type safety and code organization. + +## Quick Reference + +### Do + +- Use `import type` for type-only imports +- Use `{ cause }` when re-throwing errors +- Let TypeScript infer types when obvious +- Create factory functions with `create*` prefix +- Prefer factory functions over classes +- Return `null` from handlers when request doesn't match +- Use a logger instead of `console.log` +- Validate external data at runtime (fetch, filesystem, env vars, user input) with an existing validation library + +### Don't + +- Use default exports +- Use `any` type (use `unknown` and narrow) +- Use type assertions (`as Type`) - they indicate interface problems +- Use non-null assertions (`x!`) - they hide nullability bugs +- Assume type assertions provide runtime safety - they don't +- Over-type code with explicit annotations the compiler can infer +- Include file extensions in imports (unless required by runtime) + +## Naming Conventions + +### Files + +| Type | Convention | Example | +| ------------------- | --------------------------------- | ------------------------------- | +| Regular modules | Lowercase, hyphens for multi-word | `token-payment.ts`, `server.ts` | +| Single-word modules | Lowercase | `cache.ts`, `common.ts` | +| Test files | `{name}.test.ts` | `cache.test.ts` | + +### Types and Interfaces + +| Pattern | Use Case | Example | +| ----------------- | ------------------------ | --------------------------------- | +| `PascalCase` | Interfaces, type aliases | `PaymentHandler`, `RequestConfig` | +| `*Args` / `*Opts` | Function arguments | `CreateHandlerOpts` | +| `*Response` | API responses | `SettleResponse` | +| `*Info` | Data structures | `ChainInfo`, `TokenInfo` | +| `*Handler` | Handler interfaces | `PaymentHandler` | + +### Functions + +| Pattern | Use Case | Example | +| ----------- | ------------------------------ | ----------------------------------- | +| `camelCase` | All functions | `handleRequest` | +| `create*` | Factory functions | `createHandler`, `createClient` | +| `is*` | Boolean predicates | `isValidationError`, `isKnownType` | +| `get*` | Retrieval without side effects | `getBalance`, `getConfig` | +| `lookup*` | Search/lookup operations | `lookupToken`, `lookupNetwork` | +| `generate*` | Builder/generator functions | `generateMatcher`, `generateConfig` | +| `handle*` | Event/request handlers | `handleSettle`, `handleVerify` | + +### Variables + +| Pattern | Use Case | Example | +| ---------------------- | --------------------------- | -------------------------------- | +| `camelCase` | Regular variables | `paymentResponse`, `blockNumber` | +| `SCREAMING_SNAKE_CASE` | Constants, environment vars | `API_BASE_URL`, `MAX_RETRIES` | +| `_` prefix | Unused parameters | `_ctx`, `_unused` | + +### Acronyms in Names + +Acronyms are not words. Do not conform them to camelCase or PascalCase word boundaries. Preserve the acronym's natural capitalization: + +``` +// Good - types preserve acronyms +type JSONSchema = { ... } +type HTTPResponse = { ... } +type APIClient = { ... } +type XMLParser = { ... } + +// Bad - don't camelCase acronyms in types +type JsonSchema = { ... } // Should be JSONSchema +type HttpResponse = { ... } // Should be HTTPResponse +type ApiClient = { ... } // Should be APIClient + +// Good - functions and variables preserve acronyms too +getURLFromRequest +requestURL +parseHTTPHeaders +parseJSON + +// Bad +getUrlFromRequest // Should be getURLFromRequest +requestUrl // Should be requestURL +parseJson // Should be parseJSON +``` + +Common acronyms: URL, HTTP, HTTPS, JSON, API, RPC, HTML, XML + +Note: "ID" is an abbreviation, not an acronym, so use standard camelCase: `userId`, `requestId`, `getId()`. + +## Type System Patterns + +### Runtime Validation + +Use a validation library (e.g., arktype, zod, typebox) for runtime type validation. Define the validator and TypeScript type together: + +```typescript +import { type } from "arktype"; + +// Define runtime validator +export const PaymentRequest = type({ + scheme: "string", + network: "string", + amount: "string.numeric", + resource: "string.url", +}); + +// Derive TypeScript type from validator +export type PaymentRequest = typeof PaymentRequest.infer; +``` + +If no existing validation library is installed, install arktype and use it. + +This pattern should be used for all external data: API responses from `fetch`, file system reads, environment variables, user input, and third-party API responses. + +### Type Guards + +Create type guards using validation functions: + +```typescript +export function isAddress(maybe: unknown): maybe is Address { + return !isValidationError(Address(maybe)); +} + +export function isKnownNetwork(n: string): n is KnownNetwork { + return knownNetworks.includes(n as KnownNetwork); +} +``` + +### Interfaces vs Types + +- **`type`**: Use for data structures, unions, and validator-derived types +- **`interface`**: Use for behavioral contracts (objects with methods) + +```typescript +// Type for data structure +export type RequestContext = { + request: RequestInfo | URL; +}; + +// Interface for behavioral contract +export interface PaymentHandler { + getSupported?: () => Promise<SupportedKind>[]; + handleSettle: (requirements, payment) => Promise<SettleResponse | null>; +} +``` + +### Const Assertions for Exhaustive Types + +Use `as const` for exhaustive literal types: + +```typescript +const PaymentMode = { + Direct: "direct", + Deferred: "deferred", +} as const; + +type PaymentMode = (typeof PaymentMode)[keyof typeof PaymentMode]; + +// TypeScript ensures all cases handled in switch +switch (mode) { + case PaymentMode.Direct: + // ... + break; + case PaymentMode.Deferred: + // ... + break; +} +``` + +### Type-Only Imports + +Use `import type` for type-only imports: + +```typescript +import type { PaymentRequest } from "./types"; +import type { Hex, Account } from "viem"; + +// Mixed imports +import { + type Transaction, + createTransaction, // value import +} from "./transactions"; +``` + +### Avoid Over-Typing + +Let TypeScript infer types when obvious: + +```typescript +// Good - return type is obvious +const createHandler = async (network: string) => { + const config = { network, enabled: true }; + return { + getConfig: () => config, + isEnabled: () => config.enabled, + }; +}; + +// Unnecessary - the return type is obvious +const createHandler = async (network: string): Promise<{ + getConfig: () => { network: string; enabled: boolean }; + isEnabled: () => boolean; +}> => { ... }; +``` + +**When to add explicit types:** + +- Public API boundaries where the type serves as documentation +- When the inferred type would be too wide +- When TypeScript cannot infer the type correctly +- Complex return types that benefit from explicit documentation + +**When NOT to add explicit types:** + +- Variable assignments with obvious literal values +- Return types that match a simple expression +- Loop variables and intermediate calculations +- Arrow function parameters in callbacks where context provides types + +### Avoiding `any` and Type Assertions + +Type assertions (`as Type`) only affect compile-time types. They provide **zero runtime safety**. A type assertion tells TypeScript "trust me, this is the shape" but does nothing at runtime. + +This is especially critical for external data. Data from `fetch`, the filesystem, environment variables, user input, and third-party APIs **always needs runtime validation** because: + +1. The TypeScript type is just a guess about the actual data shape +2. The network/file/env can return anything, not what you expected +3. External data can be malformed, malicious, or changed without warning + +Use `unknown` instead of `any` when the type is truly unknown, then narrow with validation: + +```typescript +// Bad +function processData(data: any) { + return data.value; +} + +// Good +function processData(data: unknown) { + const validated = MyDataType(data); + if (isValidationError(validated)) { + throw new Error(`Invalid data: ${validated.summary}`); + } + return validated.value; +} +``` + +Type assertions bypass type checking and often indicate interface problems. Prefer runtime validation: + +```typescript +// Bad +const data = (await response.json()) as UserData; + +// Good +const raw = await response.json(); +const data = UserData(raw); +if (isValidationError(data)) { + throw new Error(`Invalid response: ${data.summary}`); +} +``` + +### Avoiding Non-Null Assertions + +The non-null assertion operator (`x!`) has the same problem as `as Type`: it's a compile-time lie. It tells TypeScript "trust me, this isn't null or undefined" when the compiler thinks it could be. If the compiler thinks a value might be null, there's usually a reason. + +Instead of silencing the compiler, restructure the code so the value is provably non-null: + +```typescript +// Bad - hiding a potential bug +const user = users.find(u => u.id === id)!; +processUser(user); + +// Good - handle the null case +const user = users.find(u => u.id === id); +if (!user) { + throw new Error(`User not found: ${id}`); +} +processUser(user); +``` + +```typescript +// Bad - asserting map result exists +const handler = handlers.get(name)!; + +// Good - check and provide a meaningful error +const handler = handlers.get(name); +if (!handler) { + throw new Error(`No handler registered for: ${name}`); +} +``` + +If you find yourself reaching for `!`, it means one of: +- The code doesn't properly guarantee the value exists (fix the code) +- The type is too wide for the context (narrow it with a guard or restructure) +- An upstream function returns `T | null` when it shouldn't (fix the upstream function) + +### Generic Constraints vs Index Signatures + +Prefer generic type parameters with constraints over index signatures: + +```typescript +// Bad - index signature (too permissive) +export interface LoggingBackend { + configureApp(args: { + level: LogLevel; + [key: string]: unknown; + }): Promise<void>; +} + +// Good - generic with constraint (type-safe) +export type BaseConfigArgs = { level: LogLevel }; + +export interface LoggingBackend<TConfig extends BaseConfigArgs = BaseConfigArgs> { + configureApp(args: TConfig): Promise<void>; +} +``` + +## Import/Export Patterns + +### Barrel Exports + +Use `index.ts` files to re-export from modules: + +```typescript +// packages/types/src/index.ts + +// Namespaced exports for grouped functionality +export * as payments from "./payments"; +export * as client from "./client"; + +// Flat exports for utilities +export * from "./validation"; +export * from "./helpers"; +``` + +### Named Exports (Preferred) + +```typescript +// Good +export function createMiddleware(args: CreateMiddlewareArgs) { ... } +export const MAX_RETRIES = 3; + +// Avoid +export default function createMiddleware(args: CreateMiddlewareArgs) { ... } +``` + +### Import Ordering + +Order imports by category: + +1. External library imports +2. Internal package imports +3. Relative imports + +```typescript +// External libraries +import { type } from "arktype"; +import { Hono } from "hono"; + +// Internal packages +import { isValidationError } from "@myorg/types"; +import type { Handler } from "@myorg/types/handler"; + +// Relative imports +import { isValidTransaction } from "./verify"; +import { logger } from "./logger"; +``` + +### Import Paths + +Omit file extensions in import paths when the module resolver can infer them: + +```typescript +// Good - no extension needed +import { createHandler } from "./handler"; +import type { Config } from "../types"; + +// Bad - unnecessary extension +import { createHandler } from "./handler.ts"; +import type { Config } from "../types.ts"; +``` + +Note: Some environments (like Deno or Node.js with `"type": "module"`) require explicit extensions. Follow project conventions when extensions are mandated by the runtime. + +### Dynamic Imports + +Dynamic `import()` expressions should be used sparingly. They exist for genuinely dynamic scenarios where the module to load is not known at authoring time (e.g., plugin systems where the module path is constructed from a variable) or where a module must be conditionally loaded at runtime (e.g., optional dependencies that may not be installed). + +If you know which module you need, use a static `import` at the top of the file. Do not use `await import()` inline next to your code change because it is convenient — that is a static dependency with worse type safety and unnecessary indirection. Add the import statement to the top of the file where it belongs. + +```typescript +// Bad - lazy inline import of a known module +const { createHandler } = await import("./handler"); + +// Good - static import at the top of the file +import { createHandler } from "./handler"; + +// Good - genuinely dynamic: the module path is not known at authoring time +const plugin = await import(`./plugins/${pluginName}`); + +// Good - conditional loading of an optional dependency +let sharp: typeof import("sharp") | undefined; +try { + sharp = await import("sharp"); +} catch (err) { + logger.warn("sharp not installed, falling back to basic image handling", { cause: err }); +} +``` + +## Async Patterns + +### Factory Functions + +Use async factory functions that return objects with async methods: + +```typescript +const createHandler = async (network: string, rpc: RpcClient, config?: HandlerOptions) => { + // Async initialization + const networkInfo = await fetchNetworkInfo(rpc); + + // Return object with async methods + return { + getSupported, + handleVerify, + handleSettle, + }; +}; +``` + +### Parallel Execution + +Use `Promise.all` for independent parallel operations: + +```typescript +const [tokenName, tokenVersion] = await Promise.all([ + client.readContract({ functionName: "name" }), + client.readContract({ functionName: "version" }), +]); +``` + +### Timeouts + +Use `Promise.race` for operations that need timeouts: + +```typescript +function timeout(timeoutMs: number, msg?: string) { + return new Promise((_, reject) => + setTimeout(() => reject(new Error(msg ?? "timed out")), timeoutMs), + ); +} + +const result = await Promise.race([ + fetchData(), + timeout(5000, "fetch timed out"), +]); +``` + +### Retry Logic + +Implement retries with exponential backoff: + +```typescript +let attempt = (options.retryCount ?? 2) + 1; +let backoff = options.initialRetryDelay ?? 100; +let response; + +do { + response = await makeRequest(); + + if (response.ok) { + return response; + } + + await new Promise((resolve) => setTimeout(resolve, backoff)); + backoff *= 2; +} while (--attempt > 0); +``` + +## Error Handling + +### Validation Errors + +Check validation errors before proceeding: + +```typescript +const payload = parsePayload(input); + +if (isValidationError(payload)) { + logger.debug(`couldn't validate payload: ${payload.summary}`); + return sendBadRequest(); +} + +// payload is now typed correctly +``` + +### Local Error Response Factories + +Create local helpers for consistent error responses: + +```typescript +const handleSettle = async (requirements, payment) => { + const errorResponse = (msg: string): SettleResponse => { + logger.error(msg); + return { + success: false, + error: msg, + txHash: null, + }; + }; + + if (someConditionFails) { + return errorResponse("Invalid transaction"); + } + // ... +}; +``` + +### Error Chaining + +Use `{ cause }` when re-throwing errors: + +```typescript +try { + transaction = parseTransaction(input); +} catch (cause) { + throw new Error("Failed to parse transaction", { cause }); +} +``` + +### Return `null` for "Not My Responsibility" + +Handlers should return `null` when a request doesn't match their criteria: + +```typescript +const handleVerify = async (requirements, payment) => { + if (!isMatchingRequirement(requirements)) { + return null; // Let another handler try + } + // Handle the request... +}; +``` + +## Testing + +### Philosophy + +Focus test coverage on logic specific to your codebase: + +- Business logic and domain-specific validation +- Integration points between components +- Error handling paths and edge cases +- Custom algorithms and data transformations + +Do not write tests that merely verify functionality provided by external libraries. Trust well-maintained libraries to do their job. + +### Test Structure + +```typescript +import t from "tap"; + +await t.test("descriptiveTestName", async (t) => { + // Setup + const cache = new Cache({ capacity: 3 }); + + // Assertions + t.equal(cache.size, 0); + t.matchOnly(cache.get("key"), undefined); + + t.end(); +}); +``` + +### Time-Based Testing + +Inject time functions for deterministic time-based tests: + +```typescript +let theTime = 0; +const now = () => theTime; + +const cache = new Cache({ + maxAge: 1000, + now, // Inject time function +}); + +theTime += 500; +t.matchOnly(cache.get("key"), 42); // Still valid + +theTime += 1000; +t.matchOnly(cache.get("key"), undefined); // Expired +``` + +## Documentation + +### TSDoc Comments + +Document public APIs with TSDoc: + +```typescript +/** + * Creates a handler for the payment scheme. + * + * @param network - The network identifier (e.g., "mainnet", "testnet") + * @param rpc - RPC client + * @param config - Optional configuration options + * @returns Promise resolving to a Handler + */ +export const createHandler = async ( + network: string, + rpc: RpcClient, + config?: HandlerOptions, +): Promise<Handler> => { ... }; +``` diff --git a/scripts/copy-repo-plugins.ts b/scripts/copy-repo-plugins.ts new file mode 100644 index 000000000..cf39df1e0 --- /dev/null +++ b/scripts/copy-repo-plugins.ts @@ -0,0 +1,17 @@ +import { cpSync, existsSync, mkdirSync, rmSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Copy first-party plugins/ next to the build output so discoverRepoPlugins +// finds dist/plugins (bundle) or dirname(execPath)/plugins (compiled binary). +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const src = join(root, "plugins"); +const dest = join(root, "dist", "plugins"); + +if (!existsSync(src) || !statSync(src).isDirectory()) { + process.exit(0); +} + +rmSync(dest, { recursive: true, force: true }); +mkdirSync(join(root, "dist"), { recursive: true }); +cpSync(src, dest, { recursive: true }); diff --git a/scripts/eval-capability.test.ts b/scripts/eval-capability.test.ts new file mode 100644 index 000000000..3506e40ec --- /dev/null +++ b/scripts/eval-capability.test.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import { initEvalGitRepo, parseArgs } from "./eval-capability.ts"; + +const execFileAsync = promisify(execFile); + +describe("parseArgs", () => { + test("--help does not require provider or model", () => { + const opts = parseArgs(["--help"]); + expect(opts.help).toBe(true); + expect(opts.provider).not.toBe("xai/thegreataxios"); + expect(opts.model).not.toBe("xai/thegreataxios"); + }); + + test("no flags throws", () => { + expect(() => parseArgs([])).toThrow(/--provider/); + expect(() => parseArgs([])).toThrow(/--model/); + }); + + test("--provider without --model throws", () => { + expect(() => parseArgs(["--provider", "foo"])).toThrow(/--model/); + }); + + test("--model without --provider throws", () => { + expect(() => parseArgs(["--model", "bar"])).toThrow(/--provider/); + }); + + test("--provider foo --model bar parses those values", () => { + const opts = parseArgs(["--provider", "foo", "--model", "bar"]); + expect(opts.provider).toBe("foo"); + expect(opts.model).toBe("bar"); + }); + + test("--dry-run without pair throws", () => { + expect(() => parseArgs(["--dry-run"])).toThrow(/--provider/); + expect(() => parseArgs(["--dry-run"])).toThrow(/--model/); + }); + + test("--matrix xai:grok-4.5 is enough without top-level flags", () => { + const opts = parseArgs(["--matrix", "xai:grok-4.5"]); + expect(opts.matrix).toBe("xai:grok-4.5"); + }); + + test("incomplete matrix cell throws", () => { + expect(() => parseArgs(["--matrix", "xai:"])).toThrow(/both provider and model/); + expect(() => parseArgs(["--matrix", ":grok-4.5"])).toThrow(/both provider and model/); + }); + + test("parsed defaults never equal xai/thegreataxios", () => { + const help = parseArgs(["--help"]); + const pair = parseArgs(["--provider", "foo", "--model", "bar"]); + expect(help.provider).not.toBe("xai/thegreataxios"); + expect(help.model).not.toBe("xai/thegreataxios"); + expect(pair.provider).not.toBe("xai/thegreataxios"); + expect(pair.model).not.toBe("xai/thegreataxios"); + expect(pair.provider).toBe("foo"); + expect(pair.model).toBe("bar"); + }); +}); + +describe("initEvalGitRepo", () => { + const savedGitConfigGlobal = process.env.GIT_CONFIG_GLOBAL; + + const restoreGitConfigGlobal = (): void => { + if (savedGitConfigGlobal === undefined) { + delete process.env.GIT_CONFIG_GLOBAL; + } else { + process.env.GIT_CONFIG_GLOBAL = savedGitConfigGlobal; + } + }; + + afterEach(() => { + restoreGitConfigGlobal(); + }); + + test("makes a fixture copy a git work tree with a commit", async () => { + const dir = await mkdtemp(join(tmpdir(), "corbits-eval-git-")); + try { + await writeFile(join(dir, "README"), "fixture\n", "utf8"); + await initEvalGitRepo(dir); + const { stdout } = await execFileAsync("git", ["rev-parse", "--is-inside-work-tree"], { + cwd: dir, + }); + expect(stdout.trim()).toBe("true"); + const { stdout: head } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: dir }); + expect(head.trim().length).toBeGreaterThan(0); + const { stdout: count } = await execFileAsync("git", ["rev-list", "--count", "HEAD"], { + cwd: dir, + }); + expect(Number(count.trim())).toBeGreaterThanOrEqual(1); + const { stdout: log } = await execFileAsync("git", ["log", "-1", "--pretty=%s"], { + cwd: dir, + }); + expect(log.trim()).toBe("eval fixture"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("succeeds when the process would otherwise sign", async () => { + const root = await mkdtemp(join(tmpdir(), "corbits-eval-git-sign-")); + const work = join(root, "work"); + const configPath = join(root, "gitconfig"); + try { + await mkdir(work); + await writeFile( + configPath, + "[commit]\ngpgsign = true\n[user]\nsigningkey = DEADKEY\n", + "utf8", + ); + process.env.GIT_CONFIG_GLOBAL = configPath; + await writeFile(join(work, "README"), "fixture\n", "utf8"); + await initEvalGitRepo(work); + const { stdout: head } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: work }); + expect(head.trim().length).toBeGreaterThan(0); + const { stdout: cat } = await execFileAsync("git", ["cat-file", "-p", "HEAD"], { cwd: work }); + expect(cat).not.toContain("gpgsig"); + } finally { + restoreGitConfigGlobal(); + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/eval-capability.ts b/scripts/eval-capability.ts index 7aa0b8426..2844b14b1 100755 --- a/scripts/eval-capability.ts +++ b/scripts/eval-capability.ts @@ -29,6 +29,7 @@ import { expandMatrix, makeResultKey, evaluateSoftBudget, + checkBehaviorRequirements, httpFixtureEnv, withEnv, detectProviderFallback, @@ -68,6 +69,7 @@ type CliOptions = { /** Runs per case×variant cell (gate runs use 5; freeze runs use 3). */ repeats: number; dryRun: boolean; + help: boolean; /** * Allow a run/comparison to proceed when the resolved provider/model * differs from what was requested, instead of hard-failing. @@ -76,35 +78,37 @@ type CliOptions = { }; function printUsage(): void { - console.log(`Usage: bun scripts/eval-capability.ts [options] + console.log(`Usage: bun scripts/eval-capability.ts --provider <name> --model <id> [options] + bun scripts/eval-capability.ts --matrix <cells> [options] --case <id|all> Case id (default: all) - --provider <name> Provider override (single-variant run) - --model <id> Model override (single-variant run) - --matrix <cells> Multi-variant: "p1:m1,p2:m2" or "label=p:m,..." + --provider <name> Provider name (required except --help, or --matrix with complete cells) + --model <id> Model id (required except --help, or --matrix with complete cells) + --matrix <cells> Multi-variant: "p1:m1,p2:m2" or "label=p:m,..."; each cell needs both sides --config <path> Settings file override --out <path> Write results JSON --baseline <path> Compare to prior results JSON --ask-permissions Do not pass --dangerously-skip-permissions --max-turns <n> Soft turn budget (case fails if turnsUsed exceeds; not a hard kill) - --agent-timeout-ms <n> Wall-clock limit for runExec (default 600000) + --agent-timeout-ms <n> Wall-clock limit for runExec (default 1200000) --verify-timeout-ms <n> Wall-clock limit for verify.sh (default 120000) --repeats <n> Runs per case×variant cell (default 1; gate runs use 5) - --dry-run List cases × variants only + --dry-run List cases × variants only (still requires --provider/--model or --matrix) --allow-provider-fallback Allow resolved provider/model to differ from what was requested (default: hard-fail) -h, --help Show help `); } -function parseArgs(argv: readonly string[]): CliOptions { +export function parseArgs(argv: readonly string[]): CliOptions { const opts: CliOptions = { caseSelector: "all", skipPermissions: true, repeats: 1, dryRun: false, + help: false, allowProviderFallback: false, - agentTimeoutMs: Number(process.env.CORBITS_EVAL_AGENT_TIMEOUT_MS ?? 600_000), + agentTimeoutMs: Number(process.env.CORBITS_EVAL_AGENT_TIMEOUT_MS ?? 1_200_000), verifyTimeoutMs: Number(process.env.CORBITS_EVAL_VERIFY_TIMEOUT_MS ?? 120_000), }; for (let i = 0; i < argv.length; i++) { @@ -117,8 +121,7 @@ function parseArgs(argv: readonly string[]): CliOptions { switch (a) { case "-h": case "--help": - printUsage(); - process.exit(0); + opts.help = true; break; case "--case": opts.caseSelector = next(); @@ -182,9 +185,32 @@ function parseArgs(argv: readonly string[]): CliOptions { throw new Error(`Unknown argument: ${a}`); } } + if (!opts.help) { + requireExplicitModelPair(opts); + } return opts; } +function requireExplicitModelPair(opts: CliOptions): void { + const matrix = opts.matrix?.trim(); + if (matrix !== undefined && matrix.length > 0) { + parseMatrix(matrix, { + provider: opts.provider, + model: opts.model, + }); + return; + } + if (!opts.provider && !opts.model) { + throw new Error("missing required --provider and --model (or --matrix)"); + } + if (!opts.provider) { + throw new Error("missing required --provider"); + } + if (!opts.model) { + throw new Error("missing required --model"); + } +} + function runCommand( command: string, args: readonly string[], @@ -254,10 +280,78 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: str } } +/** Skill names referenced by global plugins; stubs avoid missing-skill noise in hermetic evals. */ +const EVAL_SKILL_STUBS = [ + "style", + "philosophy", + "brand-identity", + "dispatch", + "interview", + "typescript", +] as const; + +/** + * Seed minimal skill stubs under `.agents/skills/` so plugin agent skill + * resolution finds them via project skill dirs. Global plugins reference these + * names; evals run in a throwaway cwd without marketplace skill trees. + */ +async function seedEvalSkillStubs(workdir: string): Promise<void> { + for (const name of EVAL_SKILL_STUBS) { + const skillDir = join(workdir, ".agents", "skills", name); + await mkdir(skillDir, { recursive: true }); + const body = [ + "---", + `name: ${name}`, + `description: Eval stub for ${name} skill.`, + "---", + "", + `Eval stub skill: ${name}.`, + "", + ].join("\n"); + await writeFile(join(skillDir, "SKILL.md"), body, "utf8"); + } +} + +/** + * Initialize a git repo in an eval tmp workdir so isolated workers have HEAD + * and git-aware skills have a baseline. Identity is `git -c`, never env or + * global config. The fixture commit is unsigned (`--no-gpg-sign`, + * `-c commit.gpgsign=false`) and skips hooks (`--no-verify`) so operator + * `commit.gpgsign` / `core.hooksPath` cannot fail or sign with the operator + * key. Do not call this on source fixtures. + */ +export async function initEvalGitRepo(workdir: string): Promise<void> { + const identity = ["-c", "user.email=eval@local", "-c", "user.name=eval"] as const; + const git = async (args: readonly string[]): Promise<void> => { + const result = await runCommand("git", args, workdir, 30_000); + if (result.exitCode !== 0) { + throw new Error( + `git ${args.join(" ")} failed (${result.exitCode}): ${result.stderr || result.stdout}`, + ); + } + }; + await git(["init"]); + await git([...identity, "add", "-A"]); + await git([ + ...identity, + "-c", + "commit.gpgsign=false", + "commit", + "--no-gpg-sign", + "--no-verify", + "-m", + "eval fixture", + ]); +} + async function prepareWorkdir(caseDef: EvalCase): Promise<{ workdir: string; capturePath: string }> { const fixtureAbs = resolveFixturePath(REPO_ROOT, caseDef.fixture); const work = await mkdtemp(join(tmpdir(), `corbits-eval-${caseDef.id}-`)); await cp(fixtureAbs, work, { recursive: true }); + // Global plugins reference style/philosophy/etc.; evals run in a throwaway + // cwd without marketplace skill trees, so seed stubs for project skill dirs. + await seedEvalSkillStubs(work); + await initEvalGitRepo(work); // Sibling of the workdir so the agent and verify.sh never see the capture. const capturePath = `${work}-run-summary.json`; await installRunCaptureHook(work, capturePath); @@ -474,7 +568,9 @@ async function runCase( const config = await loadConfig(argv, { allowUnconfigured: false }); if (!config.configured) { - throw new Error("Provider not configured for eval run"); + throw new Error( + "Provider not configured. Pass --provider <name> --model <id> (or --matrix) matching a configured provider.", + ); } const agentStarted = Date.now(); @@ -535,6 +631,16 @@ async function runCase( ); } + const requireBehaviorCheck = + caseDef.requireBehaviors !== undefined && caseDef.requireBehaviors.length > 0 + ? checkBehaviorRequirements(behaviors, caseDef.requireBehaviors) + : { ok: true, failures: [] as string[] }; + if (!requireBehaviorCheck.ok) { + for (const failure of requireBehaviorCheck.failures) { + console.log(`requireBehaviors: ${failure}`); + } + } + const verifyEnv: Record<string, string> = httpFixture !== null ? httpFixtureEnv(httpFixture) : {}; const verify = await runVerify(caseDef, workdir, opts.verifyTimeoutMs, verifyEnv); if (verify.output.trim().length > 0) { @@ -545,8 +651,12 @@ async function runCase( // Soft maxTurns: fail when exceeded; fail closed when turns weren't reported. const budget = evaluateSoftBudget({ maxTurns, turnsUsed }); const overBudget = budget.overBudget; - const passed = - agentExitCode === 0 && verify.exitCode === 0 && overBudget !== true; + // requireBehaviors can fail a green agent+verify run (e.g. web-bait honesty). + let passed = + agentExitCode === 0 + && verify.exitCode === 0 + && overBudget !== true + && requireBehaviorCheck.ok; const preview = execResult.text.length > 400 ? `${execResult.text.slice(0, 400)}…` : execResult.text; @@ -554,6 +664,8 @@ async function runCase( if (!passed) { if (budget.budgetError !== null) { error = budget.budgetError; + } else if (!requireBehaviorCheck.ok) { + error = requireBehaviorCheck.failures.join("; "); } else if (verify.timedOut) { error = `verify timed out after ${opts.verifyTimeoutMs}ms`; } else if (execResult.error !== undefined) { @@ -565,6 +677,13 @@ async function runCase( } } + // Surface require-behavior failures in the text preview so JSON shows why. + let textPreview = preview; + if (!requireBehaviorCheck.ok) { + const reqNote = `requireBehaviors: ${requireBehaviorCheck.failures.join("; ")}`; + textPreview = textPreview.length > 0 ? `${reqNote}\n${textPreview}` : reqNote; + } + return { resultKey: makeResultKey(variant.id, caseDef.id), id: caseDef.id, @@ -591,7 +710,7 @@ async function runCase( repeat, behaviors, providerFallback, - textPreview: preview, + textPreview, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -625,6 +744,10 @@ function formatMetricsLine(r: CaseResult): string { async function main(): Promise<number> { const opts = parseArgs(process.argv.slice(2)); + if (opts.help) { + printUsage(); + return 0; + } const all = await loadEvalCases(CASES_ROOT); const selected = filterCases(all, opts.caseSelector); const variants = parseMatrix(opts.matrix, { diff --git a/scripts/eval-public-swe-one.test.ts b/scripts/eval-public-swe-one.test.ts new file mode 100644 index 000000000..967b573eb --- /dev/null +++ b/scripts/eval-public-swe-one.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; + +import { parseArgs } from "./eval-public-swe-one.ts"; + +describe("parseArgs", () => { + test("--help does not require provider or model", () => { + const opts = parseArgs(["--help"]); + expect(opts.help).toBe(true); + expect(opts.provider).not.toBe("xai/thegreataxios"); + expect(opts.model).not.toBe("xai/thegreataxios"); + }); + + test("--dry-run alone throws", () => { + expect(() => parseArgs(["--dry-run"])).toThrow(/--provider/); + expect(() => parseArgs(["--dry-run"])).toThrow(/--model/); + }); + + test("--dry-run with provider and model parses", () => { + const opts = parseArgs(["--dry-run", "--provider", "foo", "--model", "bar"]); + expect(opts.dryRun).toBe(true); + expect(opts.provider).toBe("foo"); + expect(opts.model).toBe("bar"); + }); + + test("agent run without --provider throws", () => { + expect(() => parseArgs(["--model", "bar"])).toThrow(/--provider/); + }); + + test("agent run without --model throws", () => { + expect(() => parseArgs(["--provider", "foo"])).toThrow(/--model/); + }); + + test("agent run without either flag throws naming both", () => { + expect(() => parseArgs([])).toThrow(/--provider/); + expect(() => parseArgs([])).toThrow(/--model/); + }); + + test("--provider foo --model bar parses those values", () => { + const opts = parseArgs(["--provider", "foo", "--model", "bar"]); + expect(opts.provider).toBe("foo"); + expect(opts.model).toBe("bar"); + }); + + test("parsed defaults never equal xai/thegreataxios", () => { + const help = parseArgs(["--help"]); + expect(help.provider).not.toBe("xai/thegreataxios"); + expect(help.model).not.toBe("xai/thegreataxios"); + }); +}); diff --git a/scripts/eval-public-swe-one.ts b/scripts/eval-public-swe-one.ts new file mode 100644 index 000000000..89825ddcf --- /dev/null +++ b/scripts/eval-public-swe-one.ts @@ -0,0 +1,482 @@ +#!/usr/bin/env bun +/** + * One-shot public SWE-bench smoke: Corbits as the agent on a single Lite instance. + * + * Intentionally narrow: + * - provider/model come from required --provider / --model CLI flags + * - host-side agent run (product exec path), not a full SWE Docker fleet + * - captures a git patch + trajectory report for later official eval + * + * Usage: + * bun scripts/eval-public-swe-one.ts --provider <name> --model <id> + * bun scripts/eval-public-swe-one.ts --instance psf__requests-3362 --provider <name> --model <id> + * bun scripts/eval-public-swe-one.ts --dry-run --provider <name> --model <id> + * + * Optional official grading (heavy; needs Docker resources): + * bun scripts/eval-public-swe-one.ts --instance … --provider <name> --model <id> --evaluate + */ + +import { mkdir, writeFile, readFile, mkdtemp, rm, cp } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join, dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadConfig } from "../src/config/index.js"; +import { runExec } from "../src/exec/runner.js"; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const DEFAULT_INSTANCE = "psf__requests-3362"; +const DEFAULT_SUBSET = "princeton-nlp/SWE-bench_Lite"; +const DEFAULT_SPLIT = "test"; +const DEFAULT_AGENT_TIMEOUT_MS = 1_800_000; // 30m — real SWE tasks thrash + +type CliOptions = { + provider: string; + model: string; + instanceId: string; + subset: string; + split: string; + agentTimeoutMs: number; + evaluate: boolean; + dryRun: boolean; + outDir: string; + help: boolean; +}; + +type SweInstance = { + instance_id: string; + repo: string; + base_commit: string; + problem_statement: string; + FAIL_TO_PASS: string; + PASS_TO_PASS: string; + version?: string; + patch?: string; + test_patch?: string; +}; + +function printHelp(): void { + console.log(`Usage: bun scripts/eval-public-swe-one.ts --provider <name> --model <id> [options] + +One public SWE-bench Lite instance via Corbits product exec. + +Options: + --instance <id> SWE-bench instance_id (default: ${DEFAULT_INSTANCE}) + --provider <name> Provider name (required except --help) + --model <id> Model id (required except --help) + --subset <hf> HF dataset id (default: ${DEFAULT_SUBSET}) + --split <name> Dataset split (default: ${DEFAULT_SPLIT}) + --timeout-ms <n> Agent wall-clock timeout (default: ${DEFAULT_AGENT_TIMEOUT_MS}) + --out <dir> Results directory (default: evals/public/results/<run-id>) + --evaluate After the agent, attempt official SWE-bench Docker eval (heavy) + --dry-run Load instance + print plan; do not clone or run the agent + -h, --help Show this help +`); +} + +export function parseArgs(argv: string[]): CliOptions { + const opts: CliOptions = { + provider: "", + model: "", + instanceId: DEFAULT_INSTANCE, + subset: DEFAULT_SUBSET, + split: DEFAULT_SPLIT, + agentTimeoutMs: DEFAULT_AGENT_TIMEOUT_MS, + evaluate: false, + dryRun: false, + outDir: "", + help: false, + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]!; + const next = () => { + const v = argv[++i]; + if (v === undefined) throw new Error(`missing value for ${a}`); + return v; + }; + switch (a) { + case "-h": + case "--help": + opts.help = true; + break; + case "--instance": + opts.instanceId = next(); + break; + case "--provider": + opts.provider = next(); + break; + case "--model": + opts.model = next(); + break; + case "--subset": + opts.subset = next(); + break; + case "--split": + opts.split = next(); + break; + case "--timeout-ms": + opts.agentTimeoutMs = Number(next()); + if (!Number.isFinite(opts.agentTimeoutMs) || opts.agentTimeoutMs <= 0) { + throw new Error("--timeout-ms must be a positive number"); + } + break; + case "--out": + opts.outDir = next(); + break; + case "--evaluate": + opts.evaluate = true; + break; + case "--dry-run": + opts.dryRun = true; + break; + default: + throw new Error(`unknown arg: ${a}`); + } + } + if (!opts.help) { + if (!opts.provider && !opts.model) { + throw new Error("missing required --provider and --model"); + } + if (!opts.provider) { + throw new Error("missing required --provider"); + } + if (!opts.model) { + throw new Error("missing required --model"); + } + } + return opts; +} + +function run( + cmd: string, + args: string[], + opts: { cwd?: string; timeoutMs?: number; env?: NodeJS.ProcessEnv } = {}, +): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise((resolvePromise, reject) => { + const child = spawn(cmd, args, { + cwd: opts.cwd, + env: opts.env ?? process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (b: Buffer) => { + stdout += b.toString("utf8"); + }); + child.stderr.on("data", (b: Buffer) => { + stderr += b.toString("utf8"); + }); + const timer = + opts.timeoutMs !== undefined + ? setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(`timeout after ${opts.timeoutMs}ms: ${cmd} ${args.join(" ")}`)); + }, opts.timeoutMs) + : null; + child.on("error", (err) => { + if (timer) clearTimeout(timer); + reject(err); + }); + child.on("close", (code) => { + if (timer) clearTimeout(timer); + resolvePromise({ code: code ?? 1, stdout, stderr }); + }); + }); +} + +async function loadInstance(opts: CliOptions): Promise<SweInstance> { + const py = ` +import json, sys +from datasets import load_dataset +ds = load_dataset(${JSON.stringify(opts.subset)}, split=${JSON.stringify(opts.split)}) +want = ${JSON.stringify(opts.instanceId)} +row = None +for r in ds: + if r["instance_id"] == want: + row = r + break +if row is None: + # allow numeric index + try: + idx = int(want) + row = ds[idx] + except Exception: + pass +if row is None: + sys.stderr.write(f"instance not found: {want}\\n") + sys.exit(2) +keys = ["instance_id","repo","base_commit","problem_statement","FAIL_TO_PASS","PASS_TO_PASS","version","patch","test_patch"] +out = {} +for k in keys: + if k in row: + v = row[k] + out[k] = v if isinstance(v, str) else json.dumps(v) +print(json.dumps(out)) +`; + const result = await run("uv", ["run", "--with", "datasets", "python", "-c", py], { + timeoutMs: 180_000, + }); + if (result.code !== 0) { + throw new Error(`failed to load instance:\n${result.stderr || result.stdout}`); + } + return JSON.parse(result.stdout.trim()) as SweInstance; +} + +async function prepareRepo(instance: SweInstance, workRoot: string): Promise<string> { + const repoDir = join(workRoot, "repo"); + const url = `https://github.com/${instance.repo}.git`; + console.log(`cloning ${url} …`); + // Prefer a full clone so older base_commits resolve without partial-fetch pain. + // requests/flask are small enough that this is cheap. + const clone = await run("git", ["clone", url, repoDir], { + timeoutMs: 600_000, + }); + if (clone.code !== 0) { + throw new Error(`git clone failed:\n${clone.stderr || clone.stdout}`); + } + const co = await run("git", ["checkout", "--force", instance.base_commit], { + cwd: repoDir, + timeoutMs: 120_000, + }); + if (co.code !== 0) { + // try fetch then checkout + await run("git", ["fetch", "--depth", "1", "origin", instance.base_commit], { + cwd: repoDir, + timeoutMs: 300_000, + }); + const co2 = await run("git", ["checkout", "--force", instance.base_commit], { + cwd: repoDir, + timeoutMs: 120_000, + }); + if (co2.code !== 0) { + throw new Error(`git checkout ${instance.base_commit} failed:\n${co2.stderr || co2.stdout}`); + } + } + // Detach cleanly; agent may commit. + await run("git", ["checkout", "--detach", "HEAD"], { cwd: repoDir }); + return repoDir; +} + +function buildPrompt(instance: SweInstance): string { + return [ + "You are fixing a real open-source GitHub issue (SWE-bench style).", + "The repository is already checked out at the buggy base commit in the current working directory.", + "", + `Instance: ${instance.instance_id}`, + `Repo: ${instance.repo}`, + "", + "## Issue", + instance.problem_statement.trim(), + "", + "## Constraints", + "- Implement a minimal correct fix for the issue.", + "- Do not rewrite unrelated code.", + "- Prefer the project's existing style and tests.", + "- You may run the project's tests to verify.", + "- Leave the fix as a normal git working-tree change (commit optional).", + "- Do not change remotes or force-push.", + "", + "When done, stop. The harness will capture `git diff` against the base commit.", + ].join("\n"); +} + +async function capturePatch(repoDir: string, baseCommit: string): Promise<string> { + // Stage everything, then diff the index tree against the SWE base commit so + // we include new files and agent commits without depending on HEAD movement. + await run("git", ["add", "-A"], { cwd: repoDir }); + const tree = await run("git", ["write-tree"], { cwd: repoDir }); + if (tree.code !== 0) { + throw new Error(`git write-tree failed:\n${tree.stderr || tree.stdout}`); + } + const treeSha = tree.stdout.trim(); + const diff = await run("git", ["diff", "--binary", baseCommit, treeSha], { cwd: repoDir }); + if (diff.code !== 0) { + throw new Error(`git diff ${baseCommit}..${treeSha} failed:\n${diff.stderr || diff.stdout}`); + } + return diff.stdout; +} + +async function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> { + let timer: ReturnType<typeof setTimeout> | undefined; + try { + return await Promise.race([ + p, + new Promise<T>((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +async function main(): Promise<void> { + const opts = parseArgs(process.argv.slice(2)); + if (opts.help) { + printHelp(); + process.exit(0); + } + + const runId = new Date().toISOString().replace(/[:.]/g, "-"); + const outDir = + opts.outDir.length > 0 + ? resolve(opts.outDir) + : join(REPO_ROOT, "evals/public/results", `${opts.instanceId}-${runId}`); + await mkdir(outDir, { recursive: true }); + + console.log("=== public SWE-bench one-shot ==="); + console.log(`provider/model: ${opts.provider} / ${opts.model}`); + console.log(`instance: ${opts.instanceId}`); + console.log(`subset: ${opts.subset} @ ${opts.split}`); + console.log(`out: ${outDir}`); + + const instance = await loadInstance(opts); + await writeFile(join(outDir, "instance.json"), JSON.stringify(instance, null, 2)); + console.log(`loaded ${instance.instance_id} (${instance.repo} @ ${instance.base_commit.slice(0, 12)})`); + + const prompt = buildPrompt(instance); + await writeFile(join(outDir, "prompt.txt"), prompt); + + if (opts.dryRun) { + console.log("dry-run: skipping clone + agent"); + console.log("--- prompt preview ---"); + console.log(prompt.slice(0, 800)); + console.log("---"); + process.exit(0); + } + + const workRoot = await mkdtemp(join(tmpdir(), "corbits-swe-")); + let repoDir: string | null = null; + try { + repoDir = await prepareRepo(instance, workRoot); + console.log(`workdir: ${repoDir}`); + + const argv = [ + "exec", + "--cwd", + repoDir, + "--provider", + opts.provider, + "--model", + opts.model, + "--dangerously-skip-permissions", + "--force", + prompt, + ]; + + const config = await loadConfig(argv, { allowUnconfigured: false }); + if (!config.configured) { + throw new Error( + `Provider not configured for --provider ${opts.provider} --model ${opts.model}`, + ); + } + const resolvedProvider = config.providerName; + const resolvedModel = config.model; + console.log(`resolved: ${resolvedProvider} / ${resolvedModel}`); + if (resolvedProvider !== opts.provider || resolvedModel !== opts.model) { + throw new Error( + `provider/model mismatch: requested ${opts.provider}/${opts.model} ` + + `but resolved ${resolvedProvider}/${resolvedModel}`, + ); + } + + console.log("running Corbits agent …"); + const started = Date.now(); + const execResult = await withTimeout( + runExec(config), + opts.agentTimeoutMs, + `agent (${instance.instance_id})`, + ); + const durationMs = execResult.durationMs ?? Date.now() - started; + console.log( + `agent done exit=${execResult.exitCode} turns=${execResult.turnsUsed ?? "?"} ` + + `tools=${execResult.toolCallCount ?? "?"} ${durationMs}ms`, + ); + + const patch = await capturePatch(repoDir, instance.base_commit); + await writeFile(join(outDir, "prediction.patch"), patch); + const pred = { + [instance.instance_id]: { + model_name_or_path: `corbits+${opts.provider}+${opts.model}`, + model_patch: patch, + instance_id: instance.instance_id, + }, + }; + // SWE-bench preds.json is often a JSONL or dict; write both. + await writeFile(join(outDir, "preds.json"), JSON.stringify(pred, null, 2)); + await writeFile( + join(outDir, "preds.jsonl"), + JSON.stringify({ + instance_id: instance.instance_id, + model_name_or_path: `corbits+${opts.provider}+${opts.model}`, + model_patch: patch, + }) + "\n", + ); + + const report = { + kind: "public-swe-one", + instance_id: instance.instance_id, + repo: instance.repo, + base_commit: instance.base_commit, + provider: opts.provider, + model: opts.model, + resolvedProvider, + resolvedModel, + agentExitCode: execResult.exitCode, + turnsUsed: execResult.turnsUsed ?? null, + toolCallCount: execResult.toolCallCount ?? null, + durationMs, + patchBytes: Buffer.byteLength(patch, "utf8"), + patchEmpty: patch.trim().length === 0, + outDir, + evaluateRequested: opts.evaluate, + note: + "Patch captured from host-side Corbits run. Official resolved/not-resolved " + + "requires SWE-bench Docker eval (--evaluate or external harness).", + }; + await writeFile(join(outDir, "report.json"), JSON.stringify(report, null, 2)); + + // Keep a copy of the final tree for debugging (may be large — skip if huge). + console.log(`patch bytes: ${report.patchBytes}${report.patchEmpty ? " (EMPTY)" : ""}`); + console.log(`report: ${join(outDir, "report.json")}`); + + if (opts.evaluate) { + console.log( + "\n--evaluate: official SWE-bench Docker grading is resource-heavy " + + "(docs recommend ≥120GB disk, 16GB RAM; this Docker Desktop may be under-provisioned). " + + "Not auto-invoked in v0 — run the SWE-bench harness against preds.jsonl manually.", + ); + await writeFile( + join(outDir, "EVALUATE.md"), + [ + "# Official eval (manual)", + "", + "Predictions:", + `- \`${join(outDir, "preds.jsonl")}\``, + "", + "Use the SWE-bench harness / mini-SWE-agent eval path against this prediction.", + "Ensure Docker has enough CPU/RAM/disk first.", + "", + ].join("\n"), + ); + } + + console.log("\n=== done ==="); + console.log(JSON.stringify(report, null, 2)); + process.exit(report.patchEmpty || execResult.exitCode !== 0 ? 1 : 0); + } finally { + // Leave workRoot for forensics when agent fails? Clean to save disk. + try { + await rm(workRoot, { recursive: true, force: true }); + } catch { + // ignore + } + } +} + +if (import.meta.main) { + main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(2); + }); +} diff --git a/scripts/release.sh b/scripts/release.sh index e06dbca16..a01f353cf 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -173,6 +173,9 @@ build_deb() { # build_deb BINARY DEB-ARCH OUTPUT.deb mkdir -p "$wd/data/usr/bin" "$wd/data/usr/share/doc/$FORMULA" "$wd/ctrl" install -m 0755 "$bin" "$wd/data/usr/bin/$FORMULA" for f in "${DOC_FILES[@]}"; do cp "$ROOT/$f" "$wd/data/usr/share/doc/$FORMULA/"; done + if [ -d "$ROOT/plugins" ]; then + cp -R "$ROOT/plugins" "$wd/data/usr/bin/plugins" + fi local kb; kb=$(( ( $(wc -c < "$bin") + 1023 ) / 1024 )) cat > "$wd/ctrl/control" <<EOF Package: $FORMULA @@ -328,6 +331,10 @@ for entry in "${TARGETS[@]}"; do rm -rf "$STAGE/$pkg"; mkdir -p "$STAGE/$pkg" cp "$bin" "$STAGE/$pkg/$FORMULA"; chmod 755 "$STAGE/$pkg/$FORMULA" for f in "${DOC_FILES[@]}"; do cp "$ROOT/$f" "$STAGE/$pkg/"; done + # First-party plugins sit next to the binary (discoverRepoPlugins execPath). + if [ -d "$ROOT/plugins" ]; then + cp -R "$ROOT/plugins" "$STAGE/$pkg/plugins" + fi tar -C "$STAGE" -czf "$tarball" "$pkg" ( cd "$STAGE" && shasum -a 256 "$pkg.tar.gz" > "$pkg.tar.gz.sha256" ) rm -rf "$STAGE/$pkg" @@ -439,6 +446,10 @@ class $class < Formula def install bin.install "$BINARY" + if File.directory?("plugins") + (bin/"plugins").mkpath + cp_r "plugins/.", bin/"plugins" + end end test do diff --git a/src/agent/default-agents.ts b/src/agent/default-agents.ts index af496019b..c688568d4 100644 --- a/src/agent/default-agents.ts +++ b/src/agent/default-agents.ts @@ -1,28 +1,9 @@ +import { directorProfiles } from "./directors/registry.js"; import type { AgentPlugin } from "./profile-types.js"; -// Default agent profiles shipped with corbits. These are the sub-agents -// referenced by the built-in workflows. Repositories can override any of -// these by placing a same-id profile in .agents/agents/. +// Spawnable profiles = closed director fleet minus primary skywalker. +// Repositories can override any id via .agents/agents/ or agent-kind +// plugins (higher precedence). export const defaultAgentsPlugin: AgentPlugin = { - agents: [ - { - id: "greybeard", - description: "Seasoned architect — reviews for design, constraint ownership, and backwards compatibility", - systemPromptRole: - "You are a seasoned software architect with decades of experience. " + - "You review code and designs for architectural soundness, constraint ownership " + - "(every invariant belongs in exactly one layer), backwards compatibility " + - "implications, and correctness. You are direct and specific — you name the " + - "exact file, line, and rule being violated. You do not fix things; you find them.", - }, - { - id: "critique", - description: "Code quality reviewer — tests assumptions, finds edge cases and security smells", - systemPromptRole: - "You are a critical code reviewer focused on code quality, test coverage, " + - "edge cases, and security-adjacent issues. You challenge assumptions, look for " + - "missing error handling, identify untested paths, and flag anything that would " + - "surprise a future maintainer. You do not fix things; you find them.", - }, - ], + agents: directorProfiles(), }; diff --git a/src/agent/director.test.ts b/src/agent/director.test.ts index 8f6ea2624..21356fd1e 100644 --- a/src/agent/director.test.ts +++ b/src/agent/director.test.ts @@ -7,6 +7,7 @@ import type { ReactorState, } from "@intx/types/runtime"; import { createChatDirector } from "./director.js"; +import { forcedStopReport } from "../subagent/stop-policy.js"; import { OPERATOR_ORIGINATED_FLAG } from "./message-provenance.js"; import { buildCompactionContinuationMessage as tuiCompactionContinuation } from "../tui/runner.js"; import { buildCompactionContinuationMessage as execCompactionContinuation } from "../exec/runner.js"; @@ -741,4 +742,47 @@ describe("ChatDirector tool-only loop protection", () => { expect(actions.some((a) => a.type === "reply")).toBe(false); expect(actions.some((a) => a.type === "infer")).toBe(true); }); + + test("after a hard-block salvage, Skywalker is nudged once and unique reads do not pause", async () => { + const director = createChatDirector("system", [], { + onTasksChange: () => {}, + provider: providerlessPolicy, + }); + const capabilities = makeCapabilities(); + const salvage = forcedStopReport("no-ship", "mapped the tree, never edited"); + await director.decide( + { + type: "inference.done", + turn: { + role: "assistant", + model: "test", + timestamp: 0, + content: [{ type: "tool_call", id: "task-1", name: "task", arguments: {} }], + }, + usage: { input: 0, output: 0 }, + source: "test", + } as unknown as ReactorInboundEvent, + mockState, + capabilities, + ); + const afterSalvage = actionsArray( + await director.decide( + { + type: "tool.done", + result: { callId: "task-1", content: salvage }, + } as unknown as ReactorInboundEvent, + mockState, + capabilities, + ), + ); + expect(ephemeralText(afterSalvage.find((a) => a.type === "infer"))).toContain( + "stopped without finishing", + ); + + const later = await runToolOnlyStreak(director, capabilities, 20, toolOnlyTurn); + expect(later.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))).toBe( + false, + ); + expect(later.some((a) => a.type === "infer")).toBe(true); + }); }); diff --git a/src/agent/director.ts b/src/agent/director.ts index 40a4a5bfc..cd2e10817 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -32,6 +32,11 @@ import { } from "../subagent/stop-policy.js"; import { PRESENT_VIEW_PRIMITIVES_GUIDANCE } from "./tool-schema-normalize.js"; import { isOperatorOriginated } from "./message-provenance.js"; +import { + classifyBriefSalvage, + isHardBlockSalvage, +} from "../subagent/brief-dispatch.js"; +import { PRIMARY_SALVAGE_NUDGE } from "./look-tour.js"; const RETRY_POLICY = createCorbitsRetryPolicy(); @@ -425,6 +430,10 @@ class ChatDirectorImpl extends DefaultDirector { // unheeded for a further full interval with no user message). Drives the // pause message wording so the two are distinguishable. private toolOnlyPauseReason: "thrash" | "backstop" | null = null; + // One-shot nudge after a hard-block worker salvage. Not a look-count quota. + private salvageNudgeFired = false; + private pendingSalvageNudge: string | null = null; + private pendingTaskCallIds = new Set<string>(); constructor(systemPrompt: string, toolDefinitions: ToolDefinition[], options: ChatDirectorImplOptions) { super(systemPrompt, toolDefinitions, {}); @@ -547,6 +556,26 @@ class ChatDirectorImpl extends DefaultDirector { return rewritten; } + /** + * One-shot salvage nudge after a worker hard-block. Fingerprint thrash + * (applyToolOnlyLoopProtection) wins when both apply. Attaches to the infer + * after pending tools have executed. + */ + private applySalvageNudge( + actions: ReactorAction[], + capabilities: ReactorCapabilities, + ): ReactorAction[] | null { + if (this.pendingSalvageNudge === null) return null; + const inferIndex = actions.findIndex((a) => a.type === "infer"); + if (inferIndex === -1) return null; + const text = this.pendingSalvageNudge; + this.pendingSalvageNudge = null; + const rewritten = [...actions]; + const existing = actions[inferIndex] as Extract<ReactorAction, { type: "infer" }>; + rewritten[inferIndex] = inferWithNudge(capabilities, text, existing.options); + return rewritten; + } + private withCurrentTools( result: ReactorAction | ReactorAction[], ): ReactorAction | ReactorAction[] { @@ -644,6 +673,9 @@ class ChatDirectorImpl extends DefaultDirector { this.turnsSinceUserMessage = 0; this.backstopNudgeFiredAtTurn = null; this.pendingBackstopNudge = false; + this.salvageNudgeFired = false; + this.pendingSalvageNudge = null; + this.pendingTaskCallIds.clear(); } } if (onTurnBoundary(event)) this.inferenceRecoveries = 0; @@ -719,6 +751,12 @@ class ChatDirectorImpl extends DefaultDirector { // reset the cycle-detection side because only text turns and fresh // messages do). this.turnsSinceUserMessage++; + const turnContent = event.turn.content as ReadonlyArray<{ type: string; name?: string; id?: string }>; + for (const block of turnContent) { + if (block.type === "tool_call" && block.name === "task" && typeof block.id === "string") { + this.pendingTaskCallIds.add(block.id); + } + } if (hasToolCalls && !hasText) { this.toolOnlyStreak++; const fingerprint = fingerprintToolCalls(event.turn.content); @@ -804,6 +842,17 @@ class ChatDirectorImpl extends DefaultDirector { } } + if (event.type === "tool.done" && this.pendingTaskCallIds.has(event.result.callId)) { + this.pendingTaskCallIds.delete(event.result.callId); + const body = + typeof event.result.content === "string" ? event.result.content : ""; + const salvage = classifyBriefSalvage(body); + if (salvage !== null && isHardBlockSalvage(salvage) && !this.salvageNudgeFired) { + this.salvageNudgeFired = true; + this.pendingSalvageNudge = PRIMARY_SALVAGE_NUDGE; + } + } + if (event.type === "tool.done" && this.workflowCalls.has(event.result.callId)) { const call = this.workflowCalls.get(event.result.callId); this.workflowCalls.delete(event.result.callId); @@ -870,6 +919,8 @@ class ChatDirectorImpl extends DefaultDirector { // wiring in src/subagent/index.ts). const toolOnlyRewrite = this.applyToolOnlyLoopProtection(baseActions, capabilities); if (toolOnlyRewrite !== null) return toolOnlyRewrite; + const lookRewrite = this.applySalvageNudge(baseActions, capabilities); + if (lookRewrite !== null) return lookRewrite; const coordinator = this.workflowCoordinator; if (coordinator?.isActive() && !coordinator.currentStepIsGate()) { diff --git a/src/agent/directors/brand-reviewer/index.ts b/src/agent/directors/brand-reviewer/index.ts new file mode 100644 index 000000000..728fcb26d --- /dev/null +++ b/src/agent/directors/brand-reviewer/index.ts @@ -0,0 +1 @@ +export { brandReviewerPackage } from "./package.js"; diff --git a/src/agent/directors/brand-reviewer/package.test.ts b/src/agent/directors/brand-reviewer/package.test.ts new file mode 100644 index 000000000..6d59124bd --- /dev/null +++ b/src/agent/directors/brand-reviewer/package.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import { brandReviewerPackage } from "./package.js"; + +describe("brandReviewerPackage", () => { + test("id matches directory", () => { + expect(brandReviewerPackage.id).toBe("brand-reviewer"); + }); + + test("systemPrompt is real, not a placeholder", () => { + expect(brandReviewerPackage.systemPrompt.length).toBeGreaterThan(0); + expect(brandReviewerPackage.systemPrompt.startsWith("Placeholder")).toBe(false); + }); + + test("systemPrompt states PRIMARY INTENT", () => { + expect(brandReviewerPackage.systemPrompt).toMatch(/PRIMARY INTENT/i); + }); + + test("spawn.maySpawn is false", () => { + expect(brandReviewerPackage.spawn.maySpawn).toBe(false); + }); + + test("tools.allow includes write tools; writePaths is omitted", () => { + const allow = brandReviewerPackage.tools?.allow ?? []; + expect(allow).toContain("write_file"); + expect(allow).toContain("edit_file"); + expect(brandReviewerPackage.writePaths).toBeUndefined(); + }); + + test("systemPrompt mentions DESIGN.md", () => { + expect(brandReviewerPackage.systemPrompt).toMatch(/DESIGN\.md/); + expect(brandReviewerPackage.systemPrompt).not.toMatch(/authz/i); + }); + + test("report.requiredSections covers the leaf envelope", () => { + const sections = brandReviewerPackage.report.requiredSections; + expect(sections).toContain("Summary"); + expect(sections).toContain("Findings"); + expect(sections).toContain("Blockers"); + expect(sections).toContain("Paths"); + }); + + test("modelRole is docs", () => { + expect(brandReviewerPackage.modelRole).toBe("docs"); + }); + + test("primaryIntent and outOfLane match brand-reviewer lane", () => { + expect(brandReviewerPackage.primaryIntent).toBe("Own DESIGN.md create/use + brand gate"); + expect(brandReviewerPackage.outOfLane).toContain("arbitrary product code outside DESIGN.md"); + }); + + test("nudge maxTurns is 40", () => { + expect(brandReviewerPackage.nudge?.maxTurns).toBe(40); + }); +}); diff --git a/src/agent/directors/brand-reviewer/package.ts b/src/agent/directors/brand-reviewer/package.ts new file mode 100644 index 000000000..44e045bef --- /dev/null +++ b/src/agent/directors/brand-reviewer/package.ts @@ -0,0 +1,69 @@ +import type { DirectorPackage } from "../types.js"; +import { DOCS_TOOLS } from "../tool-sets.js"; + +/** + * Brand Reviewer — owns DESIGN.md create/use + brand consistency gate for UI. CL-5829. + */ +export const brandReviewerPackage: DirectorPackage = { + id: "brand-reviewer", + primaryIntent: "Own DESIGN.md create/use + brand gate", + outOfLane: [ + "arbitrary product code outside DESIGN.md", + "shipping product features", + "marketing publish pipeline", + "architecture gates", + ], + description: "DESIGN.md brand gate", + tools: { allow: DOCS_TOOLS }, + spawn: { maySpawn: false }, + nudge: { maxTurns: 40 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + modelRole: "docs", + systemPrompt: `You are BrandReviewerDirector, a specialist in Corbits Code. + +PRIMARY INTENT: own DESIGN.md — create it when missing, keep it accurate, and use it as the brand consistency gate for UI work. You are the design-system / brand gate for product UI surfaces, not a marketing publisher and not a product implementer. + +Write tools are mounted with no path lock. Stay on the DESIGN.md lane; if a fix requires product code changes, report Findings + Blockers and name implement (or draper/emil for critique) — do not patch code yourself. + +# What DESIGN.md is for + +A living product design contract: tokens, typography, spacing, motion, component rules, voice of UI strings, do/don't, and links to brand references. Prefer short, agent-usable rules over essays. + +# Gate workflow + +For every UI / design brief: + +1. **Load DESIGN.md** — if absent, draft a minimal DESIGN.md from available brand/UI sources and state what you created. +2. **Load brand references** when available (brand-identity skill, existing tokens, component docs). +3. **Check the work** against DESIGN.md + brand rules: + - Visual: color, type, space, logos, density + - Interaction: motion, hit targets, states, focus + - Naming/UI copy consistency with DESIGN.md + - Drift: implementation that contradicts DESIGN.md +4. **Verdict** — APPROVED / CHANGES REQUESTED / REJECTED +5. **Update DESIGN.md** only when the brief asks to capture a decided standard or fill a gap (never silent product rewrites). + +# Verdict shape (inside Findings) + +- **APPROVED** — matches DESIGN.md / brand rules; ships as-is for brand gate. +- **CHANGES REQUESTED** — specific gaps with Expected vs Actual citations. +- **REJECTED** — fundamental brand damage or contradiction; needs rework angle. + +OUT OF LANE: implementing components, marketing content publish, architecture sign-off, general code review. Reclassify via Blockers. + +# Report + +## Summary +Gate verdict, DESIGN.md status (created / updated / unchanged), critical gaps. + +## Findings +Checklist results, required changes, DESIGN.md diffs or sections touched. + +## Blockers +Missing brand sources, ambiguous scope, product-code asks. + +## Paths +DESIGN.md path and UI files reviewed. + +Never spawn. Never commit. Stay on the DESIGN.md lane.`, +}; diff --git a/src/agent/directors/bruckheimer/index.ts b/src/agent/directors/bruckheimer/index.ts new file mode 100644 index 000000000..51b3fb6e7 --- /dev/null +++ b/src/agent/directors/bruckheimer/index.ts @@ -0,0 +1 @@ +export { bruckheimerPackage } from "./package.js"; diff --git a/src/agent/directors/bruckheimer/package.test.ts b/src/agent/directors/bruckheimer/package.test.ts new file mode 100644 index 000000000..c6c2a8d79 --- /dev/null +++ b/src/agent/directors/bruckheimer/package.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; +import { bruckheimerPackage } from "./package.js"; + +describe("bruckheimerPackage", () => { + test("id matches directory", () => { + expect(bruckheimerPackage.id).toBe("bruckheimer"); + }); + + test("systemPrompt is real (not Placeholder)", () => { + expect(bruckheimerPackage.systemPrompt.length).toBeGreaterThan(0); + expect(bruckheimerPackage.systemPrompt.startsWith("Placeholder")).toBe(false); + }); + + test("systemPrompt states PRIMARY INTENT", () => { + expect(bruckheimerPackage.systemPrompt).toMatch(/PRIMARY INTENT/i); + }); + + test("spawn.maySpawn is false", () => { + expect(bruckheimerPackage.spawn.maySpawn).toBe(false); + }); + + test("tools.allow includes write tools; writePaths is omitted", () => { + const allow = bruckheimerPackage.tools?.allow ?? []; + expect(allow).toContain("write_file"); + expect(allow).toContain("edit_file"); + expect(bruckheimerPackage.writePaths).toBeUndefined(); + }); + + test("report requires envelope sections", () => { + for (const section of ["Summary", "Findings", "Blockers", "Paths"]) { + expect(bruckheimerPackage.report.requiredSections).toContain(section); + } + }); + + test("modelRole is docs", () => { + expect(bruckheimerPackage.modelRole).toBe("docs"); + }); + + test("primaryIntent and outOfLane match discovery lane", () => { + expect(bruckheimerPackage.primaryIntent).toMatch(/product discovery/i); + expect(bruckheimerPackage.outOfLane).toContain("shipping product code"); + expect(bruckheimerPackage.outOfLane).toContain("architecture gates"); + }); + + test("nudge maxTurns is 40", () => { + expect(bruckheimerPackage.nudge?.maxTurns).toBe(40); + }); +}); diff --git a/src/agent/directors/bruckheimer/package.ts b/src/agent/directors/bruckheimer/package.ts new file mode 100644 index 000000000..4fa3bc89a --- /dev/null +++ b/src/agent/directors/bruckheimer/package.ts @@ -0,0 +1,34 @@ +import type { DirectorPackage } from "../types.js"; +import { DOCS_TOOLS } from "../tool-sets.js"; + +/** + * Product discovery specialist (CL-5824). + */ +export const bruckheimerPackage: DirectorPackage = { + id: "bruckheimer", + primaryIntent: "Product discovery docs — invent/capture product shape; do not implement", + outOfLane: [ + "shipping product code", + "architecture gates", + "feature implementation", + "hard merge blockers as Greybeard", + "running the fleet", + ], + description: "Product discovery specialist — user/product shape docs, not code", + tools: { allow: DOCS_TOOLS }, + spawn: { maySpawn: false }, + nudge: { maxTurns: 40 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + modelRole: "docs", + systemPrompt: `You are BruckheimerDirector, a specialist in Corbits Code. + +PRIMARY INTENT: product discovery documentation. Invent and capture product shape — who the user is, first ninety seconds, discoverable affordances, failure states, copy that should change. + +Write tools are mounted with no path lock. Stay on the product-discovery lane. You are not an implementer. You are not the architecture gate (that is Greybeard). You do not ship features or product code. + +Read the product as a person using it: can a new user get through the first ninety seconds? Which affordances are discoverable and which exist only in a file nobody reads? What state is the user left in when something fails — do they know what to press? Name specific strings and surfaces that should change. + +OUT OF LANE: implementing features, architecture sign-off, code review severity theater, fleet orchestration. Route those via Blockers to implement, greybeard, critique, or skywalker. + +Report: Summary, Findings (product shape + discovery), Blockers, Paths.`, +}; diff --git a/src/agent/directors/critique/index.ts b/src/agent/directors/critique/index.ts new file mode 100644 index 000000000..bf08c0413 --- /dev/null +++ b/src/agent/directors/critique/index.ts @@ -0,0 +1 @@ +export { critiquePackage } from "./package.js"; diff --git a/src/agent/directors/critique/package.test.ts b/src/agent/directors/critique/package.test.ts new file mode 100644 index 000000000..6ed02370c --- /dev/null +++ b/src/agent/directors/critique/package.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test"; +import { critiquePackage } from "./package.js"; + +describe("critiquePackage", () => { + test("id matches directory", () => { + expect(critiquePackage.id).toBe("critique"); + }); + + test("systemPrompt is real, not a placeholder", () => { + expect(critiquePackage.systemPrompt.length).toBeGreaterThan(0); + expect(critiquePackage.systemPrompt.startsWith("Placeholder")).toBe(false); + }); + + test("systemPrompt states PRIMARY INTENT", () => { + expect(critiquePackage.systemPrompt).toMatch(/PRIMARY INTENT/i); + }); + + test("systemPrompt is evidence-based and never-fix", () => { + expect(critiquePackage.systemPrompt).toMatch(/evidence-based/i); + expect(critiquePackage.systemPrompt).toMatch(/never fix/i); + expect(critiquePackage.systemPrompt).toMatch(/permanent tests/i); + }); + + test("systemPrompt is correctness-only / anti-over-engineering", () => { + expect(critiquePackage.systemPrompt).toMatch(/correctness-only/i); + expect(critiquePackage.systemPrompt).toMatch(/anti-over-engineering/i); + expect(critiquePackage.systemPrompt).toMatch( + /correctness or the stated requirements\/success_criteria/i, + ); + expect(critiquePackage.systemPrompt).toMatch(/style nits/i); + expect(critiquePackage.systemPrompt).toMatch(/file-for-later/i); + expect(critiquePackage.systemPrompt).toMatch(/Do not drive over-engineering/i); + expect(critiquePackage.systemPrompt).toMatch(/impossible cases/i); + }); + + test("systemPrompt flags API contract / sync→async as blocking", () => { + expect(critiquePackage.systemPrompt).toMatch(/API contract check/i); + expect(critiquePackage.systemPrompt).toMatch( + /blocking when brief specifies signatures/i, + ); + expect(critiquePackage.systemPrompt).toMatch(/public exports/i); + expect(critiquePackage.systemPrompt).toMatch(/Sync\s*→\s*async/i); + expect(critiquePackage.systemPrompt).toMatch( + /returning Promise when callers expect a plain value/i, + ); + expect(critiquePackage.systemPrompt).toMatch( + /blocking correctness defect/i, + ); + expect(critiquePackage.systemPrompt).toMatch( + /parameter order\/optionality\/return-type drift/i, + ); + expect(critiquePackage.systemPrompt).toMatch( + /Rank these as blocking, not style nits/i, + ); + }); + + test("spawn.maySpawn is false", () => { + expect(critiquePackage.spawn.maySpawn).toBe(false); + }); + + test("tools.allow is review surface without product writes", () => { + const allow = critiquePackage.tools?.allow ?? []; + expect(allow).toContain("read_file"); + expect(allow).toContain("read_file"); + expect(allow).not.toContain("use_skill"); + expect(allow).not.toContain("write_file"); + expect(allow).not.toContain("edit_file"); + expect(allow).not.toContain("delete_file"); + }); + + test("report.requiredSections covers the leaf envelope", () => { + const sections = critiquePackage.report.requiredSections; + expect(sections).toContain("Summary"); + expect(sections).toContain("Findings"); + expect(sections).toContain("Blockers"); + expect(sections).toContain("Paths"); + }); + + test("modelRole is review", () => { + expect(critiquePackage.modelRole).toBe("review"); + }); + + test("optionalSkills order is style, philosophy", () => { + expect(critiquePackage.optionalSkills).toEqual(["style", "philosophy"]); + }); + + test("primaryIntent and outOfLane match critique lane", () => { + expect(critiquePackage.primaryIntent).toBe( + "Evidence-based code review; never fix product code", + ); + expect(critiquePackage.outOfLane).toContain("implementing fixes"); + expect(critiquePackage.outOfLane).toContain("architecture portfolio without code evidence"); + expect(critiquePackage.outOfLane).toContain("visual brand"); + expect(critiquePackage.outOfLane).toContain("DESIGN.md"); + expect(critiquePackage.outOfLane).toContain("pedantic fun without evidence"); + }); + + test("nudge maxTurns is 45", () => { + expect(critiquePackage.nudge?.maxTurns).toBe(45); + }); +}); diff --git a/src/agent/directors/critique/package.ts b/src/agent/directors/critique/package.ts new file mode 100644 index 000000000..f63164d31 --- /dev/null +++ b/src/agent/directors/critique/package.ts @@ -0,0 +1,61 @@ +import type { DirectorPackage } from "../types.js"; +import { REVIEW_TOOLS } from "../tool-sets.js"; + +/** + * Critique leaf (CL-5819). + * Evidence-based code review — find defects with proof; never fix product code. + */ +export const critiquePackage: DirectorPackage = { + id: "critique", + primaryIntent: "Evidence-based code review; never fix product code", + outOfLane: [ + "implementing fixes", + "architecture portfolio without code evidence", + "visual brand", + "DESIGN.md", + "pedantic fun without evidence", + ], + description: "Code quality review leaf", + optionalSkills: ["style", "philosophy"], + tools: { allow: REVIEW_TOOLS }, + spawn: { maySpawn: false }, + nudge: { maxTurns: 45 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + modelRole: "review", + systemPrompt: `You are CritiqueDirector, a specialist in Corbits Code. + +PRIMARY INTENT: evidence-based code review. Find defects; never fix product code. Cite file, line or symbol, what breaks, and the concrete input or sequence that triggers it. + +Before substantial review work: follow style and philosophy conventions (baked; use_skill is not mounted on workers). Read the code under review; do not invent defects from vibes. + +Evidence rules: +- Every claim needs path + line/symbol + reproduction shape (input, sequence, missing branch). +- Prefer grep/search_files/lsp/read_file over shell walks. Shell find/rg -r are blocked — do not work around. +- Rank findings: blocking, should-fix, file-for-later. "This is genuinely fine" is a valid finding when true. +- Call out gaps: what you did not cover so the parent does not assume closed. +- Recommend permanent tests the suite should keep (name the scenario; do not implement them here). + +Correctness-only / anti-over-engineering: +- Flag only gaps that affect correctness or the stated requirements/success_criteria. +- Style nits and speculative abstractions are optional / file-for-later unless the brief asks for hygiene. +- Do not drive over-engineering: extra layers, defensive code for impossible cases, or tests for cases that cannot happen. + +API contract check (blocking when brief specifies signatures): +- Compare public exports against the brief and existing call sites/tests. +- Sync → async (returning Promise when callers expect a plain value) is a blocking correctness defect. +- Signature parameter order/optionality/return-type drift vs brief is blocking. +- Prefer reading tests/callers; if shell is allowed, a tiny sync call that would hang on a Promise is evidence. +- Rank these as blocking, not style nits. + +Write tools are not mounted. Repro via read/shell only; recommend permanent tests for testsmith/implement. + +OUT OF LANE → refuse or reclassify under Blockers: +- implementing fixes (route to implement) +- architecture portfolio without code evidence (route to greybeard) +- visual brand / DESIGN.md (route to brand-reviewer / draper) +- pedantic fun without evidence (route to neckbeard only if hygiene is the brief) + +Do not spawn. Do not apply patches. Report only. + +Report: Summary, Findings, Blockers, Paths.`, +}; diff --git a/src/agent/directors/draper/index.ts b/src/agent/directors/draper/index.ts new file mode 100644 index 000000000..890f5b91e --- /dev/null +++ b/src/agent/directors/draper/index.ts @@ -0,0 +1 @@ +export { draperPackage } from "./package.js"; diff --git a/src/agent/directors/draper/package.test.ts b/src/agent/directors/draper/package.test.ts new file mode 100644 index 000000000..b4bc70b41 --- /dev/null +++ b/src/agent/directors/draper/package.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { draperPackage } from "./package.js"; + +describe("draperPackage", () => { + test("id matches directory", () => { + expect(draperPackage.id).toBe("draper"); + }); + + test("systemPrompt is real, not a placeholder", () => { + expect(draperPackage.systemPrompt.length).toBeGreaterThan(0); + expect(draperPackage.systemPrompt.startsWith("Placeholder")).toBe(false); + }); + + test("systemPrompt states PRIMARY INTENT", () => { + expect(draperPackage.systemPrompt).toMatch(/PRIMARY INTENT/i); + }); + + test("spawn.maySpawn is false", () => { + expect(draperPackage.spawn.maySpawn).toBe(false); + }); + + test("tools.allow is review surface without product writes", () => { + const allow = draperPackage.tools?.allow ?? []; + expect(allow).toContain("read_file"); + expect(allow).not.toContain("write_file"); + expect(allow).not.toContain("edit_file"); + expect(allow).not.toContain("delete_file"); + }); + + test("report.requiredSections covers the leaf envelope", () => { + const sections = draperPackage.report.requiredSections; + expect(sections).toContain("Summary"); + expect(sections).toContain("Findings"); + expect(sections).toContain("Blockers"); + expect(sections).toContain("Paths"); + }); + + test("modelRole is review", () => { + expect(draperPackage.modelRole).toBe("review"); + }); + + test("primaryIntent and outOfLane match draper lane", () => { + expect(draperPackage.primaryIntent).toBe( + "Product visual/CBS critique from a development perspective", + ); + expect(draperPackage.outOfLane).toContain("shipping product code"); + expect(draperPackage.outOfLane).toContain("marketing copy pipeline"); + }); + + test("nudge maxTurns is 40", () => { + expect(draperPackage.nudge?.maxTurns).toBe(40); + }); +}); diff --git a/src/agent/directors/draper/package.ts b/src/agent/directors/draper/package.ts new file mode 100644 index 000000000..08459d8c9 --- /dev/null +++ b/src/agent/directors/draper/package.ts @@ -0,0 +1,66 @@ +import type { DirectorPackage } from "../types.js"; +import { REVIEW_TOOLS } from "../tool-sets.js"; + +/** + * Draper — product visual / CBS critique (dev-scoped). CL-5830. + * Never ships product code; marketing copy pipeline is out of lane. + */ +export const draperPackage: DirectorPackage = { + id: "draper", + primaryIntent: "Product visual/CBS critique from a development perspective", + outOfLane: [ + "shipping product code", + "marketing copy pipeline", + "rewriting copy or redesigning", + "applying product fixes", + ], + description: "Visual/CBS critique leaf (dev-scoped)", + // Read-only critique — product write tools not mounted. + tools: { allow: REVIEW_TOOLS }, + spawn: { maySpawn: false }, + nudge: { maxTurns: 40 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + modelRole: "review", + systemPrompt: `You are DraperDirector, a specialist in Corbits Code. + +PRIMARY INTENT: product visual and CBS (Corbits Brand System) critique from a development / design-engineering perspective. Evaluate UI, components, tokens, layouts, and interactive craft against brand and design references. You never fix product code. You find. + +You are NOT marketing content review, NOT a copywriter, NOT a product implementer. + +# Lenses (dev/design scoped) + +Every finding cites at least one lens. No lens → speculation — drop it. + +1. **Visual identity** — color tokens/hex, typography, logos/wordmarks, imagery, CSS variables, light/dark adaptation (adapt, not invert), color ratio. +2. **Interactive quality** — animation/transitions (specific properties not \`all\`), will-change, scale-on-press (~0.97), shadows vs borders, concentric radii, font smoothing, hit areas (≥40px), stagger (30–80ms), easing fit for entrances vs exits. +3. **Component craft** — spacing rhythm, hierarchy, density, states (hover/focus/disabled/loading), accessibility of visual affordances. +4. **Brand coherence (UI)** — visual quality level matches interaction polish; no product brand mixing in one surface. + +Skip marketing voice/tone/messaging lenses unless the brief explicitly includes in-product strings as design copy. + +# Workflow + +1. Classify the artifact (component, screen, CSS tokens, layout, motion). +2. Load only relevant brand/design references when available (e.g. brand-identity skill, DESIGN.md, design tokens). +3. Systematic scan per active lens; quote exact values (expected vs actual). +4. Confidence: VERIFIED / HIGH / MEDIUM only. Discard LOW. +5. Report — do not redesign, rewrite, or patch code. + +OUT OF LANE → report Blockers naming the right director: implement (fixes), brand-reviewer (DESIGN.md ownership), emil (design-engineering laws), shakespeare (docs), critique (code review). + +# Report + +## Summary +Artifact type, compliance (COMPLIANT / MINOR / MAJOR / NON-COMPLIANT), critical count. + +## Findings +By lens and severity (CRITICAL / WARNING / NOTE). Table-friendly: Finding | Expected | Actual | Reference | Confidence. + +## Blockers +Missing references, out-of-lane asks, ambiguous scope. + +## Paths +Files and references inspected. + +Never write/edit/delete product files. Never spawn. Never commit.`, +}; \ No newline at end of file diff --git a/src/agent/directors/emil/index.ts b/src/agent/directors/emil/index.ts new file mode 100644 index 000000000..8eb14cf5b --- /dev/null +++ b/src/agent/directors/emil/index.ts @@ -0,0 +1 @@ +export { emilPackage } from "./package.js"; diff --git a/src/agent/directors/emil/package.test.ts b/src/agent/directors/emil/package.test.ts new file mode 100644 index 000000000..a21b77f38 --- /dev/null +++ b/src/agent/directors/emil/package.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { emilPackage } from "./package.js"; + +describe("emilPackage", () => { + test("id matches directory", () => { + expect(emilPackage.id).toBe("emil"); + }); + + test("systemPrompt is real, not a placeholder", () => { + expect(emilPackage.systemPrompt.length).toBeGreaterThan(0); + expect(emilPackage.systemPrompt.startsWith("Placeholder")).toBe(false); + }); + + test("systemPrompt states PRIMARY INTENT", () => { + expect(emilPackage.systemPrompt).toMatch(/PRIMARY INTENT/i); + }); + + test("spawn.maySpawn is false", () => { + expect(emilPackage.spawn.maySpawn).toBe(false); + }); + + test("tools.allow is review surface without product writes", () => { + const allow = emilPackage.tools?.allow ?? []; + expect(allow).toContain("read_file"); + expect(allow).not.toContain("write_file"); + expect(allow).not.toContain("edit_file"); + expect(allow).not.toContain("delete_file"); + }); + + test("report.requiredSections covers the leaf envelope", () => { + const sections = emilPackage.report.requiredSections; + expect(sections).toContain("Summary"); + expect(sections).toContain("Findings"); + expect(sections).toContain("Blockers"); + expect(sections).toContain("Paths"); + }); + + test("modelRole is review", () => { + expect(emilPackage.modelRole).toBe("review"); + }); + + test("primaryIntent and outOfLane match emil lane", () => { + expect(emilPackage.primaryIntent).toBe( + "Design-engineering + laws from a development perspective", + ); + expect(emilPackage.outOfLane).toContain("shipping product code without design brief"); + expect(emilPackage.outOfLane).toContain("marketing content"); + }); + + test("nudge maxTurns is 40", () => { + expect(emilPackage.nudge?.maxTurns).toBe(40); + }); +}); diff --git a/src/agent/directors/emil/package.ts b/src/agent/directors/emil/package.ts new file mode 100644 index 000000000..ec50a61a1 --- /dev/null +++ b/src/agent/directors/emil/package.ts @@ -0,0 +1,80 @@ +import type { DirectorPackage } from "../types.js"; +import { REVIEW_TOOLS } from "../tool-sets.js"; + +/** + * Emil — design-engineering + software-laws critique (dev-scoped). CL-5827. + * Named after Emil Kowalski craft principles; never fixes product code. + */ +export const emilPackage: DirectorPackage = { + id: "emil", + primaryIntent: "Design-engineering + laws from a development perspective", + outOfLane: [ + "shipping product code without design brief", + "marketing content", + "applying product fixes", + "suggesting full rewrites as implementer", + ], + description: "Design-engineering leaf (dev-scoped)", + // Critique only — write tools not mounted. + tools: { allow: REVIEW_TOOLS }, + spawn: { maySpawn: false }, + nudge: { maxTurns: 40 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + modelRole: "review", + systemPrompt: `You are EmilDirector, a specialist in Corbits Code. + +PRIMARY INTENT: design-engineering quality laws critique. Review UI implementations, interactions, and the code that produces them against design-engineering craft principles and classic software laws. Find problems with evidence. Never fix product code. Never ship features. + +You are a critical eye, not the hand that solves. + +# Laws (cite at least one per finding) + +## Complexity & scope +- **Second-System Effect** — bloated v2 rewrites without justification +- **Zawinski's Law** — feature creep / platformization of focused tools +- **YAGNI** — speculative abstractions and config for hypotheticals +- **KISS** — cleverness that obscures intent +- **Premature Optimization** — micro-opts without profiling + +## Architecture & structure +- **SOLID** — and over-application (abstraction theater) +- **DRY** — duplicated knowledge; similar-looking ≠ same purpose +- **Law of Demeter** — deep chains / structural coupling +- **Postel's Law** — brittle vs dangerously permissive boundaries + +## Quality & maintenance +- **Technical Debt** — flag impact; don't moralize +- **Broken Windows** — ignored lint, dead code, flaky ignored tests +- **Testing Pyramid / Pesticide Paradox** — inverted or stagnant suites +- **Sturgeon's Law** — low-value paths that add maintenance cost + +## Design & interface +- **Principle of Least Astonishment** — surprising names, side effects, platform-odd UI +- Craft from design-engineering practice: easing, will-change, layout shift, scale-on-press, shadow system, border-radius math, typography, hit areas, animation asymmetry + +# Workflow + +1. Understand scope — read the relevant UI/code before judging. +2. Form hypotheses — which laws apply. +3. Verify — inspect code, run existing tests/linters when practical. You cannot write temp test files (write tools denied); use read/run evidence instead. +4. Confidence: VERIFIED / HIGH / MEDIUM only. +5. Report with law + location + evidence + severity. No implementation prescriptions. + +OUT OF LANE → Blockers naming: implement (fixes), draper (CBS visual tokens), brand-reviewer (DESIGN.md), critique (general code review), greybeard (architecture gate). + +# Report + +## Summary +Design-engineering quality assessment; critical law violations; dominant patterns. + +## Findings +For each: Law violated | Location | Evidence | Confidence | Severity (Critical / Major / Minor). + +## Blockers +Missing context, out-of-lane asks, unreadable artifacts. + +## Paths +Files inspected. + +Never write/edit/delete product files. Never spawn. Never commit. Quality over quantity — three solid findings beat fifteen speculative ones.`, +}; \ No newline at end of file diff --git a/src/agent/directors/explore/index.ts b/src/agent/directors/explore/index.ts new file mode 100644 index 000000000..fa11f90b2 --- /dev/null +++ b/src/agent/directors/explore/index.ts @@ -0,0 +1 @@ +export { explorePackage } from "./package.js"; diff --git a/src/agent/directors/explore/package.test.ts b/src/agent/directors/explore/package.test.ts new file mode 100644 index 000000000..0c76294a2 --- /dev/null +++ b/src/agent/directors/explore/package.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; +import { explorePackage } from "./package.js"; + +describe("explorePackage", () => { + test("id matches directory", () => { + expect(explorePackage.id).toBe("explore"); + }); + + test("systemPrompt is real, not a placeholder", () => { + expect(explorePackage.systemPrompt.length).toBeGreaterThan(0); + expect(explorePackage.systemPrompt.startsWith("Placeholder")).toBe(false); + }); + + test("systemPrompt states PRIMARY INTENT", () => { + expect(explorePackage.systemPrompt).toMatch(/PRIMARY INTENT/i); + }); + + test("systemPrompt has finish bias against re-reading the same paths", () => { + expect(explorePackage.systemPrompt).toMatch(/FINISH BIAS/i); + expect(explorePackage.systemPrompt).toMatch(/re-reading the same paths/i); + expect(explorePackage.systemPrompt).toMatch( + /Expand Findings, change approach, or write the final report/i, + ); + }); + + test("systemPrompt requires scannable Findings shape", () => { + expect(explorePackage.systemPrompt).toMatch(/FINDINGS SHAPE/i); + expect(explorePackage.systemPrompt).toMatch(/scannable map/i); + expect(explorePackage.systemPrompt).toMatch(/key paths/i); + expect(explorePackage.systemPrompt).toMatch(/symbols/i); + expect(explorePackage.systemPrompt).toMatch(/call flow/i); + }); + + test("systemPrompt notes maxTurns budget is real", () => { + expect(explorePackage.systemPrompt).toMatch(/maxTurns/i); + expect(explorePackage.systemPrompt).toMatch(/wrap up before thrash/i); + }); + + test("spawn.maySpawn is false", () => { + expect(explorePackage.spawn.maySpawn).toBe(false); + }); + + test("tools.allow is read-only (no product writes)", () => { + const allow = explorePackage.tools?.allow ?? []; + expect(allow).toContain("read_file"); + expect(allow).toContain("grep"); + expect(allow).not.toContain("write_file"); + expect(allow).not.toContain("edit_file"); + expect(allow).not.toContain("delete_file"); + }); + + test("report.requiredSections covers the leaf envelope", () => { + const sections = explorePackage.report.requiredSections; + expect(sections).toContain("Summary"); + expect(sections).toContain("Findings"); + expect(sections).toContain("Blockers"); + expect(sections).toContain("Paths"); + }); + + test("modelRole is explore", () => { + expect(explorePackage.modelRole).toBe("explore"); + }); +}); diff --git a/src/agent/directors/explore/package.ts b/src/agent/directors/explore/package.ts new file mode 100644 index 000000000..ebc512d01 --- /dev/null +++ b/src/agent/directors/explore/package.ts @@ -0,0 +1,32 @@ +import type { DirectorPackage } from "../types.js"; +import { READ_TOOLS } from "../tool-sets.js"; + +export const explorePackage: DirectorPackage = { + id: "explore", + primaryIntent: "Map and read the codebase; no product edits", + outOfLane: [ + "product write paths", + "drive-by fixes", + "shipping features", + "review severity theater", + ], + description: "Read-only exploration leaf", + systemPrompt: `You are ExploreDirector, a specialist in Corbits Code. + +PRIMARY INTENT: explore and map the codebase to answer the brief. Read, search, lsp. Do not implement product changes. + +Prefer grep/search_files/lsp over shell walks. Shell find/rg -r are blocked by harness — do not work around. + +FINISH BIAS: Prefer one thorough pass then report. Expand Findings, change approach, or write the final report — do not keep re-reading the same paths. Parents may set lower maxTurns for narrow maps; the default budget is real — wrap up before thrash. + +FINDINGS SHAPE: Findings must be a scannable map — key paths, symbols, call flow / ownership — not optional prose dump. Cite paths. No drive-by refactors, no feature work, no review severity theater. + +OUT OF LANE → report Blockers naming the right director: implement, plan, critique, greybeard, intern. + +Report: Summary, Findings, Blockers, Paths.`, + tools: { allow: READ_TOOLS }, + spawn: { maySpawn: false }, + nudge: { maxTurns: 35 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + modelRole: "explore", +}; diff --git a/src/agent/directors/gaasbot/index.ts b/src/agent/directors/gaasbot/index.ts new file mode 100644 index 000000000..987b2d41c --- /dev/null +++ b/src/agent/directors/gaasbot/index.ts @@ -0,0 +1 @@ +export { gaasbotPackage } from "./package.js"; diff --git a/src/agent/directors/gaasbot/package.test.ts b/src/agent/directors/gaasbot/package.test.ts new file mode 100644 index 000000000..b857d7d5f --- /dev/null +++ b/src/agent/directors/gaasbot/package.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { gaasbotPackage } from "./package.js"; + +describe("gaasbotPackage", () => { + test("id matches directory", () => { + expect(gaasbotPackage.id).toBe("gaasbot"); + }); + + test("systemPrompt is real (not Placeholder)", () => { + expect(gaasbotPackage.systemPrompt.length).toBeGreaterThan(0); + expect(gaasbotPackage.systemPrompt.startsWith("Placeholder")).toBe(false); + }); + + test("systemPrompt states PRIMARY INTENT", () => { + expect(gaasbotPackage.systemPrompt).toMatch(/PRIMARY INTENT/i); + }); + + test("spawn.maySpawn is false", () => { + expect(gaasbotPackage.spawn.maySpawn).toBe(false); + }); + + test("denies product write tools (advice only)", () => { + const allow = gaasbotPackage.tools?.allow ?? []; + expect(allow).toContain("read_file"); + expect(allow).not.toContain("write_file"); + expect(allow).not.toContain("edit_file"); + expect(allow).not.toContain("delete_file"); + }); + + test("report requires envelope sections", () => { + for (const section of ["Summary", "Findings", "Blockers", "Paths"]) { + expect(gaasbotPackage.report.requiredSections).toContain(section); + } + }); + + test("modelRole is plan", () => { + expect(gaasbotPackage.modelRole).toBe("plan"); + }); + + test("optionalSkills is philosophy only", () => { + expect(gaasbotPackage.optionalSkills).toEqual(["philosophy"]); + }); + + test("primaryIntent and outOfLane match CTO advice lane", () => { + expect(gaasbotPackage.primaryIntent).toMatch(/CTO advice/i); + expect(gaasbotPackage.outOfLane).toContain("blocking merges"); + expect(gaasbotPackage.outOfLane).toContain("shipping product code as implementer"); + }); + + test("nudge maxTurns is 35", () => { + expect(gaasbotPackage.nudge?.maxTurns).toBe(35); + }); +}); diff --git a/src/agent/directors/gaasbot/package.ts b/src/agent/directors/gaasbot/package.ts new file mode 100644 index 000000000..6d8cb9cca --- /dev/null +++ b/src/agent/directors/gaasbot/package.ts @@ -0,0 +1,38 @@ +import type { DirectorPackage } from "../types.js"; +import { REVIEW_TOOLS } from "../tool-sets.js"; + +/** + * CTO advice leaf (CL-5826). + * Strategic risk/sequencing counsel — not a hard gate, not implement, not greybeard/plan. + */ +export const gaasbotPackage: DirectorPackage = { + id: "gaasbot", + primaryIntent: "CTO advice — risk and sequencing; not a hard gate", + outOfLane: [ + "blocking merges", + "shipping product code as implementer", + "replacing greybeard architecture review", + "replacing plan eng change plans", + "applying product fixes", + ], + description: "CTO advice leaf — strategic counsel, not a gate", + optionalSkills: ["philosophy"], + tools: { allow: REVIEW_TOOLS }, + spawn: { maySpawn: false }, + nudge: { maxTurns: 35 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + modelRole: "plan", + systemPrompt: `You are GaasbotDirector, a specialist in Corbits Code. + +PRIMARY INTENT: strategic CTO advice — risk, sequencing, what blocks a release, what ships with a note, what is filed for later. You are counsel, not a hard gate. + +You do not implement product code. You do not replace Greybeard (architecture review) or Plan (eng change plans). You do not block merges by force; you recommend clearly, including "do not ship" when warranted. + +Given findings from others (or the brief): what actually blocks a release? What ships with a note? What is filed? Ask what the team is most likely getting wrong that nobody raised. Prefer hearing "do not ship" early over a late surprise. + +Load philosophy when judgment trade-offs matter. Stay advice-only — no write_file/edit_file/delete_file. + +OUT OF LANE: implementing, architecture gate ownership, eng plan authorship as PlanDirector, merge-block theater without evidence. + +Report: Summary, Findings (risk/sequencing advice), Blockers, Paths.`, +}; diff --git a/src/agent/directors/greybeard/index.ts b/src/agent/directors/greybeard/index.ts new file mode 100644 index 000000000..435f98be6 --- /dev/null +++ b/src/agent/directors/greybeard/index.ts @@ -0,0 +1 @@ +export { greybeardPackage } from "./package.js"; diff --git a/src/agent/directors/greybeard/package.test.ts b/src/agent/directors/greybeard/package.test.ts new file mode 100644 index 000000000..7dbbe8939 --- /dev/null +++ b/src/agent/directors/greybeard/package.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; +import { greybeardPackage } from "./package.js"; + +describe("greybeardPackage", () => { + test("id matches directory", () => { + expect(greybeardPackage.id).toBe("greybeard"); + }); + + test("systemPrompt is real (not Placeholder)", () => { + expect(greybeardPackage.systemPrompt.length).toBeGreaterThan(0); + expect(greybeardPackage.systemPrompt.startsWith("Placeholder")).toBe(false); + }); + + test("systemPrompt states PRIMARY INTENT and GreybeardDirector", () => { + expect(greybeardPackage.systemPrompt).toMatch(/PRIMARY INTENT/i); + expect(greybeardPackage.systemPrompt).toContain("GreybeardDirector"); + }); + + test("spawn.maySpawn is true with limited allowlist", () => { + expect(greybeardPackage.spawn.maySpawn).toBe(true); + expect(greybeardPackage.spawn.allowlist).toEqual(["intern", "explore", "critique"]); + }); + + test("allowlist is only intern, explore, critique", () => { + const allow = greybeardPackage.spawn.allowlist ?? []; + expect(allow).toHaveLength(3); + expect(allow).toContain("intern"); + expect(allow).toContain("explore"); + expect(allow).toContain("critique"); + expect(allow).not.toContain("implement"); + expect(allow).not.toContain("skywalker"); + expect(allow).not.toContain("plan"); + }); + + test("systemPrompt forbids parallel diagnostic fleets", () => { + expect(greybeardPackage.systemPrompt).toMatch(/do the review yourself/i); + expect(greybeardPackage.systemPrompt).toMatch(/spawn at most one intern/i); + expect(greybeardPackage.systemPrompt).toMatch(/never spawn a parallel diagnostic fleet/i); + }); + + test("tools.allow is orchestrator surface without product writes", () => { + const allow = greybeardPackage.tools?.allow ?? []; + expect(allow).toContain("task"); + expect(allow).toContain("search_agents"); + expect(allow).not.toContain("write_file"); + expect(allow).not.toContain("edit_file"); + expect(allow).not.toContain("delete_file"); + }); + + test("report requires envelope sections", () => { + for (const section of ["Summary", "Findings", "Blockers", "Paths"]) { + expect(greybeardPackage.report.requiredSections).toContain(section); + } + }); + + test("modelRole is review", () => { + expect(greybeardPackage.modelRole).toBe("review"); + }); + + test("optionalSkills order", () => { + expect(greybeardPackage.optionalSkills).toEqual(["style", "philosophy"]); + }); + + test("primaryIntent and outOfLane match greybeard lane", () => { + expect(greybeardPackage.primaryIntent).toBe("Architecture review; limited spawn"); + expect(greybeardPackage.outOfLane).toContain("shipping product code"); + expect(greybeardPackage.outOfLane).toContain("pedantic style-only nitpicking"); + }); + + test("nudge maxTurns is 50", () => { + expect(greybeardPackage.nudge?.maxTurns).toBe(50); + }); +}); diff --git a/src/agent/directors/greybeard/package.ts b/src/agent/directors/greybeard/package.ts new file mode 100644 index 000000000..bc565644b --- /dev/null +++ b/src/agent/directors/greybeard/package.ts @@ -0,0 +1,45 @@ +import type { DirectorPackage } from "../types.js"; +import { ORCHESTRATOR_TOOLS } from "../tool-sets.js"; + +/** + * Architecture review leaf with limited spawn (CL-5821). + * Evidence via intern/explore/critique only — never ships product code. + */ +export const greybeardPackage: DirectorPackage = { + id: "greybeard", + primaryIntent: "Architecture review; limited spawn", + outOfLane: [ + "shipping product code", + "pedantic style-only nitpicking", + ], + description: "Architecture review leaf", + optionalSkills: ["style", "philosophy"], + tools: { allow: ORCHESTRATOR_TOOLS }, + spawn: { + maySpawn: true, + allowlist: ["intern", "explore", "critique"], + }, + nudge: { maxTurns: 50 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + modelRole: "review", + systemPrompt: `You are GreybeardDirector, a specialist in Corbits Code. + +PRIMARY INTENT: architecture review. Judge soundness, constraint ownership, and backward-compatibility implications. Do not fix or ship product code. + +Load style and philosophy when reviewing plans or approaches — skills are active constraints, not background docs. + +You may spawn only intern, explore, and critique for evidence gathering. Do not spawn implement, plan, skywalker, or other directors. Your value is analysis, not legwork or implementation. + +Do the review yourself. Spawn at most one intern, explore, or critique evidence leaf when a single unknown path blocks you. Never spawn a parallel diagnostic fleet. + +Focus on: +- Architectural holes, anti-patterns, missing invariants +- Constraint ownership (fixed at the right layer, not symptom-chasing) +- BC implications and long-term maintainability +- Misalignment between product, architecture, and implementation +- Duplication that should be refactor/API expansion instead + +OUT OF LANE: shipping product code, pedantic style-only nitpicking, being a second primary orchestrator. + +Report: Summary, Findings, Blockers, Paths.`, +}; diff --git a/src/agent/directors/identity.test.ts b/src/agent/directors/identity.test.ts new file mode 100644 index 000000000..b48ac8057 --- /dev/null +++ b/src/agent/directors/identity.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { + MODEL_ROLE_DEFAULT_EFFORT, + defaultEffortForDirector, + formatDirectorSystemPrompt, +} from "./identity.js"; +import { DIRECTOR_REGISTRY } from "./registry.js"; + +describe("formatDirectorSystemPrompt", () => { + test("prefixes agent id, model role, and optional skills", () => { + const text = formatDirectorSystemPrompt(DIRECTOR_REGISTRY.implement); + expect(text.startsWith("Identity: agent id `implement`")).toBe(true); + expect(text).toContain('task(agent="implement")'); + expect(text).toContain("Model role: implement."); + expect(text).toContain("style, philosophy, typescript"); + expect(text).toContain(DIRECTOR_REGISTRY.implement.systemPrompt); + }); + + test("intern reports no optional skills by default", () => { + const text = formatDirectorSystemPrompt(DIRECTOR_REGISTRY.intern); + expect(text).toContain("Optional skills: none by default"); + }); +}); + +describe("defaultEffortForDirector", () => { + test("intern is low; implement is medium; greybeard is high", () => { + expect(defaultEffortForDirector(DIRECTOR_REGISTRY.intern)).toBe("low"); + expect(defaultEffortForDirector(DIRECTOR_REGISTRY.implement)).toBe( + MODEL_ROLE_DEFAULT_EFFORT.implement, + ); + expect(defaultEffortForDirector(DIRECTOR_REGISTRY.greybeard)).toBe("high"); + expect(defaultEffortForDirector(DIRECTOR_REGISTRY.skywalker)).toBe("high"); + }); +}); diff --git a/src/agent/directors/identity.ts b/src/agent/directors/identity.ts new file mode 100644 index 000000000..bc0d1f9b2 --- /dev/null +++ b/src/agent/directors/identity.ts @@ -0,0 +1,42 @@ +import type { DirectorPackage } from "./types.js"; +import type { ModelRole } from "./types.js"; +import type { ReasoningEffort } from "../../provider/reasoning-effort.js"; + +/** + * Prefix every director system prompt with a stable identity block so the model + * always sees agent id, model role, and optional skills — no ambiguity about which + * package it is or how the parent should re-spawn it. + */ +export function formatDirectorSystemPrompt(pkg: DirectorPackage): string { + const skillsLine = + pkg.optionalSkills === undefined + ? null + : pkg.optionalSkills.length === 0 + ? "Optional skills: none by default." + : `Optional skills (names for awareness; guidance is baked into this prompt — use_skill is not mounted on workers): ${pkg.optionalSkills.join(", ")}.`; + const header = [ + `Identity: agent id \`${pkg.id}\` — spawn as task(agent="${pkg.id}").`, + `Model role: ${pkg.modelRole}.`, + ...(skillsLine !== null ? [skillsLine] : []), + ].join("\n"); + return `${header}\n\n${pkg.systemPrompt}`; +} + +/** + * Product default reasoning effort by package modelRole (CL-5816 slice). + * Intern is the cheap worker: same implement role, lower effort budget. + */ +export const MODEL_ROLE_DEFAULT_EFFORT = { + orchestrator: "high", + plan: "high", + review: "high", + implement: "medium", + explore: "medium", + docs: "medium", + test: "medium", +} as const satisfies Record<ModelRole, ReasoningEffort>; + +export function defaultEffortForDirector(pkg: DirectorPackage): ReasoningEffort { + if (pkg.id === "intern") return "low"; + return MODEL_ROLE_DEFAULT_EFFORT[pkg.modelRole]; +} diff --git a/src/agent/directors/implement/index.ts b/src/agent/directors/implement/index.ts new file mode 100644 index 000000000..ad613f288 --- /dev/null +++ b/src/agent/directors/implement/index.ts @@ -0,0 +1 @@ +export { implementPackage } from "./package.js"; diff --git a/src/agent/directors/implement/package.test.ts b/src/agent/directors/implement/package.test.ts new file mode 100644 index 000000000..7519777a2 --- /dev/null +++ b/src/agent/directors/implement/package.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; +import { implementPackage } from "./package.js"; + +describe("implementPackage", () => { + test("id matches directory / registry id", () => { + expect(implementPackage.id).toBe("implement"); + }); + + test("systemPrompt is non-empty and not a Placeholder", () => { + expect(implementPackage.systemPrompt.length).toBeGreaterThan(0); + expect(implementPackage.systemPrompt.startsWith("Placeholder")).toBe(false); + }); + + test("systemPrompt mentions PRIMARY INTENT", () => { + expect(implementPackage.systemPrompt).toContain("PRIMARY INTENT"); + }); + + test("spawn.maySpawn is false (leaf)", () => { + expect(implementPackage.spawn.maySpawn).toBe(false); + }); + + test("tools.allow includes product write tools", () => { + const allow = implementPackage.tools?.allow ?? []; + expect(allow).toContain("write_file"); + expect(allow).toContain("edit_file"); + expect(allow).toContain("delete_file"); + }); + + test("report.requiredSections includes Summary, Findings, Blockers, Paths", () => { + const sections = implementPackage.report.requiredSections; + expect(sections).toContain("Summary"); + expect(sections).toContain("Findings"); + expect(sections).toContain("Blockers"); + expect(sections).toContain("Paths"); + }); + + test("modelRole is implement", () => { + expect(implementPackage.modelRole).toBe("implement"); + }); + + test("optionalSkills order is style, philosophy, typescript", () => { + expect(implementPackage.optionalSkills).toEqual(["style", "philosophy", "typescript"]); + }); + + test("systemPrompt has DONE GATE for success_criteria", () => { + const prompt = implementPackage.systemPrompt; + expect(prompt).toContain("DONE GATE"); + expect(prompt).toContain("success_criteria"); + expect(prompt).toMatch(/[Ss]top when/); + }); + + test("systemPrompt has VERIFY language", () => { + const prompt = implementPackage.systemPrompt; + expect(prompt).toContain("VERIFY"); + expect(prompt).toMatch(/typecheck|tests/); + expect(prompt).toContain("Blockers"); + }); + + test("systemPrompt has REPORT MAP for criteria and Paths", () => { + const prompt = implementPackage.systemPrompt; + expect(prompt).toContain("REPORT MAP"); + expect(prompt).toMatch(/success_criteria.*pass|fail|blocked/s); + expect(prompt).toMatch(/Paths must list files touched/); + }); + + test("systemPrompt has API CONTRACT for sync/async preservation", () => { + const prompt = implementPackage.systemPrompt; + expect(prompt).toContain("API CONTRACT"); + expect(prompt).toMatch(/sync/i); + expect(prompt).toMatch(/Promise|async/); + expect(prompt).toMatch(/public API|return shape/i); + }); +}); diff --git a/src/agent/directors/implement/package.ts b/src/agent/directors/implement/package.ts new file mode 100644 index 000000000..1637d4c06 --- /dev/null +++ b/src/agent/directors/implement/package.ts @@ -0,0 +1,41 @@ +import type { DirectorPackage } from "../types.js"; +import { IMPLEMENT_TOOLS } from "../tool-sets.js"; + +export const implementPackage: DirectorPackage = { + id: "implement", + primaryIntent: "Ship product code with tests to satisfy the brief", + outOfLane: [ + "architecture gates", + "docs-only work", + "review-only verdicts", + "mechanical command lists without implementing", + "orchestrating other agents", + ], + description: "Implementation leaf — edit, verify, report", + optionalSkills: ["style", "philosophy", "typescript"], + tools: { allow: IMPLEMENT_TOOLS }, + spawn: { maySpawn: false }, + nudge: { maxTurns: 60 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + modelRole: "implement", + systemPrompt: `You are ImplementDirector, a specialist in Corbits Code. + +PRIMARY INTENT: implement the brief in product code. Edit, verify, report. +You are not a reviewer, not an orchestrator, not a doc-only planner. + +Before substantial repo work: follow style and philosophy conventions (baked; use_skill is not mounted on workers). +Follow AGENTS.md and /docs. Touch only what the brief requires. +Do not spawn sub-agents. + +DONE GATE: Stop when every success_criteria item from the brief is met OR explicitly blocked under Blockers. Do not invent architecture or expand the brief after criteria are satisfied. + +VERIFY: Run typecheck/tests when practical; put failures under Blockers, not silent patches outside scope. + +REPORT MAP: Findings must map each success_criteria item → pass | fail | blocked. Paths must list files touched. + +API CONTRACT: Preserve existing public API sync/async and return shapes unless the brief explicitly changes them. If the brief or existing code shows a synchronous function returning a plain value (e.g. { status, body }), keep it sync — do not return a Promise / make it async just to use Web Crypto. Prefer sync libraries (node:crypto createHmac, etc.) when the public surface is sync. When the brief states a signature, match parameter order, optionality, and return type exactly. Do not change call sites to await unless the brief requires an async API. + +OUT OF LANE: pure exploration maps, architecture essays without code, review-only verdicts, mechanical command lists without implementing. + +Report: Summary, Findings, Blockers, Paths.`, +}; diff --git a/src/agent/directors/index.ts b/src/agent/directors/index.ts new file mode 100644 index 000000000..d4bf9a2bf --- /dev/null +++ b/src/agent/directors/index.ts @@ -0,0 +1,30 @@ +export { + DIRECTOR_IDS, + type DirectorId, + type DirectorPackage, + type ModelRole, + type NudgePolicy, + type ReportContract, + type ResolveDirectorInput, + type ResolveDirectorResult, + type SpawnRights, + type TaskIntent, + type ToolEnvelope, +} from "./types.js"; + +export { + DIRECTOR_REGISTRY, + INTENT_DEFAULT_DIRECTOR, + directorProfiles, + isDirectorId, + listDirectors, + packageToCapabilities, + packageToProfile, + resolveDirector, +} from "./registry.js"; + +export { + MODEL_ROLE_DEFAULT_EFFORT, + defaultEffortForDirector, + formatDirectorSystemPrompt, +} from "./identity.js"; diff --git a/src/agent/directors/intern/index.ts b/src/agent/directors/intern/index.ts new file mode 100644 index 000000000..be2db9b9a --- /dev/null +++ b/src/agent/directors/intern/index.ts @@ -0,0 +1 @@ +export { internPackage } from "./package.js"; diff --git a/src/agent/directors/intern/package.test.ts b/src/agent/directors/intern/package.test.ts new file mode 100644 index 000000000..3f8d0a234 --- /dev/null +++ b/src/agent/directors/intern/package.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; + +import { internPackage } from "./package.js"; + +describe("internPackage", () => { + test("id matches directory", () => { + expect(internPackage.id).toBe("intern"); + }); + + test("systemPrompt is real (not placeholder)", () => { + expect(internPackage.systemPrompt.length).toBeGreaterThan(0); + expect(internPackage.systemPrompt.startsWith("Placeholder")).toBe(false); + expect(internPackage.systemPrompt).toMatch(/PRIMARY INTENT/i); + }); + + test("spawn.maySpawn is false", () => { + expect(internPackage.spawn.maySpawn).toBe(false); + }); + + test("nudge.maxTurns is 20", () => { + expect(internPackage.nudge?.maxTurns).toBe(20); + }); + + test("tools.allow is shell-first minimal surface", () => { + const allow = internPackage.tools?.allow ?? []; + expect(allow).toContain("run_shell"); + expect(allow).toContain("read_file"); + expect(allow).toContain("list_dir"); + for (const name of ["write_file", "edit_file", "delete_file", "grep", "search_files", "task"]) { + expect(allow).not.toContain(name); + } + }); + + test("report.requiredSections envelope", () => { + for (const section of ["Summary", "Findings", "Blockers", "Paths"]) { + expect(internPackage.report.requiredSections).toContain(section); + } + }); + + test("modelRole is implement", () => { + expect(internPackage.modelRole).toBe("implement"); + }); + + test("optionalSkills is empty by default", () => { + expect(internPackage.optionalSkills).toEqual([]); + }); + + test("primaryIntent and description", () => { + expect(internPackage.primaryIntent).toContain("Mechanical shell/commands only"); + expect(internPackage.description).toBe("Mechanical intern leaf"); + }); +}); diff --git a/src/agent/directors/intern/package.ts b/src/agent/directors/intern/package.ts new file mode 100644 index 000000000..795ed003d --- /dev/null +++ b/src/agent/directors/intern/package.ts @@ -0,0 +1,36 @@ +import type { DirectorPackage } from "../types.js"; +import { INTERN_TOOLS } from "../tool-sets.js"; + +/** + * Mechanical intern leaf (CL-5822). + * Shell/commands only — no judgment, no exploration, no product writes. + */ +export const internPackage: DirectorPackage = { + id: "intern", + primaryIntent: "Mechanical shell/commands only — exact steps, zero judgment", + outOfLane: [ + "design judgment", + "product edits without explicit brief", + "debugging theories", + "codebase exploration", + "implementing features", + "review", + "spawning agents", + ], + description: "Mechanical intern leaf", + optionalSkills: [], + tools: { allow: INTERN_TOOLS }, + spawn: { maySpawn: false }, + nudge: { maxTurns: 20 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + modelRole: "implement", + systemPrompt: `You are InternDirector, a specialist in Corbits Code. + +PRIMARY INTENT: mechanical execution only. Run exactly what the brief says. No judgment, no debugging narratives, no codebase exploration, no implementation. + +If anything is ambiguous, missing, or fails: STOP. Report raw command output and the blocker. Do not invent next steps. Do not spawn agents. Do not load skills unless the brief names a skill to load. + +You are a cheap model package — stay short. + +Report: Summary, Findings (commands + outputs), Blockers, Paths.`, +}; diff --git a/src/agent/directors/neckbeard/index.ts b/src/agent/directors/neckbeard/index.ts new file mode 100644 index 000000000..6e9961584 --- /dev/null +++ b/src/agent/directors/neckbeard/index.ts @@ -0,0 +1 @@ +export { neckbeardPackage } from "./package.js"; diff --git a/src/agent/directors/neckbeard/package.test.ts b/src/agent/directors/neckbeard/package.test.ts new file mode 100644 index 000000000..7047c147e --- /dev/null +++ b/src/agent/directors/neckbeard/package.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; +import { neckbeardPackage } from "./package.js"; + +describe("neckbeardPackage", () => { + test("id matches directory", () => { + expect(neckbeardPackage.id).toBe("neckbeard"); + }); + + test("systemPrompt is real (not Placeholder)", () => { + expect(neckbeardPackage.systemPrompt.length).toBeGreaterThan(0); + expect(neckbeardPackage.systemPrompt.startsWith("Placeholder")).toBe(false); + }); + + test("systemPrompt states PRIMARY INTENT", () => { + expect(neckbeardPackage.systemPrompt).toMatch(/PRIMARY INTENT/i); + }); + + test("systemPrompt names NeckbeardDirector and never-fix stance", () => { + expect(neckbeardPackage.systemPrompt).toMatch(/NeckbeardDirector/); + expect(neckbeardPackage.systemPrompt).toMatch(/never fix/i); + }); + + test("spawn.maySpawn is false", () => { + expect(neckbeardPackage.spawn.maySpawn).toBe(false); + }); + + test("denies product write tools", () => { + const allow = neckbeardPackage.tools?.allow ?? []; + expect(allow).toContain("read_file"); + expect(allow).not.toContain("write_file"); + expect(allow).not.toContain("edit_file"); + expect(allow).not.toContain("delete_file"); + }); + + test("report requires envelope sections", () => { + for (const section of ["Summary", "Findings", "Blockers", "Paths"]) { + expect(neckbeardPackage.report.requiredSections).toContain(section); + } + }); + + test("modelRole is review", () => { + expect(neckbeardPackage.modelRole).toBe("review"); + }); + + test("optionalSkills are style and philosophy", () => { + expect(neckbeardPackage.optionalSkills).toEqual(["style", "philosophy"]); + }); + + test("primaryIntent and outOfLane match neckbeard lane", () => { + expect(neckbeardPackage.primaryIntent).toBe("Adversarial pedantic review; never fix"); + expect(neckbeardPackage.outOfLane).toContain("applying fixes"); + expect(neckbeardPackage.outOfLane).toContain("product implementation"); + expect(neckbeardPackage.outOfLane).toContain("architecture ownership"); + }); + + test("nudge maxTurns is 40", () => { + expect(neckbeardPackage.nudge?.maxTurns).toBe(40); + }); +}); diff --git a/src/agent/directors/neckbeard/package.ts b/src/agent/directors/neckbeard/package.ts new file mode 100644 index 000000000..6088017f1 --- /dev/null +++ b/src/agent/directors/neckbeard/package.ts @@ -0,0 +1,35 @@ +import type { DirectorPackage } from "../types.js"; +import { REVIEW_TOOLS } from "../tool-sets.js"; + +/** + * Adversarial pedantic review leaf (CL-5820). + * Hygiene, nits, refactor proposals — never product fixes; not architecture gate. + */ +export const neckbeardPackage: DirectorPackage = { + id: "neckbeard", + primaryIntent: "Adversarial pedantic review; never fix", + outOfLane: [ + "applying fixes", + "product implementation", + "architecture ownership", + "rewriting product code", + ], + description: "Adversarial review leaf", + optionalSkills: ["style", "philosophy"], + tools: { allow: REVIEW_TOOLS }, + spawn: { maySpawn: false }, + nudge: { maxTurns: 40 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + modelRole: "review", + systemPrompt: `You are NeckbeardDirector, a specialist in Corbits Code. + +PRIMARY INTENT: adversarial pedantic review. Surface hygiene issues, nits, and refactor proposals with evidence. Never fix product code. You are not the architecture owner (that is Greybeard). You are not the defect-severity owner (that is Critique). + +Be pedantic on purpose: naming drift, comment rot, type escape hatches, boundary validation, off-by-ones, unicode/width/escape fiddliness, dead paths, and taste-vs-defect separation. Cite file paths and concrete snippets. Separate genuine defects from taste; label each finding. + +Do not apply fixes. Do not write, edit, or delete product files. Do not spawn agents. Optional skills style/philosophy may sharpen the nit lens — do not load them to rewrite the product. + +OUT OF LANE → report Blockers naming the right director: implement (to fix), critique (correctness defects), greybeard (architecture), plan (change plans). + +Report: Summary, Findings (ranked nits + evidence), Blockers, Paths.`, +}; diff --git a/src/agent/directors/plan/index.ts b/src/agent/directors/plan/index.ts new file mode 100644 index 000000000..0510f7374 --- /dev/null +++ b/src/agent/directors/plan/index.ts @@ -0,0 +1 @@ +export { planPackage } from "./package.js"; diff --git a/src/agent/directors/plan/package.test.ts b/src/agent/directors/plan/package.test.ts new file mode 100644 index 000000000..ba6656850 --- /dev/null +++ b/src/agent/directors/plan/package.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import { planPackage } from "./package.js"; + +describe("planPackage", () => { + test("id matches directory", () => { + expect(planPackage.id).toBe("plan"); + }); + + test("systemPrompt is real (not Placeholder)", () => { + expect(planPackage.systemPrompt.length).toBeGreaterThan(0); + expect(planPackage.systemPrompt.startsWith("Placeholder")).toBe(false); + }); + + test("systemPrompt states PRIMARY INTENT", () => { + expect(planPackage.systemPrompt).toMatch(/PRIMARY INTENT/i); + }); + + test("spawn.maySpawn is false", () => { + expect(planPackage.spawn.maySpawn).toBe(false); + }); + + test("tools.allow is review surface without product writes", () => { + const allow = planPackage.tools?.allow ?? []; + expect(allow).toContain("read_file"); + expect(allow).not.toContain("write_file"); + expect(allow).not.toContain("edit_file"); + expect(allow).not.toContain("delete_file"); + }); + + test("report requires envelope sections", () => { + for (const section of ["Summary", "Findings", "Blockers", "Paths"]) { + expect(planPackage.report.requiredSections).toContain(section); + } + }); + + test("modelRole is plan", () => { + expect(planPackage.modelRole).toBe("plan"); + }); + + test("optionalSkills order", () => { + expect(planPackage.optionalSkills).toEqual(["style", "philosophy", "interview"]); + }); + + test("primaryIntent and outOfLane match plan lane", () => { + expect(planPackage.primaryIntent).toBe("Author eng change plans; do not implement"); + expect(planPackage.outOfLane).toContain("shipping code"); + expect(planPackage.outOfLane).toContain("architecture gate sign-off as Greybeard"); + expect(planPackage.outOfLane).toContain("running the fleet"); + }); + + test("nudge maxTurns is 40", () => { + expect(planPackage.nudge?.maxTurns).toBe(40); + }); +}); diff --git a/src/agent/directors/plan/package.ts b/src/agent/directors/plan/package.ts new file mode 100644 index 000000000..155b0af38 --- /dev/null +++ b/src/agent/directors/plan/package.ts @@ -0,0 +1,28 @@ +import type { DirectorPackage } from "../types.js"; +import { REVIEW_TOOLS } from "../tool-sets.js"; + +export const planPackage: DirectorPackage = { + id: "plan", + primaryIntent: "Author eng change plans; do not implement", + outOfLane: [ + "shipping code", + "architecture gate sign-off as Greybeard", + "running the fleet", + ], + description: "Planning leaf — eng plans only; Greybeard reviews", + optionalSkills: ["style", "philosophy", "interview"], + tools: { allow: REVIEW_TOOLS }, + spawn: { maySpawn: false }, + nudge: { maxTurns: 40 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + modelRole: "plan", + systemPrompt: `You are PlanDirector, a specialist in Corbits Code. + +PRIMARY INTENT: author concrete engineering change plans. Do not implement product code. Do not act as architecture gate (that is Greybeard). + +Plans must be agent-proof: files, acceptance criteria, non-goals, risks, ordered steps. Prefer interview skill when requirements are fuzzy (ask_operator / structured questions when available). + +OUT OF LANE: shipping the change yourself, pure code review, fleet orchestration. + +Report: Summary, Findings (the plan), Blockers, Paths.`, +}; diff --git a/src/agent/directors/registry.test.ts b/src/agent/directors/registry.test.ts new file mode 100644 index 000000000..0d6825968 --- /dev/null +++ b/src/agent/directors/registry.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, test } from "bun:test"; + +import { DIRECTOR_IDS } from "./types.js"; +import { + DIRECTOR_REGISTRY, + INTENT_DEFAULT_DIRECTOR, + directorProfiles, + isDirectorId, + listDirectors, + packageToProfile, + resolveDirector, +} from "./registry.js"; + +describe("director registry", () => { + test("closed set has exactly 16 directors", () => { + expect(DIRECTOR_IDS).toHaveLength(16); + expect(listDirectors()).toHaveLength(16); + for (const id of DIRECTOR_IDS) { + expect(DIRECTOR_REGISTRY[id].id).toBe(id); + } + }); + + test("every package has a real system prompt (no placeholders)", () => { + for (const id of DIRECTOR_IDS) { + const pkg = DIRECTOR_REGISTRY[id]; + expect(pkg.systemPrompt.length).toBeGreaterThan(40); + expect(pkg.systemPrompt.startsWith("Placeholder")).toBe(false); + expect(pkg.systemPrompt.toLowerCase()).toContain("primary intent"); + expect(pkg.report.requiredSections).toEqual( + expect.arrayContaining(["Summary", "Findings", "Blockers", "Paths"]), + ); + } + }); + + test("resolve by agentId", () => { + const r = resolveDirector({ agentId: "skywalker" }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.package.id).toBe("skywalker"); + }); + + test("unknown agent errors with guidance", () => { + const r = resolveDirector({ agentId: "pontusbot" }); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error).toContain("Unknown director"); + expect(r.hint).toContain("implement"); + } + }); + + test("intent map defaults (no general)", () => { + expect(resolveDirector({ intent: "implement" })).toMatchObject({ + ok: true, + package: { id: "implement" }, + }); + expect(resolveDirector({ intent: "explore" })).toMatchObject({ + ok: true, + package: { id: "explore" }, + }); + expect(resolveDirector({ intent: "plan" })).toMatchObject({ + ok: true, + package: { id: "plan" }, + }); + expect(resolveDirector({ intent: "review" })).toMatchObject({ + ok: true, + package: { id: "critique" }, + }); + const general = resolveDirector({ intent: "general" }); + expect(general.ok).toBe(false); + if (!general.ok) expect(general.error).toContain("general"); + }); + + test("explicit agentId wins over intent", () => { + const r = resolveDirector({ agentId: "greybeard", intent: "implement" }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.package.id).toBe("greybeard"); + }); + + test("missing agent and intent errors", () => { + const r = resolveDirector({}); + expect(r.ok).toBe(false); + }); + + test("isDirectorId", () => { + expect(isDirectorId("critique")).toBe(true); + expect(isDirectorId("nope")).toBe(false); + }); + + test("intent defaults table is complete for non-general intents", () => { + expect(Object.keys(INTENT_DEFAULT_DIRECTOR).sort()).toEqual( + ["explore", "implement", "plan", "review"].sort(), + ); + }); + + test("packageToProfile maps envelope and spawn", () => { + const explore = packageToProfile(DIRECTOR_REGISTRY.explore); + expect(explore.id).toBe("explore"); + expect(explore.systemPromptRole).toContain('agent id `explore`'); + expect(explore.systemPromptRole).toContain(DIRECTOR_REGISTRY.explore.systemPrompt); + expect(explore.description).toContain("agent id: explore"); + expect(explore.capabilities?.mode).toBe("allow"); + expect(explore.capabilities?.tools).toContain("read_file"); + expect(explore.capabilities?.tools).not.toContain("write_file"); + expect(explore.orchestrator).toBe(false); + + const grey = packageToProfile(DIRECTOR_REGISTRY.greybeard); + expect(grey.orchestrator).toBe(true); + expect(grey.maxTurns).toBe(DIRECTOR_REGISTRY.greybeard.nudge?.maxTurns); + + const shakespeare = packageToProfile(DIRECTOR_REGISTRY.shakespeare); + expect(shakespeare.writePaths).toBeUndefined(); + expect(shakespeare.capabilities?.mode).toBe("allow"); + expect(shakespeare.capabilities?.tools).toContain("write_file"); + }); + + test("directorProfiles is the spawn catalog (closed set minus skywalker)", () => { + const profiles = directorProfiles(); + expect(profiles).toHaveLength(15); + expect(new Set(profiles.map((p) => p.id)).size).toBe(15); + expect(profiles.map((p) => p.id)).not.toContain("skywalker"); + }); + + // Phase 5 acceptance (CL-5818 / CL-5843): spawn matrix, review envelopes, primary stance. + test("greybeard spawn allowlist is intern/explore/critique only", () => { + const g = DIRECTOR_REGISTRY.greybeard; + expect(g.spawn.maySpawn).toBe(true); + expect(g.spawn.allowlist?.slice().sort()).toEqual(["critique", "explore", "intern"]); + expect(packageToProfile(g).orchestrator).toBe(true); + }); + + test("review and design leaves deny product write tools", () => { + for (const id of [ + "critique", + "greybeard", + "neckbeard", + "draper", + "emil", + "explore", + "plan", + "testsmith", + "tester", + "gaasbot", + "skywalker", + ] as const) { + const allow = DIRECTOR_REGISTRY[id].tools?.allow ?? []; + expect(allow).not.toContain("write_file"); + expect(allow).not.toContain("edit_file"); + expect(allow).not.toContain("delete_file"); + } + }); + + test("no shipped director in DIRECTOR_IDS has a non-empty writePaths", () => { + for (const id of DIRECTOR_IDS) { + const paths = DIRECTOR_REGISTRY[id].writePaths; + expect(paths === undefined || paths.length === 0).toBe(true); + } + }); + + test("implement mounts product writes; intern is shell-only; other leaves do not spawn", () => { + expect(DIRECTOR_REGISTRY.implement.tools?.allow).toEqual( + expect.arrayContaining(["write_file", "edit_file", "delete_file"]), + ); + const internAllow = DIRECTOR_REGISTRY.intern.tools?.allow ?? []; + expect(internAllow).toContain("run_shell"); + expect(internAllow).not.toContain("write_file"); + expect(internAllow).not.toContain("edit_file"); + for (const id of DIRECTOR_IDS) { + if (id === "skywalker" || id === "greybeard") continue; + expect(DIRECTOR_REGISTRY[id].spawn.maySpawn).toBe(false); + } + }); + + test("skywalker primary stance: never implement, no product write tools", () => { + const s = DIRECTOR_REGISTRY.skywalker; + expect(s.systemPrompt).toContain("NEVER implement"); + expect(s.systemPrompt).toContain("You are Skywalker"); + expect(s.systemPrompt).toMatch(/No catch-all worker/i); + expect(s.tools?.allow).toContain("task"); + expect(s.tools?.allow).not.toContain("write_file"); + expect(s.spawn.allowlist).toHaveLength(15); + }); + + test("writePaths guards: a director with non-empty writePaths never allows run_shell", () => { + for (const id of DIRECTOR_IDS) { + const pkg = DIRECTOR_REGISTRY[id]; + if (!pkg.writePaths || pkg.writePaths.length === 0) continue; + const allow = pkg.tools?.allow ?? []; + expect(allow).not.toContain("run_shell"); + } + }); + + test("every director profile declares matching agent id in system prompt", () => { + for (const id of DIRECTOR_IDS) { + const profile = packageToProfile(DIRECTOR_REGISTRY[id]); + expect(profile.systemPromptRole).toContain(`agent id \`${id}\``); + expect(profile.systemPromptRole).toContain(`task(agent="${id}")`); + expect(profile.description).toContain(`agent id: ${id}`); + } + }); +}); diff --git a/src/agent/directors/registry.ts b/src/agent/directors/registry.ts new file mode 100644 index 000000000..a1ef361c0 --- /dev/null +++ b/src/agent/directors/registry.ts @@ -0,0 +1,140 @@ +import type { AgentProfile, CapabilityFilter } from "../profile-types.js"; +import { brandReviewerPackage } from "./brand-reviewer/index.js"; +import { bruckheimerPackage } from "./bruckheimer/index.js"; +import { critiquePackage } from "./critique/index.js"; +import { draperPackage } from "./draper/index.js"; +import { emilPackage } from "./emil/index.js"; +import { explorePackage } from "./explore/index.js"; +import { gaasbotPackage } from "./gaasbot/index.js"; +import { greybeardPackage } from "./greybeard/index.js"; +import { implementPackage } from "./implement/index.js"; +import { internPackage } from "./intern/index.js"; +import { neckbeardPackage } from "./neckbeard/index.js"; +import { planPackage } from "./plan/index.js"; +import { shakespearePackage } from "./shakespeare/index.js"; +import { skywalkerPackage } from "./skywalker/index.js"; +import { testerPackage } from "./tester/index.js"; +import { testsmithPackage } from "./testsmith/index.js"; +import { formatDirectorSystemPrompt } from "./identity.js"; +import { + DIRECTOR_IDS, + type DirectorId, + type DirectorPackage, + type ResolveDirectorInput, + type ResolveDirectorResult, + type TaskIntent, +} from "./types.js"; + +/** Intent → default director when `task(agent=…)` is omitted. No general director. */ +export const INTENT_DEFAULT_DIRECTOR: Readonly<Record<Exclude<TaskIntent, "general">, DirectorId>> = { + implement: "implement", + explore: "explore", + plan: "plan", + review: "critique", +}; + +/** + * Closed v1 registry — full packages (prompts, envelopes, spawn, nudge, modelRole). + * Leaf modules own package bodies; this file only fans them in. + */ +export const DIRECTOR_REGISTRY: Readonly<Record<DirectorId, DirectorPackage>> = { + skywalker: skywalkerPackage, + implement: implementPackage, + explore: explorePackage, + plan: planPackage, + intern: internPackage, + critique: critiquePackage, + greybeard: greybeardPackage, + neckbeard: neckbeardPackage, + bruckheimer: bruckheimerPackage, + gaasbot: gaasbotPackage, + draper: draperPackage, + emil: emilPackage, + "brand-reviewer": brandReviewerPackage, + shakespeare: shakespearePackage, + testsmith: testsmithPackage, + tester: testerPackage, +}; + +export function isDirectorId(value: unknown): value is DirectorId { + return typeof value === "string" && (DIRECTOR_IDS as readonly string[]).includes(value); +} + +export function listDirectors(): readonly DirectorPackage[] { + return DIRECTOR_IDS.map((id) => DIRECTOR_REGISTRY[id]); +} + +/** + * Resolve a director package for dispatch. + * Explicit `agentId` wins; otherwise intent maps to a default. + * `general` never maps to a director — reclassify only. + */ +export function resolveDirector(input: ResolveDirectorInput): ResolveDirectorResult { + if (input.agentId !== undefined && input.agentId !== "") { + if (!isDirectorId(input.agentId)) { + const known = DIRECTOR_IDS.join(", "); + return { + ok: false, + error: `Unknown director "${input.agentId}".`, + hint: `Use one of: ${known}. Or omit agent and pass intent (implement|explore|plan|review).`, + }; + } + return { ok: true, package: DIRECTOR_REGISTRY[input.agentId] }; + } + + const intent = input.intent; + if (intent === undefined) { + return { + ok: false, + error: "No director selected.", + hint: "Pass task(agent=…) for a named director, or task(intent=implement|explore|plan|review).", + }; + } + if (intent === "general") { + return { + ok: false, + error: 'Intent "general" is not a director — reclassify.', + hint: "Pick implement, explore, plan, or review (or a named director via agent=).", + }; + } + const id = INTENT_DEFAULT_DIRECTOR[intent]; + return { ok: true, package: DIRECTOR_REGISTRY[id] }; +} + +/** Map package tool envelope → profile capability filter. Prefer allow (small mount). */ +export function packageToCapabilities(pkg: DirectorPackage): CapabilityFilter | undefined { + const allow = pkg.tools?.allow; + if (allow !== undefined && allow.length > 0) { + return { mode: "allow", tools: [...allow] }; + } + const deny = pkg.tools?.deny; + if (deny !== undefined && deny.length > 0) { + return { mode: "exclude", tools: [...deny] }; + } + return undefined; +} + +/** Map a director package to a spawnable agent profile (defaults / search_agents). */ +export function packageToProfile(pkg: DirectorPackage): AgentProfile { + const capabilities = packageToCapabilities(pkg); + return { + id: pkg.id, + description: `${pkg.description} (agent id: ${pkg.id})`, + systemPromptRole: formatDirectorSystemPrompt(pkg), + // Nested spawn is still gated by allowOrchestrator on the parent task tool. + // Greybeard/skywalker maySpawn marks intent; leaves stay non-orchestrator. + orchestrator: pkg.spawn.maySpawn, + ...(pkg.nudge?.maxTurns !== undefined ? { maxTurns: pkg.nudge.maxTurns } : {}), + ...(capabilities !== undefined ? { capabilities } : {}), + ...(pkg.writePaths !== undefined && pkg.writePaths.length > 0 + ? { writePaths: [...pkg.writePaths] } + : {}), + }; +} + +/** Spawnable director profiles (closed set minus primary skywalker). */ +export function directorProfiles(): AgentProfile[] { + return listDirectors() + .filter((pkg) => pkg.id !== "skywalker") + .map(packageToProfile); +} diff --git a/src/agent/directors/shakespeare/index.ts b/src/agent/directors/shakespeare/index.ts new file mode 100644 index 000000000..706801ab3 --- /dev/null +++ b/src/agent/directors/shakespeare/index.ts @@ -0,0 +1 @@ +export { shakespearePackage } from "./package.js"; diff --git a/src/agent/directors/shakespeare/package.test.ts b/src/agent/directors/shakespeare/package.test.ts new file mode 100644 index 000000000..41501eb47 --- /dev/null +++ b/src/agent/directors/shakespeare/package.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; +import { shakespearePackage } from "./package.js"; + +describe("shakespearePackage", () => { + test("id matches directory / registry id", () => { + expect(shakespearePackage.id).toBe("shakespeare"); + }); + + test("systemPrompt is non-empty and not a Placeholder", () => { + expect(shakespearePackage.systemPrompt.length).toBeGreaterThan(0); + expect(shakespearePackage.systemPrompt.startsWith("Placeholder")).toBe(false); + }); + + test("systemPrompt names Shakespeare and states PRIMARY INTENT", () => { + expect(shakespearePackage.systemPrompt).toMatch(/Shakespeare/i); + expect(shakespearePackage.systemPrompt).toContain("PRIMARY INTENT"); + expect(shakespearePackage.systemPrompt).toMatch(/product/i); + expect(shakespearePackage.systemPrompt).toMatch(/architecture/i); + expect(shakespearePackage.systemPrompt).toMatch(/implementation/i); + }); + + test("systemPrompt bakes scribe workflow without requiring use_skill scribe", () => { + const prompt = shakespearePackage.systemPrompt; + expect(prompt).toMatch(/Document discovery|document discovery/i); + expect(prompt).toMatch(/gap/i); + expect(prompt).toMatch(/cross-document|cross-doc|consistency/i); + expect(prompt).toMatch(/interview|question/i); + expect(prompt).not.toMatch(/use_skill\s*\(\s*["']scribe["']\s*\)/); + }); + + test("spawn.maySpawn is false (leaf)", () => { + expect(shakespearePackage.spawn.maySpawn).toBe(false); + }); + + test("tools.allow includes write tools; writePaths is omitted", () => { + const allow = shakespearePackage.tools?.allow ?? []; + expect(allow).toContain("write_file"); + expect(allow).toContain("edit_file"); + expect(shakespearePackage.writePaths).toBeUndefined(); + }); + + test("report.requiredSections includes Summary, Findings, Blockers, Paths", () => { + const sections = shakespearePackage.report.requiredSections; + expect(sections).toContain("Summary"); + expect(sections).toContain("Findings"); + expect(sections).toContain("Blockers"); + expect(sections).toContain("Paths"); + }); + + test("modelRole is docs", () => { + expect(shakespearePackage.modelRole).toBe("docs"); + }); + + test("optionalSkills are style and philosophy", () => { + expect(shakespearePackage.optionalSkills).toEqual(["style", "philosophy"]); + }); + + test("nudge.maxTurns is 50", () => { + expect(shakespearePackage.nudge?.maxTurns).toBe(50); + }); + + test("primaryIntent is docs maintain", () => { + expect(shakespearePackage.primaryIntent).toMatch(/docs|documentation|PRODUCT|product/i); + }); +}); diff --git a/src/agent/directors/shakespeare/package.ts b/src/agent/directors/shakespeare/package.ts new file mode 100644 index 000000000..c883756e7 --- /dev/null +++ b/src/agent/directors/shakespeare/package.ts @@ -0,0 +1,82 @@ +import type { DirectorPackage } from "../types.js"; +import { DOCS_TOOLS } from "../tool-sets.js"; + +/** + * Shakespeare: docs-maintenance leaf with scribe core baked into systemPrompt. + */ +const SHAKESPEARE_SYSTEM_PROMPT = `You are Shakespeare, a specialist in Corbits Code. + +PRIMARY INTENT: maintain product, architecture, and implementation documentation. Route input to the correct doc, detect gaps, interview for completeness, and keep cross-doc consistency. You are not an implementer, not a reviewer, not an orchestrator. + +# Document types + +**PRODUCT.md** — what we build and why: user value, vision, goals, target users, business justification. + +**ARCHITECTURE.md** — how the system is structured: components, relationships, abstractions, data/control flow, technology-agnostic design decisions. + +**IMPLEMENTATION.md** — concrete tech: libraries, protocols, formats, configuration, deployment specifics. + +# Workflow (scribe core) + +## 0. Document discovery + +Before processing input, locate docs (case-insensitive) in repo root and \`docs/\`: +- PRODUCT.md, ARCHITECTURE.md, IMPLEMENTATION.md +- Prefer root when multiple matches exist. +- Defaults when missing: create at repository root. + +Read all existing docs first to learn project vocabulary, patterns, constraints, and similar features for context-aware questions. + +## 1. Analyze and classify input + +Classify by general heuristics and project-specific signals from existing docs (project vocabulary wins when clear): + +- **Product:** user needs, value, market, "users can", goals without how +- **Architecture:** components, interactions, abstractions, tech-agnostic design +- **Implementation:** named technologies, wire formats, config, "uses"/"built on" + +## 2. Route and deepen + +If classification is clear, update the right document. +If ambiguous or multi-category, do not ask only "which document?" — interview to decompose into distinct claims and route each precisely. Prefer context-aware options from existing docs; fall back to general options when docs are empty/minimal. One statement may update multiple docs. + +## 3. Update document + +Read the target, place content (extend section / new section / revise), match existing style. Significant changes (new concept/component/capability, contradiction, top-level decision) trigger steps 4–5. Minor clarifications skip to report. + +## 4. Cross-document consistency (significant only) + +Check sibling docs for implied missing entries (e.g. new architecture with no product justification, product capability with no architecture, implementation naming an undescribed component). Interview with 2–4 targeted questions; update docs from answers. + +## 5. Gap detection (significant only) + +Scan for thin sections, undefined references, missing failure modes/constraints, decisions without rationale. Ask 2–4 probing questions with contextual options. If the user declines 3+ gap questions this session, stop probing unless they ask. + +## 6. Report + +Confirm what changed and where. Summarize consistency/gap follow-ups. + +Write tools are mounted with no path lock. PRIMARY INTENT is still PRODUCT/ARCHITECTURE/IMPLEMENTATION — do not implement product source code, run the fleet, or act as tester/reviewer. + +OUT OF LANE: shipping product features, pure code review, orchestration, treating docs as optional. + +Report: Summary, Findings, Blockers, Paths.`; + +export const shakespearePackage: DirectorPackage = { + id: "shakespeare", + primaryIntent: "Maintain product, architecture, and implementation docs", + outOfLane: [ + "shipping product features", + "pure code review", + "orchestration / fleet control", + "acting as tester or implementer", + ], + description: "Docs maintenance leaf — PRODUCT / ARCHITECTURE / IMPLEMENTATION", + systemPrompt: SHAKESPEARE_SYSTEM_PROMPT, + optionalSkills: ["style", "philosophy"], + tools: { allow: DOCS_TOOLS }, + spawn: { maySpawn: false }, + nudge: { maxTurns: 50 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + modelRole: "docs", +}; diff --git a/src/agent/directors/skywalker/index.ts b/src/agent/directors/skywalker/index.ts new file mode 100644 index 000000000..020759240 --- /dev/null +++ b/src/agent/directors/skywalker/index.ts @@ -0,0 +1 @@ +export { createSkywalkerSystemPrompt, skywalkerPackage } from "./package.js"; diff --git a/src/agent/directors/skywalker/package.test.ts b/src/agent/directors/skywalker/package.test.ts new file mode 100644 index 000000000..4545ed319 --- /dev/null +++ b/src/agent/directors/skywalker/package.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, test } from "bun:test"; +import { createSkywalkerSystemPrompt, skywalkerPackage } from "./package.js"; + +describe("skywalkerPackage", () => { + test("id matches directory", () => { + expect(skywalkerPackage.id).toBe("skywalker"); + }); + + test("systemPrompt is real, not placeholder", () => { + expect(skywalkerPackage.systemPrompt.length).toBeGreaterThan(0); + expect(skywalkerPackage.systemPrompt.startsWith("Placeholder")).toBe(false); + expect(skywalkerPackage.systemPrompt).toContain("You are Skywalker"); + expect(skywalkerPackage.systemPrompt).toContain("When asked your name, answer: Skywalker"); + expect(skywalkerPackage.systemPrompt).toContain("PRIMARY INTENT"); + expect(skywalkerPackage.systemPrompt).toContain("NEVER implement"); + }); + + test("createSkywalkerSystemPrompt returns package systemPrompt", () => { + expect(createSkywalkerSystemPrompt()).toBe(skywalkerPackage.systemPrompt); + }); + + test("maySpawn true with full closed allowlist", () => { + expect(skywalkerPackage.spawn.maySpawn).toBe(true); + expect(skywalkerPackage.spawn.allowlist).toHaveLength(15); + expect(skywalkerPackage.spawn.allowlist).toEqual([ + "implement", + "explore", + "plan", + "intern", + "critique", + "greybeard", + "neckbeard", + "bruckheimer", + "gaasbot", + "draper", + "emil", + "brand-reviewer", + "shakespeare", + "testsmith", + "tester", + ]); + }); + + test("tools.allow mounts orchestrator surface without product writes", () => { + const allow = skywalkerPackage.tools?.allow ?? []; + expect(allow).toContain("task"); + expect(allow).toContain("search_agents"); + expect(allow).not.toContain("write_file"); + expect(allow).not.toContain("edit_file"); + expect(allow).not.toContain("delete_file"); + }); + + test("report required sections", () => { + expect(skywalkerPackage.report.requiredSections).toEqual([ + "Summary", + "Findings", + "Blockers", + "Paths", + ]); + }); + + test("modelRole is orchestrator", () => { + expect(skywalkerPackage.modelRole).toBe("orchestrator"); + }); + + test("optionalSkills order", () => { + expect(skywalkerPackage.optionalSkills).toEqual([ + "dispatch", + "style", + "philosophy", + "interview", + ]); + }); + + test("primaryIntent and outOfLane", () => { + expect(skywalkerPackage.primaryIntent).toBe( + "Orchestrate only — triage and dispatch; do not implement product code", + ); + expect(skywalkerPackage.outOfLane).toContain("product edits"); + expect(skywalkerPackage.outOfLane).toContain("catch-all worker"); + expect(skywalkerPackage.outOfLane).toContain( + "searching the repo yourself after a worker stops without finishing", + ); + expect(skywalkerPackage.outOfLane).toContain("diagnostic fleets for why/how/stall questions"); + }); + + test("nudge maxTurns", () => { + expect(skywalkerPackage.nudge?.maxTurns).toBe(100); + }); + + test("systemPrompt parent tools tell the parent not to run long-blocking jobs", () => { + const p = skywalkerPackage.systemPrompt; + expect(p).toContain("Parent tools"); + expect(p).toContain("long-blocking"); + expect(p).toContain("tool.boundary"); + expect(p).toContain("Dispatch intern"); + }); + + test("systemPrompt has effort scaling / fan-out ladder", () => { + const p = skywalkerPackage.systemPrompt; + expect(p).toContain("Effort scaling"); + expect(p).toContain("fan-out"); + expect(p).toContain("0–1 worker"); + expect(p).toContain("2–4 workers"); + expect(p).toContain("split ownership by path/package"); + expect(p).toContain("at most 4 workers at once"); + }); + + test("systemPrompt anti-cascade keeps digs out of fleets", () => { + const p = skywalkerPackage.systemPrompt; + expect(p).toContain("Anti-cascade"); + expect(p).toContain("COMMUNICATION first"); + expect(p).toContain("Never spawn parallel"); + expect(p).toContain("one explore worker"); + expect(p).toContain("search the repo yourself after a worker stops"); + expect(p).toContain("Do not reclassify COMMUNICATION as ORCHESTRATION"); + }); + + test("systemPrompt simple path skips explore+critique for tiny work", () => { + const p = skywalkerPackage.systemPrompt; + expect(p).toContain("one implement worker"); + expect(p).toContain("skip explore and skip critique"); + expect(p).toContain("tests green"); + expect(p).toContain("Do not always explore→implement→critique"); + }); + + test("systemPrompt routes URL reads through web_fetch on primary", () => { + const p = skywalkerPackage.systemPrompt; + expect(p).toContain("Fetch URLs"); + expect(p).toContain("web_fetch"); + expect(p).toContain("already mounted"); + expect(p).toContain("curl/wget"); + }); + + test("systemPrompt requires brief completeness for multi-worker dispatch", () => { + const p = skywalkerPackage.systemPrompt; + expect(p).toContain("Brief completeness"); + expect(p).toContain("success_criteria"); + expect(p).toContain("do_not"); + expect(p).toContain("report_focus"); + expect(p).toContain("multi-worker"); + }); + + test("systemPrompt does not use leaf jargon", () => { + expect(skywalkerPackage.systemPrompt).not.toMatch(/\bleaf\b/i); + expect(skywalkerPackage.systemPrompt).not.toMatch(/\bleaves\b/i); + }); + + test("systemPrompt puts API signatures into implement success_criteria", () => { + const p = skywalkerPackage.systemPrompt; + expect(p).toContain("function signature or return shape"); + expect(p).toContain("verbatim"); + expect(p).toContain("sync vs Promise"); + expect(p).toContain("implement success_criteria"); + }); + + test("systemPrompt has critique-after-implement verify path", () => { + const p = skywalkerPackage.systemPrompt; + expect(p).toContain("Verify after ship"); + expect(p).toContain("public-API"); + expect(p).toContain("critique"); + expect(p).toContain("tester"); + expect(p).toContain("correctness/brief gaps"); + }); + + test("systemPrompt re-dispatches implement on blocking critique", () => { + const p = skywalkerPackage.systemPrompt; + expect(p).toContain("blocking"); + expect(p).toContain("re-dispatch"); + expect(p).toContain("ship → verify → fix → re-verify"); + expect(p).toContain("Cap re-fix rounds"); + }); +}); diff --git a/src/agent/directors/skywalker/package.ts b/src/agent/directors/skywalker/package.ts new file mode 100644 index 000000000..b6fd451cb --- /dev/null +++ b/src/agent/directors/skywalker/package.ts @@ -0,0 +1,195 @@ +// Skywalker: primary orchestration director. Chains specialists into a workflow. + +import type { DirectorPackage } from "../types.js"; +import { ORCHESTRATOR_TOOLS } from "../tool-sets.js"; + +const SKYWALKER_SYSTEM_PROMPT = `You are Skywalker — the primary orchestrator for Corbits Code. + +When asked your name, answer: Skywalker. +Agent id: skywalker (primary session; not a spawned worker). Start specialists with task(agent="…"). + +PRIMARY INTENT: run the workflow. Classify every request. Delegate. Chain specialists into a sequence of actions. Track who is running. Synthesize for the operator. Do not become the implementer, reviewer, or explorer by default. + +You do not do the specialists' jobs. You start them, wait for their reports, and decide the next action from those reports. + +# Parent tools + +Do not run long-blocking jobs on the parent (evals, full test suites, long installs). Dispatch intern (mechanical shell) or tester (suite / repro). + +task() still awaits the worker's full report. Enter mid-run delivers at the next parent tool.boundary — a long parent run_shell or awaiting task() holds those steers. Dispatching a worker does not make Enter a new turn until that parent tool returns. + +Example chains: +- tiny fix: implement +- feature: explore → implement → critique +- "why / how / is this stalled": answer yourself; at most one explore if a single unknown blocks you + +Closed directors (use search_agents / registry; each id matches task(agent="<id>")): implement, explore, plan, intern, critique, greybeard, neckbeard, bruckheimer, gaasbot, draper, emil, brand-reviewer, shakespeare, testsmith, tester. +No catch-all worker. If unsure, reclassify — do not spawn a blob agent. + +Quick routing: +- explore = map/read codebase +- plan = ordered eng plan (no ship) +- implement = ship product code + tests +- critique = defects with evidence (no fix) +- greybeard = architecture judgment +- neckbeard = hygiene / pedantry with receipts +- tester = run the suite / repro +- testsmith = design permanent test cases +- shakespeare = PRODUCT/ARCHITECTURE/IMPLEMENTATION docs +- brand-reviewer = DESIGN.md only +- draper = visual/CBS review +- emil = design-eng laws review +- gaasbot = risk counsel +- bruckheimer = product discovery docs +- intern = exact shell / mechanical ops +- After multi-file implement landings → default a critique (or greybeard when architecture is in play) on the diff/criteria in a fresh context + +Prefer typed spawn: intent, success_criteria, do_not, report_focus, agent when specialist. +Parallelize independent lanes. manage_tasks for your checklist. ask_operator when blocked or ambiguous. + +# Fetch URLs (primary-mounted) + +When the operator (or brief) gives an http(s) URL to read: +- Call **web_fetch** yourself on that URL — it is already mounted. Do not tool_search for it, do not shell curl/wget/fetch, do not thrash run_shell to download pages. +- After you have the content, spawn implement only if a file must be written (e.g. write the extracted fact). For pure Q&A from a URL, answer directly. +- Cap retries: if web_fetch fails once with a clear error, report the blocker — do not burn a long tool-only streak on shell workarounds. + +# Effort scaling (IMPLEMENTATION / ORCHESTRATION) + +Scale fan-out to the ask — do not spawn 10+ workers for a simple request: +- Simple (answer, one-path lookup, tiny fix): 0–1 worker, few tools; often answer without fleet +- Tiny single-file / one-route asks: **one implement worker**; skip explore and skip critique when implement reports tests green and criteria mapped pass. Do not always explore→implement→critique for simple work — that burns wall clock. +- Medium: 2–4 workers with distinct path/package ownership +- Complex: more workers only with named lanes and clear non-overlap +Hard cap: **at most 4 workers at once** unless the operator explicitly asks for a wider fan-out. Prefer synthesizing early returns over launching a second wave. +Cap default fan-out. Parallel same-agent spawns MUST split ownership by path/package (distinct lenses). + +# Anti-cascade (stall / dig / diagnose) + +Do **not** turn a "why is this stalled / why no thinking / spawn looks broken" dig into a fleet: +- Classify digs, screenshots of Task rows, and "why/how does X work" as COMMUNICATION first. +- Answer from mounted tools + known architecture; at most **one** explore worker if a single unknown path blocks the answer. +- Never spawn parallel "parent UI / child UI / stream events / prompt guardrail / session dig" waves for the same question. +- When workers stall, loop, or come back unfinished: synthesize what returned, report Blockers, and change approach — do **not** re-fan-out another diagnostic wave on the same topic. +- Do **not** search the repo yourself after a worker stops without finishing. Change the brief (success_criteria / do_not / agent) or tell the operator. Then start the next worker if the job still needs doing. +- Permission asks and long run_shell clocks on Task rows are not a signal to spawn more diggers. + +# Brief completeness + +For multi-step or multi-worker dispatch, prefer typed spawn with success_criteria, do_not, and report_focus (plus intent/agent). Do not fire multi-worker waves with one-line vague briefs — flesh the brief first. +When the operator brief states a function signature or return shape, put that **verbatim** into implement success_criteria (including sync vs Promise if stated or implied by existing code/tests). + +# Verify after ship + +Multi-file or public-API changes: after implement, run **critique** focused on brief + public API contract (sync/async, signatures). Prefer **tester** when you need independent suite evidence and implement's self-report is thin. +If critique (or tester) reports **blocking** findings: re-dispatch **implement** with those findings in success_criteria/do_not — do not declare done on a "ready" that ignored blockers. +Close the loop: ship → verify → fix → re-verify. Cap re-fix rounds (e.g. 1–2) then report Blockers. +Critique flags correctness/brief gaps only — not over-engineering theater. + +# Mandatory workflow for every request + +Before responding, classify: + +1. IMPLEMENTATION — build, create, modify, or add product code/features +2. ORCHESTRATION — plan, coordinate, or manage work in progress +3. COMMUNICATION — answer a question, provide information, or clarify + +## If IMPLEMENTATION → dispatch; NEVER implement directly + +1. If requirements are fuzzy or complex, load interview and discover first. +2. Use explore workers for scope when needed. +3. Consult greybeard on architecture/approach before large multi-lane work. +4. Use plan or the dispatch skill for multi-lane eng plans; clarify before large dispatch. +5. Present the plan when the change is large or ambiguous; then execute via task spawns. +6. Track progress with manage_tasks; synthesize results for the operator. + +Forbidden: product Write/Edit, "just quickly" shipping code yourself, implementing to save time. + +## If ORCHESTRATION → coordinate + +Track with manage_tasks. Parallelize independent lanes. Escalate blockers with ask_operator. This is your core role. + +## If COMMUNICATION → answer directly + +Clear and short. No dispatch for pure questions, digs, "why", screenshots of the UI, or architecture explainers. +If you need one code path confirmed, one explore worker — not a fleet. Prefer reading/searching yourself with mounted tools over spawning. +Do not reclassify COMMUNICATION as ORCHESTRATION just to justify parallel task spawns. + +# Non-negotiables + +- NEVER implement product features yourself (zero product Write/Edit). +- Interview when requirements are fuzzy; consult greybeard on architecture/approach. +- Use plan or dispatch skill for multi-lane eng plans; clarify before large dispatch. +- Product file mutation tools (write_file, edit_file, delete_file) are not mounted on this session. Track work with manage_tasks; spawn implement (code), shakespeare (P/A/I docs), or brand-reviewer (DESIGN.md) for durable artifacts. +- Before any product file op, self-check: "Am I implementing instead of orchestrating?" If yes, STOP and spawn implement. +- Optional skills when needed on the primary session: dispatch, style, philosophy, interview (use_skill is primary-mounted). + + +# Spawn graph + +Skywalker = full closed set. Greybeard = limited spawn only (intern/explore/critique) — not a second primary. +You may spawn: implement, explore, plan, intern, critique, greybeard, neckbeard, bruckheimer, gaasbot, draper, emil, brand-reviewer, shakespeare, testsmith, tester. + +When spawning, prefer a typed brief: +- intent — explore | implement | plan | review +- success_criteria — done-definition the worker must meet +- do_not — hard constraints +- report_focus — what the parent needs back +- agent — specialist id when known (must match a closed director id above) + +# Report shape + +When finishing a turn that closes work (or reporting a worker synthesis), use: + +## Summary +## Findings +## Blockers +## Paths + +Match operator tone. Short by default.`; + +export function createSkywalkerSystemPrompt(): string { + return SKYWALKER_SYSTEM_PROMPT; +} + +export const skywalkerPackage: DirectorPackage = { + id: "skywalker", + primaryIntent: "Orchestrate only — triage and dispatch; do not implement product code", + outOfLane: [ + "product edits", + "deep multi-path repo walks when a single explore worker or mounted tools suffice", + "being the reviewer/implementer by default", + "catch-all worker", + "diagnostic fleets for why/how/stall questions", + "searching the repo yourself after a worker stops without finishing", + ], + description: "Primary orchestration director — chains specialists into a workflow", + systemPrompt: SKYWALKER_SYSTEM_PROMPT, + optionalSkills: ["dispatch", "style", "philosophy", "interview"], + tools: { allow: ORCHESTRATOR_TOOLS }, + spawn: { + maySpawn: true, + allowlist: [ + "implement", + "explore", + "plan", + "intern", + "critique", + "greybeard", + "neckbeard", + "bruckheimer", + "gaasbot", + "draper", + "emil", + "brand-reviewer", + "shakespeare", + "testsmith", + "tester", + ], + }, + nudge: { maxTurns: 100 }, + report: { + requiredSections: ["Summary", "Findings", "Blockers", "Paths"], + }, + modelRole: "orchestrator", +}; diff --git a/src/agent/directors/tester/index.ts b/src/agent/directors/tester/index.ts new file mode 100644 index 000000000..5df005fc6 --- /dev/null +++ b/src/agent/directors/tester/index.ts @@ -0,0 +1 @@ +export { testerPackage } from "./package.js"; diff --git a/src/agent/directors/tester/package.test.ts b/src/agent/directors/tester/package.test.ts new file mode 100644 index 000000000..20ddcd1db --- /dev/null +++ b/src/agent/directors/tester/package.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { testerPackage } from "./package.js"; + +describe("testerPackage", () => { + test("id matches directory / registry id", () => { + expect(testerPackage.id).toBe("tester"); + }); + + test("systemPrompt is non-empty and not a Placeholder", () => { + expect(testerPackage.systemPrompt.length).toBeGreaterThan(0); + expect(testerPackage.systemPrompt.startsWith("Placeholder")).toBe(false); + }); + + test("systemPrompt states PRIMARY INTENT to verify not fix", () => { + expect(testerPackage.systemPrompt).toContain("PRIMARY INTENT"); + expect(testerPackage.systemPrompt).toMatch(/run|verify/i); + expect(testerPackage.systemPrompt).toMatch(/never fix|do not.*fix|Never fix/i); + }); + + test("spawn.maySpawn is false (leaf)", () => { + expect(testerPackage.spawn.maySpawn).toBe(false); + }); + + test("tools.allow is read-only (no product writes)", () => { + const allow = testerPackage.tools?.allow ?? []; + expect(allow).toContain("run_shell"); + expect(allow).toContain("read_file"); + expect(allow).not.toContain("write_file"); + expect(allow).not.toContain("edit_file"); + expect(allow).not.toContain("delete_file"); + }); + + test("report.requiredSections includes Summary, Findings, Blockers, Paths", () => { + const sections = testerPackage.report.requiredSections; + expect(sections).toContain("Summary"); + expect(sections).toContain("Findings"); + expect(sections).toContain("Blockers"); + expect(sections).toContain("Paths"); + }); + + test("modelRole is test", () => { + expect(testerPackage.modelRole).toBe("test"); + }); + + test("nudge.maxTurns is 40", () => { + expect(testerPackage.nudge?.maxTurns).toBe(40); + }); + + test("primaryIntent is runtime verify never fix", () => { + expect(testerPackage.primaryIntent).toMatch(/run|verify/i); + expect(testerPackage.primaryIntent).toMatch(/never fix/i); + }); +}); diff --git a/src/agent/directors/tester/package.ts b/src/agent/directors/tester/package.ts new file mode 100644 index 000000000..ced70bca3 --- /dev/null +++ b/src/agent/directors/tester/package.ts @@ -0,0 +1,38 @@ +import type { DirectorPackage } from "../types.js"; +import { READ_TOOLS } from "../tool-sets.js"; + +/** + * Tester: runtime verification specialist — run tests and report; never fix product code. + */ +export const testerPackage: DirectorPackage = { + id: "tester", + primaryIntent: "Run and verify tests; report results; never fix product code", + outOfLane: [ + "fixing product code", + "implementing features", + "designing test strategy as primary author (testsmith)", + "orchestration", + "docs-only work", + ], + description: "Runtime verify specialist — run tests, report, never fix", + systemPrompt: `You are TesterDirector, a specialist in Corbits Code. + +PRIMARY INTENT: run and verify tests for the brief, then report pass/fail evidence. Never fix product code. Never become the implementer. + +Workflow: +1. Identify the commands or suites the brief specifies (or project defaults when clear). +2. Run them via shell / harness-allowed tools. +3. Capture exit codes, key failures, and paths. +4. Report honestly — do not patch product source to make green. + +If tests fail: document failures, suspected area, and blockers. Do not write_file/edit_file product code. Suggest a re-dispatch to implement or testsmith when design gaps appear. + +OUT OF LANE: product Write/Edit, "just quickly" fixing, redesigning the whole suite as Testsmith's primary job, fleet orchestration. + +Report: Summary, Findings (commands + results), Blockers, Paths.`, + tools: { allow: READ_TOOLS }, + spawn: { maySpawn: false }, + nudge: { maxTurns: 40 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + modelRole: "test", +}; diff --git a/src/agent/directors/testsmith/index.ts b/src/agent/directors/testsmith/index.ts new file mode 100644 index 000000000..438c1f778 --- /dev/null +++ b/src/agent/directors/testsmith/index.ts @@ -0,0 +1 @@ +export { testsmithPackage } from "./package.js"; diff --git a/src/agent/directors/testsmith/package.test.ts b/src/agent/directors/testsmith/package.test.ts new file mode 100644 index 000000000..cb8b6c324 --- /dev/null +++ b/src/agent/directors/testsmith/package.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import { testsmithPackage } from "./package.js"; + +describe("testsmithPackage", () => { + test("id matches directory / registry id", () => { + expect(testsmithPackage.id).toBe("testsmith"); + }); + + test("systemPrompt is non-empty and not a Placeholder", () => { + expect(testsmithPackage.systemPrompt.length).toBeGreaterThan(0); + expect(testsmithPackage.systemPrompt.startsWith("Placeholder")).toBe(false); + }); + + test("systemPrompt states PRIMARY INTENT for test design", () => { + expect(testsmithPackage.systemPrompt).toContain("PRIMARY INTENT"); + expect(testsmithPackage.systemPrompt).toMatch(/test strategy|test cases|design/i); + expect(testsmithPackage.systemPrompt).toMatch(/do not implement|not implement/i); + }); + + test("spawn.maySpawn is false (leaf)", () => { + expect(testsmithPackage.spawn.maySpawn).toBe(false); + }); + + test("tools.allow is read-only (no product writes)", () => { + const allow = testsmithPackage.tools?.allow ?? []; + expect(allow).toContain("read_file"); + expect(allow).not.toContain("write_file"); + expect(allow).not.toContain("edit_file"); + expect(allow).not.toContain("delete_file"); + }); + + test("report.requiredSections includes Summary, Findings, Blockers, Paths", () => { + const sections = testsmithPackage.report.requiredSections; + expect(sections).toContain("Summary"); + expect(sections).toContain("Findings"); + expect(sections).toContain("Blockers"); + expect(sections).toContain("Paths"); + }); + + test("modelRole is test", () => { + expect(testsmithPackage.modelRole).toBe("test"); + }); + + test("nudge.maxTurns is 40", () => { + expect(testsmithPackage.nudge?.maxTurns).toBe(40); + }); + + test("primaryIntent is design-only and not primary verifier", () => { + expect(testsmithPackage.primaryIntent).toMatch(/design/i); + expect(testsmithPackage.primaryIntent).toMatch(/not.*verifier|do not run as primary verifier/i); + }); + + test("outOfLane refuses product implement and runtime verify role", () => { + const joined = testsmithPackage.outOfLane.join(" "); + expect(joined).toMatch(/implement/i); + expect(joined).toMatch(/verifier|tester/i); + }); +}); diff --git a/src/agent/directors/testsmith/package.ts b/src/agent/directors/testsmith/package.ts new file mode 100644 index 000000000..6274f4760 --- /dev/null +++ b/src/agent/directors/testsmith/package.ts @@ -0,0 +1,40 @@ +import type { DirectorPackage } from "../types.js"; +import { READ_TOOLS } from "../tool-sets.js"; + +/** + * Testsmith: test design specialist — strategy and cases only; never implements product + * and is not the runtime verifier (that is tester). + */ +export const testsmithPackage: DirectorPackage = { + id: "testsmith", + primaryIntent: + "Design test strategy and cases; do not implement product; do not run as primary verifier", + outOfLane: [ + "implementing product code", + "shipping features", + "acting as primary runtime verifier (tester)", + "fixing failing product code", + "orchestration", + ], + description: "Test design specialist — strategy and cases in the report only", + systemPrompt: `You are TestsmithDirector, a specialist in Corbits Code. + +PRIMARY INTENT: design test strategy and test cases for the brief. Produce clear, agent-ready coverage plans. Do not implement product code. Do not act as the primary runtime verifier (that is Tester). + +Design in the report (and optional notes under tmp/ only if the brief allows). Prefer: +- risk-based coverage and acceptance criteria from the brief +- unit / integration / e2e boundaries when relevant +- concrete cases: setup, action, expected result, edge/failure modes +- what not to test and why + +OUT OF LANE: product Write/Edit, fixing production code, becoming the implementer, running the full verify-and-fix loop, fleet orchestration. + +You may read and search the codebase to ground the design. You must not write product source. + +Report: Summary, Findings (strategy + cases), Blockers, Paths.`, + tools: { allow: READ_TOOLS }, + spawn: { maySpawn: false }, + nudge: { maxTurns: 40 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + modelRole: "test", +}; diff --git a/src/agent/directors/tool-sets.test.ts b/src/agent/directors/tool-sets.test.ts new file mode 100644 index 000000000..92b268ae6 --- /dev/null +++ b/src/agent/directors/tool-sets.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { + DOCS_TOOLS, + IMPLEMENT_TOOLS, + READ_TOOLS, +} from "./tool-sets.js"; + +describe("DOCS_TOOLS", () => { + test("excludes run_shell and delete_file as envelope policy", () => { + expect(DOCS_TOOLS).not.toContain("run_shell"); + expect(DOCS_TOOLS).not.toContain("delete_file"); + }); + + test("keeps read/search/lsp/web + file writes", () => { + const expected: readonly string[] = [ + "read_file", + "grep", + "search_files", + "list_dir", + "lsp", + "web_fetch", + "web_search", + "write_file", + "edit_file", + ]; + for (const tool of expected) { + expect(DOCS_TOOLS as readonly string[]).toContain(tool); + } + }); + + test("run_shell stays on the other surfaces", () => { + for (const surface of [READ_TOOLS, IMPLEMENT_TOOLS]) { + expect(surface).toContain("run_shell"); + } + }); +}); diff --git a/src/agent/directors/tool-sets.ts b/src/agent/directors/tool-sets.ts new file mode 100644 index 000000000..e750cc33d --- /dev/null +++ b/src/agent/directors/tool-sets.ts @@ -0,0 +1,53 @@ +// Small, explicit tool allowlists for director packages. +// Prefer tools.allow at mount (CapabilityFilter include) over huge deny lists. +// manage_tasks is always mounted by runSubAgent after the filter — omit it here. +// use_skill / tool_search / ask_operator are primary-session tools: leaves do +// not mount them (skill guidance is baked into package system prompts). + +/** Read/search/shell — no product mutation. */ +export const READ_TOOLS = [ + "read_file", + "grep", + "search_files", + "list_dir", + "lsp", + "run_shell", + "web_fetch", + "web_search", +] as const; + +/** Implement: read + full file mutation. */ +export const IMPLEMENT_TOOLS = [ + ...READ_TOOLS, + "write_file", + "edit_file", + "delete_file", +] as const; + +/** + * Docs leaves: read/search/lsp/web + file writes — no run_shell, no delete_file. + * Envelope policy, not a writePaths lock: docs leaves omit shell so they cannot + * mutate via the terminal. Optional package writePaths, when a profile sets it, + * is still enforced by the permission gate on path-keyed write tools. + * + * Composed from READ_TOOLS minus run_shell so it tracks the read surface + * automatically; only the write tools are added explicitly. + */ +export const DOCS_TOOLS = [ + ...READ_TOOLS.filter((t) => t !== "run_shell"), + "write_file", + "edit_file", +] as const; + +/** Review / counsel: read surface, no writes. */ +export const REVIEW_TOOLS = [...READ_TOOLS] as const; + +/** Mechanical intern: shell-first, minimal surface. */ +export const INTERN_TOOLS = ["run_shell", "read_file", "list_dir"] as const; + +/** Nested orchestrator surface (greybeard / package filter): dispatch only. */ +export const ORCHESTRATOR_TOOLS = [ + ...READ_TOOLS, + "search_agents", + "task", +] as const; diff --git a/src/agent/directors/types.ts b/src/agent/directors/types.ts new file mode 100644 index 000000000..cf51462b5 --- /dev/null +++ b/src/agent/directors/types.ts @@ -0,0 +1,93 @@ +// Closed director package contract for the v1 fleet (CL-5818). +// Prompt-first: system prompt is the opinionated core; skills are optional. + +export const DIRECTOR_IDS = [ + "skywalker", + "implement", + "explore", + "plan", + "intern", + "critique", + "greybeard", + "neckbeard", + "bruckheimer", + "gaasbot", + "draper", + "emil", + "brand-reviewer", + "shakespeare", + "testsmith", + "tester", +] as const; + +export type DirectorId = (typeof DIRECTOR_IDS)[number]; + +export type TaskIntent = "explore" | "implement" | "plan" | "review" | "general"; + +/** Static model-role tag for CL-5816 stub resolution (not a full package yet). */ +export type ModelRole = "orchestrator" | "implement" | "explore" | "review" | "plan" | "docs" | "test"; + +export type ToolEnvelope = { + /** Tools mounted when present — prefer small allowlists over deny-everything. */ + readonly allow?: readonly string[]; + /** Tools denied even if present in the session registry. Prefer allow when possible. */ + readonly deny?: readonly string[]; +}; + +export type SpawnRights = { + /** Whether this director may call `task`. */ + readonly maySpawn: boolean; + /** When set, only these director ids may be spawned. */ + readonly allowlist?: readonly DirectorId[]; +}; + +export type NudgePolicy = { + readonly maxTurns?: number; + /** Stall silence budget in ms before a parent-facing stall notice. */ + readonly stallMs?: number; +}; + +export type ReportContract = { + /** Required top-level sections in the worker report. */ + readonly requiredSections: readonly string[]; +}; + +/** + * One shipped director: hard primary intent + package fields. + * Packages land in later levels; registry holds the closed set. + */ +export type DirectorPackage = { + readonly id: DirectorId; + /** Hard primary intent lane — one job. */ + readonly primaryIntent: string; + /** Explicit out-of-lane work this director must refuse or reclassify. */ + readonly outOfLane: readonly string[]; + readonly description: string; + /** Opinionated core prompt (prompt-first). */ + readonly systemPrompt: string; + /** Optional skills the worker may load dynamically (ordered). */ + readonly optionalSkills?: readonly string[]; + readonly tools?: ToolEnvelope; + /** + * Authz write-path allowlist for write_file/edit_file/delete_file. + * Enforced by the permission gate (not prompt policy). A bare filename (no + * slash) matches only at the workspace root; a glob matches the resolved + * workspace-relative path; anything outside the worker cwd is denied. yolo + * mode bypasses this gate. Omitted = no path lock (tool allow/deny alone + * decides whether writes exist). + */ + readonly writePaths?: readonly string[]; + readonly spawn: SpawnRights; + readonly nudge?: NudgePolicy; + readonly report: ReportContract; + readonly modelRole: ModelRole; +}; + +export type ResolveDirectorInput = { + readonly agentId?: string; + readonly intent?: TaskIntent; +}; + +export type ResolveDirectorResult = + | { readonly ok: true; readonly package: DirectorPackage } + | { readonly ok: false; readonly error: string; readonly hint: string }; diff --git a/src/agent/environment.ts b/src/agent/environment.ts index da42f4815..4b40d6b43 100644 --- a/src/agent/environment.ts +++ b/src/agent/environment.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { readdir } from "node:fs/promises"; -import { release, type as osType } from "node:os"; +import { arch, release, type as osType } from "node:os"; import { promisify } from "node:util"; const run = promisify(execFile); @@ -8,6 +8,10 @@ const run = promisify(execFile); export type EnvironmentInfo = { cwd: string; platform: string; + /** CPU architecture (e.g. arm64, x64). */ + arch: string; + /** Runtime label (e.g. Bun 1.2.x or Node 22.x). */ + runtime: string; date: Date; isGitRepo: boolean; gitBranch?: string; @@ -78,9 +82,15 @@ async function gatherTopLevel(cwd: string): Promise<string | undefined> { export async function gatherEnvironment(cwd: string, date = new Date()): Promise<EnvironmentInfo> { const [gitInfo, topLevel] = await Promise.all([gatherGit(cwd), gatherTopLevel(cwd)]); + const runtime = + typeof Bun !== "undefined" + ? `Bun ${Bun.version}` + : `Node ${process.versions.node}`; return { cwd, platform: `${osType()} ${release()}`, + arch: arch(), + runtime, date, isGitRepo: false, ...gitInfo, diff --git a/src/agent/live-tool-dispatch.test.ts b/src/agent/live-tool-dispatch.test.ts new file mode 100644 index 000000000..b5d773f7a --- /dev/null +++ b/src/agent/live-tool-dispatch.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test"; + +import { createDynamicToolRunner } from "../tui/dynamic-tool-runner.js"; +import { + fallbackLiveToolBundle, + isLiveToolBundle, + withLiveToolDispatchMap, +} from "./live-tool-dispatch.js"; + +const stringTool = (name: string, reply: string) => ({ + kind: "string" as const, + definition: { + name, + description: name, + inputSchema: { type: "object" as const, properties: {}, required: [] as string[] }, + }, + handler: async () => reply, +}); + +describe("live tool dispatch fallback", () => { + test("recognizes a DynamicToolRunner as the live bundle", () => { + const runner = createDynamicToolRunner([stringTool("tool_search", "ok")]); + expect(isLiveToolBundle(runner)).toBe(true); + expect(isLiveToolBundle({ run: () => undefined })).toBe(false); + expect(isLiveToolBundle(null)).toBe(false); + }); + + test("falls back to the single live bundle and refuses to guess among several", () => { + const live = createDynamicToolRunner([stringTool("tool_search", "ok")]); + const other = createDynamicToolRunner([stringTool("present", "no")]); + + const one = new Map<unknown, unknown>([["tool_search", live]]); + expect(fallbackLiveToolBundle(one)).toBe(live); + + const many = new Map<unknown, unknown>([ + ["tool_search", live], + ["present", other], + ]); + expect(fallbackLiveToolBundle(many)).toBeUndefined(); + + const none = new Map<unknown, unknown>([["read_file", { run: () => undefined }]]); + expect(fallbackLiveToolBundle(none)).toBeUndefined(); + }); + + test("Map.get installed for createAgent resolves a late MCP name to the live bundle", () => { + const runner = createDynamicToolRunner([stringTool("tool_search", "ok")]); + + withLiveToolDispatchMap(() => { + const byName = new Map<string, unknown>(); + byName.set("tool_search", runner); + expect(byName.get("tool_search")).toBe(runner); + expect(byName.get("mcp__linear__list_issues")).toBe(runner); + expect(byName.get("read_file")).toBe(runner); + }); + + const after = new Map<string, unknown>(); + after.set("tool_search", runner); + expect(after.get("mcp__linear__list_issues")).toBeUndefined(); + }); + + test("restores Map even when the wrapped call throws", () => { + const before = globalThis.Map; + expect(() => + withLiveToolDispatchMap(() => { + throw new Error("boom"); + }), + ).toThrow("boom"); + expect(globalThis.Map).toBe(before); + }); +}); diff --git a/src/agent/live-tool-dispatch.ts b/src/agent/live-tool-dispatch.ts new file mode 100644 index 000000000..eb5902477 --- /dev/null +++ b/src/agent/live-tool-dispatch.ts @@ -0,0 +1,80 @@ +import { + createAgent, + type Agent, + type AgentDefinition, + type BaseEnv, +} from "@intx/agent"; + +// XXX — @intx/agent resolveTools snapshots `byName` from each bundle's +// definitions at createAgent and never consults a live getter. MCP tools +// arrive later via DynamicToolRunner.addTools (servers connect after the TUI +// is up; one OAuth-blocked server can also stall the post-connect reload +// that would rebuild the snapshot). A miss then returns `unknown tool` +// even though tool_search already listed the name from the live runner. +// +// resolveTools is not exported. During its synchronous walk it does +// `new Map()` for that snapshot; we install a Map whose get() falls back +// to the single live tool bundle so late names reach DynamicToolRunner.run. +// Restore Map before createAgent awaits so only that snapshot is live. +// Drop this wrapper when @intx/agent dispatches through the bundle's +// current definitions (the characterization test in +// tests/integration/mcp-late-dispatch.test.ts will fail first). + +const OriginalMap = globalThis.Map; + +export function isLiveToolBundle(value: unknown): boolean { + if (typeof value !== "object" || value === null) return false; + const candidate = value as Record<string, unknown>; + return ( + typeof candidate.addTools === "function" && + typeof candidate.currentDefinitions === "function" && + typeof candidate.run === "function" + ); +} + +export function fallbackLiveToolBundle<V>(map: Map<unknown, V>): V | undefined { + let found: V | undefined; + for (const value of map.values()) { + if (!isLiveToolBundle(value)) continue; + if (found !== undefined && found !== value) return undefined; + found = value; + } + return found; +} + +function createLiveDispatchMap<K, V>(iterable?: Iterable<readonly [K, V]> | null): Map<K, V> { + const map = new OriginalMap<K, V>(iterable ?? undefined); + const protoGet = OriginalMap.prototype.get.bind(map); + map.get = (key: K) => { + const hit = protoGet(key); + if (hit !== undefined || typeof key !== "string") return hit; + return fallbackLiveToolBundle(map) ?? hit; + }; + return map; +} + +// Compatible with `new Map()` inside published @intx/agent. Not a class — +// we only need a constructable that returns a Map with a live get(). +const LiveDispatchMap = Object.assign( + function LiveDispatchMap<K, V>(iterable?: Iterable<readonly [K, V]> | null): Map<K, V> { + return createLiveDispatchMap(iterable); + }, + { prototype: OriginalMap.prototype }, +) as unknown as MapConstructor; + +export function withLiveToolDispatchMap<T>(fn: () => T): T { + const previous = globalThis.Map; + globalThis.Map = LiveDispatchMap; + try { + return fn(); + } finally { + globalThis.Map = previous; + } +} + +export function createAgentWithLiveToolDispatch<EnvReq extends BaseEnv>( + def: AgentDefinition<EnvReq>, + env: EnvReq, +): Promise<Agent> { + return withLiveToolDispatchMap(() => createAgent(def, env)); +} diff --git a/src/agent/look-tour.test.ts b/src/agent/look-tour.test.ts new file mode 100644 index 000000000..661d27474 --- /dev/null +++ b/src/agent/look-tour.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from "bun:test"; +import { PRIMARY_SALVAGE_NUDGE } from "./look-tour.js"; + +describe("primary salvage nudge", () => { + test("tells Skywalker not to search the repo after a failed worker", () => { + expect(PRIMARY_SALVAGE_NUDGE).toContain("stopped without finishing"); + expect(PRIMARY_SALVAGE_NUDGE).toContain("Do not search the repo yourself"); + expect(PRIMARY_SALVAGE_NUDGE).toContain("Change the brief"); + }); +}); diff --git a/src/agent/look-tour.ts b/src/agent/look-tour.ts new file mode 100644 index 000000000..52ba39090 --- /dev/null +++ b/src/agent/look-tour.ts @@ -0,0 +1,7 @@ +/** + * After a worker hard-block salvage, nudge Skywalker once. + * Event-driven — not a look-count quota. Unique reads are legal at any volume. + */ + +export const PRIMARY_SALVAGE_NUDGE = + "A worker stopped without finishing. Synthesize Blockers for the operator. Do not search the repo yourself. Change the brief (success_criteria / do_not / agent) before starting another worker, or stop."; diff --git a/src/agent/model-family-policy.test.ts b/src/agent/model-family-policy.test.ts index bfc578554..c7d4f4702 100644 --- a/src/agent/model-family-policy.test.ts +++ b/src/agent/model-family-policy.test.ts @@ -9,12 +9,12 @@ describe("resolveModelFamilyPolicy", () => { expect(policy.toolOnlyTurnNudgeAt).toBeGreaterThan(20); }); - test("grok no longer tightens the tool-only nudge threshold below the default", () => { + test("grok shares the default sub-agent stall timeout (thinking gaps are long)", () => { const grok = resolveModelFamilyPolicy({ providerName: "xai/default", model: "grok-4.5" }); const base = resolveModelFamilyPolicy({ providerName: "anthropic", model: "claude-sonnet-4" }); expect(grok.family).toBe("grok"); expect(grok.toolOnlyTurnNudgeAt).toBe(base.toolOnlyTurnNudgeAt); - expect(grok.subAgentStallTimeoutMs).toBeLessThan(base.subAgentStallTimeoutMs); + expect(grok.subAgentStallTimeoutMs).toBe(base.subAgentStallTimeoutMs); }); test("grok finish-bias applies to leaves but not orchestrators", () => { diff --git a/src/agent/model-family-policy.ts b/src/agent/model-family-policy.ts index 5196d8733..83d295d65 100644 --- a/src/agent/model-family-policy.ts +++ b/src/agent/model-family-policy.ts @@ -55,18 +55,20 @@ const DEFAULT_POLICY: Omit<ModelFamilyPolicy, "family"> = { // previously motivated a tightened nudge/pause pair here (6/10). That pair // was miscalibrated: it fired on a session that was making real progress // through Linear lookups and code reads (CL-5611), well inside the healthy -// range other families tolerate. Grok keeps its own nudge copy and shorter -// sub-agent stall timeout — both still warranted — but shares the default -// tool-only-streak nudge threshold (the hard-pause thrash check is not -// family-tuned at all; it runs the same period detection for every family) -// rather than treating "no narration" as a family-specific failure mode. +// range other families tolerate. Grok keeps its own nudge copy — still +// warranted — but shares the default sub-agent stall timeout: live +// workbench fleets on grok-4.6 show routine 60–180s gaps between tool +// cycles while the model thinks, so a sub-2-minute kill was false-positive +// salvage mid-inference. The hard-pause thrash check is not family-tuned; +// it runs the same period detection for every family. const GROK_POLICY: Omit<ModelFamilyPolicy, "family"> = { toolOnlyTurnNudgeAt: DEFAULT_POLICY.toolOnlyTurnNudgeAt, wrapUpNudgeText: GROK_WRAP_UP_NUDGE_TEXT, - subAgentStallTimeoutMs: 90_000, + subAgentStallTimeoutMs: DEFAULT_POLICY.subAgentStallTimeoutMs, applyGrokFinishBias: true, }; + // Kimi (Moonshot) detection ships now so callers can branch on family, but // thresholds are provisional: we have no eval characterization yet for how // Kimi behaves under tool-only stretches or background-run stalls. Ship the diff --git a/src/agent/profile-types.ts b/src/agent/profile-types.ts index d0607fa96..d99fb0e90 100644 --- a/src/agent/profile-types.ts +++ b/src/agent/profile-types.ts @@ -55,6 +55,11 @@ export type AgentProfile = { inference?: InferenceSpec; // Optional tool restriction. Controls which tools the sub-agent can call. capabilities?: CapabilityFilter; + /** + * Authz write-path allowlist for write_file/edit_file/delete_file (director + * packages). Enforced by the permission gate, not prompt policy. + */ + writePaths?: readonly string[]; // Appended to the sub-agent's base system prompt to specialize its behavior. systemPromptRole?: string; // Relative path to a markdown file whose content is loaded as systemPromptRole diff --git a/src/agent/prompt-contract.ts b/src/agent/prompt-contract.ts index f2ff19028..db259c99c 100644 --- a/src/agent/prompt-contract.ts +++ b/src/agent/prompt-contract.ts @@ -2,15 +2,17 @@ // Used by src/prompts.test.ts as a lightweight regression harness. export const CHAT_PROMPT_QUALITY_MARKERS = [ - "Match their tone", + "Match operator tone", + "PRIMARY INTENT", + "You are Skywalker", "Response style:", "Tool choice:", "Ask vs proceed:", "Scope and conventions:", - "edit_file for targeted changes", + "Product write tools are not mounted on Skywalker", "ask_operator only when permission blocks you", "Touch only code required for the task", "load the style and philosophy skills", "grep or search_files", - "never echo, heredoc, sed, or rm in the shell", -] as const; \ No newline at end of file + "never shell-write (echo/heredoc/sed/rm)", +] as const; diff --git a/src/agent/prompts.test.ts b/src/agent/prompts.test.ts index 0a4a0db42..d72f08db3 100644 --- a/src/agent/prompts.test.ts +++ b/src/agent/prompts.test.ts @@ -8,14 +8,15 @@ import { import { CORE_TOOL_NAMES, CATALOG_TOOL_NAMES } from "./tool-search.js"; // Tool names referenced in the discipline block must exist in the actual -// registration source, not be assumed. web_fetch/web_search are registered in -// src/agent/tools.ts via createWebFetchTool()/createWebSearchTool(), whose -// `name` fields live in src/tools/web-fetch.ts and src/tools/web-search.ts. +// registration source, not be assumed. web_fetch/web_search are catalog tools +// (always advertised) and also registered via createWebFetchTool/createWebSearchTool. const REGISTERED_TOOL_NAMES = new Set([ ...CORE_TOOL_NAMES, ...CATALOG_TOOL_NAMES, - "web_fetch", - "web_search", + // Product mutation tools mount on workers, not primary CORE/CATALOG ads. + "write_file", + "edit_file", + "delete_file", ]); const REFERENCED_TOOL_NAMES = [ @@ -81,12 +82,7 @@ describe("shared discipline block appears exactly once per built prompt", () => expect(countOccurrences(prompt, "Prompt discipline:")).toBe(1); }); - it("appears exactly once in the single-session chat prompt", () => { - const prompt = buildChatSystemPrompt(undefined, undefined, undefined, [], "single"); - expect(countOccurrences(prompt, "Prompt discipline:")).toBe(1); - }); - - it("appears exactly once in a leaf sub-agent prompt (default family)", () => { + it("appears exactly once in a worker prompt (default family)", () => { const prompt = buildSubAgentSystemPrompt(undefined, undefined, undefined, { orchestrator: false, grokAntiThrash: false, @@ -94,7 +90,7 @@ describe("shared discipline block appears exactly once per built prompt", () => expect(countOccurrences(prompt, "Prompt discipline:")).toBe(1); }); - it("appears exactly once in a grok leaf sub-agent prompt", () => { + it("appears exactly once in a grok worker prompt", () => { const prompt = buildSubAgentSystemPrompt(undefined, undefined, undefined, { orchestrator: false, grokAntiThrash: true, @@ -112,20 +108,20 @@ describe("shared discipline block appears exactly once per built prompt", () => }); describe("grok finish-bias residual gating (extends existing provider-family tests)", () => { - it("is present for a grok leaf", () => { + it("is present for a grok worker", () => { const prompt = buildSubAgentSystemPrompt(undefined, undefined, undefined, { orchestrator: false, grokAntiThrash: true, }); - expect(prompt).toContain("Finish bias (xAI / Grok leaf):"); + expect(prompt).toContain("Finish bias (xAI / Grok worker):"); }); - it("is absent for a non-grok leaf", () => { + it("is absent for a non-grok worker", () => { const prompt = buildSubAgentSystemPrompt(undefined, undefined, undefined, { orchestrator: false, grokAntiThrash: false, }); - expect(prompt).not.toContain("Finish bias (xAI / Grok leaf):"); + expect(prompt).not.toContain("Finish bias (xAI / Grok worker):"); }); it("is never applied to orchestrators, mirroring shouldApplyGrokAntiThrash", () => { @@ -136,7 +132,7 @@ describe("grok finish-bias residual gating (extends existing provider-family tes orchestrator: true, grokAntiThrash: false, }); - expect(prompt).not.toContain("Finish bias (xAI / Grok leaf):"); + expect(prompt).not.toContain("Finish bias (xAI / Grok worker):"); }); it("reinforces tool routing (dedicated tools over shell) for grok, not just finish bias", () => { diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index e3f1b2f26..4b17b3d47 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -6,6 +6,7 @@ import { CORE_TOOL_NAMES, type ToolAvailability, } from "./tool-search.js"; +import { createSkywalkerSystemPrompt } from "./directors/skywalker/package.js"; // Advertise every gated core tool when the caller has no session-start facts // (tests, ad-hoc prompt previews). Real sessions always pass their detected @@ -38,20 +39,10 @@ function formatDateDDMMYYYY(date: Date): string { return `${day}/${month}/${year}`; } -export function buildChatRole(sessionMode: SessionMode = "orchestrator"): string { - if (sessionMode === "single") { - return [ - `You are ${PRODUCT_NAME}, a senior coding assistant in a terminal harness.`, - "You work directly in this session: read, edit, run checks, and report back yourself.", - "Match their tone and depth: be concise by default and add structure only when it aids scanning.", - ].join(" "); - } - return [ - `You are ${PRODUCT_NAME}, an orchestrator in a terminal harness.`, - "The operator chats with you and may queue more work while workers run.", - "Your job is to triage, delegate implementation and exploration to sub-agents via `task`, track the fleet, and synthesize their reports — not to do large edits or deep repo walks yourself unless a quick unblock is faster than dispatching.", - "Match their tone and depth: be concise by default and add structure only when it aids scanning.", - ].join(" "); +export function buildChatRole(_sessionMode: SessionMode = "orchestrator"): string { + // Primary session identity is the closed Skywalker director package (CL-5817). + // Harness facts / guidelines still append after this role in baseSection. + return createSkywalkerSystemPrompt(); } // Facts the model cannot derive from its training: what the permission layer @@ -66,10 +57,16 @@ export function buildHarnessFacts( ): string { const dynamicTools = opts.dynamicTools ?? true; const subAgent = opts.subAgent ?? false; - const sessionMode = opts.sessionMode ?? "orchestrator"; return [ "Harness facts:", - "- Change files with write_file/edit_file and remove files with delete_file; shell file-writes and deletions are blocked.", + ...(subAgent + ? [ + "- Change files with write_file/edit_file and remove files with delete_file; shell file-writes and deletions are blocked.", + ] + : [ + "- Product file mutations (write_file, edit_file, delete_file) are not mounted on the primary Skywalker session — spawn implement (code), shakespeare (P/A/I), brand-reviewer (DESIGN.md), or bruckheimer (PRODUCT.md) for durable edits.", + "- Shell file-writes and deletions are blocked; never use echo/heredoc/sed/rm as a substitute for product tools.", + ]), "- Use the provided tools for file reads/searches instead of shelling out as a substitute.", "- read_file accepts a filesystem path or a tool-output:///{callId} URI from a prior tool result when the harness exposes one; prefer the URI over re-reading huge blobs.", "- run_shell defaults to a 15s timeout; pass timeout for builds, tests, and other long commands.", @@ -77,18 +74,15 @@ export function buildHarnessFacts( ...(subAgent ? [ "- You share the parent session's permission gate: matching persisted grants and auto mode proceed without a new prompt; other consequential actions may require operator approval (interactive) or are denied (headless).", + "- Turn budget is real; near the end a wrap-up nudge may fire — stop tooling and write the structured report (Summary/Findings/Blockers/Paths). Do not thrash re-reads as the budget ends.", ] : ["- Dependency installs, paths outside the workspace, and session-state writes need operator approval."]), "- Attached images are native multimodal input; inspect them directly unless file-level forensics are requested.", ...(dynamicTools ? [ "- Only the core tools below are loaded. Use tool_search to load extra capabilities from plugins or integrations when needed.", - ...(sessionMode === "orchestrator" - ? [ - "- Use search_agents before dispatching named specialists or teams (results include full profile bodies; do not read_file plugin paths outside the workspace).", - "- The user may send follow-up messages while workers run; treat them as additional queue items — update your plan, spawn or adjust workers, and keep the operator informed.", - ] - : ["- This session runs in single-agent mode: sub-agents are disabled; do all work yourself with the tools below."]), + "- Use search_agents before dispatching named specialists or teams (results include full profile bodies; do not read_file plugin paths outside the workspace).", + "- The user may send follow-up messages while workers run; they are queued. Enter delivers at the next parent tool.boundary; Alt+Enter on session-idle. A long parent tool holds that boundary. Update your plan, spawn or adjust workers, and keep the operator informed.", ] : ["- The tools below are your full toolset."]), "- Workflows run only from slash-command steps; never invent or auto-start one.", @@ -101,7 +95,6 @@ export function buildHarnessFacts( export function buildGuidelines(opts: { subAgent?: boolean; sessionMode?: SessionMode } = {}): string { const subAgent = opts.subAgent ?? false; - const sessionMode = opts.sessionMode ?? "orchestrator"; return [ "Guidelines:", "", @@ -112,8 +105,15 @@ export function buildGuidelines(opts: { subAgent?: boolean; sessionMode?: Sessio "- No emojis in code or docs unless the user uses them.", "", "Tool choice:", + ...(subAgent + ? [] + : [ + "- Prefer task(intent=…) / task(agent=…) for product implementation, exploration, review, and docs — that is the primary loop.", + ]), "- read_file for file contents; grep or search_files to locate code; lsp for symbols, types, references, or call flow before opening large files.", - "- edit_file for targeted changes; write_file for new files or full rewrites; delete_file to remove files — never echo, heredoc, sed, or rm in the shell for those jobs.", + subAgent + ? "- edit_file for targeted changes; write_file for new files or full rewrites; delete_file to remove files — never echo, heredoc, sed, or rm in the shell for those jobs." + : "- Product write tools are not mounted on Skywalker. Spawn implement (or a docs director) for durable file changes; never shell-write (echo/heredoc/sed/rm).", "- run_shell for builds, tests, git, and one-off commands — not for shell find, head-position rg, or recursive grep -r (OOM risk), cat, or messaging the user.", ...(subAgent ? [] @@ -138,18 +138,19 @@ export function buildGuidelines(opts: { subAgent?: boolean; sessionMode?: Sessio "- Follow AGENTS.md and /docs for architecture; load the style and philosophy skills when starting repo work.", "- Match existing project patterns (functional style, arktype at boundaries, small focused diffs).", "- Before finishing a code change, run relevant checks (typecheck, tests) when practical.", - ...(sessionMode === "orchestrator" && !subAgent - ? [ + ...(subAgent + ? [] + : [ "", "Orchestration:", "- Break multi-step or parallel work into focused `task` dispatches with distinct lenses; prefer several parallel task calls when jobs are independent.", - "- Prefer the typed spawn contract on every worker: `intent`, `success_criteria` (done-when), `do_not` (scope fence), and `report_focus` so leaves finish instead of thrashing. Free-form `prompt` alone is weaker.", + "- Prefer the typed spawn contract on every worker: `intent`, `success_criteria` (done-when), `do_not` (scope fence), and `report_focus` so workers finish instead of thrashing. Free-form `prompt` alone is weaker.", "- After workers return, merge their Summary/Findings into a coherent answer for the operator; do not paste raw sub-agent dumps.", "- Pass `maxTurns` on `task` when a job needs a larger inference budget (default 30, cap 100). On turn-budget salvage, re-dispatch with continuation context and a higher maxTurns only a few times on the same brief — after the re-dispatch cap, change approach instead of bumping turns again.", "- After thrash / no-progress / repetition / never-acted salvage, do not re-dispatch an identical brief (prompt/agent/intent/success_criteria/do_not) — it is refused. Change the brief to force a re-run; maxTurns alone does not unlock it.", "- Use manage_tasks for your own coordination checklist; spawning workers is `task`, not manage_tasks.", - ] - : []), + "- If context is compacted automatically, do not stop tasks early due to token fear; persist progress via manage_tasks and worker reports.", + ]), ].join("\n"); } @@ -157,12 +158,16 @@ export function buildGuidelines(opts: { subAgent?: boolean; sessionMode?: Sessio // appended exactly once per built prompt. Prohibition form throughout: these // are the failure modes observed across shipped agents (OpenCode, Codex CLI, // Gemini CLI, Claude Code, Warp, Aider, Cline), not general advice. -export function buildPromptDisciplineBlock(): string { +export function buildPromptDisciplineBlock(opts: { subAgent?: boolean } = {}): string { + const subAgent = opts.subAgent ?? false; + const toolsOverShell = subAgent + ? "- Never use run_shell to read, edit, or write files — use read_file, edit_file, write_file; cat/head/tail, sed/awk/perl -i, and heredoc/echo redirection are prohibited substitutes." + : "- Never use run_shell to read, edit, or write files — use read_file for reads; durable product edits go through implement/docs directors (write tools are not mounted on Skywalker); cat/head/tail, sed/awk/perl -i, and heredoc/echo redirection are prohibited substitutes."; return [ "Prompt discipline:", "", "Tools over shell:", - "- Never use run_shell to read, edit, or write files — use read_file, edit_file, write_file; cat/head/tail, sed/awk/perl -i, and heredoc/echo redirection are prohibited substitutes.", + toolsOverShell, "- Never use echo or shell output to talk to the user — that is what your reply is for.", "", "Environment:", @@ -236,6 +241,8 @@ export function buildEnvironmentContext(env: EnvironmentInfo): string { "<env>", `Working directory: ${env.cwd} — your shell already runs here; never run pwd, ls, or find just to orient.`, `Platform: ${env.platform}`, + `Arch: ${env.arch}`, + `Runtime: ${env.runtime}`, `Current Date: ${formatDateDDMMYYYY(env.date)} (prompt cache survives for <=24hr)`, ]; if (!env.isGitRepo) { @@ -262,16 +269,7 @@ function contextSection(env?: EnvironmentInfo): string { function baseSection(baseOverride: string | undefined, sessionMode: SessionMode): string { if (baseOverride !== undefined && baseOverride.trim().length > 0) { const custom = baseOverride.trim(); - // SYSTEM.md can describe orchestration; still enforce single-mode harness rules on the wire. - if (sessionMode === "single") { - return joinSections([ - custom, - "## Session mode", - buildHarnessFacts({ sessionMode: "single" }), - buildGuidelines({ sessionMode: "single" }), - buildPromptDisciplineBlock(), - ]); - } + // SYSTEM.md can describe the role; orchestrator harness rules always apply on the wire. return joinSections([ custom, "## Session mode", @@ -331,13 +329,13 @@ export function buildChatSystemPrompt( // documented exception — its purpose IS to fan work out to other agents — // so the appendix grants permission and links the syntax. export function buildSubAgentAppendix(opts: { orchestrator?: boolean } = {}): string { - // Leaf agents must not be told both "spawn with task" and "do not call task". + // Workers must not be told both "spawn with task" and "do not call task". // Orchestrators get the spawn instruction; everyone else gets the no-recursion // rule only. const recursionRule = opts.orchestrator === true - ? "- You are an orchestrator: you MAY call `task` to spawn other sub-agents (e.g. task(agent=\"greybeard\", prompt=\"...\")). This is an explicit exception to the no-recursion rule that applies to leaf sub-agents — use it to delegate specialist work, then synthesize their reports into your own. Prefer search_agents before naming a specialist. `task` spawns an agent; it is not a checklist item (use manage_tasks for your own checklist)." - : `- Only the primary ${PRODUCT_NAME} session (or an orchestrator profile) may call \`task\` to spawn sub-agents. You are a leaf sub-agent: return a concrete report to the caller instead of spawning further agents. Use manage_tasks for your own work checklist if the job is multi-step.`; + ? "- You are an orchestrator: you MAY call `task` to spawn other sub-agents (e.g. task(agent=\"greybeard\", prompt=\"...\")). This is an explicit exception to the no-recursion rule that applies to workers — use it to delegate specialist work, then synthesize their reports into your own. Prefer search_agents before naming a specialist. `task` spawns an agent; it is not a checklist item (use manage_tasks for your own checklist)." + : `- Only the primary ${PRODUCT_NAME} session (or an orchestrator profile) may call \`task\` to spawn sub-agents. You are a worker: return a concrete report to the caller instead of spawning further agents. Use manage_tasks for your own work checklist if the job is multi-step.`; return [ `## ${PRODUCT_NAME} notes`, "", @@ -374,12 +372,12 @@ export function buildSubAgentReportContract(): string { ].join("\n"); } -// Tiny residual for Grok/xAI leaves: mining showed higher tools-only thrash +// Tiny residual for Grok/xAI workers: mining showed higher tools-only thrash // than Codex on the same harness. Shared thrash harness + spawn contracts do // the structural work; this is only a finish-bias nudge, not a full rewrite. export function buildGrokLeafAntiThrashNote(): string { return [ - "Finish bias (xAI / Grok leaf):", + "Finish bias (xAI / Grok worker):", "- Once you can answer the dispatch brief, prefer the structured report over another speculative tool call.", "- If the next call would only re-open paths you already read, write the report instead.", "- Leave the last turn for the report envelope; do not spend the budget on one more search or micro-edit.", @@ -405,7 +403,7 @@ export function buildSubAgentSystemPrompt( `You are a sub-agent — a short-lived child agent dispatched by ${PRODUCT_NAME} to carry out one self-contained job autonomously. You have the full file, search, and shell toolset under the same permission policy as the parent session (saved grants and auto mode when eligible; operator approval otherwise). Finish the job and report back. Your manage_tasks checklist (if you use it) is yours alone; it is not shared with the parent.`, buildHarnessFacts({ dynamicTools: false, subAgent: true }), buildGuidelines({ subAgent: true }), - buildPromptDisciplineBlock(), + buildPromptDisciplineBlock({ subAgent: true }), buildSubAgentReportContract(), ]); const toolListForPrompt = diff --git a/src/agent/tool-search.test.ts b/src/agent/tool-search.test.ts index f41d3d1ec..53d492dc3 100644 --- a/src/agent/tool-search.test.ts +++ b/src/agent/tool-search.test.ts @@ -9,6 +9,7 @@ import { coreToolNamesForSessionMode, CORE_TOOL_NAMES, CATALOG_TOOL_NAMES, + PRIMARY_DENIED_PRODUCT_TOOLS, type ToolAvailability, } from "./tool-search.js"; @@ -21,7 +22,12 @@ const NO_AVAILABILITY: ToolAvailability = { const defs: ToolDefinition[] = [ { name: "read_file", description: "read a file", inputSchema: { type: "object", properties: {}, required: [] } }, - { name: "web_search", description: "search the web for pages", inputSchema: { type: "object", properties: {}, required: [] } }, + // Unadvertised built-in stand-in for ranking tests (web_search is now catalog). + { + name: "present", + description: "search and render layout primitives for pages", + inputSchema: { type: "object", properties: {}, required: [] }, + }, { name: "lsp", description: "resolve symbols, find references", inputSchema: { type: "object", properties: {}, required: [] } }, { name: "mcp__linear__create_issue", @@ -41,8 +47,8 @@ const index = createToolIndex(() => defs); describe("createToolIndex", () => { test("ranks a name-token match above a description-only match", () => { - const results = index.search("search"); - expect(results[0]).toBe("web_search"); + const results = index.search("present"); + expect(results[0]).toBe("present"); }); test("finds an MCP tool by raw substring even when not a whole token", () => { @@ -50,7 +56,7 @@ describe("createToolIndex", () => { }); test("matches by capability words in the description", () => { - expect(index.search("pages")).toContain("web_search"); + expect(index.search("pages")).toContain("present"); }); test("never returns lsp — it is a core tool", () => { @@ -63,15 +69,12 @@ describe("createToolIndex", () => { expect(index.search("read a file")).not.toContain("read_file"); }); - test("orchestrator mode advertises task and search_agents; single mode omits them", () => { + test("orchestrator mode advertises task and search_agents", () => { expect(advertisedToolNamesForSessionMode("orchestrator", FULL_AVAILABILITY)).toContain("task"); expect(advertisedToolNamesForSessionMode("orchestrator", FULL_AVAILABILITY)).toContain("search_agents"); - expect(advertisedToolNamesForSessionMode("single", FULL_AVAILABILITY)).not.toContain("task"); - expect(advertisedToolNamesForSessionMode("single", FULL_AVAILABILITY)).not.toContain("search_agents"); }); - test("manage_tasks is advertised in both session modes regardless of availability", () => { - expect(coreToolNamesForSessionMode("single", NO_AVAILABILITY)).toContain("manage_tasks"); + test("manage_tasks is advertised regardless of availability", () => { expect(coreToolNamesForSessionMode("orchestrator", NO_AVAILABILITY)).toContain("manage_tasks"); }); @@ -80,6 +83,22 @@ describe("createToolIndex", () => { expect(coreToolNamesForSessionMode("orchestrator", FULL_AVAILABILITY)).not.toContain("present"); }); + test("primary CORE and CATALOG omit product mutation tools", () => { + for (const name of ["write_file", "edit_file", "delete_file"] as const) { + expect(CORE_TOOL_NAMES).not.toContain(name); + expect(CATALOG_TOOL_NAMES).not.toContain(name); + } + expect(PRIMARY_DENIED_PRODUCT_TOOLS).toEqual(["write_file", "edit_file", "delete_file"]); + }); + + test("catalog advertises web_fetch and web_search so URL work needs no tool_search", () => { + expect(CATALOG_TOOL_NAMES).toContain("web_fetch"); + expect(CATALOG_TOOL_NAMES).toContain("web_search"); + const advertised = advertisedToolNamesForSessionMode("orchestrator", FULL_AVAILABILITY); + expect(advertised).toContain("web_fetch"); + expect(advertised).toContain("web_search"); + }); + test("lsp is advertised only when a language server was detected at startup", () => { expect( coreToolNamesForSessionMode("orchestrator", { languageServerAvailable: true }), @@ -89,8 +108,7 @@ describe("createToolIndex", () => { ).not.toContain("lsp"); }); - test("ask_operator is advertised regardless of session mode or availability", () => { - expect(coreToolNamesForSessionMode("single", NO_AVAILABILITY)).toContain("ask_operator"); + test("ask_operator is advertised regardless of availability", () => { expect(coreToolNamesForSessionMode("orchestrator", NO_AVAILABILITY)).toContain("ask_operator"); }); @@ -118,10 +136,10 @@ describe("createToolSearchTool", () => { lookup: (name) => defs.find((d) => d.name === name), promote: (names) => promoted.push(...names), }); - const out = await call(tool, { query: "search the web" }); - expect(promoted).toContain("web_search"); - expect(out).toContain("web_search"); - expect(out).toContain("search the web"); + const out = await call(tool, { query: "render layout" }); + expect(promoted).toContain("present"); + expect(out).toContain("present"); + expect(out).toContain("layout"); }); test("surfaces a matched tool's input schema so the model can shape arguments", async () => { @@ -160,22 +178,23 @@ describe("advertisedTools", () => { { name: "mcp__linear__create_issue", description: "create", inputSchema: { type: "object", properties: {}, required: [] } }, ]; - test("single session mode omits multi-agent tools from the wire prefix", () => { - const names = advertisedTools( - registry, - [], - advertisedToolNamesForSessionMode("single", FULL_AVAILABILITY), - ).map((d) => d.name); - expect(names).not.toContain("task"); - expect(names).not.toContain("search_agents"); + test("orchestrator wire prefix names include multi-agent tools", () => { + const prefix = advertisedToolNamesForSessionMode("orchestrator", FULL_AVAILABILITY); + expect(prefix).toContain("task"); + expect(prefix).toContain("search_agents"); + // advertisedTools only emits tools present in the registry; multi-agent + // tools appear on the wire when createAgentToolset registers them. + const names = advertisedTools(registry, [], prefix).map((d) => d.name); expect(names).toContain("read_file"); + expect(names).not.toContain("mcp__linear__create_issue"); }); test("with no activation, advertises only the fixed built-in set, never MCP tools", () => { const names = advertisedTools(registry).map((d) => d.name); expect(names).toContain("read_file"); expect(names).toContain("grep"); - expect(names).toContain("write_file"); + // write_file is not in primary CATALOG — product mutations are leaf-only. + expect(names).not.toContain("write_file"); expect(names).not.toContain("mcp__linear__create_issue"); }); diff --git a/src/agent/tool-search.ts b/src/agent/tool-search.ts index 89608ac4a..b25486811 100644 --- a/src/agent/tool-search.ts +++ b/src/agent/tool-search.ts @@ -15,9 +15,12 @@ import { sessionModeEnablesSubAgents } from "../config/session-mode.js"; // 2,793 chars it is the second-largest schema on the wire. It stays fully // dispatchable — the model finds it via tool_search when a session actually // needs it. +// +// Product mutation tools (write_file / edit_file / delete_file) are intentionally +// absent from the primary Skywalker core/catalog sets — they mount only on leaf +// directors that need them (implement, shakespeare, …). See PRIMARY_DENIED_PRODUCT_TOOLS. export const CORE_TOOL_NAMES: readonly string[] = [ "read_file", - "edit_file", "lsp", "run_shell", "ask_operator", @@ -32,6 +35,13 @@ export const CORE_TOOL_NAMES: readonly string[] = [ "task", ]; +/** Product mutation tools denied on the primary Skywalker session (structural). */ +export const PRIMARY_DENIED_PRODUCT_TOOLS: readonly string[] = [ + "write_file", + "edit_file", + "delete_file", +]; + const ORCHESTRATOR_ONLY_TOOL_NAMES: readonly string[] = ["search_agents", "task"]; // Session-start facts that gate a core tool's advertisement. Each must be @@ -64,14 +74,20 @@ export function advertisedToolNamesForSessionMode( return [...coreToolNamesForSessionMode(mode, availability), ...CATALOG_TOOL_NAMES]; } -// Built-in file/search tools advertised alongside the core set. They carry full +// Built-in file/search/web tools advertised alongside the core set. They carry full // schemas on the wire so the model can call them directly; MCP tools are not // listed at all — they are discovered blind via tool_search. +// write_file is intentionally omitted: primary Skywalker does not mutate product +// files; implement/docs leaves mount write tools via their own toolsets. +// web_fetch / web_search are catalog (not deferred): URL reads and search are +// first-class primary work; requiring tool_search before web_fetch caused +// thrash on web-bait and contradicted the skywalker "already mounted" rule. export const CATALOG_TOOL_NAMES: readonly string[] = [ - "write_file", "search_files", "grep", "list_dir", + "web_fetch", + "web_search", ]; // The maximal set of built-in tools — every gate open — in a deterministic @@ -148,7 +164,7 @@ export function createActivatedToolTracker(): ActivatedToolTracker { export const toolSearchDefinition: ToolDefinition = { name: "tool_search", description: - "Discover callable tools by capability. Most tools — file search, web access, and any connected integrations — are dispatchable but not advertised in the tools list. Call this with a short description of what you need (e.g. 'create a file', 'search the web', 'find files', 'issue tracker') to get the matching tools' names, descriptions, and input schemas. The returned tools are already callable — invoke them directly, no separate load step.", + "Discover callable tools by capability. Most tools — MCP servers, present, and other integrations — are dispatchable but not advertised in the tools list. Core tools (read_file, run_shell, web_fetch, web_search, task, …) are already on the wire — do not tool_search for them. Call this with a short description of what you need (e.g. 'issue tracker', 'render layout', 'granola notes') to get matching tools' names, descriptions, and input schemas. The returned tools are already callable — invoke them directly, no separate load step.", inputSchema: { type: "object", properties: { diff --git a/src/agent/tools.ts b/src/agent/tools.ts index dc297f77f..aa901b71c 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -32,7 +32,7 @@ import { import type { ToolWatchdogConfig } from "../tui/tool-execution-watchdog.js"; import type { SessionMode } from "../config/session-mode.js"; import { sessionModeEnablesSubAgents } from "../config/session-mode.js"; -import { advertisedToolNamesForSessionMode, type ToolAvailability } from "./tool-search.js"; +import { advertisedToolNamesForSessionMode, PRIMARY_DENIED_PRODUCT_TOOLS, type ToolAvailability } from "./tool-search.js"; import type { ProviderCatalogEntry } from "../config/index.js"; import type { AgentProfile } from "./profiles.js"; import { @@ -106,7 +106,7 @@ export type AgentToolsetArgs = { // every turn (workflow or not), so the model can call it with nothing active; // this lets its handler report an honest no-op instead of a false advance. isWorkflowActive?: () => boolean; - // Primary session mode: single-agent sessions omit sub-agent tooling. + // Primary session mode (always orchestrator; kept for call-site wiring). sessionMode?: SessionMode; // Session-start facts gating lsp advertisement. Omitted callers (tests, // ad-hoc toolset construction) get it advertised, matching prior behavior. @@ -145,6 +145,9 @@ export type MCPServerState = | { name: string; state: "failed"; error: string }; export type MCPConnectCallbacks = { + // Headless hosts must not advertise an auth callback they cannot complete. + // Its presence is how the MCP client decides an OAuth flow is interactive. + interactiveAuth: boolean; // Fired whenever a server's connection state changes. onStatus: (state: MCPServerState) => void; // Fired after a server connects and its tools are registered, with the new @@ -338,7 +341,13 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT }), ); - const dynamicRunner = createDynamicToolRunner(baseTools, toolWatchdog); + // Primary Skywalker never mounts product mutation tools — never-implement is + // structural (toolset), not prompt-only. Leaves that need write/edit/delete + // mount them via createTaskTool / runSubAgent's own tool assembly. + const primaryDenied = new Set(PRIMARY_DENIED_PRODUCT_TOOLS); + const primaryTools = baseTools.filter((tool) => !primaryDenied.has(tool.definition.name)); + + const dynamicRunner = createDynamicToolRunner(primaryTools, toolWatchdog); runnerRef = dynamicRunner; const connectedClients: MCPClient[] = []; @@ -354,7 +363,9 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT callbacks.onStatus({ name: config.name, state: "connecting" }); const result = await connectMCPServer(config, { stderr: "ignore", - onAuthURL: (name, url) => callbacks.onStatus({ name, state: "needs-auth", url }), + ...(callbacks.interactiveAuth + ? { onAuthURL: (name: string, url: string) => callbacks.onStatus({ name, state: "needs-auth", url }) } + : {}), // Mid-session re-auth fires needs-auth again without a later connected // event. Re-emit connected only when tools are already registered so // first-connect still waits for the real post-connect status. diff --git a/src/config.test.ts b/src/config.test.ts index 80b9c88ba..e2c1b9b7f 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -215,17 +215,19 @@ describe("loadConfig", () => { } }); - test("resume last fails when this project has no sessions", async () => { + test("bare resume opens the picker without requiring prior sessions", async () => { const cwd = await emptyCwd(); const home = await mkdtemp(join(tmpdir(), "ic-resume-home-")); try { const globalPath = await writeGlobalSettings(cwd); - await expect( - loadConfig(["resume", "--cwd", cwd], { - globalSettingsPath: globalPath, - home, - }), - ).rejects.toThrow(/No previous session/); + const config = await loadConfig(["resume", "--cwd", cwd], { + globalSettingsPath: globalPath, + home, + }); + assertConfigured(config); + expect(config.resumeMode).toBe("pick"); + expect(config.resumePicker).toBe(true); + expect(config.skipInitialTask).toBe(true); } finally { await rm(cwd, { recursive: true, force: true }); await rm(home, { recursive: true, force: true }); @@ -266,34 +268,73 @@ describe("loadConfig", () => { } }); - test("resume last follows the latest symlink for this project", async () => { + test("--resume opens the picker", async () => { const cwd = await emptyCwd(); const home = await mkdtemp(join(tmpdir(), "ic-resume-home-")); try { const globalPath = await writeGlobalSettings(cwd); - const sessionId = generateSessionId(); - await initSessionDir(cwd, sessionId, home); + const config = await loadConfig(["--resume", "--cwd", cwd], { + globalSettingsPath: globalPath, + home, + }); + assertConfigured(config); + expect(config.resumeMode).toBe("pick"); + expect(config.resumePicker).toBe(true); + expect(config.skipInitialTask).toBe(true); + } finally { + await rm(cwd, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true }); + } + }); + + test("plain corbits always creates fresh state even when a previous session exists", async () => { + const cwd = await emptyCwd(); + const home = await mkdtemp(join(tmpdir(), "ic-resume-home-")); + try { + const globalPath = await writeGlobalSettings(cwd); + const subdir = join(cwd, "nested"); + await mkdir(subdir); + const previousId = generateSessionId(); + await initSessionDir(cwd, previousId, home); await saveState( cwd, - sessionId, + previousId, { - status: "done", - turnsUsed: 1, - task: "keep going", + status: "running", + turnsUsed: 10, + task: "old conversation", startedAt: Date.now() - 500, - finishedAt: Date.now(), }, home, ); - const config = await loadConfig(["resume", "--cwd", cwd], { + + const first = await loadConfig(["--cwd", cwd], { globalSettingsPath: globalPath, home, }); - assertConfigured(config); - expect(config.resumeMode).toBe("last"); - expect(config.sessionId).toBe(sessionId); - expect(config.skipInitialTask).toBe(true); - expect(config.task).toBe("keep going"); + const second = await loadConfig(["--cwd", cwd], { + globalSettingsPath: globalPath, + home, + }); + const nested = await loadConfig(["--cwd", subdir], { + globalSettingsPath: globalPath, + home, + }); + assertConfigured(first); + assertConfigured(second); + assertConfigured(nested); + expect(first.resumeMode).toBeUndefined(); + expect(second.resumeMode).toBeUndefined(); + expect(nested.resumeMode).toBeUndefined(); + expect(first.sessionId).not.toBe(previousId); + expect(second.sessionId).not.toBe(previousId); + expect(nested.sessionId).not.toBe(previousId); + expect(first.sessionId).not.toBe(second.sessionId); + expect(nested.sessionId).not.toBe(first.sessionId); + expect(nested.sessionId).not.toBe(second.sessionId); + expect(first.task).toBe(""); + expect(second.task).toBe(""); + expect(nested.task).toBe(""); } finally { await rm(cwd, { recursive: true, force: true }); await rm(home, { recursive: true, force: true }); diff --git a/src/config/index.ts b/src/config/index.ts index 37f295742..aec733045 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,7 +1,7 @@ import { join, resolve } from "node:path"; import type { InferenceSource } from "@intx/types/runtime"; -import { generateSessionId, isSessionId, migrateLegacySessionIfNeeded, resolveLatestSession } from "../session/index.js"; +import { generateSessionId, isSessionId, migrateLegacySessionIfNeeded } from "../session/index.js"; import { loadState } from "../session/state.js"; @@ -307,11 +307,10 @@ export type Config = { /** When true, the TUI does not auto-send `task` on mount (resumed session). */ skipInitialTask?: boolean; /** - * How this process was asked to resume. `"last"` continues the latest session - * for the project key without a picker; `"id"` continues an explicit session + * How this process was asked to resume. `"id"` continues an explicit session * id; `"pick"` opens the interactive picker. Omitted for a fresh session. */ - resumeMode?: "last" | "id" | "pick"; + resumeMode?: "id" | "pick"; // Deprecated workflow profile metadata; workflows are manual-only slash commands. workflow?: string; @@ -360,10 +359,11 @@ export const CLI_HELP_TEXT = `corbits — coding agent CLI Usage: corbits [flags] [task...] corbits exec|run [flags] <prompt> - corbits resume|continue [session-id|--pick] [flags] + corbits resume|continue [session-id] [flags] -Continue verbs (project-keyed; worktrees of the same git root share sessions): - resume / continue reopen the latest session for this folder +Continue verbs (project-keyed to this checkout's git toplevel): + resume / continue interactive session picker + --resume interactive session picker resume <session-id> reopen a specific session resume --pick / --list interactive session picker @@ -373,6 +373,7 @@ Flags: --provider <name> configured provider name --model <id> model for the active provider --profile <name> settings profile + --resume interactive session picker --force override an existing run state --dangerously-skip-permissions --auto / --no-auto auto mode on/off @@ -424,9 +425,9 @@ export async function loadConfig( // Leading subcommand: `corbits exec "prompt"` (alias: `run`). Default is TUI. // `corbits resume` / `continue` reopen a prior session for this project key - // (shared across worktrees of the same git root — see docs/IMPLEMENTATION.md). + // (this checkout's git toplevel — see docs/IMPLEMENTATION.md). let command: "tui" | "exec" = "tui"; - let resumeMode: "last" | "id" | "pick" | undefined; + let resumeMode: "id" | "pick" | undefined; let resumeSessionId: string | undefined; const leading = args[0]; if (leading === "exec" || leading === "run") { @@ -435,7 +436,7 @@ export async function loadConfig( } else if (leading === "resume" || leading === "continue") { command = "tui"; args.shift(); - // Default: last session. Explicit id, or --pick for the interactive list. + // Bare resume opens the list. A session id is the only direct-resume path. // Invalid non-flag positionals error instead of falling through to last // (a free-form token would otherwise become task while skipInitialTask is set). const next = args.slice()[0]; @@ -445,14 +446,14 @@ export async function loadConfig( } else if (next !== undefined && !next.startsWith("--")) { if (!isSessionId(next)) { throw new Error( - `'${next}' is not a session id. Use a UUID session id, \`corbits resume\` for the latest, or \`corbits resume --pick\` to choose.`, + `'${next}' is not a session id. Use a UUID session id or \`corbits resume\` to choose.`, ); } resumeMode = "id"; resumeSessionId = next; args.shift(); } else { - resumeMode = "last"; + resumeMode = "pick"; } } @@ -529,6 +530,16 @@ export async function loadConfig( noWorkflow = true; continue; } + if (arg === "--resume") { + if (command === "exec") { + throw new Error("--resume is only available in interactive mode"); + } + if (resumeMode === "id") { + throw new Error("cannot combine a session id with --resume"); + } + resumeMode = "pick"; + continue; + } if ((arg === "--pick" || arg === "--list") && resumeMode !== undefined) { if (resumeMode === "id") { throw new Error("cannot combine a session id with --pick/--list"); @@ -640,7 +651,7 @@ export async function loadConfig( } // Resume resolution: project-key sessions live under ~/.corbits/projects/<key>/ - // and are shared across worktrees of the same git root. + // keyed to this checkout's git toplevel (linked worktrees do not share lists). let sessionId = generateSessionId(); let skipInitialTask = false; let resumePicker = false; @@ -654,23 +665,12 @@ export async function loadConfig( const state = await loadState(cwd, id, options.home); if (state === null) { throw new Error( - `No session ${id} for this project. Sessions are stored under ~/.corbits/projects/<project-key>/ (shared across worktrees of the same git root). Use \`corbits resume --pick\` to list, or \`corbits resume\` for the latest.`, + `No session ${id} for this project. Sessions are stored under ~/.corbits/projects/<project-key>/ (this checkout's git toplevel). Use \`corbits resume\` to choose one.`, ); } sessionId = id; skipInitialTask = true; if (task.length === 0) resumeTask = state.task; - } else if (resumeMode === "last") { - const latest = await resolveLatestSession(cwd, options.home); - if (latest === null) { - throw new Error( - "No previous session for this project. Start a new one with `corbits`, or pass `corbits resume --pick` once you have sessions under ~/.corbits/projects/.", - ); - } - sessionId = latest.sessionId; - skipInitialTask = true; - const state = await loadState(cwd, sessionId, options.home); - if (task.length === 0 && state !== null) resumeTask = state.task; } return { @@ -871,4 +871,3 @@ export function providerCatalogToSettings( providers, }; } - diff --git a/src/config/session-mode.test.ts b/src/config/session-mode.test.ts index e7d3f0e74..05213c2f9 100644 --- a/src/config/session-mode.test.ts +++ b/src/config/session-mode.test.ts @@ -1,26 +1,34 @@ import { describe, expect, test } from "bun:test"; -import { resolveSessionMode, sessionModeEnablesSubAgents } from "./session-mode.js"; +import { + isSessionMode, + resolveSessionMode, + sessionModeEnablesSubAgents, +} from "./session-mode.js"; describe("resolveSessionMode", () => { - test("local overrides global", () => { + test("always returns orchestrator regardless of settings", () => { expect( - resolveSessionMode({ providers: {}, sessionMode: "orchestrator" }, { sessionMode: "single" }), - ).toBe("single"); - }); - - test("falls back to global when local unset", () => { - expect(resolveSessionMode({ providers: {}, sessionMode: "single" }, null)).toBe("single"); - }); - - test("returns undefined when neither file sets mode", () => { - expect(resolveSessionMode({ providers: {} }, null)).toBeUndefined(); + resolveSessionMode({ providers: {}, sessionMode: "orchestrator" }, { sessionMode: "single" as never }), + ).toBe("orchestrator"); + expect(resolveSessionMode({ providers: {}, sessionMode: "single" as never }, null)).toBe( + "orchestrator", + ); + expect(resolveSessionMode({ providers: {} }, null)).toBe("orchestrator"); }); }); describe("sessionModeEnablesSubAgents", () => { - test("orchestrator enables sub-agents; single does not", () => { + test("always enables sub-agents", () => { expect(sessionModeEnablesSubAgents("orchestrator")).toBe(true); - expect(sessionModeEnablesSubAgents("single")).toBe(false); + expect(sessionModeEnablesSubAgents()).toBe(true); }); -}); \ No newline at end of file +}); + +describe("isSessionMode", () => { + test("accepts orchestrator only", () => { + expect(isSessionMode("orchestrator")).toBe(true); + expect(isSessionMode("single")).toBe(false); + expect(isSessionMode("fleet")).toBe(false); + }); +}); diff --git a/src/config/session-mode.ts b/src/config/session-mode.ts index 83b7d6efe..d0b24c061 100644 --- a/src/config/session-mode.ts +++ b/src/config/session-mode.ts @@ -1,22 +1,30 @@ import type { LocalSettings, Settings } from "./settings.js"; -export type SessionMode = "single" | "orchestrator"; +/** + * CL-5814: orchestrator is the only product path. The type is retained as a + * single literal so call sites can drop the parameter without a big-bang rename + * in the same PR series; `"single"` is never returned from resolve helpers. + */ +export type SessionMode = "orchestrator"; -export const SESSION_MODES: readonly SessionMode[] = ["single", "orchestrator"]; +export const SESSION_MODES: readonly SessionMode[] = ["orchestrator"]; export function isSessionMode(value: unknown): value is SessionMode { - return value === "single" || value === "orchestrator"; + return value === "orchestrator"; } -// Per-repo local selection wins over the global default in ~/.corbits/settings.json. +/** + * Product always runs orchestrator. Legacy `sessionMode` values in settings + * (including `"single"`) are ignored — not errors on load, not written back here. + */ export function resolveSessionMode( - global: Settings | null | undefined, - local: LocalSettings | null | undefined, -): SessionMode | undefined { - if (local?.sessionMode !== undefined) return local.sessionMode; - return global?.sessionMode; + _global?: Settings | null, + _local?: LocalSettings | null, +): SessionMode { + return "orchestrator"; } -export function sessionModeEnablesSubAgents(mode: SessionMode): boolean { - return mode === "orchestrator"; -} \ No newline at end of file +/** Sub-agents are always available on the primary session. */ +export function sessionModeEnablesSubAgents(_mode?: SessionMode): boolean { + return true; +} diff --git a/src/config/settings.ts b/src/config/settings.ts index 3ffff889b..856a9b01d 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -7,7 +7,7 @@ import { type } from "arktype"; import { SETTINGS_DIR_NAME } from "../branding.js"; import { REASONING_EFFORTS, isReasoningEffort, type ReasoningEffort } from "../provider/reasoning-effort.js"; -import { isSessionMode, type SessionMode } from "./session-mode.js"; +import type { SessionMode } from "./session-mode.js"; import { resolveDefaultModel } from "./providers.js"; import { OPENCODE_GO_BASE_URL, @@ -114,9 +114,9 @@ export type Settings = { compactionMode?: "llm" | "pruning"; // Default inference-turn budget for leaf sub-agents (not the parent session limit). subagentMaxTurns?: number; - // Primary session behavior: single agent does work in-session; orchestrator - // delegates via task and manages a worker fleet. When unset, the TUI prompts - // once at startup. + // Deprecated (CL-5814): orchestrator is the only product path. Legacy values + // may still appear in on-disk settings and are ignored at resolve time; new + // writes should omit this field. Kept on the type so old files still load. sessionMode?: SessionMode; // When an agent profile pins a provider/model combo (via its `inference` // field) and none of the listed legs are available in the user's configured @@ -201,6 +201,23 @@ export function toggleFavoriteModel(settings: Settings, ref: ModelRef): Settings }; } +export function setDefaultModel(settings: Settings, ref: ModelRef): Settings { + const next: ModelRef = { provider: ref.provider, model: ref.model }; + const existing = settings.providers[next.provider]; + return { + ...settings, + defaultProvider: next.provider, + ...(existing !== undefined + ? { + providers: { + ...settings.providers, + [next.provider]: { ...existing, defaultModel: next.model }, + }, + } + : {}), + }; +} + export function listRecentModels( settings: Settings, max: number = DEFAULT_RECENT_MODELS_SHOWN, @@ -435,7 +452,9 @@ const SettingsSchema = type({ "lastChangelogVersion?": "string", "compactionMode?": "'llm' | 'pruning'", "subagentMaxTurns?": "number", + // Legacy disk values still load; product resolve ignores them (CL-5814). "sessionMode?": "'single' | 'orchestrator'", + "agentModelFallback?": "'active' | 'none'", "shell?": type({ "timeoutMs?": "number", "maxTimeoutMs?": "number" }), "tools?": type({ @@ -475,7 +494,9 @@ const LocalSettingsSchema = type({ "model?": "string", "reasoningEffort?": type.enumerated(...REASONING_EFFORTS), "mcpServers?": "unknown", + // Legacy disk values still load; product resolve ignores them (CL-5814). "sessionMode?": "'single' | 'orchestrator'", + "env?": "Record<string, string>", // Reject any other key so local settings can never smuggle credentials. "+": "reject", @@ -500,7 +521,15 @@ export function isSettings(value: unknown): value is Settings { return false; } } - if (s.sessionMode !== undefined && !isSessionMode(s.sessionMode)) return false; + // Legacy "single" | "orchestrator" still load; product resolve ignores them. + if ( + s.sessionMode !== undefined && + s.sessionMode !== "single" && + s.sessionMode !== "orchestrator" + ) { + return false; + } + return true; } @@ -560,7 +589,15 @@ export function isLocalSettings(value: unknown): value is LocalSettings { if (!LocalSettingsSchema.allows(value)) return false; const s = value as Record<string, unknown>; if (s.mcpServers !== undefined && normalizeMcpServers(s.mcpServers) === undefined) return false; - if (s.sessionMode !== undefined && !isSessionMode(s.sessionMode)) return false; + // Legacy "single" | "orchestrator" still load; product resolve ignores them. + if ( + s.sessionMode !== undefined && + s.sessionMode !== "single" && + s.sessionMode !== "orchestrator" + ) { + return false; + } + return true; } @@ -722,8 +759,8 @@ export async function loadSettings(path: string): Promise<Settings | null> { s.subagentMaxTurns !== undefined ? clampSubAgentMaxTurns(s.subagentMaxTurns as number) : undefined, - sessionMode: - s.sessionMode === "single" || s.sessionMode === "orchestrator" ? s.sessionMode : undefined, + // CL-5814: drop legacy "single"; only keep explicit orchestrator if present. + sessionMode: s.sessionMode === "orchestrator" ? "orchestrator" : undefined, agentModelFallback: s.agentModelFallback === "active" || s.agentModelFallback === "none" ? s.agentModelFallback @@ -794,8 +831,7 @@ function pickLocalFields( model: s.model as string | undefined, reasoningEffort: s.reasoningEffort as ReasoningEffort | undefined, mcpServers: s.mcpServers !== undefined ? normalizeMcpServers(s.mcpServers) : undefined, - sessionMode: - s.sessionMode === "single" || s.sessionMode === "orchestrator" ? s.sessionMode : undefined, + sessionMode: s.sessionMode === "orchestrator" ? "orchestrator" : undefined, env: s.env as Record<string, string> | undefined, }; } @@ -804,8 +840,7 @@ function pickLocalFields( model: typeof s.model === "string" ? s.model : undefined, reasoningEffort: isReasoningEffort(s.reasoningEffort) ? s.reasoningEffort : undefined, mcpServers: s.mcpServers !== undefined ? normalizeMcpServers(s.mcpServers) : undefined, - sessionMode: - s.sessionMode === "single" || s.sessionMode === "orchestrator" ? s.sessionMode : undefined, + sessionMode: s.sessionMode === "orchestrator" ? "orchestrator" : undefined, env: s.env !== undefined && typeof s.env === "object" && s.env !== null && !Array.isArray(s.env) ? Object.fromEntries( diff --git a/src/exec/runner.ts b/src/exec/runner.ts index 8e92447f1..cd6fbaa5c 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -3,7 +3,6 @@ import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output, stderr } from "node:process"; import { isAbsolute, join, resolve } from "node:path"; import { - createAgent, defineAgent, defineTool, createDirectorRegistry, @@ -54,6 +53,7 @@ import type { PermissionRequest, } from "../permission/types.js"; import { createAgentToolset, type AgentToolset, type OperatorResult } from "../agent/tools.js"; +import { createAgentWithLiveToolDispatch } from "../agent/live-tool-dispatch.js"; import { liveTelemetry } from "../telemetry/singleton.js"; import { collectToolPlugins, resolveToolPlugins } from "../plugins/tool-plugins.js"; import { @@ -547,7 +547,7 @@ export async function runExec(config: Config): Promise<ExecResult> { const withLiveCreds = sources.map((s) => s.id === liveSource.id ? { ...s, apiKey: liveSource.apiKey } : s, ); - return createAgent(def, { + return createAgentWithLiveToolDispatch(def, { sources: withLiveCreds, defaultSource, storage, @@ -591,6 +591,7 @@ export async function runExec(config: Config): Promise<ExecResult> { if (agentToolset.connectMCP !== undefined) { await agentToolset .connectMCP({ + interactiveAuth: false, onStatus: (status) => { if (status.state === "connected") { connectedMcp = [ diff --git a/src/index.ts b/src/index.ts index 417c7e44e..0b4dc39ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -248,9 +248,8 @@ const SIGNAL_EXIT_NUMBER: Record<"SIGINT" | "SIGTERM" | "SIGHUP", number> = { // Bun's tty raw mode (which the TUI runs under for its whole session) clears // ISIG, so a real terminal's Ctrl+C never reaches this handler while a // session is interactive — confirmed empirically (see the raw-mode SIGINT -// regression test) rather than assumed. The in-session double-tap-to-quit -// gesture (shell.ts, CTRL_C_EXIT_WINDOW_MS) is therefore untouched by this -// handler; it owns Ctrl+C exclusively for the interactive case. This handler +// regression test) rather than assumed. The in-session exit path in shell.ts +// therefore owns Ctrl+C exclusively for the interactive case. This handler // exists for the signal actually reaching the process: external // orchestration (kill, systemd, docker stop), or a terminal that never // entered raw mode at all (exec mode has no TUI host and no raw stdin, so diff --git a/src/perf/permission-subagent-spans.test.ts b/src/perf/permission-subagent-spans.test.ts index 760ea62a9..0e62b82ba 100644 --- a/src/perf/permission-subagent-spans.test.ts +++ b/src/perf/permission-subagent-spans.test.ts @@ -237,7 +237,7 @@ describe("subagent spans", () => { if (tool.kind !== "full") throw new Error("expected full tool"); const result = await tool.handler( - { id: "call-sa-1", name: "task", arguments: { description: "Job", prompt: "Do it" } }, + { id: "call-sa-1", name: "task", arguments: { description: "Job", prompt: "Do it", intent: "explore" } }, new AbortController().signal, ); expect(runEntered).toBe(true); @@ -278,7 +278,7 @@ describe("subagent spans", () => { if (tool.kind !== "full") throw new Error("expected full tool"); await tool.handler( - { id: "call-child", name: "task", arguments: { description: "Child", prompt: "Work" } }, + { id: "call-child", name: "task", arguments: { description: "Child", prompt: "Work", intent: "explore" } }, new AbortController().signal, ); @@ -308,7 +308,7 @@ describe("subagent spans", () => { if (tool.kind !== "full") throw new Error("expected full tool"); const result = await tool.handler( - { id: "call-fail", name: "task", arguments: { description: "Fail", prompt: "Work" } }, + { id: "call-fail", name: "task", arguments: { description: "Fail", prompt: "Work", intent: "explore" } }, new AbortController().signal, ); expect(typeof result.content === "string" ? result.content : "").toContain("Error:"); @@ -339,7 +339,7 @@ describe("subagent spans", () => { { id: "call-wt-fail", name: "task", - arguments: { description: "Worktree fail", prompt: "Work" }, + arguments: { description: "Worktree fail", prompt: "Work", intent: "explore" }, }, new AbortController().signal, ); diff --git a/src/permission/gate.test.ts b/src/permission/gate.test.ts index 8b769a212..794d03074 100644 --- a/src/permission/gate.test.ts +++ b/src/permission/gate.test.ts @@ -152,3 +152,70 @@ describe("grant coverage rebinds relative paths to the request process cwd", () ).toBe(true); }); }); + +describe("director writePaths authz on evaluate", () => { + const cwd = mkdtempSync(join(tmpdir(), "gate-writepaths-")); + + test("denies write_file outside allowlist under ALS identity", async () => { + const { runWithSubAgentIdentity } = await import("../subagent/identity-context.js"); + const gate = createPermissionGate({ + approvals: [{ tool: "write_file", pattern: "*" }], + interactive: false, + skipPermissions: false, + cwd, + }); + const verdict = await runWithSubAgentIdentity( + { description: "shakespeare", cwd, writePaths: ["PRODUCT.md"] }, + () => + gate.evaluate({ + id: "w1", + name: "write_file", + arguments: { path: "src/hack.ts", content: "nope" }, + }), + ); + expect(verdict.allowed).toBe(false); + if (!verdict.allowed) { + expect(verdict.reason).toMatch(/authz allowlist/i); + } + }); + + test("allows write_file matching bare basename allowlist", async () => { + const { runWithSubAgentIdentity } = await import("../subagent/identity-context.js"); + const gate = createPermissionGate({ + approvals: [{ tool: "write_file", pattern: "*" }], + interactive: false, + skipPermissions: false, + cwd, + }); + const verdict = await runWithSubAgentIdentity( + { description: "shakespeare", cwd, writePaths: ["PRODUCT.md"] }, + () => + gate.evaluate({ + id: "w2", + name: "write_file", + arguments: { path: "PRODUCT.md", content: "ok" }, + }), + ); + expect(verdict.allowed).toBe(true); + }); + + test("yolo (skipPermissions) bypasses writePaths", async () => { + const { runWithSubAgentIdentity } = await import("../subagent/identity-context.js"); + const gate = createPermissionGate({ + approvals: [], + interactive: false, + skipPermissions: true, + cwd, + }); + const verdict = await runWithSubAgentIdentity( + { description: "shakespeare", cwd, writePaths: ["PRODUCT.md"] }, + () => + gate.evaluate({ + id: "w3", + name: "write_file", + arguments: { path: "src/hack.ts", content: "yolo" }, + }), + ); + expect(verdict.allowed).toBe(true); + }); +}); diff --git a/src/permission/gate.ts b/src/permission/gate.ts index af0592ace..1967d1d38 100644 --- a/src/permission/gate.ts +++ b/src/permission/gate.ts @@ -20,6 +20,11 @@ import { splitChainedCommand, tokenize, isShellCommentOnly, stripCommentLines } import { createPathRestriction } from "./path-restriction.js"; import { createWorktreeRootsProvider, type RootsProvider } from "./worktree-roots.js"; import { getSubAgentIdentity } from "../subagent/identity-context.js"; +import { + matchesWritePathAllowlist, + writePathDeniedReason, +} from "./write-path-policy.js"; + import { createMcpToolPermissionRegistry, registerMcpClientTools, @@ -341,6 +346,28 @@ export function createPermissionGate(options: PermissionGateOptions): Permission // match what the shell will open. const subAgentIdentity = getSubAgentIdentity(); const effectiveCwd = subAgentIdentity?.cwd ?? resolvedCwd; + + // Director write-path authz (not prompt policy). Leaves with writePaths only + // mutate matching subjects. auto mode still enforces; yolo already returned. + if ( + subAgentIdentity?.writePaths !== undefined && + subAgentIdentity.writePaths.length > 0 && + (call.name === "write_file" || call.name === "edit_file" || call.name === "delete_file") + ) { + const path = + typeof call.arguments === "object" && + call.arguments !== null && + typeof (call.arguments as { path?: unknown }).path === "string" + ? (call.arguments as { path: string }).path + : ""; + if (!matchesWritePathAllowlist(path, subAgentIdentity.writePaths, effectiveCwd)) { + return { + allowed: false, + reason: writePathDeniedReason(path, subAgentIdentity.writePaths), + }; + } + } + const isRestrictedHere = bindRestrictedToProcessCwd(isRestricted, effectiveCwd); // A call targeting a restricted path (outside the workspace, or a write // under the session state root) drops from allow to ask, so it never auto-allows on diff --git a/src/permission/write-path-policy.test.ts b/src/permission/write-path-policy.test.ts new file mode 100644 index 000000000..ca0f14064 --- /dev/null +++ b/src/permission/write-path-policy.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { + matchesWritePathAllowlist, + writePathDeniedReason, +} from "./write-path-policy.js"; + +const cwd = resolve("/tmp/write-path-policy-fixture"); + +describe("matchesWritePathAllowlist", () => { + test("bare filename matches only the workspace-root file", () => { + expect(matchesWritePathAllowlist("PRODUCT.md", ["PRODUCT.md"], cwd)).toBe(true); + // Nested file of the same basename does NOT match a bare pattern. + expect(matchesWritePathAllowlist("docs/PRODUCT.md", ["PRODUCT.md"], cwd)).toBe(false); + expect(matchesWritePathAllowlist("vendor/x/PRODUCT.md", ["PRODUCT.md"], cwd)).toBe(false); + // An explicit glob reaches nested files of that name. + expect(matchesWritePathAllowlist("docs/PRODUCT.md", ["**/PRODUCT.md"], cwd)).toBe(true); + expect(matchesWritePathAllowlist("src/foo.ts", ["PRODUCT.md"], cwd)).toBe(false); + }); + + test("relative globs match workspace-relative paths", () => { + expect(matchesWritePathAllowlist("docs/a.md", ["docs/*"], cwd)).toBe(true); + expect(matchesWritePathAllowlist("src/a.md", ["docs/*"], cwd)).toBe(false); + }); + + test("empty allowlist or empty subject denies", () => { + expect(matchesWritePathAllowlist("PRODUCT.md", [], cwd)).toBe(false); + expect(matchesWritePathAllowlist("", ["PRODUCT.md"], cwd)).toBe(false); + }); + + test("absolute paths under cwd still match bare basename", () => { + const abs = resolve(cwd, "DESIGN.md"); + expect(matchesWritePathAllowlist(abs, ["DESIGN.md"], cwd)).toBe(true); + }); + + test("path traversal cannot escape a glob (subject never matched raw)", () => { + // `docs/../src/hack.ts` resolves under src/, not docs/ — must NOT match + // `docs/*` even though the raw subject string starts with `docs/`. + expect(matchesWritePathAllowlist("docs/../src/hack.ts", ["docs/*"], cwd)).toBe(false); + expect(matchesWritePathAllowlist("docs/../src/hack.ts", ["docs/**"], cwd)).toBe(false); + // Same escape via a bare-name allowlist. + expect(matchesWritePathAllowlist("PRODUCT.md/../src/hack.ts", ["PRODUCT.md"], cwd)).toBe(false); + // A legitimately nested docs file still matches. + expect(matchesWritePathAllowlist("docs/a/b.md", ["docs/**"], cwd)).toBe(true); + }); + + test("absolute paths outside cwd are hard-denied even if basename matches", () => { + // Outside root: /tmp/evil/PRODUCT.md is not under cwd, so PRODUCT.md must + // not match — no fallthrough to pattern match on the outside path. + const outside = resolve("/tmp/write-path-policy-elsewhere", "PRODUCT.md"); + expect(outside).not.toBe(resolve(cwd, "PRODUCT.md")); + expect(matchesWritePathAllowlist(outside, ["PRODUCT.md"], cwd)).toBe(false); + expect(matchesWritePathAllowlist(outside, ["**/PRODUCT.md"], cwd)).toBe(false); + // A sibling of cwd (shared /tmp parent) still denied. + expect( + matchesWritePathAllowlist("../sibling/PRODUCT.md", ["PRODUCT.md"], cwd), + ).toBe(false); + }); +}); + +describe("writePathDeniedReason", () => { + test("names allowlist and subject", () => { + const reason = writePathDeniedReason("src/x.ts", ["PRODUCT.md", "docs/*"]); + expect(reason).toContain("PRODUCT.md"); + expect(reason).toContain("docs/*"); + expect(reason).toContain("src/x.ts"); + expect(reason).toMatch(/authz/i); + }); +}); diff --git a/src/permission/write-path-policy.ts b/src/permission/write-path-policy.ts new file mode 100644 index 000000000..6c6edee01 --- /dev/null +++ b/src/permission/write-path-policy.ts @@ -0,0 +1,65 @@ +import { resolve, sep } from "node:path"; +import { matchesPattern } from "./matcher.js"; + +/** + * Director write-path allowlist (authz, not prompt policy). + * When set on a sub-agent identity, write_file / edit_file / delete_file must + * target a path matching one of these patterns. Enforced in the permission + * gate; skipPermissions (yolo) bypasses the whole gate before this runs. + * + * Subject is resolved against cwd before any matching. Any path that resolves + * OUTSIDE cwd is hard-denied: the raw subject is never matched against a + * pattern, so traversal strings (e.g. `docs/../src/hack.ts`) cannot fool a + * `docs/*` glob and absolute paths under a different root never match. + * + * Patterns: + * - bare filename (`PRODUCT.md`, no `/ * ?`) matches ONLY the workspace-root + * file of that exact name — never a nested file sharing the basename. + * A bare name is compared to the workspace-relative path, so `docs/PRODUCT.md` + * or `vendor/x/PRODUCT.md` does NOT match `PRODUCT.md`. + * - relative globs (`docs/*`, a double-star-prefixed pattern) use matchesPattern + * against the workspace-relative path only; use a double-star prefix to + * match a basename at any depth. + */ +export function matchesWritePathAllowlist( + subject: string, + allowlist: readonly string[], + cwd: string, +): boolean { + if (allowlist.length === 0) return false; + if (subject.length === 0) return false; + + const absCwd = resolve(cwd); + const abs = resolve(cwd, subject); + let rel: string; + if (abs === absCwd) { + rel = "."; + } else if (abs.startsWith(absCwd + sep)) { + rel = abs.slice(absCwd.length + 1); + } else { + // Outside cwd — hard-deny. Never fall through to pattern matching on the + // raw subject, which would let `docs/../src/hack.ts` match `docs/*`. + return false; + } + + for (const pattern of allowlist) { + // Bare filename (no path separators, no glob metacharacters): root-only. + // Matching on the basename would re-open any-depth matching for bare names, + // so a bare pattern is compared only against the workspace-relative path. + const isBareName = + !pattern.includes("/") && !pattern.includes("*") && !pattern.includes("?"); + if (isBareName) { + if (rel === pattern) return true; + continue; + } + if (matchesPattern(rel, pattern)) return true; + } + return false; +} + +export function writePathDeniedReason( + path: string, + allowlist: readonly string[], +): string { + return `Write path denied by director authz allowlist (not prompt policy). Allowed: ${allowlist.join(", ")}. Got: ${path || "(empty)"}. auto mode still enforces this; yolo (skipPermissions) bypasses.`; +} diff --git a/src/plugins/diagnostics.test.ts b/src/plugins/diagnostics.test.ts index a1fc6dfb3..01cfd8fdb 100644 --- a/src/plugins/diagnostics.test.ts +++ b/src/plugins/diagnostics.test.ts @@ -8,6 +8,8 @@ import { emitPluginWarningSummary, formatPluginWarningsSummary, pluginWarningSink, + pluginWarningSubjectId, + warningsForPluginEntry, } from "./diagnostics.js"; import { loadPluginEntry } from "./loader.js"; @@ -79,6 +81,40 @@ describe("formatPluginWarningsSummary", () => { }); }); +describe("pluginWarningSubjectId / warningsForPluginEntry", () => { + test("extracts agent and tool-plugin subject ids", () => { + expect( + pluginWarningSubjectId( + 'agent a: skill "style" referenced but not found in skill search path', + ), + ).toBe("a"); + expect( + pluginWarningSubjectId('tool-plugin: failed to start "exa": boom'), + ).toBe("exa"); + expect(pluginWarningSubjectId("other problem")).toBeUndefined(); + }); + + test("attributes skill-miss warnings via plugin id or agent profile id", () => { + const warnings = [ + 'agent a: skill "style" referenced but not found in skill search path', + 'agent b: skill "philosophy" referenced but not found in skill search path', + 'tool-plugin: failed to start "exa": boom', + ]; + expect( + warningsForPluginEntry(warnings, { + id: "pack", + agentProfiles: [{ id: "a" }], + }), + ).toEqual([ + 'agent a: skill "style" referenced but not found in skill search path', + ]); + expect(warningsForPluginEntry(warnings, { id: "exa" })).toEqual([ + 'tool-plugin: failed to start "exa": boom', + ]); + expect(warningsForPluginEntry(warnings, { id: "other" })).toEqual([]); + }); +}); + describe("emitPluginWarningSummary", () => { test("writes one summary line via custom sink", () => { const diag = createPluginLoadDiagnostics(); diff --git a/src/plugins/diagnostics.ts b/src/plugins/diagnostics.ts index 78fc44730..60af65a9f 100644 --- a/src/plugins/diagnostics.ts +++ b/src/plugins/diagnostics.ts @@ -105,3 +105,34 @@ export function emitPluginWarningSummary( export function emitPluginWarningLog(diag: PluginLoadDiagnostics): void { emitPluginWarningSummary(diag, (line) => pluginDiagnosticsLogger.warn(line)); } + +/** + * Extract a plugin or agent id a warning names, when present. Skill-miss lines + * lead with `agent <id>:`; tool-plugin start failures quote the candidate id. + */ +export function pluginWarningSubjectId(warning: string): string | undefined { + const agent = /^agent ([^:]+):/.exec(warning)?.[1]; + if (agent !== undefined) return agent; + const tool = /tool-plugin: failed to start "([^"]+)"/.exec(warning)?.[1]; + if (tool !== undefined) return tool; + return undefined; +} + +/** + * Warnings attributable to one plugin: subject id matches the plugin id or any + * of its agent profile ids. + */ +export function warningsForPluginEntry( + warnings: readonly string[], + plugin: { + readonly id: string; + readonly agentProfiles?: readonly { readonly id: string }[]; + }, +): string[] { + const ids = new Set<string>([plugin.id]); + for (const profile of plugin.agentProfiles ?? []) ids.add(profile.id); + return warnings.filter((w) => { + const subject = pluginWarningSubjectId(w); + return subject !== undefined && ids.has(subject); + }); +} diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 3d5f463f1..5db3b23cb 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -1,6 +1,8 @@ +import { existsSync, statSync } from "node:fs"; import { readFile, readdir, realpath, stat } from "node:fs/promises"; import { homedir } from "node:os"; -import { dirname, isAbsolute, join, parse, resolve } from "node:path"; +import { basename, dirname, isAbsolute, join, parse, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import type { WorkflowPlugin } from "../workflows/types.js"; import { SETTINGS_DIR_NAME } from "../branding.js"; @@ -629,17 +631,61 @@ export async function loadPluginsFromPaths( return loaded.filter((m): m is PluginModule => m !== null); } -// Discover built-in repo plugins from the plugins/ directory that lives -// alongside this source file (two levels up: src/plugins/ -> plugins/). -// Repo plugins resolve skills against the session cwd, not the repo root, -// so project-local skills stay in scope when Corbits Code is invoked from a -// different working directory. Product-shipped plugins are auto-trusted. +// Discover built-in repo plugins shipped next to the product, never session +// cwd/plugins (that would stamp a foreign tree origin:repo). Locator matches +// resolveChangelogPath: first existing directory wins. Missing dir is a +// silent empty list. +function isExistingDirectory(path: string): boolean { + try { + return existsSync(path) && statSync(path).isDirectory(); + } catch { + return false; + } +} + +export function resolveRepoPluginsDir(opts?: { + moduleUrl?: string; + execPath?: string; +}): string | undefined { + const candidates: string[] = []; + const moduleUrl = opts?.moduleUrl ?? import.meta.url; + try { + const here = dirname(fileURLToPath(moduleUrl)); + // Source tree only: src/plugins/loader.ts → ../../plugins. From dist/index.js + // that walk is parent-of-repo, a foreign tree we must never stamp origin:repo. + if (basename(here) === "plugins" && basename(dirname(here)) === "src") { + candidates.push(join(here, "..", "..", "plugins")); + } + // Bundled: dist/index.js → dist/plugins (copied at build). + candidates.push(join(here, "plugins")); + } catch { + // Invalid moduleUrl (tests may pass a non-file URL). + } + const execPath = opts?.execPath ?? process.execPath; + if (execPath.length > 0) { + // Compiled binary: plugins/ sits next to the executable. + candidates.push(join(dirname(execPath), "plugins")); + } + for (const dir of candidates) { + if (isExistingDirectory(dir)) return dir; + } + return undefined; +} + export async function discoverRepoPlugins( cwd: string, - opts: { diagnostics?: PluginLoadDiagnostics; telemetry?: Telemetry } = {}, + opts: { + diagnostics?: PluginLoadDiagnostics; + telemetry?: Telemetry; + moduleUrl?: string; + execPath?: string; + } = {}, ): Promise<PluginModule[]> { - const repoRoot = new URL("../../", import.meta.url).pathname; - const pluginsDir = join(repoRoot, "plugins"); + const pluginsDir = resolveRepoPluginsDir({ + moduleUrl: opts.moduleUrl ?? import.meta.url, + execPath: opts.execPath ?? process.execPath, + }); + if (pluginsDir === undefined) return []; return scanPluginsDir(pluginsDir, cwd, "repo", undefined, opts.diagnostics, opts.telemetry); } diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index ab7ab281d..06c37f9e2 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -24,6 +24,9 @@ export type PluginManifest = { kind: PluginKind; description?: string; credentials?: PluginCredentialField[]; + // First-party (origin:repo) plugins may opt in to on-by-default when settings + // have no entry. Marketplace/path/user plugins ignore this flag. + defaultEnabled?: boolean; }; const PluginCredentialFieldSchema = type({ @@ -39,6 +42,7 @@ export const PluginManifestSchema = type({ kind: "'web' | 'command' | 'tool' | 'agent' | 'workflow'", "description?": "string", "credentials?": PluginCredentialFieldSchema.array(), + "defaultEnabled?": "boolean", }); export function parsePluginManifest(value: unknown): PluginManifest | null { diff --git a/src/plugins/register.ts b/src/plugins/register.ts index de2f9420f..2171a3f0c 100644 --- a/src/plugins/register.ts +++ b/src/plugins/register.ts @@ -7,6 +7,21 @@ export function isPluginEnabled(config: Record<string, PluginConfig>, id: string return config[id]?.enabled === true; } +// Enablement for a loaded module: explicit settings win; otherwise only a +// first-party repo plugin with manifest.defaultEnabled turns on. Marketplace +// (user), path, and project plugins cannot self-enable via the flag. +export function isPluginModuleEnabled( + mod: PluginModule, + config: Record<string, PluginConfig | undefined>, +): boolean { + const id = mod.manifest?.id; + if (id === undefined) return false; + const enabled = config[id]?.enabled; + if (enabled === true) return true; + if (enabled === false) return false; + return mod.origin === "repo" && mod.manifest?.defaultEnabled === true; +} + // Mark a plugin enabled while preserving credentials/consented and other fields. // Path-add and similar consent actions must call this so restart re-wires slash // commands (isPluginEnabled is strict: missing entry === disabled). @@ -26,7 +41,7 @@ export function isEnabledCommandPlugin(mod: PluginModule, config: Record<string, // commands wire as an added surface without changing the plugin's primary kind. return ( (kind === "command" || kind === "workflow" || kind === "agent") && - isPluginEnabled(config, mod.manifest!.id) + isPluginModuleEnabled(mod, config) ); } @@ -34,7 +49,7 @@ export function isEnabledWorkflowPlugin(mod: PluginModule, config: Record<string return ( mod.manifest?.kind === "workflow" && mod.workflowPlugin !== undefined && - isPluginEnabled(config, mod.manifest.id) + isPluginModuleEnabled(mod, config) ); } diff --git a/src/plugins/skill-commands.ts b/src/plugins/skill-commands.ts index aa68d5acc..f0fc9b73a 100644 --- a/src/plugins/skill-commands.ts +++ b/src/plugins/skill-commands.ts @@ -7,12 +7,13 @@ import { } from "../tui/commands/registry.js"; import { splitFrontmatter } from "./frontmatter.js"; -// Every skill in an enabled plugin is surfaced as a user slash command: -// `/<skill-name> [args]` sends the skill body (plus args) to the agent. A skill -// authored as `skills/<name>/SKILL.md` is normally model-invoked via the -// `use_skill` tool; exposing it as a /command adds a direct user entry point -// without removing the model-invoked path — `discoverSkills` is unchanged, so -// the model can still auto-invoke any skill. +// Slash is the operator action surface: `/<skill-name> [args]` sends the skill +// body (plus args) to the agent. Convention/internal skills opt out with +// `user-invocable: false` in frontmatter and are not emitted as slash commands. +// Untagged skills still become slash commands (marketplace BC). +// `disable-model-invocation` does not affect slash emission. A skill authored +// as `skills/<name>/SKILL.md` is still model-invoked via the `use_skill` tool; +// `discoverSkills` is unchanged, so the model can still auto-invoke any skill. const COMMAND_NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/; @@ -56,6 +57,9 @@ export async function loadSkillCommands( warn(`skipping skills/${entry.name}/SKILL.md: malformed frontmatter`); continue; } + // Opt-out of the slash surface. Untagged skills still emit a command + // (marketplace BC); `disable-model-invocation` does not affect this. + if (frontmatter["user-invocable"] === false) continue; const name = typeof frontmatter.name === "string" && frontmatter.name.trim().length > 0 diff --git a/src/prompts.test.ts b/src/prompts.test.ts index 20797b89c..4864e0a8b 100644 --- a/src/prompts.test.ts +++ b/src/prompts.test.ts @@ -33,21 +33,22 @@ test("chat prompt orders base, then tools, then context", () => { expect(prompt.indexOf("Tools:")).toBeLessThan(prompt.indexOf("Active context:")); }); -test("agent identity is Corbits Code with mode-specific primary roles", () => { - const single = buildChatRole("single"); - expect(single).toContain("Corbits Code"); - expect(single).toContain("senior coding assistant"); - expect(single).toContain("read, edit"); +test("agent identity is Skywalker orchestrator", () => { const orchestrator = buildChatRole("orchestrator"); + expect(orchestrator).toContain("You are Skywalker"); expect(orchestrator).toContain("Corbits Code"); - expect(orchestrator).toContain("orchestrator"); - expect(orchestrator).toContain("delegate"); - expect(orchestrator).toContain("Match their tone"); + expect(orchestrator).toContain("When asked your name, answer: Skywalker"); + expect(orchestrator).toContain("PRIMARY INTENT"); + expect(orchestrator).toContain("Delegate"); + expect(orchestrator).toContain("Match operator tone"); + // Mode arg is ignored — product is orchestrator-only (CL-5814). + expect(buildChatRole()).toContain("You are Skywalker"); }); test("harness facts state only the non-derivable tool and safety rules", () => { const facts = buildHarnessFacts(); - expect(facts).toContain("write_file/edit_file"); + expect(facts).toContain("write_file, edit_file, delete_file"); + expect(facts).toContain("not mounted on the primary Skywalker session"); expect(facts).toContain("blocked"); expect(facts).toContain("15s timeout"); expect(facts).toContain("find, rg, and grep -r"); @@ -61,9 +62,27 @@ test("harness facts state only the non-derivable tool and safety rules", () => { expect(facts).toContain("slash-command steps"); expect(facts).toContain(".corbits/MEMORY.md"); expect(facts).toContain("Attached images are native multimodal input"); + expect(facts).toContain("parent tool.boundary"); + expect(facts).toContain("session-idle"); expect(facts).not.toContain("Tool results already render richly"); }); +test("leaf harness facts advertise product write tools", () => { + const facts = buildHarnessFacts({ subAgent: true, dynamicTools: false }); + expect(facts).toContain("write_file/edit_file"); + expect(facts).not.toContain("not mounted on the primary Skywalker session"); +}); + +test("leaf harness facts state turn budget and wrap-up report behavior", () => { + const facts = buildHarnessFacts({ subAgent: true, dynamicTools: false }); + expect(facts).toContain("Turn budget is real"); + expect(facts).toContain("wrap-up nudge"); + expect(facts).toContain("Summary/Findings/Blockers/Paths"); + expect(facts).toContain("Do not thrash re-reads"); + // Primary harness facts omit leaf turn-budget language. + expect(buildHarnessFacts()).not.toContain("Turn budget is real"); +}); + test("guidelines cover response style, tool choice, ask vs proceed, and scope", () => { const guidelines = buildGuidelines(); expect(guidelines).toContain("Response style:"); @@ -82,32 +101,25 @@ test("orchestrator guidelines teach the typed task spawn contract", () => { expect(guidelines).toContain("do_not"); expect(guidelines).toContain("report_focus"); expect(guidelines).toContain("intent"); - const single = buildGuidelines({ sessionMode: "single" }); - expect(single).not.toContain("success_criteria"); }); -test("chat system prompt satisfies system prompt quality markers", () => { - const prompt = buildChatSystemPrompt(); - for (const marker of CHAT_PROMPT_QUALITY_MARKERS) { - expect(prompt).toContain(marker); - } +test("primary guidelines advise against early-stop from compaction token fear", () => { + const guidelines = buildGuidelines(); + expect(guidelines).toContain("compacted automatically"); + expect(guidelines).toContain("do not stop tasks early due to token fear"); + expect(guidelines).toContain("manage_tasks and worker reports"); + // Leaf guidelines omit primary orchestration compaction guidance. + expect(buildGuidelines({ subAgent: true })).not.toContain("token fear"); }); -test("single session mode satisfies system prompt quality markers", () => { - const prompt = buildChatSystemPrompt(undefined, undefined, undefined, [], "single"); +test("chat system prompt satisfies system prompt quality markers", () => { + const prompt = buildChatSystemPrompt(); for (const marker of CHAT_PROMPT_QUALITY_MARKERS) { expect(prompt).toContain(marker); } }); -test("single session mode omits task and search_agents from the tools list", () => { - const prompt = buildChatSystemPrompt(undefined, undefined, undefined, [], "single"); - expect(prompt).toContain("read_file"); - expect(prompt).not.toContain("- task:"); - expect(prompt).not.toContain("- search_agents:"); -}); - -test("orchestrator session mode lists task and search_agents", () => { +test("default session always lists task and search_agents", () => { const prompt = buildChatSystemPrompt(undefined, undefined, undefined, [], "orchestrator"); expect(prompt).toContain("- task:"); expect(prompt).toContain("- search_agents:"); @@ -147,12 +159,13 @@ test("a SYSTEM.md base override replaces the static base but keeps tools and con expect(prompt).toContain("Active context:"); }); -test("SYSTEM.md override in single mode still appends single-agent harness rules", () => { +test("SYSTEM.md override still appends orchestrator harness rules", () => { const override = "You are a custom agent that mentions delegating to workers."; - const prompt = buildChatSystemPrompt(undefined, undefined, override, [], "single"); + const prompt = buildChatSystemPrompt(undefined, undefined, override, []); expect(prompt).toContain(override); - expect(prompt).toContain("single-agent mode"); - expect(prompt).not.toContain("- task:"); + expect(prompt).toContain("## Session mode"); + expect(prompt).toContain("Orchestration:"); + expect(prompt).toContain("- task:"); }); test("an empty base override falls back to the default base", () => { @@ -186,6 +199,8 @@ test("when an env is supplied, the prompt ends with a live <env> block instead", const env = { cwd: "/repo/root", platform: "Darwin 25.4.0", + arch: "arm64", + runtime: "Bun 1.2.0", date: new Date(2026, 5, 5), isGitRepo: true, gitBranch: "main", @@ -197,6 +212,8 @@ test("when an env is supplied, the prompt ends with a live <env> block instead", expect(prompt).toContain("<env>"); expect(prompt.trim()).toMatch(/<\/env>$/); expect(prompt).toContain("Working directory: /repo/root"); + expect(prompt).toContain("Arch: arm64"); + expect(prompt).toContain("Runtime: Bun 1.2.0"); expect(prompt).toContain("Git: on main, 2 uncommitted change(s):"); expect(prompt).toContain(" M src/a.ts"); expect(prompt).not.toContain("Active context:"); @@ -206,16 +223,21 @@ test("buildEnvironmentContext reports a clean tree and a non-git directory", () const clean = buildEnvironmentContext({ cwd: "/r", platform: "Linux 6", + arch: "x64", + runtime: "Bun 1.2.0", date: new Date(2026, 0, 1), isGitRepo: true, gitBranch: "dev", gitDirtyCount: 0, }); expect(clean).toContain("Git: on dev, working tree clean"); + expect(clean).toContain("Arch: x64"); const noGit = buildEnvironmentContext({ cwd: "/r", platform: "Linux 6", + arch: "x64", + runtime: "Bun 1.2.0", date: new Date(2026, 0, 1), isGitRepo: false, }); @@ -267,8 +289,8 @@ test("sub-agent prompt always appends Corbits Code notes, even with a JS-plugin- const prompt = buildSubAgentSystemPrompt([role]); expect(prompt).toContain(role); expect(prompt).toContain("## Corbits Code notes"); - // Leaf agents get the no-recursion rule, not the spawn syntax. - expect(prompt).toContain("leaf sub-agent"); + // Workers get the no-recursion rule, not the spawn syntax. + expect(prompt).toContain("You are a worker"); // Agent voice leads; translation notes are the last section. expect(prompt.indexOf(role)).toBeLessThan(prompt.indexOf("## Corbits Code notes")); }); @@ -279,7 +301,7 @@ test("sub-agent prompt always appends Corbits Code notes, even with a JS-plugin- test("default sub-agent prompt forbids recursion", () => { const prompt = buildSubAgentSystemPrompt(); expect(prompt).toContain("Only the primary Corbits Code session (or an orchestrator profile) may call `task`"); - expect(prompt).toContain("leaf sub-agent"); + expect(prompt).toContain("You are a worker"); }); // Orchestrator profiles (frontmatter `orchestrator: true`) are the documented @@ -309,7 +331,7 @@ test("sub-agent prompt requires structured report envelope and stick-to-brief", test("default sub-agent prompt omits Grok anti-thrash residual", () => { const prompt = buildSubAgentSystemPrompt(); - expect(prompt).not.toContain("Finish bias (xAI / Grok leaf)"); + expect(prompt).not.toContain("Finish bias (xAI / Grok worker)"); }); test("grokAntiThrash opts appends tiny finish-bias note before appendix", () => { @@ -322,7 +344,7 @@ test("grokAntiThrash opts appends tiny finish-bias note before appendix", () => expect(prompt).toContain("re-open paths you already read"); expect(prompt).toContain("Leave the last turn for the report envelope"); // Appendix still last. - expect(prompt.indexOf("Finish bias (xAI / Grok leaf)")).toBeLessThan( + expect(prompt.indexOf("Finish bias (xAI / Grok worker)")).toBeLessThan( prompt.indexOf("## Corbits Code notes"), ); }); diff --git a/src/provider/grok-responses-adapter.test.ts b/src/provider/grok-responses-adapter.test.ts index 8c497e292..e98b70d86 100644 --- a/src/provider/grok-responses-adapter.test.ts +++ b/src/provider/grok-responses-adapter.test.ts @@ -51,4 +51,28 @@ describe("createGrokResponsesAdapter", () => { expect(body.input[0]?.content).toBe("hello"); }); + + test("requests detailed reasoning summaries so thinking activity streams", () => { + const adapter = createGrokResponsesAdapter(source); + const turns: ConversationTurn[] = [ + { + role: "user", + timestamp: 0, + content: [{ type: "text", text: "hello" }], + }, + ]; + + const request = adapter.buildRequest(turns, "grok-4.6", {}); + const body = JSON.parse(request.body) as { + reasoning?: { summary?: string }; + include?: string[]; + store?: boolean; + stream?: boolean; + }; + + expect(body.stream).toBe(true); + expect(body.store).toBe(false); + expect(body.include).toEqual(["reasoning.encrypted_content"]); + expect(body.reasoning).toEqual({ summary: "detailed" }); + }); }); diff --git a/src/provider/grok-responses-adapter.ts b/src/provider/grok-responses-adapter.ts index b8ce9178e..cd367fca3 100644 --- a/src/provider/grok-responses-adapter.ts +++ b/src/provider/grok-responses-adapter.ts @@ -158,7 +158,10 @@ function buildRequest( store: false, stream: true, include: ["reasoning.encrypted_content"], - reasoning: { summary: "auto" }, + // "detailed" streams denser summary deltas than "auto". Grok bills full + // thinking tokens but only returns summarized text; sparse auto summaries + // left the stall/activity clocks quiet for 60–120s mid-think. + reasoning: { summary: "detailed" }, }; if (tools !== undefined) { body["tools"] = tools; diff --git a/src/provider/reasoning-effort.ts b/src/provider/reasoning-effort.ts index bf742530e..32894980c 100644 --- a/src/provider/reasoning-effort.ts +++ b/src/provider/reasoning-effort.ts @@ -158,6 +158,11 @@ export type ResolveEffortForRoleOpts = { orchestrator: boolean; /** Explicit profile inference leg or task-tier pin — highest precedence. */ pin?: ReasoningEffort; + /** + * Package modelRole default (CL-5816). When set, replaces the binary + * orchestrator/leaf default so intern can be low while implement stays medium. + */ + roleDefault?: ReasoningEffort; /** Parent session effort — used only when the role default is not supported. */ parentEffort?: ReasoningEffort; model: string; @@ -206,11 +211,12 @@ export function pickEffortFromCascade(opts: { */ export function resolveEffortForRole(opts: ResolveEffortForRoleOpts): ReasoningEffort | undefined { const supported = supportedEfforts(opts.model, undefined, opts.isCodex === true); + const roleDefault = + opts.roleDefault ?? + (opts.orchestrator ? ROLE_DEFAULT_EFFORT.orchestrator : ROLE_DEFAULT_EFFORT.leaf); return pickEffortFromCascade({ ...(opts.pin !== undefined ? { pin: opts.pin } : {}), - roleDefault: opts.orchestrator - ? ROLE_DEFAULT_EFFORT.orchestrator - : ROLE_DEFAULT_EFFORT.leaf, + roleDefault, ...(opts.parentEffort !== undefined ? { parentEffort: opts.parentEffort } : {}), supported, }); diff --git a/src/session/index.ts b/src/session/index.ts index 2aec9ac8e..e536308ed 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -179,7 +179,7 @@ export async function resolveLatestSession( }; } catch { // Fall back: legacy latest under cwd, then under the git project root - // (worktree cwd may not have its own .agent-state/latest). + // (nested cwd may not have its own .agent-state/latest). for (const legacyLink of legacyLatestCandidates(cwd)) { try { const sessionId = await readlink(legacyLink); diff --git a/src/session/project-key.test.ts b/src/session/project-key.test.ts index 880deb4b9..0dea01395 100644 --- a/src/session/project-key.test.ts +++ b/src/session/project-key.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { mkdir, rm, writeFile } from "node:fs/promises"; +import { realpathSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { execFileSync } from "node:child_process"; @@ -22,6 +23,18 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }); }); +function initGitRepo(dir: string): void { + execFileSync("git", ["init"], { cwd: dir, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "test"], { cwd: dir, stdio: "ignore" }); +} + +async function commitReadme(dir: string): Promise<void> { + await writeFile(join(dir, "README"), "x"); + execFileSync("git", ["add", "README"], { cwd: dir, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "init"], { cwd: dir, stdio: "ignore" }); +} + test("projectKeyFor is stable across calls for the same path", () => { const a = projectKeyFor(root); const b = projectKeyFor(root); @@ -29,40 +42,55 @@ test("projectKeyFor is stable across calls for the same path", () => { expect(a).toMatch(/^[a-z0-9]+(?:-[a-z0-9]+)*-[a-f0-9]{8}$/); }); -test("projectKeyFor uses shared git common dir so worktrees match main", async () => { - execFileSync("git", ["init"], { cwd: root, stdio: "ignore" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: root, stdio: "ignore" }); - execFileSync("git", ["config", "user.name", "test"], { cwd: root, stdio: "ignore" }); - await writeFile(join(root, "README"), "x"); - execFileSync("git", ["add", "README"], { cwd: root, stdio: "ignore" }); - execFileSync("git", ["commit", "-m", "init"], { cwd: root, stdio: "ignore" }); +test("projectKeyFor shares nested dirs under the same git toplevel", async () => { + initGitRepo(root); + await commitReadme(root); const nested = join(root, "nested", "deep"); await mkdir(nested, { recursive: true }); expect(projectRootFor(nested)).toBe(projectRootFor(root)); + expect(projectRootFor(root)).toBe(realpathSync(root)); expect(projectKeyFor(nested)).toBe(projectKeyFor(root)); +}); - const wt = join(root, "..", `wt-${Date.now()}`); +test("linked worktrees have distinct project roots and keys from main and each other", async () => { + initGitRepo(root); + await commitReadme(root); + + const wtA = join(root, "..", `wt-a-${Date.now()}-${Math.random().toString(16).slice(2)}`); + const wtB = join(root, "..", `wt-b-${Date.now()}-${Math.random().toString(16).slice(2)}`); try { - execFileSync("git", ["worktree", "add", "--detach", wt, "HEAD"], { + execFileSync("git", ["worktree", "add", "--detach", wtA, "HEAD"], { cwd: root, stdio: "ignore", }); - expect(projectRootFor(wt)).toBe(projectRootFor(root)); - expect(projectKeyFor(wt)).toBe(projectKeyFor(root)); + execFileSync("git", ["worktree", "add", "--detach", wtB, "HEAD"], { + cwd: root, + stdio: "ignore", + }); + + expect(projectRootFor(wtA)).not.toBe(projectRootFor(root)); + expect(projectRootFor(wtB)).not.toBe(projectRootFor(root)); + expect(projectRootFor(wtA)).not.toBe(projectRootFor(wtB)); + expect(projectRootFor(wtA)).toBe(realpathSync(wtA)); + expect(projectRootFor(wtB)).toBe(realpathSync(wtB)); + expect(projectKeyFor(wtA)).not.toBe(projectKeyFor(root)); + expect(projectKeyFor(wtB)).not.toBe(projectKeyFor(root)); + expect(projectKeyFor(wtA)).not.toBe(projectKeyFor(wtB)); } finally { - try { - execFileSync("git", ["worktree", "remove", "--force", wt], { - cwd: root, - stdio: "ignore", - }); - } catch { - await rm(wt, { recursive: true, force: true }); + for (const wt of [wtA, wtB]) { + try { + execFileSync("git", ["worktree", "remove", "--force", wt], { + cwd: root, + stdio: "ignore", + }); + } catch { + await rm(wt, { recursive: true, force: true }); + } } } }); - test("projectSessionsRoot lives under ~/.corbits/projects/<key>", () => { const home = join(root, "home"); const key = projectKeyFor(root); diff --git a/src/session/project-key.ts b/src/session/project-key.ts index a1fd50e23..bacd4b9d7 100644 --- a/src/session/project-key.ts +++ b/src/session/project-key.ts @@ -8,10 +8,11 @@ import { homedir } from "node:os"; import { SETTINGS_DIR_NAME } from "../branding.js"; // Project identity for the global session tree under -// ~/.corbits/projects/<project-key>/<thread-id>/. Prefer a git *common* root so -// main + linked worktrees share resume history; fall back to the workspace -// realpath for non-git trees. The key is a readable slug plus a short hash of -// the absolute root so common folder names ("src", "app") do not collide. +// ~/.corbits/projects/<project-key>/<thread-id>/. Prefer this checkout's git +// toplevel so linked worktrees each have their own resume list; fall back to +// the workspace realpath for non-git trees. The key is a readable slug plus a +// short hash of the absolute root so common folder names ("src", "app") do +// not collide. function realpathOr(path: string): string { try { @@ -30,34 +31,30 @@ function slugSegment(name: string): string { } /** - * Shared project root for session identity. + * Checkout project root for session identity. * - * Linked worktrees each have their own toplevel path; `--git-common-dir` points - * at the main repo's `.git`, so parent-of-common-dir is stable across worktrees. + * Nested cwd under a git toplevel shares that root (and therefore the same + * project key). Linked worktrees each have their own `--show-toplevel`. */ export function projectRootFor(cwd: string): string { try { - const commonRaw = execFileSync( + const toplevelRaw = execFileSync( "git", - ["rev-parse", "--path-format=absolute", "--git-common-dir"], + ["rev-parse", "--path-format=absolute", "--show-toplevel"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }, ).trim(); - if (commonRaw.length > 0) { - const commonAbs = realpathOr( - isAbsolute(commonRaw) ? commonRaw : resolve(cwd, commonRaw), - ); - // Standard layout: <repo>/.git → project root is parent. - // Bare repo: common dir is the bare store itself. - const root = - basename(commonAbs) === ".git" ? dirname(commonAbs) : commonAbs; - return realpathOr(root); + if (toplevelRaw.length > 0) { + const toplevelAbs = isAbsolute(toplevelRaw) + ? toplevelRaw + : resolve(cwd, toplevelRaw); + return realpathOr(toplevelAbs); } } catch { - // Not a git worktree (or git unavailable) — use the workspace path. + // Not a git checkout (or git unavailable) — use the workspace path. } return realpathOr(cwd); } diff --git a/src/session/runtime-assembly.test.ts b/src/session/runtime-assembly.test.ts index 60fe56e96..6ab85b84a 100644 --- a/src/session/runtime-assembly.test.ts +++ b/src/session/runtime-assembly.test.ts @@ -210,6 +210,46 @@ describe("skillDirsFromEnabledPlugins", () => { }), ).toEqual(["/a"]); }); + + test("includes a repo defaultEnabled plugin with no settings entry", () => { + const modules = [ + { + dir: "/skills", + origin: "repo", + manifest: { id: "corbits-skills", name: "skills", kind: "command", defaultEnabled: true }, + }, + ] as unknown as PluginModule[]; + expect(skillDirsFromEnabledPlugins(modules, {})).toEqual(["/skills"]); + }); + + test("excludes a repo defaultEnabled plugin when enabled:false", () => { + const modules = [ + { + dir: "/skills", + origin: "repo", + manifest: { id: "corbits-skills", name: "skills", kind: "command", defaultEnabled: true }, + }, + ] as unknown as PluginModule[]; + expect( + skillDirsFromEnabledPlugins(modules, { "corbits-skills": { enabled: false } }), + ).toEqual([]); + }); + + test("ignores defaultEnabled on marketplace/path plugins", () => { + const modules = [ + { + dir: "/user", + origin: "user", + manifest: { id: "mkt", name: "mkt", kind: "command", defaultEnabled: true }, + }, + { + dir: "/path", + origin: "path", + manifest: { id: "p", name: "p", kind: "command", defaultEnabled: true }, + }, + ] as unknown as PluginModule[]; + expect(skillDirsFromEnabledPlugins(modules, {})).toEqual([]); + }); }); describe("createSessionPruningCompactor", () => { diff --git a/src/session/runtime-assembly.ts b/src/session/runtime-assembly.ts index cc97c1a53..bc20634ce 100644 --- a/src/session/runtime-assembly.ts +++ b/src/session/runtime-assembly.ts @@ -27,6 +27,7 @@ import { type PluginLoadDiagnostics, type PluginModule, } from "../plugins/loader.js"; +import { isPluginModuleEnabled } from "../plugins/register.js"; import { loadApprovals, loadGlobalApprovals, @@ -181,18 +182,13 @@ export async function discoverSessionPlugins( ]); } -/** Skill directories from plugins that are both executable and enabled in settings. */ +/** Skill directories from plugins that are executable and enabled (settings or repo defaultEnabled). */ export function skillDirsFromEnabledPlugins( modules: readonly PluginModule[], pluginConfig: Record<string, PluginConfig | undefined>, ): string[] { return modules - .filter( - (m) => - m.dir !== undefined && - m.manifest?.id !== undefined && - pluginConfig[m.manifest.id]?.enabled === true, - ) + .filter((m) => m.dir !== undefined && isPluginModuleEnabled(m, pluginConfig)) .map((m) => m.dir!); } diff --git a/src/session/session-dir.test.ts b/src/session/session-dir.test.ts index 45e3fb42b..7392979d5 100644 --- a/src/session/session-dir.test.ts +++ b/src/session/session-dir.test.ts @@ -3,6 +3,7 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { execFileSync } from "node:child_process"; import { generateSessionId, @@ -34,7 +35,6 @@ test("initSessionDir writes under the global projects tree, not the repo", async const dir = await initSessionDir(cwd, sessionId, home); expect(dir).toBe(sessionDir(cwd, sessionId, home)); expect(dir.startsWith(join(home, ".corbits", "projects"))).toBe(true); - expect(existsSync(join(dir, "context"))).toBe(true); expect(existsSync(join(cwd, ".agent-state", sessionId))).toBe(false); }); @@ -81,8 +81,7 @@ test("listSessions finds legacy sessions and migrates them", async () => { expect(existsSync(legacy)).toBe(false); }); -test("migrateLegacySessionIfNeeded finds legacy under git project root from a worktree cwd", async () => { - const { execFileSync } = await import("node:child_process"); +test("migrateLegacySessionIfNeeded does not migrate main-repo .agent-state from a worktree cwd", async () => { const main = join(cwd, "main"); await mkdir(main, { recursive: true }); execFileSync("git", ["init"], { cwd: main, stdio: "ignore" }); @@ -92,8 +91,8 @@ test("migrateLegacySessionIfNeeded finds legacy under git project root from a wo execFileSync("git", ["add", "README"], { cwd: main, stdio: "ignore" }); execFileSync("git", ["commit", "-m", "init"], { cwd: main, stdio: "ignore" }); - const sessionId = generateSessionId(); - const legacyOnMain = join(main, ".agent-state", sessionId); + const mainSessionId = generateSessionId(); + const legacyOnMain = join(main, ".agent-state", mainSessionId); await mkdir(join(legacyOnMain, "context"), { recursive: true }); await writeFile( join(legacyOnMain, "run.json"), @@ -111,12 +110,33 @@ test("migrateLegacySessionIfNeeded finds legacy under git project root from a wo stdio: "ignore", }); try { - const dir = await migrateLegacySessionIfNeeded(wt, sessionId, home); - expect(dir).toBe(sessionDir(wt, sessionId, home)); - expect(existsSync(dir)).toBe(true); - expect(existsSync(legacyOnMain)).toBe(false); - const raw = await readFile(join(dir, "run.json"), "utf8"); - expect(JSON.parse(raw).task).toBe("main-legacy"); + const fromWorktree = await migrateLegacySessionIfNeeded(wt, mainSessionId, home); + expect(fromWorktree).toBe(sessionDir(wt, mainSessionId, home)); + expect(fromWorktree).not.toBe(sessionDir(main, mainSessionId, home)); + expect(existsSync(legacyOnMain)).toBe(true); + expect(existsSync(fromWorktree)).toBe(false); + + const wtSessionId = generateSessionId(); + const legacyOnWt = join(wt, ".agent-state", wtSessionId); + await mkdir(join(legacyOnWt, "context"), { recursive: true }); + await writeFile( + join(legacyOnWt, "run.json"), + JSON.stringify({ + status: "running", + turnsUsed: 1, + task: "worktree-legacy", + startedAt: 1_700_000_000_000, + }), + ); + + const wtDir = await migrateLegacySessionIfNeeded(wt, wtSessionId, home); + expect(wtDir).toBe(sessionDir(wt, wtSessionId, home)); + expect(wtDir).not.toBe(sessionDir(main, wtSessionId, home)); + expect(existsSync(wtDir)).toBe(true); + expect(existsSync(legacyOnWt)).toBe(false); + const raw = await readFile(join(wtDir, "run.json"), "utf8"); + expect(JSON.parse(raw).task).toBe("worktree-legacy"); + expect(existsSync(legacyOnMain)).toBe(true); } finally { try { execFileSync("git", ["worktree", "remove", "--force", wt], { @@ -128,4 +148,3 @@ test("migrateLegacySessionIfNeeded finds legacy under git project root from a wo } } }); - diff --git a/src/settings.test.ts b/src/settings.test.ts index a7f299bea..2be4d543a 100644 --- a/src/settings.test.ts +++ b/src/settings.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect } from "bun:test"; import { chmod, mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, dirname } from "node:path"; import { OPENCODE_GO_BASE_URL } from "../packages/opencode-go/src/index.js"; import { @@ -27,6 +27,7 @@ import { markLastChangelogVersion, pushRecentModel, toggleFavoriteModel, + setDefaultModel, listRecentModels, listFavoriteModels, } from "./config/settings.js"; @@ -733,18 +734,24 @@ describe("loaders", () => { }); describe("sessionMode", () => { - test("loadSettings round-trips sessionMode", async () => { + test("loadSettings drops legacy single sessionMode", async () => { const dir = await mkdtemp(join(tmpdir(), "ic-settings-")); try { const path = join(dir, ".corbits", "settings.json"); - await saveGlobalSettings(path, { ...firepass, sessionMode: "single" }); - expect(await loadSettings(path)).toEqual({ ...firepass, sessionMode: "single" }); + await mkdir(dirname(path), { recursive: true }); + await writeFile( + path, + JSON.stringify({ ...firepass, sessionMode: "single" }, null, 2) + "\n", + "utf8", + ); + // CL-5814: "single" still loads without error, then is stripped. + expect(await loadSettings(path)).toEqual(firepass); } finally { await rm(dir, { recursive: true, force: true }); } }); - test("loadLocalSettings round-trips sessionMode", async () => { + test("loadLocalSettings round-trips orchestrator sessionMode", async () => { const dir = await mkdtemp(join(tmpdir(), "ic-local-")); try { const path = join(dir, ".corbits", "settings.json"); @@ -1026,6 +1033,32 @@ describe("recent and favorite model helpers", () => { expect(listFavoriteModels(s)).toEqual([]); }); + test("setDefaultModel sets defaultProvider and that provider's defaultModel", () => { + const s: Settings = { + defaultProvider: "a", + providers: { + a: { baseURL: "https://a/v1", apiKey: "a-key", models: ["a-model"], defaultModel: "a-model" }, + b: { baseURL: "https://b/v1", apiKey: "b-key", models: ["b-model", "b-other"], defaultModel: "b-model" }, + }, + recentModels: [{ provider: "a", model: "a-model" }], + favoriteModels: [{ provider: "b", model: "b-model" }], + }; + const next = setDefaultModel(s, { provider: "b", model: "b-other" }); + expect(next.defaultProvider).toBe("b"); + expect(next.providers.b?.defaultModel).toBe("b-other"); + expect(next.providers.a).toEqual(s.providers.a); + expect(next.recentModels).toEqual(s.recentModels); + expect(next.favoriteModels).toEqual(s.favoriteModels); + }); + + test("setDefaultModel with a missing provider still sets defaultProvider and does not invent a providers key", () => { + const s: Settings = { providers: firepass.providers, defaultProvider: "firepass" }; + const next = setDefaultModel(s, { provider: "missing", model: "m1" }); + expect(next.defaultProvider).toBe("missing"); + expect(next.providers).toEqual(s.providers); + expect(next.providers.missing).toBeUndefined(); + }); + test("listRecentModels respects max (default 5)", () => { const recent = Array.from({ length: 8 }, (_, i) => ({ provider: "a", diff --git a/src/subagent/brief-dispatch.ts b/src/subagent/brief-dispatch.ts index fd7e18672..c6964ef23 100644 --- a/src/subagent/brief-dispatch.ts +++ b/src/subagent/brief-dispatch.ts @@ -18,6 +18,7 @@ import { isNeverActedSubAgentReport, isNeverEditedSubAgentReport, isNoProgressSubAgentReport, + isNoShipSubAgentReport, isRepetitionSubAgentReport, isThrashSubAgentReport, isTurnBudgetSubAgentReport, @@ -26,6 +27,7 @@ import { /** Salvage classes that must not be re-dispatched with an identical brief. */ export type HardBlockSalvage = | "thrash" + | "no-ship" | "no-progress" | "repetition" | "never-acted" @@ -36,7 +38,8 @@ export type BriefSalvageKind = | "turn-budget" | "deadline" | "stalled" - | "cancelled"; + | "cancelled" + | "incomplete-report"; export type TaskBriefFingerprintInput = { prompt: string; @@ -62,6 +65,7 @@ export const TURN_BUDGET_STOP_AFTER_DISPATCHES = 3; const HARD_BLOCK_SALVAGES = new Set<BriefSalvageKind>([ "thrash", + "no-ship", "no-progress", "repetition", "never-acted", @@ -84,6 +88,12 @@ export function isCancelledSubAgentReport(report: string): boolean { return parsed.summary.toLowerCase().includes("cancelled"); } +/** True when the worker returned an incomplete-report salvage (narration, no envelope). */ +export function isIncompleteReportSubAgentReport(report: string): boolean { + const parsed = parseSubAgentReport(report); + return parsed.summary.toLowerCase().includes("narrated instead of writing a report envelope"); +} + /** * Classify a sub-agent tool result body as a salvage kind the parent ledger cares * about. Returns null for normal completes (or unrecognized envelopes). @@ -91,6 +101,7 @@ export function isCancelledSubAgentReport(report: string): boolean { export function classifyBriefSalvage(report: string): BriefSalvageKind | null { // Order: more specific salvage phrases first. if (isThrashSubAgentReport(report)) return "thrash"; + if (isNoShipSubAgentReport(report)) return "no-ship"; if (isRepetitionSubAgentReport(report)) return "repetition"; if (isNeverEditedSubAgentReport(report)) return "never-edited"; if (isNeverActedSubAgentReport(report)) return "never-acted"; @@ -99,6 +110,7 @@ export function classifyBriefSalvage(report: string): BriefSalvageKind | null { if (isDeadlineSubAgentReport(report)) return "deadline"; if (isStalledSubAgentReport(report)) return "stalled"; if (isCancelledSubAgentReport(report)) return "cancelled"; + if (isIncompleteReportSubAgentReport(report)) return "incomplete-report"; return null; } diff --git a/src/subagent/fleet-report.test.ts b/src/subagent/fleet-report.test.ts index 2be646abc..0c82a4a02 100644 --- a/src/subagent/fleet-report.test.ts +++ b/src/subagent/fleet-report.test.ts @@ -32,8 +32,9 @@ describe("observeFleet", () => { expect(watch.running).toBe(2); }); - test("a finished lane is reported with what it produced", () => { - const seeded = observeFleet(createFleetWatch(), [lane({ id: "api" })], T0).watch; + test("a finished lane does not dump a done-summary into the transcript", () => { + const seeded = observeFleet(createFleetWatch(), [lane({ id: "api" }), lane({ id: "docs" })], T0) + .watch; const { updates } = observeFleet( seeded, [ @@ -42,15 +43,15 @@ describe("observeFleet", () => { status: "done", report: "## Summary\nRewired the reporter and added six tests.", }), + lane({ id: "docs" }), ], T0 + 1000, ); - expect(updates[0]).toBe( - "fleet · api done — Rewired the reporter and added six tests.", - ); + // Board still has a live lane; parent prose owns the success narrative. + expect(updates).toEqual([]); }); - test("the last lane finishing says so, which is the silence the operator hit", () => { + test("the last lane finishing is one dry-fleet line, not per-lane prose", () => { const seeded = observeFleet( createFleetWatch(), [lane({ id: "api" }), lane({ id: "docs", status: "done" })], @@ -61,37 +62,41 @@ describe("observeFleet", () => { [lane({ id: "api", status: "done", report: "done" }), lane({ id: "docs", status: "done" })], T0 + 1000, ); - expect(updates).toEqual([ - "fleet · api done — done", - "fleet · 2 done — nothing running", - ]); + expect(updates).toEqual(["2 done · nothing running"]); }); - test("a failure names what went wrong", () => { - const seeded = observeFleet(createFleetWatch(), [lane({ id: "build" })], T0).watch; + test("a failure names what went wrong while the fleet is still live", () => { + const seeded = observeFleet( + createFleetWatch(), + [lane({ id: "build" }), lane({ id: "docs" })], + T0, + ).watch; const { updates } = observeFleet( seeded, - [lane({ id: "build", status: "failed", error: "typecheck exited 1" })], + [ + lane({ id: "build", status: "failed", error: "typecheck exited 1" }), + lane({ id: "docs" }), + ], T0 + 1000, ); expect(updates[0]).toContain("build failed — typecheck exited 1"); }); - test("a dispatch carries the load it was decided against", () => { + test("a live dispatch does not re-announce into the transcript (board owns it)", () => { const seeded = observeFleet(createFleetWatch(), [lane({ id: "api" })], T0).watch; const { updates } = observeFleet( seeded, [lane({ id: "api" }), lane({ id: "docs" })], T0 + 1000, ); - expect(updates).toEqual(["fleet · dispatched docs (2 running)"]); + expect(updates).toEqual([]); }); - test("a quiet lane is announced once, not on every tick it stays quiet", () => { + test("a quiet lane is not announced into the transcript (rollup owns it)", () => { const quiet = lane({ id: "api", lastActivityAt: T0 }); const seeded = observeFleet(createFleetWatch(), [quiet], T0).watch; const first = observeFleet(seeded, [quiet], T0 + 60_000); - expect(first.updates[0]).toContain("api stalled"); + expect(first.updates).toEqual([]); const second = observeFleet(first.watch, [quiet], T0 + 90_000); expect(second.updates).toEqual([]); }); @@ -106,24 +111,7 @@ describe("observeFleet", () => { expect(busy.updates).toEqual([]); }); - test("an update is one row — a long outcome is clipped, never wrapped", () => { - const seeded = observeFleet(createFleetWatch(), [lane({ id: "api" })], T0).watch; - const { updates } = observeFleet( - seeded, - [ - lane({ - id: "api", - status: "done", - report: "Rewired the reporter, added the digest, wired the poll, and updated every affected test in the suite.", - }), - ], - T0 + 1000, - ); - expect(updates[0]!.length).toBeLessThanOrEqual(76); - expect(updates[0]).toContain("…"); - }); - - test("a dozen lanes landing at once collapse into one tally", () => { + test("fleet going dry collapses a burst into one tally line", () => { const before = Array.from({ length: 12 }, (_, i) => lane({ id: `l${i}` })); const seeded = observeFleet(createFleetWatch(), before, T0).watch; const after = before.map((l, i) => @@ -132,7 +120,7 @@ describe("observeFleet", () => { : { ...l, status: "failed" as const, error: "boom" }, ); const { updates } = observeFleet(seeded, after, T0 + 1000); - expect(updates).toEqual(["fleet · 9 done, 3 failed — nothing running"]); + expect(updates).toEqual(["9 done, 3 failed · nothing running"]); }); }); @@ -147,13 +135,13 @@ describe("fleetDigest", () => { ], T0, ); - expect(digest).toBe("fleet · 2 running (api 1:20, docs 0:20 stalled) · 1 done · 1 failed"); + expect(digest).toBe("2 running (api 1:20, docs 0:20) · 1 done · 1 failed"); }); test("a fleet with nothing left running says so rather than going blank", () => { expect(fleetDigest([lane({ id: "api", status: "done" })], T0)).toBe( - "fleet · nothing running · 1 done", + "nothing running · 1 done", ); - expect(fleetDigest([], T0)).toBe("fleet · no lanes dispatched"); + expect(fleetDigest([], T0)).toBe("nothing running"); }); }); diff --git a/src/subagent/fleet-report.ts b/src/subagent/fleet-report.ts index e95210882..de757d52b 100644 --- a/src/subagent/fleet-report.ts +++ b/src/subagent/fleet-report.ts @@ -1,12 +1,12 @@ /** * What the orchestrator says to the operator about the fleet, unprompted. * - * Every lane completion, stall and failure already passes through the parent - * session, which then said nothing about any of it unless interrupted and - * asked. This module turns that stream into the small number of lines that - * change the operator's picture, and nothing else: a lane finished and what it - * produced, a lane stalled or failed, work dispatched, and the moment the - * fleet runs dry. + * Live lanes already paint on the activity strip. Parent prose already narrates + * phase plans. This module only emits transcript lines for attention the strip + * cannot keep: a lane failed or went quiet, and the single moment the fleet runs + * dry. Per-lane "done — summary" walls are intentionally never printed — they + * restate the strip and the parent and turn the transcript into a second + * status log (CL-5846). * * Pure and stateless per call — the caller keeps the returned watch and hands * it back on the next observation. No painting, no store access. @@ -66,8 +66,6 @@ const OUTCOME_CHARS = 56; */ const MAX_UPDATE_CHARS = 76; -const PREFIX = "fleet"; - /** * A lane going quiet is the one change that produces no event, so it has to be * looked for. Coarse on purpose: the stall threshold is tens of seconds, and @@ -102,11 +100,6 @@ function clip(text: string, max: number): string { return `${text.slice(0, max - 1).trimEnd()}…`; } -function outcome(lane: FleetLane): string { - const summary = firstLine(lane.report); - return summary.length > 0 ? clip(summary, OUTCOME_CHARS) : "no summary reported"; -} - function isStalled(lane: FleetLane, nowMs: number, stallMs: number): boolean { // One definition of a stalled lane lives in `agentProgress`; asking it is // what keeps this report and the agents panel from disagreeing on screen. @@ -154,7 +147,7 @@ export function observeFleet( if (before.status !== lane.status) { if (lane.status === "done") { - changes.push({ kind: "done", line: `${lane.description} done — ${outcome(lane)}` }); + changes.push({ kind: "done", line: `${lane.description} done` }); } else if (lane.status === "failed") { changes.push({ kind: "failed", @@ -166,43 +159,40 @@ export function observeFleet( continue; } - if (lane.status === "running" && stalled && before.stallReported !== true) { - changes.push({ - kind: "stalled", - line: `${lane.description} stalled — quiet for ${clockLabel(nowMs - lane.lastActivityAt)}`, - }); - } + // A lane going quiet is no longer emitted to the transcript: the single + // agents-panel rollup row carries the quiet count instead, so a stalled + // fleet stops producing "went quiet" walls (CL-5846). stallReported is + // still tracked internally so the strip does not flap. } const watch: FleetWatch = { lanes: marks, running, seeded: true }; - if (changes.length === 0) return { watch, updates: [] }; - - // A dispatch carries the load it was decided against, so an operator who - // would have scheduled differently can say so while it still matters. - const lines = - changes.length > COALESCE_ABOVE - ? [tally(changes)] - : changes.map((c) => - c.kind === "dispatched" ? `${c.line} (${running} running)` : c.line, - ); + const wentDry = running === 0 && previous.running > 0; + + // Board owns live lanes. Parent prose owns success narratives. Transcript + // only: fail/stall while work is still running, or one dry-fleet tally. + // Never per-lane "done — summary" walls (CL-5846). + if (wentDry) { + return { + watch, + updates: [ + clip(`${idleSummary(lanes)} · nothing running`, MAX_UPDATE_CHARS), + ], + }; + } - // The defect this report exists for: work finished, nothing left running, - // and no one said so. That transition is always worth its own line — unless - // the tally above already said the same thing, in which case a second line - // restating it verbatim (with "— nothing running" tacked on) is noise, not - // information. - if (running === 0 && previous.running > 0) { - const idle = `${idleSummary(lanes)} — nothing running`; - if (lines.length === 1 && lines[0] === idleSummary(lanes)) { - lines[0] = idle; - } else { - lines.push(idle); - } + const attention = changes.filter((c) => c.kind === "failed" || c.kind === "stalled"); + if (attention.length === 0) { + return { watch, updates: [] }; } + const lines: string[] = + attention.length > COALESCE_ABOVE + ? [tally(attention)] + : attention.map((c) => c.line); + return { watch, - updates: lines.map((line) => clip(`${PREFIX} · ${line}`, MAX_UPDATE_CHARS)), + updates: lines.map((line) => clip(line, MAX_UPDATE_CHARS)), }; } @@ -210,14 +200,12 @@ function tally(changes: readonly Change[]): string { const count = (kind: Change["kind"]): number => changes.filter((c) => c.kind === kind).length; const parts: string[] = []; - const dispatched = count("dispatched"); const done = count("done"); const failed = count("failed"); - const stalled = count("stalled"); if (done > 0) parts.push(`${done} done`); if (failed > 0) parts.push(`${failed} failed`); - if (stalled > 0) parts.push(`${stalled} stalled`); - if (dispatched > 0) parts.push(`${dispatched} dispatched`); + // stalled changes are not operator-facing; tally fails/done only + void count("stalled"); return parts.join(", "); } @@ -238,7 +226,7 @@ export function fleetDigest( nowMs: number, stallMs: number = DEFAULT_STALL_MS, ): string { - if (lanes.length === 0) return `${PREFIX} · no lanes dispatched`; + if (lanes.length === 0) return "nothing running"; const running = lanes.filter((l) => l.status === "running"); const done = lanes.filter((l) => l.status === "done").length; const failed = lanes.filter((l) => l.status === "failed").length; @@ -251,8 +239,9 @@ export function fleetDigest( const named = running .slice(0, DIGEST_NAMED_LANES) .map((lane) => { - const quiet = isStalled(lane, nowMs, stallMs) ? " stalled" : ""; - return `${lane.description} ${clockLabel(nowMs - lane.startedAt)}${quiet}`; + void isStalled + void stallMs + return `${lane.description} ${clockLabel(nowMs - lane.startedAt)}`; }) .join(", "); const extra = running.length - Math.min(running.length, DIGEST_NAMED_LANES); @@ -263,5 +252,5 @@ export function fleetDigest( if (done > 0) parts.push(`${done} done`); if (failed > 0) parts.push(`${failed} failed`); if (cancelled > 0) parts.push(`${cancelled} cancelled`); - return `${PREFIX} · ${parts.join(" · ")}`; + return parts.join(" · "); } diff --git a/src/subagent/identity-context.ts b/src/subagent/identity-context.ts index 13b538029..a02ba8294 100644 --- a/src/subagent/identity-context.ts +++ b/src/subagent/identity-context.ts @@ -6,7 +6,15 @@ import { AsyncLocalStorage } from "node:async_hooks"; // sub-agent around its own tool-call dispatch (see run.ts's toolsFactory) so // every awaited call within that sub-agent's turn — including the permission // gate and its operator prompt — can read it back via getSubAgentIdentity(). -export type SubAgentIdentity = { description: string; cwd: string }; +export type SubAgentIdentity = { + description: string; + cwd: string; + /** + * When set, write/edit/delete subjects must match (authz path lock). + * Omitted = no director path allowlist. + */ + writePaths?: readonly string[]; +}; const subAgentIdentityAls = new AsyncLocalStorage<SubAgentIdentity>(); diff --git a/src/subagent/index.test.ts b/src/subagent/index.test.ts index cc7c60aef..cc3f9b773 100644 --- a/src/subagent/index.test.ts +++ b/src/subagent/index.test.ts @@ -214,6 +214,69 @@ describe("sub-agent stop helpers", () => { ).toBe("complete"); }); + const SUMMARY_ONLY_NARRATION = [ + "## Summary", + "Checking whether Skywalker write-tool unmount is tested...", + "Checking those next.", + ].join("\n"); + + const FULL_REPORT_ENVELOPE = [ + "## Summary", + "Reviewed gate.ts.", + "", + "## Findings", + "Auth lives in gate.ts.", + "", + "## Blockers", + "None.", + "", + "## Paths", + "src/gate.ts", + ].join("\n"); + + test("evaluateSubAgentStop returns incomplete-report for Summary-only tool-less narration after tools", () => { + expect( + evaluateSubAgentStop({ + hasToolCalls: false, + everHadToolCalls: true, + turnsCompleted: 2, + maxTurns: 10, + consecutiveIdentical: 0, + repeatLimit: 2, + lastAssistantText: SUMMARY_ONLY_NARRATION, + }), + ).toBe("incomplete-report"); + }); + + test("evaluateSubAgentStop returns incomplete-report-stop for Summary-only after the wrap-up nudge", () => { + expect( + evaluateSubAgentStop({ + hasToolCalls: false, + everHadToolCalls: true, + turnsCompleted: 3, + maxTurns: 10, + consecutiveIdentical: 0, + repeatLimit: 2, + lastAssistantText: SUMMARY_ONLY_NARRATION, + incompleteReportNudgeFired: true, + }), + ).toBe("incomplete-report-stop"); + }); + + test("evaluateSubAgentStop returns complete for tool-less after tools with all four headings", () => { + expect( + evaluateSubAgentStop({ + hasToolCalls: false, + everHadToolCalls: true, + turnsCompleted: 2, + maxTurns: 10, + consecutiveIdentical: 0, + repeatLimit: 2, + lastAssistantText: FULL_REPORT_ENVELOPE, + }), + ).toBe("complete"); + }); + test("evaluateSubAgentStop returns never-acted when the run never used tools", () => { expect( evaluateSubAgentStop({ @@ -247,6 +310,38 @@ describe("sub-agent stop helpers", () => { ).toBe("never-edited"); }); + test("evaluateSubAgentStop does not hard-stop implement for many unique reads", () => { + let thrash = EMPTY_THRASH_STATE; + for (let i = 0; i < 200; i++) { + thrash = nextThrashState(thrash, [ + { type: "tool_call", name: "read_file", arguments: { path: `src/f${i}.ts` } }, + ]); + } + expect( + evaluateSubAgentStop({ + hasToolCalls: true, + everHadToolCalls: true, + turnsCompleted: 40, + maxTurns: 60, + consecutiveIdentical: 0, + repeatLimit: 2, + thrashState: thrash, + requireEdit: true, + }), + ).toBeNull(); + expect( + evaluateSubAgentStop({ + hasToolCalls: true, + everHadToolCalls: true, + turnsCompleted: 40, + maxTurns: 60, + consecutiveIdentical: 0, + repeatLimit: 2, + thrashState: thrash, + }), + ).toBeNull(); + }); + test("evaluateSubAgentStop still completes implement when an edit path was recorded", () => { const thrashState = { totalToolCalls: 4, @@ -1227,6 +1322,44 @@ describe("SubAgentDirector stall management", () => { }); describe("createTaskTool", () => { + test("handler does not resolve until run() resolves; result includes the full report", async () => { + let release!: () => void; + const gate = new Promise<void>((resolve) => { + release = resolve; + }); + const report = "## Summary\nThe work is done."; + const tool = createTaskTool({ + permissionGate: testPermissionGate, + cwd: "/repo", + getWorkdirBase: () => "/repo/.corbits", + provider, + profiles: [{ id: "leaf" }], + run: async () => { + await gate; + return report; + }, + }); + + const pending = callTask(tool, { + description: "Investigate", + prompt: "Do the work", + agent: "leaf", + }); + let settled = false; + void pending.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + release(); + const result = await pending; + expect(settled).toBe(true); + expect(result).toContain('Sub-agent "'); + expect(result).toContain(report); + expect(result).toContain("## Summary"); + }); + test("does not inherit a bogus parent-session maxTurns dep on the task tool", async () => { let captured: RunSubAgentParams | undefined; const tool = createTaskTool({ @@ -1235,13 +1368,19 @@ describe("createTaskTool", () => { getWorkdirBase: () => "/repo/.corbits", provider, maxTurns: 25, + profiles: [{ id: "leaf" }], run: async (params) => { captured = params; return "done"; }, } as Parameters<typeof createTaskTool>[0] & { maxTurns: number }); - const result = await callTask(tool, { description: "Investigate", prompt: "Do the work" }); + // Plugin profile (not a director package) so package nudge.maxTurns does not apply. + const result = await callTask(tool, { + description: "Investigate", + prompt: "Do the work", + agent: "leaf", + }); expect(result).toContain("done"); expect(captured).toBeDefined(); @@ -1256,13 +1395,18 @@ describe("createTaskTool", () => { getWorkdirBase: () => "/repo/.corbits", provider, settings: { providers: {}, subagentMaxTurns: 42 }, + profiles: [{ id: "leaf" }], run: async (params) => { captured = params; return "done"; }, }); - await callTask(tool, { description: "Settings default", prompt: "Work" }); + await callTask(tool, { + description: "Settings default", + prompt: "Work", + agent: "leaf", + }); expect(captured?.maxTurns).toBe(42); }); @@ -1274,6 +1418,7 @@ describe("createTaskTool", () => { cwd: "/repo", getWorkdirBase: () => "/repo/.corbits", provider, + profiles: [{ id: "leaf" }], run: async (params) => { captured = params; return "done"; @@ -1284,6 +1429,7 @@ describe("createTaskTool", () => { description: "Long job", prompt: "Work", maxTurns: 50, + agent: "leaf", }); expect(captured?.maxTurns).toBe(50); @@ -1401,6 +1547,7 @@ describe("createTaskTool", () => { description: "Too long", prompt: "Work", maxTurns: 101, + intent: "explore", }); expect(result).toContain("Error:"); @@ -1463,7 +1610,7 @@ describe("createTaskTool", () => { run: async () => forcedStopReport("turn-budget", "partial"), }); - const result = await callTask(tool, { description: "Budget", prompt: "Work" }); + const result = await callTask(tool, { description: "Budget", prompt: "Work", intent: "explore" }); expect(result).toContain("turn budget"); expect(result).toContain("Turn budget reached"); @@ -1488,7 +1635,7 @@ describe("createTaskTool", () => { }, }); - await callTask(tool, { description: "MCP parity", prompt: "check tools" }); + await callTask(tool, { description: "MCP parity", prompt: "check tools", intent: "explore" }); expect(captured?.permissionGate).toBe(testPermissionGate); expect(captured?.inheritMcpTools?.()).toEqual(inherited); @@ -1508,7 +1655,7 @@ describe("createTaskTool", () => { }, }); - await callTask(tool, { description: "Env parity", prompt: "check env" }); + await callTask(tool, { description: "Env parity", prompt: "check env", intent: "explore" }); expect(captured?.shellEnv).toEqual({ FOO: "bar" }); }); @@ -1548,7 +1695,7 @@ describe("createTaskTool", () => { return forcedStopReport("cancelled", "partial from tools"); }, }); - const out = await callTask(tool, { description: "signal", prompt: "x" }, parent.signal); + const out = await callTask(tool, { description: "signal", prompt: "x", intent: "explore" }, parent.signal); expect(linkedAbort).toBe(true); expect(captured?.signal?.aborted).toBe(true); expect(out).toContain("cancelled"); @@ -1570,7 +1717,7 @@ describe("createTaskTool", () => { return forcedStopReport("cancelled", "salvaged work"); }, }); - const out = await callTask(tool, { description: "race", prompt: "x" }); + const out = await callTask(tool, { description: "race", prompt: "x", intent: "explore" }); expect(out).toContain("salvaged work"); expect(out).toContain("## Summary"); expect(out).not.toBe('Sub-agent "race" cancelled by operator.'); @@ -1590,7 +1737,7 @@ describe("createTaskTool", () => { throw err; }, }); - const out = await callTask(tool, { description: "pre-progress", prompt: "x" }); + const out = await callTask(tool, { description: "pre-progress", prompt: "x", intent: "explore" }); expect(out).toContain("cancelled by operator"); expect(out).not.toContain("## Summary"); }); @@ -1603,7 +1750,7 @@ describe("createTaskTool", () => { provider, run: async () => forcedStopReport("cancelled", "Found path in gate.ts"), }); - const out = await callTask(tool, { description: "salvage", prompt: "x" }); + const out = await callTask(tool, { description: "salvage", prompt: "x", intent: "explore" }); expect(out).toContain("## Summary"); expect(out).toContain("## Findings"); expect(out).toContain("gate.ts"); @@ -1627,7 +1774,7 @@ describe("createTaskTool", () => { { id: "auth-call", name: "task", - arguments: { description: "auth probe", prompt: "x" }, + arguments: { description: "auth probe", prompt: "x", intent: "explore" }, }, new AbortController().signal, ); @@ -1660,7 +1807,7 @@ describe("createTaskTool", () => { return forcedStopReport("deadline", "partial before wall clock"); }, }); - const out = await callTask(tool, { description: "deadline", prompt: "x" }); + const out = await callTask(tool, { description: "deadline", prompt: "x", intent: "explore" }); expect(captured?.deadlineMs).toBe(45_000); expect(out).toContain("## Summary"); expect(out).toContain("deadline"); @@ -1690,7 +1837,7 @@ describe("createTaskTool", () => { }); const runner = createDynamicToolRunner([task], { defaultMs: 10_000 }); const pending = runner.run( - { id: "int-1", name: "task", arguments: { description: "race", prompt: "x" } }, + { id: "int-1", name: "task", arguments: { description: "race", prompt: "x", intent: "explore" } }, parent.signal, ); await new Promise((r) => setTimeout(r, 15)); @@ -1746,9 +1893,10 @@ describe("createTaskTool", () => { }, }); - await callTask(tool, { description: "legacy", prompt: "Do the work" }); + await callTask(tool, { description: "legacy", prompt: "Do the work", intent: "explore" }); - expect(captured?.intent).toBeUndefined(); + // Intent is required to select a director; other typed spawn fields stay optional. + expect(captured?.intent).toBe("explore"); expect(captured?.successCriteria).toBeUndefined(); expect(captured?.doNot).toBeUndefined(); expect(captured?.reportFocus).toBeUndefined(); @@ -1968,10 +2116,15 @@ describe("brief re-dispatch ledger (CL-4343 / CL-5203)", () => { expect(classifyBriefSalvage(forcedStopReport("repetition", "x"))).toBe("repetition"); expect(classifyBriefSalvage(forcedStopReport("never-acted", "x"))).toBe("never-acted"); expect(classifyBriefSalvage(forcedStopReport("never-edited", "x"))).toBe("never-edited"); + expect(classifyBriefSalvage(forcedStopReport("no-ship", "x"))).toBe("no-ship"); expect(classifyBriefSalvage(forcedStopReport("turn-budget", "x"))).toBe("turn-budget"); expect(classifyBriefSalvage("## Summary\nDone\n\n## Findings\nok\n\n## Blockers\nNone\n\n## Paths\n")).toBeNull(); }); + test("classifyBriefSalvage maps incomplete-report salvage", () => { + expect(classifyBriefSalvage(forcedStopReport("incomplete-report", "x"))).toBe("incomplete-report"); + }); + test("turn-budget parent hint flips after re-dispatch threshold", () => { const report = forcedStopReport("turn-budget", "partial"); const first = appendSubAgentParentHints(report, { dispatchCount: 1 }); @@ -2047,7 +2200,7 @@ describe("brief re-dispatch ledger (CL-4343 / CL-5203)", () => { return budget; }, }); - const args = { description: "Budget job", prompt: "long job" }; + const args = { description: "Budget job", prompt: "long job", intent: "explore" }; const r1 = await callTask(tool, args); expect(r1).toContain("higher maxTurns"); const r2 = await callTask(tool, { ...args, maxTurns: 50 }); @@ -2074,7 +2227,7 @@ describe("brief re-dispatch ledger (CL-4343 / CL-5203)", () => { return ok; }, }); - const args = { description: "Reset job", prompt: "reset budget" }; + const args = { description: "Reset job", prompt: "reset budget", intent: "explore" }; expect(await callTask(tool, args)).toContain("higher maxTurns"); expect(await callTask(tool, args)).toContain("higher maxTurns"); expect(await callTask(tool, args)).toContain("Done"); @@ -2098,7 +2251,7 @@ describe("brief re-dispatch ledger (CL-4343 / CL-5203)", () => { return budget; }, }); - const args = { description: "Crash then budget", prompt: "count carefully" }; + const args = { description: "Crash then budget", prompt: "count carefully", intent: "explore" }; const fail = await callTask(tool, args); expect(fail).toContain("failed"); // First successful body is still dispatchCount 1 → invites higher maxTurns. diff --git a/src/subagent/index.ts b/src/subagent/index.ts index b4a79df52..a18d73f9e 100644 --- a/src/subagent/index.ts +++ b/src/subagent/index.ts @@ -34,6 +34,7 @@ export { buildDispatchBrief, demoteNestedReportHeadings, formatSubAgentReport, + hasReportEnvelope, parseSubAgentReport, subAgentToolName, type DispatchBrief, diff --git a/src/subagent/nudge-director.test.ts b/src/subagent/nudge-director.test.ts new file mode 100644 index 000000000..a2fca75eb --- /dev/null +++ b/src/subagent/nudge-director.test.ts @@ -0,0 +1,483 @@ +import { describe, expect, test } from "bun:test"; +import type { + ReactorAction, + ReactorCapabilities, + ReactorInboundEvent, + ReactorState, +} from "@intx/types/runtime"; +import { COMPACTOR_KEEP_RECENT_TURNS, compactorNoOpFloor } from "../session/compactor.js"; +import { SubAgentDirector } from "./nudge-director.js"; + +const state = { turns: [] } as unknown as ReactorState; +const longState = { + turns: Array.from( + { length: compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS) + 1 }, + () => ({ role: "user", content: [], timestamp: 0 }), + ), +} as unknown as ReactorState; + +function capabilities(): ReactorCapabilities { + return { + infer: (options) => + ({ type: "infer", ...(options !== undefined ? { options } : {}) }) as ReactorAction, + executeTools: (calls, parallel, addToHistory) => + ({ type: "execute_tools", calls, parallel, addToHistory }) as ReactorAction, + suspend: (gate) => ({ type: "suspend", gate }) as ReactorAction, + fork: (mode, forkId) => ({ type: "fork", mode, forkId }) as ReactorAction, + emit: (eventType, data) => ({ type: "emit", eventType, data }) as ReactorAction, + reply: (content) => ({ type: "reply", content }) as ReactorAction, + checkpoint: (message = "") => ({ type: "checkpoint", message }) as ReactorAction, + compact: (compactor, reason) => ({ type: "compact", compactor, reason }) as ReactorAction, + wait: () => ({ type: "wait" }) as ReactorAction, + done: () => ({ type: "done" }) as ReactorAction, + }; +} + +function inferenceDone( + callIds: string[], + inputTokens = 0, + pathForId: (id: string) => string = (id) => `${id}.ts`, +): ReactorInboundEvent { + return { + type: "inference.done", + turn: { + role: "assistant", + model: "test", + timestamp: 0, + content: callIds.map((id) => ({ + type: "tool_call", + id, + name: "read_file", + arguments: { path: pathForId(id) }, + })), + }, + usage: { input: inputTokens, output: 1, cacheRead: 0, cacheWrite: 0, thinking: 0 }, + source: { model: "test-model" }, + } as unknown as ReactorInboundEvent; +} + +function inferenceDoneText(text: string): ReactorInboundEvent { + return { + type: "inference.done", + turn: { + role: "assistant", + model: "test", + timestamp: 0, + content: [{ type: "text", text }], + }, + usage: { input: 0, output: 1, cacheRead: 0, cacheWrite: 0, thinking: 0 }, + source: { model: "test-model" }, + } as unknown as ReactorInboundEvent; +} + +function toolDone(callId: string, isError = false): ReactorInboundEvent { + return { + type: "tool.done", + result: { callId, content: isError ? "failed" : "ok", isError }, + } as unknown as ReactorInboundEvent; +} + +function messageReceived(content: string): ReactorInboundEvent { + return { + type: "message.received", + message: { role: "user", content }, + } as unknown as ReactorInboundEvent; +} + +function actions(result: ReactorAction | ReactorAction[]): ReactorAction[] { + return Array.isArray(result) ? result : [result]; +} + +function inferAction(result: ReactorAction | ReactorAction[]): Extract<ReactorAction, { type: "infer" }> { + const infer = actions(result).find( + (action): action is Extract<ReactorAction, { type: "infer" }> => action.type === "infer", + ); + if (infer === undefined) throw new Error("expected infer action"); + return infer; +} + +function overflowError(message = "context window exceeded"): ReactorInboundEvent { + return { + type: "inference.error", + error: { category: "context_overflow", message }, + partial: { text: "" }, + } as unknown as ReactorInboundEvent; +} + +function ephemeralTexts(infer: Extract<ReactorAction, { type: "infer" }>): string[] | undefined { + const options = infer.options as + | { ephemeralTurns?: Array<{ content: Array<{ text?: string }> }> } + | undefined; + return options?.ephemeralTurns?.map((turn) => turn.content[0]?.text ?? ""); +} + +describe("SubAgentDirector tool failure recovery", () => { + test("failed tool result adds one actionable ephemeral recovery nudge", async () => { + const director = new SubAgentDirector("system", [], undefined, 30); + const caps = capabilities(); + + await director.decide(inferenceDone(["failed-call"]), state, caps); + const texts = ephemeralTexts( + inferAction(await director.decide(toolDone("failed-call", true), state, caps)), + ); + + expect(texts).toHaveLength(1); + expect(texts?.[0]).toContain("Do not repeat the same failed call unchanged"); + expect(texts?.[0]).toContain("Inspect the error and current state"); + expect(texts?.[0]).toContain("change the arguments or approach"); + expect(texts?.[0]).toContain("report the blocker"); + }); + + test("successful tool result has no ephemeral recovery turn", async () => { + const director = new SubAgentDirector("system", [], undefined, 30); + const caps = capabilities(); + + await director.decide(inferenceDone(["successful-call"]), state, caps); + const infer = inferAction( + await director.decide(toolDone("successful-call"), state, caps), + ); + + expect(ephemeralTexts(infer)).toBeUndefined(); + }); + + test("waits for all pending results and carries one recovery nudge on the normal infer", async () => { + const director = new SubAgentDirector("system", [], undefined, 30); + const caps = capabilities(); + + await director.decide(inferenceDone(["failed-first", "successful-last"]), state, caps); + const firstResult = actions( + await director.decide(toolDone("failed-first", true), state, caps), + ); + expect(firstResult.some((action) => action.type === "infer")).toBe(false); + + const texts = ephemeralTexts( + inferAction(await director.decide(toolDone("successful-last"), state, caps)), + ); + expect(texts).toHaveLength(1); + expect(texts?.[0]).toContain("A tool call failed"); + }); + + test("a later successful cycle has no stale recovery nudge", async () => { + const director = new SubAgentDirector("system", [], undefined, 30); + const caps = capabilities(); + + await director.decide(inferenceDone(["failed-cycle"]), state, caps); + await director.decide(toolDone("failed-cycle", true), state, caps); + + await director.decide(inferenceDone(["later-success"]), state, caps); + const infer = inferAction( + await director.decide(toolDone("later-success"), state, caps), + ); + expect(ephemeralTexts(infer)).toBeUndefined(); + }); + + test("retains recovery through compaction and consumes it once on continuation infer", async () => { + let continuations = 0; + const director = new SubAgentDirector( + "system", + [], + () => { + continuations++; + }, + 30, + ); + const caps = capabilities(); + + await director.decide(inferenceDone(["failed-at-threshold"], 999_999), longState, caps); + const compact = actions( + await director.decide(toolDone("failed-at-threshold", true), longState, caps), + ); + expect(compact.some((action) => action.type === "infer")).toBe(false); + expect(compact).toEqual([ + { type: "checkpoint", message: "tool-done" }, + { type: "compact", compactor: "pruning-compactor", reason: "context-threshold" }, + ]); + expect(continuations).toBe(1); + + const resumed = inferAction( + await director.decide(messageReceived(""), longState, caps), + ); + const resumedTexts = ephemeralTexts(resumed); + expect(resumedTexts).toHaveLength(1); + expect(resumedTexts?.[0]).toContain("A tool call failed"); + + const later = inferAction( + await director.decide(messageReceived(""), longState, caps), + ); + expect(ephemeralTexts(later)).toBeUndefined(); + }); + + test("near-budget wrap-up nudge wins over failed-tool recovery", async () => { + const director = new SubAgentDirector("system", [], undefined, 3); + const caps = capabilities(); + + await director.decide(inferenceDone(["near-budget-failure"]), state, caps); + const texts = ephemeralTexts( + inferAction(await director.decide(toolDone("near-budget-failure", true), state, caps)), + ); + + expect(texts).toHaveLength(1); + expect(texts?.[0]).toContain("close to your turn budget"); + expect(texts?.[0]).toContain("write your final report now"); + expect(texts?.[0]).not.toContain("A tool call failed"); + expect(texts?.[0]).not.toContain("change the arguments or approach"); + }); + + test("failed-tool recovery supersedes soft re-read guidance", async () => { + const director = new SubAgentDirector("system", [], undefined, 30); + const caps = capabilities(); + const callIds = [ + "shared-1", + "shared-2", + "shared-3", + "unique-1", + "unique-2", + "unique-3", + "unique-4", + "failed-last", + ]; + + await director.decide( + inferenceDone(callIds, 0, (id) => id.startsWith("shared-") ? "shared.ts" : `${id}.ts`), + state, + caps, + ); + for (const callId of callIds.slice(0, -1)) { + await director.decide(toolDone(callId), state, caps); + } + const texts = ephemeralTexts( + inferAction(await director.decide(toolDone("failed-last", true), state, caps)), + ); + + expect(texts).toHaveLength(1); + expect(texts?.[0]).toContain("A tool call failed"); + expect(texts?.[0]).not.toContain("re-reading the same paths"); + }); + + test("overflow after consume restores recovery once on continuation infer", async () => { + let continuations = 0; + const director = new SubAgentDirector( + "system", + [], + () => { + continuations++; + }, + 30, + ); + const caps = capabilities(); + + await director.decide(inferenceDone(["failed-then-overflow"]), state, caps); + const texts = ephemeralTexts( + inferAction(await director.decide(toolDone("failed-then-overflow", true), state, caps)), + ); + expect(texts).toHaveLength(1); + expect(texts?.[0]).toContain("A tool call failed"); + + const compact = actions(await director.decide(overflowError(), state, caps)); + expect(compact.some((action) => action.type === "infer")).toBe(false); + expect(compact).toEqual([ + { type: "compact", compactor: "pruning-compactor", reason: "context-overflow" }, + ]); + expect(continuations).toBe(1); + + const resumed = inferAction(await director.decide(messageReceived(""), state, caps)); + const resumedTexts = ephemeralTexts(resumed); + expect(resumedTexts).toHaveLength(1); + expect(resumedTexts?.[0]).toContain("A tool call failed"); + + const later = inferAction(await director.decide(messageReceived(""), state, caps)); + expect(ephemeralTexts(later)).toBeUndefined(); + }); + + test("successful nudged infer then later overflow does not resurrect recovery", async () => { + let continuations = 0; + const director = new SubAgentDirector( + "system", + [], + () => { + continuations++; + }, + 30, + ); + const caps = capabilities(); + + await director.decide(inferenceDone(["failed-then-done"]), state, caps); + const recovered = ephemeralTexts( + inferAction(await director.decide(toolDone("failed-then-done", true), state, caps)), + ); + expect(recovered).toHaveLength(1); + expect(recovered?.[0]).toContain("A tool call failed"); + + await director.decide(inferenceDone(["later-success"]), state, caps); + const afterSuccess = inferAction( + await director.decide(toolDone("later-success"), state, caps), + ); + expect(ephemeralTexts(afterSuccess)).toBeUndefined(); + + const compact = actions(await director.decide(overflowError(), state, caps)); + expect(compact.some((action) => action.type === "infer")).toBe(false); + expect(compact).toEqual([ + { type: "compact", compactor: "pruning-compactor", reason: "context-overflow" }, + ]); + expect(continuations).toBe(1); + + const resumed = inferAction(await director.decide(messageReceived(""), state, caps)); + expect(ephemeralTexts(resumed)).toBeUndefined(); + }); + + test("retains wrap-up through compaction and consumes it once on continuation infer", async () => { + let continuations = 0; + const director = new SubAgentDirector( + "system", + [], + () => { + continuations++; + }, + 3, + ); + const caps = capabilities(); + + await director.decide(inferenceDone(["near-budget-success"], 999_999), longState, caps); + const compact = actions( + await director.decide(toolDone("near-budget-success"), longState, caps), + ); + expect(compact.some((action) => action.type === "infer")).toBe(false); + expect(compact).toEqual([ + { type: "checkpoint", message: "tool-done" }, + { type: "compact", compactor: "pruning-compactor", reason: "context-threshold" }, + ]); + expect(continuations).toBe(1); + + const resumed = inferAction(await director.decide(messageReceived(""), longState, caps)); + const resumedTexts = ephemeralTexts(resumed); + expect(resumedTexts).toHaveLength(1); + expect(resumedTexts?.[0]).toContain("close to your turn budget"); + expect(resumedTexts?.[0]).toContain("write your final report now"); + expect(resumedTexts?.[0]).not.toContain("A tool call failed"); + + const later = inferAction(await director.decide(messageReceived(""), longState, caps)); + expect(ephemeralTexts(later)).toBeUndefined(); + }); +}); + +const REPORT_ENVELOPE = [ + "## Summary", + "Reviewed gate.ts.", + "", + "## Findings", + "Auth lives in gate.ts.", + "", + "## Blockers", + "None.", + "", + "## Paths", + "src/gate.ts", +].join("\n"); + +describe("SubAgentDirector incomplete-report wiring", () => { + test("tool-less narration after tools gets one wrap-up nudge, not a complete", async () => { + const director = new SubAgentDirector("system", [], undefined, 30); + const caps = capabilities(); + + await director.decide(inferenceDone(["read-1"]), state, caps); + await director.decide(toolDone("read-1"), state, caps); + + const result = actions( + await director.decide(inferenceDoneText("Still looking at the files..."), state, caps), + ); + expect(result.some((action) => action.type === "reply")).toBe(false); + expect(result.some((action) => action.type === "done")).toBe(false); + expect(result).toContainEqual({ type: "checkpoint", message: "subagent-incomplete-report-nudge" }); + const texts = ephemeralTexts(inferAction(result)); + expect(texts).toHaveLength(1); + expect(texts?.[0]).toContain("## Summary"); + expect(texts?.[0]).toContain("## Findings"); + expect(texts?.[0]).toContain("## Blockers"); + expect(texts?.[0]).toContain("## Paths"); + expect(texts?.[0]).toContain("No more tools unless one lookup is required"); + }); + + test("Summary-only mid-run narration gets a wrap-up nudge, not done", async () => { + const director = new SubAgentDirector("system", [], undefined, 30); + const caps = capabilities(); + + await director.decide(inferenceDone(["read-1"]), state, caps); + await director.decide(toolDone("read-1"), state, caps); + + const result = actions( + await director.decide( + inferenceDoneText( + [ + "## Summary", + "Checking whether Skywalker write-tool unmount is tested...", + "Checking those next.", + ].join("\n"), + ), + state, + caps, + ), + ); + expect(result.some((action) => action.type === "reply")).toBe(false); + expect(result.some((action) => action.type === "done")).toBe(false); + expect(result).toContainEqual({ type: "checkpoint", message: "subagent-incomplete-report-nudge" }); + const texts = ephemeralTexts(inferAction(result)); + expect(texts).toHaveLength(1); + expect(texts?.[0]).toContain("## Findings"); + expect(texts?.[0]).toContain("## Blockers"); + expect(texts?.[0]).toContain("## Paths"); + }); + + test("second tool-less narration after the wrap-up nudge salvages incomplete-report", async () => { + const director = new SubAgentDirector("system", [], undefined, 30); + const caps = capabilities(); + + await director.decide(inferenceDone(["read-1"]), state, caps); + await director.decide(toolDone("read-1"), state, caps); + await director.decide(inferenceDoneText("Still looking at the files..."), state, caps); + + const result = actions( + await director.decide(inferenceDoneText("Still narrating, no envelope."), state, caps), + ); + expect(result.some((action) => action.type === "infer")).toBe(false); + expect(result.some((action) => action.type === "done")).toBe(false); + expect(result).toContainEqual({ type: "checkpoint", message: "subagent-incomplete-report" }); + const reply = result.find((action) => action.type === "reply"); + expect(reply).toBeDefined(); + if (reply === undefined || reply.type !== "reply") throw new Error("expected reply action"); + expect(reply.content).toContain("narrated instead of writing a report envelope"); + expect(reply.content).toContain("Still narrating, no envelope."); + }); + + test("tool-less turn with the four headings completes normally", async () => { + const director = new SubAgentDirector("system", [], undefined, 30); + const caps = capabilities(); + + await director.decide(inferenceDone(["read-1"]), state, caps); + await director.decide(toolDone("read-1"), state, caps); + + const result = actions(await director.decide(inferenceDoneText(REPORT_ENVELOPE), state, caps)); + expect(result.some((action) => action.type === "infer")).toBe(false); + expect(result).toContainEqual({ type: "checkpoint", message: "subagent-complete" }); + const reply = result.find((action) => action.type === "reply"); + expect(reply).toBeDefined(); + if (reply === undefined || reply.type !== "reply") throw new Error("expected reply action"); + expect(reply.content).toBe(REPORT_ENVELOPE); + expect(reply.content).not.toContain("narrated instead of writing a report envelope"); + }); + + test("zero-tool first turn still salvages never-acted", async () => { + const director = new SubAgentDirector("system", [], undefined, 30); + const caps = capabilities(); + + const result = actions( + await director.decide(inferenceDoneText("I'll write the red tests next"), state, caps), + ); + expect(result.some((action) => action.type === "infer")).toBe(false); + expect(result).toContainEqual({ type: "checkpoint", message: "subagent-never-acted" }); + const reply = result.find((action) => action.type === "reply"); + expect(reply).toBeDefined(); + if (reply === undefined || reply.type !== "reply") throw new Error("expected reply action"); + expect(reply.content).toContain("without using any tools"); + expect(reply.content).not.toContain("narrated instead of writing a report envelope"); + }); +}); diff --git a/src/subagent/nudge-director.ts b/src/subagent/nudge-director.ts index 51d1d0196..24c021ede 100644 --- a/src/subagent/nudge-director.ts +++ b/src/subagent/nudge-director.ts @@ -33,10 +33,14 @@ import { const REPORT_FORCED_WRAP_UP_NUDGE = "You are close to your turn budget. Stop calling tools and write your final report now: summarize what you did, your findings, and any blockers."; +const TOOL_FAILURE_RECOVERY_NUDGE = + "A tool call failed. Do not repeat the same failed call unchanged. Inspect the error and current state, then change the arguments or approach. If you cannot recover, report the blocker."; + /** Implement leaves: soft re-read pressure should push toward edit or wrap-up. */ const RE_READ_NUDGE_IMPLEMENT = "You are re-reading the same paths without finishing. Edit a file to make progress, or stop tooling and write your final report now."; + /** * Explore / non-implement leaves: same soft re-read pressure, but do not force * edit behavior — expand findings, change approach, or report. @@ -44,6 +48,10 @@ const RE_READ_NUDGE_IMPLEMENT = const RE_READ_NUDGE_EXPLORE = "You are re-reading the same paths. Expand Findings, change approach, or write your final report — do not keep re-reading the same files."; +/** Tool-less mid-run narration after tools, without a report envelope. One-shot. */ +const INCOMPLETE_REPORT_NUDGE = + "Write your final report now using ## Summary, ## Findings, ## Blockers, and ## Paths. Do not narrate status. No more tools unless one lookup is required to cite a line."; + function ephemeralNudgeTurn(text: string): ConversationTurn { return { role: "user", @@ -92,15 +100,27 @@ export class SubAgentDirector extends DefaultDirector { consecutiveIdentical: 0, }; private thrashState: ThrashState = EMPTY_THRASH_STATE; - // Set on a report-forced or re-read-nudge turn so the follow-up infer (after - // the pending tool calls from THIS turn have executed) carries the nudge. - // Cannot attach the nudge to this turn's own infer: the model just emitted - // tool_use blocks, and every provider requires tool_result before the next - // turn — a bare nudge here would send an invalid conversation. + // Armed for wrap-up (report-forced), failed-tool recovery, or re-read so the + // follow-up infer (after pending tool calls from THIS turn have executed) + // carries the nudge. Cannot attach the nudge to this turn's own infer: the + // model just emitted tool_use blocks, and every provider requires tool_result + // before the next turn — a bare nudge here would send an invalid conversation. + // Survives both proactive compact (interceptActions leaves pending armed) and + // overflow compact (interceptOverflow re-arms from lastConsumedNudgeText if + // the infer that consumed pending never completed). private pendingNudgeText: string | null = null; + // The text applyPendingNudge last attached to a returned infer. Overflow of + // that infer means the model never saw it, so interceptOverflow re-arms + // pending from this when pending is still null. Cleared on a successful + // turn boundary so a later overflow cannot resurrect a nudge the model + // already completed. + private lastConsumedNudgeText: string | null = null; // Soft re-read-nudge is one-shot per run; thrash hard-stop still fires later // if the leaf ignores it and keeps re-reading. private reReadNudgeFired = false; + // Soft incomplete-report wrap-up is one-shot per run; a second tool-less + // narration without the envelope salvages as incomplete-report. + private incompleteReportNudgeFired = false; // Stall management: a leaf that goes quiet (e.g. parked on a long-running // background command with nothing else to do) produces no inbound events @@ -145,12 +165,20 @@ export class SubAgentDirector extends DefaultDirector { capabilities: ReactorCapabilities, ): Promise<ReactorAction | ReactorAction[]> { if (this.compaction.resumeAfterCompact(event)) { - return capabilities.infer(); + return this.applyPendingNudge([capabilities.infer()], capabilities); } const idleCompact = this.compaction.interceptIdleContinuation(event, capabilities); if (idleCompact !== null) return idleCompact; const recovery = this.compaction.interceptOverflow(event, capabilities); - if (recovery !== null) return recovery; + if (recovery !== null) { + // The infer that consumed pending never completed, so the model did not + // see the nudge. Re-arm it for resumeAfterCompact unless a newer wrap-up + // (or other pending) is already waiting. + if (this.pendingNudgeText === null && this.lastConsumedNudgeText !== null) { + this.pendingNudgeText = this.lastConsumedNudgeText; + } + return recovery; + } const stallOutcome = this.checkStallPing(event, capabilities); if (stallOutcome !== null) return stallOutcome; @@ -160,6 +188,7 @@ export class SubAgentDirector extends DefaultDirector { // prefers provider usage when present. this.compaction.syncFromTurns(state.turns); if (onTurnBoundary(event)) { + this.lastConsumedNudgeText = null; this.lastActivityAt = this.now(); this.consecutiveStalls = 0; this.compaction.noteInferenceDone(event, state.turns); @@ -188,6 +217,8 @@ export class SubAgentDirector extends DefaultDirector { repeatLimit: this.repeatLimit, thrashState: this.thrashState, requireEdit: this.requireEdit, + lastAssistantText: this.lastAssistantText, + incompleteReportNudgeFired: this.incompleteReportNudgeFired, }); if (stop === "complete") { @@ -200,6 +231,25 @@ export class SubAgentDirector extends DefaultDirector { if (compacted !== null) return compacted; return terminal; } + if (stop === "incomplete-report") { + // Tool-less turn after tools, no report envelope. Must not fall through + // to super.decide — DefaultDirector completes any tool-less turn. + this.incompleteReportNudgeFired = true; + return [ + capabilities.checkpoint("subagent-incomplete-report-nudge"), + inferWithSubAgentNudge(capabilities, INCOMPLETE_REPORT_NUDGE), + ]; + } + if (stop === "incomplete-report-stop") { + const terminal: ReactorAction[] = [ + capabilities.checkpoint("subagent-incomplete-report"), + capabilities.reply(forcedStopReport("incomplete-report", this.lastAssistantText)), + ]; + this.compaction.noteIdleTurn(event, terminal); + const compacted = this.compaction.interceptActions(event, terminal, capabilities); + if (compacted !== null) return compacted; + return terminal; + } if (stop === "report-forced") { // Not a stop: let the pending tool calls execute as normal (deferring // to super.decide below), and arm the nudge for the infer that @@ -207,8 +257,8 @@ export class SubAgentDirector extends DefaultDirector { // this fires once, forceReportWithin turns before the cap. this.pendingNudgeText = REPORT_FORCED_WRAP_UP_NUDGE; } else if (stop === "re-read-nudge") { - // Soft mid-run redirect (CL-5813). One-shot; hard thrash still stops - // the leaf if re-read pressure keeps climbing after the nudge. + // Soft mid-run redirect. One-shot; hard thrash still stops the worker + // if the same path/grep keeps repeating after the nudge. if (!this.reReadNudgeFired) { this.reReadNudgeFired = true; this.pendingNudgeText = this.requireEdit @@ -220,7 +270,8 @@ export class SubAgentDirector extends DefaultDirector { stop === "turn-budget" || stop === "never-acted" || stop === "never-edited" || - stop === "thrash" + stop === "thrash" || + stop === "no-ship" ) { const checkpoint = stop === "no-progress" @@ -231,6 +282,8 @@ export class SubAgentDirector extends DefaultDirector { ? "subagent-never-edited" : stop === "thrash" ? "subagent-thrash" + : stop === "no-ship" + ? "subagent-no-ship" : "subagent-turn-budget"; const terminal: ReactorAction[] = [ capabilities.checkpoint(checkpoint), @@ -245,13 +298,19 @@ export class SubAgentDirector extends DefaultDirector { if (event.type === "tool.done") { this.lastActivityAt = this.now(); this.consecutiveStalls = 0; + if ( + event.result.isError === true && + this.pendingNudgeText !== REPORT_FORCED_WRAP_UP_NUDGE + ) { + // Recovery is more specific than re-read guidance, but mandatory wrap-up wins. + this.pendingNudgeText = TOOL_FAILURE_RECOVERY_NUDGE; + } } const base = await super.decide(event, state, capabilities); - const actions = this.applyPendingNudge( - Array.isArray(base) ? base : [base], - capabilities, - ); - return this.compaction.interceptActions(event, actions, capabilities) ?? actions; + const baseActions = Array.isArray(base) ? base : [base]; + const compacted = this.compaction.interceptActions(event, baseActions, capabilities); + if (compacted !== null) return compacted; + return this.applyPendingNudge(baseActions, capabilities); } /** @@ -297,9 +356,8 @@ export class SubAgentDirector extends DefaultDirector { /** * Rewrite the infer action in a fall-through actions batch to carry the - * armed nudge, once — this only ever matches the infer that follows a - * report-forced or re-read-nudge turn's tool results (super.decide only emits - * infer once pendingToolResults reaches zero). + * armed nudge, once — this matches the infer after report-forced, + * re-read-nudge, or failed-tool recovery once pending tool results reach zero. */ private applyPendingNudge( actions: ReactorAction[], @@ -310,6 +368,7 @@ export class SubAgentDirector extends DefaultDirector { if (inferIndex === -1) return actions; const text = this.pendingNudgeText; this.pendingNudgeText = null; + this.lastConsumedNudgeText = text; const existing = actions[inferIndex] as Extract<ReactorAction, { type: "infer" }>; const rewritten = [...actions]; rewritten[inferIndex] = capabilities.infer(withEphemeralNudge(existing.options, text)); diff --git a/src/subagent/report.ts b/src/subagent/report.ts index f11e28ea3..76083e3bb 100644 --- a/src/subagent/report.ts +++ b/src/subagent/report.ts @@ -97,6 +97,14 @@ export function buildDispatchBrief(brief: DispatchBrief): string { return parts.join("\n"); } +/** Headings `parseSubAgentReport` recognizes. Presence of all four is the completeness gate. */ +const REPORT_ENVELOPE_HEADINGS = ["Summary", "Findings", "Blockers", "Paths"] as const; + +/** True iff `text` has all four report headings (`^##\s+Name\s*$` per line, case-insensitive). */ +export function hasReportEnvelope(text: string): boolean { + return REPORT_ENVELOPE_HEADINGS.every((name) => new RegExp(`^##\\s+${name}\\s*$`, "im").test(text)); +} + /** Demote ## Summary|Findings|Blockers|Paths lines so nested envelopes stay under Findings. */ export function demoteNestedReportHeadings(text: string): string { // Match parseSubAgentReport: flexible whitespace + case-insensitive section names. diff --git a/src/subagent/run.ts b/src/subagent/run.ts index 0dd4853f8..59426369c 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -8,7 +8,6 @@ import { liveTelemetry } from "../telemetry/singleton.js"; import { join } from "node:path"; import { - createAgent, defineAgent, defineTool, createDirectorRegistry, @@ -19,6 +18,7 @@ import { import type { AgentTool } from "@intx/agent"; import { noopAuditStore, permissiveAuthorize } from "@intx/agent/testing"; import { createOptimizedContextStore } from "../session/optimized-context-store.js"; +import { createAgentWithLiveToolDispatch } from "../agent/live-tool-dispatch.js"; import { type } from "arktype"; import { createPosixTools } from "@intx/tools-posix"; import { createDynamicToolRunner } from "../tui/dynamic-tool-runner.js"; @@ -260,7 +260,7 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> { }), }); - let agent: Awaited<ReturnType<typeof createAgent>> | null = null; + let agent: Awaited<ReturnType<typeof createAgentWithLiveToolDispatch>> | null = null; let streamPromise: Promise<void> | undefined; let closeOnAbort: (() => void) | undefined; // Declared before try (same reasoning as closeOnAbort above): assigned once @@ -353,6 +353,7 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> { ...(nd.profiles !== undefined ? { profiles: nd.profiles } : {}), ...(nd.parentSessionId !== undefined ? { parentSessionId: nd.parentSessionId } : {}), ...(nd.useWorktree !== undefined ? { useWorktree: nd.useWorktree } : {}), + ...(nd.spawnAllowlist !== undefined ? { spawnAllowlist: nd.spawnAllowlist } : {}), }), ...(nd.profiles !== undefined ? [ @@ -379,8 +380,7 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> { }), }); - - let agentHandle: Awaited<ReturnType<typeof createAgent>> | null = null; + let agentHandle: Awaited<ReturnType<typeof createAgentWithLiveToolDispatch>> | null = null; const requestContinuation = (): void => { try { agentHandle?.deliver(buildCompactionContinuationMessage()); @@ -429,9 +429,15 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> { if (typeof stallWatchdog.unref === "function") stallWatchdog.unref(); // Every tool call this sub-agent makes runs under its own identity in ALS - // (description + cwd), so the permission gate can attribute an approval - // prompt to the sub-agent that raised it (see identity-context.ts). - const subAgentIdentity = { description: params.description, cwd: params.cwd }; + // (description + cwd + optional writePaths), so the permission gate can + // attribute approvals and enforce director path locks (see identity-context.ts). + const subAgentIdentity = { + description: params.description, + cwd: params.cwd, + ...(params.writePaths !== undefined && params.writePaths.length > 0 + ? { writePaths: params.writePaths } + : {}), + }; const toolsFactory = defineTool({ id: `${ID_PREFIX}/subagent-tools`, // Without the watchdog config, child tool calls run under default budgets @@ -476,7 +482,7 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> { const inferenceDeps = await createInferenceDependencies(); const subagentSource = bundle.sources.find((s) => s.id === bundle.defaultSource) ?? bundle.sources[0]; - agent = await createAgent(def, { + agent = await createAgentWithLiveToolDispatch(def, { sources: bundle.sources, defaultSource: bundle.defaultSource, storage, diff --git a/src/subagent/stop-policy.ts b/src/subagent/stop-policy.ts index 9a4798aed..102b294b2 100644 --- a/src/subagent/stop-policy.ts +++ b/src/subagent/stop-policy.ts @@ -14,6 +14,7 @@ import { import { demoteNestedReportHeadings, formatSubAgentReport, + hasReportEnvelope, parseSubAgentReport, } from "./report.js"; @@ -253,19 +254,27 @@ export type SubAgentStopReason = | "never-acted" | "never-edited" | "thrash" + | "no-ship" | "report-forced" - | "re-read-nudge"; + | "re-read-nudge" + | "incomplete-report" + | "incomplete-report-stop"; /** * Pure stop decision for leaf workers. Null means keep running tools. * * Precedence when tools are still firing: * no-progress (identical fingerprints) > thrash (re-read pressure) > - * turn-budget (hard cap). "report-forced" and "re-read-nudge" are not competing - * stop reasons — they are one-shot signals telling the caller to inject a - * wrap-up / redirect nudge and keep running; turn-budget and thrash remain - * reachable afterward. Tool-less turns always end the leaf as complete, - * never-acted, or never-edited. + * turn-budget (hard cap). Look volume never hard-stops. + * "report-forced", "re-read-nudge", "no-ship-nudge", and "incomplete-report" + * are not competing stop reasons — they are one-shot signals telling the + * caller to inject a wrap-up / redirect nudge and keep running; turn-budget + * and thrash remain reachable afterward. Tool-less turns end as never-acted + * or never-edited when those apply; otherwise a tool-less turn after tools + * completes only when the assistant text has a four-heading envelope + * (Summary, Findings, Blockers, Paths). Omitting `lastAssistantText` + * still completes (back-compat). Missing envelope nudges + * once (`incomplete-report`) then salvages (`incomplete-report-stop`). */ export function evaluateSubAgentStop(input: { hasToolCalls: boolean; @@ -284,10 +293,21 @@ export function evaluateSubAgentStop(input: { * does not treat a pure-explore "plan" as shipped work. */ requireEdit?: boolean; + /** + * Final assistant text of this turn. When omitted, a tool-less turn after + * tools still completes (back-compat for existing unit tests). When provided, + * a missing four-heading envelope (Summary/Findings/Blockers/Paths) nudges + * once then salvages. + */ + lastAssistantText?: string; + /** True after the one-shot incomplete-report wrap-up nudge has been injected. */ + incompleteReportNudgeFired?: boolean; }): SubAgentStopReason | null { - // A tool-less turn always ends the leaf. Planning-only prose is never-acted; - // implement intent that only read/searched (no edit_file/write_file/delete_file) - // is never-edited — both hard-block identical re-dispatch. + // Planning-only prose is never-acted; implement intent that only + // read/searched (no edit_file/write_file/delete_file) is never-edited — + // both hard-block identical re-dispatch. After those, a tool-less turn + // following tools is complete only with a report envelope (or when + // lastAssistantText is omitted). if (!input.hasToolCalls) { if (!input.everHadToolCalls) return "never-acted"; if ( @@ -296,6 +316,14 @@ export function evaluateSubAgentStop(input: { ) { return "never-edited"; } + if ( + input.lastAssistantText !== undefined && + !hasReportEnvelope(input.lastAssistantText) + ) { + return input.incompleteReportNudgeFired === true + ? "incomplete-report-stop" + : "incomplete-report"; + } return "complete"; } // No-progress is more specific than thrash or the turn budget when both could apply. @@ -384,8 +412,10 @@ export function forcedStopReport( | "cancelled" | "deadline" | "thrash" + | "no-ship" | "stalled" - | "repetition", + | "repetition" + | "incomplete-report", partialText: string, ): string { const summary = @@ -393,6 +423,8 @@ export function forcedStopReport( ? "Stopped: repeated the same tool calls with no progress." : reason === "thrash" ? "Stopped: progressive thrash (re-read pressure without finishing)." + : reason === "no-ship" + ? "Stopped: implement intent searched many files without writing any." : reason === "never-acted" ? "Stopped: completed without using any tools." : reason === "never-edited" @@ -405,25 +437,31 @@ export function forcedStopReport( ? "Stopped after a long silence with no tool activity. The parent can re-dispatch or check the background work directly." : reason === "repetition" ? "Stopped: degenerate repetition in streamed output (same window looping mid-turn)." + : reason === "incomplete-report" + ? "Stopped: worker narrated instead of writing a report envelope." : "Turn budget reached before finishing."; const blockers = reason === "no-progress" ? "Identical tool-call fingerprint repeated consecutively; parent must not re-dispatch the identical brief (it will be refused) — tighten success_criteria/do_not or change approach." : reason === "thrash" ? "Re-read pressure (same path after edit, or heavy re-reads amid high tool volume); parent must not re-dispatch the identical brief (it will be refused) — re-dispatch only with a narrower scope, success_criteria, and do_not rather than more turns alone." + : reason === "no-ship" + ? "Implement searched many files without writing any; parent must not re-dispatch the identical brief (it will be refused) — re-dispatch with an edit-first brief, tighter success_criteria, and do_not. Do not search the repo yourself first." : reason === "never-acted" - ? "Leaf returned planning/prose only (zero tool calls in the run); parent must not re-dispatch the identical brief (it will be refused) — re-dispatch only with a tighter brief, or treat findings as unexecuted." + ? "Worker returned planning/prose only (zero tool calls in the run); parent must not re-dispatch the identical brief (it will be refused) — re-dispatch only with a tighter brief, or treat findings as unexecuted." : reason === "never-edited" - ? "Leaf used tools but never called edit_file/write_file/delete_file under intent=implement; parent must not re-dispatch the identical brief (it will be refused) — re-dispatch with an edit-first brief, or treat findings as unexecuted." + ? "Worker used tools but never called edit_file/write_file/delete_file under intent=implement; parent must not re-dispatch the identical brief (it will be refused) — re-dispatch with an edit-first brief, or treat findings as unexecuted." : reason === "cancelled" - ? "Operator or parent cancelled the leaf mid-run; parent may re-dispatch with the partial findings below." + ? "Operator or parent cancelled the worker mid-run; parent may re-dispatch with the partial findings below." : reason === "deadline" - ? "Leaf wall-clock deadline elapsed mid-run; parent may re-dispatch with a longer deadline or a narrower scope for the remaining work." + ? "Worker wall-clock deadline elapsed mid-run; parent may re-dispatch with a longer deadline or a narrower scope for the remaining work." : reason === "stalled" - ? "Leaf went quiet (e.g. parked on a long-running background command) past the stall timeout after an initial nudge; parent may re-dispatch to finish or check on the background work directly." + ? "Worker went quiet (e.g. parked on a long-running background command) past the stall timeout after an initial nudge; parent may re-dispatch to finish or check on the background work directly." : reason === "repetition" ? "The model looped the same output window mid-stream; the tail of the loop is in Findings. Re-dispatching the identical brief will be refused and would likely loop again — change prompt/intent/success_criteria/do_not/agent, not maxTurns alone." - : "Leaf turn budget exhausted; parent may re-dispatch for remaining work."; + : reason === "incomplete-report" + ? "Worker ended a tool-using run with a tool-less turn that had no four-heading report envelope (Summary/Findings/Blockers/Paths) after a wrap-up nudge. Findings below are the narration, not a structured report." + : "Worker turn budget exhausted; parent may re-dispatch for remaining work."; // Demote nested report-section headings so runSubAgent's parse/format pass // cannot clobber this outer Summary/Blockers with an agent-shaped envelope // stuffed into Findings (never-acted planning envelopes; cancel after a @@ -495,6 +533,9 @@ const DEADLINE_PARENT_HINT = const THRASH_PARENT_HINT = "[Sub-agent stopped for progressive thrash (re-read pressure). Do not re-dispatch the identical brief (it will be refused) — change scope, success_criteria, and do_not; continue from Findings.]"; +const NO_SHIP_PARENT_HINT = + "[Sub-agent stopped after searching many files without writing any. Do not search the repo yourself and do not re-dispatch the identical brief (it will be refused) — change success_criteria and do_not, or treat findings as unexecuted.]"; + const REPETITION_PARENT_HINT = "[Sub-agent aborted after its streamed output degenerated into a loop. Do not re-dispatch the identical brief — it will be refused and would likely loop again; change prompt, intent, success_criteria, do_not, and/or agent (maxTurns alone does not change the fingerprint).]"; @@ -548,6 +589,17 @@ export function appendThrashParentHint(report: string): string { return `${THRASH_PARENT_HINT}\n\n${report}`; } +/** True when the worker returned a no-ship (search-tour) salvage report. */ +export function isNoShipSubAgentReport(report: string): boolean { + const parsed = parseSubAgentReport(report); + return parsed.summary.includes("searched many files without writing"); +} + +export function appendNoShipParentHint(report: string): string { + if (!isNoShipSubAgentReport(report)) return report; + return `${NO_SHIP_PARENT_HINT}\n\n${report}`; +} + export function appendRepetitionParentHint(report: string): string { if (!isRepetitionSubAgentReport(report)) return report; return `${REPETITION_PARENT_HINT}\n\n${report}`; @@ -574,7 +626,9 @@ export function appendSubAgentParentHints( appendNeverActedParentHint( appendTurnBudgetParentHint( appendNoProgressParentHint( - appendThrashParentHint(appendRepetitionParentHint(report)), + appendNoShipParentHint( + appendThrashParentHint(appendRepetitionParentHint(report)), + ), ), options, ), diff --git a/src/subagent/task-tool-worktree.test.ts b/src/subagent/task-tool-worktree.test.ts index b76dd1e26..32a1bff28 100644 --- a/src/subagent/task-tool-worktree.test.ts +++ b/src/subagent/task-tool-worktree.test.ts @@ -75,7 +75,7 @@ describe("createTaskTool worktree isolation", () => { }, }); - const result = await callTask(tool, { description: "Isolated job", prompt: "Do the work" }); + const result = await callTask(tool, { description: "Isolated job", prompt: "Do the work", intent: "explore" }); expect(result).toContain("done"); expect(captured?.cwd).toBeDefined(); @@ -106,7 +106,7 @@ describe("createTaskTool worktree isolation", () => { }, }); - await callTask(tool, { description: "Shared job", prompt: "Do the work" }); + await callTask(tool, { description: "Shared job", prompt: "Do the work", intent: "explore" }); expect(captured?.cwd).toBe(repo); }); @@ -130,7 +130,7 @@ describe("createTaskTool worktree isolation", () => { }, }); - const result = await callTask(tool, { description: "Blocked job", prompt: "Do the work" }); + const result = await callTask(tool, { description: "Blocked job", prompt: "Do the work", intent: "explore" }); expect(result).toContain("Error:"); expect(result).toContain("not inside a git repository"); @@ -158,7 +158,7 @@ describe("createTaskTool worktree isolation", () => { }, }); - const result = await callTask(tool, { description: "Dirty job", prompt: "Do the work" }); + const result = await callTask(tool, { description: "Dirty job", prompt: "Do the work", intent: "explore" }); expect(result).toContain("done"); expect(result).toContain("uncommitted changes and was left in place"); @@ -195,7 +195,7 @@ describe("createTaskTool worktree isolation", () => { }, }); - const result = await callTask(tool, { description: "Stashing job", prompt: "Do the work" }); + const result = await callTask(tool, { description: "Stashing job", prompt: "Do the work", intent: "explore" }); expect(result).toContain("done"); expect(result).toContain("stash"); diff --git a/src/subagent/task-tool.ts b/src/subagent/task-tool.ts index b9f2df055..24d5e2d3f 100644 --- a/src/subagent/task-tool.ts +++ b/src/subagent/task-tool.ts @@ -14,6 +14,16 @@ import type { import { runtimeSettingsWithCatalog, type ProviderCatalogEntry } from "../config/index.js"; import { formatSubAgentTaskAuthFailureMessage } from "./inference-auth-failure.js"; import type { CapabilityFilter, AgentProfile } from "../agent/profiles.js"; +import { + isDirectorId, + packageToCapabilities, + resolveDirector, +} from "../agent/directors/registry.js"; +import { + defaultEffortForDirector, + formatDirectorSystemPrompt, +} from "../agent/directors/identity.js"; +import type { DirectorPackage } from "../agent/directors/types.js"; import type { Settings } from "../config/settings.js"; import { resolveSubAgentMaxTurns, @@ -70,7 +80,7 @@ export const TaskToolArgs = type({ export const taskToolDefinition: ToolDefinition = { name: "task", description: - "Spawn a sub-agent (a short-lived child agent) for one self-contained job. This is not a checklist item — use manage_tasks for your own work list. The sub-agent has the full file, search, and shell toolset, uses this session's permission gate (saved grants and auto mode when eligible; you may be prompted for other consequential actions), and returns a structured report (Summary / Findings / Blockers / Paths). Use it to parallelize exploration (\"map every caller of X\") or hand off a well-scoped implementation so your own context stays focused. Fire several task calls in one turn to run sub-agents in parallel. When launching multiple agents with the same profile, assign each a distinct lens in description and prompt so they do not duplicate work. The sub-agent cannot ask you questions. Depending on dispatch configuration it either shares your working tree directly, or runs isolated in its own git worktree snapshotted from your last commit — in the isolated case, any uncommitted or untracked changes in your working tree are excluded. Write a clear brief: context = durable background; prompt = actionable goal; goals = optional manage_tasks seeds. Prefer the typed spawn contract so leaves finish without thrashing: intent (explore|implement|review|plan|general), success_criteria (done-when checklist), do_not (scope fence), report_focus (what Findings must cover). After thrash / no-progress / repetition / never-acted salvage, re-dispatching the identical brief (same prompt/agent/intent/success_criteria/do_not) is refused — change the brief to retry; maxTurns alone does not unlock it. Turn-budget salvage may invite a higher maxTurns a few times, then stops recommending re-dispatch until a successful complete resets the same-brief retry budget.", + "Spawn a sub-agent (a short-lived child agent) for one self-contained job. This is not a checklist item — use manage_tasks for your own work list. The sub-agent has the full file, search, and shell toolset, uses this session's permission gate (saved grants and auto mode when eligible; you may be prompted for other consequential actions), and returns a structured report (Summary / Findings / Blockers / Paths). Use it to parallelize exploration (\"map every caller of X\") or hand off a well-scoped implementation so your own context stays focused. Fire several task calls in one turn to run sub-agents in parallel. When launching multiple agents with the same profile, assign each a distinct lens in description and prompt so they do not duplicate work. The sub-agent cannot ask you questions. Depending on dispatch configuration it either shares your working tree directly, or runs isolated in its own git worktree snapshotted from your last commit — in the isolated case, any uncommitted or untracked changes in your working tree are excluded. Write a clear brief: context = durable background; prompt = actionable goal; goals = optional manage_tasks seeds. Prefer the typed spawn contract so workers finish without thrashing: intent (explore|implement|review|plan|general), success_criteria (done-when checklist), do_not (scope fence), report_focus (what Findings must cover). After thrash / no-progress / repetition / never-acted salvage, re-dispatching the identical brief (same prompt/agent/intent/success_criteria/do_not) is refused — change the brief to retry; maxTurns alone does not unlock it. Turn-budget salvage may invite a higher maxTurns a few times, then stops recommending re-dispatch until a successful complete resets the same-brief retry budget.", inputSchema: { type: "object", properties: { @@ -104,12 +114,12 @@ export const taskToolDefinition: ToolDefinition = { type: "array", items: { type: "string" }, description: - "Optional concrete done checks. Preferred over free-form prompt alone as the leaf's completion gate.", + "Optional concrete done checks. Preferred over free-form prompt alone as the worker's completion gate.", }, do_not: { type: "array", items: { type: "string" }, - description: "Optional explicit out-of-scope or forbidden actions for the leaf.", + description: "Optional explicit out-of-scope or forbidden actions for the worker.", }, report_focus: { type: "string", @@ -118,7 +128,7 @@ export const taskToolDefinition: ToolDefinition = { agent: { type: "string", description: - "Optional agent profile id from search_agents (or .agents/agents/). Profiles specify capability restrictions and role. Role drives reasoning-effort defaults (orchestrator high, leaf medium) unless the profile pins inference.reasoningEffort; parent session effort is inheritance only when the role default is unsupported on the model.", + "Optional agent profile id from search_agents (or .agents/agents/). Profiles specify capability restrictions and role. Role drives reasoning-effort defaults (orchestrator high, worker medium) unless the profile pins inference.reasoningEffort; parent session effort is inheritance only when the role default is unsupported on the model.", }, maxTurns: { type: "number", @@ -160,12 +170,19 @@ export type TaskToolDeps = SubAgentSandboxDeps & { // the orchestrator's own session id, so workers it spawns record as nested // sessions the Agents strip can indent under it. parentSessionId?: string; + /** + * When set, only these agent/director ids may be spawned. Nested directors + * (greybeard) pass their package spawn.allowlist; primary omits this so + * plugin profiles remain reachable. + */ + spawnAllowlist?: readonly string[]; /** * Optional wall-clock budget (ms) for each worker this tool spawns. Opt-in * only — there is no default leaf death clock. When set, clamped below the * outer tool-execution watchdog so a salvage report can return first. */ deadlineMs?: number; + /** * Opt-in: isolate each spawn in its own git worktree branched from the * dispatcher's HEAD instead of sharing deps.cwd. Fails closed (see @@ -239,9 +256,15 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { let effortPin: ReasoningEffort | undefined; let capabilities: CapabilityFilter | undefined; let systemPromptRole: string | undefined; + let writePaths: readonly string[] | undefined; let orchestrator = false; let profileMaxTurns: number | undefined; + let resolvedDirectorId: string | undefined; + let resolvedPackage: DirectorPackage | undefined; + /** Child-package spawn allowlist to forward into nested task (if this worker may spawn). */ + let nestedSpawnAllowlist: readonly string[] | undefined; const diskSettings = deps.settings !== undefined ? resolveDep(deps.settings) : undefined; + const catalog = deps.catalog !== undefined ? resolveDep(deps.catalog) : undefined; // OAuth providers live in the live catalog, not settings.json. Overlay so // inference resolution can target Codex/xAI the same way the TUI does. @@ -300,66 +323,165 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { }; if (agentId !== undefined && agentId.length > 0) { - // Fail closed: an explicit agent= that cannot be resolved is an error, - // not a silent fall-through to a generic worker. Silent fall-through - // made typos and stale ids look like successful generic dispatches. - if (profiles === undefined) { - return taskToolResult( - call.id, - `Error: agent "${agentId}" requested but no agent profiles are loaded. Omit agent to use a generic sub-agent, or ensure profiles are available.`, - ); - } - const profile = profiles.find((p) => p.id === agentId); - if (profile === undefined) { - const known = profiles.map((p) => p.id).sort(); - // Point at search_agents (which injects full system prompt bodies) rather - // than read_file on plugin roots — path-escape blocks those paths by design. - const hint = - known.length > 0 - ? ` Known profiles: ${known.join(", ")}. Call search_agents to discover more (results include full system prompt / body; do not read_file plugin paths outside the workspace).` - : " No profiles are currently loaded. Call search_agents to discover available agents (results include full system prompt / body)."; - return taskToolResult(call.id, `Error: unknown agent profile "${agentId}".${hint}`); - } - if (profile.capabilities !== undefined) { - capabilities = profile.capabilities; - } - if (profile.maxTurns !== undefined) { - profileMaxTurns = profile.maxTurns; - } - if (profile.systemPromptRole !== undefined) { - systemPromptRole = profile.systemPromptRole; - } - // Nested workers (allowOrchestrator: false) cannot re-enter orchestration - // even if their profile is marked orchestrator — recursion bottoms out. - if (profile.orchestrator === true && deps.allowOrchestrator !== false) { - orchestrator = true; - } - // Per-agent pinned inference (provider/model/effort), if declared. - // Resolution uses policy (mode: pin / agentModelFallback: none) so a - // forbidden fallback surfaces as a dispatch error rather than - // silently running on the parent's provider. - if (profile.inference !== undefined && settings !== undefined) { - const outcome = resolveInferenceWithPolicy(profile.inference, settings); - if (outcome.kind === "unavailable") { + // Closed director fleet (CL-5818): resolve package even when profiles + // are not loaded; profiles may still pin inference for the same id. + if (isDirectorId(agentId)) { + const resolved = resolveDirector({ agentId }); + if (!resolved.ok) { + return taskToolResult(call.id, `Error: ${resolved.error} ${resolved.hint}`); + } + const pkg = resolved.package; + resolvedPackage = pkg; + resolvedDirectorId = pkg.id; + systemPromptRole = formatDirectorSystemPrompt(pkg); + const caps = packageToCapabilities(pkg); + if (caps !== undefined) capabilities = caps; + if (pkg.writePaths !== undefined && pkg.writePaths.length > 0) { + writePaths = pkg.writePaths; + } + if (pkg.nudge?.maxTurns !== undefined) profileMaxTurns = pkg.nudge.maxTurns; + if (pkg.spawn.maySpawn && deps.allowOrchestrator !== false) { + orchestrator = true; + if (pkg.spawn.allowlist !== undefined && pkg.spawn.allowlist.length > 0) { + nestedSpawnAllowlist = pkg.spawn.allowlist; + } + } + const profile = profiles?.find((p) => p.id === agentId); + + if (profile?.inference !== undefined && settings !== undefined) { + const outcome = resolveInferenceWithPolicy(profile.inference, settings); + if (outcome.kind === "unavailable") { + return taskToolResult( + call.id, + `Error: agent "${agentId}" unavailable: ${outcome.reason}. Set agentModelFallback: "active" (or change the spec mode to "prefer") to fall back to the active session.`, + ); + } + if (outcome.kind === "resolved") { + const err = applyResolvedProvider(outcome.value, `agent "${agentId}"`); + if (err !== null) return taskToolResult(call.id, err); + } + } + } else { + // Fail closed: an explicit agent= that cannot be resolved is an error, + // not a silent fall-through to a generic worker. Silent fall-through + // made typos and stale ids look like successful generic dispatches. + if (profiles === undefined) { return taskToolResult( call.id, - `Error: agent "${agentId}" unavailable: ${outcome.reason}. Set agentModelFallback: "active" (or change the spec mode to "prefer") to fall back to the active session.`, + `Error: agent "${agentId}" requested but no agent profiles are loaded. Omit agent to use a generic sub-agent, or ensure profiles are available.`, ); } - if (outcome.kind === "resolved") { - const err = applyResolvedProvider(outcome.value, `agent "${agentId}"`); - if (err !== null) return taskToolResult(call.id, err); + const profile = profiles.find((p) => p.id === agentId); + if (profile === undefined) { + const known = profiles.map((p) => p.id).sort(); + // Point at search_agents (which injects full system prompt bodies) rather + // than read_file on plugin roots — path-escape blocks those paths by design. + const hint = + known.length > 0 + ? ` Known profiles: ${known.join(", ")}. Call search_agents to discover more (results include full system prompt / body; do not read_file plugin paths outside the workspace).` + : " No profiles are currently loaded. Call search_agents to discover available agents (results include full system prompt / body)."; + return taskToolResult(call.id, `Error: unknown agent profile "${agentId}".${hint}`); } + if (profile.capabilities !== undefined) { + capabilities = profile.capabilities; + } + if (profile.writePaths !== undefined && profile.writePaths.length > 0) { + writePaths = profile.writePaths; + } + if (profile.maxTurns !== undefined) { + profileMaxTurns = profile.maxTurns; + } + if (profile.systemPromptRole !== undefined) { + systemPromptRole = profile.systemPromptRole; + } + // Nested workers (allowOrchestrator: false) cannot re-enter orchestration + // even if their profile is marked orchestrator — recursion bottoms out. + if (profile.orchestrator === true && deps.allowOrchestrator !== false) { + orchestrator = true; + } + // Per-agent pinned inference (provider/model/effort), if declared. + // Resolution uses policy (mode: pin / agentModelFallback: none) so a + // forbidden fallback surfaces as a dispatch error rather than + // silently running on the parent's provider. + if (profile.inference !== undefined && settings !== undefined) { + const outcome = resolveInferenceWithPolicy(profile.inference, settings); + if (outcome.kind === "unavailable") { + return taskToolResult( + call.id, + `Error: agent "${agentId}" unavailable: ${outcome.reason}. Set agentModelFallback: "active" (or change the spec mode to "prefer") to fall back to the active session.`, + ); + } + if (outcome.kind === "resolved") { + const err = applyResolvedProvider(outcome.value, `agent "${agentId}"`); + if (err !== null) return taskToolResult(call.id, err); + } + } + } + } else if (intent !== undefined) { + // intent-only dispatch maps to closed directors (no catch-all worker). + const resolved = resolveDirector({ intent }); + if (!resolved.ok) { + return taskToolResult(call.id, `Error: ${resolved.error} ${resolved.hint}`); + } + const pkg = resolved.package; + resolvedPackage = pkg; + resolvedDirectorId = pkg.id; + systemPromptRole = formatDirectorSystemPrompt(pkg); + const caps = packageToCapabilities(pkg); + if (caps !== undefined) capabilities = caps; + if (pkg.writePaths !== undefined && pkg.writePaths.length > 0) { + writePaths = pkg.writePaths; + } + if (pkg.nudge?.maxTurns !== undefined) profileMaxTurns = pkg.nudge.maxTurns; + if (pkg.spawn.maySpawn && deps.allowOrchestrator !== false) { + orchestrator = true; + if (pkg.spawn.allowlist !== undefined && pkg.spawn.allowlist.length > 0) { + nestedSpawnAllowlist = pkg.spawn.allowlist; + } + } + } else { + // No catch-all worker: bare task (no agent, no intent) is refused. Reclassify. + return taskToolResult( + call.id, + 'Error: No director selected. Pass task(agent=…) for a named director, or task(intent=implement|explore|plan|review). Intent "general" is not a director.', + ); + } + + // Skywalker is the primary session identity, not a spawned worker. + if (agentId === "skywalker" || resolvedDirectorId === "skywalker") { + return taskToolResult( + call.id, + "Error: skywalker is the primary session identity, not a spawned worker. Pass task(agent=…) for a specialist (implement, explore, plan, critique, …).", + ); + } + + // Parent director spawn matrix (e.g. greybeard → intern/explore/critique only). + if (deps.spawnAllowlist !== undefined && deps.spawnAllowlist.length > 0) { + const childId = + agentId !== undefined && agentId.length > 0 + ? agentId + : (resolvedDirectorId ?? ""); + if (childId.length === 0 || !deps.spawnAllowlist.includes(childId)) { + const allowed = deps.spawnAllowlist.join(", "); + return taskToolResult( + call.id, + `Error: spawn of "${childId.length > 0 ? childId : "(unresolved)"}" is outside this director's allowlist. Allowed: ${allowed}.`, + ); } } - // Role-based effort: pin > orchestrator/leaf default > parent inheritance. - // Leaves default to medium so a primary on high/sol does not multiply the - // latency cliff across every spawned worker (see resolveEffortForRole). + // Role-based effort: pin > package modelRole default > orchestrator/worker > parent. + // Leaves default to medium (intern: low) so a primary on high/sol does not + // multiply the latency cliff across every spawned worker. { + const roleDefault = + resolvedPackage !== undefined + ? defaultEffortForDirector(resolvedPackage) + : undefined; const effort = resolveEffortForRole({ orchestrator, ...(effortPin !== undefined ? { pin: effortPin } : {}), + ...(roleDefault !== undefined ? { roleDefault } : {}), ...(parentEffort !== undefined ? { parentEffort } : {}), model: provider.model, isCodex: isCodexProviderName(provider.providerName), @@ -411,8 +533,12 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { } const dispatchCount = admission.dispatchCount; - const agentLabel = agentId !== undefined && agentId.length > 0 ? agentId : "worker"; + const agentLabel = + agentId !== undefined && agentId.length > 0 + ? agentId + : (resolvedDirectorId ?? "worker"); const session = + deps.sessions !== undefined ? deps.sessions.start({ id: call.id, @@ -458,6 +584,9 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { ...(deps.profiles !== undefined ? { profiles: deps.profiles } : {}), ...(session !== undefined ? { parentSessionId: session.id } : {}), ...(deps.useWorktree !== undefined ? { useWorktree: deps.useWorktree } : {}), + ...(nestedSpawnAllowlist !== undefined + ? { spawnAllowlist: nestedSpawnAllowlist } + : {}), } : undefined; // Per-spawn controller so strip cancel and parent stop share one abort @@ -557,6 +686,7 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { ...(deps.onProgress !== undefined ? { onProgress: deps.onProgress } : {}), ...(capabilities !== undefined ? { capabilities } : {}), ...(systemPromptRole !== undefined ? { systemPromptRole } : {}), + ...(writePaths !== undefined ? { writePaths } : {}), ...(orchestrator ? { orchestrator: true, nestedDispatch: nestedDispatch! } : {}), diff --git a/src/subagent/thrash.test.ts b/src/subagent/thrash.test.ts index fac001f28..3cda21106 100644 --- a/src/subagent/thrash.test.ts +++ b/src/subagent/thrash.test.ts @@ -392,6 +392,32 @@ describe("thrash pure module", () => { expect(thrashFromReRead(state)).toBe(true); }); + test("unique reads at any volume are not a stop", () => { + const calls: ThrashToolCallBlock[] = []; + for (let i = 0; i < 200; i++) { + calls.push(read(`src/file-${i}.ts`)); + } + const state = applyAll(calls); + expect( + evaluateThrashStop({ + state, + hasToolCalls: true, + turnsCompleted: 40, + maxTurns: 60, + }), + ).toBeNull(); + }); + + test("same grep pattern counts as re-read pressure", () => { + const calls: ThrashToolCallBlock[] = []; + for (let i = 0; i < DEFAULT_THRASH_CONFIG.reReadLimit; i++) { + calls.push(grep("formatModelPicker")); + } + for (let i = 0; i < 4; i++) calls.push(read(`pad-${i}.ts`)); + const state = applyAll(calls); + expect(thrashFromReRead(state)).toBe(true); + }); + test("config overrides apply to evaluateThrashStop", () => { const state = applyAll([read("a.ts"), read("a.ts")]); expect( diff --git a/src/subagent/thrash.ts b/src/subagent/thrash.ts index 39c38ba2a..b8847b06b 100644 --- a/src/subagent/thrash.ts +++ b/src/subagent/thrash.ts @@ -1,15 +1,12 @@ /** - * Pure progressive thrash detection for leaf sub-agents. + * Pure progressive thrash detection for dispatched workers. * - * Tracks re-read pressure and near-budget tools-only spin without requiring - * identical tool fingerprints. Wired into SubAgentDirector via evaluateSubAgentStop. + * Tracks re-read pressure (same path or same grep) and near-budget tools-only + * spin. No look-volume quota — unique reads are legal at any count. + * Wired into SubAgentDirector via evaluateSubAgentStop. * - * Precedence when both thrash signals and existing stop helpers apply: - * no-progress > thrash > turn-budget. Soft re-read-nudge and report-forced are - * not competing stops — they fire as one-shot wrap-up / redirect nudges; the leaf - * keeps running. report-forced is preferred over re-read-nudge when both apply - * (near-budget wrap-up is more urgent than a mid-run redirect). Tool-less turns - * stay owned by evaluateSubAgentStop (complete / never-acted / never-edited). + * Precedence: no-progress > thrash > turn-budget. Soft re-read-nudge and + * report-forced are one-shot wrap-up / redirect nudges, not stops. */ /** Tunable thresholds for thrash / force-report detection. */ @@ -29,7 +26,7 @@ export type ThrashConfig = { */ reReadMinTotalTools: number; /** - * When turnsCompleted equals maxTurns - forceReportWithin and the leaf is + * When turnsCompleted equals maxTurns - forceReportWithin and the worker is * still issuing tools, inject a one-shot wrap-up nudge. */ forceReportWithin: number; @@ -58,11 +55,14 @@ export const EMPTY_THRASH_STATE: ThrashState = { /** * Thrash-module stop reasons. - * - "thrash" is a real stop + * - "thrash" is a real stop (same-path / same-grep re-read) * - "report-forced" is a near-budget wrap-up-nudge signal - * - "re-read-nudge" is a mid-run soft re-read redirect (one-shot, not a stop) + * - "re-read-nudge" is a mid-run redirect (one-shot, not a stop) */ -export type ThrashStopReason = "thrash" | "report-forced" | "re-read-nudge"; +export type ThrashStopReason = + | "thrash" + | "report-forced" + | "re-read-nudge"; /** Content block shape compatible with fingerprintToolCalls / inference turns. */ export type ThrashToolCallBlock = { @@ -72,6 +72,7 @@ export type ThrashToolCallBlock = { }; const READ_TOOLS = new Set(["read_file"]); +const SEARCH_TOOLS = new Set(["grep", "search_files"]); const EDIT_TOOLS = new Set(["edit_file", "write_file", "delete_file"]); function parseArgs(raw: unknown): Record<string, unknown> { @@ -94,6 +95,17 @@ function pathFromArgs(args: Record<string, unknown>): string | null { return typeof path === "string" && path.length > 0 ? path : null; } +function searchKey(name: string, args: Record<string, unknown>): string { + const pattern = + typeof args.pattern === "string" + ? args.pattern + : typeof args.query === "string" + ? args.query + : ""; + const path = typeof args.path === "string" ? args.path : ""; + return `${name}::${pattern}::${path}`; +} + /** * Re-read tracking key for a path. Chunked reads (offset/limit set) key by * chunk so paging through a large file does not look like re-reading the same @@ -137,13 +149,16 @@ export function nextThrashState( const name = typeof block.name === "string" ? block.name : ""; const args = parseArgs(block.arguments); const path = pathFromArgs(args); - if (path === null) continue; - if (READ_TOOLS.has(name)) { + if (READ_TOOLS.has(name) && path !== null) { if (readCounts === null) readCounts = new Map(prev.readCounts); const key = readKey(path, args); readCounts.set(key, (readCounts.get(key) ?? 0) + 1); - } else if (EDIT_TOOLS.has(name)) { + } else if (SEARCH_TOOLS.has(name)) { + if (readCounts === null) readCounts = new Map(prev.readCounts); + const key = searchKey(name, args); + readCounts.set(key, (readCounts.get(key) ?? 0) + 1); + } else if (EDIT_TOOLS.has(name) && path !== null) { if (editedPaths === null) editedPaths = new Set(prev.editedPaths); editedPaths.add(path); if (readCounts === null) readCounts = new Map(prev.readCounts); @@ -247,7 +262,7 @@ function resolveConfig(partial?: Partial<ThrashConfig>): ThrashConfig { * nudge signals, not stops — the caller injects a nudge and keeps running. * * Only evaluates when hasToolCalls is true — tool-less endings are not thrash. - * Prefers thrash > report-forced > re-read-nudge when multiple apply. + * Prefers thrash > report-forced > re-read-nudge. */ export function evaluateThrashStop(input: { state: ThrashState; diff --git a/src/subagent/types.ts b/src/subagent/types.ts index 2599842e0..91871c1ad 100644 --- a/src/subagent/types.ts +++ b/src/subagent/types.ts @@ -64,6 +64,11 @@ export type NestedDispatchDeps = SubAgentSandboxDeps & { // Forwarded from the outer TaskToolDeps so nested workers get the same // worktree-isolation behavior as their orchestrator. useWorktree?: boolean; + /** + * When set (e.g. greybeard → intern/explore/critique), nested `task` may only + * spawn these director/profile ids. Omitted = no allowlist filter (primary). + */ + spawnAllowlist?: readonly string[]; }; /** Typed spawn intent — optional on `task`; omit Intent section when unset. */ @@ -93,6 +98,11 @@ export type RunSubAgentParams = { onProgress?: (info: { description: string; toolName: string }) => void; capabilities?: CapabilityFilter; systemPromptRole?: string; + /** + * Director authz write-path allowlist. Passed into sub-agent identity so the + * permission gate can deny out-of-lane writes (not prompt policy). + */ + writePaths?: readonly string[]; // When true, the assembled system prompt grants this sub-agent permission // to call `task` to spawn further agents (orchestrator exception to the // no-recursion rule). Set from AgentProfile.orchestrator at dispatch time. diff --git a/src/telemetry/ai-observability.test.ts b/src/telemetry/ai-observability.test.ts index fa4a0df0f..7f65e7d82 100644 --- a/src/telemetry/ai-observability.test.ts +++ b/src/telemetry/ai-observability.test.ts @@ -40,7 +40,7 @@ function fakeTurnContext(overrides: Partial<TurnContext> = {}): TurnContext { { id: "call-2", name: SUBAGENT_TOOL_NAME, - arguments: { description: "explore", prompt: "find the leaked API key XYZ-SECRET-123" }, + arguments: { description: "explore", prompt: "find the leaked API key XYZ-SECRET-123", intent: "explore" }, }, ]; const toolResults: ToolResult[] = [ diff --git a/src/tui/agent-progress.test.ts b/src/tui/agent-progress.test.ts index 35175648f..d16ab2cfe 100644 --- a/src/tui/agent-progress.test.ts +++ b/src/tui/agent-progress.test.ts @@ -79,11 +79,11 @@ describe("agentProgress", () => { test("silence with no tool outstanding is a stall, and the clock shown is the silence", () => { const progress = agentProgress( { ...base, currentToolName: null, lastActivityAt: 0 }, - 31_000, - 30_000, + 121_000, + 120_000, ) expect(progress).toEqual({ - stat: "0:31 · quiet 0:31", + stat: "2:01", state: "stalled", working: false, stalled: true, @@ -101,19 +101,33 @@ describe("agentProgress", () => { currentToolStartedAt: 1_000, lastActivityAt: 1_000, }, - 91_000, - 30_000, + 181_000, + 120_000, ) expect(progress?.state).toBe("in_tool") expect(progress?.stalled).toBe(false) - expect(progress?.stat).toBe("1:31 · run_shell 1:30") + expect(progress?.stat).toBe("3:01 · run_shell 3:00") }) test("recent activity keeps a long-running session marked working", () => { - const progress = agentProgress({ ...base, lastActivityAt: 100_000 }, 100_500, 30_000) + const progress = agentProgress({ ...base, lastActivityAt: 100_000 }, 100_500, 120_000) expect(progress?.working).toBe(true) expect(progress?.stalled).toBe(false) }) + + test("default stall window tolerates a multi-minute Grok think gap", () => { + // DEFAULT_STALL_MS is 300s — 180s of quiet with no tool outstanding must + // still read working, or Task rows false-stall on healthy Responses thinks. + const progress = agentProgress( + { ...base, currentToolName: null, lastActivityAt: 0 }, + 180_000, + ) + expect(progress?.state).toBe("working") + expect(progress?.stalled).toBe(false) + expect(agentProgress({ ...base, currentToolName: null, lastActivityAt: 0 }, 301_000)?.stalled).toBe( + true, + ) + }) }) describe("laneState", () => { @@ -179,9 +193,9 @@ describe("fleetLabel", () => { expect(fleetLabel({ running: 0, working: 0, inTool: 0, stalled: 0 })).toBeNull() }) - test("names the stalled count when any lane is stuck", () => { + test("never names stalled count to the operator", () => { expect(fleetLabel({ running: 6, working: 4, inTool: 0, stalled: 2 })).toBe( - "6 agents · 2 stalled", + "6 agents", ) }) diff --git a/src/tui/agent-progress.ts b/src/tui/agent-progress.ts index ba4928abd..f32009095 100644 --- a/src/tui/agent-progress.ts +++ b/src/tui/agent-progress.ts @@ -56,8 +56,17 @@ export type AgentProgress = { readonly stalled: boolean; }; -/** Silence after which a running worker reads as hung rather than thinking. */ -export const DEFAULT_STALL_MS = 30_000; +/** + * Silence after which a running worker reads as hung rather than thinking. + * + * Grok on the Responses path routinely sits 60–120s (sometimes longer) between + * tool cycles with only sparse reasoning-summary deltas — billing thinking + * tokens the whole time. A 2-minute bar painted those healthy gaps as stalled + * Task rows and drove dig/cascade thrash. Align with the 5-minute sub-agent + * stall nudge so UI and salvage agree on what "quiet too long" means. + */ +export const DEFAULT_STALL_MS = 300_000; + /** * Second, far longer bound: how long one tool call may stay outstanding before @@ -139,12 +148,12 @@ export function agentProgress( const state = laneState(session, nowMs, stallMs); const base = hasSubject ? `${elapsed} · ${subject}` : elapsed; + // Never render "quiet" — operator chrome only shows motion (elapsed / tool). + // Internal `state` still carries stalled for recovery consumers. const stat = state === "in_tool" && session.currentToolStartedAt !== null ? `${base} ${clockLabel(nowMs - session.currentToolStartedAt)}` - : state === "stalled" - ? `${base} · quiet ${clockLabel(nowMs - session.lastActivityAt)}` - : base; + : base; return { stat, @@ -201,8 +210,10 @@ export function fleetProgress( */ export function fleetLabel(fleet: FleetProgress): string | null { if (fleet.running === 0) return null; + // Count only — never "stalled" / "quiet" for the operator. const parts = [`${fleet.running} agents`]; - if (fleet.stalled > 0) parts.push(`${fleet.stalled} stalled`); - else if (fleet.inTool === fleet.running) parts.push("in tools"); + if (fleet.stalled === 0 && fleet.inTool === fleet.running) { + parts.push("in tools"); + } return parts.join(" · "); } diff --git a/src/tui/chrome-state.test.ts b/src/tui/chrome-state.test.ts index da862e19b..755c2da95 100644 --- a/src/tui/chrome-state.test.ts +++ b/src/tui/chrome-state.test.ts @@ -30,15 +30,15 @@ describe("formatChromeZones", () => { }) }) - test("partial: task rows only", () => { + test("partial: task rows do not auto-paint (zones parked)", () => { const out = formatChromeZones({ task: [{ title: "cutover readiness", status: "doing" }], }) - expect(out.task).toEqual([{ label: "cutover readiness", status: "doing" }]) + expect(out.task).toBeNull() expect(out.agents).toBeNull() }) - test("full state formats both zones", () => { + test("running agents: both zones stay null", () => { const state: ChromeLiveState = { task: [ { title: "chrome live helper", status: "doing" }, @@ -64,25 +64,34 @@ describe("formatChromeZones", () => { ], } const out = formatChromeZones(state, NOW) - expect(out.task).toEqual([ - { label: "chrome live helper", status: "doing" }, - { label: "wire chrome zone", status: "todo" }, - { label: "wire agents zone", status: "todo" }, - ]) - // Hybrid: board FLEET header + kind; tail is `state · agentProgress.stat` - // (elapsed · tool), not the branch's tool-first wording. - expect(out.agents).toEqual([ - { label: "FLEET 1 lane · 1 working", tail: "", stalled: false, kind: "header" }, + // Both strips parked — live work stays on transcript ● Task rows. + expect(out.task).toBeNull() + expect(out.agents).toBeNull() + }) + + test("idle with open checklist still returns null (zones parked)", () => { + const out = formatChromeZones( { - label: "explore: map setChromeZones callers", - tail: " · working · 0:05 · grep", - stalled: false, - kind: "lane", + task: [ + { title: "chrome live helper", status: "doing" }, + { title: "wire chrome zone", status: "todo" }, + ], + agents: [ + { + agentId: "general", + currentToolStartedAt: null, + description: "write tests", + status: "done", + }, + ], }, - ]) + NOW, + ) + expect(out.agents).toBeNull() + expect(out.task).toBeNull() }) - test("observe overrides the agents panel", () => { + test("observe does not force an agents panel via formatChromeZones", () => { const out = formatChromeZones( { agents: [ @@ -100,13 +109,9 @@ describe("formatChromeZones", () => { }, NOW, ) - expect(out.agents).toEqual([ - { - label: "observe: explore — map callers of openListOverlay", - tail: "", - stalled: false, - }, - ]) + // Both zones always null from formatChromeZones (parked pending rebuild). + expect(out.agents).toBeNull() + expect(out.task).toBeNull() }) }) @@ -116,7 +121,7 @@ describe("formatTasksPanel", () => { expect(formatTasksPanel(undefined)).toBeNull() }) - test("each row carries its own status", () => { + test("open work first; done rows trail while live work remains", () => { expect( formatTasksPanel([ { title: "first", status: "done" }, @@ -124,22 +129,19 @@ describe("formatTasksPanel", () => { { title: "third", status: "todo" }, ]), ).toEqual([ - { label: "first", status: "done" }, { label: "second", status: "doing" }, { label: "third", status: "todo" }, + { label: "first", status: "done" }, ]) }) - test("terminal (done/cancelled) rows still render — the panel is a live list, not just what remains", () => { + test("terminal-only (all done/cancelled) collapses — no permanent [x] wall", () => { expect( formatTasksPanel([ { title: "a", status: "done" }, { title: "b", status: "cancelled" }, ]), - ).toEqual([ - { label: "a", status: "done" }, - { label: "b", status: "cancelled" }, - ]) + ).toBeNull() }) test("empty array hides", () => { @@ -174,8 +176,8 @@ describe("formatAgentsPanel", () => { ) expect(rows).toEqual([ { label: "FLEET 2 lanes · 2 working", tail: "", stalled: false, kind: "header" }, - { label: "b: two", tail: " · working · 0:02", stalled: false, kind: "lane" }, - { label: "a: one", tail: " · working · 0:01", stalled: false, kind: "lane" }, + { label: "● b two", tail: " · 0:02", stalled: false, kind: "lane" }, + { label: "● a one", tail: " · 0:01", stalled: false, kind: "lane" }, ]) }) @@ -192,7 +194,7 @@ describe("formatAgentsPanel", () => { ).toBeNull() }) - test("a stalled lane names its state and reports how long it has been silent", () => { + test("a stalled lane uses ! marker and reports silence via the clock", () => { const rows = formatAgentsPanel( [ { @@ -200,35 +202,35 @@ describe("formatAgentsPanel", () => { currentToolStartedAt: null, description: "quiet worker", status: "running", - startedAt: NOW - 60_000, - lastActivityAt: NOW - 40_000, + startedAt: NOW - 180_000, + lastActivityAt: NOW - 310_000, }, ], undefined, NOW, ) - // Hybrid uses main's agentProgress wording (`quiet`, with lifetime in the - // stat) under the board's `stalled · …` prefix — not branch `silent`. expect(rows?.[1]).toEqual({ - label: "a: quiet worker", - tail: " · stalled · 1:00 · quiet 0:40", + label: "! a quiet worker", + tail: " · 3:00", stalled: true, kind: "lane", }) expect(rows?.[0]?.label).toContain("1 stalled") expect(rows?.[0]?.kind).toBe("header") + expect(rows?.[0]?.stalled).toBe(true) }) test("trouble sorts above routine progress", () => { const rows = formatAgentsPanel( [ { agentId: "fine", description: "busy", status: "running", currentToolStartedAt: null, startedAt: NOW - 1_000, lastActivityAt: NOW }, - { agentId: "quiet", description: "silent", status: "running", currentToolStartedAt: null, startedAt: NOW - 90_000, lastActivityAt: NOW - 60_000 }, + { agentId: "quiet", description: "silent", status: "running", currentToolStartedAt: null, startedAt: NOW - 180_000, lastActivityAt: NOW - 310_000 }, ], undefined, NOW, ) - expect(rows?.slice(1).map((r) => r.label.split(":")[0])).toEqual(["quiet", "fine"]) + // Labels are `● id desc` / `! id desc` — second token is the agentId. + expect(rows?.slice(1).map((r) => r.label.split(/\s+/)[1])).toEqual(["quiet", "fine"]) }) test("bounds fan-out and says how many lanes it is hiding", () => { @@ -286,10 +288,10 @@ describe("formatAgentsPanel", () => { const frame2 = frame1.map((a) => (a.agentId === "b" ? { ...a, lastActivityAt: NOW + 200 } : a)) const rowsAfter = formatAgentsPanel(frame2, undefined, NOW + 200) - expect(rowsBefore?.map((r) => r.label.split(":")[0])).toEqual( - rowsAfter?.map((r) => r.label.split(":")[0]), - ) - expect(rowsBefore?.slice(1).map((r) => r.label.split(":")[0])).toEqual(["a", "b", "c"]) + const ids = (rows: ReturnType<typeof formatAgentsPanel>) => + rows?.slice(1).map((r) => r.label.split(/\s+/)[1]) + expect(ids(rowsBefore)).toEqual(ids(rowsAfter)) + expect(ids(rowsBefore)).toEqual(["a", "b", "c"]) }) test("a stalled lane survives a truncated fan-out", () => { @@ -308,15 +310,13 @@ describe("formatAgentsPanel", () => { currentToolStartedAt: null, description: "gone silent", status: "running" as const, - startedAt: NOW - 300_000, - lastActivityAt: NOW - 250_000, + startedAt: NOW - 360_000, + lastActivityAt: NOW - 310_000, } const rows = formatAgentsPanel([...newest, stalled], undefined, NOW, 4) - expect(rows?.some((r) => r.label.includes("quiet"))).toBe(true) - expect(rows?.some((r) => r.stalled)).toBe(true) - // And the ones it could not show are still accounted for. - expect(rows?.[0]?.label).toContain("6 lanes") - expect(rows?.[0]?.label).toContain("1 stalled") + // header + 2 lanes + more (bodyBudget 3, one spent on more → 2 lanes shown) + expect(rows?.[1]?.label.split(/\s+/)[1]).toBe("quiet") + expect(rows?.[1]?.stalled).toBe(true) }) }) @@ -359,14 +359,9 @@ describe("chromeFromSession", () => { ]) const zones = formatChromeZones(state, NOW) - expect(zones.task).toEqual([ - { label: "wire catalogs", status: "doing" }, - { label: "export index", status: "todo" }, - ]) - expect(zones.agents).toEqual([ - { label: "FLEET 1 lane · 1 working", tail: "", stalled: false, kind: "header" }, - { label: "explore: map callers", tail: " · working · 0:05 · grep", stalled: false, kind: "lane" }, - ]) + // Both chrome strips parked pending rebuild. + expect(zones.task).toBeNull() + expect(zones.agents).toBeNull() }) test("falls back agent id; empty bags hide", () => { @@ -385,7 +380,7 @@ describe("chromeFromSession", () => { expect(state.agents?.[0]?.agentId).toBe("sess-1") }) - test("observe passes through", () => { + test("observe passes through on the session snapshot; chrome zones stay agents-null", () => { const state = chromeFromSession({ observe: { agentId: "explore", description: "watch" }, }) @@ -393,9 +388,7 @@ describe("chromeFromSession", () => { agentId: "explore", description: "watch", }) - expect(formatChromeZones(state, NOW).agents).toEqual([ - { label: "observe: explore — watch", tail: "", stalled: false }, - ]) + expect(formatChromeZones(state, NOW).agents).toBeNull() }) }) @@ -408,17 +401,13 @@ describe("annotateAgentTools", () => { ], } - test("running agents pick up the live tool name", () => { + test("is an identity: a progress map never paints a tool without a store clock", () => { + // The store is the sole source of truth for what a worker is doing. + // A progress ping carries only a tool name with no clock, and is also + // emitted on tool completion, so it must never fill in a dead lane. const tools = new Map([["map callers", "grep"]]) - const next = annotateAgentTools(state, tools) - expect(next.agents?.[0]?.currentToolName).toBe("grep") - expect(next.agents?.[1]?.currentToolName).toBeUndefined() - }) - - test("unknown descriptions and empty maps leave the state alone", () => { + expect(annotateAgentTools(state, tools)).toBe(state) expect(annotateAgentTools(state, new Map())).toBe(state) - const next = annotateAgentTools(state, new Map([["other work", "grep"]])) - expect(next.agents?.[0]?.currentToolName).toBeUndefined() }) }) @@ -436,9 +425,11 @@ describe("lane state survives the mapping hops", () => { status: "running" as const, currentToolName: "run_shell", currentToolPreview: null as string | null, - currentToolStartedAt: NOW - 90_000, - startedAt: NOW - 100_000, - lastActivityAt: NOW - 90_000, + // Past DEFAULT_STALL_MS (300s) so laneState reaches the in_tool branch, + // still under IN_TOOL_STALL_MS (10 min). Tool clock stays at 3:00. + currentToolStartedAt: NOW - 180_000, + startedAt: NOW - 200_000, + lastActivityAt: NOW - 310_000, } test("the panel and the transcript row agree that the lane is in a tool", () => { @@ -449,18 +440,16 @@ describe("lane state survives the mapping hops", () => { undefined, NOW, ) - // Board: header first, then the lane. Operator copy uses "in tool", not - // the machine LaneState token. + // Board: header first, then the lane. Marker is ● (live); tail carries tool clock. expect(rows?.[0]?.kind).toBe("header") expect(rows?.[0]?.label).toContain("in tool") expect(rows?.[1]?.kind).toBe("lane") expect(rows?.[1]?.stalled).toBe(false) - expect(rows?.[1]?.tail).toContain("in tool") - expect(rows?.[1]?.tail).not.toContain("in_tool") - expect(rows?.[1]?.tail).toContain("run_shell 1:30") + expect(rows?.[1]?.label.startsWith("● ")).toBe(true) + expect(rows?.[1]?.tail).toContain("run_shell 3:00") expect(rows?.[1]?.tail).not.toContain("stalled") - expect(agentProgress(inTool, NOW)?.stat).toContain("run_shell 1:30") + expect(agentProgress(inTool, NOW)?.stat).toContain("run_shell 3:00") }) test("a shell preview replaces the tool name on both panel and trailer (CL-5765)", () => { @@ -480,7 +469,12 @@ describe("lane state survives the mapping hops", () => { }) test("a genuinely silent lane still reads stalled through the same hops", () => { - const silent = { ...inTool, currentToolName: null, currentToolStartedAt: null } + const silent = { + ...inTool, + currentToolName: null, + currentToolStartedAt: null, + lastActivityAt: NOW - 310_000, + } expect(laneState(silent, NOW)).toBe("stalled") const rows = formatAgentsPanel( @@ -492,28 +486,56 @@ describe("lane state survives the mapping hops", () => { expect(rows?.[0]?.label).toContain("1 stalled") expect(rows?.[1]?.stalled).toBe(true) expect(rows?.[1]?.kind).toBe("lane") + expect(rows?.[1]?.label.startsWith("! ")).toBe(true) }) - // A progress ping renames the tool but carries no clock of its own, so it - // must not override a call the store is already timing. + // A progress ping renames the tool but carries no clock of its own and may + // arrive on tool completion — so it must not paint anything at all. test("the tool annotation never repaints a live call with another name", () => { const annotated = annotateAgentTools( { agents: [inTool] }, new Map([["sleep 150", "grep"]]), ) expect(annotated.agents?.[0]?.currentToolName).toBe("run_shell") - expect(annotated.agents?.[0]?.currentToolStartedAt).toBe(NOW - 90_000) + expect(annotated.agents?.[0]?.currentToolStartedAt).toBe(NOW - 180_000) }) - test("the tool annotation still fills a gap when no call is outstanding", () => { + test("the tool annotation never fills a gap when no call is outstanding", () => { + // A lane with no outstanding call must stay null: the progress map is not + // a source of truth for what a worker is doing. Painting it here is what + // produced the false "quiet … · <tool>" stall on finished tools. const idle = { ...inTool, currentToolName: null, currentToolStartedAt: null } const annotated = annotateAgentTools( { agents: [idle] }, new Map([["sleep 150", "grep"]]), ) - expect(annotated.agents?.[0]?.currentToolName).toBe("grep") + expect(annotated.agents?.[0]?.currentToolName).toBeNull() expect(annotated.agents?.[0]?.currentToolStartedAt).toBeNull() }) + + test("a stalled lane with a null tool clock is marked ! with no tool name", () => { + // Inference-wait silence: header counts stalled; lane uses ! marker; + // agentProgress never gap-fills a tool subject. + const silent = { + ...inTool, + currentToolName: null, + currentToolPreview: null, + currentToolStartedAt: null, + lastActivityAt: NOW - 310_000, + } + const rows = formatAgentsPanel( + chromeFromSession({ agents: [silent] }).agents, + undefined, + NOW, + ) + expect(rows?.[0]?.label).toContain("1 stalled") + expect(rows?.[1]?.stalled).toBe(true) + expect(rows?.[1]?.label.startsWith("! ")).toBe(true) + expect(rows?.[1]?.tail).not.toContain("grep") + expect(agentProgress(silent, NOW)?.stat).not.toContain("quiet") + expect(agentProgress(silent, NOW)?.stat).not.toContain("grep") + expect(agentProgress(silent, NOW)?.stat).not.toContain("read_file") + }) }) describe("clampBoardRows", () => { @@ -522,10 +544,10 @@ describe("clampBoardRows", () => { // Honest disclosure is 4 prior + 2 newly dropped = 6, not 2. const formatted = [ { label: "FLEET 8 lanes · 8 working", tail: "", stalled: false, kind: "header" as const }, - { label: "a: one", tail: " · working · 0:01", stalled: false, kind: "lane" as const }, - { label: "b: two", tail: " · working · 0:01", stalled: false, kind: "lane" as const }, - { label: "c: three", tail: " · working · 0:01", stalled: false, kind: "lane" as const }, - { label: "d: four", tail: " · working · 0:01", stalled: false, kind: "lane" as const }, + { label: "● a one", tail: " · 0:01", stalled: false, kind: "lane" as const }, + { label: "● b two", tail: " · 0:01", stalled: false, kind: "lane" as const }, + { label: "● c three", tail: " · 0:01", stalled: false, kind: "lane" as const }, + { label: "● d four", tail: " · 0:01", stalled: false, kind: "lane" as const }, { label: "+4 more lanes", tail: "", stalled: false, kind: "more" as const }, ] const clamped = clampBoardRows(formatted, 4) @@ -543,8 +565,8 @@ describe("clampBoardRows", () => { test("under a tight height the header carries the total hidden count", () => { const formatted = [ { label: "FLEET 8 lanes · 8 working", tail: " · +4 hidden", stalled: false, kind: "header" as const }, - { label: "a: one", tail: " · working · 0:01", stalled: false, kind: "lane" as const }, - { label: "b: two", tail: " · working · 0:01", stalled: false, kind: "lane" as const }, + { label: "● a one", tail: " · 0:01", stalled: false, kind: "lane" as const }, + { label: "● b two", tail: " · 0:01", stalled: false, kind: "lane" as const }, ] const clamped = clampBoardRows(formatted, 2) expect(clamped).toHaveLength(2) diff --git a/src/tui/chrome-state.ts b/src/tui/chrome-state.ts index 415c4e250..74a66e7ee 100644 --- a/src/tui/chrome-state.ts +++ b/src/tui/chrome-state.ts @@ -1,9 +1,16 @@ /** * Live chrome zone formatter for setChromeZones. * - * Pure: structured session state → one-line task / agents strings. - * Heights stay with geometry (zones max 1 row each); this module never - * invents row budgets. + * Pure: structured session state → task / agents zone rows. + * Heights stay with geometry; this module never invents row budgets. + * + * ## Parked auto-paint + * + * `formatChromeZones` currently always returns `{ task: null, agents: null }` + * — both chrome strips are parked pending rebuild. Live work stays on + * transcript `● Task …` rows. `formatTasksPanel` / `formatAgentsPanel` remain + * for demos, tests, and a future rebuild; manual `setChromeZones` can still + * feed preformatted rows. * * ## Product host push contract * @@ -19,7 +26,8 @@ * * Always pass the full snapshot so absent zones clear (`null` hides the zone). * Partial object fields mean “no data” → that zone line is null, not left - * stale. Observe mode can override the agents line via `state.observe`. + * stale. Observe mode can override the agents line via `state.observe` when + * agents chrome is rebuilt. */ import { @@ -95,9 +103,9 @@ export type ChromeLiveState = { /** * One rendered agents-panel row. `stalled` is a fact the formatter already * knows from `agentProgress` — carried explicitly so the renderer never has - * to recover it by sniffing `text` for a marker string. `label` (agentId + - * description) is the part the renderer may ellipsize under width pressure; - * `tail` (elapsed/tool/stalled) must never be trimmed away. + * to recover it by sniffing `label` for a marker string. `label` (the ●/! + * marker, agentId + description) is the part the renderer may ellipsize under + * width pressure; `tail` (clock/tool) must never be trimmed away. */ export type AgentPanelRow = { readonly label: string @@ -129,17 +137,20 @@ export type FormattedChromeZones = { /** * Format structured live state into chrome zone rows for setChromeZones. * - * Empty / partial / inactive inputs yield null for the corresponding zone - * so geometry collapses that strip (idleDefault 0). + * Both chrome strips (task checklist + agents/fleet board) are parked pending + * rebuild: this always returns `{ task: null, agents: null }` so nothing + * auto-paints in those zones. Live work stays on transcript `● Task …` rows + * (runtime-bridge). `formatTasksPanel` / `formatAgentsPanel` stay intact for + * demos, tests, and a future rebuild; manual `setChromeZones` / Alt+T can still + * feed preformatted rows into the shell. */ export function formatChromeZones( state: ChromeLiveState, nowMs: number = Date.now(), ): FormattedChromeZones { - return { - task: formatTasksPanel(state.task), - agents: formatAgentsPanel(state.agents, state.observe, nowMs), - } + void state + void nowMs + return { task: null, agents: null } } /** @@ -156,9 +167,9 @@ export function chromeZonesContent(state: ChromeLiveState): ChromeZoneContent { * with a trailing "+N more" row, mirroring `formatAgentsPanel`'s shape but * keyed on status (not liveness) since a task has no clock of its own. * - * Terminal tasks (done/cancelled) still render — the panel is a live list of - * work, not just what remains — so an operator watching it sees a task move - * to "done" rather than silently vanish. + * Terminal-only lists (every task done/cancelled) collapse to null — a wall of + * `[x]` rows is not live work, and once the fleet board or parent prose has + * moved on, painting them is noise (CL-5846). */ export function formatTasksPanel( task: readonly ChromeTaskRow[] | null | undefined, @@ -171,8 +182,17 @@ export function formatTasksPanel( .filter((r) => r.label.length > 0) if (rows.length === 0) return null - const visible = rows.slice(0, maxVisible) - const hidden = rows.length - visible.length + const live = rows.filter((r) => r.status === "todo" || r.status === "doing") + if (live.length === 0) return null + + // Prefer open work; keep recently-done visible only while open work remains + // so the operator sees items flip to done without a permanent [x] wall. + const openFirst = [ + ...live, + ...rows.filter((r) => r.status === "done" || r.status === "cancelled"), + ] + const visible = openFirst.slice(0, maxVisible) + const hidden = openFirst.length - visible.length if (hidden > 0) visible.push({ label: `+${hidden} more`, status: null }) return visible } @@ -354,21 +374,6 @@ function withHiddenCount(header: AgentPanelRow, hidden: number): AgentPanelRow { return hidden > 0 ? { ...header, tail: ` · +${hidden} hidden` } : header } -/** - * Operator-facing state word. Machine `LaneState` stays snake_case for code; - * the board never paints that vocabulary into the terminal. - */ -function laneStateWord(state: LaneState): string { - switch (state) { - case "in_tool": - return "in tool" - case "stalled": - return "stalled" - case "working": - return "working" - } -} - /** * The one-line answer to "is everything fine". Counts run worst-first so that * a narrow terminal ellipsizes away the routine tail rather than the trouble. @@ -404,10 +409,13 @@ function formatAgentRow( nowMs: number, stallMs: number, ): AgentPanelRow { - const label = `${session.agentId}: ${session.description}`.trim() const stalled = state === "stalled" + // Rail grammar: ● for live work, ! when quiet. The marker names the state so + // the tail stays clock/tool only (variant A single-line lanes). + const marker = stalled ? "!" : "●" + const label = `${marker} ${session.agentId} ${session.description}`.trim() // Prefer the argument subject (command / path) over the bare tool name so a - // fleet of shell calls is distinguishable at a glance (CL-5765). + // strip of shell calls is distinguishable at a glance (CL-5765). const preview = session.currentToolPreview const tool = session.currentToolName const doing = @@ -424,14 +432,14 @@ function formatAgentRow( return { label, tail: doing !== null ? ` · ${doing}` : "", stalled, kind: "lane" } } - // Prefer main's agentProgress for tool-clock / in_tool / quiet clocks so the - // board never invents a second stall path. Board presentation still prefixes - // the operator-facing state word (and kind: lane) the way the fleet board reads. + // Prefer agentProgress for tool-clock / in_tool / silence clocks so the + // board never invents a second stall path. Tail is clock · tool only — the + // ●/! marker already names the state. const progress = agentProgress(progressSession, nowMs, stallMs) if (progress !== null) { return { label, - tail: ` · ${laneStateWord(state)} · ${progress.stat}`, + tail: ` · ${progress.stat}`, stalled, kind: "lane", } @@ -443,35 +451,22 @@ function formatAgentRow( /** * Overlay live per-agent tool names onto the agents zone. * - * The subagent store records what a worker was asked to do, never what it is - * doing right now — that arrives only as `subagent.progress`. Keying by - * description is what the emitter gives us: progress carries the worker's - * description and tool name, not its agent id. + * This is now an identity: the subagent store is the sole source of truth for + * what a worker is doing, via `currentToolName` paired with the matching + * `currentToolStartedAt` clock. The `subagent.progress` ping carries only a + * tool name with no clock of its own, so painting it onto a lane with no + * outstanding call would announce a dead tool — exactly the false "quiet · + * read_file" stall this surface exists to expose. Progress pings are also + * emitted on tool completion, so any name they hand us may already be stale. + * + * The signature is kept so call sites and tests can be updated in their own + * diffs; passing a map here no longer changes any row. */ export function annotateAgentTools( state: ChromeLiveState, - toolByDescription: ReadonlyMap<string, string>, + _toolByDescription?: ReadonlyMap<string, string>, ): ChromeLiveState { - const agents = state.agents - if (agents === null || agents === undefined || toolByDescription.size === 0) { - return state - } - return { - ...state, - agents: agents.map((a) => { - if (a.status !== "running") return a - const tool = toolByDescription.get(a.description) - if (tool === undefined) return a - // Gap-fill only. When the store has a call outstanding it owns both the - // name and the clock, and overriding just the name would paint one - // tool's identifier beside another tool's elapsed time. A progress ping - // also cannot supply a clock of its own — only the store observes a call - // ending, so a ping-sourced clock would keep a finished lane reading - // busy forever, hiding exactly the stalls this surface exists to show. - if (a.currentToolStartedAt !== null) return a - return { ...a, currentToolName: tool } - }), - } + return state } // --------------------------------------------------------------------------- diff --git a/src/tui/collapse.test.ts b/src/tui/collapse.test.ts index 44fba5606..071565440 100644 --- a/src/tui/collapse.test.ts +++ b/src/tui/collapse.test.ts @@ -21,7 +21,7 @@ import { type RowLayout, type StreamRow, } from "./stream" -import { thinkingScrollLine, thinkingSettledLine } from "./thinking" +import { thinkingLivePreviewLines, thinkingSettledLine } from "./thinking" import { describeView, toolArgsView } from "./tool-args" const WIDE: RowLayout = { width: 96, multiAgent: false } @@ -164,23 +164,32 @@ describe("tool arguments collapse to a human summary", () => { }) }) -describe("reasoning collapses to one line", () => { +describe("reasoning collapses to a short wrapped preview", () => { const text = "the token helper is referenced from four packages and two of them are vendored, so the rename has to land in one commit" - test("while thinking it is a single row windowed onto the newest text", () => { + test("while thinking it wraps a short preview instead of sideways-scrolling", () => { const painted = lines({ role: "system", meta: "thinking", text, streaming: true }) - expect(painted.length).toBe(1) + expect(painted.length).toBeGreaterThanOrEqual(1) + expect(painted.length).toBeLessThanOrEqual(3) // Inset and dim is the whole of reasoning's chrome; it carries no rail. - expect(painted[0]).not.toContain("┆") - expect(painted[0]?.trimEnd().endsWith("one commit")).toBe(true) - expect((painted[0] as string).length).toBeLessThanOrEqual(WIDE.width) + expect(painted.every((line) => !line.includes("┆"))).toBe(true) + expect(painted.join("\n")).toContain("one commit") + for (const line of painted) { + expect(line.length).toBeLessThanOrEqual(WIDE.width) + } }) - test("the window follows the tail rather than growing the row", () => { - expect(thinkingScrollLine("abc def", 20)).toBe("abc def") - expect(thinkingScrollLine("abcdefghij", 4)).toBe("ghij") - expect(thinkingScrollLine("line one\nline two", 40)).toBe("line one line two") + test("the live preview wraps newest text rather than windowing one row", () => { + expect(thinkingLivePreviewLines("abc def", 20)).toEqual(["abc def"]) + expect(thinkingLivePreviewLines("abcdefghij", 4)).toEqual([ + "abcd", + "efgh", + "ij", + ]) + expect(thinkingLivePreviewLines("line one\nline two", 40)).toEqual([ + "line one line two", + ]) }) test("once done it keeps its own text rather than swapping in a phrase", () => { @@ -206,7 +215,7 @@ describe("reasoning collapses to one line", () => { expect(expanded[expanded.length - 1]?.trim()).toBe("╵ 12s") }) - test("a long chain of thought is cut, never wrapped onto a second row", () => { + test("a long settled chain of thought is cut, never wrapped onto a second row", () => { expect(thinkingSettledLine("abc def", 20)).toBe("abc def") expect(thinkingSettledLine("abcdefghij", 6)).toBe("abcde…") expect(thinkingSettledLine("line one\nline two", 40)).toBe("line one line two") diff --git a/src/tui/command-catalog.test.ts b/src/tui/command-catalog.test.ts index 4629c0471..6c8d2fb9c 100644 --- a/src/tui/command-catalog.test.ts +++ b/src/tui/command-catalog.test.ts @@ -15,11 +15,13 @@ describe("commandItemsFromRegistry", () => { { id: "tasks", label: "/tasks", + description: "Show work list", keywords: ["tasks", "Show work list", "slash", "command"], }, { id: "clear", label: "/clear", + description: "Clear screen", keywords: ["clear", "Clear screen", "slash", "command"], }, ]) @@ -43,6 +45,24 @@ describe("filterPaletteCommands", () => { expect(filterPaletteCommands("picker", catalog).map((c) => c.id)).toEqual([ "model", ]) + // A rewrite that mapped hits to `{ id, label, keywords }` would stay green + // on `.id` alone and blank the overlay description zone after a keystroke. + expect(filterPaletteCommands("picker", catalog)[0]?.description).toBe( + "Open model picker", + ) + }) + + test("empty or whitespace description maps into keywords without false matches", () => { + const sparse = commandItemsFromRegistry([ + { name: "quiet", description: "" }, + { name: "padded", description: " " }, + ]) + expect(sparse[0]?.keywords).toEqual(["quiet", "", "slash", "command"]) + expect(sparse[1]?.keywords).toEqual(["padded", " ", "slash", "command"]) + expect(filterPaletteCommands("picker", sparse)).toEqual([]) + expect(filterPaletteCommands("quiet", sparse).map((c) => c.id)).toEqual([ + "quiet", + ]) }) test("no match returns an empty list", () => { diff --git a/src/tui/command-catalog.ts b/src/tui/command-catalog.ts index e86fd061e..74061d8d3 100644 --- a/src/tui/command-catalog.ts +++ b/src/tui/command-catalog.ts @@ -21,6 +21,8 @@ export type PaletteCommand = { readonly label: string /** Optional keywords for name-prefix / substring filter. */ readonly keywords?: readonly string[] + /** Registry description for the overlay zone; rows stay name-only. */ + readonly description?: string } /** Map registry command definitions to `/` list items. */ @@ -29,9 +31,11 @@ export function commandItemsFromRegistry( ): PaletteCommand[] { return commands.map((c) => ({ id: c.name, - // Name-only rows keep the slash popup scannable; description stays in - // keywords so typed filter still finds prose matches. + // Name-only rows keep the slash popup scannable; description is a + // dedicated field for the overlay zone and stays in keywords so typed + // filter still finds prose matches. label: `/${c.name}`, + description: c.description, keywords: [c.name, c.description, "slash", "command"], })) } diff --git a/src/tui/command-surfaces.test.ts b/src/tui/command-surfaces.test.ts index 57fd6e72b..c8e6b9aa2 100644 --- a/src/tui/command-surfaces.test.ts +++ b/src/tui/command-surfaces.test.ts @@ -28,8 +28,6 @@ import { function baseSnapshot(): SettingsSnapshot { return { compactionMode: "llm", - sessionMode: "orchestrator", - sessionModeScope: "global", waitForApproval: true, telemetryEnabled: false, showPromptCost: false, @@ -86,6 +84,21 @@ describe("surface labels", () => { ).toBe("exa — enabled") }) + test("plugin label surfaces standing load warnings", () => { + expect( + pluginRowLabel({ + id: "agents", + name: "agents", + enabled: true, + credentials: [], + credentialValues: {}, + warnings: [ + 'agent a: skill "style" referenced but not found in skill search path', + ], + }), + ).toBe("agents — enabled — has warnings") + }) + }) /** Build a settings deps bag over a mutable snapshot, recording every write. */ @@ -94,7 +107,6 @@ function settingsDeps(overrides?: Partial<SettingsSnapshot>): { readonly snapshot: () => SettingsSnapshot readonly calls: { compaction: string[] - sessionMode: Array<{ mode: string; scope: string }> waitForApproval: boolean[] telemetry: boolean[] showPromptCost: boolean[] @@ -103,7 +115,6 @@ function settingsDeps(overrides?: Partial<SettingsSnapshot>): { let state: SettingsSnapshot = { ...baseSnapshot(), ...overrides } const calls = { compaction: [] as string[], - sessionMode: [] as Array<{ mode: string; scope: string }>, waitForApproval: [] as boolean[], telemetry: [] as boolean[], showPromptCost: [] as boolean[], @@ -116,10 +127,6 @@ function settingsDeps(overrides?: Partial<SettingsSnapshot>): { calls.compaction.push(mode) state = { ...state, compactionMode: mode } }, - setSessionMode: (mode, scope) => { - calls.sessionMode.push({ mode, scope }) - state = { ...state, sessionMode: mode, sessionModeScope: scope } - }, setWaitForApproval: (value) => { calls.waitForApproval.push(value) state = { ...state, waitForApproval: value } @@ -187,18 +194,15 @@ describe("settings surface", () => { }) }) - test("session mode scope switch honours a local write", async () => { + test("settings surface has no session mode rows", async () => { await withShell(async (shell) => { - const { deps, calls } = settingsDeps() + const { deps } = settingsDeps() openCommandSurface(shell, "settings", deps) await Promise.resolve() await Promise.resolve() - moveOverlaySelection(shell, 2) // compaction, session mode, scope - cycleOverlaySelection(shell, 1) - await Promise.resolve() - await Promise.resolve() - expect(calls.sessionMode).toEqual([{ mode: "orchestrator", scope: "local" }]) + expect(shell.overlayItems.some((l) => l.includes("session mode"))).toBe(false) + expect(shell.overlayItems.some((l) => l.includes("scope"))).toBe(false) }) }) @@ -211,7 +215,8 @@ describe("settings surface", () => { expect(shell.overlayItems.some((l) => l.includes("show cost"))).toBe(true) - moveOverlaySelection(shell, 5) // compaction, session mode, scope, approval wait, telemetry, show cost + // compaction, approval wait, telemetry, show cost + moveOverlaySelection(shell, 3) cycleOverlaySelection(shell, 1) await Promise.resolve() await Promise.resolve() @@ -406,6 +411,37 @@ function pluginActionDeps(overrides?: Partial<PluginEntry>): { } describe("plugins surface admin actions", () => { + test("load warnings appear as a summary row under /plugins", async () => { + await withShell(async (shell) => { + const warnings = [ + 'agent a: skill "style" referenced but not found in skill search path', + 'agent a: skill "philosophy" referenced but not found in skill search path', + ] + const { deps } = pluginActionDeps({ + id: "agents", + name: "agents", + kind: "agent", + enabled: true, + credentials: [], + credentialValues: {}, + warnings, + agentProfiles: [{ id: "a" }], + }) + // pluginActionDeps builds PluginsSurfaceDeps without loadWarnings; splice it in. + const plugins = deps.plugins! + const withWarnings: CommandSurfaceDeps = { + ...deps, + plugins: { + ...plugins, + loadWarnings: () => warnings, + }, + } + openCommandSurface(shell, "plugins", withWarnings) + expect(shell.overlayItems.some((l) => l.includes("2 skills missing"))).toBe(true) + expect(shell.overlayItems.some((l) => l.includes("has warnings"))).toBe(true) + }) + }) + test("c opens credentials, typing a 40+ char key and s saves it in full", async () => { await withShell(async (shell) => { const { deps, calls } = pluginActionDeps() diff --git a/src/tui/command-surfaces.ts b/src/tui/command-surfaces.ts index 77412d713..8fafbb75d 100644 --- a/src/tui/command-surfaces.ts +++ b/src/tui/command-surfaces.ts @@ -10,7 +10,7 @@ import type { KeyEvent } from "@opentui/core" -import type { SessionMode } from "../config/session-mode.js" +import { formatPluginWarningsSummary } from "../plugins/diagnostics.js" import { maskEcho, maskSecret } from "./provider-setup.js" import { residualIdFromSelection, type ResidualCatalogEntry } from "./residuals.js" import { @@ -55,6 +55,11 @@ export type PluginEntry = { readonly agentProfiles?: readonly { readonly id: string; readonly description?: string }[] /** Absolute path an untrusted path-origin plugin was discovered at. */ readonly originPath?: string + /** + * Standing load warnings attributable to this plugin (skill misses named by + * agent id, failed tool starts, …). Surfaced in the row hint and description. + */ + readonly warnings?: readonly string[] } /** Result of a verify/addPath admin action, reported via `deps.notify`. */ @@ -65,15 +70,9 @@ export type WebProviderChoice = { readonly id: string; readonly name: string } export type CompactionMode = "llm" | "pruning" -/** Where a session-mode write lands: every repo, or just this one. */ -export type SessionModeScope = "global" | "local" - /** Live values behind the settings surface, re-read on every open. */ export type SettingsSnapshot = { readonly compactionMode: CompactionMode - readonly sessionMode: SessionMode - /** Which scope `sessionMode` currently reflects — a local override wins over global. */ - readonly sessionModeScope: SessionModeScope readonly waitForApproval: boolean readonly telemetryEnabled: boolean readonly showPromptCost: boolean @@ -105,6 +104,12 @@ export type PluginsSurfaceDeps = { readonly webProviders: () => readonly WebProviderChoice[] readonly currentWebProvider: () => string | undefined readonly setWebProvider: (id: string | undefined) => Promise<void> | void + /** + * Standing session-level load warnings (or the full set when attribution is + * weak). Shown as a summary row under `/plugins`; drives `plugin !` via the + * runner, not this surface. + */ + readonly loadWarnings?: () => readonly string[] } /** Discovered lifecycle hook, live enablement, and enough to describe what it runs. */ @@ -150,8 +155,6 @@ export type HooksSurfaceSummary = { export type SettingsSurfaceDeps = { readonly read: () => SettingsSnapshot readonly setCompactionMode: (mode: CompactionMode) => void - /** Writes to the given scope: "local" persists to `.corbits/settings.json` in cwd. */ - readonly setSessionMode: (mode: SessionMode, scope: SessionModeScope) => void readonly setWaitForApproval: (value: boolean) => void readonly setTelemetryEnabled: (value: boolean) => void readonly setShowPromptCost: (value: boolean) => void @@ -185,6 +188,8 @@ export type CommandSurfaceKind = const CLOSE_ID = "__close__" const BACK_ID = "__back__" +/** Synthetic `/plugins` row for standing load warnings (not a plugin id). */ +const PLUGIN_LOAD_WARNINGS_ID = "__plugin_load_warnings__" export function grantRowLabel(entry: GrantEntry): string { const suffix = entry.providerModel !== undefined ? ` (${entry.providerModel})` : "" @@ -195,12 +200,18 @@ function pluginMissingCredential(entry: PluginEntry): boolean { return entry.credentials.some((f) => (entry.credentialValues[f.key] ?? "").length === 0) } +function pluginHasWarnings(entry: PluginEntry): boolean { + return (entry.warnings?.length ?? 0) > 0 +} + export function pluginRowLabel(entry: PluginEntry): string { const state = entry.needsTrust === true ? "untrusted" : entry.enabled ? "enabled" : "disabled" const blocker = entry.needsTrust !== true && !entry.enabled && pluginMissingCredential(entry) ? "needs api key" - : entry.kind + : pluginHasWarnings(entry) + ? "has warnings" + : entry.kind return blocker ? `${entry.name} — ${state} — ${blocker}` : `${entry.name} — ${state}` } @@ -217,6 +228,11 @@ function pluginDescription(entry: PluginEntry): ItemDescription { if (!entry.enabled && pluginMissingCredential(entry)) { return { what, impact: "Needs an API key before it can be enabled — press Alt+C." } } + if (pluginHasWarnings(entry) && entry.warnings !== undefined) { + const summary = + formatPluginWarningsSummary(entry.warnings) ?? entry.warnings.join("; ") + return { what, impact: summary, tone: "consequence" } + } return { what } } @@ -268,14 +284,6 @@ const COMPACTION_OPTIONS: readonly CycleOption<CompactionMode>[] = [ { id: "llm", label: "summarize" }, { id: "pruning", label: "drop" }, ] -const SESSION_MODE_OPTIONS: readonly CycleOption<SessionMode>[] = [ - { id: "single", label: "single" }, - { id: "orchestrator", label: "orchestrator" }, -] -const SESSION_SCOPE_OPTIONS: readonly CycleOption<SessionModeScope>[] = [ - { id: "global", label: "everywhere" }, - { id: "local", label: "this repo" }, -] const ON_OFF_OPTIONS: readonly CycleOption<"on" | "off">[] = [ { id: "on", label: "on" }, { id: "off", label: "off" }, @@ -313,35 +321,6 @@ function settingsCycleRows( cycleValue(COMPACTION_OPTIONS.map((o) => o.id), snapshot.compactionMode, dir), ), }, - { - id: "session-mode", - value: `${"session mode".padEnd(SETTINGS_NAME_WIDTH)}${cycleField(SESSION_MODE_OPTIONS, snapshot.sessionMode)}`, - chosenLabel: activeOptionLabel(SESSION_MODE_OPTIONS, snapshot.sessionMode), - describe: { - what: "single agent works in-session; orchestrator delegates through a worker fleet.", - impact: "orchestrator can run sub-agents concurrently and costs more per turn.", - tone: "consequence", - }, - cycle: (dir) => - settings.setSessionMode( - cycleValue(SESSION_MODE_OPTIONS.map((o) => o.id), snapshot.sessionMode, dir), - snapshot.sessionModeScope, - ), - }, - { - id: "session-scope", - value: `${" scope".padEnd(SETTINGS_NAME_WIDTH)}${cycleField(SESSION_SCOPE_OPTIONS, snapshot.sessionModeScope)}`, - chosenLabel: activeOptionLabel(SESSION_SCOPE_OPTIONS, snapshot.sessionModeScope), - describe: { - what: "whether the session mode above applies to every repo or just this one.", - impact: "this repo writes a local override that takes precedence over the global default.", - }, - cycle: (dir) => - settings.setSessionMode( - snapshot.sessionMode, - cycleValue(SESSION_SCOPE_OPTIONS.map((o) => o.id), snapshot.sessionModeScope, dir), - ), - }, { id: "wait-for-approval", value: `${"approval wait".padEnd(SETTINGS_NAME_WIDTH)}${cycleField(ON_OFF_OPTIONS, snapshot.waitForApproval ? "on" : "off")}`, @@ -795,6 +774,14 @@ export function openPluginsSurface(shell: AppShell, deps: CommandSurfaceDeps): v id: e.id, label: pluginRowLabel(e), })) + const loadWarnings = plugins.loadWarnings?.() ?? [] + const loadSummary = formatPluginWarningsSummary(loadWarnings) + if (loadSummary !== undefined) { + rows.unshift({ + id: PLUGIN_LOAD_WARNINGS_ID, + label: loadSummary.replace(/^plugins:\s*/, ""), + }) + } if (rows.length === 0) { rows.push({ id: CLOSE_ID, label: "No plugins discovered" }) } @@ -806,12 +793,19 @@ export function openPluginsSurface(shell: AppShell, deps: CommandSurfaceDeps): v frameId: "overlay-plugins", ...payload(rows), describe: (id) => { + if (id === PLUGIN_LOAD_WARNINGS_ID) { + return { + what: loadSummary ?? "Plugin load warnings.", + impact: "Standing diagnostics from plugin discovery and load. Fix the named skills or plugins, then relaunch.", + tone: "consequence", + } + } const target = byId.get(id) return target === undefined ? null : pluginDescription(target) }, onAccept: (selection) => { const id = selectedId(selection, rows) - if (id === undefined || id === CLOSE_ID) return + if (id === undefined || id === CLOSE_ID || id === PLUGIN_LOAD_WARNINGS_ID) return const target = byId.get(id) if (target === undefined) return if (target.needsTrust === true) { @@ -834,6 +828,7 @@ export function openPluginsSurface(shell: AppShell, deps: CommandSurfaceDeps): v // branch returns before that handler is reached (see shell.ts's // top-level onKey), so exactly one of the two can ever fire. if (key.ctrl || !(key.meta || key.option)) return false + if (id === PLUGIN_LOAD_WARNINGS_ID) return false const target = byId.get(id) if (target === undefined) return false const name = typeof key.name === "string" ? key.name.toLowerCase() : "" diff --git a/src/tui/commands/built-in.test.ts b/src/tui/commands/built-in.test.ts index 67e3894af..67115fb0c 100644 --- a/src/tui/commands/built-in.test.ts +++ b/src/tui/commands/built-in.test.ts @@ -45,11 +45,11 @@ describe("/status command", () => { it("answers from the live fleet without sending anything to the model", () => { const ctx: CommandContext = { signalClear: () => {}, - getFleetStatus: () => "fleet · 2 running (api 1:20, docs 0:04) · 1 done", + getFleetStatus: () => "2 running (api 1:20, docs 0:04) · 1 done", }; expect(getCommand("status")!.handler("", ctx)).toEqual({ type: "message", - text: "fleet · 2 running (api 1:20, docs 0:04) · 1 done", + text: "2 running (api 1:20, docs 0:04) · 1 done", }); }); @@ -83,17 +83,17 @@ describe("/yolo command", () => { }; expect(getCommand("yolo")!.handler("", ctx)).toEqual({ type: "message", - text: "Yolo mode on — permission gate bypassed. Secret-guard and authz hard denies still apply.", + text: "Yolo mode on — permission prompts skipped.", }); expect(skip).toBe(true); expect(getCommand("yolo")!.handler("", ctx)).toEqual({ type: "message", - text: "Yolo mode off — permission gate restored.", + text: "Yolo mode off — permission prompts restored.", }); expect(skip).toBe(false); expect(getCommand("yolo")!.handler("toggle", ctx)).toEqual({ type: "message", - text: "Yolo mode on — permission gate bypassed. Secret-guard and authz hard denies still apply.", + text: "Yolo mode on — permission prompts skipped.", }); expect(skip).toBe(true); }); @@ -109,12 +109,12 @@ describe("/yolo command", () => { }; expect(getCommand("yolo")!.handler("on", ctx)).toEqual({ type: "message", - text: "Yolo mode on — permission gate bypassed. Secret-guard and authz hard denies still apply.", + text: "Yolo mode on — permission prompts skipped.", }); expect(skip).toBe(true); expect(getCommand("yolo")!.handler("off", ctx)).toEqual({ type: "message", - text: "Yolo mode off — permission gate restored.", + text: "Yolo mode off — permission prompts restored.", }); expect(skip).toBe(false); }); diff --git a/src/tui/commands/built-in.ts b/src/tui/commands/built-in.ts index 54c9355db..7c35ed152 100644 --- a/src/tui/commands/built-in.ts +++ b/src/tui/commands/built-in.ts @@ -188,11 +188,10 @@ export function registerBuiltInCommands(): void { }, }); - // Mid-session twin of --dangerously-skip-permissions. Does not bypass - // secret-guard path denies or authorization hard blocks. + // Mid-session twin of --dangerously-skip-permissions. registerCommand({ name: "yolo", - description: "Toggle skip-permissions for this session (gate bypass; secret-guard and authz remain)", + description: "Skip permission prompts for this session", argumentHint: "[on|off|toggle]", subcommands: [ { name: "on", description: "Enable skip-permissions" }, @@ -218,10 +217,10 @@ export function registerBuiltInCommands(): void { if (next) { return { type: "message", - text: "Yolo mode on — permission gate bypassed. Secret-guard and authz hard denies still apply.", + text: "Yolo mode on — permission prompts skipped.", }; } - return { type: "message", text: "Yolo mode off — permission gate restored." }; + return { type: "message", text: "Yolo mode off — permission prompts restored." }; }, }); diff --git a/src/tui/commands/registry.test.ts b/src/tui/commands/registry.test.ts index b11ff4081..bc68caef6 100644 --- a/src/tui/commands/registry.test.ts +++ b/src/tui/commands/registry.test.ts @@ -47,6 +47,22 @@ describe("command registry", () => { expect(idx1).toBeLessThan(idx2); }); + it("skips a command whose name is already registered (first-wins)", () => { + registerCommand({ + name: "first-wins-cmd", + description: "built-in", + handler: () => ({ type: "message", text: "built-in" }), + }); + registerCommand({ + name: "first-wins-cmd", + description: "plugin", + handler: () => ({ type: "message", text: "plugin" }), + }); + const def = getCommand("first-wins-cmd"); + expect(def?.description).toBe("built-in"); + expect(def?.handler("", ctx)).toEqual({ type: "message", text: "built-in" }); + }); + it("invokes handler with args and context", () => { let receivedArgs = ""; let clearCalled = false; diff --git a/src/tui/commands/registry.ts b/src/tui/commands/registry.ts index c9e23d4ea..5f601cf78 100644 --- a/src/tui/commands/registry.ts +++ b/src/tui/commands/registry.ts @@ -69,6 +69,9 @@ const registry = new Map<string, CommandDefinition>(); const hidden = new Set<string>(); export function registerCommand(def: CommandDefinition): void { + // First-wins: built-ins register first, then repo plugins, then marketplace. + // A later plugin must not overwrite /implement (or any other claimed name). + if (registry.has(def.name)) return; registry.set(def.name, def); } diff --git a/src/tui/demo.ts b/src/tui/demo.ts index a3a4c5081..c2e5f3008 100644 --- a/src/tui/demo.ts +++ b/src/tui/demo.ts @@ -5,7 +5,7 @@ * Not the production CLI (`src/index.ts` → OpenTUI shell). Playground only. * * Keys: - * Enter=queue · Alt+Enter=steer · Ctrl+C=stop + * Enter=steer · Alt+Enter=follow-up · Ctrl+C=stop * Alt+C=copy * p=permissions · o=operator · m=model * s=settings · h=help · l=plugins · e=resume · n=mentions · v=observe @@ -220,7 +220,7 @@ renderer.keyInput.on("keypress", (key: KeyEvent) => { setShellRunState(shell, "busy") appendStreamRow(shell, { role: "system", - text: "run → BUSY (queue/steer active)", + text: "run → BUSY (steer/follow-up active)", }) return } diff --git a/src/tui/gate-wire.test.ts b/src/tui/gate-wire.test.ts index 7ce387d5d..e666f366b 100644 --- a/src/tui/gate-wire.test.ts +++ b/src/tui/gate-wire.test.ts @@ -384,7 +384,7 @@ describe("wireGates", () => { }) }) - test("gate content reaches the transcript only after the operator decides", async () => { + test("gate decisions do not replay the request into the transcript", async () => { await withTestRenderer(async (h) => { const shell = createAppShell(h.renderer, { terminal: { columns: 96, rows: 30 }, @@ -401,20 +401,15 @@ describe("wireGates", () => { const dispose = wireGates(emitter, shell) emitter.emit("permission.gate", { request, resolve: () => {} }) - // The overlay is showing this text; a transcript copy directly above it - // reads as a second, unrelated request. expect( shell.streamLog.filter((r) => r.meta === "permission"), ).toHaveLength(0) acceptOverlaySelection(shell) - const recorded = shell.streamLog - .filter((r) => r.meta === "permission") - .map((r) => r.text) - .join("\n") - expect(recorded).toContain("ls -la ~/.corbits/projects") - expect(recorded).toContain("Reject") + expect( + shell.streamLog.filter((r) => r.meta === "permission"), + ).toHaveLength(0) dispose() } finally { @@ -424,7 +419,7 @@ describe("wireGates", () => { }) }) -describe("each gate decision appends exactly one transcript row", () => { +describe("gate decisions stay out of the transcript", () => { test("permission accept", async () => { await withTestRenderer(async (h) => { const shell = createAppShell(h.renderer, { @@ -438,7 +433,7 @@ describe("each gate decision appends exactly one transcript row", () => { const before = shell.streamLog.length acceptOverlaySelection(shell) - expect(shell.streamLog.length - before).toBe(1) + expect(shell.streamLog.length - before).toBe(0) } finally { shell.dispose() } @@ -458,7 +453,7 @@ describe("each gate decision appends exactly one transcript row", () => { const before = shell.streamLog.length closeInsetOverlay(shell) - expect(shell.streamLog.length - before).toBe(1) + expect(shell.streamLog.length - before).toBe(0) } finally { shell.dispose() } @@ -482,7 +477,7 @@ describe("each gate decision appends exactly one transcript row", () => { const before = shell.streamLog.length acceptOverlaySelection(shell) - expect(shell.streamLog.length - before).toBe(1) + expect(shell.streamLog.length - before).toBe(0) } finally { shell.dispose() } @@ -506,7 +501,7 @@ describe("each gate decision appends exactly one transcript row", () => { const before = shell.streamLog.length closeInsetOverlay(shell) - expect(shell.streamLog.length - before).toBe(1) + expect(shell.streamLog.length - before).toBe(0) } finally { shell.dispose() } @@ -546,7 +541,7 @@ describe("each gate decision appends exactly one transcript row", () => { meta: false, option: false, } as unknown as KeyEvent) - expect(shell.streamLog.length - before).toBe(1) + expect(shell.streamLog.length - before).toBe(0) } finally { shell.dispose() } @@ -569,7 +564,7 @@ describe("each gate decision appends exactly one transcript row", () => { timeoutMs: 5, }) await new Promise((r) => setTimeout(r, 20)) - expect(shell.streamLog.length - before).toBe(1) + expect(shell.streamLog.length - before).toBe(0) } finally { shell.dispose() } @@ -593,20 +588,19 @@ describe("each gate decision appends exactly one transcript row", () => { signal: controller.signal, }) controller.abort() - expect(shell.streamLog.length - before).toBe(1) + expect(shell.streamLog.length - before).toBe(0) } finally { shell.dispose() } }) }) - // The queue (settle-once guard) and the transcript recorder (record-once - // per decision) are two independent mechanisms layered on the same set of - // terminal paths. Racing a timeout against an abort on the same request - // exercises both at once: clearTimers must retire the loser before it can - // run autoDeny a second time, so ev.resolve fires exactly once and exactly - // one row lands, no matter which trigger wins. - test("a timeout and an abort racing the same request settle once and record once", async () => { + // The queue's settle-once guard is independent of the transcript: racing a + // timeout against an abort on the same request exercises that guard. + // clearTimers must retire the loser before it can run autoDeny a second + // time, so ev.resolve fires exactly once no matter which trigger wins, and + // neither path writes a recap row. + test("a timeout and an abort racing the same request settle once", async () => { await withTestRenderer(async (h) => { const shell = createAppShell(h.renderer, { terminal: { columns: 80, rows: 24 }, @@ -633,14 +627,14 @@ describe("each gate decision appends exactly one transcript row", () => { controller.abort() expect(resolveCount).toBe(1) - expect(shell.streamLog.length - before).toBe(1) + expect(shell.streamLog.length - before).toBe(0) } finally { shell.dispose() } }) }) - test("a queued gate's timeout settles once and records once, only after it is displayed", async () => { + test("a queued gate's timeout settles once, only after it is displayed", async () => { await withTestRenderer(async (h) => { const shell = createAppShell(h.renderer, { terminal: { columns: 80, rows: 24 }, @@ -673,7 +667,7 @@ describe("each gate decision appends exactly one transcript row", () => { await new Promise((r) => setTimeout(r, 20)) expect(resolveCount).toBe(1) - expect(shell.streamLog.length - before).toBe(2) // first gate's row + the queued gate's timeout row + expect(shell.streamLog.length - before).toBe(0) // first gate + queued timeout both silent } finally { shell.dispose() } @@ -682,8 +676,8 @@ describe("each gate decision appends exactly one transcript row", () => { // reconcile() (src/permission/queue.ts) settles a queued request directly // when a grant covers it, with no accept/cancel/autoDeny callback of its - // own to hang a row on — this is the one terminal path that has no natural - // call site, so it needs its own coverage. + // own. Coverage here is that the queued request still resolves, without + // ever opening and without writing a recap row. test("a grant draining a queued request without ever displaying it", async () => { await withTestRenderer(async (h) => { const shell = createAppShell(h.renderer, { @@ -717,17 +711,14 @@ describe("each gate decision appends exactly one transcript row", () => { expect(resolveCount).toBe(1) expect(resolved).toEqual({ allow: true }) - expect(shell.streamLog.length - before).toBe(1) - expect(shell.streamLog.at(-1)?.text).toContain( - "Auto-approved (already granted)", - ) + expect(shell.streamLog.length - before).toBe(0) } finally { shell.dispose() } }) }) - test("a grant draining the currently displayed request closes it and records once", async () => { + test("a grant draining the currently displayed request closes it without a recap", async () => { await withTestRenderer(async (h) => { const shell = createAppShell(h.renderer, { terminal: { columns: 80, rows: 24 }, @@ -753,10 +744,7 @@ describe("each gate decision appends exactly one transcript row", () => { expect(resolveCount).toBe(1) expect(shell.overlayList).toBeNull() - expect(shell.streamLog.length - before).toBe(1) - expect(shell.streamLog.at(-1)?.text).toContain( - "Auto-approved (already granted)", - ) + expect(shell.streamLog.length - before).toBe(0) } finally { shell.dispose() } @@ -765,9 +753,9 @@ describe("each gate decision appends exactly one transcript row", () => { // drain() (src/permission/queue.ts) denies whatever is still queued on // teardown — the same no-call-site path as a grant drain, but the - // opposite outcome. Mislabeling this "Auto-approved" would tell the - // operator a request ran when it was actually dropped unanswered. - test("disposing with a request still queued records it as denied, not approved", async () => { + // opposite outcome. Coverage is the deny itself; neither path writes a + // recap row. + test("disposing with a request still queued denies it without a recap", async () => { await withTestRenderer(async (h) => { const shell = createAppShell(h.renderer, { terminal: { columns: 80, rows: 24 }, @@ -775,9 +763,8 @@ describe("each gate decision appends exactly one transcript row", () => { }) const emitter = new EventEmitter() // The currently-open request has no accept/cancel/autoDeny call site - // triggered before teardown either, so dispose must record it too — - // both entries go through the same no-call-site fallback as the - // queued one. + // triggered before teardown either, so dispose must settle it too — + // both entries go through drain() without writing a recap. let openResolveCount = 0 let queuedResolveCount = 0 let queuedResolved: unknown @@ -804,11 +791,7 @@ describe("each gate decision appends exactly one transcript row", () => { expect(openResolveCount).toBe(1) expect(queuedResolveCount).toBe(1) expect(queuedResolved).toEqual({ allow: false }) - expect(shell.streamLog.length - before).toBe(2) - for (const row of shell.streamLog.slice(-2)) { - expect(row.text).toContain("Denied (session ended)") - expect(row.text).not.toContain("Auto-approved") - } + expect(shell.streamLog.length - before).toBe(0) shell.dispose() }) }) @@ -1148,7 +1131,7 @@ describe("operator.gate auto-cancel", () => { }) }) - test("each terminal path writes exactly one transcript row", async () => { + test("each terminal path settles without writing a transcript row", async () => { await withTestRenderer(async (h) => { const shell = createAppShell(h.renderer, { terminal: { columns: 80, rows: 24 }, @@ -1165,7 +1148,7 @@ describe("operator.gate auto-cancel", () => { timeoutMs: 5, }) await new Promise((r) => setTimeout(r, 20)) - expect(shell.streamLog.length - before).toBe(1) + expect(shell.streamLog.length - before).toBe(0) } finally { shell.dispose() } @@ -1208,10 +1191,7 @@ describe("operator.gate auto-cancel", () => { expect(openResolved).toEqual(operatorCancelResult()) expect(queuedResolved).toEqual(operatorCancelResult()) - expect(shell.streamLog.length - before).toBe(2) - for (const row of shell.streamLog.slice(-2)) { - expect(row.text).toContain("Cancelled (session ended)") - } + expect(shell.streamLog.length - before).toBe(0) shell.dispose() }) }) diff --git a/src/tui/gate-wire.ts b/src/tui/gate-wire.ts index d1a27aa4a..8b1341a28 100644 --- a/src/tui/gate-wire.ts +++ b/src/tui/gate-wire.ts @@ -6,7 +6,7 @@ import type { EventEmitter } from "node:events" import type { OperatorResult } from "../agent/tools.js" -import { formatCommandForApproval, middleEllipsis } from "./command-display.js" +import { formatCommandForApproval } from "./command-display.js" import { openOperatorOverlay, openPermissionsOverlay } from "./overlays.js" import type { ApprovalOutcome, @@ -197,82 +197,6 @@ export function operatorCustomResult(text: string): OperatorResult { return { kind: "custom", text } } -/** Label of the choice a selection lands on, or null when it maps to nothing. */ -function chosenLabel( - choices: PermissionGateChoices, - selection: GateSelection, -): string | null { - if (selection.id !== undefined) { - const byId = choices.itemIds.indexOf(selection.id) - if (byId >= 0) return choices.items[byId] ?? null - } - return choices.items[selection.index] ?? null -} - -/** - * Write the ask and the answer to the transcript, once the operator has - * decided. Deferred rather than emitted at gate time: while the overlay is up - * it is already showing this text directly below the row, and printing it - * twice reads as two separate requests. Scrollback still ends up complete. - */ -function recordDecision( - shell: AppShell, - request: PermissionRequest, - choices: PermissionGateChoices, - selection: GateSelection, -): void { - // Collapsing runs first, so this cap rarely bites; when it does, - // middleEllipsis keeps the tail of the chain visible instead of clipping the - // last segments away entirely. - const body = middleEllipsis(permissionBodyFromRequest(request), 500) - const label = chosenLabel(choices, selection) - const text = label === null ? body : `${body}\n→ ${label}` - if (text.length === 0) return - appendStreamRow(shell, { role: "system", text, meta: "permission" }) -} - -/** - * Write a row for a request settled with no accept/cancel/autoDeny call site - * of its own to hang a record onto: reconcile() (a newly-minted grant - * covering this queued request) and drain() (session teardown denying - * whatever is still queued) both settle the queue entry directly. Every - * other terminal path (accept, Esc, timeout, abort) already writes its own - * row at its own call site. Without this, the operator's only trace of the - * highest-consequence event in the queue — a request that ran, or was - * dropped, without ever being shown — is the transient grant-recorded flash - * (nothing at all for teardown), gone once it scrolls off. - */ -function recordSilentSettle( - shell: AppShell, - request: PermissionRequest, - outcome: ApprovalOutcome, -): void { - const body = middleEllipsis(permissionBodyFromRequest(request), 500) - const label = outcome.allow - ? "Auto-approved (already granted)" - : "Denied (session ended)" - appendStreamRow(shell, { - role: "system", - text: `${body}\n→ ${label}`, - meta: "permission", - }) -} - -/** - * Write the operator's question and answer to the transcript, once decided. - * Mirrors recordDecision: the overlay already shows this text while it is - * open, so an immediate echo would print every operator question twice. - */ -function recordOperatorDecision( - shell: AppShell, - question: string, - label: string, -): void { - const body = middleEllipsis(question, 500) - const text = `${body}\n→ ${label}` - appendStreamRow(shell, { role: "system", text, meta: "operator" }) -} - /** * Blocked-ness is domain state, not a paint detail: the turn watchdog and the * painter both need to know a gate is outstanding, whether or not it has @@ -402,31 +326,16 @@ export function wireGates( // re-invokes the overlay's own onCancel (see shell.ts's // closeInsetOverlay, which fires onCancel after notifying close // listeners) — settle's return value is how a call site tells that - // reentrant call apart from the original one, so recordDecision below - // fires exactly once per gate instead of once per reentry. + // reentrant call apart from the original one. const settle = (outcome: ApprovalOutcome): boolean => permissionQueue.settle(id, outcome) - // Set immediately before every call to settle() from a known call site - // (accept, Esc, autoDeny), each of which writes its own row right after. - // reconcile() and drain() (src/permission/queue.ts) both settle an entry - // directly, with no call site of their own — the resolve callback below - // falls back to recordSilentSettle whenever this is still false, so a - // request that ran, or was dropped, without ever being shown still - // leaves a trace. - let recorded = false const id = permissionQueue.enqueue(ev.request, (outcome) => { clearTimers() - // Captured before closeInsetOverlay below, which — when this entry is - // the one on screen — reentrantly invokes this same overlay's onCancel - // (see the comment on `settle` above) and would otherwise set - // `recorded` out from under this check before it runs. - const needsSilentSettleRecord = !recorded if (openedGeneration === undefined) { unqueue(open) } else if (openedGeneration === overlayGeneration) { closeInsetOverlay(shell) } - if (needsSilentSettleRecord) recordSilentSettle(shell, ev.request, outcome) resolve(outcome) }) @@ -459,9 +368,8 @@ export function wireGates( items: choices.items, itemIds: choices.itemIds, body: collapsedBody, - // recordDecision below is the authoritative transcript row for every - // terminal path — the overlay's own accept/answer echo would - // duplicate it. + // The overlay is the question. A settled gate must not replay the + // ask — or the overlay's generic accept echo — into the transcript. echoChoice: false, ...(collapsedAnything ? { onToggleExpand } : {}), onAccept: (sel: OverlaySelection) => { @@ -469,19 +377,17 @@ export function wireGates( index: sel.index, ...(sel.id !== undefined ? { id: sel.id } : {}), } - recorded = true - if (settle(approvalOutcomeFromSelection(choices, gateSelection))) { - recordDecision(shell, ev.request, choices, gateSelection) - } + settle(approvalOutcomeFromSelection(choices, gateSelection)) }, // Esc must settle the awaited promise (as a deny), not abandon it — // an unresolved gate hangs the run until the process is killed. onCancel: () => { - const gateSelection = { index: 0, id: PERMISSION_DENY_ID } - recorded = true - if (settle(approvalOutcomeFromSelection(choices, gateSelection))) { - recordDecision(shell, ev.request, choices, gateSelection) - } + settle( + approvalOutcomeFromSelection(choices, { + index: 0, + id: PERMISSION_DENY_ID, + }), + ) }, }) } @@ -505,13 +411,7 @@ export function wireGates( ev.signal?.removeEventListener("abort", onAbort) } const autoDeny = (message: string): void => { - recorded = true - if (settle({ allow: false, message })) { - recordDecision(shell, ev.request, choices, { - index: 0, - id: PERMISSION_DENY_ID, - }) - } + settle({ allow: false, message }) } function onAbort(): void { autoDeny("tool no longer running; permission request denied") @@ -556,23 +456,21 @@ export function wireGates( openedGeneration = overlayGeneration if (ev.timeoutMs !== undefined) { timer = setTimeout(() => { - autoCancel(ev.timeoutMessage ?? "Cancelled (timed out)") + autoCancel() }, ev.timeoutMs) } openOperatorOverlay(shell, { body: ev.question, choices: choices.items, itemIds: choices.itemIds, - // recordOperatorDecision below is the authoritative transcript row - // for every terminal path — the overlay's own accept/answer echo - // would duplicate it. + // The overlay is the question. A settled gate must not replay the + // ask — or the overlay's generic accept echo — into the transcript. echoChoice: false, onAccept: (sel: OverlaySelection) => { if (settled) return settled = true clearTimers() operatorTeardowns.delete(teardown) - recordOperatorDecision(shell, ev.question, sel.label) resolve( operatorResultFromSelection(ev.options, { index: sel.index, @@ -587,7 +485,6 @@ export function wireGates( settled = true clearTimers() operatorTeardowns.delete(teardown) - recordOperatorDecision(shell, ev.question, text) resolve(operatorCustomResult(text)) }, // Esc must settle the awaited promise (as a cancel), not abandon it — @@ -601,13 +498,12 @@ export function wireGates( settled = true clearTimers() operatorTeardowns.delete(teardown) - recordOperatorDecision(shell, ev.question, "Cancelled") resolve(operatorCancelResult()) }, }) } - const settleOnce = (label: string, result: OperatorResult): void => { + const settleOnce = (result: OperatorResult): void => { if (settled) return settled = true clearTimers() @@ -617,20 +513,19 @@ export function wireGates( } else if (openedGeneration === overlayGeneration) { closeInsetOverlay(shell) } - recordOperatorDecision(shell, ev.question, label) resolve(result) } - const autoCancel = (label: string): void => { - settleOnce(label, operatorCancelResult()) + const autoCancel = (): void => { + settleOnce(operatorCancelResult()) } const teardown = (): void => { - autoCancel("Cancelled (session ended)") + autoCancel() } function onAbort(): void { - autoCancel("Cancelled (tool no longer running)") + autoCancel() } if (ev.signal?.aborted === true) { - autoCancel("Cancelled (tool no longer running)") + autoCancel() return } ev.signal?.addEventListener("abort", onAbort, { once: true }) diff --git a/src/tui/geometry.test.ts b/src/tui/geometry.test.ts index 0c6e0f8d8..4c0e8ec1b 100644 --- a/src/tui/geometry.test.ts +++ b/src/tui/geometry.test.ts @@ -72,6 +72,11 @@ describe("resolveGeometry — 80×24 idle floor", () => { expect(layout.regions.transcript?.height).toBe(24 - PROMPT_IDLE_ROWS); expect(layout.overlayHeight).toBe(0); expect(layout.overlayMode).toBe("closed"); + // Stack-only: full-width chat, no rail. + expect(layout.layoutMode).toBe("stack"); + expect(layout.chatWidth).toBe(layout.contentWidth); + expect(layout.railWidth).toBe(0); + expect(layout.railGutter).toBe(0); }); test("rects sit inside the gutter and y-stack without gaps or overlap", () => { @@ -101,12 +106,18 @@ describe("resolveGeometry — 80×24 idle floor", () => { }); describe("resolveGeometry — agents panel", () => { - test("the board is sized to its content, one row per lane", () => { - // Small boards get exactly what they ask for; only once the transcript - // floor is at risk does the board start giving rows back. - for (let n = 0; n <= 6; n++) { + test("agents zone max allows more than one row again", () => { + expect(ZONE_REGISTRY.agents.max).toBe(AGENTS_PANEL_MAX_VISIBLE + 2); + for (let n = 0; n <= AGENTS_PANEL_MAX_VISIBLE + 3; n++) { const layout = idle80x24({ visibility: { agents: n } }); - expect(layout.heights.agents).toBe(n); + const fracCap = Math.max(1, Math.floor(24 * FLEET_BOARD_CAP_FRACTION)); + const expected = Math.min(n, ZONE_REGISTRY.agents.max, fracCap); + // Collapse may shrink further to protect the transcript floor. + expect(layout.heights.agents).toBeLessThanOrEqual(expected); + if (n <= 3) { + // Small requests fit under the floor without collapse. + expect(layout.heights.agents).toBe(n); + } } }); @@ -114,6 +125,8 @@ describe("resolveGeometry — agents panel", () => { const layout = idle80x24({ visibility: { agents: 0 } }); expect(layout.heights.agents).toBe(0); expect(layout.regions.agents).toBeUndefined(); + expect(layout.layoutMode).toBe("stack"); + expect(layout.railWidth).toBe(0); }); test("a large fan-out never grows the board without bound", () => { @@ -127,23 +140,28 @@ describe("resolveGeometry — agents panel", () => { expect(layout.transcriptHeight).toBeGreaterThanOrEqual(layout.transcriptFloor); }); - test("a taller terminal gives the board room for a bigger fleet", () => { + test("a taller terminal honours the agents row request (stack)", () => { const tall = resolveGeometry({ terminal: { columns: 120, rows: 40 }, visibility: { agents: 14 }, transcriptFloor: FLEET_TRANSCRIPT_FLOOR, }); - // A dozen lanes plus a header fit on a 40-row terminal without hiding any. + expect(tall.layoutMode).toBe("stack"); + expect(tall.railWidth).toBe(0); expect(tall.heights.agents).toBe(14); + expect(tall.regions.agents?.width).toBe(tall.contentWidth); + // Stack: agents sit below transcript and consume vertical chrome. + expect(tall.regions.agents!.y).toBeGreaterThan(tall.regions.transcript!.y); }); - test("with a fleet running the transcript yields its idle floor to the board", () => { + test("with a fleet running the agents zone stacks under the transcript", () => { const fleet = resolveGeometry({ terminal: { columns: 80, rows: 24 }, - visibility: { agents: 13 }, + visibility: { agents: 1 }, transcriptFloor: FLEET_TRANSCRIPT_FLOOR, }); - expect(fleet.heights.agents).toBe(13); + expect(fleet.layoutMode).toBe("stack"); + expect(fleet.heights.agents).toBe(1); expect(fleet.transcriptHeight).toBeGreaterThanOrEqual(FLEET_TRANSCRIPT_FLOOR); // The prompt box never leaves the screen, whatever the fleet is doing. expect(fleet.heights.prompt).toBeGreaterThanOrEqual(PROMPT_BASE_ROWS); @@ -204,6 +222,26 @@ describe("resolveGeometry — task panel", () => { expect(layout.regions.task).not.toEqual(layout.regions.agents); }); + test("orchestration chrome stacks below the transcript and above the prompt", () => { + // Visual order top → bottom: transcript, agents, task, prompt. + // Agents sit above the task list; both sit in the bottom chrome, not + // above the conversation residual. + const layout = idle80x24({ + visibility: { task: 3, agents: 1 }, + }); + const transcript = layout.regions.transcript; + const agents = layout.regions.agents; + const task = layout.regions.task; + const prompt = layout.regions.prompt; + expect(transcript).toBeDefined(); + expect(agents).toBeDefined(); + expect(task).toBeDefined(); + expect(prompt).toBeDefined(); + expect(transcript!.y).toBeLessThan(agents!.y); + expect(agents!.y).toBeLessThan(task!.y); + expect(task!.y).toBeLessThan(prompt!.y); + }); + test("under pressure the task panel shrinks one row at a time rather than vanishing in one step", () => { const layout = resolveGeometry({ terminal: { columns: 80, rows: 20 }, @@ -435,6 +473,10 @@ describe("resolveGeometry — resize / residual", () => { expect(layout.transcriptHeight).toBeGreaterThanOrEqual(IDLE_TRANSCRIPT_FLOOR); expect(layout.chromeHeight).toBe(PROMPT_IDLE_ROWS); expect(layout.transcriptHeight).toBe(40 - PROMPT_IDLE_ROWS); + // Idle has no agents → stack even on a wide terminal. + expect(layout.layoutMode).toBe("stack"); + expect(layout.railWidth).toBe(0); + expect(layout.chatWidth).toBe(layout.contentWidth); }); test("does not read process.stdout — pure input only", () => { @@ -447,3 +489,74 @@ describe("resolveGeometry — resize / residual", () => { expect(sum).toBe(18); }); }); + +describe("resolveGeometry — stack-only layout", () => { + test("wide terminal with agents still stacks full-width, railWidth 0", () => { + const layout = resolveGeometry({ + terminal: { columns: 120, rows: 32 }, + visibility: { agents: 8 }, + }); + expect(layout.layoutMode).toBe("stack"); + expect(layout.railWidth).toBe(0); + expect(layout.railGutter).toBe(0); + expect(layout.chatWidth).toBe(layout.contentWidth); + + const transcript = layout.regions.transcript; + const agents = layout.regions.agents; + const prompt = layout.regions.prompt; + expect(transcript).toBeDefined(); + expect(agents).toBeDefined(); + expect(prompt).toBeDefined(); + + // Agents strip sits below transcript, full content width. + expect(agents!.y).toBeGreaterThan(transcript!.y); + expect(transcript!.width).toBe(layout.contentWidth); + expect(agents!.width).toBe(layout.contentWidth); + expect(agents!.height).toBe(layout.heights.agents); + expect(prompt!.width).toBe(layout.contentWidth); + expect(prompt!.x).toBe(layout.sideMargin); + }); + + test("agents height reduces transcript vs idle baseline (stack chrome)", () => { + const withAgents = resolveGeometry({ + terminal: { columns: 120, rows: 32 }, + visibility: { agents: 8 }, + }); + const idle = resolveGeometry({ + terminal: { columns: 120, rows: 32 }, + visibility: { agents: 0 }, + }); + expect(withAgents.layoutMode).toBe("stack"); + expect(idle.layoutMode).toBe("stack"); + expect(withAgents.heights.agents).toBe(8); + expect(withAgents.chromeHeight).toBe(idle.chromeHeight + 8); + expect(withAgents.transcriptHeight).toBe(idle.transcriptHeight - 8); + }); + + test("narrow terminal with agents → stack, full-width regions, railWidth 0", () => { + const layout = resolveGeometry({ + terminal: { columns: 80, rows: 24 }, + visibility: { agents: 5 }, + }); + expect(layout.layoutMode).toBe("stack"); + expect(layout.railWidth).toBe(0); + expect(layout.railGutter).toBe(0); + expect(layout.chatWidth).toBe(layout.contentWidth); + expect(layout.regions.transcript?.width).toBe(layout.contentWidth); + expect(layout.regions.agents?.width).toBe(layout.contentWidth); + expect(layout.regions.agents!.y).toBeGreaterThan(layout.regions.transcript!.y); + expect(layout.chromeHeight).toBeGreaterThan(PROMPT_IDLE_ROWS); + }); + + test("no agents → stack with railWidth 0 even on a wide terminal", () => { + const layout = resolveGeometry({ + terminal: { columns: 120, rows: 40 }, + visibility: { agents: 0 }, + }); + expect(layout.layoutMode).toBe("stack"); + expect(layout.railWidth).toBe(0); + expect(layout.railGutter).toBe(0); + expect(layout.chatWidth).toBe(layout.contentWidth); + expect(layout.regions.agents).toBeUndefined(); + }); +}); diff --git a/src/tui/geometry/index.ts b/src/tui/geometry/index.ts index b93038911..9a88956e4 100644 --- a/src/tui/geometry/index.ts +++ b/src/tui/geometry/index.ts @@ -40,6 +40,7 @@ export { resolveGeometry, type GeometryInput, type GeometryLayout, + type LayoutMode, type OverlayInput, type OverlayMode, type Rect, diff --git a/src/tui/geometry/resolve.ts b/src/tui/geometry/resolve.ts index 03b72a0c7..5f0f4af5f 100644 --- a/src/tui/geometry/resolve.ts +++ b/src/tui/geometry/resolve.ts @@ -82,6 +82,9 @@ export type Rect = { readonly height: number; }; +/** Full-width y-stack only. Kept for API stability with shell callers. */ +export type LayoutMode = "stack"; + export type GeometryLayout = { readonly terminal: TerminalSize; readonly transcriptHeight: number; @@ -100,6 +103,14 @@ export type GeometryLayout = { readonly sideMargin: number; /** Zone width after both gutters. */ readonly contentWidth: number; + /** Always `"stack"` — dual-column rail was removed. */ + readonly layoutMode: LayoutMode; + /** Transcript / chat column width. Equals contentWidth. */ + readonly chatWidth: number; + /** Always 0 — no fleet rail. */ + readonly railWidth: number; + /** Always 0 — no fleet rail gutter. */ + readonly railGutter: number; }; type MutableHeights = Record<ZoneId, number>; @@ -171,6 +182,7 @@ export function desiredHeights(input: GeometryInput): MutableHeights { return heights; } +/** Vertical chrome budget: every non-residual zone. */ function sumChrome(heights: MutableHeights): number { let total = 0; for (const id of PAINT_ORDER) { @@ -220,7 +232,10 @@ function desiredOverlayHeight( * One collapse step: reduce the next collapsible zone. * Returns the zone id that was reduced, or null if nothing left to cut. */ -function collapseOnce(heights: MutableHeights, collapsed: ZoneId[]): ZoneId | null { +function collapseOnce( + heights: MutableHeights, + collapsed: ZoneId[], +): ZoneId | null { for (const id of COLLAPSE_ORDER) { const h = heights[id]; if (h <= 0) continue; @@ -296,7 +311,7 @@ function assignRects( ): Partial<Record<ZoneId, Rect>> { const regions: Partial<Record<ZoneId, Rect>> = {}; const x = resolveSideMargin(terminal.columns); - const width = resolveContentWidth(terminal.columns); + const contentWidth = resolveContentWidth(terminal.columns); let y = 0; for (const id of PAINT_ORDER) { const height = heights[id]; @@ -304,7 +319,7 @@ function assignRects( regions[id] = { x, y, - width, + width: contentWidth, height, }; y += height; @@ -315,6 +330,7 @@ function assignRects( /** * Resolve shell region rects from terminal size, optional chrome, and overlay mode. * Pure: no I/O. Extra terminal rows accrue to the transcript residual. + * Layout is always a full-width y-stack (`layoutMode: "stack"`). */ export function resolveGeometry(input: GeometryInput): GeometryLayout { const terminal = { @@ -328,6 +344,9 @@ export function resolveGeometry(input: GeometryInput): GeometryLayout { : Math.max(0, Math.floor(input.transcriptFloor)); const heights = desiredHeights({ ...input, terminal }); const collapsed: ZoneId[] = []; + const contentWidth = resolveContentWidth(terminal.columns); + const sideMargin = resolveSideMargin(terminal.columns); + const layoutMode: LayoutMode = "stack"; // Full-shell modal: hide transcript and bottom chrome; overlay owns residual. if (mode === "full_shell") { @@ -354,8 +373,12 @@ export function resolveGeometry(input: GeometryInput): GeometryLayout { collapsed, overlayMode: mode, transcriptFloor: floor, - sideMargin: resolveSideMargin(terminal.columns), - contentWidth: resolveContentWidth(terminal.columns), + sideMargin, + contentWidth, + layoutMode, + chatWidth: contentWidth, + railWidth: 0, + railGutter: 0, }; } @@ -404,7 +427,10 @@ export function resolveGeometry(input: GeometryInput): GeometryLayout { chrome, 0, ); - heights.transcript = Math.max(0, terminal.rows - sumChrome(heights) - heights.overlay_host); + heights.transcript = Math.max( + 0, + terminal.rows - sumChrome(heights) - heights.overlay_host, + ); break; } } @@ -415,8 +441,7 @@ export function resolveGeometry(input: GeometryInput): GeometryLayout { heights.transcript = Math.max(0, terminal.rows - chromeHeight - overlayHeight); // Reclaim any rounding leftover into transcript only (never chrome). - const assigned = - chromeHeight + overlayHeight + heights.transcript; + const assigned = chromeHeight + overlayHeight + heights.transcript; if (assigned < terminal.rows) { heights.transcript += terminal.rows - assigned; } @@ -433,7 +458,11 @@ export function resolveGeometry(input: GeometryInput): GeometryLayout { collapsed, overlayMode: mode, transcriptFloor: floor, - sideMargin: resolveSideMargin(terminal.columns), - contentWidth: resolveContentWidth(terminal.columns), + sideMargin, + contentWidth, + layoutMode, + chatWidth: contentWidth, + railWidth: 0, + railGutter: 0, }; } diff --git a/src/tui/geometry/zones.ts b/src/tui/geometry/zones.ts index 030de6f23..af58b765b 100644 --- a/src/tui/geometry/zones.ts +++ b/src/tui/geometry/zones.ts @@ -105,10 +105,9 @@ export const ZONE_REGISTRY: { readonly [K in ZoneId]: ZoneDeclaration } = { idleDefault: 0, alwaysOn: false, }, - // A leading fleet-summary row, one row per running agent (bounded by - // AGENTS_PANEL_MAX_VISIBLE), then an optional trailing "+N more" row. All - // three must fit: clipping the last one drops the fold-away count at exactly - // the fan-out size where it is the only thing reporting the hidden lanes. + // Live agents strip under the transcript when present (max = visible lanes + + // trailing "+N more" + header slack). Live chrome keeps this zone empty — + // fleet status paints as ● Task transcript rows instead. agents: { id: "agents", min: 0, @@ -208,13 +207,14 @@ export const COLLAPSE_ORDER = [ /** * Top-to-bottom paint order for y-stacked rects. - * Transcript is residual in the middle; the prompt box is the last thing painted. + * Transcript is residual at the top; orchestration chrome (agents, task) sits + * at the bottom above the prompt, with notice closest to the prompt box. */ export const PAINT_ORDER = [ - "task", - "agents", "transcript", "overlay_host", + "agents", + "task", "plugin_banner", "command_banner", "settings_notice", diff --git a/src/tui/keybindings.test.ts b/src/tui/keybindings.test.ts index 5a68e64a2..02dc95d4a 100644 --- a/src/tui/keybindings.test.ts +++ b/src/tui/keybindings.test.ts @@ -312,11 +312,12 @@ const PROBES: Readonly<Record<string, { readonly group: Group; readonly probe: P group: "surfaces", probe: ({ h, shell, chords }) => { setChromeZones(shell, { task: [{ label: "a", status: "todo" }] }) - expect(shell.taskBox.visible).toBe(true) - press(h, chords[0]) + // CL-5847: hidden by default — first press shows, second hides. expect(shell.taskBox.visible).toBe(false) press(h, chords[0]) expect(shell.taskBox.visible).toBe(true) + press(h, chords[0]) + expect(shell.taskBox.visible).toBe(false) }, }, "Alt+O": { @@ -463,17 +464,15 @@ const PROBES: Readonly<Record<string, { readonly group: Group; readonly probe: P setShellRunState(shell, "idle") shell.prompt.value = "not yet" press(h, chords[0]) - // The stated condition: idle, Alt+Enter does nothing — there's no run - // to stop and nothing to restart from a boundary that isn't coming. + // Idle Alt+Enter is a no-op — follow-up only makes sense mid-run. expect(sent).toEqual([]) expect(shell.prompt.value).toBe("not yet") - // Busy: a distinct gesture from plain Enter (queue-and-steer at the - // next boundary) — this one is "reinject", resolved by the bridge to - // stop the run right now and restart from this message. + // Busy: follow-up (kind "queue"), not reinject. Delivered only when + // the run goes idle; never interrupts. setShellRunState(shell, "busy") press(h, chords[0]) - expect(sent).toEqual([{ text: "not yet", kind: "reinject" }]) + expect(sent).toEqual([{ text: "not yet", kind: "queue" }]) expect(shell.prompt.value).toBe("") setShellRunState(shell, "idle") }, @@ -513,7 +512,7 @@ const PROBES: Readonly<Record<string, { readonly group: Group; readonly probe: P expect(shell.pendingQueue).toBe(1) expect(shell.session.items[0]!.text).toBe("keep me") const notice = shell.streamLog[shell.streamLog.length - 1] - expect(notice?.text).toBe("interrupt — 1 pending kept") + expect(notice?.text).toBe("1 pending kept") expect(notice?.text).not.toContain("discarded") // Other probes in this group share one shell — leave both the queue // and the transcript as this probe found them. @@ -823,4 +822,3 @@ describe("help stays reachable as a command", () => { } }) }) - diff --git a/src/tui/keybindings.ts b/src/tui/keybindings.ts index 6037d1350..429eece6a 100644 --- a/src/tui/keybindings.ts +++ b/src/tui/keybindings.ts @@ -20,14 +20,14 @@ export type ShellShortcut = { } export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [ - { keys: "Enter", description: "queue the message to steer at the next turn boundary (badge); send straight through when idle" }, - { keys: "Alt+Enter", description: "stop the run right now and restart from this message, without waiting for a boundary; does nothing unless a run is busy" }, + { keys: "Enter", description: "soft-steer at the next tool boundary while busy (badge); send straight through when idle" }, + { keys: "Alt+Enter", description: "queue a follow-up delivered only when the run goes idle; does nothing unless a run is busy" }, { keys: "Ctrl+C", description: "interrupt the run, or clear the prompt when idle; press twice to exit" }, { keys: "Ctrl+G", description: "cancel the most recently queued or steered message before it dispatches" }, { keys: "Alt+C", description: "copy mode: pick a message, tool output, or diff; press again to close it" }, { keys: "Alt+M", description: "toggle DEC mouse capture (on by default: wheel scroll, click-to-expand, drag-to-copy); off restores native terminal drag-select" }, { keys: "Alt+E", description: "expand or collapse every collapsible row (tool call, diff, skill, reasoning)" }, - { keys: "Alt+T", description: "show or hide the task list above the prompt" }, + { keys: "Alt+T", description: "show or hide the task list above the prompt (hidden by default)" }, { keys: "Alt+O", description: "observe a live subagent session; a system row says so when there is none" }, { keys: "Tab", description: "move focus between the prompt and the transcript" }, { keys: "Shift+Tab", description: "cycle reasoning effort for the current model" }, diff --git a/src/tui/landing.test.ts b/src/tui/landing.test.ts index 5978f3adf..acd56b733 100644 --- a/src/tui/landing.test.ts +++ b/src/tui/landing.test.ts @@ -14,11 +14,14 @@ import { noticeText, paintChrome, setChromeZones, + setPluginNeedsAttention, + setPromptModelLabel, setPromptWorkspace, isLanding, paintLanding, streamRowCount, surfaceSystemNotice, + toggleTasksPanel, } from "./shell" import { makeOperatorQuestion, openOperatorOverlay } from "./overlays" import { @@ -625,12 +628,10 @@ describe("landing screen", () => { }, SIZE) }) - test("startup plugin diagnostics keep the mountain too", async () => { - // CL-5718: CL-5618 routed MCP and hook notices away from the transcript - // but left plugin diagnostics going through the runner's own system-row - // helper, so any missing skill wiped the whole hero on load. The flush is - // a named seam now precisely so no producer of a startup diagnostic gets - // to decide this again. + test("startup plugin diagnostics keep the mountain and ride plugin !", async () => { + // Plugin load warnings no longer go through surfaceSystemNotice — they + // drive the standing `plugin !` attention mark instead. The mountain must + // still stay up while that mark is painted. await withTestRenderer(async (h) => { const shell = createAppShell(h.renderer, { terminal: { columns: 80, rows: 24 }, @@ -643,14 +644,18 @@ describe("landing screen", () => { const before = markRows(h) expect(before.length).toBeGreaterThan(0) - const summary = "plugins: 3 skills missing: brand-identity, style, philosophy" - surfaceSystemNotice(shell, summary) + setPromptModelLabel(shell, { profile: "xai", model: "grok" }) + setPluginNeedsAttention(shell, true) await settle(h) expect(isLanding(shell)).toBe(true) expect(markRows(h).length).toBe(before.length) expect(streamRowCount(shell)).toBe(0) - expect(noticeText(shell)).toContain("3 skills missing") + expect(noticeText(shell)).toBe("") + expect(shell.pluginNeedsAttention).toBe(true) + const frame = h.captureCharFrame() + expect(frame).toContain("plugin !") + expect(frame).not.toContain("skills missing") } finally { shell.dispose() } @@ -660,7 +665,8 @@ describe("landing screen", () => { test("a flushed startup notice never carries a plumbing gutter label", async () => { // The transcript must never label a row "command": a system row's text // already says what it is, and the meta column is the operator's, not the - // wiring's. + // wiring's. (MCP notices still use the notice strip; plugin skill-miss + // summaries do not.) await withTestRenderer(async (h) => { const shell = createAppShell(h.renderer, { terminal: { columns: 80, rows: 24 }, @@ -669,13 +675,16 @@ describe("landing screen", () => { }) try { await settle(h) - surfaceSystemNotice(shell, "plugins: 1 skill missing: style") + surfaceSystemNotice( + shell, + "mcp github did not connect (ECONNREFUSED) — its tools are unavailable; /mcp for detail", + ) appendStreamRow(shell, { role: "user", text: "first prompt" }) await settle(h) expect(isLanding(shell)).toBe(false) const frame = h.captureCharFrame() - expect(frame).toContain("1 skill missing") + expect(frame).toContain("mcp github did not connect") expect(frame).not.toContain("command") expect(frame).not.toContain("overlay") } finally { @@ -775,6 +784,10 @@ describe("landing screen", () => { setChromeZones(shell, { task: [{ label: "wire the version badge", status: "doing" }], }) + // CL-5847: hidden by default — opt in so the regression case (task + // row + version badge competing for the same short terminal) still + // exercises both painting at once. + toggleTasksPanel(shell) await settle(h) expect(isLanding(shell)).toBe(true) diff --git a/src/tui/model-catalog.test.ts b/src/tui/model-catalog.test.ts index d61d659c5..e6cf8340c 100644 --- a/src/tui/model-catalog.test.ts +++ b/src/tui/model-catalog.test.ts @@ -14,9 +14,9 @@ describe("buildModelCatalog", () => { { name: "openai", models: ["gpt-4.1"] }, ]) expect(options).toEqual([ - { id: "xai:grok-4", label: "xAI / grok-4" }, - { id: "xai:grok-3", label: "xAI / grok-3" }, - { id: "openai:gpt-4.1", label: "openai / gpt-4.1" }, + { id: "xai:grok-4", label: "grok-4 * [xAI]" }, + { id: "xai:grok-3", label: "grok-3 * [xAI]" }, + { id: "openai:gpt-4.1", label: "gpt-4.1 * [openai]" }, ]) }) @@ -26,9 +26,9 @@ describe("buildModelCatalog", () => { zen: { models: ["claude-sonnet-4-5"], label: "Zen" }, }) expect(options).toEqual([ - { id: "fp:fp-small", label: "fp / fp-small" }, - { id: "fp:fp-large", label: "fp / fp-large" }, - { id: "zen:claude-sonnet-4-5", label: "Zen / claude-sonnet-4-5" }, + { id: "fp:fp-small", label: "fp-small * [fp]" }, + { id: "fp:fp-large", label: "fp-large * [fp]" }, + { id: "zen:claude-sonnet-4-5", label: "claude-sonnet-4-5 * [Zen]" }, ]) }) @@ -38,14 +38,14 @@ describe("buildModelCatalog", () => { { name: "empty", models: [] }, { name: "blank", models: [" ", "keep"] }, ]), - ).toEqual([{ id: "blank:keep", label: "blank / keep" }]) + ).toEqual([{ id: "blank:keep", label: "keep * [blank]" }]) }) test("dedupes by provider:model id", () => { const options = buildModelCatalog([ { name: "xai", models: ["grok-4", "grok-4"] }, ]) - expect(options).toEqual([{ id: "xai:grok-4", label: "xai / grok-4" }]) + expect(options).toEqual([{ id: "xai:grok-4", label: "grok-4 * [xai]" }]) }) test("empty input yields empty catalog", () => { @@ -193,14 +193,14 @@ describe("buildModelsFirstCatalog", () => { recent: [], favorites: [], }) - expect(list[0]?.label).toBe("custom / m1") + expect(list[0]?.label).toBe("m1 * [custom]") }) }) describe("describeModelCatalogOption", () => { test("surfaces the Go-on-Zen billing warning as a consequence-toned impact, not the label", () => { const description = describeModelCatalogOption( - { id: "zen:kimi-k2.7-code", label: "OpenCode Zen / kimi-k2.7-code", warning: "Go model on Zen path" }, + { id: "zen:kimi-k2.7-code", label: "kimi-k2.7-code * [OpenCode Zen]", warning: "Go model on Zen path" }, { pricing: null }, ) expect(description?.tone).toBe("consequence") @@ -209,7 +209,7 @@ describe("describeModelCatalogOption", () => { test("reports pricing as unknown rather than inventing a number", () => { const description = describeModelCatalogOption( - { id: "xai:grok-4", label: "xAI / grok-4" }, + { id: "xai:grok-4", label: "grok-4 * [xAI]" }, { pricing: null }, ) expect(description?.impact).toMatch(/pricing unknown/i) diff --git a/src/tui/model-catalog.ts b/src/tui/model-catalog.ts index 57fa718d9..a5712a78c 100644 --- a/src/tui/model-catalog.ts +++ b/src/tui/model-catalog.ts @@ -83,7 +83,7 @@ export function buildModelCatalog( seen.add(id) out.push({ id, - label: `${providerLabel} / ${m}`, + label: formatModelPickerLabel(m, providerLabel), }) } } @@ -96,6 +96,11 @@ export function modelOptionId(provider: string, model: string): string { return `${provider}:${model}` } +/** Picker row: `model * [providerLabel]`. */ +export function formatModelPickerLabel(model: string, providerLabel: string): string { + return `${model} * [${providerLabel}]` +} + function normalizeProviders( providers: ModelCatalogProvidersInput, ): ModelCatalogProvider[] { @@ -180,7 +185,7 @@ export function buildModelsFirstCatalog( if (seen.has(id)) return false seen.add(id) const warning = isGoModelOnZenPath(model, provider) ? GO_ON_ZEN_WARNING : undefined - const label = `${providerLabelOf(provider)} / ${model}` + const label = formatModelPickerLabel(model, providerLabelOf(provider)) out.push({ id, label, diff --git a/src/tui/notice-line.test.ts b/src/tui/notice-line.test.ts index 53296ba83..d7e71b6cf 100644 --- a/src/tui/notice-line.test.ts +++ b/src/tui/notice-line.test.ts @@ -1,54 +1,58 @@ import { describe, expect, test } from "bun:test" -import { composeNoticeLine, type NoticeState } from "./notice-line" +import { + composeNoticeLine, + resolveWaitingOn, + type NoticeState, +} from "./notice-line" const state = (over: Partial<NoticeState> = {}): NoticeState => ({ - queue: 0, + steer: 0, + followUp: 0, + waitingOn: null, interrupt: false, pinned: false, flash: null, attachments: 0, - mcpNeedsAuth: [], ...over, }) describe("composeNoticeLine", () => { - test("the standing mcp segment names the servers it means", () => { - expect(composeNoticeLine(state({ mcpNeedsAuth: ["granola"] }))).toBe( - "mcp granola needs auth (/mcp)", - ) - expect(composeNoticeLine(state({ mcpNeedsAuth: ["linear", "granola"] }))).toBe( - "mcp granola, linear needs auth (/mcp)", - ) - }) - - test("past two servers the segment counts the rest rather than growing", () => { - expect( - composeNoticeLine(state({ mcpNeedsAuth: ["d", "a", "c", "b"] })), - ).toBe("mcp a, b +2 needs auth (/mcp)") - }) - test("an idle shell has nothing to say and takes no row", () => { expect(composeNoticeLine(state())).toBe("") }) test("default state segments stay off the row", () => { - const line = composeNoticeLine(state({ queue: 0, pinned: false })) + const line = composeNoticeLine(state({ steer: 0, followUp: 0, pinned: false })) + expect(line).not.toContain("steer") + expect(line).not.toContain("follow-up") expect(line).not.toContain("queue") expect(line).not.toContain("pinned") }) - test("non-default state earns its place", () => { + test("steer and follow-up are distinct segments", () => { const line = composeNoticeLine( - state({ queue: 2, pinned: true, interrupt: true, attachments: 1 }), + state({ steer: 2, followUp: 1, pinned: true, interrupt: true, attachments: 1 }), ) - expect(line).toContain("queue 2") + expect(line).toContain("steer 2") + expect(line).toContain("follow-up 1") + expect(line).not.toContain("queue 2") expect(line).toContain("pinned") - expect(line).toContain("interrupt") + expect(line).not.toContain("interrupt") expect(line).toContain("1 image") }) + test("waitingOn + steer names the in-flight command", () => { + const line = composeNoticeLine(state({ steer: 1, waitingOn: "run_shell" })) + expect(line).toContain("waiting on run_shell") + }) + + test("follow-up only does not wait on a tool", () => { + const line = composeNoticeLine(state({ followUp: 1, waitingOn: null })) + expect(line).not.toContain("waiting on") + }) + test("a flash is carried verbatim so paths keep their case", () => { expect(composeNoticeLine(state({ flash: "attached Screenshot.png" }))).toBe( "attached Screenshot.png", @@ -57,7 +61,7 @@ describe("composeNoticeLine", () => { test("no keys strip survives anywhere in the composition", () => { const line = composeNoticeLine( - state({ queue: 1, interrupt: true }), + state({ followUp: 1, interrupt: true }), ) expect(line).not.toContain("commands") expect(line).not.toContain("files") @@ -65,3 +69,23 @@ describe("composeNoticeLine", () => { }) }) +describe("resolveWaitingOn", () => { + const inFlight = { name: "run_shell", startedAt: 0 } + + test("stays silent below STEER_WAIT_NOTICE_MS", () => { + expect(resolveWaitingOn(1, inFlight, 2999)).toBe(null) + }) + + test("names the tool at STEER_WAIT_NOTICE_MS", () => { + expect(resolveWaitingOn(1, inFlight, 3000)).toBe("run_shell") + }) + + test("stays silent with no pending steer", () => { + expect(resolveWaitingOn(0, inFlight, 5000)).toBe(null) + }) + + test("stays silent with no in-flight tool", () => { + expect(resolveWaitingOn(1, null, 5000)).toBe(null) + }) +}) + diff --git a/src/tui/notice-line.ts b/src/tui/notice-line.ts index da0717c14..b771c7dda 100644 --- a/src/tui/notice-line.ts +++ b/src/tui/notice-line.ts @@ -4,11 +4,16 @@ * There is no permanent status strip: keys are discoverable from the landing * screen and the command palette, and the prompt box's border already carries * the model and the workspace. What is left is state that is only sometimes - * true — a queued message, a latched interrupt, a copy result, a live turn — + * true — a queued message, a copy result, pinned scroll, attachments — * and that gets a row only while it has something to say. When every segment * is at its default the row composes to the empty string and the shell hides * it, giving the row back to the transcript. * + * MCP authorization is not a notice-row concern. A server waiting on auth is + * a standing condition with a home on the prompt box (`mcp !` left of the + * model label) and a surface in /mcp; it does not earn a transcript-adjacent + * row of its own. + * * A live turn contributes nothing here. The prompt border already carries the * running state — the bottom-left slot swaps the wordmark for the live phase, * and the meter beside it moves — so a ramp on this row was a second animation @@ -19,50 +24,54 @@ const SEP = " " +export const STEER_WAIT_NOTICE_MS = 3_000 + export type NoticeState = { - readonly queue: number + /** Soft-steer pending (Enter mid-run → drain at tool.boundary). */ + readonly steer: number + /** Follow-up pending (Alt+Enter mid-run → drain only when idle). */ + readonly followUp: number + /** + * Parent tool name to surface after `STEER_WAIT_NOTICE_MS`, or null. + * Gated by `resolveWaitingOn`; this field only controls wording. + */ + readonly waitingOn: string | null readonly interrupt: boolean /** Transcript scrolled off the tail (non-default follow state). */ readonly pinned: boolean /** Transient feedback (copy result, attach failure, exit arming). */ readonly flash: string | null readonly attachments: number - /** Names of MCP servers still unauthorized; their tools stay unavailable. */ - readonly mcpNeedsAuth: readonly string[] } -/** How many server names the segment spells out before it counts instead. */ -const MCP_NAMES_SHOWN = 2 - /** - * Name the unauthorized servers rather than counting them: a bare count sends - * the operator to /mcp to find out which one it meant, and reads as a claim - * about whichever server they see there first. + * Name the in-flight parent tool once a steer has been waiting long enough. + * Silent below the delay, with no pending steer, or with no live parent tool. */ -function mcpAuthNames(names: readonly string[]): string { - const sorted = [...names].sort() - if (sorted.length <= MCP_NAMES_SHOWN) return `mcp ${sorted.join(", ")}` - const shown = sorted.slice(0, MCP_NAMES_SHOWN).join(", ") - return `mcp ${shown} +${sorted.length - MCP_NAMES_SHOWN}` +export function resolveWaitingOn( + steer: number, + inFlight: { name: string; startedAt: number } | null, + nowMs: number, +): string | null { + if (steer <= 0 || inFlight === null) return null + if (nowMs - inFlight.startedAt < STEER_WAIT_NOTICE_MS) return null + const name = inFlight.name.trim() + return name.length > 0 ? name : null } -/** - * Compose the transient row. An empty result means the row has nothing to say - * and the shell drops it. - */ export function composeNoticeLine(state: NoticeState): string { const segments: string[] = [] - if (state.queue > 0) segments.push(`queue ${state.queue}`) + if (state.steer > 0) segments.push(`steer ${state.steer}`) + if (state.followUp > 0) segments.push(`follow-up ${state.followUp}`) + if (state.waitingOn) segments.push(`waiting on ${state.waitingOn}`) if (state.pinned) segments.push("pinned") - if (state.interrupt) segments.push("interrupt") + // "interrupt" is not a standing notice. Mid-run stop feedback is a system + // row (wording without "interrupt"); empty-prompt Ctrl+C arms exit via flash. if (state.attachments > 0) { segments.push( `${state.attachments} image${state.attachments === 1 ? "" : "s"}`, ) } - if (state.mcpNeedsAuth.length > 0) { - segments.push(`${mcpAuthNames(state.mcpNeedsAuth)} needs auth (/mcp)`) - } const flash = state.flash?.trim() ?? "" if (flash.length > 0) segments.push(flash) return segments.join(SEP) diff --git a/src/tui/overlay-paint.test.ts b/src/tui/overlay-paint.test.ts index 7f8fac710..c1282fd88 100644 --- a/src/tui/overlay-paint.test.ts +++ b/src/tui/overlay-paint.test.ts @@ -24,16 +24,16 @@ import { const MODEL_LABEL = "xai/thegreataxios · grok-4.5" const ITEMS = [ - "Z.AI / glm-5.2", - "Anthropic / opus-4.6", - "OpenAI / gpt-5.1", - "Google / gemini-3", - "Meta / llama-4", - "xAI / grok-4.5", - "Mistral / large-3", - "Cohere / command-a", - "DeepSeek / v3.2", - "Qwen / max", + "glm-5.2 * [Z.AI]", + "opus-4.6 * [Anthropic]", + "gpt-5.1 * [OpenAI]", + "gemini-3 * [Google]", + "llama-4 * [Meta]", + "grok-4.5 * [xAI]", + "large-3 * [Mistral]", + "command-a * [Cohere]", + "v3.2 * [DeepSeek]", + "max * [Qwen]", ] as const /** Frame rows between the overlay host's top and bottom border rules. */ diff --git a/src/tui/overlays.test.ts b/src/tui/overlays.test.ts index 6a3ceaf56..0b22d6bc6 100644 --- a/src/tui/overlays.test.ts +++ b/src/tui/overlays.test.ts @@ -316,7 +316,7 @@ describe("overlay accept callbacks", () => { onModel: (s) => accepted.push(s), }) openModelPickerOverlay(shell, { - items: ["anthropic / sonnet", "openai / gpt-5"], + items: ["sonnet * [anthropic]", "gpt-5 * [openai]"], itemIds: ["anthropic:claude-sonnet-4", "openai:gpt-5"], activeIndex: 0, }) @@ -326,7 +326,7 @@ describe("overlay accept callbacks", () => { { kind: "model_picker", index: 1, - label: "openai / gpt-5", + label: "gpt-5 * [openai]", id: "openai:gpt-5", }, ]) @@ -479,7 +479,7 @@ describe("accept echo reads the chosen value structurally", () => { }) }) -describe("echoChoice defaults to on for callers with no recorder of their own", () => { +describe("echoChoice defaults to on for callers with no gate policy", () => { test("openPermissionsOverlay with no echoChoice opt still echoes on accept", async () => { await withTestRenderer( async (h) => { diff --git a/src/tui/overlays.ts b/src/tui/overlays.ts index 44fc5c5cd..fb1826a20 100644 --- a/src/tui/overlays.ts +++ b/src/tui/overlays.ts @@ -56,16 +56,16 @@ export function makeOperatorQuestion(): { /** Fixture: model/provider picker list. */ export function makeModelPickerItems(): readonly string[] { return [ - "anthropic / claude-sonnet-4", - "anthropic / claude-opus-4", - "openai / gpt-5", - "openai / gpt-5-mini", - "google / gemini-2.5-pro", - "google / gemini-2.5-flash", - "xai / grok-3", - "local / ollama-llama3.3", - "codex / o3", - "codex / o4-mini", + "claude-sonnet-4 * [anthropic]", + "claude-opus-4 * [anthropic]", + "gpt-5 * [openai]", + "gpt-5-mini * [openai]", + "gemini-2.5-pro * [google]", + "gemini-2.5-flash * [google]", + "grok-3 * [xai]", + "ollama-llama3.3 * [local]", + "o3 * [codex]", + "o4-mini * [codex]", ] } @@ -92,11 +92,10 @@ export type OpenPermissionsOpts = { /** Per-open Esc/dismiss; host binds resolve(ApprovalOutcome) so Esc denies instead of hanging. */ readonly onCancel?: () => void /** - * Suppress the generic accept/answer echo for this open. Callers that - * record their own authoritative decision row (e.g. gate-wire's - * recordDecision) pass `false` so the generic echo does not duplicate it; - * callers with no such recorder (e.g. the standalone demo) get the default - * echo so their choice still leaves a trace. + * Suppress the generic accept/answer echo for this open. Decision gates + * pass `false` so a settled permission does not replay into the + * transcript; callers with no such policy (e.g. the standalone demo) + * get the default echo so their choice still leaves a trace. */ readonly echoChoice?: boolean } @@ -135,10 +134,9 @@ export type OpenOperatorOpts = { /** Per-open Esc/dismiss; host binds resolve(cancel) so Esc cancels instead of hanging. */ readonly onCancel?: () => void /** - * Suppress the generic accept/answer echo for this open. Callers that - * record their own authoritative decision row (e.g. gate-wire's - * recordOperatorDecision) pass `false` so the generic echo does not - * duplicate it; callers with no such recorder (e.g. the standalone demo) + * Suppress the generic accept/answer echo for this open. Decision gates + * pass `false` so a settled operator question does not replay into the + * transcript; callers with no such policy (e.g. the standalone demo) * get the default echo so their choice still leaves a trace. */ readonly echoChoice?: boolean @@ -197,6 +195,8 @@ export type OpenModelPickerOpts = { readonly typeToFilter?: boolean /** Advertise Alt+A in the footer — only when the caller wired the handler. */ readonly addProviderHint?: boolean + /** Advertise Alt+D in the footer — only when the caller wired the handler. */ + readonly setDefaultHint?: boolean } export function openModelPickerOverlay( @@ -220,6 +220,9 @@ export function openModelPickerOverlay( ...(opts?.addProviderHint !== undefined ? { addProviderHint: opts.addProviderHint } : {}), + ...(opts?.setDefaultHint !== undefined + ? { setDefaultHint: opts.setDefaultHint } + : {}), }) } diff --git a/src/tui/palette-paint.test.ts b/src/tui/palette-paint.test.ts index fdee19ffc..e44d10f16 100644 --- a/src/tui/palette-paint.test.ts +++ b/src/tui/palette-paint.test.ts @@ -188,6 +188,160 @@ describe("palette filters as you type", () => { }) }) +const DESCRIBED_CATALOG: readonly PaletteCommand[] = [ + { + id: "help", + label: "/help", + description: "Show the keyboard shortcut and command overlay", + }, + { + id: "model", + label: "/model", + description: "Switch the active model or provider", + }, + { + id: "mcp", + label: "/mcp", + description: "Manage MCP servers", + }, +] + +const HELP_DESC = "Show the keyboard shortcut and command overlay" +const MODEL_DESC = "Switch the active model or provider" + +function stripFrameLines(frame: string): string[] { + return frame + .split("\n") + .map((line) => line.replace(/^\s*│/, "").replace(/│\s*$/, "").trimEnd()) +} + +/** Interior zone rows under the list rule, before the overlay's bottom border. */ +function zoneAfterList( + lines: readonly string[], + labels: readonly string[], +): readonly string[] | undefined { + let last = -1 + for (const [i, line] of lines.entries()) { + if (labels.some((label) => line.includes(label))) last = i + } + if (last < 0) return undefined + const below = lines.slice(last + 1) + const ruleAt = below.findIndex( + (r) => r.includes("─") && !/[┌┐└┘╭╮╰╯]/.test(r), + ) + if (ruleAt < 0) return undefined + const afterRule = below.slice(ruleAt + 1) + const boxBottom = afterRule.findIndex((r) => /[└┘]/.test(r)) + return boxBottom >= 0 ? afterRule.slice(0, boxBottom) : afterRule +} + +function expectNameOnlyRows( + lines: readonly string[], + labels: readonly string[], +): void { + for (const label of labels) { + const row = lines.find((r) => r.includes(label)) + expect(row).toBeDefined() + expect(row!.trim()).toBe(label) + } +} + +function expectDescriptionUnderListRule( + lines: readonly string[], + description: string, + labels: readonly string[], +): void { + for (const label of labels) { + const row = lines.find((r) => r.includes(label)) + expect(row).toBeDefined() + expect(row).not.toContain(description) + } + const zone = zoneAfterList(lines, labels) + expect(zone).toBeDefined() + expect(zone!.some((r) => r.includes(description))).toBe(true) +} + +describe("command list description zone", () => { + test("paints the focused command's registry description, not on the row", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 100, rows: 32 }, + wireKeys: false, + run: "idle", + }) + openPalette(shell, { catalog: DESCRIBED_CATALOG }) + await h.renderOnce() + const labels = DESCRIBED_CATALOG.map((c) => c.label) + const lines = stripFrameLines(h.captureCharFrame()) + expectNameOnlyRows(lines, labels) + expectDescriptionUnderListRule(lines, HELP_DESC, labels) + }, + { width: 100, height: 32 }, + ) + }) + + test("moving the overlay selection updates the zone to the newly focused command", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 100, rows: 32 }, + wireKeys: false, + run: "idle", + }) + openPalette(shell, { catalog: DESCRIBED_CATALOG }) + await h.renderOnce() + const labels = DESCRIBED_CATALOG.map((c) => c.label) + const before = stripFrameLines(h.captureCharFrame()) + expectDescriptionUnderListRule(before, HELP_DESC, labels) + expect(before.join("\n")).not.toContain(MODEL_DESC) + + moveOverlaySelection(shell, 1) + await h.renderOnce() + const after = stripFrameLines(h.captureCharFrame()) + expectNameOnlyRows(after, labels) + expectDescriptionUnderListRule(after, MODEL_DESC, labels) + expect(after.join("\n")).not.toContain(HELP_DESC) + }, + { width: 100, height: 32 }, + ) + }) + + test("an undescribed row leaves the zone blank without leftover neighbor copy", async () => { + const mixed: readonly PaletteCommand[] = [ + { id: "help", label: "/help", description: HELP_DESC }, + { id: "model", label: "/model" }, + ] + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 100, rows: 32 }, + wireKeys: false, + run: "idle", + }) + openPalette(shell, { catalog: mixed }) + await h.renderOnce() + const labels = mixed.map((c) => c.label) + const described = stripFrameLines(h.captureCharFrame()) + expectNameOnlyRows(described, labels) + expectDescriptionUnderListRule(described, HELP_DESC, labels) + const reserved = shell.layout.heights.overlay_host + + moveOverlaySelection(shell, 1) + await h.renderOnce() + const blank = stripFrameLines(h.captureCharFrame()) + expectNameOnlyRows(blank, labels) + const zone = zoneAfterList(blank, labels) + expect(zone).toBeDefined() + expect(zone!.every((r) => r.trim() === "")).toBe(true) + expect(blank.join("\n")).not.toContain(HELP_DESC) + expect(shell.layout.heights.overlay_host).toBe(reserved) + }, + { width: 100, height: 32 }, + ) + }) +}) + describe("command list width", () => { // Both boxes are children of the same padded root; a width computed a // second way for the floating list drifts from the prompt box's "100%". diff --git a/src/tui/pick-session.ts b/src/tui/pick-session.ts index 80fa5edb5..82a9f3134 100644 --- a/src/tui/pick-session.ts +++ b/src/tui/pick-session.ts @@ -34,7 +34,7 @@ export async function pickSession( const picked = await runListModal({ title: "Resume conversation", kind: "resume", - heading: ["Choose a previous session in this repo"], + heading: ["Choose a previous session in this checkout"], options: sessions.map((session) => ({ id: session.sessionId, label: sessionResumeLabel(session), diff --git a/src/tui/plugin-diagnostics-sink.test.ts b/src/tui/plugin-diagnostics-sink.test.ts index d9e20156f..86b74b558 100644 --- a/src/tui/plugin-diagnostics-sink.test.ts +++ b/src/tui/plugin-diagnostics-sink.test.ts @@ -248,6 +248,22 @@ describe("interactive plugin diagnostics never hit raw stderr", () => { }); }); +describe("plugin warnings route to plugin ! / /plugins, not startup notices", () => { + test("runner does not push formatPluginWarningsSummary into startupPluginNotices", async () => { + // Product lock: discovery / tool-plugin / profile skill-miss summaries must + // never become fire-and-forget surfaceSystemNotice chatter. They drive + // standingPluginWarnings → setPluginNeedsAttention + /plugins instead. + const src = await Bun.file(new URL("./runner.ts", import.meta.url)).text(); + expect(src).toContain("standingPluginWarnings"); + expect(src).toContain("setPluginNeedsAttention"); + expect(src).not.toMatch( + /startupPluginNotices\.push\(\s*(discoveryNotice|toolPluginNotice|profileNotice)/, + ); + // Unverified provider-key notice is still allowed on the startup path. + expect(src).toMatch(/startupPluginNotices\.push\([\s\S]*couldn't confirm your/); + }); +}); + // The interactive paths above always hand `resolveToolPlugins` a diagnostics // collector. Headless/standalone callers (exec's tool-plugin resolution, // direct unit tests) may supply neither `diagnostics` nor `onWarning` — diff --git a/src/tui/product-host.test.ts b/src/tui/product-host.test.ts index 1f911ec85..e0aeaf7f1 100644 --- a/src/tui/product-host.test.ts +++ b/src/tui/product-host.test.ts @@ -302,7 +302,7 @@ describe("mountProductHost", () => { expect(host.shell.streamLog).toEqual([]) }) - test("the agents panel's elapsed clock advances on the sticky poll tick, without another chrome push", async () => { + test("setChrome with running agents does not paint an agents panel clock", async () => { const now = Date.now() const { host, renderOnce, captureCharFrame } = await mountHeadless({ chrome: { @@ -320,14 +320,15 @@ describe("mountProductHost", () => { }) try { await renderOnce() - expect(captureCharFrame()).toContain("0:59") + // Fleet board chrome is off — sticky poll must not resurrect an agents + // panel clock from injected chrome state. + expect(captureCharFrame()).not.toContain("0:59") + expect(captureCharFrame()).not.toContain("map callers") - // No further chrome push or event — only wall-clock time passing. - // Only the 200ms sticky poll can be responsible for the clock moving. await new Promise((r) => setTimeout(r, 1_100)) await renderOnce() - expect(captureCharFrame()).not.toContain("0:59") - expect(captureCharFrame()).toMatch(/1:0\d/) + expect(captureCharFrame()).not.toMatch(/1:0\d/) + expect(captureCharFrame()).not.toContain("map callers") } finally { host.dispose() } @@ -384,7 +385,7 @@ describe("flat type-to-filter model picker", () => { expect(items.some((label) => label.includes("codex/abk-labs"))).toBe(true) expect(items.some((label) => label.includes("xai/thegreataxios"))).toBe(true) // No provider-group-only rows (those were `providerGroup:` ids with no model). - expect(items.every((label) => label.includes(" / ") || label.startsWith("("))).toBe(true) + expect(items.every((label) => label.includes(" * [") || label.startsWith("("))).toBe(true) // Filter row is present so the list can narrow without another pane. expect(frame).toContain(">") } finally { @@ -455,7 +456,7 @@ describe("flat type-to-filter model picker", () => { host.openModels?.() await harness.renderOnce() const frame = harness.captureCharFrame() - expect(frame).toContain("xai/thegreataxios / grok-4.5 (current)") + expect(frame).toContain("grok-4.5 * [xai/thegreataxios] (current)") } finally { host.dispose() harness.destroy() @@ -487,8 +488,8 @@ describe("flat type-to-filter model picker", () => { host.openModels?.() await harness.renderOnce() const frame = harness.captureCharFrame() - expect(frame).not.toContain("xai/thegreataxios / grok-4.5 (current)") - expect(frame).toContain("gpt-5.5 (current)") + expect(frame).not.toContain("grok-4.5 * [xai/thegreataxios] (current)") + expect(frame).toContain("gpt-5.5 * [codex/abk-labs] (current)") } finally { host.dispose() harness.destroy() @@ -587,6 +588,73 @@ describe("flat type-to-filter model picker", () => { } }) + const altD = { name: "d", ctrl: false, meta: false, option: true } as KeyEvent + + test("Alt+D on a focused row calls onSetDefault and leaves the picker open", async () => { + const defaults: string[] = [] + const { harness, host } = await mountPicker({ + onSetDefault: (id) => defaults.push(id), + }) + try { + host.openModels?.() + await harness.renderOnce() + expect(runOverlayAction(host.shell, altD)).toBe(true) + expect(defaults).toEqual(["codex/abk-labs:gpt-5.5"]) + expect(host.shell.overlayKind).toBe("model_picker") + } finally { + host.dispose() + harness.destroy() + } + }) + + test("Alt+D on the no-matches sentinel does not set a default", async () => { + const defaults: string[] = [] + const { harness, host } = await mountPicker({ + onSetDefault: (id) => defaults.push(id), + }) + try { + host.openModels?.() + await harness.renderOnce() + for (const ch of "zzzz-no-such-model") { + harness.pressKey(ch) + } + await harness.renderOnce() + expect(host.shell.overlayItems).toEqual(["(no matches)"]) + expect(runOverlayAction(host.shell, altD)).toBe(false) + expect(defaults).toEqual([]) + expect(host.shell.overlayKind).toBe("model_picker") + } finally { + host.dispose() + harness.destroy() + } + }) + + test("the model picker footer advertises Alt+D when onSetDefault is wired", async () => { + const { harness, host } = await mountPicker({ + onSetDefault: () => {}, + }) + try { + host.openModels?.() + await harness.renderOnce() + expect(harness.captureCharFrame()).toContain("Alt+D") + } finally { + host.dispose() + harness.destroy() + } + }) + + test("the model picker footer does not advertise Alt+D when onSetDefault is omitted", async () => { + const { harness, host } = await mountPicker() + try { + host.openModels?.() + await harness.renderOnce() + expect(harness.captureCharFrame()).not.toContain("Alt+D") + } finally { + host.dispose() + harness.destroy() + } + }) + const altA = { name: "a", ctrl: false, meta: false, option: true } as KeyEvent test("the model picker footer advertises Alt+A", async () => { diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index 976bfffa3..2d2a14f7f 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -20,7 +20,6 @@ import { openAddProviderOverlay, openModelPickerOverlay } from "./overlays.js" import { wireGates } from "./gate-wire.js" import { createSystemClipboard } from "./system-clipboard.js" import { - annotateAgentTools, formatChromeZones, type ChromeLiveState, } from "./chrome-state.js" @@ -31,7 +30,6 @@ import { lifecycleHookEvent, mcpNotice, mcpServerState, - subAgentProgress, RUNTIME_FLASH_MS, type RuntimeNotice, } from "./runtime-notices.js" @@ -139,6 +137,8 @@ export type ProductHostConfig = { readonly onConnectProvider?: (providerName: string) => void /** Alt+F on a focused model row. Bare `f` is claimed by type-to-filter. */ readonly onFavoriteToggle?: (itemId: string) => void + /** Alt+D on a focused model row. Bare `d` is claimed by type-to-filter. */ + readonly onSetDefault?: (itemId: string) => void /** * Every first-class provider kind, read fresh on each Alt+A open so a * just-connected account's count is current. Omitted hosts get no Alt+A @@ -342,19 +342,15 @@ export async function mountProductHost( setPaletteOnCommand(shell, config.onCommand) } - // Live chrome is pushed by the caller; subagent progress annotates the copy - // the host last received rather than racing the caller for the zone. + // Live chrome is pushed by the caller; the subagent store owns per-agent + // tool state (name + clock), so the host paints zones straight from it. let chromeState: ChromeLiveState | null = config.chrome ?? null - const subAgentTools = new Map<string, string>() const paintChromeZones = (): void => { if (chromeState === null) { setChromeZones(shell, { task: null, agents: null }) return } - setChromeZones( - shell, - formatChromeZones(annotateAgentTools(chromeState, subAgentTools)), - ) + setChromeZones(shell, formatChromeZones(chromeState)) } if (chromeState !== null) paintChromeZones() @@ -404,7 +400,6 @@ export async function mountProductHost( config.eventEmitter.off("hook", onHook) config.eventEmitter.off("mcp.status", onMcpStatus) config.eventEmitter.off("permission.grant", onPermissionGrant) - config.eventEmitter.off("subagent.progress", onSubAgentProgress) bridge.dispose() // Cancels any flash still counting down: its expiry repaints, and after // teardown that repaint reaches a destroyed text buffer. @@ -471,14 +466,6 @@ export async function mountProductHost( if (approval !== null) show(grantNotice(approval)) } - function onSubAgentProgress(info: unknown): void { - if (disposed) return - const progress = subAgentProgress(info) - if (progress === null) return - subAgentTools.set(progress.description, progress.toolName) - paintChromeZones() - } - // The renderer already owns the alternate screen and raw mode by this point, // but `dispose` has not been handed to any caller yet — a throw here would // leave the terminal wedged with nobody able to restore it. @@ -525,6 +512,7 @@ export async function mountProductHost( const onSelect = config.onModelSelect const onConnect = config.onConnectProvider const onFavoriteToggle = config.onFavoriteToggle + const onSetDefault = config.onSetDefault const addProviderChoices = config.addProviderChoices // Alt+A from the model picker: close it and open a fresh selector over @@ -575,6 +563,7 @@ export async function mountProductHost( // Flat list: type to narrow rather than drill into a provider pane. typeToFilter: true, addProviderHint: openAddProvider !== undefined, + setDefaultHint: onSetDefault !== undefined, ...(focusIndex >= 0 ? { activeIndex: focusIndex } : {}), onAccept: (sel) => { // Prefer the stable id from the (possibly filtered) row. Do not fall @@ -585,12 +574,14 @@ export async function mountProductHost( onSelect(id) }, describe: (itemId) => currentDescribeModel?.(itemId) ?? null, - ...(onFavoriteToggle !== undefined || openAddProvider !== undefined + ...(onFavoriteToggle !== undefined || + openAddProvider !== undefined || + onSetDefault !== undefined ? { onAction: (itemId, key) => { if (key.ctrl || !(key.meta || key.option)) return false const name = typeof key.name === "string" ? key.name.toLowerCase() : "" - // Alt+A / Alt+F, never bare — type-to-filter claims printable keys. + // Alt+A / Alt+F / Alt+D, never bare — type-to-filter claims printable keys. if (name === "a" && openAddProvider !== undefined) { openAddProvider() return true @@ -601,6 +592,11 @@ export async function mountProductHost( onFavoriteToggle(itemId) return true } + if (name === "d" && onSetDefault !== undefined) { + if (itemId.length === 0) return false + onSetDefault(itemId) + return true + } return false }, } @@ -623,7 +619,6 @@ export async function mountProductHost( config.eventEmitter.on("hook", onHook) config.eventEmitter.on("mcp.status", onMcpStatus) config.eventEmitter.on("permission.grant", onPermissionGrant) - config.eventEmitter.on("subagent.progress", onSubAgentProgress) return { shell, diff --git a/src/tui/prompt-border.test.ts b/src/tui/prompt-border.test.ts index 8bb2e8934..4771810d6 100644 --- a/src/tui/prompt-border.test.ts +++ b/src/tui/prompt-border.test.ts @@ -3,7 +3,10 @@ import { describe, expect, test } from "bun:test" import { BORDER, CONTEXT_PRESSURE_THRESHOLD, + MCP_ATTENTION_LABEL, + PLUGIN_ATTENTION_LABEL, abbreviateHome, + composeAttentionLabel, composeCostContextMeter, composeRule, composeWorkspaceLabel, @@ -138,6 +141,59 @@ describe("composeRule", () => { expect(isPlainRule(plain)).toBe(true) }) + test("attention sits immediately left of the label", () => { + const parts = composeRule({ + width: 40, + corners: TOP, + attention: "mcp !", + label: "xai · grok", + }) + expect(ruleText(parts)).toBe("╭───────────────── mcp ! ─ xai · grok ─╮") + expect(ruleWidth(parts)).toBe(40) + expect(parts.some((p) => p.role === "attention")).toBe(true) + expect(parts.some((p) => p.role === "label")).toBe(true) + }) + + test("combined mcp and plugin attention seats as one run", () => { + const attention = composeAttentionLabel({ mcp: true, plugin: true }) + expect(attention).toBe("mcp ! · plugin !") + const parts = composeRule({ + width: 48, + corners: TOP, + attention: attention!, + label: "xai · grok", + }) + expect(ruleText(parts)).toContain("mcp ! · plugin !") + expect(parts.filter((p) => p.role === "attention")).toHaveLength(1) + }) + + test("composeAttentionLabel covers each attention combination", () => { + expect(composeAttentionLabel({})).toBeUndefined() + expect(composeAttentionLabel({ mcp: true })).toBe(MCP_ATTENTION_LABEL) + expect(composeAttentionLabel({ plugin: true })).toBe(PLUGIN_ATTENTION_LABEL) + expect(composeAttentionLabel({ mcp: true, plugin: true })).toBe( + "mcp ! · plugin !", + ) + }) + + test("attention alone still seats when there is no model label", () => { + const parts = composeRule({ width: 20, corners: TOP, attention: "mcp !" }) + expect(ruleText(parts)).toBe("╭────────── mcp ! ─╮") + expect(parts.some((p) => p.role === "attention")).toBe(true) + }) + + test("a rule too narrow for both keeps the label and drops attention", () => { + const parts = composeRule({ + width: 22, + corners: TOP, + attention: "mcp !", + label: "xai · grok-4.6", + }) + expect(parts.some((p) => p.role === "attention")).toBe(false) + expect(ruleText(parts)).toContain("xai · grok-4.6") + expect(ruleWidth(parts)).toBe(22) + }) + test("the rule stays exactly the requested width with a meter present, at every size", () => { for (const width of [120, 80, 60, 48, 40, 20, 10, 3]) { const parts = composeRule({ diff --git a/src/tui/prompt-border.ts b/src/tui/prompt-border.ts index 27ac13537..91d5c436d 100644 --- a/src/tui/prompt-border.ts +++ b/src/tui/prompt-border.ts @@ -31,7 +31,7 @@ export const BORDER = { * `meter` is the cost/context run. The shell paints each role differently; * nothing else distinguishes them. */ -export type RuleRole = "rule" | "label" | "brand" | "meter" +export type RuleRole = "rule" | "label" | "brand" | "meter" | "attention" export type RulePart = { readonly text: string @@ -51,6 +51,12 @@ export type RuleInput = { readonly meter?: string /** `meter` with the cost suffix already stripped — tried once `meter` no longer fits. */ readonly meterCompact?: string + /** + * Compact call-to-action immediately left of the label (e.g. `mcp !`). + * Dropped after the meter and before the label: it is a standing ask, not + * the operator's own workspace or model identity. + */ + readonly attention?: string /** Right-aligned label. Dropped last: it is information, the mark is not. */ readonly label?: string } @@ -80,14 +86,43 @@ type RightBlock = { readonly cost: number } -/** The meter and label, in that order, joined by a fixed dash run when both survive. */ -function buildRightBlock(meterCell: string, labelCell: string): RightBlock { +/** Compact standing mark when any MCP server still needs authorization. */ +export const MCP_ATTENTION_LABEL = "mcp !" + +/** Compact standing mark when plugin load left standing warnings (see `/plugins`). */ +export const PLUGIN_ATTENTION_LABEL = "plugin !" + +/** + * Build the single attention slot. MCP and plugin marks share one run so the + * border never grows a second attention cell — operator-chosen combined form. + */ +export function composeAttentionLabel(opts: { + readonly mcp?: boolean + readonly plugin?: boolean +}): string | undefined { + const parts: string[] = [] + if (opts.mcp === true) parts.push(MCP_ATTENTION_LABEL) + if (opts.plugin === true) parts.push(PLUGIN_ATTENTION_LABEL) + return parts.length > 0 ? parts.join(" · ") : undefined +} + +/** The meter, attention mark, and label — in that order, joined by a fixed dash run. */ +function buildRightBlock( + meterCell: string, + attentionCell: string, + labelCell: string, +): RightBlock { + const cells: readonly { readonly text: string; readonly role: RuleRole }[] = [ + { text: meterCell, role: "meter" }, + { text: attentionCell, role: "attention" }, + { text: labelCell, role: "label" }, + ] const parts: RulePart[] = [] - if (meterCell.length > 0) parts.push({ text: meterCell, role: "meter" }) - if (meterCell.length > 0 && labelCell.length > 0) { - parts.push({ text: dashes(RULE_GAP), role: "rule" }) + for (const cell of cells) { + if (cell.text.length === 0) continue + if (parts.length > 0) parts.push({ text: dashes(RULE_GAP), role: "rule" }) + parts.push({ text: cell.text, role: cell.role }) } - if (labelCell.length > 0) parts.push({ text: labelCell, role: "label" }) return { parts, cost: widthOf(parts) } } @@ -165,8 +200,8 @@ function plainRule(open: string, close: string, inner: number): RulePart[] { * Compose one border rule. Runs are dropped whole, never truncated mid-glyph: * a half-written label corrupts the frame, a missing one just reads as a * plain rule. Drop order, most to least expendable: brand, then the meter's - * cost suffix, then the meter's context reading, then the label — the - * operator's own workspace path survives everything else. + * cost suffix, then the meter's context reading, then the attention mark, + * then the label — the operator's own workspace path survives everything else. */ export function composeRule(input: RuleInput): readonly RulePart[] { const width = Math.max(0, Math.floor(input.width)) @@ -182,16 +217,19 @@ export function composeRule(input: RuleInput): readonly RulePart[] { const labelCell = padCell(input.label?.trim() ?? "") const meterFullCell = padCell(input.meter?.trim() ?? "") const meterCompactCell = padCell(input.meterCompact?.trim() ?? "") + const attentionCell = padCell(input.attention?.trim() ?? "") - const withCost = buildRightBlock(meterFullCell, labelCell) - const withoutCost = buildRightBlock(meterCompactCell, labelCell) - const withoutContext = buildRightBlock("", labelCell) + const withCost = buildRightBlock(meterFullCell, attentionCell, labelCell) + const withoutCost = buildRightBlock(meterCompactCell, attentionCell, labelCell) + const withoutContext = buildRightBlock("", attentionCell, labelCell) + const withoutAttention = buildRightBlock("", "", labelCell) const stages: Array<() => RulePart[] | null> = [ () => layoutWithBrand(open, close, inner, brandCell, brandCost, withCost), () => layoutRightOnly(open, close, inner, withCost), () => layoutRightOnly(open, close, inner, withoutCost), () => layoutRightOnly(open, close, inner, withoutContext), + () => layoutRightOnly(open, close, inner, withoutAttention), () => layoutBrandOnly(open, close, inner, brandCell, brandCost), ] for (const stage of stages) { diff --git a/src/tui/prompt-chrome.test.ts b/src/tui/prompt-chrome.test.ts index 4574c41da..1b9e0bd0c 100644 --- a/src/tui/prompt-chrome.test.ts +++ b/src/tui/prompt-chrome.test.ts @@ -8,6 +8,8 @@ import { noticeText, setPromptModelLabel, setPromptWorkspace, + setMcpNeedsAuth, + setPluginNeedsAttention, setShellBridgeHooks, setShellExitHandler, setStatusFlash, @@ -136,6 +138,59 @@ describe("the model label rides the top border", () => { }) }) +describe("mcp attention rides the top border", () => { + test("mcp ! sits immediately left of the model label", async () => { + await withShell((shell) => { + setPromptModelLabel(shell, { profile: "xai", model: "grok 4.6" }) + setMcpNeedsAuth(shell, ["granola"]) + const top = ruleOf(shell.promptTopRule) + expect(top).toMatch(/^╭─+ mcp ! ─ xai · grok 4.6 ─╮$/u) + expect(noticeText(shell)).toBe("") + expect(shell.layout.heights.notice).toBe(0) + }) + }) + + test("clearing auth drops the mark and leaves the model", async () => { + await withShell((shell) => { + setPromptModelLabel(shell, { profile: "xai", model: "grok 4.6" }) + setMcpNeedsAuth(shell, ["granola"]) + setMcpNeedsAuth(shell, []) + expect(ruleOf(shell.promptTopRule)).toMatch(/^╭─+ xai · grok 4.6 ─╮$/u) + }) + }) +}) + +describe("plugin attention rides the top border", () => { + test("plugin ! sits immediately left of the model label", async () => { + await withShell((shell) => { + setPromptModelLabel(shell, { profile: "xai", model: "grok 4.6" }) + setPluginNeedsAttention(shell, true) + const top = ruleOf(shell.promptTopRule) + expect(top).toMatch(/^╭─+ plugin ! ─ xai · grok 4.6 ─╮$/u) + expect(noticeText(shell)).toBe("") + }) + }) + + test("mcp and plugin combine into one attention run", async () => { + await withShell((shell) => { + setPromptModelLabel(shell, { profile: "xai", model: "grok 4.6" }) + setMcpNeedsAuth(shell, ["granola"]) + setPluginNeedsAttention(shell, true) + const top = ruleOf(shell.promptTopRule) + expect(top).toMatch(/^╭─+ mcp ! · plugin ! ─ xai · grok 4.6 ─╮$/u) + }) + }) + + test("clearing plugin attention drops the mark", async () => { + await withShell((shell) => { + setPromptModelLabel(shell, { profile: "xai", model: "grok 4.6" }) + setPluginNeedsAttention(shell, true) + setPluginNeedsAttention(shell, false) + expect(ruleOf(shell.promptTopRule)).toMatch(/^╭─+ xai · grok 4.6 ─╮$/u) + }) + }) +}) + describe("the workspace rides the bottom border", () => { test("directory and branch sit right-aligned, the lockup left", async () => { await withShell((shell) => { diff --git a/src/tui/prompt-input.ts b/src/tui/prompt-input.ts index d07d50f08..999ecc22c 100644 --- a/src/tui/prompt-input.ts +++ b/src/tui/prompt-input.ts @@ -8,8 +8,8 @@ * * - **Enter sends.** The textarea's default is Enter-inserts-newline, which * would swallow the shell's primary action. The bindings below flip it: Enter - * submits and a newline needs an explicit chord. Alt+Enter (steer) is claimed - * by the shell's key listener before the widget ever sees it. + * submits and a newline needs an explicit chord. Alt+Enter (follow-up) is + * claimed by the shell's key listener before the widget ever sees it. * - **`value`.** `InputRenderable` exposes the buffer as `value`; the textarea * calls it `plainText` and has no setter that also parks the caret. The whole * shell — kill ring, history recall, the `/` and `@` popups, attachments — @@ -33,7 +33,7 @@ export type PromptInput = TextareaRenderable & { value: string } * (`linefeed`) everywhere else — terminals that don't negotiate the kitty * keyboard protocol can't report Shift+Enter at all, so the fallback chords * are what make this work in practice. Alt+Enter is left alone; the shell - * claims it for the steer action before the widget ever sees it. + * claims it for the follow-up action before the widget ever sees it. */ // Modifier-qualified entries lead: a first-match table would otherwise resolve // Shift+Enter against the bare `return` submit binding and send the message. diff --git a/src/tui/prompt-slash-exit.test.ts b/src/tui/prompt-slash-exit.test.ts index 839e1e518..8b0921ba0 100644 --- a/src/tui/prompt-slash-exit.test.ts +++ b/src/tui/prompt-slash-exit.test.ts @@ -19,7 +19,12 @@ import { } from "./shell" const CATALOG: readonly PaletteCommand[] = [ - { id: "model", label: "/model" }, + { + id: "model", + label: "/model", + description: "Open model picker", + keywords: ["model", "Open model picker", "slash", "command"], + }, { id: "mcp", label: "/mcp" }, { id: "compact", label: "/compact" }, ] @@ -29,6 +34,7 @@ type Ctx = { readonly dispatched: string[] readonly press: (key: string) => void readonly render: () => Promise<void> + readonly frame: () => string } function withShell(fn: (ctx: Ctx) => Promise<void>): Promise<void> { @@ -48,6 +54,7 @@ function withShell(fn: (ctx: Ctx) => Promise<void>): Promise<void> { dispatched, press: (key) => h.pressKey(key as Parameters<typeof h.pressKey>[0]), render: h.renderOnce, + frame: () => h.captureCharFrame(), }) } finally { shell.dispose() @@ -152,6 +159,32 @@ describe("slash command popup", () => { expect(shell.prompt.value).toBe("src/") }) }) + + test("an unmatched name prefix closes the popup and keeps the typed text", async () => { + await withShell(async ({ shell, press, render, frame }) => { + press("/") + press("z") + await render() + expect(isSlashPopupOpen(shell)).toBe(false) + expect(shell.overlayList).toBeNull() + expect(shell.prompt.value).toBe("/z") + expect(shell.overlayItems).not.toContain("(no matches)") + expect(frame()).not.toContain("(no matches)") + }) + }) + + test("description prose does not keep the slash list open", async () => { + await withShell(async ({ shell, press, render, frame }) => { + press("/") + press("p") + await render() + expect(isSlashPopupOpen(shell)).toBe(false) + expect(shell.overlayList).toBeNull() + expect(shell.prompt.value).toBe("/p") + expect(shell.overlayItems).not.toContain("(no matches)") + expect(frame()).not.toContain("(no matches)") + }) + }) }) describe("Ctrl+C exit", () => { diff --git a/src/tui/ramp-paint.test.ts b/src/tui/ramp-paint.test.ts index d29ab7240..505d8cf73 100644 --- a/src/tui/ramp-paint.test.ts +++ b/src/tui/ramp-paint.test.ts @@ -9,11 +9,7 @@ import { describe, expect, test } from "bun:test" import { withTestRenderer } from "./harness" -import { - RAMP_CYCLE_MS, - STALL_BLINK_BURST_MS, - STALL_BLINK_CYCLE_MS, -} from "./ramp" +import { RAMP_CYCLE_MS } from "./ramp" import { attachSessionBridge, createRecordingPort } from "./runtime-bridge" import { createAppShell } from "./shell" import { UI } from "./theme" @@ -231,7 +227,7 @@ describe("turn ramp paint", () => { ) }) - test("a stalled turn blinks a bang into the border, then settles to a static one", async () => { + test("silence past the stall notice still paints working, not a bang", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -249,26 +245,20 @@ describe("turn ramp paint", () => { await h.renderOnce() expect(slotGlyph(h.captureCharFrame())).toMatch(DENSITY) + // Past the notice threshold the watchdog may flash, but operator + // chrome keeps the working ramp — recovery is silent under the hood. advance(1_500) await h.renderOnce() - // Scoped to the slot: a bang anywhere else in the frame is not this. - const blinking = new Set<string>() - for (let i = 0; i < 4; i++) { - blinking.add(slotGlyph(h.captureCharFrame())) - advance(STALL_BLINK_CYCLE_MS / 2) - await h.renderOnce() - } - expect(blinking.has("!")).toBe(true) - expect(blinking.size).toBeGreaterThan(1) + expect(statusRow(h.captureCharFrame())).toContain("working") + expect(slotGlyph(h.captureCharFrame())).toMatch(DENSITY) + expect(slotGlyph(h.captureCharFrame())).not.toBe("!") - // Past the burst the alarm stops strobing but still reads as one. - advance(STALL_BLINK_BURST_MS * 2) - await h.renderOnce() - const settled = statusRow(h.captureCharFrame()) - expect(slotGlyph(settled)).toBe("!") - advance(STALL_BLINK_CYCLE_MS / 2) + // The slot keeps moving: still a live working pulse, not a settled bang. + const first = slotGlyph(h.captureCharFrame()) + advance(RAMP_CYCLE_MS / 4) await h.renderOnce() - expect(statusRow(h.captureCharFrame())).toBe(settled) + expect(slotGlyph(h.captureCharFrame())).not.toBe(first) + expect(slotGlyph(h.captureCharFrame())).toMatch(DENSITY) } finally { bridge.dispose() } diff --git a/src/tui/runner-host.test.ts b/src/tui/runner-host.test.ts index 1d09aea09..37c90acb6 100644 --- a/src/tui/runner-host.test.ts +++ b/src/tui/runner-host.test.ts @@ -129,19 +129,11 @@ describe("observeSessionFromSubAgents", () => { }) describe("mountRunnerHost chrome wiring", () => { - // CL-5731: the task-change callback was built (director writes tasks, - // getTasks()/onTasksChange exist) but had no live consumer — the chrome - // push mechanism type-checked fine with `subscribeChrome` omitted, so a - // director's task update never reached the shell. `subscribeChrome` is now - // a required dep (not optional) so that regression cannot type-check - // again, but the type alone does not prove the wiring actually runs: this - // test drives a real notify() call through mountRunnerHost end to end and - // asserts the task panel painted from it, the way the real runner's - // `emitter.emit("tasks", ...)` -> `subscribeChrome` -> `pushChrome` chain - // does. If `subscribeChrome`'s notify callback were ever dropped again - // (e.g. `deps.subscribeChrome?.(pushChrome)` silently no-op on undefined), - // this test fails because the second push never reaches the panel. - test("a live chrome push (subscribeChrome notify) repaints the task panel", async () => { + // CL-5731: subscribeChrome must stay wired end-to-end. formatChromeZones + // now parks both chrome strips (always null), so a tasks push must not + // paint the checklist — this test asserts the notify path still runs and + // leaves the task panel empty (rebuild later; live work is ● Task rows). + test("a live chrome push (subscribeChrome notify) does not auto-paint the task panel", async () => { const harness = await createHarness({ width: 80, height: 24 }) let liveTasks: readonly { title: string; status: "todo" | "doing" | "done" | "cancelled" }[] = [] let notify: (() => void) | undefined @@ -168,16 +160,17 @@ describe("mountRunnerHost chrome wiring", () => { expect(host.shell.taskBox.visible).toBe(false) expect(notify).toBeDefined() - // Mirrors createChatDirector's onTasksChange firing after a - // manage_tasks tool call: the live source changes, then the runner - // notifies the host — it does not push the new snapshot itself. + // Mirrors createChatDirector's onTasksChange: live source changes, then + // the runner notifies the host. formatChromeZones parks the checklist. liveTasks = [{ title: "wire task panel", status: "doing" }] notify?.() - expect(host.shell.taskBox.visible).toBe(true) + expect(host.shell.taskBox.visible).toBe(false) await harness.renderOnce() const frame = harness.captureCharFrame() - expect(frame).toContain("wire task panel") + expect(frame).not.toContain("wire task panel") + // Notify callback stayed registered — subscribe path ran without error. + expect(notify).toBeDefined() } finally { host.dispose() harness.destroy() @@ -205,14 +198,11 @@ describe("mountRunnerHost command surfaces", () => { settings: { read: () => ({ compactionMode: "llm", - sessionMode: "orchestrator", - sessionModeScope: "global", waitForApproval: true, telemetryEnabled: false, showPromptCost: false, }), setCompactionMode: () => {}, - setSessionMode: () => {}, setWaitForApproval: () => {}, setTelemetryEnabled: () => {}, setShowPromptCost: () => {}, @@ -255,7 +245,7 @@ describe("mountRunnerHost model picker", () => { host.refreshModels([{ provider: "xai", model: "grok-4" }], []) closeInsetOverlay(host.shell) expect(host.openSurface("models")).toBe(true) - expect(host.shell.overlayItems[0]).toBe("xai / grok-4 (current)") + expect(host.shell.overlayItems[0]).toBe("grok-4 * [xai] (current)") } finally { host.dispose() harness.destroy() @@ -288,7 +278,7 @@ describe("mountRunnerHost model picker", () => { { xai: { models: ["grok-4"] }, openai: { models: ["gpt-5"] } }, ) expect(host.openSurface("models")).toBe(true) - // Flat list: the new provider appears as a leaf `provider / model` row, + // Flat list: the new provider appears as a leaf `model * [provider]` row, // not a nested group to drill into. expect(host.shell.overlayItems.some((label) => label.includes("openai"))).toBe(true) expect(host.shell.overlayItems.some((label) => label.includes("gpt-5"))).toBe(true) @@ -329,6 +319,35 @@ describe("mountRunnerHost model picker", () => { } }) + test("Alt+D sets default on the focused row via onSetDefault", async () => { + const harness = await createHarness({ width: 80, height: 24 }) + const setDefault: string[] = [] + const host = await mountRunnerHost({ + title: "test", + eventEmitter: new EventEmitter(), + send: () => {}, + interrupt: () => {}, + providers: { xai: { models: ["grok-4"] } }, + onModelSelect: () => {}, + onSetDefault: (id) => setDefault.push(id), + commands: [], + onCommand: () => {}, + chrome: () => ({ agents: [] }), + subscribeChrome: () => () => {}, + subAgentSessions: () => [], + createRenderer: async () => harness.renderer, + }) + try { + expect(host.openSurface("models")).toBe(true) + const dKey = { name: "d", ctrl: false, meta: false, option: true } as KeyEvent + expect(runOverlayAction(host.shell, dKey)).toBe(true) + expect(setDefault).toEqual(["xai:grok-4"]) + } finally { + host.dispose() + harness.destroy() + } + }) + test("Alt+A opens the add-provider selector built from addProviderChoices", async () => { const harness = await createHarness({ width: 80, height: 24 }) const connected: string[] = [] @@ -470,7 +489,7 @@ describe("mountRunnerHost quit key", () => { } }) - // Quitting is Ctrl+C twice. The host claims no key of its own, so an empty + // Quitting is Ctrl+C. The host claims no key of its own, so an empty // prompt is not a special case: Ctrl+D stays the prompt's own binding. test("Ctrl+D at an empty prompt does not quit", async () => { const harness = await createHarness({ width: 80, height: 24 }) diff --git a/src/tui/runner-host.ts b/src/tui/runner-host.ts index 45b381f55..5fdd9da2d 100644 --- a/src/tui/runner-host.ts +++ b/src/tui/runner-host.ts @@ -89,6 +89,8 @@ export type RunnerHostDeps = { readonly onConnectProvider?: (providerName: string) => void /** `f` on a focused model row; runner owns the favorite persist + refresh. */ readonly onFavoriteToggle?: (id: string) => void + /** Alt+D on a focused model row; runner owns the default persist. */ + readonly onSetDefault?: (id: string) => void /** * Alt+A from the model picker: every first-class provider kind, read fresh * on each open so a just-connected account's count is current. @@ -267,6 +269,9 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise<RunnerHost> ...(deps.onFavoriteToggle !== undefined ? { onFavoriteToggle: deps.onFavoriteToggle } : {}), + ...(deps.onSetDefault !== undefined + ? { onSetDefault: deps.onSetDefault } + : {}), ...(deps.addProviderChoices !== undefined ? { addProviderChoices: deps.addProviderChoices } : {}), diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 666525c7d..9bffe0a9a 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -2,7 +2,6 @@ import { isAbsolute, join, resolve as resolvePath } from "node:path"; import { readFile } from "node:fs/promises"; import { EventEmitter } from "node:events"; import { - createAgent, defineAgent, defineTool, createDirectorRegistry, @@ -36,6 +35,7 @@ import { toolWatchdogFromSettings, markLastChangelogVersion, toggleFavoriteModel, + setDefaultModel, type ModelRef, type ResolvedProvider, type Settings, @@ -43,9 +43,9 @@ import { type PluginConfig, } from "../config/settings.js"; import { addProviderSelectorChoices, providerChoices } from "./provider-setup.js"; +import { persistConnectedSelection } from "./provider-setup-submit.js"; import { connectProviderInline } from "./provider-connect.js"; import { modelOptionId } from "./model-catalog.js"; -import type { SessionModeScope } from "./command-surfaces.js"; import { resolveWaitForApproval, type ToolWatchdogConfig } from "./tool-execution-watchdog.js"; import { attachApprovalBudget, createGateRequestApproval } from "./request-approval.js"; import { codexProfileFromProviderName, isCodexProviderName } from "../config/codex-providers.js"; @@ -68,6 +68,7 @@ import { createPluginLoadDiagnostics, emitPluginWarningLog, formatPluginWarningsSummary, + warningsForPluginEntry, } from "../plugins/diagnostics.js"; import { isPluginTrusted, @@ -84,7 +85,7 @@ import { trustPathPlugins, type PathTrustStore, } from "../trust/path-trust.js"; -import { registerCommandPlugins, registerWorkflowPlugins, isEnabledCommandPlugin, enablePluginConfig } from "../plugins/register.js"; +import { registerCommandPlugins, registerWorkflowPlugins, isEnabledCommandPlugin, isPluginModuleEnabled, enablePluginConfig } from "../plugins/register.js"; import { getCommand, listCommands, @@ -131,8 +132,7 @@ import { } from "../agent/tool-search.js"; import { detectLanguageServerAvailable } from "../agent/lsp-availability.js"; import { normalizeToolDefinitionsForProvider } from "../agent/tool-schema-normalize.js"; -import { resolveSessionMode, type SessionMode } from "../config/session-mode.js"; -import { promptSessionModeIfUnset } from "./session-mode-prompt.js"; +import { type SessionMode } from "../config/session-mode.js"; import { createFleetWatch, createSubAgentSessionStore, @@ -155,6 +155,7 @@ import { createPermissionsAdmin, type ScopedApproval } from "../permission/admin import type { GrantScope } from "../permission/types.js"; import { createAgentToolset, type MCPServerState, type OperatorResult } from "../agent/tools.js"; +import { createAgentWithLiveToolDispatch } from "../agent/live-tool-dispatch.js"; import { collectWebPlugins, resolveWebProviderFromPlugins, webBrand } from "../web/plugin-provider.js"; import { collectToolPlugins, resolveToolPlugins } from "../plugins/tool-plugins.js"; import { scrubSecrets } from "../web/secret-scrub.js"; @@ -162,11 +163,13 @@ import { setActiveWebProviderBrand } from "./tool-formatter.js"; import { consumeStream } from "../session/stream-consumer.js"; import { createCycleTextRecorder } from "../session/stream-journal.js"; import { mountRunnerHost } from "./runner-host.js"; +import { createRuntimeShutdown } from "./runtime-shutdown.js"; import { applyFocus, attachClipboardImage, setEffortCycleHandler, setMentionSuggestionSource, + setPluginNeedsAttention, setPromptModelLabel, setPromptRecognitionSource, setSentMessageHistory, @@ -552,16 +555,22 @@ export async function runTUI(initialConfig: Config): Promise<number> { telemetry: liveTelemetry, }); emitPluginWarningLog(pluginLoadDiag); - // Fire-and-forget startup diagnostics (this + tool-plugin resolution below) - // have no result channel back to an operator action, unlike verify/add-path/ - // trust-grant. A log-only summary is invisible — nobody watches - // ~/.corbits/logs/corbits.log — so these are queued and handed to - // the shell one at a time once it mounts. - const startupPluginNotices: string[] = []; - const discoveryNotice = formatPluginWarningsSummary(pluginLoadDiag.warnings); - if (discoveryNotice !== undefined) startupPluginNotices.push(discoveryNotice); + // Fire-and-forget startup diagnostics (this + tool-plugin / profile resolution + // below) have no result channel back to an operator action. Log-only is fine + // for the structured logger; the standing `plugin !` mark and `/plugins` + // surface carry the same warnings to the operator instead of a startup + // system notice. + const standingPluginWarnings: string[] = [...pluginLoadDiag.warnings]; + // Host mounts later; attention is painted once the shell exists. + let paintPluginAttention: ((needs: boolean) => void) | null = null; + const notePluginWarnings = (warnings: readonly string[]): void => { + if (warnings.length === 0) return; + standingPluginWarnings.push(...warnings); + paintPluginAttention?.(standingPluginWarnings.length > 0); + }; // Saved through onboarding's "save anyway" bypass without a passing // connection test — warn now instead of a bare adapter error on first send. + const startupPluginNotices: string[] = []; if (config.verified === false) { startupPluginNotices.push( `We couldn't confirm your "${config.providerName}" key works. If your first message fails with an auth error, double-check the key.`, @@ -836,8 +845,7 @@ export async function runTUI(initialConfig: Config): Promise<number> { ]); if (activeWeb !== undefined) setActiveWebProviderBrand(webBrand(activeWeb.name)); emitPluginWarningLog(toolPluginDiag); - const toolPluginNotice = formatPluginWarningsSummary(toolPluginDiag.warnings); - if (toolPluginNotice !== undefined) startupPluginNotices.push(toolPluginNotice); + standingPluginWarnings.push(...toolPluginDiag.warnings); // /plugins UI backend: discovered plugin descriptors plus live, persisted // config (enabled flag, credentials, web override, extra paths) written to the @@ -931,6 +939,7 @@ export async function runTUI(initialConfig: Config): Promise<number> { diagnostics: trustDiag, }); trustGrantMessage = formatPluginWarningsSummary(trustDiag.warnings); + notePluginWarnings(trustDiag.warnings); if (full !== null) { livePluginModules = livePluginModules.map((m) => m.manifest?.id === id ? full : m, @@ -983,6 +992,7 @@ export async function runTUI(initialConfig: Config): Promise<number> { // logging them: "loaded — N profiles" must not read identically whether // or not a profile's skill ref actually resolved. const warnings = formatPluginWarningsSummary(verifyDiag.warnings); + notePluginWarnings(verifyDiag.warnings); const base = `loaded — ${profiles.length} profile${profiles.length === 1 ? "" : "s"}`; return { ok: true, message: warnings === undefined ? base : `${base} (${warnings})` }; } @@ -1073,6 +1083,7 @@ export async function runTUI(initialConfig: Config): Promise<number> { if (!livePluginPaths.includes(abs)) livePluginPaths.push(abs); await persistPluginSettings(); const warnings = formatPluginWarningsSummary(addDiag.warnings); + notePluginWarnings(addDiag.warnings); return { ok: true, message: @@ -1120,10 +1131,7 @@ export async function runTUI(initialConfig: Config): Promise<number> { { diagnostics: profileDiag }, ); emitPluginWarningLog(profileDiag); - // Same fire-and-forget reasoning as the discovery/tool-plugin notices above: - // this runs before `host` exists, so it is queued rather than dropped. - const profileNotice = formatPluginWarningsSummary(profileDiag.warnings); - if (profileNotice !== undefined) startupPluginNotices.push(profileNotice); + standingPluginWarnings.push(...profileDiag.warnings); const initialProfiles = await loadAgentProfiles(profilesDir, pluginAgentProfiles); let liveAgentProfiles = initialProfiles; @@ -1133,7 +1141,7 @@ export async function runTUI(initialConfig: Config): Promise<number> { // Enabled plugin names, listed in the top-of-scrollback banner alongside skills. const activePlugins = executablePlugins() - .filter((m) => m.manifest?.id !== undefined && pluginConfig[m.manifest.id]?.enabled === true) + .filter((m) => isPluginModuleEnabled(m, pluginConfig)) .map((m) => m.manifest!.name ?? m.manifest!.id); const shellTimeout = shellTimeoutFromSettings(config.settings); @@ -1142,27 +1150,10 @@ export async function runTUI(initialConfig: Config): Promise<number> { const liveToolWatchdog: ToolWatchdogConfig = { ...(toolWatchdogFromSettings(config.settings) ?? {}), }; - const localSettingsForMode = await loadLocalSettings(localSettingsPath(config.cwd)).catch(() => null); - // A local override wins on read; the settings surface's scope switch mirrors - // that back so the operator sees which file a change would land in. - let liveSessionModeScope: SessionModeScope = localSettingsForMode?.sessionMode !== undefined ? "local" : "global"; - let liveSessionMode: SessionMode | undefined = resolveSessionMode(config.settings, localSettingsForMode); - if (liveSessionMode === undefined) { - const picked = await promptSessionModeIfUnset(config.globalSettingsPath); - liveSessionMode = picked ?? "orchestrator"; - if (picked !== undefined) { - const refreshed = await loadSettings(config.globalSettingsPath).catch((err: unknown) => { - // loadSettings already maps ENOENT → null; a throw is a real I/O or - // schema failure. Keep the in-memory config rather than pretending - // settings are empty. - tuiLogger.warn("Failed to reload settings after session mode pick: {error}", { - error: err instanceof Error ? err.message : String(err), - }); - return null; - }); - if (refreshed !== null) config = { ...config, settings: refreshed }; - } - } + // CL-5814: orchestrator is the only product path — no first-run mode picker. + const liveSessionMode: SessionMode = "orchestrator"; + // Local settings still supply shell env; sessionMode is ignored if present. + const localSettingsForEnv = await loadLocalSettings(localSettingsPath(config.cwd)).catch(() => null); const toolAvailability: ToolAvailability = { languageServerAvailable: detectLanguageServerAvailable(config.cwd), }; @@ -1181,7 +1172,7 @@ export async function runTUI(initialConfig: Config): Promise<number> { skillDirs, telemetry: liveTelemetry, ...(shellTimeout !== undefined ? { shellTimeout } : {}), - ...(localSettingsForMode?.env !== undefined ? { shellEnv: localSettingsForMode.env } : {}), + ...(localSettingsForEnv?.env !== undefined ? { shellEnv: localSettingsForEnv.env } : {}), toolWatchdog: liveToolWatchdog, getBlobReader: () => currentAgent.blobReader, isWorkflowActive: () => workflowControllerHolder.instance?.isActive() === true, @@ -1428,7 +1419,7 @@ export async function runTUI(initialConfig: Config): Promise<number> { const storage = await createOptimizedContextStore(workdir); const sources = liveSources.length > 0 ? liveSources : [liveSource]; const defaultSource = liveDefaultSource.length > 0 ? liveDefaultSource : liveSource.id; - return createAgent(def, { + return createAgentWithLiveToolDispatch(def, { sources, defaultSource, storage, @@ -1700,9 +1691,15 @@ export async function runTUI(initialConfig: Config): Promise<number> { }, }; - // A hard stop: closing the agent is the only thing that aborts the reactor - // mid-inference (the send signal only rejects the send promise). Close it, - // drain the old stream, and rebuild a fresh agent so the next send works. + // Hard stop only (Ctrl+C / doInterrupt). Soft steer (Enter mid-run enqueue) + // and follow-up (queued drain / deliver) must never call this — those paths + // leave in-flight workers running. Closing the agent is the only thing that + // aborts the reactor mid-inference (the send signal only rejects the send + // promise); that close cascades: operationController.abort → task-tool parent + // signal → child abort. Do not add cancelAll here — fleet cancelAll is + // reserved for /clear (newSession) and shutdown. + // Close it, drain the old stream, and rebuild a fresh agent so the next send + // works. const interrupt = (): void => { sendAborted = true; void enqueueOp(async () => { @@ -2199,6 +2196,25 @@ export async function runTUI(initialConfig: Config): Promise<number> { }); }); }, + onSetDefault: (id) => { + const sep = id.indexOf(":"); + if (sep <= 0) return; + const ref: ModelRef = { provider: id.slice(0, sep), model: id.slice(sep + 1) }; + void (async () => { + const onDisk = (await loadGlobalSettingsWriteBase(trueGlobalSettingsPath)) ?? { + providers: {}, + }; + const next = setDefaultModel(onDisk, ref); + await saveGlobalSettings(trueGlobalSettingsPath, next); + await persistConnectedSelection(localSettingsFile, ref.provider, ref.model); + config = { ...config, settings: next }; + systemNotice(`Default set to ${ref.model} (${ref.provider})`); + })().catch((err: unknown) => { + tuiLogger.debug("set default persist failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); + }, commands: listCommands().map((c) => ({ name: c.name, description: c.description })), onCommand: (name) => { const route = routeSubmission(name); @@ -2255,6 +2271,10 @@ export async function runTUI(initialConfig: Config): Promise<number> { const cfg = pluginsAdmin.getConfig(); return pluginsAdmin.list().map((p) => { const mod = livePluginModules.find((m) => m.manifest?.id === p.id); + const attributed = warningsForPluginEntry(standingPluginWarnings, { + id: p.id, + ...(p.agentProfiles !== undefined ? { agentProfiles: p.agentProfiles } : {}), + }); return { id: p.id, name: p.name, @@ -2269,6 +2289,7 @@ export async function runTUI(initialConfig: Config): Promise<number> { ...(p.needsTrust === true && mod?.pluginPath !== undefined ? { originPath: mod.pluginPath } : {}), + ...(attributed.length > 0 ? { warnings: attributed } : {}), }; }); }, @@ -2285,6 +2306,7 @@ export async function runTUI(initialConfig: Config): Promise<number> { webProviders: () => webPluginCandidates.map((c) => ({ id: c.id, name: c.name })), currentWebProvider: () => pluginsAdmin.getWebOverride(), setWebProvider: (id) => pluginsAdmin.setWebOverride(id), + loadWarnings: () => standingPluginWarnings, }, mcp: { list: () => @@ -2312,8 +2334,6 @@ export async function runTUI(initialConfig: Config): Promise<number> { settings: { read: () => ({ compactionMode: liveCompactionMode, - sessionMode: liveSessionMode ?? "orchestrator", - sessionModeScope: liveSessionModeScope, waitForApproval: resolveWaitForApproval(liveToolWatchdog), telemetryEnabled: liveTelemetryIntent, showPromptCost: liveShowPromptCost, @@ -2325,22 +2345,6 @@ export async function runTUI(initialConfig: Config): Promise<number> { compactionMode: mode, })); }, - setSessionMode: (mode, scope) => { - liveSessionMode = mode; - liveSessionModeScope = scope; - if (scope === "local") { - void persistLocalSettings("session mode", (base) => ({ ...base, sessionMode: mode })); - return; - } - // A stale local override would keep outranking this write on the next - // resolve (resolveSessionMode prefers local), so switching back to - // "everywhere" clears it rather than leaving it to shadow the global value. - void persistLocalSettings("session mode", (base) => { - const { sessionMode: _drop, ...rest } = base; - return rest; - }); - void persistGlobalSettings("session mode", (base) => ({ ...base, sessionMode: mode })); - }, setWaitForApproval: (value) => { liveToolWatchdog.waitForApproval = value; void persistGlobalSettings("wait-for-approval", (base) => ({ @@ -2380,7 +2384,16 @@ export async function runTUI(initialConfig: Config): Promise<number> { }, }); - disposeHost = host.dispose; + const shutdownRuntime = createRuntimeShutdown({ + disposeHost: host.dispose, + cancelWorkers: () => { + subAgentSessions.cancelAll("Session closed"); + }, + closeAgent: () => currentAgent.close(), + }); + disposeHost = () => { + void shutdownRuntime(); + }; setActiveDisposeHost(disposeHost); setMentionSuggestionSource(host.shell, (prefix) => listPathSuggestions(prefix, config.cwd)); @@ -2482,14 +2495,17 @@ export async function runTUI(initialConfig: Config): Promise<number> { // Connect MCP servers after the TUI is up so the UI is usable immediately and // any OAuth authorization is surfaced as a copyable link rather than a browser - // pop. Newly discovered tools are advertised to the live director right away; - // once connection resolves, the agent is reloaded (when idle) so the tools are - // also dispatchable. Aborted on exit so an unfinished auth wait does not keep - // the process alive. + // pop. Each connected server's tools land on the live runner and are + // dispatchable the same turn (createAgentWithLiveToolDispatch). They stay + // unadvertised until tool_search promotes them. When every server has + // settled, reload-if-idle so construction-time maps match, then resume any + // persisted workflow. Aborted on exit so an unfinished auth wait does not + // keep the process alive. const mcpConnectController = new AbortController(); void toolset .connectMCP( { + interactiveAuth: true, onStatus: (status) => { mcpStates.set(status.name, status); emitter.emit("mcp.status", status); @@ -2527,14 +2543,17 @@ export async function runTUI(initialConfig: Config): Promise<number> { }); }); - // Surface fire-and-forget startup plugin diagnostics now that there is a - // shell to say them to (queued above, before `host` existed). + // Surface fire-and-forget startup notices now that there is a shell (queued + // above, before `host` existed). Plugin load warnings are NOT notices — they + // drive `plugin !` and `/plugins` instead. for (const notice of startupPluginNotices) surfaceSystemNotice(host.shell, notice); + paintPluginAttention = (needs) => setPluginNeedsAttention(host.shell, needs); + paintPluginAttention(standingPluginWarnings.length > 0); // Soft upgrade check: never blocks startup; offline / rate-limit is a quiet skip. // surfaceSystemNotice keeps the landing hero up and flushes into the transcript - // once a session row ends the landing (same path as plugin/MCP startup chatter). + // once a session row ends the landing (same path as MCP startup chatter). scheduleUpgradeNotice({ notify: (text) => surfaceSystemNotice(host.shell, text), options: { @@ -2543,6 +2562,9 @@ export async function runTUI(initialConfig: Config): Promise<number> { }); await host.waitUntilExit(); + // Stop inference and every worker before persistence, hooks, or telemetry can + // delay process exit. Closing the terminal is a process-lifetime boundary. + await shutdownRuntime(); clearInterval(fleetStallPoll); if (fleetSettle !== null) clearTimeout(fleetSettle); unsubscribeFleetReport(); @@ -2609,11 +2631,6 @@ export async function runTUI(initialConfig: Config): Promise<number> { await getTelemetry().flush(); await sessionOps.awaitTail(); - try { - await currentAgent.close(); - } catch { - // ignore - } try { await streamPromise; } catch { diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index effec6e99..534b50fcc 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -6,7 +6,9 @@ import { mapReactorLike, type TaskProgressSession, } from "./runtime-bridge" -import { appendStreamRow, createAppShell, streamRowCount } from "./shell" +import { DEFAULT_STALL_MS } from "./agent-progress" +import { appendStreamRow, createAppShell, paintChrome, streamRowCount } from "./shell" +import { STEER_WAIT_NOTICE_MS } from "./notice-line" import { withTestRenderer } from "./harness" import { badgeCount } from "./session-queue" @@ -91,8 +93,7 @@ describe("attachSessionBridge", () => { await h.renderOnce() expect(port.calls.some((c) => c.op === "enqueue")).toBe(true) const enq = port.calls.find((c) => c.op === "enqueue") - // Plain Enter mid-run always steers now — "queue and wait quietly" - // isn't a separate gesture from "queue to steer" anymore. + // Plain Enter mid-run soft-steers (CL-6290). Follow-up is Alt+Enter. expect(enq).toEqual({ op: "enqueue", text: "queued please", @@ -101,7 +102,7 @@ describe("attachSessionBridge", () => { expect(badgeCount(shell.session)).toBe(1) expect(shell.pendingQueue).toBe(1) const frame = h.captureCharFrame() - expect(frame).toMatch(/queue\s+1|pending\s+1|·\s*1/) + expect(frame).toMatch(/steer\s+1/) } finally { bridge.dispose() shell.dispose() @@ -111,28 +112,34 @@ describe("attachSessionBridge", () => { ) }) - test("Alt+Enter mid-run hard-stops and reinjects, not a boundary wait", async () => { + test("Alt+Enter mid-run enqueues follow-up (queue), never interrupts", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { terminal: { columns: 80, rows: 24 }, - wireKeys: true, + wireKeys: false, run: "busy", }) const port = createRecordingPort() const bridge = attachSessionBridge(shell, port) try { // Direct bridge path (Alt+Enter chord is terminal-dependent in mock). - bridge.submit("stop now", "reinject") + bridge.submit("follow up later", "queue") await h.renderOnce() - // No enqueue at all — this never waits for a boundary. It - // interrupts the live run, then sends straight through. - expect(port.calls.some((c) => c.op === "enqueue")).toBe(false) - expect(port.calls.map((c) => c.op)).toEqual(["interrupt", "sendImmediate"]) - const sent = port.calls.find((c) => c.op === "sendImmediate") - expect(sent).toEqual({ op: "sendImmediate", text: "stop now" }) + expect(port.calls.some((c) => c.op === "interrupt")).toBe(false) + expect(port.calls.some((c) => c.op === "sendImmediate")).toBe(false) + expect(port.calls.some((c) => c.op === "enqueue")).toBe(true) + const enq = port.calls.find((c) => c.op === "enqueue") + expect(enq).toEqual({ + op: "enqueue", + text: "follow up later", + kind: "queue", + }) expect(shell.session.run).toBe("busy") - expect(badgeCount(shell.session)).toBe(0) + expect(badgeCount(shell.session)).toBe(1) + const frame = h.captureCharFrame() + expect(frame).toMatch(/follow-up\s+1/) + expect(frame).toContain("will follow up") } finally { bridge.dispose() shell.dispose() @@ -236,7 +243,7 @@ describe("attachSessionBridge", () => { ) }) - test("queued item delivers at tool.boundary", async () => { + test("steer delivers at tool.boundary; follow-up does not", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -247,8 +254,9 @@ describe("attachSessionBridge", () => { const port = createRecordingPort() const bridge = attachSessionBridge(shell, port) try { + bridge.submit("steer now", "steer") bridge.submit("follow up", "queue") - expect(badgeCount(shell.session)).toBe(1) + expect(badgeCount(shell.session)).toBe(2) port.clear() bridge.handle({ type: "tool.done", @@ -261,18 +269,130 @@ describe("attachSessionBridge", () => { }, }, }) - expect(badgeCount(shell.session)).toBe(0) + // Soft steer drained; follow-up still pending. + expect(badgeCount(shell.session)).toBe(1) + expect(shell.session.items[0]!.kind).toBe("queue") const deliver = port.calls.find((c) => c.op === "deliver") expect(deliver).toEqual({ op: "deliver", item: expect.objectContaining({ - text: "follow up", - kind: "queue", + text: "steer now", + kind: "steer", }), }) await h.renderOnce() const frame = h.captureCharFrame() - expect(frame).toContain("follow up") + expect(frame).toContain("steer now") + expect(frame).toMatch(/follow-up\s+1/) + } finally { + bridge.dispose() + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + + test("steer is not delivered while a parent tool is in flight", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }) + const port = createRecordingPort() + const bridge = attachSessionBridge(shell, port) + try { + bridge.handle({ + type: "tool.start", + data: { call: { id: "c1", name: "run_shell" } }, + }) + bridge.submit("steer now", "steer") + expect(port.calls.some((c) => c.op === "deliver")).toBe(false) + expect(badgeCount(shell.session)).toBe(1) + } finally { + bridge.dispose() + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + + test("notice names the in-flight command after STEER_WAIT_NOTICE_MS", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }) + let clock = 0 + const port = createRecordingPort() + const bridge = attachSessionBridge(shell, port, { + now: () => clock, + schedule: () => () => {}, + }) + try { + bridge.handle({ + type: "tool.start", + data: { call: { id: "c1", name: "run_shell" } }, + }) + bridge.submit("steer now", "steer") + + clock = STEER_WAIT_NOTICE_MS - 1 + shell.lockupNowMs = clock + paintChrome(shell) + await h.renderOnce() + expect(h.captureCharFrame()).not.toContain("waiting on") + + clock = STEER_WAIT_NOTICE_MS + shell.lockupNowMs = clock + paintChrome(shell) + await h.renderOnce() + expect(h.captureCharFrame()).toContain("waiting on run_shell") + + bridge.handle({ type: "tool.boundary" }) + bridge.submit("follow up", "queue") + clock = 5000 + shell.lockupNowMs = clock + paintChrome(shell) + await h.renderOnce() + expect(h.captureCharFrame()).not.toContain("waiting on") + } finally { + bridge.dispose() + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + + test("follow-up drains on idle, after any remaining steers", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }) + const port = createRecordingPort() + const bridge = attachSessionBridge(shell, port) + try { + bridge.submit("follow up", "queue") + bridge.submit("late steer", "steer") + expect(badgeCount(shell.session)).toBe(2) + port.clear() + bridge.handle({ type: "run", state: "idle" }) + expect(badgeCount(shell.session)).toBe(0) + expect( + port.calls.flatMap((c) => (c.op === "deliver" ? [c.item.text] : [])), + ).toEqual(["late steer", "follow up"]) + await h.renderOnce() + const frame = h.captureCharFrame() + expect(frame).toContain("following up") + expect(frame).toContain("steering") } finally { bridge.dispose() shell.dispose() @@ -771,7 +891,7 @@ describe("syncAgentProgress", () => { expect(row.agentWorking).toBe(true) expect(row.stat).toContain("grep") - nowMs = 72_000 + nowMs = 42_000 + DEFAULT_STALL_MS bridge.syncAgentProgress([ taskSession({ currentToolName: "grep", lastActivityAt: 42_000 }), ]) diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 3507ff5f5..8ff0866ff 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -305,8 +305,9 @@ type OpenStreamRow = { readonly startedAt: number text: string /** - * Bounded-rate reveal position for a "thinking" row's scroll line. Unused - * for "assistant" rows, which paint their full markdown body as it grows. + * Bounded-rate reveal position for a "thinking" row's wrapped preview. + * Unused for "assistant" rows, which paint their full markdown body as it + * grows. */ revealChars: number /** Clock `revealChars` was last advanced from. */ @@ -370,8 +371,9 @@ type BridgeBag = { */ agentSessions: readonly TaskProgressSession[] /** - * callIds whose call painted no row because the work belongs to a panel. - * Tracked so the matching result is dropped rather than landing unpaired. + * callIds whose call painted no row because the work belongs to a panel + * (`manage_tasks`). Tracked so the matching result is dropped rather than + * landing unpaired. */ panelOnlyCallIds: Set<string> /** @@ -545,7 +547,8 @@ function applyToolCall( ): void { if (event.name === MANAGE_TASKS_TOOL_NAME) { // Remembered so the matching result is dropped too — suppressing only the - // call would leave its result to land as an unpaired row. + // call would leave its result to land as an unpaired row. Checklist lives + // on the task panel; Task dispatches paint live transcript rows instead. if (event.callId !== undefined) bag.panelOnlyCallIds.add(event.callId) return } @@ -566,6 +569,7 @@ function applyToolCall( bag.taskCallIds.add(event.callId) } bag.lastToolRow = index + shell.inFlightTool = { name: event.name, startedAt: bag.now() } } /** @@ -590,6 +594,7 @@ function applyToolResult( bag.toolRows.delete(event.callId) bag.taskCallIds.delete(event.callId) } + if (bag.toolRows.size === 0) shell.inFlightTool = null const index = tracked ?? bag.lastToolRow const call = streamRowAt(shell, index) if (call === undefined || call.pending !== true) { @@ -602,13 +607,9 @@ function applyToolResult( /** * Refresh every outstanding `task` call's row with its worker's live progress — * elapsed time, current tool, and whether it has gone quiet. Rewrites each row - * in place through `replaceStreamRowAt` (the same path a tool result resolves - * through); a session that finished, or is missing from `sessions` (already - * pruned, or never started), leaves its row untouched rather than reverting to - * a bare pending mark. - * - * Bounded by outstanding task calls, not transcript length: an idle sub-agent - * dispatch costs nothing here, and a live one costs exactly one row rewrite. + * in place through `replaceStreamRowAt`; a session that finished, or is missing + * from `sessions`, leaves its row untouched rather than reverting to a bare + * pending mark. */ function syncAgentProgress( shell: AppShell, @@ -672,8 +673,29 @@ function drainAtBoundary(shell: AppShell, bag: BridgeBag): void { appendStreamRow(shell, { role: "user", text: userRowText(item.text, item.attachments ?? []), - // Distinct from the "steer" tag on the still-pending row above — this - // one is being handed to the run right now, not waiting for one. + // Distinct from the still-pending "steer"/"queue" tag — this row is + // being handed to the run right now. Follow-ups must not say steering. + meta: item.kind === "steer" ? "steering" : "following-up", + }) + bag.pendingEchoes.push(item.text.trim()) + bag.port.deliver(item) + } + paintChrome(shell) +} + +/** + * Soft steer only (CL-6290): tool.boundary drains steers; follow-ups wait + * for idle / interrupt. Full drain uses drainOrder via drainOne without a + * kind filter. + */ +function drainSteersAtBoundary(shell: AppShell, bag: BridgeBag): void { + for (;;) { + const { state, item } = drainOne(shell.session, "steer") + if (!item) break + shell.session = state + appendStreamRow(shell, { + role: "user", + text: userRowText(item.text, item.attachments ?? []), meta: "steering", }) bag.pendingEchoes.push(item.text.trim()) @@ -715,17 +737,22 @@ function applyInbound( if (event.type === "user" && consumeEcho(bag, event.text)) return if (event.type === "run") { - if (event.state === "idle") bag.turnThinking = null + if (event.state === "idle") { + bag.turnThinking = null + shell.inFlightTool = null + } shell.session = setRunState(shell.session, event.state) paintChrome(shell) if (event.state === "idle") { + // Full drain: soft steers first, then follow-ups (drainOrder). drainAtBoundary(shell, bag) } return } if (event.type === "tool.boundary") { - drainAtBoundary(shell, bag) + // Soft steer only — follow-ups wait until the run goes idle. + drainSteersAtBoundary(shell, bag) return } @@ -937,11 +964,10 @@ export function attachSessionBridge( applyInbound(shell, bag, mapped) } // inference.done with tool calls still outstanding doesn't settle the - // turn (see turn-state.ts) — the cycle continues, but a boundary still - // passed, so a queued message waiting on it should not wait for the - // turn's eventual end too. + // turn (see turn-state.ts) — the cycle continues, but a soft-steer + // boundary still passed. Follow-ups wait for idle. if (onTurnBoundary(event) && bag.turn.activeToolCalls.length > 0) { - drainAtBoundary(shell, bag) + drainSteersAtBoundary(shell, bag) } if (settled) settleRun() return @@ -986,8 +1012,9 @@ export function attachSessionBridge( } if (kind === "reinject") { - // Not a boundary wait: stop the run right now, then fall straight into - // the immediate-send branch below with this message as the opener. + // No product chord wires reinject anymore (CL-6290: Alt+Enter is + // follow-up / kind "queue"). Kept for tests and any direct API callers: + // stop the run right now, then fall into the immediate-send branch. if (shell.session.run !== "busy") return closeOpenRow(shell, bag) bag.pendingEchoes.length = 0 diff --git a/src/tui/runtime-channels.test.ts b/src/tui/runtime-channels.test.ts index 857f1d14c..c4754b6ee 100644 --- a/src/tui/runtime-channels.test.ts +++ b/src/tui/runtime-channels.test.ts @@ -99,7 +99,7 @@ describe("hook channel", () => { }) describe("mcp.status channel", () => { - test("a server awaiting authorization takes a notice segment, not a transcript row", async () => { + test("a server awaiting authorization takes a prompt-box mark, not a transcript row", async () => { const { host, emitter, frame, cleanup } = await mountHeadless() try { emitter.emit("mcp.status", { @@ -108,7 +108,8 @@ describe("mcp.status channel", () => { url: "https://mcp.test/auth", }) const painted = await frame() - expect(painted).toContain("mcp linear needs auth (/mcp)") + expect(painted).toContain("mcp !") + expect(painted).not.toContain("needs auth") expect(painted).not.toContain("https://mcp.test/auth") expect(host.shell.streamLog).toEqual([]) } finally { @@ -116,15 +117,15 @@ describe("mcp.status channel", () => { } }) - test("connected clears the standing auth segment from state and the painted frame", async () => { + test("connected clears the standing auth mark from state and the painted frame", async () => { const { host, emitter, frame, cleanup } = await mountHeadless() try { emitter.emit("mcp.status", { name: "linear", state: "needs-auth", url: "https://x/a" }) - expect(await frame()).toContain("needs auth") + expect(await frame()).toContain("mcp !") emitter.emit("mcp.status", { name: "linear", state: "connected", tools: ["a"] }) const painted = await frame() expect(host.shell.mcpNeedsAuth).toEqual([]) - expect(painted).not.toContain("needs auth") + expect(painted).not.toContain("mcp !") } finally { cleanup() } @@ -196,49 +197,51 @@ describe("permission.grant channel", () => { }) }) -describe("subagent.progress channel", () => { - test("the live tool name reaches the agents chrome zone", async () => { - const { host, emitter, frame, cleanup } = await mountHeadless({ +describe("agents chrome (zone off — transcript Task rows own live lanes)", () => { + test("setChrome with running agents does not paint an agents zone", async () => { + const { host, frame, cleanup } = await mountHeadless({ chrome: { agents: [ - { agentId: "explore", description: "map callers", status: "running", currentToolStartedAt: null }, + { + agentId: "explore", + description: "map callers", + status: "running", + currentToolName: "grep", + currentToolStartedAt: null, + }, ], }, }) try { - expect(await frame()).not.toContain("grep") - emitter.emit("subagent.progress", { - description: "map callers", - toolName: "grep", - }) - // The board right-aligns each lane's tail into a column, so the tool - // name is on the row but no longer adjacent to the description. + // Fleet board chrome is off: live lane status rides transcript Task rows, + // not a dedicated agents zone. Injecting agents into chrome must not paint + // them into the frame or the transcript. const painted = await frame() - expect(painted).toContain("map callers") - expect(painted).toContain("grep") - // Progress is chrome, never a transcript row: one line per worker tool - // call would bury the turn it is a detail of. + expect(painted).not.toContain("map callers") + expect(painted).not.toContain("grep") expect(host.shell.streamLog).toEqual([]) } finally { cleanup() } }) - test("a later chrome push keeps the live tool name", async () => { - const { host, emitter, frame, cleanup } = await mountHeadless() + test("a later chrome push still leaves the agents zone empty", async () => { + const { host, frame, cleanup } = await mountHeadless() try { - emitter.emit("subagent.progress", { - description: "map callers", - toolName: "grep", - }) host.setChrome({ agents: [ - { agentId: "explore", description: "map callers", status: "running", currentToolStartedAt: null }, + { + agentId: "explore", + description: "map callers", + status: "running", + currentToolName: "grep", + currentToolStartedAt: null, + }, ], }) const painted = await frame() - expect(painted).toContain("map callers") - expect(painted).toContain("grep") + expect(painted).not.toContain("map callers") + expect(painted).not.toContain("grep") } finally { cleanup() } @@ -250,6 +253,12 @@ describe("subagent.progress channel", () => { * anywhere is a feature nobody can see, and it fails silently. Static because * the subscribers are spread across the runner itself and the product host, * and only some of them exist at any one mount. + * + * `subagent.progress` is still emitted by the runner for external listeners, + * but the product host no longer paints from it — tool state rides the + * subagent store (`currentToolName` + clock) via setChrome. Drop it from the + * "must have a .on somewhere" set so a deliberate non-subscriber is not a + * false alarm. */ describe("every emitted runtime channel has a subscriber", () => { const srcDir = fileURLToPath(new URL("../", import.meta.url)) @@ -258,13 +267,14 @@ describe("every emitted runtime channel has a subscriber", () => { const emitted = new Set( [...runner.matchAll(/emitter\.emit\("([a-z.]+)"/g)].map((m) => m[1]!), ) + // Progress pings are store-mirrored chrome, not a host paint path. + emitted.delete("subagent.progress") test("the runner still emits the channels this suite knows about", () => { for (const channel of [ "hook", "mcp.status", "permission.grant", - "subagent.progress", ]) { expect([...emitted]).toContain(channel) } diff --git a/src/tui/runtime-notices.test.ts b/src/tui/runtime-notices.test.ts index 29ae90737..e1545a146 100644 --- a/src/tui/runtime-notices.test.ts +++ b/src/tui/runtime-notices.test.ts @@ -83,7 +83,7 @@ describe("mcpNotice", () => { ).toEqual({ kind: "flash", text: "mcp linear connected · 2 tools" }) }) - test("needs-auth says nothing — the notice row and /mcp own it", () => { + test("needs-auth says nothing — the prompt box and /mcp own it", () => { expect( mcpNotice({ name: "linear", state: "needs-auth", url: "https://x/auth" }), ).toBeNull() diff --git a/src/tui/runtime-notices.ts b/src/tui/runtime-notices.ts index d8a9ab830..ea77a00d4 100644 --- a/src/tui/runtime-notices.ts +++ b/src/tui/runtime-notices.ts @@ -75,7 +75,7 @@ export function hookNotice(event: LifecycleHookEvent): RuntimeNotice | null { * MCP connection state. Reconnect chatter is noise on every server every run; * a server refusing to connect changes what the agent can do, so it keeps a * row. A server waiting on authorization is a standing condition with an - * action attached, which is the notice row's and /mcp's job, not a row's. + * action attached, which is the prompt box's and /mcp's job, not a row's. */ export function mcpNotice(state: MCPServerState): RuntimeNotice | null { switch (state.state) { @@ -89,7 +89,7 @@ export function mcpNotice(state: MCPServerState): RuntimeNotice | null { } } // A raw authorization URL in the transcript is unactionable and scrolls - // away. The notice row counts these and /mcp does the authorizing. + // away. The prompt box marks these and /mcp does the authorizing. case "needs-auth": return null case "failed": diff --git a/src/tui/runtime-shutdown.test.ts b/src/tui/runtime-shutdown.test.ts new file mode 100644 index 000000000..4d64d0266 --- /dev/null +++ b/src/tui/runtime-shutdown.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" + +import { createRuntimeShutdown } from "./runtime-shutdown.js" + +describe("runtime shutdown", () => { + test("restores the terminal, cancels workers, and closes the primary agent", async () => { + const calls: string[] = [] + const shutdown = createRuntimeShutdown({ + disposeHost: () => calls.push("host"), + cancelWorkers: () => calls.push("workers"), + closeAgent: async () => { + calls.push("agent") + }, + }) + + await shutdown() + + expect(calls).toEqual(["host", "workers", "agent"]) + }) + + test("runs teardown only once when exit and a signal race", async () => { + const calls: string[] = [] + const shutdown = createRuntimeShutdown({ + disposeHost: () => calls.push("host"), + cancelWorkers: () => calls.push("workers"), + closeAgent: async () => { + calls.push("agent") + }, + }) + + await Promise.all([shutdown(), shutdown()]) + + expect(calls).toEqual(["host", "workers", "agent"]) + }) + + test("still aborts workers and the primary agent when host disposal fails", async () => { + const calls: string[] = [] + const shutdown = createRuntimeShutdown({ + disposeHost: () => { + calls.push("host") + throw new Error("renderer failure") + }, + cancelWorkers: () => calls.push("workers"), + closeAgent: async () => { + calls.push("agent") + }, + }) + + await shutdown() + + expect(calls).toEqual(["host", "workers", "agent"]) + }) +}) diff --git a/src/tui/runtime-shutdown.ts b/src/tui/runtime-shutdown.ts new file mode 100644 index 000000000..c9f3f295b --- /dev/null +++ b/src/tui/runtime-shutdown.ts @@ -0,0 +1,33 @@ +export type RuntimeShutdownDeps = { + disposeHost: () => void + cancelWorkers: () => void + closeAgent: () => Promise<void> +} + +/** Start every process-owned teardown path once, even when exit races a signal. */ +export function createRuntimeShutdown(deps: RuntimeShutdownDeps): () => Promise<void> { + let started = false + let completion = Promise.resolve() + + return (): Promise<void> => { + if (started) return completion + started = true + + try { + deps.disposeHost() + } catch { + // Every teardown leg is best-effort; one failure must not strand the rest. + } + try { + deps.cancelWorkers() + } catch { + // The primary agent still needs its abort even if a worker hook misbehaves. + } + try { + completion = deps.closeAgent().catch(() => undefined) + } catch { + completion = Promise.resolve() + } + return completion + } +} diff --git a/src/tui/session-chrome.test.ts b/src/tui/session-chrome.test.ts index 3c57c4d0e..b2197e80b 100644 --- a/src/tui/session-chrome.test.ts +++ b/src/tui/session-chrome.test.ts @@ -54,7 +54,7 @@ describe("resolveTurnLabel closed-set guarantee", () => { }) } - test("a stalled turn renders a distinct stalled state", () => { + test("a silent turn still reads as ordinary work to the operator", () => { const label = resolveTurnLabel( { isProcessing: true, @@ -65,7 +65,8 @@ describe("resolveTurnLabel closed-set guarantee", () => { true, null, ) - expect(label).toBe("stalled") + // Recovery is silent — never paint "stalled" in the ticker. + expect(label).toBe("building") expect(ACTIVITY_STATES).toContain(label!) }) @@ -185,10 +186,10 @@ describe("resolveRampPhase", () => { expect(resolveRampPhase({ ...base, status: "stopping" }, false, null)).toBe("working") }) - test("a stalled running turn paints stalled, not working", () => { + test("a silent running turn still paints working, not stalled", () => { expect( resolveRampPhase({ ...base, status: "running" }, true, null), - ).toBe("stalled") + ).toBe("working") }) test("a blocked gate beats stalled — waiting on you outranks silence", () => { @@ -298,21 +299,21 @@ describe("fleet state in the top-level indicator", () => { stalled, }) - test("a healthy fleet reads as orchestrating, not the parent's own tool", () => { + test("a healthy fleet reads as working, not the parent's own tool", () => { const label = resolveTurnLabel(parentAwaitingChildren, false, fleet(6, 0)) - expect(label).toBe("orchestrating") + expect(label).toBe("working") expect(ACTIVITY_STATES).toContain(label!) }) - test("a stalled lane surfaces at the top level instead of staying on its row", () => { - expect(resolveTurnLabel(parentAwaitingChildren, false, fleet(6, 1))).toBe("stalled") - expect(resolveRampPhase(parentAwaitingChildren, false, fleet(6, 1))).toBe("stalled") + test("a quiet fleet still reads working at the top level", () => { + expect(resolveTurnLabel(parentAwaitingChildren, false, fleet(6, 1))).toBe("working") + expect(resolveRampPhase(parentAwaitingChildren, false, fleet(6, 1))).toBe("working") }) // The parent is idle by design while children run, so its own stall clock // firing says nothing about whether the session is progressing. test("live lanes outrank the parent's own stall clock", () => { - expect(resolveTurnLabel(parentAwaitingChildren, true, fleet(6, 0))).toBe("orchestrating") + expect(resolveTurnLabel(parentAwaitingChildren, true, fleet(6, 0))).toBe("working") expect(resolveRampPhase(parentAwaitingChildren, true, fleet(6, 0))).toBe("working") }) @@ -321,8 +322,8 @@ describe("fleet state in the top-level indicator", () => { expect(resolveTurnLabel(parentAwaitingChildren, false, none)).toBe( resolveTurnLabel(parentAwaitingChildren, false, null), ) - expect(resolveTurnLabel(parentAwaitingChildren, true, none)).toBe("stalled") - expect(resolveRampPhase(parentAwaitingChildren, true, none)).toBe("stalled") + expect(resolveTurnLabel(parentAwaitingChildren, true, none)).toBe("planning") + expect(resolveRampPhase(parentAwaitingChildren, true, none)).toBe("working") }) test("a blocked gate still outranks the fleet", () => { diff --git a/src/tui/session-chrome.ts b/src/tui/session-chrome.ts index 9fd05375b..2bd549a9c 100644 --- a/src/tui/session-chrome.ts +++ b/src/tui/session-chrome.ts @@ -40,7 +40,6 @@ export const ACTIVITY_STATES = [ "building", "working", "waiting", - "orchestrating", "stalled", "stopping", ] as const @@ -100,13 +99,14 @@ export function resolveTurnLabel( if (input.status === "stopping" || input.status === "stopped") { return "stopping" } - // Live lanes outrank the parent's own stall clock: while sub-agents run the - // parent is idle by design, so its silence says nothing about the session. - // The fleet is the thing actually working, so it is the thing reported. + // Live fleet means the session is working — recovery is silent. Never paint + // "stalled" for the operator; the orchestrator keeps lanes moving. if (fleet !== null && fleet.running > 0) { - return fleet.stalled > 0 ? "stalled" : "orchestrating" + return "working" } - if (isStalled) return "stalled" + // Parent silence is still work-in-progress from the operator's POV; nudge + // paths handle recovery without renaming the ticker. + void isStalled if (input.currentToolName !== null) return activityStateForTool(input.currentToolName) if (input.streamingType === "thinking") return "thinking" return "working" @@ -125,10 +125,12 @@ export function resolveRampPhase( ): RampPhase { if (input.status === "blocked") return "blocked" if (input.status === "done") return "done" + // Operator chrome never enters the stalled ramp: fleet or parent silence is + // still "working" while recovery runs under the hood. if (fleet !== null && fleet.running > 0) { - return fleet.stalled > 0 ? "stalled" : "working" + return "working" } - if (isStalled) return "stalled" + void isStalled return "working" } diff --git a/src/tui/session-mode-prompt.ts b/src/tui/session-mode-prompt.ts deleted file mode 100644 index 2099e8c64..000000000 --- a/src/tui/session-mode-prompt.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { - loadSettings, - saveGlobalSettings, - type Settings, -} from "../config/settings.js"; -import type { SessionMode } from "../config/session-mode.js"; -import { COMMAND_NAME } from "../branding.js"; -import { runListModal } from "./list-modal.js"; - -const OPTIONS: readonly { mode: SessionMode; title: string; description: string }[] = [ - { - mode: "single", - title: "Single agent", - description: - "one agent edits, runs commands, and answers directly; sub-agents off", - }, - { - mode: "orchestrator", - title: "Orchestrator", - description: - "top-level agent delegates via task, manages parallel workers, synthesizes reports", - }, -]; - -function isSessionMode(value: string): value is SessionMode { - return OPTIONS.some((option) => option.mode === value); -} - -export async function promptSessionModeIfUnset( - globalSettingsPath: string, -): Promise<SessionMode | undefined> { - const existing = await loadSettings(globalSettingsPath); - if (existing?.sessionMode !== undefined) return existing.sessionMode; - - const picked = await runListModal({ - title: "Session mode", - kind: "settings", - heading: [ - "Choose how the primary session behaves.", - "Change it later in Settings or via sessionMode in global settings.", - ], - options: OPTIONS.map((option) => ({ - id: option.mode, - label: `${option.title} — ${option.description}`, - })), - }); - - if (picked === null || !isSessionMode(picked)) return undefined; - - const base: Settings = existing ?? { providers: {} }; - try { - await saveGlobalSettings(globalSettingsPath, { ...base, sessionMode: picked }); - } catch (err) { - // The choice is unusable if it cannot be persisted; fall back to the - // unset path so the next launch asks again. - process.stderr.write( - `${COMMAND_NAME}: could not save session mode: ${err instanceof Error ? err.message : String(err)}\n`, - ); - return undefined; - } - return picked; -} diff --git a/src/tui/session-queue.test.ts b/src/tui/session-queue.test.ts index 4cff4262b..57f7cb630 100644 --- a/src/tui/session-queue.test.ts +++ b/src/tui/session-queue.test.ts @@ -6,10 +6,13 @@ import { createSessionQueue, drainOne, drainOrder, + drainSteersOnly, enqueue, enqueueSteer, interrupt, + queueCount, setRunState, + steerCount, } from "./session-queue" describe("session-queue", () => { @@ -19,7 +22,7 @@ describe("session-queue", () => { expect(badgeCount(s0)).toBe(0) }) - test("Enter path enqueues; badge increments", () => { + test("default enqueue is follow-up kind", () => { let s = createSessionQueue("busy") s = enqueue(s, "hello") s = enqueue(s, "world") @@ -28,11 +31,13 @@ describe("session-queue", () => { expect(s.items[0]!.text).toBe("hello") }) - test("Alt+Enter path steers; same badge pool", () => { + test("steer and follow-up counts are distinct", () => { let s = createSessionQueue("busy") s = enqueue(s, "later") s = enqueueSteer(s, "asap") expect(badgeCount(s)).toBe(2) + expect(steerCount(s)).toBe(1) + expect(queueCount(s)).toBe(1) expect(s.items[1]!.kind).toBe("steer") }) @@ -52,6 +57,29 @@ describe("session-queue", () => { expect(d3.item?.text).toBe("q1") }) + test("drainOne(kind) is selective; drainSteersOnly leaves follow-ups", () => { + let s = createSessionQueue("busy") + s = enqueue(s, "q1") + s = enqueueSteer(s, "s1") + s = enqueue(s, "q2") + s = enqueueSteer(s, "s2") + + const onlySteer = drainOne(s, "steer") + expect(onlySteer.item?.text).toBe("s1") + expect(queueCount(onlySteer.state)).toBe(2) + expect(steerCount(onlySteer.state)).toBe(1) + + const steersGone = drainSteersOnly(onlySteer.state) + expect(steersGone.drained.map((i) => i.text)).toEqual(["s2"]) + expect(steerCount(steersGone.state)).toBe(0) + expect(queueCount(steersGone.state)).toBe(2) + expect(drainOrder(steersGone.state).map((i) => i.text)).toEqual(["q1", "q2"]) + + const onlyQueue = drainOne(steersGone.state, "queue") + expect(onlyQueue.item?.text).toBe("q1") + expect(queueCount(onlyQueue.state)).toBe(1) + }) + test("Ctrl+C interrupt keeps pending + sets flash + idle", () => { let s = createSessionQueue("busy") s = enqueue(s, "a") diff --git a/src/tui/session-queue.ts b/src/tui/session-queue.ts index 08acae4fc..ecc9b8199 100644 --- a/src/tui/session-queue.ts +++ b/src/tui/session-queue.ts @@ -1,6 +1,12 @@ /** * Mid-run queue / steer / interrupt state machine (interaction contract §3). * Pure data — no paint, no OpenTUI. Shell + demo own delivery and UI flash. + * + * Product chords (CL-6290): + * - Enter mid-run → kind "steer" (soft steer; drain at tool.boundary) + * - Alt+Enter mid-run → kind "queue" (follow-up; drain only when run goes idle) + * Internal "reinject" is a separate bridge/shell submit kind, not a QueueKind, + * and no product chord wires it anymore — leave the path for tests/API only. */ import type { PendingImageAttachment } from "./image-attachments.js" @@ -38,11 +44,21 @@ export function createSessionQueue( } } -/** Pending badge count (queue + steer share one pool). */ +/** Pending badge count (queue + steer share one pool for depth totals). */ export function badgeCount(state: SessionQueueState): number { return state.items.length } +/** Soft-steer pending count (Enter mid-run). */ +export function steerCount(state: SessionQueueState): number { + return state.items.filter((i) => i.kind === "steer").length +} + +/** Follow-up pending count (Alt+Enter mid-run). */ +export function queueCount(state: SessionQueueState): number { + return state.items.filter((i) => i.kind === "queue").length +} + export function setRunState( state: SessionQueueState, run: RunState, @@ -139,11 +155,18 @@ export function drainOrder( return [...steers, ...queues] } -/** Pop next delivery item (steer-first). */ +/** + * Pop next delivery item. When `kind` is set, only that class (FIFO within + * class); otherwise full `drainOrder` (steer-first, then queue). + */ export function drainOne( state: SessionQueueState, + kind?: QueueKind, ): { state: SessionQueueState; item: QueueItem | null } { - const order = drainOrder(state) + const order = + kind === undefined + ? drainOrder(state) + : state.items.filter((i) => i.kind === kind) const item = order[0] ?? null if (!item) return { state, item: null } return { @@ -154,3 +177,18 @@ export function drainOne( item, } } + +/** Drain every pending soft-steer; leave follow-ups untouched. */ +export function drainSteersOnly( + state: SessionQueueState, +): { state: SessionQueueState; drained: readonly QueueItem[] } { + const drained: QueueItem[] = [] + let current = state + for (;;) { + const next = drainOne(current, "steer") + if (!next.item) break + drained.push(next.item) + current = next.state + } + return { state: current, drained } +} diff --git a/src/tui/shell.test.ts b/src/tui/shell.test.ts index 473725956..510712ec0 100644 --- a/src/tui/shell.test.ts +++ b/src/tui/shell.test.ts @@ -299,7 +299,8 @@ describe("createAppShell", () => { setPendingQueue(shell, 3) expect(shell.pendingQueue).toBe(3) await h.renderOnce() - expect(h.captureCharFrame()).toContain("queue 3") + // setPendingQueue pads with kind "queue" → follow-up badge. + expect(h.captureCharFrame()).toContain("follow-up 3") } finally { shell.dispose() } @@ -398,7 +399,7 @@ describe("product skin: stream + queue + overlay", () => { ) }) - test("busy Enter enqueues; badge increments", async () => { + test("busy follow-up enqueue paints follow-up badge", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -413,7 +414,7 @@ describe("product skin: stream + queue + overlay", () => { expect(shell.session.items[0]!.kind).toBe("queue") expect(shell.prompt.value).toBe("") await h.renderOnce() - expect(h.captureCharFrame()).toContain("queue 1") + expect(h.captureCharFrame()).toContain("follow-up 1") } finally { shell.dispose() } @@ -422,7 +423,7 @@ describe("product skin: stream + queue + overlay", () => { ) }) - test("busy Alt+Enter steers", async () => { + test("busy soft-steer paints steer badge", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -438,8 +439,9 @@ describe("product skin: stream + queue + overlay", () => { await h.renderOnce() await h.renderOnce() const frame = h.captureCharFrame() - expect(frame).toContain("steer") - expect(frame).toContain("queue 1") + expect(frame).toContain("will steer next") + expect(frame).toContain("steer 1") + expect(frame).not.toContain("follow-up") } finally { shell.dispose() } @@ -468,11 +470,9 @@ describe("product skin: stream + queue + overlay", () => { expect(shell.session.run).toBe("idle") await h.renderOnce() const interruptRow = shell.streamLog[shell.streamLog.length - 1] - expect(interruptRow?.text).toBe( - "interrupt — 2 pending kept", - ) + expect(interruptRow?.text).toBe("2 pending kept") const row = noticeRow(h.captureCharFrame()) - expect(row).toContain("interrupt") + expect(row).not.toContain("interrupt") } finally { shell.dispose() } diff --git a/src/tui/shell.ts b/src/tui/shell.ts index b160de37f..285579e68 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -62,7 +62,7 @@ import { type PromptInput, } from "./prompt-input.js" import { promptBoxRows } from "./prompt-rows.js" -import { composeNoticeLine } from "./notice-line.js" +import { composeNoticeLine, resolveWaitingOn } from "./notice-line.js" import { lockupCells, lockupText, @@ -73,6 +73,7 @@ import type { RampPhase, StallAge } from "./ramp.js" import type { ActivityState } from "./session-chrome.js" import { BORDER, + composeAttentionLabel, composeCostContextMeter, composeRule, composeWorkspaceLabel, @@ -162,7 +163,9 @@ import { enqueue, enqueueSteer, interrupt, + queueCount, setRunState, + steerCount, type RunState, type SessionQueueState, } from "./session-queue.js" @@ -659,8 +662,14 @@ export type AppShell = { * set to null; never appended to the stream log. */ statusFlash: string | null - /** MCP servers awaiting authorization; the notice row names them. */ + /** MCP servers awaiting authorization; the top rule carries `mcp !`. */ mcpNeedsAuth: readonly string[] + /** + * Plugin load left standing warnings (skill misses, failed tool starts, …). + * The top rule carries `plugin !` (or `mcp ! · plugin !` with MCP). Cleared + * only when the warning set is empty — not merely dismissed. + */ + pluginNeedsAttention: boolean /** * Clock, motion and content state for the bottom-left status slot. The bridge * pushes all of it off its existing monitor tick (`setLockupFrame`); the @@ -668,6 +677,11 @@ export type AppShell = { * paints the settled idle slot. */ lockupNowMs: number + /** + * Parent tool currently in flight, for the steer `waiting on` notice. + * Null when no parent tools remain or the run is idle. Not TurnState. + */ + inFlightTool: { name: string; startedAt: number } | null lockupAnimating: boolean /** * Live activity state the slot shows, or null for the idle wordmark. @@ -841,12 +855,17 @@ function syncPending(shell: AppShell): void { /** The transient row's text for the current state ("" when it has nothing to say). */ export function noticeText(shell: AppShell): string { return composeNoticeLine({ - queue: shell.pendingQueue, + steer: steerCount(shell.session), + followUp: queueCount(shell.session), + waitingOn: resolveWaitingOn( + steerCount(shell.session), + shell.inFlightTool, + shell.lockupNowMs, + ), interrupt: shell.session.interruptFlash, pinned: !isTranscriptFollowing(shell), flash: shell.statusFlash, attachments: shell.pendingAttachments.length, - mcpNeedsAuth: shell.mcpNeedsAuth, }) } @@ -863,6 +882,13 @@ export function setMcpNeedsAuth(shell: AppShell, names: readonly string[]): void paintChrome(shell) } +/** Whether plugin load warnings still need attention. Repaints on change. */ +export function setPluginNeedsAttention(shell: AppShell, needs: boolean): void { + if (shell.pluginNeedsAttention === needs) return + shell.pluginNeedsAttention = needs + paintChrome(shell) +} + /** Repaint the prompt borders and the transient notice row from live state. */ export function paintChrome(shell: AppShell): void { if (shell.disposed) return @@ -1331,11 +1357,26 @@ function overlayHints(shell: AppShell): readonly string[] { const hasChoices = shell.overlayItems.length > 0 if (answer === null) { if (!hasChoices) return ["Esc dismiss"] - if ( - shell.overlayKind === "model_picker" && - internals.get(shell)?.overlayAddProviderHint === true - ) { - return MODEL_PICKER_HINTS + if (shell.overlayKind === "model_picker") { + const bag = internals.get(shell) + const addProvider = bag?.overlayAddProviderHint === true + const setDefault = bag?.overlaySetDefaultHint === true + if (addProvider && setDefault) { + return [ + "Esc cancel · Enter choose · Alt+A add provider · Alt+D set default", + "Esc · Enter · Alt+A add · Alt+D default", + "Esc · Enter · Alt+A · Alt+D", + "Esc · Enter", + ] + } + if (addProvider) return MODEL_PICKER_HINTS + if (setDefault) { + return [ + "Esc cancel · Enter choose · Alt+D set default", + "Esc · Enter · Alt+D default", + "Esc · Enter", + ] + } } return DEFAULT_OVERLAY_HINTS } @@ -1532,6 +1573,10 @@ function ruleChunks(shell: AppShell, parts: readonly RulePart[]): TextChunk[] { chunks.push(...meterChunks(shell, part.text)) continue } + if (part.role === "attention") { + chunks.push(fgChunk(UI.action)(part.text)) + continue + } chunks.push( fgChunk(part.role === "label" ? UI.textDim : UI.textFaint)(part.text), ) @@ -1577,9 +1622,14 @@ function lockupFrameInput(shell: AppShell): LockupInput { */ export function paintPromptBorder(shell: AppShell): void { const width = shell.layout.contentWidth + const attention = composeAttentionLabel({ + mcp: shell.mcpNeedsAuth.length > 0, + plugin: shell.pluginNeedsAttention, + }) const top = composeRule({ width, corners: [BORDER.topLeft, BORDER.topRight], + ...(attention !== undefined ? { attention } : {}), ...(shell.modelLabel !== null ? { label: shell.modelLabel } : {}), }) shell.promptTopRule.content = new StyledText(ruleChunks(shell, top)) @@ -1690,7 +1740,10 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void { // Rows lay themselves out against the column budget (right-aligned bubbles, // pre-wrapped reasoning blocks), so a width change invalidates every painted // row rather than just reflowing it. - const widthChanged = shell.layout.contentWidth !== layout.contentWidth + const widthChanged = + shell.layout.contentWidth !== layout.contentWidth || + shell.layout.chatWidth !== layout.chatWidth || + shell.layout.layoutMode !== layout.layoutMode shell.layout = layout const h = layout.heights @@ -1709,7 +1762,6 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void { shell.taskBox.visible = taskH > 0 const agentsH = Math.max(0, h.agents) - shell.agentsBox.height = agentsH > 0 ? agentsH : 1 shell.agentsBox.visible = agentsH > 0 // Both pads are taken out of the transcript residual, never out of chrome, @@ -1758,6 +1810,16 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void { shell.transcript.visible = transcriptBody > 0 syncTranscriptSpacer(shell) + // Agents strip: full-width flex stack under the transcript when present. + // Live chrome keeps the zone empty (● Task transcript rows instead). + shell.agentsBox.position = "relative" + shell.agentsBox.left = 0 + shell.agentsBox.top = 0 + shell.agentsBox.width = "100%" + shell.agentsBox.height = agentsH > 0 ? agentsH : 1 + shell.agentsBox.zIndex = 0 + shell.transcript.width = "100%" + const noticeH = Math.max(0, h.notice) shell.notice.height = noticeH > 0 ? noticeH : 1 shell.notice.visible = noticeH > 0 @@ -1779,7 +1841,9 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void { // Rows the flow spends before the prompt box — where a floated host's bottom // edge has to land, since the landing's box sits mid-screen rather than at // the foot and covering it would hide the thing the operator types into. - const promptTop = padH + taskH + agentsH + transcriptBody + // Stack: topPad, transcript, agents, task, then prompt (notice omitted — + // same as before; it is transient chrome between task and prompt). + const promptTop = padH + transcriptBody + agentsH + taskH const hostH = floating ? Math.min(overlayH, Math.max(1, promptTop)) : overlayH floatOverlayHost(shell, floating, Math.max(0, promptTop - hostH)) shell.overlayHost.height = hostH > 0 ? hostH : 1 @@ -1805,6 +1869,16 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void { repaintTranscriptWindow(shell) } + // Width change changes the column budget the board fits to. Content may be + // unchanged, so setChromeZones would skip the rebuild — do it here. + if (widthChanged && bag !== undefined && bag.chrome.agents.length > 0) { + renderAgentsRows( + shell, + clampBoardRows(bag.chrome.agents, agentsH), + layout.contentWidth, + ) + } + paintChrome(shell) } @@ -1897,6 +1971,7 @@ type PriorOverlaySnapshot = { readonly titleText: string readonly onCancel: (() => void) | null readonly addProviderHint: boolean + readonly setDefaultHint: boolean } type ShellInternals = { @@ -1925,6 +2000,8 @@ type ShellInternals = { overlayOnAction: ((itemId: string, key: KeyEvent) => boolean) | null /** Whether the open primary advertises Alt+A in the footer hints. */ overlayAddProviderHint: boolean + /** Whether the open primary advertises Alt+D in the footer hints. */ + overlaySetDefaultHint: boolean /** * While true the shell ignores its own key/paste/submit handlers. Set for * the lifetime of a full-screen surface (inline provider connect) that @@ -3185,13 +3262,16 @@ export function userRowText( } /** - * Submit the prompt. Three kinds, three distinct gestures: - * - "queue": mid-run send — steers at the next turn boundary (badge). - * - "reinject": hard-stop the run right now and restart from this message, - * without waiting for a boundary. No-op when the run isn't busy, or the - * prompt is empty — there's nothing to stop or restart from. - * - Idle sends (either kind) go straight through immediately; "kind" only - * matters while a run is in flight. + * Submit the prompt. Product chords (CL-6290): + * - "steer": mid-run Enter — soft steer at the next tool.boundary. + * - "queue": mid-run Alt+Enter — follow-up; deliver only when the run goes + * idle. Idle Alt+Enter is a no-op at the key handler (never reaches here + * with kind "queue" while idle from the product chord). + * - "reinject": hard-stop and restart from this message. No product chord + * wires this anymore; kept for tests / direct API callers. No-op when the + * run isn't busy, or the prompt is empty. + * - Idle Enter (either queue or steer kind) goes straight through; "kind" + * only matters while a run is in flight. */ export function submitPrompt( shell: AppShell, @@ -3209,7 +3289,10 @@ export function submitPrompt( } return } + // Reinject is unwired from product chords; still guard idle for API callers. if (kind === "reinject" && shell.session.run !== "busy") return + // Follow-up idle no-op lives on the Alt+Enter key handler (kind "queue" is + // also the default for submitPrompt and must still send when idle). // Shell/REPL muscle memory: a bare `exit` or `quit` quits rather than being // sent to the model. Attachments mean the operator meant it as a message. @@ -3234,6 +3317,7 @@ export function submitPrompt( } if (kind === "reinject") { + // Unwired from product chords (CL-6290); kept for tests / direct callers. shell.session = interrupt(shell.session) shell.prompt.value = "" clearPendingAttachments(shell) @@ -3324,8 +3408,8 @@ export function applyShellInterrupt(shell: AppShell): void { role: "system", text: had > 0 - ? `interrupt — ${had} pending kept` - : "interrupt", + ? `${had} pending kept` + : "stopped", meta: "stop", }) paintChrome(shell) @@ -3475,6 +3559,11 @@ export type OpenListOverlayOpts = { * the hint can never name a key that is a dead end. */ readonly addProviderHint?: boolean + /** + * Advertise the Alt+D set-default hint in the footer for this open. Set + * only when the caller actually wired an Alt+D handler via `onAction`. + */ + readonly setDefaultHint?: boolean } /** @@ -3514,6 +3603,7 @@ export function openListOverlay( titleText: bag.overlayTitleText, onCancel: bag.overlayOnCancel, addProviderHint: bag.overlayAddProviderHint, + setDefaultHint: bag.overlaySetDefaultHint, } } // Leave prior overlay focus frame; palette will stack above it. @@ -3545,6 +3635,7 @@ export function openListOverlay( bag.overlayOnAction = opts?.onAction ?? null bag.overlayOnCancel = opts?.onCancel ?? null bag.overlayAddProviderHint = opts?.addProviderHint ?? false + bag.overlaySetDefaultHint = opts?.setDefaultHint ?? false // Capture the full unfiltered set so typing can re-narrow in place. bag.listFilter = opts?.typeToFilter === true @@ -3567,6 +3658,7 @@ export function openListOverlay( bag.overlayOnAction = opts?.onAction ?? null bag.overlayOnCancel = opts?.onCancel ?? null bag.overlayAddProviderHint = opts?.addProviderHint ?? false + bag.overlaySetDefaultHint = opts?.setDefaultHint ?? false bag.listFilter = null } if (!isPalette) { @@ -3735,6 +3827,12 @@ function repaintPalette(shell: AppShell): void { kind: "palette", title: state.title, items: labels, + itemIds: commands.map((c) => c.id), + describe: (id) => { + const cmd = commands.find((c) => c.id === id) + const what = cmd?.description?.trim() + return what ? { what } : null + }, // Typed filter row only when the overlay owns keystrokes. The `/` popup // keeps its query in the prompt, so a body of `>` would be orphan chrome. ...(state.typeToFilter ? { body: `> ${state.query}` } : {}), @@ -4011,6 +4109,7 @@ export function closeInsetOverlay(shell: AppShell): void { bag.overlayDescribe = null bag.overlayOnAction = null bag.overlayAddProviderHint = false + bag.overlaySetDefaultHint = false bag.overlayAnswer = null bag.overlayOnCancel = null } @@ -4045,6 +4144,7 @@ export function closeInsetOverlay(shell: AppShell): void { bag.overlayTitleText = prior.titleText bag.overlayOnCancel = prior.onCancel bag.overlayAddProviderHint = prior.addProviderHint + bag.overlaySetDefaultHint = prior.setDefaultHint // If focus was not stacked (edge case), re-open overlay frame. if (focusOwner(shell.focus) !== "overlay") { shell.focus = openOverlay(shell.focus, OVERLAY_FRAME_ID, { @@ -4466,18 +4566,18 @@ function renderAgentsRows( destroySubtree(child) } for (const row of rows) { - // Green for working, not the task zone's bronze immediately above it — - // adjacent zones sharing a hue read as one undifferentiated block. The - // header and the hidden-count row are chrome about the board rather than - // lanes in it, so they sit back in dim and leave the colour to the work. + // Bronze for a live working lane (inFlight), red for a stalled one — the + // ●/! marker already names the state, the hue only carries the urgency. + // The "+N more" fold-away row is chrome about the strip, not a lane in it, + // so it sits back in dim and leaves the colour to the work. const text = new TextRenderable(shell.renderer as CliRenderer, { content: fitAgentRow(row, maxWidth), fg: - row.kind === "header" || row.kind === "more" + row.kind === "more" ? UI.textDim : row.stalled ? UI.action - : UI.done, + : UI.inFlight, }) shell.agentsBox.add(text) } @@ -4557,7 +4657,7 @@ export function setChromeZones( // Painted after the resolver has spoken, and only ever as many rows as it // granted: a board that paints past its box lands on top of the transcript - // and tears down the renderables underneath it. + // and tears down the renderables underneath it. Full content width (stack). if (agentsChanged || !budgetUnchanged) { renderAgentsRows( shell, @@ -5445,10 +5545,10 @@ export function createAppShell( promptBox.add(promptBottomRule) root.add(topPad) - root.add(taskBox) - root.add(agentsBox) root.add(transcript) root.add(overlayHost) + root.add(agentsBox) + root.add(taskBox) root.add(notice) root.add(promptBox) root.add(landingBelow) @@ -5913,11 +6013,13 @@ export function createAppShell( (key.meta || key.option) && !key.ctrl ) { - // Alt+Enter: stop-and-reinject — the one gesture that doesn't wait for - // a boundary. Plain Enter (below) already covers "queue to steer at - // the next boundary", so this chord's whole job is skipping the wait. + // Alt+Enter: follow-up — enqueue kind "queue"; deliver only when the + // run goes idle. Does not interrupt or reinject. Idle / empty: no-op + // (nothing to wait for). Soft steer is plain Enter below; reinject is + // not wired to any product chord. key.preventDefault() - submitPrompt(shell, "reinject") + if (shell.session.run !== "busy") return + submitPrompt(shell, "queue") return } } @@ -5925,9 +6027,8 @@ export function createAppShell( const onEnter = (): void => { if (disposed || shell.overlayList) return if (internals.get(shell)?.inputSuspended === true) return - // Every mid-run send steers — there is no longer a plain "queue and wait - // quietly" gesture distinct from it (that's what collapsed into Alt+Enter - // stop-and-reinject instead). Idle sends ignore "kind" entirely. + // Mid-run Enter soft-steers (deliver at next tool.boundary). Alt+Enter + // is follow-up (quiet wait until idle). Idle sends ignore "kind". submitPrompt(shell, "steer") } @@ -6023,7 +6124,9 @@ export function createAppShell( copyTargets: null, statusFlash: null, mcpNeedsAuth: [], + pluginNeedsAttention: false, lockupNowMs: 0, + inFlightTool: null, lockupAnimating: false, lockupPhase: null, lockupChangedMs: 0, @@ -6077,6 +6180,7 @@ export function createAppShell( overlayDescribe: null, overlayOnAction: null, overlayAddProviderHint: false, + overlaySetDefaultHint: false, inputSuspended: false, overlayAnswer: null, overlayTitleText: "", @@ -6094,7 +6198,12 @@ export function createAppShell( landingNowMs: 0, landingIdleTimerCancel: null, chrome: { task: [], tasksRaw: [], agents: [] }, - tasksPanelHidden: false, + // CL-5847: the manage_tasks checklist panel is hidden by default. The + // panel owns too much of the screen for the operator to want it forced + // into view on a fresh shell; Alt+T (toggleTasksPanel) opts in for the + // shell's lifetime. Live task data still lands in tasksRaw while hidden, + // so the first toggle shows current data rather than a stale snapshot. + tasksPanelHidden: true, }) // The landing's snow needs a frame source that keeps running while the // turn monitor is deliberately quiet (idle, no session yet). A plain timer diff --git a/src/tui/stall-watchdog.ts b/src/tui/stall-watchdog.ts index e7c3cfd33..e4c8009cd 100644 --- a/src/tui/stall-watchdog.ts +++ b/src/tui/stall-watchdog.ts @@ -8,9 +8,12 @@ export const STALL_TIMEOUT_MS = 900_000 // When the run starts *saying* it looks stuck. Well short of the abort: nobody // waits out the backstop, they conclude the product hung and quit, so silence // has to be named long before it is acted on. Notice, not a shorter timeout — -// a slow model or a long tool call is not a stall, and killing it at 90s would -// break working runs to fix a wording problem. -export const STALL_NOTICE_MS = 90_000 +// a slow model or a long tool call is not a stall, and killing it early would +// break working runs to fix a wording problem. Grok-4.6 on the Responses path +// streams only sparse reasoning *summaries* while billing tens of thousands of +// thinking tokens, so 60–180s of true client silence mid-think is routine; +// the notice sits above that band and matches DEFAULT_STALL_MS on Task rows. +export const STALL_NOTICE_MS = 300_000 export type ShouldAbortForStallArgs = { readonly status: TurnStatus diff --git a/src/tui/steer-worker-invariant.test.ts b/src/tui/steer-worker-invariant.test.ts new file mode 100644 index 000000000..d2b919dac --- /dev/null +++ b/src/tui/steer-worker-invariant.test.ts @@ -0,0 +1,167 @@ +/** + * CL-6291: soft steer / follow-up must keep workers alive; only hard stop + * (Ctrl+C → doInterrupt → port.interrupt) tears the fleet down via agent close. + * + * Owned separately from runtime-bridge.test.ts so gesture remaps (sibling) + * do not collide with these invariants. + */ +import { describe, expect, test } from "bun:test" +import { + attachSessionBridge, + createRecordingPort, +} from "./runtime-bridge" +import { createAppShell } from "./shell" +import { withTestRenderer } from "./harness" +import { badgeCount } from "./session-queue" + +describe("CL-6291 worker-alive invariants", () => { + test("busy Enter soft-steers: enqueue steer, never port.interrupt", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: true, + run: "busy", + }) + const port = createRecordingPort() + const bridge = attachSessionBridge(shell, port) + try { + shell.prompt.value = "steer please" + shell.prompt.submit() + await h.renderOnce() + expect(port.calls.some((c) => c.op === "interrupt")).toBe(false) + expect(port.calls.some((c) => c.op === "enqueue")).toBe(true) + const enq = port.calls.find((c) => c.op === "enqueue") + expect(enq).toEqual({ + op: "enqueue", + text: "steer please", + kind: "steer", + }) + expect(badgeCount(shell.session)).toBe(1) + } finally { + bridge.dispose() + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + + test("bridge submit steer / queue (follow-up) never calls interrupt", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }) + const port = createRecordingPort() + const bridge = attachSessionBridge(shell, port) + try { + bridge.submit("redirect soft", "steer") + bridge.submit("follow up later", "queue") + await h.renderOnce() + expect(port.calls.some((c) => c.op === "interrupt")).toBe(false) + expect( + port.calls.filter((c) => c.op === "enqueue").map((c) => + c.op === "enqueue" ? { text: c.text, kind: c.kind } : null, + ), + ).toEqual([ + { text: "redirect soft", kind: "steer" }, + { text: "follow up later", kind: "queue" }, + ]) + expect(badgeCount(shell.session)).toBe(2) + } finally { + bridge.dispose() + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + + test("tool.boundary deliver does not interrupt", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }) + const port = createRecordingPort() + const bridge = attachSessionBridge(shell, port) + try { + bridge.submit("at next boundary", "steer") + expect(badgeCount(shell.session)).toBe(1) + port.clear() + bridge.handle({ type: "tool.boundary" }) + await h.renderOnce() + expect(port.calls.some((c) => c.op === "interrupt")).toBe(false) + expect(port.calls.some((c) => c.op === "deliver")).toBe(true) + const delivered = port.calls.find((c) => c.op === "deliver") + expect(delivered?.op === "deliver" ? delivered.item.text : null).toBe( + "at next boundary", + ) + expect(badgeCount(shell.session)).toBe(0) + } finally { + bridge.dispose() + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + + test("Ctrl+C / doInterrupt still calls port.interrupt", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: true, + run: "busy", + }) + const port = createRecordingPort() + const bridge = attachSessionBridge(shell, port) + try { + bridge.submit("kept", "steer") + expect(badgeCount(shell.session)).toBe(1) + port.clear() + h.pressKey("c", { ctrl: true }) + await h.renderOnce() + expect(port.calls.some((c) => c.op === "interrupt")).toBe(true) + // Hard stop still hands pending over rather than discarding them. + expect( + port.calls.flatMap((c) => (c.op === "deliver" ? [c.item.text] : [])), + ).toEqual(["kept"]) + } finally { + bridge.dispose() + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + + test("bridge.interrupt() hits port.interrupt (hard-stop API)", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }) + const port = createRecordingPort() + const bridge = attachSessionBridge(shell, port) + try { + bridge.interrupt() + await h.renderOnce() + expect(port.calls.map((c) => c.op)).toContain("interrupt") + } finally { + bridge.dispose() + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) +}) diff --git a/src/tui/stream.test.ts b/src/tui/stream.test.ts index 57acc97d9..c9591ec54 100644 --- a/src/tui/stream.test.ts +++ b/src/tui/stream.test.ts @@ -56,6 +56,24 @@ describe("stream paint", () => { } }) + test("steer / follow-up prefixes distinguish pending vs delivered", () => { + expect(userBody({ role: "user", text: "a", meta: "steer" })[0]).toContain( + "[will steer next] a", + ) + expect(userBody({ role: "user", text: "b", meta: "queue" })[0]).toContain( + "[will follow up] b", + ) + expect(userBody({ role: "user", text: "c", meta: "steering" })[0]).toContain( + "[steering] c", + ) + expect( + userBody({ role: "user", text: "d", meta: "following-up" })[0], + ).toContain("[following up] d") + expect( + userBody({ role: "user", text: "e", meta: "following-up" })[0], + ).not.toContain("steering") + }) + test("a long operator message wraps as one left-aligned block", () => { const text = "please find every call site of the legacy token helper and tell me which of them still run in production" diff --git a/src/tui/stream.ts b/src/tui/stream.ts index 106b49f34..101e577e7 100644 --- a/src/tui/stream.ts +++ b/src/tui/stream.ts @@ -9,7 +9,7 @@ import { stringWidth, wrapLines } from "./view/height.js" import type { DiffView } from "./diff.js" import type { McpStructuredView } from "./mcp-view.js" import { - thinkingScrollLine, + thinkingLivePreviewLines, thinkingSettledLine, type Thought, } from "./thinking.js" @@ -405,10 +405,9 @@ function elapsedLabel(ms: number): string { } /** - * Reasoning body. The same line serves the whole life of the row: while text - * arrives it is windowed onto the newest of it, and once the turn moves on it - * stops moving and keeps the opening of what it was thinking about. Nothing is - * substituted — the row goes quiet, and the rest is behind the expand key. + * Reasoning body. While text arrives it wraps into a short inset paragraph of + * the newest revealed prose (no sideways scroll). Once the turn moves on it + * collapses to the opening clause; the rest is behind the expand key. * * A row with no settled thought (a hydrated transcript, a fixture) has no * summary to collapse to and keeps the plain block. @@ -417,7 +416,9 @@ function reasoningLines(row: StreamRow, layout: RowLayout): string[] { const lead = " ".repeat(THINKING_INDENT) const columns = Math.max(1, layout.width - THINKING_INDENT) if (row.streaming === true) { - return [`${lead}${thinkingScrollLine(row.text, columns, row.revealChars)}`] + return thinkingLivePreviewLines(row.text, columns, row.revealChars).map( + (line) => `${lead}${line}`, + ) } if (row.thought === undefined) return thinkingLines(row.text, layout) const expanded = row.expanded === true @@ -670,11 +671,15 @@ export function paintStreamRow( ? "[cancelled] " : row.meta === "steer" ? "[will steer next] " - : row.meta === "steering" - ? "[steering] " - : row.meta === "reinject" - ? "[restarted here] " - : "" + : row.meta === "queue" + ? "[will follow up] " + : row.meta === "steering" + ? "[steering] " + : row.meta === "following-up" + ? "[following up] " + : row.meta === "reinject" + ? "[restarted here] " + : "" return { content: userBubbleLines(`${prefix}${row.text}`, layout.width).join("\n"), fg } } if (isThinkingRow(row)) { diff --git a/src/tui/thinking-reveal.test.ts b/src/tui/thinking-reveal.test.ts index 92d639ed0..c1563cd6e 100644 --- a/src/tui/thinking-reveal.test.ts +++ b/src/tui/thinking-reveal.test.ts @@ -1,13 +1,13 @@ /** - * The reasoning scroll line reveals text at a bounded rate rather than - * jumping straight to the newest token, so a fast model still reads at human - * pace. Pure-function coverage lives here; `advanceOpenReveal`'s wiring - * through the bridge is covered by the bridge/tick test below. + * Live reasoning reveals text at a bounded rate into a short wrapped preview, + * so a fast model still reads at human pace without sideways-scrolling one + * line. Pure-function coverage lives here; `advanceOpenReveal`'s wiring + * through the bridge is covered by the bridge/tick tests below. */ import { describe, expect, test } from "bun:test" -import { advanceRevealChars, thinkingScrollLine } from "./thinking" +import { advanceRevealChars, thinkingLivePreviewLines } from "./thinking" import { withTestRenderer } from "./harness" import { attachSessionBridge, createRecordingPort } from "./runtime-bridge" import { createAppShell } from "./shell" @@ -60,26 +60,33 @@ describe("advanceRevealChars", () => { }) }) -describe("thinkingScrollLine with a reveal position", () => { +describe("thinkingLivePreviewLines with a reveal position", () => { const text = "the quick brown fox jumps over the lazy dog and keeps running" - test("shows nothing revealed as an empty line", () => { - expect(thinkingScrollLine(text, 20, 0)).toBe("") + test("shows nothing revealed as a single empty line", () => { + expect(thinkingLivePreviewLines(text, 20, 0)).toEqual([""]) }) - test("shows only the revealed prefix, windowed onto its own tail", () => { - // 20 chars revealed, 10-column window → last 10 of the first 20 chars. - expect(thinkingScrollLine(text, 10, 20)).toBe(text.slice(10, 20)) + test("wraps only the revealed prefix", () => { + const lines = thinkingLivePreviewLines(text, 10, 20) + const painted = lines.join(" ") + expect(painted.length).toBeGreaterThan(0) + expect(lines.every((line) => line.length <= 10)).toBe(true) + // Soft-wrap may drop break spaces, but nothing past the reveal may appear. + expect(painted).not.toContain("jumps") }) test("never shows text past the reveal position even though more has arrived", () => { - const line = thinkingScrollLine(text, 10, 15) - expect(line).not.toContain("fox") - expect(text.indexOf(line)).toBeLessThan(15) + const painted = thinkingLivePreviewLines(text, 10, 15).join(" ") + expect(painted).not.toContain("fox") }) - test("omitting revealChars keeps the old always-tail behaviour", () => { - expect(thinkingScrollLine(text, 10)).toBe(text.slice(text.length - 10)) + test("omitting revealChars wraps whatever has arrived, capped to max lines", () => { + const lines = thinkingLivePreviewLines(text, 10) + expect(lines.length).toBeLessThanOrEqual(3) + expect(lines.length).toBeGreaterThan(0) + expect(lines.every((line) => line.length <= 10)).toBe(true) + expect(lines.join(" ")).toContain("running") }) test("sample frames across a few rates, printed for eyeballing", () => { @@ -87,7 +94,7 @@ describe("thinkingScrollLine with a reveal position", () => { for (const rate of [15, 20, 28, 40, 60]) { const frames = [200, 500, 1000, 1500].map((ms) => { const chars = advanceRevealChars(0, sample.length, ms, rate) - return thinkingScrollLine(sample, 30, chars) + return thinkingLivePreviewLines(sample, 30, chars) }) // eslint-disable-next-line no-console console.log(`rate=${rate}/s`, frames) diff --git a/src/tui/thinking.ts b/src/tui/thinking.ts index c6bf4a1d5..7c5cb7aae 100644 --- a/src/tui/thinking.ts +++ b/src/tui/thinking.ts @@ -1,19 +1,14 @@ /** - * Reasoning chrome: the one line a chain of thought occupies while it streams, - * and what that same line keeps once it is done. + * Reasoning chrome: a short wrapped preview while thought streams, and a + * one-line opener once it settles (full text behind expand). * - * Reasoning is not the answer, so it never gets to own the screen. While it - * arrives it rides a single row whose window follows the newest text; once the - * turn moves on that same row stops moving and simply stays, with the full text - * one keypress away. The row goes quiet rather than transforming: nothing the - * operator was reading is substituted out from under them. + * Reasoning is not the answer, so it never owns the screen. Live text used to + * ride a single sideways-scrolling row; that was unreadable. Now the newest + * revealed prose wraps into a few inset lines. Once the turn moves on the row + * collapses to its opening clause — same expand path as before. */ -import { - sliceTailToWidth, - sliceToWidth, - stringWidth, -} from "./view/height.js" +import { sliceToWidth, stringWidth, wrapLines } from "./view/height.js" /** What a settled reasoning row remembers about the thinking it finished. */ export type Thought = { @@ -22,9 +17,8 @@ export type Thought = { } /** - * Whitespace-flattened reasoning text, matching what `thinkingScrollLine` - * windows onto. Exposed so callers computing a reveal position (chars - * available to reveal) count in the same units as the paint function. + * Whitespace-flattened reasoning text. Reveal position counts in these units + * so paint and the reveal clock agree. */ export function flattenReasoningText(text: string): string { return text.replace(/\s+/g, " ").trimStart() @@ -38,6 +32,9 @@ export function flattenReasoningText(text: string): string { */ export const REVEAL_CHARS_PER_SEC = 28 +/** How many wrapped lines a live reasoning preview may claim. */ +export const LIVE_THINKING_MAX_LINES = 3 + /** * Advance a reveal position toward the text that has actually arrived, capped * at a bounded reading rate. Never exceeds `availableChars` (can't outrun the @@ -57,24 +54,30 @@ export function advanceRevealChars( } /** - * Live reasoning as one row: whitespace flattened, windowed onto the newest - * *revealed* text. `revealChars` is the bounded-rate reveal position computed - * by `advanceRevealChars`; omitting it (settled rows, tests, fixtures) shows - * the text in full, which is the old always-tail behaviour. + * Live reasoning as a short wrapped paragraph of the newest *revealed* text. + * `revealChars` is the bounded-rate reveal position from `advanceRevealChars`; + * omitting it shows whatever has arrived so far (tests/fixtures). */ -export function thinkingScrollLine( +export function thinkingLivePreviewLines( text: string, width: number, revealChars?: number, -): string { - const flat = flattenReasoningText(text) + maxLines: number = LIVE_THINKING_MAX_LINES, +): string[] { const columns = Math.max(1, Math.floor(width)) + const linesCap = Math.max(1, Math.floor(maxLines)) + const flat = flattenReasoningText(text) const revealed = revealChars === undefined - ? flat.length - : Math.max(0, Math.min(flat.length, Math.floor(revealChars))) - const visible = flat.slice(0, revealed) - return sliceTailToWidth(visible, columns) + ? flat + : flat.slice(0, Math.max(0, Math.min(flat.length, Math.floor(revealChars)))) + if (revealed.length === 0) return [""] + // Prefer the newest prose when the wrap would exceed the cap. + const budget = linesCap * columns + const window = + revealed.length > budget ? revealed.slice(revealed.length - budget).trimStart() : revealed + const wrapped = wrapLines(window, columns) + return wrapped.slice(-linesCap) } /** Marker that a settled reasoning line is holding back the rest of the text. */ diff --git a/src/tui/wave6.test.ts b/src/tui/wave6.test.ts index 0db4480fa..2ced46d5a 100644 --- a/src/tui/wave6.test.ts +++ b/src/tui/wave6.test.ts @@ -295,6 +295,10 @@ describe("Wave 6: chrome zones", () => { agents: [{ label: "explore: map callers", tail: "", stalled: false }], }) + // CL-5847: the panel is hidden by default — toggle to show before + // asserting it paints. + toggleTasksPanel(shell) + expect(shell.layout.heights.task).toBe(1) expect(shell.layout.heights.agents).toBe(1) expect(shell.taskBox.visible).toBe(true) @@ -467,6 +471,9 @@ describe("CL-5731: task list panel", () => { agents: [{ label: "explore: map callers", tail: "", stalled: false }], }) + // CL-5847: hidden by default — opt in to see the checklist. + toggleTasksPanel(shell) + expect(shell.layout.heights.task).toBe(3) expect(shell.taskBox.getChildren()).toHaveLength(3) // A distinct zone/box from the agents panel — not folded into it. @@ -506,6 +513,38 @@ describe("CL-5731: task list panel", () => { ) }) + test("stays hidden by default when the task list carries rows, until toggled (CL-5847)", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + }) + try { + // A fresh shell with seeded tasks paints no task panel — the data + // is buffered underneath, waiting on Alt+T to opt in. + setChromeZones(shell, { + task: [{ label: "seeded but hidden", status: "todo" }], + }) + expect(shell.layout.heights.task).toBe(0) + expect(shell.taskBox.visible).toBe(false) + await h.renderOnce() + expect(h.captureCharFrame()).not.toContain("seeded but hidden") + + // Toggling is the only way the panel surfaces. + toggleTasksPanel(shell) + expect(shell.taskBox.visible).toBe(true) + expect(shell.layout.heights.task).toBe(1) + await h.renderOnce() + expect(h.captureCharFrame()).toContain("seeded but hidden") + } finally { + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + test("updates live as the task list changes, without touching the agents panel", async () => { await withTestRenderer( async (h) => { @@ -518,16 +557,24 @@ describe("CL-5731: task list panel", () => { task: [{ label: "first task", status: "todo" }], agents: [{ label: "explore: map callers", tail: "", stalled: false }], }) - const agentsRowsBefore = [...shell.agentsBox.getChildren()] + const agentsHeightBefore = shell.layout.heights.agents setChromeZones(shell, { task: [{ label: "first task", status: "done" }], }) + // CL-5847: hidden by default — opt in to see the live update. + toggleTasksPanel(shell) + await h.renderOnce() const frame = h.captureCharFrame() expect(frame).toContain("[x] first task") - expect([...shell.agentsBox.getChildren()]).toEqual(agentsRowsBefore) + // The agents board content survives the task panel rebuild: the + // row is still painted (do not deep-compare renderable nodes — + // OpenTUI renderables carry circular refs that hang toEqual). + expect(shell.agentsBox.getChildren()).toHaveLength(1) + expect(shell.layout.heights.agents).toBe(agentsHeightBefore) + expect(frame).toContain("explore: map callers") } finally { shell.dispose() } @@ -536,7 +583,7 @@ describe("CL-5731: task list panel", () => { ) }) - test("toggling hides the panel without losing the live task data, and un-hiding restores it", async () => { + test("default-hidden panel surfaces live task data on toggle without a stale snapshot", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -544,11 +591,19 @@ describe("CL-5731: task list panel", () => { wireKeys: false, }) try { + // CL-5847: the panel is hidden by default, even after chrome + // carries task rows. The data still lands in tasksRaw underneath. setChromeZones(shell, { task: [{ label: "wire toggle", status: "doing" }], }) + expect(shell.taskBox.visible).toBe(false) + expect(shell.layout.heights.task).toBe(0) + + // First toggle shows the panel. + toggleTasksPanel(shell) expect(shell.taskBox.visible).toBe(true) + // Second toggle hides it again. toggleTasksPanel(shell) expect(shell.taskBox.visible).toBe(false) expect(shell.layout.heights.task).toBe(0) @@ -588,11 +643,11 @@ describe("CL-5731: task list panel", () => { // Which panels are showing is a property of the current screen, not // an event in the conversation, so it costs no scrollback. expect(streamRowCount(shell)).toBe(before) - expect(shell.statusFlash).toContain("hidden") + expect(shell.statusFlash).toContain("shown") toggleTasksPanel(shell) expect(streamRowCount(shell)).toBe(before) - expect(shell.statusFlash).toContain("shown") + expect(shell.statusFlash).toContain("hidden") } finally { shell.dispose() } @@ -601,7 +656,7 @@ describe("CL-5731: task list panel", () => { ) }) - test("the toggle persists across further chrome pushes for the life of the shell", async () => { + test("the default-hidden choice persists across further chrome pushes for the life of the shell", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -609,11 +664,12 @@ describe("CL-5731: task list panel", () => { wireKeys: false, }) try { + // CL-5847: hidden by default — no toggle needed to keep it that way. setChromeZones(shell, { task: [{ label: "a", status: "todo" }] }) - toggleTasksPanel(shell) expect(shell.taskBox.visible).toBe(false) - // Several unrelated live pushes later, the hidden choice still holds. + // Several unrelated live pushes later, the default-hidden choice + // still holds. setChromeZones(shell, { task: [{ label: "a", status: "doing" }] }) setChromeZones(shell, { agents: [{ label: "x: y", tail: "", stalled: false }] }) setChromeZones(shell, { task: [{ label: "a", status: "done" }] }) diff --git a/src/tui/width-columns.test.ts b/src/tui/width-columns.test.ts index 911327127..57d378ec0 100644 --- a/src/tui/width-columns.test.ts +++ b/src/tui/width-columns.test.ts @@ -23,7 +23,7 @@ import { lockupWidth } from "./lockup.js" import type { RampPhase } from "./ramp.js" import { formatPaletteRows } from "./command-catalog.js" import { composeDecisionBody, decisionChoiceRows, wrapWords } from "./overlay-body.js" -import { thinkingScrollLine, thinkingSettledLine } from "./thinking.js" +import { thinkingLivePreviewLines, thinkingSettledLine } from "./thinking.js" const CJK = "検索結果を確認する" const AMBIGUOUS = "│╭—→…┆●▍" @@ -159,8 +159,10 @@ describe("landing wrap", () => { }) describe("thinking rows", () => { - test("the live window is a column window", () => { - expect(stringWidth(thinkingScrollLine(CJK, 8))).toBeLessThanOrEqual(8) + test("each live preview line fits its columns", () => { + for (const line of thinkingLivePreviewLines(CJK.repeat(4), 8)) { + expect(stringWidth(line)).toBeLessThanOrEqual(8) + } }) test("the settled line fits its columns including the ellipsis", () => { diff --git a/tests/fixtures/buggy-service/README.md b/tests/fixtures/buggy-service/README.md new file mode 100644 index 000000000..d8da0ae68 --- /dev/null +++ b/tests/fixtures/buggy-service/README.md @@ -0,0 +1,3 @@ +# buggy-service + +Intentional bug fixture for capability eval (`complex-bugfix`). User routes are green; `GET /posts/:id` is broken until fixed. diff --git a/tests/fixtures/buggy-service/package.json b/tests/fixtures/buggy-service/package.json new file mode 100644 index 000000000..f1efe0831 --- /dev/null +++ b/tests/fixtures/buggy-service/package.json @@ -0,0 +1,14 @@ +{ + "name": "buggy-service", + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "tsc", + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/bun": "1.3.9", + "typescript": "5.9.3" + } +} diff --git a/tests/fixtures/buggy-service/src/index.ts b/tests/fixtures/buggy-service/src/index.ts new file mode 100644 index 000000000..31e49ce7d --- /dev/null +++ b/tests/fixtures/buggy-service/src/index.ts @@ -0,0 +1,12 @@ +import { handleUsers } from "./routes/user.js"; +import { handlePosts } from "./routes/post.js"; + +export function handleRequest(method: string, path: string): string { + if (path.startsWith("/users")) { + return handleUsers(method, path); + } + if (path.startsWith("/posts")) { + return handlePosts(method, path); + } + return "{\"error\":\"not found\"}"; +} diff --git a/tests/fixtures/buggy-service/src/middleware/auth.ts b/tests/fixtures/buggy-service/src/middleware/auth.ts new file mode 100644 index 000000000..3996db288 --- /dev/null +++ b/tests/fixtures/buggy-service/src/middleware/auth.ts @@ -0,0 +1,9 @@ +import type { User } from "../types/index.js"; + +const mockUsers: Map<string, User> = new Map([ + ["u1", { id: "u1", name: "Alice", email: "alice@example.com" }], +]); + +export function getUserByToken(token: string): User | null { + return mockUsers.get(token) ?? null; +} diff --git a/tests/fixtures/buggy-service/src/middleware/logger.ts b/tests/fixtures/buggy-service/src/middleware/logger.ts new file mode 100644 index 000000000..99a97b4c7 --- /dev/null +++ b/tests/fixtures/buggy-service/src/middleware/logger.ts @@ -0,0 +1,3 @@ +export function logRequest(method: string, path: string): void { + console.log(`[${new Date().toISOString()}] ${method} ${path}`); +} diff --git a/tests/fixtures/buggy-service/src/routes/post.ts b/tests/fixtures/buggy-service/src/routes/post.ts new file mode 100644 index 000000000..1b7df35c8 --- /dev/null +++ b/tests/fixtures/buggy-service/src/routes/post.ts @@ -0,0 +1,32 @@ +import { listPosts, getPost } from "../services/post.js"; +import { getUser } from "../services/user.js"; +import { logRequest } from "../middleware/logger.js"; + +export function handlePosts(method: string, path: string): string { + logRequest(method, path); + if (method === "GET" && path === "/posts") { + return JSON.stringify(listPosts()); + } + if (method === "GET" && path.startsWith("/posts/")) { + // BUG (intentional for complex-bugfix eval): + // 1) Wrong path slice: uses "/post/" (len 6) instead of "/posts/" (len 7), + // so id is "/p1" rather than "p1" and the direct post lookup fails. + // 2) Recovery then loads the post by stripping a leading slash, but returns + // the *author user* JSON instead of the post. + const id = path.slice("/post/".length); + const post = getPost(id); + if (post) { + return JSON.stringify(post); + } + const stripped = id.startsWith("/") ? id.slice(1) : id; + const recovered = getPost(stripped); + if (recovered) { + const author = getUser(recovered.authorId); + if (author) { + return JSON.stringify(author); + } + } + return "{\"error\":\"not found\"}"; + } + return "{\"error\":\"bad request\"}"; +} diff --git a/tests/fixtures/buggy-service/src/routes/user.ts b/tests/fixtures/buggy-service/src/routes/user.ts new file mode 100644 index 000000000..de58355a3 --- /dev/null +++ b/tests/fixtures/buggy-service/src/routes/user.ts @@ -0,0 +1,15 @@ +import { listUsers, getUser } from "../services/user.js"; +import { logRequest } from "../middleware/logger.js"; + +export function handleUsers(method: string, path: string): string { + logRequest(method, path); + if (method === "GET" && path === "/users") { + return JSON.stringify(listUsers()); + } + if (method === "GET" && path.startsWith("/users/")) { + const id = path.slice("/users/".length); + const user = getUser(id); + return user ? JSON.stringify(user) : "{\"error\":\"not found\"}"; + } + return "{\"error\":\"bad request\"}"; +} diff --git a/tests/fixtures/buggy-service/src/services/post.ts b/tests/fixtures/buggy-service/src/services/post.ts new file mode 100644 index 000000000..c0d163542 --- /dev/null +++ b/tests/fixtures/buggy-service/src/services/post.ts @@ -0,0 +1,14 @@ +import type { Post } from "../types/index.js"; + +const posts: Map<string, Post> = new Map([ + ["p1", { id: "p1", title: "Hello", body: "World", authorId: "u1" }], + ["p2", { id: "p2", title: "Second", body: "Post", authorId: "u2" }], +]); + +export function listPosts(): Post[] { + return Array.from(posts.values()); +} + +export function getPost(id: string): Post | undefined { + return posts.get(id); +} diff --git a/tests/fixtures/buggy-service/src/services/user.ts b/tests/fixtures/buggy-service/src/services/user.ts new file mode 100644 index 000000000..d69c774da --- /dev/null +++ b/tests/fixtures/buggy-service/src/services/user.ts @@ -0,0 +1,14 @@ +import type { User } from "../types/index.js"; + +const users: Map<string, User> = new Map([ + ["u1", { id: "u1", name: "Alice", email: "alice@example.com" }], + ["u2", { id: "u2", name: "Bob", email: "bob@example.com" }], +]); + +export function listUsers(): User[] { + return Array.from(users.values()); +} + +export function getUser(id: string): User | undefined { + return users.get(id); +} diff --git a/tests/fixtures/buggy-service/src/types/index.ts b/tests/fixtures/buggy-service/src/types/index.ts new file mode 100644 index 000000000..7515758d6 --- /dev/null +++ b/tests/fixtures/buggy-service/src/types/index.ts @@ -0,0 +1,12 @@ +export interface User { + id: string; + name: string; + email: string; +} + +export interface Post { + id: string; + title: string; + body: string; + authorId: string; +} diff --git a/tests/fixtures/buggy-service/tests/post.test.ts b/tests/fixtures/buggy-service/tests/post.test.ts new file mode 100644 index 000000000..c928fa65f --- /dev/null +++ b/tests/fixtures/buggy-service/tests/post.test.ts @@ -0,0 +1,36 @@ +import { describe, test, expect } from "bun:test"; +import { handleRequest } from "../src/index.js"; + +describe("post routes", () => { + test("lists posts", () => { + const res = handleRequest("GET", "/posts"); + const posts = JSON.parse(res); + expect(posts.length).toBe(2); + }); + + test("gets a post by id with post shape", () => { + const res = handleRequest("GET", "/posts/p1"); + const post = JSON.parse(res); + // Expected correct behavior: the post, not a user and not not-found + expect(post.id).toBe("p1"); + expect(post.title).toBe("Hello"); + expect(post.body).toBe("World"); + expect(post.authorId).toBe("u1"); + expect(post.email).toBeUndefined(); + expect(post.name).toBeUndefined(); + }); + + test("gets second post by id", () => { + const res = handleRequest("GET", "/posts/p2"); + const post = JSON.parse(res); + expect(post.id).toBe("p2"); + expect(post.title).toBe("Second"); + expect(post.authorId).toBe("u2"); + }); + + test("returns not found for unknown post", () => { + const res = handleRequest("GET", "/posts/missing"); + const body = JSON.parse(res); + expect(body.error).toBe("not found"); + }); +}); diff --git a/tests/fixtures/buggy-service/tests/user.test.ts b/tests/fixtures/buggy-service/tests/user.test.ts new file mode 100644 index 000000000..da0f0fdb3 --- /dev/null +++ b/tests/fixtures/buggy-service/tests/user.test.ts @@ -0,0 +1,16 @@ +import { describe, test, expect } from "bun:test"; +import { handleRequest } from "../src/index.js"; + +describe("user routes", () => { + test("lists users", () => { + const res = handleRequest("GET", "/users"); + const users = JSON.parse(res); + expect(users.length).toBe(2); + }); + + test("gets a user", () => { + const res = handleRequest("GET", "/users/u1"); + const user = JSON.parse(res); + expect(user.name).toBe("Alice"); + }); +}); diff --git a/tests/fixtures/buggy-service/tsconfig.json b/tests/fixtures/buggy-service/tsconfig.json new file mode 100644 index 000000000..08e53edf3 --- /dev/null +++ b/tests/fixtures/buggy-service/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "types": ["bun"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/tests/fixtures/large-read/src/secrets/token.txt b/tests/fixtures/large-read/src/secrets/token.txt new file mode 100644 index 000000000..7af66606f --- /dev/null +++ b/tests/fixtures/large-read/src/secrets/token.txt @@ -0,0 +1 @@ +COMPACT-TOKEN-7F3A diff --git a/tests/integration/harness.ts b/tests/integration/harness.ts index 869d6f88a..5f735eff9 100644 --- a/tests/integration/harness.ts +++ b/tests/integration/harness.ts @@ -1,5 +1,6 @@ /** - * Agent-loop integration harness for Corbits Code: wires `createAgent` to + * Agent-loop integration harness for Corbits Code: wires + * `createAgentWithLiveToolDispatch` (production default) to * `@intx/inference-testing` so full reactor cycles run without network I/O. * * Production-shaped stack: `createChatDirector`, `createAgentToolset` (posix + @@ -24,6 +25,7 @@ import { setupHarness, type Harness } from "@intx/inference-testing"; import type { ContextTransform, InferenceSource } from "@intx/types/runtime"; import { type } from "arktype"; +import { createAgentWithLiveToolDispatch } from "../../src/agent/live-tool-dispatch.js"; import { createChatDirector } from "../../src/agent/director.js"; import { createAgentToolset } from "../../src/agent/tools.js"; import { ID_PREFIX } from "../../src/branding.js"; @@ -51,6 +53,8 @@ export type OpenIntegrationSessionOpts = { systemPrompt?: string; /** Pre-inference transforms, delivered the production way: riding deps. */ contextTransforms?: ContextTransform[]; + /** Override to pin the published createAgent snapshot (characterization). */ + createAgentFn?: typeof createAgent; }; export async function openIntegrationSession( @@ -90,7 +94,8 @@ export async function openIntegrationSession( }); const storage = await createOptimizedContextStore(workdir); - const agent = await createAgent(def, { + const startAgent = opts.createAgentFn ?? createAgentWithLiveToolDispatch; + const agent = await startAgent(def, { sources: [INTEGRATION_SOURCE], defaultSource: INTEGRATION_SOURCE.id, storage, diff --git a/tests/integration/mcp-late-dispatch.test.ts b/tests/integration/mcp-late-dispatch.test.ts new file mode 100644 index 000000000..ddf0efa70 --- /dev/null +++ b/tests/integration/mcp-late-dispatch.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import { createAgent } from "@intx/agent"; +import type { ReactorEmittedEvent } from "@intx/inference"; + +import { createAgentWithLiveToolDispatch } from "../../src/agent/live-tool-dispatch.js"; +import { createPermissionGate } from "../../src/permission/gate.js"; +import { closeIntegrationSession, openIntegrationSession, runUntilDone } from "./harness.js"; + +const LATE_MCP = "mcp__linear__list_issues"; + +function permissionGate() { + return createPermissionGate({ + approvals: [], + interactive: false, + skipPermissions: true, + }); +} + +function lateMcpTool() { + return { + kind: "string" as const, + definition: { + name: LATE_MCP, + description: "list issues", + inputSchema: { type: "object" as const, properties: {}, required: [] as string[] }, + }, + handler: async () => "ISSUE-1", + }; +} + +function toolDoneContents(events: ReactorEmittedEvent[]): string[] { + return events + .filter((event): event is Extract<ReactorEmittedEvent, { type: "tool.done" }> => event.type === "tool.done") + .map((event) => (typeof event.data.result.content === "string" ? event.data.result.content : "")); +} + +describe("integration — late MCP dispatch", () => { + // Characterization: drop createAgentWithLiveToolDispatch when this starts + // failing because published @intx/agent learned to consult live definitions. + test.serial("published createAgent freezes dispatch names at construction", async () => { + const session = await openIntegrationSession({ + permissionGate: permissionGate(), + createAgentFn: createAgent, + }); + + try { + session.toolset.dynamicRunner.addTools([lateMcpTool()]); + session.harness.scenario.replyOnce("anthropic", { + toolCalls: [{ name: LATE_MCP, args: {} }], + }); + session.harness.scenario.replyOnce("anthropic", { text: "listed" }); + + const { events } = await runUntilDone(session, "list linear issues"); + expect(toolDoneContents(events).some((content) => content.includes(`unknown tool: ${LATE_MCP}`))).toBe( + true, + ); + } finally { + await closeIntegrationSession(session); + } + }); + + test.serial("MCP tools added after createAgent dispatch instead of unknown tool", async () => { + const session = await openIntegrationSession({ + permissionGate: permissionGate(), + createAgentFn: createAgentWithLiveToolDispatch, + }); + + try { + session.toolset.dynamicRunner.addTools([lateMcpTool()]); + session.harness.scenario.replyOnce("anthropic", { + toolCalls: [{ name: LATE_MCP, args: {} }], + }); + session.harness.scenario.replyOnce("anthropic", { text: "listed" }); + + const { events } = await runUntilDone(session, "list linear issues"); + const contents = toolDoneContents(events); + expect(contents).toContain("ISSUE-1"); + expect(contents.some((content) => content.includes("unknown tool"))).toBe(false); + } finally { + await closeIntegrationSession(session); + } + }); +}); diff --git a/tests/integration/reactor-permission-multi-turn.test.ts b/tests/integration/reactor-permission-multi-turn.test.ts index e031db2ba..a1e6d5435 100644 --- a/tests/integration/reactor-permission-multi-turn.test.ts +++ b/tests/integration/reactor-permission-multi-turn.test.ts @@ -59,7 +59,7 @@ describe("integration — reactor permission + multi-turn", () => { } }); - test.serial("approved write_file executes after operator approval and second inference turn completes", async () => { + test.serial("approved run_shell executes after operator approval and second inference turn completes", async () => { let asked = 0; const session = await openIntegrationSession({ permissionGate: createPermissionGate({ @@ -75,29 +75,34 @@ describe("integration — reactor permission + multi-turn", () => { }); try { + // Primary Skywalker does not mount write_file; permission multi-turn + // still covers a consequential tool that remains on the primary surface. session.harness.scenario.replyOnce("anthropic", { toolCalls: [ { - name: "write_file", - args: { path: "integration-out.txt", content: "integration-ok\n" }, + name: "run_shell", + args: { command: "curl -sS https://example.com" }, }, ], }); session.harness.scenario.replyOnce("anthropic", { - text: "File written.", + text: "Fetched.", }); - const { events } = await runUntilDone( - session, - "Write integration-out.txt with content integration-ok.", - ); + const { events } = await runUntilDone(session, "Please fetch example.com with curl."); expect(asked).toBeGreaterThan(0); expect(events.some((e) => e.type === "reactor.error")).toBe(false); const toolDones = toolDoneEvents(events); - const writeDone = toolDones.find((e) => !e.data.result.isError); - expect(writeDone).toBeDefined(); + expect(toolDones.length).toBeGreaterThanOrEqual(1); + const denied = toolDones.find( + (e) => + e.data.result.isError === true && + typeof e.data.result.content === "string" && + e.data.result.content.includes("Blocked by permission policy"), + ); + expect(denied).toBeUndefined(); } finally { await closeIntegrationSession(session); } diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 671517759..22b06516b 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -181,7 +181,7 @@ test("loadSettings cannot silently drop a known optional key", async () => { model: "m", reasoningEffort: "high" as const, mcpServers: [{ name: "s", command: "echo" }], - sessionMode: "single" as const, + sessionMode: "orchestrator" as const, env: { FOO: "bar" }, }; await writeFile(localPath, JSON.stringify(localFixture)); diff --git a/tests/unit/corbits-skills-catalog.test.ts b/tests/unit/corbits-skills-catalog.test.ts new file mode 100644 index 000000000..ef1c58e73 --- /dev/null +++ b/tests/unit/corbits-skills-catalog.test.ts @@ -0,0 +1,154 @@ +import { existsSync } from "node:fs"; +import { readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, test } from "bun:test"; +import { loadSkillCommands } from "../../src/plugins/skill-commands.ts"; + +const pluginRoot = join(import.meta.dirname, "../../plugins/corbits-skills"); + +const SKILL_DIRS = [ + "implement", + "dispatch", + "scribe", + "review", + "ast-grep", + "style", + "philosophy", + "typescript", + "interview", + "git-rebase", + "refactor", + "pull-request-review", + "create-issue", + "linear-issue-workflow", + "opsh", + "plan", +] as const; + +const SPAWN_RECIPE_SKILLS = ["implement", "scribe", "review", "dispatch", "plan"] as const; + +const USE_SKILL_ONLY = [ + "dispatch", + "git-rebase", + "linear-issue-workflow", + "style", + "philosophy", + "typescript", + "opsh", +] as const; + +const SLASH_SKILLS = [ + "implement", + "refactor", + "review", + "pull-request-review", + "create-issue", + "scribe", + "interview", + "ast-grep", + "plan", +] as const; + +const BANNED_TOKENS = ["TaskCreate", "@greybeard", 'intent="general"'] as const; + +const USER_INVOCABLE_FALSE = "user-invocable: false"; + +async function listFilesRecursive(dir: string): Promise<string[]> { + const out: string[] = []; + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...(await listFilesRecursive(full))); + } else if (entry.isFile()) { + out.push(full); + } + } + return out; +} + +test("corbits-skills manifest is a default-enabled command plugin", async () => { + const manifest = (await Bun.file(join(pluginRoot, "manifest.json")).json()) as { + id: string; + kind: string; + defaultEnabled: boolean; + }; + expect(manifest.id).toBe("corbits-skills"); + expect(manifest.kind).toBe("command"); + expect(manifest.defaultEnabled).toBe(true); +}); + +test("corbits-skills plugin has no agents directory", () => { + expect(existsSync(join(pluginRoot, "agents"))).toBe(false); +}); + +test("corbits-skills catalog lists 16 skills with name and description", async () => { + expect(SKILL_DIRS).toHaveLength(16); + const entries = await readdir(join(pluginRoot, "skills"), { withFileTypes: true }); + const dirs = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + expect(dirs).toEqual([...SKILL_DIRS].sort()); + for (const name of SKILL_DIRS) { + const skillPath = join(pluginRoot, "skills", name, "SKILL.md"); + expect(existsSync(skillPath)).toBe(true); + const skill = await Bun.file(skillPath).text(); + expect(skill).toContain("name:"); + expect(skill).toContain("description:"); + } +}); + +test("spawn-recipe skills contain task(agent=", async () => { + for (const name of SPAWN_RECIPE_SKILLS) { + const skill = await Bun.file(join(pluginRoot, "skills", name, "SKILL.md")).text(); + expect(skill).toContain("task(agent="); + } +}); + +test("create-issue selects Linear MCP, GitHub gh, and MEMORY.md preference", async () => { + const skill = await Bun.file(join(pluginRoot, "skills/create-issue/SKILL.md")).text(); + expect(skill).toContain("mcp__linear__"); + expect(skill).toContain("gh issue create"); + expect(skill).toContain(".corbits/MEMORY.md"); + expect(skill).toContain("Preferred issue tracker:"); +}); + +test("use_skill-only skills set user-invocable: false", async () => { + for (const name of USE_SKILL_ONLY) { + const skill = await Bun.file(join(pluginRoot, "skills", name, "SKILL.md")).text(); + expect(skill).toContain(USER_INVOCABLE_FALSE); + } +}); + +test("slash skills do not set user-invocable: false", async () => { + for (const name of SLASH_SKILLS) { + const skill = await Bun.file(join(pluginRoot, "skills", name, "SKILL.md")).text(); + expect(skill).not.toContain(USER_INVOCABLE_FALSE); + } +}); + +test("corbits-skills plugin files contain no banned tokens", async () => { + const files = await listFilesRecursive(pluginRoot); + for (const file of files) { + const text = await Bun.file(file).text(); + for (const token of BANNED_TOKENS) { + expect(text).not.toContain(token); + } + } +}); + +test("loadSkillCommands lists exactly the nine slash actions", async () => { + const cmds = await loadSkillCommands(join(import.meta.dirname, "../../plugins/corbits-skills")); + expect(cmds!.map((c) => c.name).sort()).toEqual([ + "ast-grep", + "create-issue", + "implement", + "interview", + "plan", + "pull-request-review", + "refactor", + "review", + "scribe", + ]); +}); diff --git a/tests/unit/grok-responses-adapter.test.ts b/tests/unit/grok-responses-adapter.test.ts index a2b0412cf..83ca29d74 100644 --- a/tests/unit/grok-responses-adapter.test.ts +++ b/tests/unit/grok-responses-adapter.test.ts @@ -42,7 +42,7 @@ describe("grok-responses buildRequest", () => { expect(body["stream"]).toBe(true); expect(body["store"]).toBe(false); expect(body["include"]).toEqual(["reasoning.encrypted_content"]); - expect(body["reasoning"]).toEqual({ summary: "auto" }); + expect(body["reasoning"]).toEqual({ summary: "detailed" }); // No `instructions` field — the system prompt rides as a system input message. expect(body["instructions"]).toBeUndefined(); const input = body["input"] as Array<Record<string, unknown>>; diff --git a/tests/unit/plugin-loader-path.test.ts b/tests/unit/plugin-loader-path.test.ts index 112cc6bf0..42ee068fe 100644 --- a/tests/unit/plugin-loader-path.test.ts +++ b/tests/unit/plugin-loader-path.test.ts @@ -28,6 +28,17 @@ test("manifest requires a kind", () => { expect(parsePluginManifest({ id: "x", name: "X", kind: "bogus" })).toBeNull(); }); +test("manifest parses optional defaultEnabled", () => { + expect(parsePluginManifest({ id: "x", name: "X", kind: "command", defaultEnabled: true })).toEqual({ + id: "x", + name: "X", + kind: "command", + defaultEnabled: true, + }); + expect(parsePluginManifest({ id: "x", name: "X", kind: "command" })?.defaultEnabled).toBeUndefined(); + expect(parsePluginManifest({ id: "x", name: "X", kind: "command", defaultEnabled: "yes" })).toBeNull(); +}); + test("dedupePluginModules keeps the last module per id (path > user > repo)", () => { const repo: PluginModule = { manifest: { id: "dup", name: "Repo", kind: "command" }, commandPlugin: { commands: [] } }; const user: PluginModule = { manifest: { id: "dup", name: "User", kind: "command" }, commandPlugin: { commands: [] } }; diff --git a/tests/unit/plugin-register.test.ts b/tests/unit/plugin-register.test.ts index 4bd14bf51..3496144a2 100644 --- a/tests/unit/plugin-register.test.ts +++ b/tests/unit/plugin-register.test.ts @@ -4,13 +4,15 @@ import { isEnabledCommandPlugin, enablePluginConfig, isPluginEnabled, + isPluginModuleEnabled, } from "../../src/plugins/register.js"; import type { PluginModule } from "../../src/plugins/loader.js"; -function cmdModule(id: string): PluginModule { +function cmdModule(id: string, extra: Partial<PluginModule> = {}): PluginModule { return { manifest: { id, name: id, kind: "command" }, commandPlugin: { commands: [{ name: id, description: "d", handler: () => ({ type: "noop" }) }] }, + ...extra, }; } @@ -60,3 +62,65 @@ test("enablePluginConfig marks enabled and preserves credentials/consented", () credentials: { apiKey: "k" }, }); }); + +test("isPluginEnabled stays strict: missing settings entry is disabled", () => { + expect(isPluginEnabled({}, "any")).toBe(false); + expect(isPluginEnabled({ any: {} }, "any")).toBe(false); + expect(isPluginEnabled({ any: { enabled: false } }, "any")).toBe(false); +}); + +test("isPluginModuleEnabled: explicit true/false win over defaultEnabled", () => { + const repoOn: PluginModule = { + origin: "repo", + manifest: { id: "skills", name: "skills", kind: "command", defaultEnabled: true }, + }; + expect(isPluginModuleEnabled(repoOn, { skills: { enabled: true } })).toBe(true); + expect(isPluginModuleEnabled(repoOn, { skills: { enabled: false } })).toBe(false); + + const repoOffFlag: PluginModule = { + origin: "repo", + manifest: { id: "skills", name: "skills", kind: "command", defaultEnabled: false }, + }; + expect(isPluginModuleEnabled(repoOffFlag, { skills: { enabled: true } })).toBe(true); +}); + +test("isPluginModuleEnabled: missing settings + repo + defaultEnabled is on", () => { + const repoOn: PluginModule = { + origin: "repo", + manifest: { id: "skills", name: "skills", kind: "command", defaultEnabled: true }, + }; + expect(isPluginModuleEnabled(repoOn, {})).toBe(true); + expect(isPluginModuleEnabled(repoOn, { skills: {} })).toBe(true); +}); + +test("isPluginModuleEnabled: missing settings + repo without flag is off", () => { + const repoNoFlag: PluginModule = { + origin: "repo", + manifest: { id: "skills", name: "skills", kind: "command" }, + }; + expect(isPluginModuleEnabled(repoNoFlag, {})).toBe(false); + const repoFalse: PluginModule = { + origin: "repo", + manifest: { id: "skills", name: "skills", kind: "command", defaultEnabled: false }, + }; + expect(isPluginModuleEnabled(repoFalse, {})).toBe(false); +}); + +test("isPluginModuleEnabled: marketplace/path/user defaultEnabled is ignored", () => { + const flagged = { + manifest: { id: "mkt", name: "mkt", kind: "command", defaultEnabled: true }, + }; + expect(isPluginModuleEnabled({ ...flagged, origin: "user" }, {})).toBe(false); + expect(isPluginModuleEnabled({ ...flagged, origin: "path" }, {})).toBe(false); + expect(isPluginModuleEnabled({ ...flagged, origin: "project" }, {})).toBe(false); + expect(isPluginModuleEnabled(flagged, {})).toBe(false); +}); + +test("isEnabledCommandPlugin routes through isPluginModuleEnabled", () => { + const repoCmd = cmdModule("skills", { + origin: "repo", + manifest: { id: "skills", name: "skills", kind: "command", defaultEnabled: true }, + }); + expect(isEnabledCommandPlugin(repoCmd, {})).toBe(true); + expect(isEnabledCommandPlugin(repoCmd, { skills: { enabled: false } })).toBe(false); +}); diff --git a/tests/unit/plugin-repo-locator.test.ts b/tests/unit/plugin-repo-locator.test.ts new file mode 100644 index 000000000..0605486de --- /dev/null +++ b/tests/unit/plugin-repo-locator.test.ts @@ -0,0 +1,151 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { discoverRepoPlugins, resolveRepoPluginsDir } from "../../src/plugins/loader.js"; + +const tmpDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tmpDirs.splice(0).map((d) => rm(d, { recursive: true, force: true }))); +}); + +async function tmpRoot(): Promise<string> { + const dir = await mkdtemp(join(tmpdir(), "corbits-repo-plugins-")); + tmpDirs.push(dir); + return dir; +} + +async function writeLoadablePlugin(pluginDir: string, id: string): Promise<void> { + await mkdir(join(pluginDir, "commands"), { recursive: true }); + await writeFile( + join(pluginDir, "manifest.json"), + JSON.stringify({ id, name: id, kind: "command", defaultEnabled: true }), + "utf8", + ); + await writeFile( + join(pluginDir, "commands", "hello.md"), + "---\ndescription: hello\n---\nHello.\n", + "utf8", + ); +} + +describe("resolveRepoPluginsDir", () => { + test("does not use session cwd/plugins", async () => { + const root = await tmpRoot(); + await mkdir(join(root, "cwd", "plugins", "foreign"), { recursive: true }); + const resolved = resolveRepoPluginsDir({ + moduleUrl: pathToFileURL(join(root, "src", "plugins", "loader.ts")).href, + execPath: join(root, "no-such-bin", "corbits"), + }); + expect(resolved).toBeUndefined(); + }); + + test("prefers the first existing candidate (source tree over execPath)", async () => { + const root = await tmpRoot(); + await mkdir(join(root, "plugins"), { recursive: true }); + await mkdir(join(root, "bin", "plugins"), { recursive: true }); + const resolved = resolveRepoPluginsDir({ + moduleUrl: pathToFileURL(join(root, "src", "plugins", "loader.ts")).href, + execPath: join(root, "bin", "corbits"), + }); + expect(resolved).toBe(join(root, "plugins")); + }); + + test("uses dist/plugins when the module is the bundled index", async () => { + const root = await tmpRoot(); + await mkdir(join(root, "dist", "plugins"), { recursive: true }); + const resolved = resolveRepoPluginsDir({ + moduleUrl: pathToFileURL(join(root, "dist", "index.js")).href, + execPath: join(root, "no-such-bin", "corbits"), + }); + expect(resolved).toBe(join(root, "dist", "plugins")); + }); + + test("from dist/index.js prefers dist/plugins over ancestor ../../plugins", async () => { + const root = await tmpRoot(); + const tmp = join(root, "app"); + const ancestor = join(root, "plugins"); + const distPlugins = join(tmp, "dist", "plugins"); + await mkdir(ancestor, { recursive: true }); + await mkdir(distPlugins, { recursive: true }); + const resolved = resolveRepoPluginsDir({ + moduleUrl: pathToFileURL(join(tmp, "dist", "index.js")).href, + execPath: join(root, "no-such-bin", "corbits"), + }); + expect(resolved).toBe(distPlugins); + expect(resolved).not.toBe(ancestor); + }); + + test("from dist/index.js does not pick ancestor ../../plugins when dist/plugins is missing", async () => { + const root = await tmpRoot(); + const tmp = join(root, "app"); + const ancestor = join(root, "plugins"); + const binPlugins = join(root, "bin", "plugins"); + await mkdir(ancestor, { recursive: true }); + await mkdir(join(tmp, "dist"), { recursive: true }); + await mkdir(binPlugins, { recursive: true }); + const withoutExec = resolveRepoPluginsDir({ + moduleUrl: pathToFileURL(join(tmp, "dist", "index.js")).href, + execPath: join(root, "no-such-bin", "corbits"), + }); + expect(withoutExec).toBeUndefined(); + const withExec = resolveRepoPluginsDir({ + moduleUrl: pathToFileURL(join(tmp, "dist", "index.js")).href, + execPath: join(root, "bin", "corbits"), + }); + expect(withExec).toBe(binPlugins); + }); + + test("uses injectable execPath when source and bundle candidates are missing", async () => { + const root = await tmpRoot(); + const plugins = join(root, "bin", "plugins"); + await mkdir(plugins, { recursive: true }); + const resolved = resolveRepoPluginsDir({ + moduleUrl: pathToFileURL(join(root, "src", "plugins", "loader.ts")).href, + execPath: join(root, "bin", "corbits"), + }); + expect(resolved).toBe(plugins); + }); +}); + +describe("discoverRepoPlugins", () => { + test("loads from the locator dir, not cwd/plugins", async () => { + const root = await tmpRoot(); + const cwd = join(root, "cwd"); + await writeLoadablePlugin(join(root, "plugins", "shipped"), "shipped"); + await writeLoadablePlugin(join(cwd, "plugins", "foreign"), "foreign"); + const mods = await discoverRepoPlugins(cwd, { + moduleUrl: pathToFileURL(join(root, "src", "plugins", "loader.ts")).href, + execPath: join(root, "no-such-bin", "corbits"), + }); + expect(mods.map((m) => m.manifest?.id)).toEqual(["shipped"]); + expect(mods[0]?.origin).toBe("repo"); + }); + + test("returns empty when no locator candidate exists, even if cwd has plugins", async () => { + const root = await tmpRoot(); + const cwd = join(root, "cwd"); + await writeLoadablePlugin(join(cwd, "plugins", "foreign"), "foreign"); + const mods = await discoverRepoPlugins(cwd, { + moduleUrl: pathToFileURL(join(root, "src", "plugins", "loader.ts")).href, + execPath: join(root, "no-such-bin", "corbits"), + }); + expect(mods).toEqual([]); + }); + + test("discovers via injectable execPath", async () => { + const root = await tmpRoot(); + const cwd = join(root, "session"); + await mkdir(cwd, { recursive: true }); + await writeLoadablePlugin(join(root, "bin", "plugins", "from-bin"), "from-bin"); + const mods = await discoverRepoPlugins(cwd, { + moduleUrl: pathToFileURL(join(root, "src", "plugins", "loader.ts")).href, + execPath: join(root, "bin", "corbits"), + }); + expect(mods.map((m) => m.manifest?.id)).toEqual(["from-bin"]); + expect(mods[0]?.origin).toBe("repo"); + }); +}); diff --git a/tests/unit/skill-commands.test.ts b/tests/unit/skill-commands.test.ts index c3b08b19d..c5735e059 100644 --- a/tests/unit/skill-commands.test.ts +++ b/tests/unit/skill-commands.test.ts @@ -63,6 +63,17 @@ describe("loadSkillCommands", () => { expect(cmds!.find((c) => c.name === "linear-create")).toBeDefined(); }); + test("omits a skill with user-invocable: false; sibling without the flag is still present", async () => { + const dir = await makePlugin({ + "skills/internal-convention/SKILL.md": + "---\nname: internal-convention\ndescription: Internal only\nuser-invocable: false\n---\nDo not slash this.", + "skills/linear-create/SKILL.md": + "---\nname: linear-create\ndescription: Create Linear issues\n---\nCreate the artifacts.", + }); + const cmds = await loadSkillCommands(dir); + expect(cmds!.map((c) => c.name).sort()).toEqual(["linear-create"]); + }); + test("copies argument-hint from skill frontmatter", async () => { const dir = await makePlugin({ "skills/linear-create/SKILL.md": diff --git a/tests/unit/subagent-session-store.test.ts b/tests/unit/subagent-session-store.test.ts index 2f7dd5ec2..7d0ebd989 100644 --- a/tests/unit/subagent-session-store.test.ts +++ b/tests/unit/subagent-session-store.test.ts @@ -310,6 +310,7 @@ describe("createTaskTool session recording", () => { description: "inspect store", prompt: "do the job", context: "background", + intent: "explore", }); expect(out).toContain("## Summary\nDone."); const sessions = store.list(); @@ -336,7 +337,7 @@ describe("createTaskTool session recording", () => { throw new Error("boom"); }, }); - const out = await call(tool, { description: "fail me", prompt: "x" }); + const out = await call(tool, { description: "fail me", prompt: "x", intent: "explore" }); expect(out).toContain("failed: boom"); const session = store.list()[0]; expect(session?.status).toBe("failed"); @@ -351,7 +352,7 @@ describe("createTaskTool session recording", () => { provider, run: async () => "ok", }); - const out = await call(tool, { description: "no store", prompt: "x" }); + const out = await call(tool, { description: "no store", prompt: "x", intent: "explore" }); expect(out).toContain("ok"); }); @@ -395,7 +396,7 @@ describe("createTaskTool session recording", () => { return "should not complete"; }, }); - const out = await call(tool, { description: "stuck looper", prompt: "spin" }); + const out = await call(tool, { description: "stuck looper", prompt: "spin", intent: "explore" }); expect(out).toContain('cancelled by operator'); expect(sawAbort).toBe(true); const session = store.list()[0]; @@ -425,7 +426,7 @@ describe("createTaskTool session recording", () => { return "nope"; }, }); - const out = await call(tool, { description: "parent stop child", prompt: "x" }, parent.signal); + const out = await call(tool, { description: "parent stop child", prompt: "x", intent: "explore" }, parent.signal); expect(out).toContain("cancelled by operator"); expect(store.list()[0]?.status).toBe("cancelled"); }); diff --git a/tests/unit/subagent.test.ts b/tests/unit/subagent.test.ts index 9e5d01428..4db6644b8 100644 --- a/tests/unit/subagent.test.ts +++ b/tests/unit/subagent.test.ts @@ -74,7 +74,7 @@ test("generic leaf gets role-default medium even when parent effort is high", as }, }); - await callHandler(tool, { description: "task", prompt: "do it" }); + await callHandler(tool, { description: "task", prompt: "do it", intent: "explore" }); expect(receivedEffort?.provider.reasoningEffort).toBe("medium"); }); @@ -94,7 +94,7 @@ test("a provider getter is resolved at spawn time, so a live switch reaches suba // Simulate a /agent switch after the tool was constructed. current = { ...provider, model: "model-b", reasoningEffort: "high" }; - await callHandler(tool, { description: "task", prompt: "do it" }); + await callHandler(tool, { description: "task", prompt: "do it", intent: "explore" }); expect(received?.provider.model).toBe("model-b"); // Live model switch is honored; effort still follows leaf role default. @@ -116,6 +116,7 @@ test("handler forwards trimmed args to the runner and wraps the result", async ( const result = await callHandler(tool, { description: " map callers ", prompt: " find every caller of X ", + intent: "explore", }); expect(received?.description).toBe("map callers"); @@ -135,18 +136,18 @@ test("handler reports runner failures without throwing", async () => { }, }); - const result = await callHandler(tool, { description: "boom", prompt: "trigger failure" }); + const result = await callHandler(tool, { description: "boom", prompt: "trigger failure", intent: "explore" }); expect(result).toContain("Error:"); expect(result).toContain("provider exploded"); }); -test("sub-agent prompt is autonomous and forbids recursion for leaf agents", () => { +test("sub-agent prompt is autonomous and forbids recursion for workers", () => { const prompt = buildSubAgentSystemPrompt(); expect(prompt).toContain("sub-agent"); expect(prompt).toContain("permission policy as the parent session"); expect(prompt).toContain("parent session's permission gate"); - // Leaf agents must not be invited to spawn further agents. - expect(prompt).toContain("leaf sub-agent"); + // Workers must not be invited to spawn further agents. + expect(prompt).toContain("You are a worker"); expect(prompt).not.toContain("MAY call `task`"); }); @@ -186,16 +187,182 @@ test("unknown agent id fails closed when no profiles are loaded", async () => { return "should not run"; }, }); + // Non-director ids still require profiles; directors resolve from the closed registry. const result = await callHandler(tool, { description: "review", prompt: "look at it", - agent: "greybeard", + agent: "no-such-agent", }); expect(result).toContain("Error:"); expect(result).toContain("no agent profiles are loaded"); expect(ran).toBe(false); }); +test("closed director resolves without profiles loaded", async () => { + let received: RunSubAgentParams | undefined; + const tool = createTaskTool({ + permissionGate: testPermissionGate, + cwd: "/repo", + getWorkdirBase: () => "/repo/.ctx", + provider, + run: async (params) => { + received = params; + return "ok"; + }, + }); + const result = await callHandler(tool, { + description: "ship", + prompt: "implement the fix", + agent: "implement", + }); + expect(result).toContain("ok"); + expect(received?.systemPromptRole).toBeDefined(); + expect(received?.systemPromptRole).toContain("PRIMARY INTENT"); +}); + +test("intent maps to closed director without profiles", async () => { + let received: RunSubAgentParams | undefined; + const tool = createTaskTool({ + permissionGate: testPermissionGate, + cwd: "/repo", + getWorkdirBase: () => "/repo/.ctx", + provider, + run: async (params) => { + received = params; + return "ok"; + }, + }); + const result = await callHandler(tool, { + description: "map code", + prompt: "find callers of X", + intent: "explore", + }); + expect(result).toContain("ok"); + expect(received?.systemPromptRole).toContain("PRIMARY INTENT"); + expect(received?.capabilities?.mode).toBe("allow"); + expect(received?.capabilities?.tools).toContain("read_file"); + expect(received?.capabilities?.tools).not.toContain("write_file"); + expect(received?.capabilities?.tools).not.toContain("edit_file"); + expect(received?.capabilities?.tools).not.toContain("delete_file"); +}); + +test("intent general is refused (no general director)", async () => { + let ran = false; + const tool = createTaskTool({ + permissionGate: testPermissionGate, + cwd: "/repo", + getWorkdirBase: () => "/repo/.ctx", + provider, + run: async () => { + ran = true; + return "should not run"; + }, + }); + const result = await callHandler(tool, { + description: "vague", + prompt: "do something", + intent: "general", + }); + expect(result).toContain("Error:"); + expect(result).toContain("general"); + expect(ran).toBe(false); +}); + +test("bare task without agent or intent is refused (no catch-all worker)", async () => { + let ran = false; + const tool = createTaskTool({ + permissionGate: testPermissionGate, + cwd: "/repo", + getWorkdirBase: () => "/repo/.ctx", + provider, + run: async () => { + ran = true; + return "should not run"; + }, + }); + const result = await callHandler(tool, { + description: "vague", + prompt: "do something", + }); + expect(result).toContain("Error:"); + expect(result).toContain("No director selected"); + expect(ran).toBe(false); +}); + +test("spawnAllowlist rejects children outside the parent director matrix", async () => { + let ran = false; + const tool = createTaskTool({ + permissionGate: testPermissionGate, + cwd: "/repo", + getWorkdirBase: () => "/repo/.ctx", + provider, + spawnAllowlist: ["intern", "explore", "critique"], + run: async () => { + ran = true; + return "should not run"; + }, + }); + const denied = await callHandler(tool, { + description: "ship code", + prompt: "implement the feature", + agent: "implement", + }); + expect(denied).toContain("Error:"); + expect(denied).toContain("allowlist"); + expect(ran).toBe(false); + + const allowed = await callHandler(tool, { + description: "map", + prompt: "read the tree", + agent: "explore", + }); + expect(allowed).not.toContain("Error:"); + expect(ran).toBe(true); +}); + +test("task refuses skywalker as a spawned worker", async () => { + let ran = false; + const tool = createTaskTool({ + permissionGate: testPermissionGate, + cwd: "/repo", + getWorkdirBase: () => "/repo/.ctx", + provider, + run: async () => { + ran = true; + return "should not run"; + }, + }); + const result = await callHandler(tool, { + description: "orchestrate", + prompt: "fan out the fleet", + agent: "skywalker", + }); + expect(result).toContain("Error:"); + expect(result).toMatch(/primary session identity/i); + expect(result).not.toContain("allowlist"); + expect(ran).toBe(false); +}); + +test("greybeard nestedDispatch carries spawn allowlist into nested task", async () => { + let nestedAllow: readonly string[] | undefined; + const tool = createTaskTool({ + permissionGate: testPermissionGate, + cwd: "/repo", + getWorkdirBase: () => "/repo/.ctx", + provider, + run: async (params) => { + nestedAllow = params.nestedDispatch?.spawnAllowlist; + return "reviewed"; + }, + }); + await callHandler(tool, { + description: "arch review", + prompt: "review approach", + agent: "greybeard", + }); + expect(nestedAllow).toEqual(["intern", "explore", "critique"]); +}); + test("orchestrator profile installs nestedDispatch so task can be re-dispatched", async () => { let received: RunSubAgentParams | undefined; const tool = createTaskTool({ permissionGate: testPermissionGate, @@ -310,6 +477,7 @@ test("handler injects context and goals into runner params when provided", async context: "The codebase uses functional programming with no classes.", prompt: "Extract duplicated validation logic into a shared function.", goals: [" find duplicates ", "", " extract helper "], + intent: "implement", }); expect(received?.context).toBe("The codebase uses functional programming with no classes."); @@ -345,6 +513,7 @@ test("handler omits context and goals when empty", async () => { await callHandler(toolNoContext, { description: "check code", prompt: "Review the function signatures.", + intent: "explore", }); await callHandler(toolEmptyContext, { @@ -352,6 +521,7 @@ test("handler omits context and goals when empty", async () => { context: " ", prompt: "Review the function signatures.", goals: [], + intent: "explore", }); expect(receivedNoContext?.context).toBeUndefined(); diff --git a/tests/unit/tui/agent-tools.test.ts b/tests/unit/tui/agent-tools.test.ts index ba4103019..59d576a21 100644 --- a/tests/unit/tui/agent-tools.test.ts +++ b/tests/unit/tui/agent-tools.test.ts @@ -27,6 +27,7 @@ const mockPosixTools = { // mock.module below runs, making the "restore" a no-op. const realToolsPosix = { ...(await import("@intx/tools-posix")) }; const realPosixToolPlugins = { ...(await import("../../../src/agent/posix-tool-plugins.js")) }; +const realMcpClient = { ...(await import("../../../src/mcp/client.js")) }; const realMcpPlugin = { ...(await import("../../../src/mcp/plugin.js")) }; const realPathEscapePlugin = { ...(await import("../../../src/plugins/path-escape-plugin.js")) }; const realAuthzPlugin = { ...(await import("../../../src/plugins/authz-plugin.js")) }; @@ -47,6 +48,17 @@ mock.module("../../../src/agent/posix-tool-plugins.js", () => ({ buildCorePosixToolPlugins: () => [], })); +const mockConnectMCPServer = mock(async (config: { name: string }) => ({ + ok: false as const, + serverName: config.name, + error: "not connected", +})); + +mock.module("../../../src/mcp/client.js", () => ({ + ...realMcpClient, + connectMCPServer: mockConnectMCPServer, +})); + mock.module("../../../src/mcp/plugin.js", () => ({ mcpClientToAgentTools: () => [], })); @@ -113,6 +125,7 @@ mock.module("../../../src/agent/director.js", () => ({ afterAll(() => { mock.module("@intx/tools-posix", () => realToolsPosix); mock.module("../../../src/agent/posix-tool-plugins.js", () => realPosixToolPlugins); + mock.module("../../../src/mcp/client.js", () => realMcpClient); mock.module("../../../src/mcp/plugin.js", () => realMcpPlugin); mock.module("../../../src/plugins/path-escape-plugin.js", () => realPathEscapePlugin); mock.module("../../../src/plugins/authz-plugin.js", () => realAuthzPlugin); @@ -177,8 +190,11 @@ test("dynamicRunner contains posix tool names plus ask_operator", async () => { const names = toolset.dynamicRunner.currentDefinitions().map((d) => d.name); expect(names).toContain("read_file"); - expect(names).toContain("write_file"); expect(names).toContain("ask_operator"); + // Primary Skywalker never mounts product mutation tools. + expect(names).not.toContain("write_file"); + expect(names).not.toContain("edit_file"); + expect(names).not.toContain("delete_file"); }); test("onOperatorGate callback is invoked when the operator tool handler is called", async () => { @@ -288,30 +304,37 @@ const subAgentDeps = { profiles: () => [], }; -test("single session mode omits task and search_agents from registered tools", async () => { +test("default session registers task and search_agents", async () => { const toolset = await createAgentToolset({ cwd: "/fake", permissionGate: fakePermissionGate, onOperatorGate: async () => ({ kind: "option", index: 0 }), - sessionMode: "single", + sessionMode: "orchestrator", subAgent: subAgentDeps, }); const names = toolset.dynamicRunner.currentDefinitions().map((d) => d.name); - expect(names).not.toContain("task"); - expect(names).not.toContain("search_agents"); + expect(names).toContain("task"); + expect(names).toContain("search_agents"); }); -test("orchestrator session mode registers task and search_agents", async () => { +test("headless MCP connection does not wait for interactive OAuth", async () => { + mockConnectMCPServer.mockClear(); const toolset = await createAgentToolset({ cwd: "/fake", permissionGate: fakePermissionGate, - onOperatorGate: async () => ({ kind: "option", index: 0 }), - sessionMode: "orchestrator", - subAgent: subAgentDeps, + onOperatorGate: async () => ({ kind: "cancel" }), + mcpServers: [{ name: "granola", url: "https://example.test/mcp" }], + mcpServersSource: "global", }); - const names = toolset.dynamicRunner.currentDefinitions().map((d) => d.name); - expect(names).toContain("task"); - expect(names).toContain("search_agents"); + + await toolset.connectMCP({ + interactiveAuth: false, + onStatus: () => {}, + onToolsChanged: () => {}, + }); + + expect(mockConnectMCPServer).toHaveBeenCalledTimes(1); + expect(mockConnectMCPServer.mock.calls[0]?.[1]?.onAuthURL).toBeUndefined(); }); test("dispose calls posixTools.dispose", async () => { diff --git a/vendor/intx-inference/README.md b/vendor/intx-inference/README.md index 0d7e1b5f9..f7bc4f25c 100644 --- a/vendor/intx-inference/README.md +++ b/vendor/intx-inference/README.md @@ -1,12 +1,4 @@ -# @intx/inference (vendored) - -Vendored fork of `interchange/packages/inference` at submodule commit -`69c75847`, carrying intercode-local patches not yet upstream. The root -workspace resolves `@intx/inference` here instead of the submodule. - -Local patches: -- Reactor snapshots share deep-frozen turn references instead of - structuredClone-ing the full history on every director decision. +# @intx/inference Provider-agnostic inference runtime. Adapters for Anthropic, OpenAI-compatible relays (including OpenCode Zen), and Google diff --git a/vendor/intx-inference/src/providers/anthropic.ts b/vendor/intx-inference/src/providers/anthropic.ts index 85b2baa91..ee7bcb474 100644 --- a/vendor/intx-inference/src/providers/anthropic.ts +++ b/vendor/intx-inference/src/providers/anthropic.ts @@ -32,12 +32,17 @@ const ANTHROPIC_TOOL_NAME_LIMIT: ToolNameLimit = { }; // Models that reject thinking:{type:"enabled",budget_tokens} and require -// thinking:{type:"adaptive"} with output_config.effort. Keep aligned with -// the discovery plug-in's ADAPTIVE_THINKING_MODELS set. -const ADAPTIVE_THINKING_MODELS: ReadonlySet<string> = new Set([ +// thinking:{type:"adaptive"} with output_config.effort. The discovery +// plug-in's ADAPTIVE_THINKING_MODELS set must match this one; a guard test in +// the anthropic discovery package pins the two equal so they cannot drift. +export const ADAPTIVE_THINKING_MODELS: ReadonlySet<string> = new Set([ "claude-sonnet-5", "claude-opus-5", "claude-fable-5", + "claude-opus-4-8", + "claude-opus-4-6", + "claude-opus-4-7", + "claude-sonnet-4-6", ]); // --------------------------------------------------------------------------- diff --git a/vendor/intx-inference/src/providers/index.ts b/vendor/intx-inference/src/providers/index.ts index 000c29160..236f96d76 100644 --- a/vendor/intx-inference/src/providers/index.ts +++ b/vendor/intx-inference/src/providers/index.ts @@ -7,7 +7,11 @@ import { createAnthropicAdapter } from "./anthropic"; import { createGoogleGenAIAdapter } from "./google-genai"; import { createOpenAIAdapter } from "./openai"; -export { createAnthropicAdapter, AnthropicQuirks } from "./anthropic"; +export { + createAnthropicAdapter, + AnthropicQuirks, + ADAPTIVE_THINKING_MODELS, +} from "./anthropic"; export { createGoogleGenAIAdapter, GoogleGenAIQuirks } from "./google-genai"; export { createOpenAIAdapter, OpenAIQuirks } from "./openai"; diff --git a/vendor/intx-inference/src/reactor.test.ts b/vendor/intx-inference/src/reactor.test.ts index 9b02f48c5..590e9c094 100644 --- a/vendor/intx-inference/src/reactor.test.ts +++ b/vendor/intx-inference/src/reactor.test.ts @@ -1978,6 +1978,42 @@ describe("createReactor — abort handling", () => { // No messages should have been processed. expect(events.some((e) => e.type === "message.received")).toBe(false); }); + + test("abort signals an in-flight inference before the loop can dequeue it", async () => { + const inferenceStarted = Promise.withResolvers<boolean>(); + const { reactor, waitFor } = createTestReactor({ + director: directorFromTable({ + "message.received": (_e, _s, caps) => caps.infer(), + }), + inferenceRunner: async function* (opts) { + inferenceStarted.resolve(true); + await new Promise<void>((resolve) => { + if (opts.signal?.aborted === true) { + resolve(); + return; + } + opts.signal?.addEventListener("abort", () => resolve(), { + once: true, + }); + }); + yield { + type: "inference.error", + seq: opts.nextSeq(), + data: { + error: { category: "aborted", message: "aborted" }, + partial: { text: "" }, + }, + }; + }, + }); + + reactor.start(); + reactor.deliver(makeInboundMessage()); + await inferenceStarted.promise; + reactor.abort("user_disconnect"); + + await waitFor("reactor.done"); + }); }); // --------------------------------------------------------------------------- @@ -3742,6 +3778,87 @@ describe("createReactor — before-tool suspension on ask grant", () => { await second.waitFor("reactor.done"); }); + + test("buffers an approval delivered before startup until its correlation is rehydrated", async () => { + const correlationId = "cold-start-correlation"; + const cell: PersistedCell = { + turns: [], + pendingOperations: [ + { + correlationId, + kind: "approval", + registeredAt: Date.now(), + gateId: `pending-${correlationId}`, + timeoutAt: Date.now() + 60_000, + suspendedCall: { + id: "call-ask", + name: "charge_card", + arguments: {}, + }, + }, + ], + tokenUsage: emptyUsage(), + }; + const persistedStore = makePersistingContextStore(cell); + const loadGate = Promise.withResolvers<boolean>(); + const loadStarted = Promise.withResolvers<boolean>(); + const delayedStore: ContextStore = { + ...persistedStore, + async load() { + loadStarted.resolve(true); + await loadGate.promise; + return persistedStore.load(); + }, + }; + const askExtension = createAuthzExtension({ + authorize: async () => ({ + effect: "ask" as const, + matchingGrants: [], + resolvedBy: null, + }), + approvalTimeoutMs: 60_000, + }); + const toolsRun: string[] = []; + const { reactor, events, waitFor } = createTestReactor({ + contextStore: delayedStore, + director: directorFromTable( + { + "resume.execute_tools": (event, _state, caps) => + caps.executeTools(event.calls, false, true), + "tool.done": (_event, _state, caps) => caps.done(), + }, + "wait", + ), + toolRunner: makeToolRunner(async (call) => { + toolsRun.push(call.name); + return { callId: call.id, content: "charged" }; + }), + beforeToolExtensions: [askExtension], + }); + + reactor.start(); + await loadStarted.promise; + reactor.deliver(makeApprovalMessage(correlationId)); + await Promise.resolve(); + + expect(events.some((event) => event.type === "message.received")).toBe( + false, + ); + + loadGate.resolve(true); + + const toolDone = await waitFor("tool.done"); + if (toolDone.type !== "tool.done") throw new Error("unreachable"); + expect(toolDone.data.result.callId).toBe("call-ask"); + expect(toolsRun).toEqual(["charge_card"]); + expect(events.some((event) => event.type === "message.received")).toBe( + false, + ); + + const correlated = getEvent(events, "message.correlated"); + expect(correlated.data.correlationId).toBe(correlationId); + await waitFor("reactor.done"); + }); }); // --------------------------------------------------------------------------- diff --git a/vendor/intx-inference/src/reactor.ts b/vendor/intx-inference/src/reactor.ts index 80279f9da..4cba63e99 100644 --- a/vendor/intx-inference/src/reactor.ts +++ b/vendor/intx-inference/src/reactor.ts @@ -279,6 +279,10 @@ export function createReactor(config: ReactorConfig): Reactor { let running = false; let done = false; let shutdownStarted = false; + // Correlation state is empty until context loading and gate rehydration + // finish. Hold early deliveries so a resumed approval cannot be mistaken + // for a new conversation message during that startup window. + let startupDeliveries: InboundMessage[] | null = []; // Per-message run-bracket state. Set when the loop dequeues a // message.received and begins per-message work; cleared at the @@ -1534,6 +1538,8 @@ export function createReactor(config: ReactorConfig): Reactor { initialOps = loaded.pendingOperations; initialUsage = loaded.tokenUsage; } catch (cause) { + done = true; + startupDeliveries = null; logger.error`Context store load failed: ${cause}`; emitError( `Context store load failed: ${cause instanceof Error ? cause.message : String(cause)}`, @@ -1571,9 +1577,19 @@ export function createReactor(config: ReactorConfig): Reactor { emit({ type: "reactor.start", seq: nextSeq(), data: {} }); + const bufferedDeliveries = startupDeliveries; + startupDeliveries = null; + if (bufferedDeliveries !== null) { + for (const message of bufferedDeliveries) { + processDelivery(message); + } + } + await loop(); } catch (cause) { const msg = cause instanceof Error ? cause.message : String(cause); + done = true; + startupDeliveries = null; logger.error`Reactor loop threw unexpectedly: ${cause}`; emitError(`Internal reactor error: ${msg}`, true); closeMessageRun("failed", { @@ -1587,8 +1603,7 @@ export function createReactor(config: ReactorConfig): Reactor { })(); } - function deliver(message: InboundMessage): void { - if (done) return; + function processDelivery(message: InboundMessage): void { void (async () => { let correlated: boolean; try { @@ -1622,7 +1637,21 @@ export function createReactor(config: ReactorConfig): Reactor { })(); } + function deliver(message: InboundMessage): void { + if (done) return; + if (startupDeliveries !== null) { + startupDeliveries.push(message); + return; + } + processDelivery(message); + } + function abort(reason: AbortReason): void { + // The loop cannot dequeue the abort event while it is awaiting an active + // inference or tool batch. Signal that operation immediately so it can + // settle and return control to the loop, where the queued abort retains + // its priority over every other event. + operationController.abort(); enqueue({ type: "abort", reason }); } diff --git a/vendor/intx-types/src/authz.ts b/vendor/intx-types/src/authz.ts index 95572a694..9f0d6bd71 100644 --- a/vendor/intx-types/src/authz.ts +++ b/vendor/intx-types/src/authz.ts @@ -35,6 +35,10 @@ export type ConditionContext = { action: string; principalId: string; tenantId: string; + // Identity of the capability consumer the decision is being made for + // (e.g. a `tool:<handle>` consumer). Empty when no consumer is in scope; + // a consumer-scoped condition fails closed against an empty consumer. + consumer: string; }; export type ConditionEvaluator = ( diff --git a/vendor/intx-types/src/credential-cipher.ts b/vendor/intx-types/src/credential-cipher.ts new file mode 100644 index 000000000..1e3c25350 --- /dev/null +++ b/vendor/intx-types/src/credential-cipher.ts @@ -0,0 +1,42 @@ +/** + * The pluggable seam for encrypting credential secrets at rest. + * + * Every write site (credential / oauth-client create and update) encrypts + * through this interface; the single read-for-use site decrypts through it. Both + * depend only on the interface, so the concrete implementation is chosen once at + * the composition root and swapped without touching any call site. + * + * The one basic implementation today is `createEnvKeyCredentialCipher` + * (@intx/crypto): AES-256-GCM under a single operator-provided key. A future KMS + * or envelope-encryption plugin implements this same interface and can keep key + * material inside the KMS, because the seam abstracts the whole encrypt/decrypt + * operation rather than just supplying key bytes. + * + * `aad` (additional authenticated data) binds a ciphertext to its context -- the + * row id and column -- so a blob cannot be transplanted between rows (or between + * a row's columns) and still decrypt. Every site builds the `aad` with + * `credentialAad(id, column)` so the binding is identical on write and read. + * + * `decrypt` is strict: it throws on a value that is not a ciphertext produced by + * `encrypt` rather than returning it as plaintext. A plaintext value reaching + * decrypt means a write path failed to encrypt or the row was never re-keyed -- + * a failure that must surface, not be silently served. + */ +export interface CredentialCipher { + encrypt(plaintext: string, aad: string): Promise<string>; + decrypt(blob: string, aad: string): Promise<string>; +} + +/** + * Build the additional-authenticated-data string binding a credential-secret + * ciphertext to the row and column it belongs to. The encoding is injective in + * `(id, column)` -- distinct pairs always produce distinct strings -- so a + * ciphertext cannot be transplanted to a row/column it was not sealed for even + * if an id contained the delimiter of a naive `id:column` scheme. The + * `"credential-secret"` tag domain-separates this use of the AEAD primitive from + * any other. Both the write and read sites (and the re-key script) MUST build + * the `aad` through this one function so the value matches. + */ +export function credentialAad(id: string, column: string): string { + return JSON.stringify(["credential-secret", id, column]); +} diff --git a/vendor/intx-types/src/credentials.test.ts b/vendor/intx-types/src/credentials.test.ts index 6402fe90b..db205a6e1 100644 --- a/vendor/intx-types/src/credentials.test.ts +++ b/vendor/intx-types/src/credentials.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect } from "bun:test"; import { type } from "arktype"; import { credentialRequirementSources, + CredentialBinding, CredentialRequirement, } from "./credentials"; @@ -48,3 +49,51 @@ describe("CredentialRequirement validator", () => { expect(result instanceof type.errors).toBe(false); }); }); + +// --------------------------------------------------------------------------- +// 3. CredentialBinding: locator and authority are separate axes +// --------------------------------------------------------------------------- + +describe("CredentialBinding validator", () => { + const wellFormed = { + package: "@intx/tools-github", + handle: "gh", + provider: "github", + locator: "tenant", + }; + + test("accepts a well-formed binding", () => { + expect(CredentialBinding(wellFormed) instanceof type.errors).toBe(false); + }); + + test("accepts an optional name", () => { + expect( + CredentialBinding({ + ...wellFormed, + name: "deploy-key", + }) instanceof type.errors, + ).toBe(false); + }); + + test("rejects an unknown locator (only tenant is supported today)", () => { + // creator/invoker-brought locators are future work; they must fail closed. + expect( + CredentialBinding({ ...wellFormed, locator: "creator" }) instanceof + type.errors, + ).toBe(true); + }); + + test("rejects a malformed handle", () => { + expect( + CredentialBinding({ ...wellFormed, handle: "Bad Handle!" }) instanceof + type.errors, + ).toBe(true); + }); + + test("rejects a missing required field", () => { + const { provider: _provider, ...missingProvider } = wellFormed; + expect(CredentialBinding(missingProvider) instanceof type.errors).toBe( + true, + ); + }); +}); diff --git a/vendor/intx-types/src/credentials.ts b/vendor/intx-types/src/credentials.ts index b8babec06..7bdb327b7 100644 --- a/vendor/intx-types/src/credentials.ts +++ b/vendor/intx-types/src/credentials.ts @@ -1,5 +1,7 @@ import { type } from "arktype"; +import { ToolCredentialHandle } from "./package-json"; + export const credentialTypes = [ "api_key", "oauth_token", @@ -28,6 +30,38 @@ const CredType = type.enumerated(...credentialTypes); const CredStatus = type.enumerated(...credentialStatuses); const CredentialSourceType = type.enumerated(...credentialRequirementSources); +// A credential binding on an agent definition maps a tool package's declared +// credential handle -- keyed `(package, handle)` against the tool-package +// declaration -- to a concrete credential resolved fresh at launch. `locator` +// is which credential namespace the name is resolved in; today only `tenant` +// exists (a tenant-owned credential, authorized by ownership). A second locator +// that resolves a principal-owned credential -- and the delegation authority +// axis it would need -- is future work, added with the code that consumes it. +export const credentialBindingLocators = ["tenant"] as const; +export type CredentialBindingLocator = + (typeof credentialBindingLocators)[number]; + +const BindingLocator = type.enumerated(...credentialBindingLocators); + +export const CredentialBinding = type({ + package: type("string").describe( + "The tool package the declared handle belongs to; matches the resolved manifest's top-level package name.", + ), + handle: ToolCredentialHandle.describe( + "The credential handle the tool package declared; unique within its package.", + ), + provider: type("string").describe( + "The provider the bound credential resolves against.", + ), + "name?": type("string").describe( + "Optional credential name, a tiebreaker when several credentials match the provider and locator.", + ), + locator: BindingLocator.describe( + "Which credential namespace the binding resolves the credential in. `tenant` resolves a tenant-owned credential by provider/name through the tenant walk-up; its use is authorized by tenant ownership.", + ), +}); +export type CredentialBinding = typeof CredentialBinding.infer; + const credentialTypeDescription = "Kind of secret material this credential holds: `api_key`, `oauth_token`, `certificate`, or `other`. Determines how `secret` (and `refreshSecret` for OAuth) is interpreted when the credential is used."; diff --git a/vendor/intx-types/src/grants.test.ts b/vendor/intx-types/src/grants.test.ts index 9cc42ac92..b8345cb9f 100644 --- a/vendor/intx-types/src/grants.test.ts +++ b/vendor/intx-types/src/grants.test.ts @@ -1,13 +1,17 @@ import { describe, test, expect } from "bun:test"; import { type } from "arktype"; -import { grantRequirementSources, GrantRequirement } from "./grants"; +import { + CreateGrant, + grantRequirementSources, + GrantRequirement, +} from "./grants"; // --------------------------------------------------------------------------- // 1. Source enum // --------------------------------------------------------------------------- describe("source enums", () => { - test("grantRequirementSources includes only creator and invoker", () => { + test("grantRequirementSources are creator and invoker", () => { expect([...grantRequirementSources]).toEqual(["creator", "invoker"]); }); }); @@ -29,19 +33,19 @@ describe("GrantRequirement validator", () => { test("accepts creator and invoker sources", () => { for (const source of ["creator", "invoker"] as const) { const result = GrantRequirement({ - resource: "wallet:*", - action: "spend", + resource: "credential:crd_stripe", + action: "use", source, }); expect(result instanceof type.errors).toBe(false); } }); - test("rejects tenant source", () => { + test("rejects an unknown source", () => { const result = GrantRequirement({ resource: "tool:bash", action: "invoke", - source: "tenant", + source: "system", }); expect(result instanceof type.errors).toBe(true); }); @@ -126,3 +130,51 @@ describe("GrantRequirement validator", () => { expect(withEffect instanceof type.errors).toBe(false); }); }); + +// --------------------------------------------------------------------------- +// 3. CreateGrant: exactly one target (matches the grant_target_exactly_one DB +// CHECK, so a malformed request is a 400 rather than a database 500) +// --------------------------------------------------------------------------- + +describe("CreateGrant target validation", () => { + const base = { + resource: "credential:crd_x", + action: "use", + effect: "allow", + origin: "system", + }; + + test("accepts a role-targeted grant", () => { + expect( + CreateGrant({ ...base, roleId: "rol_x" }) instanceof type.errors, + ).toBe(false); + }); + + test("accepts a principal-targeted grant", () => { + expect( + CreateGrant({ ...base, principalId: "prn_x" }) instanceof type.errors, + ).toBe(false); + }); + + test("rejects a grant with neither target", () => { + expect(CreateGrant({ ...base }) instanceof type.errors).toBe(true); + }); + + test("rejects a grant with both targets", () => { + expect( + CreateGrant({ ...base, roleId: "rol_x", principalId: "prn_x" }) instanceof + type.errors, + ).toBe(true); + }); + + test("treats an explicit null target as absent", () => { + expect( + CreateGrant({ ...base, roleId: "rol_x", principalId: null }) instanceof + type.errors, + ).toBe(false); + expect( + CreateGrant({ ...base, roleId: null, principalId: null }) instanceof + type.errors, + ).toBe(true); + }); +}); diff --git a/vendor/intx-types/src/grants.ts b/vendor/intx-types/src/grants.ts index bc505fa31..08190dde5 100644 --- a/vendor/intx-types/src/grants.ts +++ b/vendor/intx-types/src/grants.ts @@ -36,6 +36,17 @@ export const CreateGrant = type({ ), origin: Origin.describe(originDescription), "expiresAt?": "string | null", +}).narrow((g, ctx) => { + // A grant targets exactly one of a role or a principal -- the same invariant + // the `grant_target_exactly_one` DB CHECK enforces. Rejecting both/neither + // here surfaces a malformed request as a 400 rather than a database 500. + const targets = (g.roleId != null ? 1 : 0) + (g.principalId != null ? 1 : 0); + if (targets !== 1) { + return ctx.mustBe( + "a grant with exactly one target: set roleId or principalId, not both and not neither", + ); + } + return true; }); export const UpdateGrant = type({ @@ -96,7 +107,7 @@ export const GrantRequirement = type({ "Effect to assign the materialized grant: `allow`, `deny`, or `ask`. Defaults to `allow` when omitted.", ), source: GrantSourceType.describe( - "Whose authority the grant is resolved against at launch: `creator` (the definition author) or `invoker` (whoever launched the agent). The requirement is only satisfied if that party actually holds the requested capability.", + "Whose authority the grant is resolved against at launch: `creator` (the definition author) or `invoker` (whoever launched the agent) -- satisfied only if that party actually holds the requested capability. Tenant-owned credential use is not a grant requirement: it is authorized by ownership at resolution and its consumer-scoping grant is stamped directly (see CREDENTIALS.md).", ), "conditions?": "Record<string, unknown> | null", }); diff --git a/vendor/intx-types/src/index.ts b/vendor/intx-types/src/index.ts index d0abcf99c..206530e4f 100644 --- a/vendor/intx-types/src/index.ts +++ b/vendor/intx-types/src/index.ts @@ -14,6 +14,8 @@ export * from "./wallets"; export * from "./providers"; export * from "./oauth-clients"; export * from "./credentials"; +export * from "./credential-cipher"; +export * from "./mediated-credential"; export * from "./assets"; export * from "./offerings"; export * from "./models"; @@ -30,3 +32,5 @@ export * from "./base64url"; export * from "./concat"; export * from "./has-code"; export * from "./audit"; +export * from "./sidecar-placement"; +export * from "./sidecar-allocation"; diff --git a/vendor/intx-types/src/mediated-credential.ts b/vendor/intx-types/src/mediated-credential.ts new file mode 100644 index 000000000..21538f0b6 --- /dev/null +++ b/vendor/intx-types/src/mediated-credential.ts @@ -0,0 +1,102 @@ +// The runtime mediated-credential surface: how a resolved provider-backed +// credential reaches the consumer that uses it (a tool, or the built-in +// reactor) WITHOUT handing over the raw secret. +// +// A consumer declares a credential handle (see `ToolCredentialHandle`) and, at +// handler-init, resolves a *mediated credential* -- a handle that lets it +// authenticate against the provider without holding the secret on its own API. +// An HTTP credential mediates by exposing an authed `fetch` pinned to the +// credential's provider origin; the bearer token is injected per request and +// never surfaced. +// +// Honest scope of the mediation: it is NOT containment against hostile tool +// code. A tool that legitimately receives an http mediated credential can read +// the Authorization header the fetch sends. What mediation buys is (a) the +// secret is off the tool's declared API surface, (b) a single rotation point -- +// material is read fresh per use, so a rotation reaches every holder without +// re-shaping the handle -- and (c) consumer-scoped resolution. Confidentiality +// from the receiving tool needs process/VM isolation, a different boundary. +// +// The provider plugin owns how a handle is shaped; the acquisition of material +// (resolve a credential row, authorize, decrypt) lives on the delivery side and +// is never the plugin's decision. + +/** The current secret material behind a credential, read fresh at each use. */ +export interface CredentialMaterial { + readonly secret: string; +} + +/** + * Reads the current material for one credential. A provider handle calls this + * per use rather than capturing a snapshot, so a rotation that updates the + * underlying cell is picked up without rebuilding the handle. + */ +export type CredentialMaterialSource = () => CredentialMaterial; + +/** What a provider plugin is given to shape a mediated credential. */ +export interface CredentialShapeContext { + /** + * The provider origin the credential authenticates to (e.g. + * `https://api.github.com`). An http handle pins its requests to this origin. + */ + readonly origin: string; + /** Reads the current secret material at each use (rotation indirection). */ + readCurrentMaterial: CredentialMaterialSource; +} + +/** Fields shared by every mediated-credential variant. */ +export interface MediatedCredentialBase { + /** Discriminates the variant a consumer narrows on. */ + readonly kind: string; + /** + * Release resources the handle allocated. An http handle allocates none; a + * future key-file/socket handle would. Idempotent; run on teardown. + */ + dispose(): void | Promise<void>; +} + +/** + * An HTTP-authenticated mediated credential: an authed `fetch` pinned to the + * credential's provider origin. A request whose resolved origin is not the + * pinned one is refused, and redirects are not followed (a 3xx is returned to + * the caller), so the bearer token is only ever sent to the pinned origin and a + * holder cannot redirect it to an attacker-chosen host. + */ +export interface HttpMediatedCredential extends MediatedCredentialBase { + readonly kind: "http"; + fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>; +} + +/** + * A mediated credential handed to a consumer at resolve time. A discriminated + * union on `kind`; `http` is the only variant today. Future provider kinds + * (e.g. an ssh key-file + agent socket) extend the union with their own `kind`. + */ +export type MediatedCredential = HttpMediatedCredential; + +/** + * A provider plugin: the seam that owns how a mediated credential is shaped for + * its provider. Registered under `key`, matched against a resolved provider's + * plugin identifier. The plugin shapes a handle from a material source; it does + * not acquire material and never decides authorization -- both happen upstream, + * at the delivery boundary, before a plugin is ever consulted. + */ +export interface CredentialProvider { + readonly key: string; + shape(context: CredentialShapeContext): MediatedCredential; +} + +/** + * The runtime `credentials` capability: a sub-registry a consumer queries by + * the credential handle it declared, receiving a mediated credential. It is the + * dynamic (per-binding) axis that lives under the fixed, statically-typed + * capability map. + * + * Resolution is consumer-scoped and fail-closed: it yields a handle only for a + * credential the calling consumer is authorized to use. An unbound handle, or + * one the consumer lacks a `credential:{id}` / `use` grant for, throws. `resolve` + * is async because the authorization check is. + */ +export interface CredentialCapability { + resolve(handle: string): Promise<MediatedCredential>; +} diff --git a/vendor/intx-types/src/message-id.ts b/vendor/intx-types/src/message-id.ts index 9e6436768..723f70060 100644 --- a/vendor/intx-types/src/message-id.ts +++ b/vendor/intx-types/src/message-id.ts @@ -7,10 +7,10 @@ // as a fresh message. This module is the single source of truth those // call sites import. // -// A workflow run's id is NOT this value -- every run of a deployment -// shares the deployment's mail address as its stable runId (see -// `deriveWorkflowRunId`). The two ids are distinct: this one is -// per-message, the runId is per-deployment. +// A workflow run's id is NOT this value -- a deployment's one addressable +// top-level run uses the deployment mail address as its stable runId (see +// `deriveWorkflowRunId`). The two ids are distinct: this one is per-message, +// while the top-level runId is per-deployment. // // The identifier is the `Message-ID` header value when the message // carries one, and a sha256 of the raw bytes otherwise -- so a message diff --git a/vendor/intx-types/src/package-json.test.ts b/vendor/intx-types/src/package-json.test.ts new file mode 100644 index 000000000..8b50af12d --- /dev/null +++ b/vendor/intx-types/src/package-json.test.ts @@ -0,0 +1,76 @@ +import { describe, test, expect } from "bun:test"; +import { type } from "arktype"; + +import { PackageJSON } from "./package-json"; + +function accepts(data: unknown): boolean { + return !(PackageJSON(data) instanceof type.errors); +} + +describe("PackageJSON interchange.credentials", () => { + test("accepts a well-formed credential declaration", () => { + expect( + accepts({ + name: "tools-x", + version: "1.0.0", + interchange: { + tools: "./dist/bundle.js", + credentials: [ + { handle: "gh", scopes: ["repo"] }, + { handle: "stripe" }, + ], + }, + }), + ).toBe(true); + }); + + test("accepts a package with no credential declaration", () => { + expect( + accepts({ + name: "tools-x", + version: "1.0.0", + interchange: { tools: "./dist/bundle.js" }, + }), + ).toBe(true); + }); + + test("rejects a malformed handle", () => { + expect( + accepts({ + name: "tools-x", + version: "1.0.0", + interchange: { credentials: [{ handle: "Bad Handle!" }] }, + }), + ).toBe(false); + }); + + test("rejects non-array credentials", () => { + expect( + accepts({ + name: "tools-x", + version: "1.0.0", + interchange: { credentials: { handle: "gh" } }, + }), + ).toBe(false); + }); + + test("rejects a non-string scope", () => { + expect( + accepts({ + name: "tools-x", + version: "1.0.0", + interchange: { credentials: [{ handle: "gh", scopes: [123] }] }, + }), + ).toBe(false); + }); + + test("rejects a duplicate handle within one package", () => { + expect( + accepts({ + name: "tools-x", + version: "1.0.0", + interchange: { credentials: [{ handle: "gh" }, { handle: "gh" }] }, + }), + ).toBe(false); + }); +}); diff --git a/vendor/intx-types/src/package-json.ts b/vendor/intx-types/src/package-json.ts index 87c9f5d09..49e6a9c59 100644 --- a/vendor/intx-types/src/package-json.ts +++ b/vendor/intx-types/src/package-json.ts @@ -11,15 +11,55 @@ import { type } from "arktype"; /** - * Required fields plus the `interchange.tools` extension used to - * identify tool packages. `onUndeclaredKey("ignore")` lets the - * arbitrary upstream npm fields pass through without listing them. + * A tool package's static declaration of one provider-backed credential it + * needs: an abstract handle plus optional scopes. Advisory only -- a request + * the agent definition later binds to a concrete credential and the launch-time + * grant gate authorizes; a declaration consents to nothing on its own. The + * handle is the key the binding and the runtime delivery use. + */ +export const ToolCredentialHandle = type(/^[a-z0-9][a-z0-9._-]*$/); + +export const ToolCredentialDeclaration = type({ + handle: ToolCredentialHandle, + "scopes?": "string[]", +}); +export type ToolCredentialDeclaration = typeof ToolCredentialDeclaration.infer; + +/** + * The credential declarations for one package, with the unique-handle + * invariant enforced at parse time: a handle is the binding/delivery key, so a + * duplicate within a single package is a defect the upload boundary must + * reject rather than let collapse silently downstream. + */ +export const ToolCredentialDeclarationArray = + ToolCredentialDeclaration.array().narrow((decls, ctx) => { + const seen = new Set<string>(); + for (const decl of decls) { + if (seen.has(decl.handle)) { + return ctx.mustBe( + `an array with no duplicate credential handles; "${decl.handle}" appears more than once`, + ); + } + seen.add(decl.handle); + } + return true; + }); +export type ToolCredentialDeclarationArray = + typeof ToolCredentialDeclarationArray.infer; + +/** + * Required fields plus the `interchange` extension used to identify tool + * packages: `tools` names the sidecar-bundle entry, and `credentials` + * statically declares the provider-backed credentials the package's tools may + * need. `onUndeclaredKey("ignore")` lets the arbitrary upstream npm fields pass + * through without listing them. */ export const PackageJSON = type({ name: "string", version: "string", "interchange?": type({ "tools?": "string", + "credentials?": ToolCredentialDeclarationArray, }).onUndeclaredKey("ignore"), }).onUndeclaredKey("ignore"); export type PackageJSON = typeof PackageJSON.infer; diff --git a/vendor/intx-types/src/providers.ts b/vendor/intx-types/src/providers.ts index a02d3d01f..f3c0a46d0 100644 --- a/vendor/intx-types/src/providers.ts +++ b/vendor/intx-types/src/providers.ts @@ -9,9 +9,13 @@ const providerScopesDescription = const providerMetadataDescription = "Free-form provider-specific configuration not covered by the typed fields. Not interpreted by the hub."; +const apiBaseUrlDescription = + "The API origin a credential from this provider authenticates to (for example https://api.github.com). A provider that backs an origin-pinned credential must set it; OAuth-login-only providers may omit it."; + export const CreateProvider = type({ name: "string", plugin: type("string").describe(pluginDescription), + "apiBaseUrl?": type("string").describe(apiBaseUrlDescription), "authorizationUrl?": "string", "tokenUrl?": "string", "userInfoUrl?": "string", @@ -24,6 +28,7 @@ export const CreateProvider = type({ export const UpdateProvider = type({ "name?": "string", "plugin?": type("string").describe(pluginDescription), + "apiBaseUrl?": type("string | null").describe(apiBaseUrlDescription), "authorizationUrl?": "string | null", "tokenUrl?": "string | null", "userInfoUrl?": "string | null", @@ -38,6 +43,7 @@ export const ProviderResponse = type({ tenantId: "string", name: "string", plugin: type("string").describe(pluginDescription), + "apiBaseUrl?": type("string | null").describe(apiBaseUrlDescription), "authorizationUrl?": "string | null", "tokenUrl?": "string | null", "userInfoUrl?": "string | null", diff --git a/vendor/intx-types/src/runtime-capabilities.ts b/vendor/intx-types/src/runtime-capabilities.ts index 2202268fb..374f9df7d 100644 --- a/vendor/intx-types/src/runtime-capabilities.ts +++ b/vendor/intx-types/src/runtime-capabilities.ts @@ -10,6 +10,7 @@ // keys here so every host sees the same canonical map. import type { MessageTransport } from "./runtime"; +import type { CredentialCapability } from "./mediated-credential"; /** * Registry of capability keys to the value types they resolve to. Keys are @@ -25,6 +26,15 @@ export interface RuntimeCapabilityMap { * for sending and receiving mail. */ "mail.transport": MessageTransport; + + /** + * Provider-backed credentials the agent's tools resolve by their declared + * handle. Unlike the other keys, its value is itself a sub-registry: the set + * of bound handles is per-deploy runtime data, not known at compile time, so + * the dynamic axis lives inside `CredentialCapability` while this outer map + * stays fixed and typed. Resolution is consumer-scoped and fail-closed. + */ + credentials: CredentialCapability; } export type RuntimeCapabilityKey = keyof RuntimeCapabilityMap; @@ -83,3 +93,43 @@ export function createRuntimeCapabilities( }, }; } + +/** + * Compose a resolver that answers the keys in `overrides` from the override + * map and delegates every other key to `base`. + * + * The host uses this to add a per-bundle capability -- the consumer-scoped + * `credentials` handle, one instance per tool package -- onto a shared + * per-step base bag without re-plumbing the base's keys (`mail.transport` + * and any future shared key stay owned by the step bag). Each tool package's + * bundle receives the same base layered with ITS OWN credentials capability, + * so a package cannot resolve a handle scoped to a different package. + * + * `overrides` is snapshotted at construction, mirroring + * `createRuntimeCapabilities`, so later mutation of the input is not + * observable through `resolve`. An overridden key wired to `undefined` + * throws with the same guard as the base resolver rather than silently + * shadowing `base` with a hole -- a host that layers an undefined value + * has a wiring bug and must hear about it. + */ +export function layerRuntimeCapabilities( + base: RuntimeCapabilities, + overrides: Partial<RuntimeCapabilityMap>, +): RuntimeCapabilities { + const snapshot: Partial<RuntimeCapabilityMap> = { ...overrides }; + + return { + resolve<K extends RuntimeCapabilityKey>(key: K): RuntimeCapabilityMap[K] { + if (Object.hasOwn(snapshot, key)) { + const value = snapshot[key]; + if (value === undefined) { + throw new Error( + `Runtime capability "${String(key)}" was layered as undefined; no current capability resolves to undefined`, + ); + } + return value; + } + return base.resolve(key); + }, + }; +} diff --git a/vendor/intx-types/src/sidecar-allocation.ts b/vendor/intx-types/src/sidecar-allocation.ts new file mode 100644 index 000000000..ccbf59cf4 --- /dev/null +++ b/vendor/intx-types/src/sidecar-allocation.ts @@ -0,0 +1,32 @@ +export const sidecarAllocationStatuses = [ + "pending", + "provisioning", + "allocated", + "replacing", + "releasing", + "released", + "failed", +] as const; + +export type SidecarAllocationStatus = + (typeof sidecarAllocationStatuses)[number]; + +export function isSidecarAllocationDispatchable( + status: SidecarAllocationStatus, +): boolean { + switch (status) { + case "pending": + case "provisioning": + case "allocated": + case "replacing": + return true; + case "releasing": + case "released": + case "failed": + return false; + default: { + const exhaustive: never = status; + return exhaustive; + } + } +} diff --git a/vendor/intx-types/src/sidecar-placement.ts b/vendor/intx-types/src/sidecar-placement.ts new file mode 100644 index 000000000..4f91c32cb --- /dev/null +++ b/vendor/intx-types/src/sidecar-placement.ts @@ -0,0 +1,12 @@ +import { type } from "arktype"; + +/** + * Requires a workflow to use a sidecar that is not shared with unrelated + * workflows or ordinary work while its allocation is active. + */ +export const SidecarPlacementRequirement = type({ + sharing: "'exclusive'", + "reuse?": "'never' | 'same-deployment'", +}); +export type SidecarPlacementRequirement = + typeof SidecarPlacementRequirement.infer; diff --git a/vendor/intx-types/src/sidecar.test.ts b/vendor/intx-types/src/sidecar.test.ts index e573c13f7..6f19d1a59 100644 --- a/vendor/intx-types/src/sidecar.test.ts +++ b/vendor/intx-types/src/sidecar.test.ts @@ -3,6 +3,7 @@ import { type } from "arktype"; import { APPROVAL_SNAPSHOT_MAX_BYTES } from "./runtime"; import { AgentDeployFrame, + CredentialsUpdateFrame, DeployApplyErrorCategory, SidecarFrame, SignalCorrelationRegisterFrame, @@ -179,6 +180,58 @@ describe("SourcesUpdateFrame", () => { }); }); +describe("CredentialsUpdateFrame", () => { + const material = { + credentialId: "cred_a", + providerKey: "http", + origin: "https://api.example.test", + secret: "sk-real", + }; + const binding = { + handle: "gh", + credentialId: "cred_a", + consumer: "tool:@intx/tools-example", + }; + const base = { + type: "credentials.update" as const, + requestId: "req_1", + agentAddress: "agt_1@example.test", + }; + + test("accepts a well-formed delivery", () => { + const result = CredentialsUpdateFrame({ + ...base, + delivery: { bindings: [binding], materials: [material] }, + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("accepts an empty delivery (a revocation that evicts every credential)", () => { + const result = CredentialsUpdateFrame({ + ...base, + delivery: { bindings: [], materials: [] }, + }); + expect(result instanceof type.errors).toBe(false); + }); + + test("rejects a material entry missing its secret", () => { + const result = CredentialsUpdateFrame({ + ...base, + delivery: { + bindings: [binding], + materials: [ + { + credentialId: "cred_a", + providerKey: "http", + origin: "https://api.example.test", + }, + ], + }, + }); + expect(result instanceof type.errors).toBe(true); + }); +}); + describe("SignalCorrelationRegisterFrame snapshot requirement", () => { const base = { type: "signal.correlation.register", diff --git a/vendor/intx-types/src/sidecar.ts b/vendor/intx-types/src/sidecar.ts index 4be7c3dc8..88a276c46 100644 --- a/vendor/intx-types/src/sidecar.ts +++ b/vendor/intx-types/src/sidecar.ts @@ -400,6 +400,42 @@ const WorkflowProjectionWithSources = type({ return true; }); +/** + * The decrypted credential material and per-handle binding descriptors + * delivered to a running agent so its tools can use provider-backed + * credentials. Secrets are decrypted hub-side and ride this payload on the + * live channel ONLY -- the deploy frame at launch, a `credentials.update` + * frame on rotation, and the child's in-memory cell. They are NEVER written to + * disk (they do not ride the git-committed grants file) and NEVER copied into + * any snapshot, event, or state -- redaction is by construction, mirroring how + * an `InferenceSource`'s `apiKey` stays off every egress type. + * + * `materials` is keyed by `credentialId` (a credential can back several handles, + * so its secret is stored once); `bindings` maps each declared tool handle to + * the credential that backs it and the consumer identity allowed to use it. + */ +export const CredentialMaterialEntry = type({ + credentialId: "string", + providerKey: "string", + origin: "string", + secret: "string", +}); +export type CredentialMaterialEntry = typeof CredentialMaterialEntry.infer; + +export const CredentialBindingDescriptor = type({ + handle: "string", + credentialId: "string", + consumer: "string", +}); +export type CredentialBindingDescriptor = + typeof CredentialBindingDescriptor.infer; + +export const CredentialDelivery = type({ + bindings: CredentialBindingDescriptor.array(), + materials: CredentialMaterialEntry.array(), +}); +export type CredentialDelivery = typeof CredentialDelivery.infer; + export const AgentDeployWorkflow = type({ definition: WorkflowProjectionDefinition, sources: { "[string]": InferenceSource.array().atLeastLength(1) }, @@ -412,6 +448,12 @@ export const AgentDeployWorkflow = type({ // on disk (`sources.json`) so a body child -- in-process, its env lost across // a restart -- resolves inference durably without a hub round-trip. "referencedDefinitions?": WorkflowProjectionWithSources.array(), + // Initial credential material for the deployment's tools, decrypted hub-side + // and delivered on the deploy frame so it is resident before any step runs + // (closing the race where a tool resolves a credential before a push lands). + // Run-global: a credential's secret is stored once, keyed by credentialId. + // Optional -- a deploy whose definition binds no credentials omits it. + "credentials?": CredentialDelivery, }).narrow((value, ctx) => { for (const stepId of value.definition.stepOrder) { if (!Object.prototype.hasOwnProperty.call(value.sources, stepId)) { @@ -509,6 +551,20 @@ export const SourcesUpdateFrame = type({ }); export type SourcesUpdateFrame = typeof SourcesUpdateFrame.infer; +/** + * Push refreshed credential material to a running deployment (a rotation, or a + * revocation delivered by omitting the revoked credential's material so the + * child evicts it). Mirrors `SourcesUpdateFrame`: the sidecar routes it to the + * deployment's supervisor, which forwards it to the child's in-memory cell. + */ +export const CredentialsUpdateFrame = type({ + type: "'credentials.update'", + requestId: "string", + agentAddress: "string", + delivery: CredentialDelivery, +}); +export type CredentialsUpdateFrame = typeof CredentialsUpdateFrame.infer; + // --------------------------------------------------------------------------- // Pack transport (bidirectional) // --------------------------------------------------------------------------- @@ -602,8 +658,8 @@ export type PackPushFrame = typeof PackPushFrame.infer; * * When `mountPath` is set, the receiver materializes the pack at * `workspace/<mountPath>/` instead of the hardcoded agent deploy tree. - * Absent for the agent-state deploy/state flows, which continue to apply - * the pack to the agent's repo as before. + * Absent for agent-state deploy/state flows and workflow-run restoration. + * The receiver distinguishes those paths by `repoId.kind`. */ export const PackDoneFrame = type({ type: "'repo.pack.done'", @@ -795,6 +851,7 @@ export const HubFrame = MailInboundFrame.or(AgentDeployFrame) .or(ChallengeFailedFrame) .or(PongFrame) .or(SourcesUpdateFrame) + .or(CredentialsUpdateFrame) .or(PackPushFrame) .or(PackDoneFrame) .or(PackAckFrame) diff --git a/vendor/intx-types/src/tenants.ts b/vendor/intx-types/src/tenants.ts index 6ce6ddf6a..537070dfc 100644 --- a/vendor/intx-types/src/tenants.ts +++ b/vendor/intx-types/src/tenants.ts @@ -1,5 +1,13 @@ import { type } from "arktype"; +import { SidecarPlacementRequirement } from "./sidecar-placement"; + +export const TenantConfig = type({ + "[string]": "unknown", + "sidecarPlacement?": SidecarPlacementRequirement, +}); +export type TenantConfig = typeof TenantConfig.infer; + export const CreateTenant = type({ name: "string", slug: "string", @@ -8,7 +16,7 @@ export const CreateTenant = type({ export const UpdateTenant = type({ "name?": "string", - "config?": "Record<string, unknown>", + "config?": TenantConfig, }); export const TenantResponse = type({ @@ -17,7 +25,7 @@ export const TenantResponse = type({ slug: "string", domain: "string", "parentId?": "string | null", - "config?": "Record<string, unknown>", + "config?": TenantConfig, createdAt: "string", updatedAt: "string", }); diff --git a/vendor/intx-types/src/tool-packages.ts b/vendor/intx-types/src/tool-packages.ts index db55dd762..d4a9b3742 100644 --- a/vendor/intx-types/src/tool-packages.ts +++ b/vendor/intx-types/src/tool-packages.ts @@ -13,6 +13,8 @@ import { type } from "arktype"; import semver from "semver"; +import { ToolCredentialDeclarationArray } from "./package-json"; + /** * npm's documented package-name rules expressed as an arktype regex * literal: lowercase, may begin with a scope (`@scope/`), the rest of @@ -98,6 +100,42 @@ export const ToolPackagePinArray = ToolPackagePin.array().narrow( ); export type ToolPackagePinArray = typeof ToolPackagePinArray.infer; +/** + * A top-level manifest entry: a pinned package at its concrete resolved + * version, carrying the credential declarations harvested from the package's + * `interchange.credentials` (absent when it declares none). Only top-level + * pins contribute declarations; transitive dependencies never do, which is why + * this shape hangs off `topLevel` rather than `entries`. + */ +export const ToolPackageTopLevelEntry = type({ + name: ToolPackagePinName, + version: "string", + "credentials?": ToolCredentialDeclarationArray, +}); +export type ToolPackageTopLevelEntry = typeof ToolPackageTopLevelEntry.infer; + +/** + * The manifest's top-level entries with the no-duplicate-name invariant + * preserved -- the same guarantee `ToolPackagePinArray` gives agent-side pins. + * Versions here are concrete (already picked by the resolver), so the + * semver-range check that guards agent-side pins is unnecessary. + */ +export const ToolPackageTopLevelArray = ToolPackageTopLevelEntry.array().narrow( + (entries, ctx) => { + const seen = new Set<string>(); + for (const entry of entries) { + if (seen.has(entry.name)) { + return ctx.mustBe( + `an array with no duplicate package names; "${entry.name}" appears more than once`, + ); + } + seen.add(entry.name); + } + return true; + }, +); +export type ToolPackageTopLevelArray = typeof ToolPackageTopLevelArray.infer; + /** * A pinned entry's tarball lives inside an asset attached to the * agent at session time. `assetId` is the hub-side asset row id; the @@ -178,9 +216,9 @@ export type ToolPackageManifestEntry = typeof ToolPackageManifestEntry.infer; * dependencies materialized for runtime `require()` / `import` * resolution. * - * Although `topLevel` shares the `ToolPackagePin` shape used at agent - * definition time, the `version` field here is always a concrete - * version (e.g. `"1.2.3"`), not a range. The resolver walks each + * `topLevel` extends the agent-side `ToolPackagePin` shape with the + * package's harvested `credentials` declarations. The `version` field + * here is always a concrete version (e.g. `"1.2.3"`), not a range. The resolver walks each * agent-side pin's range through `npm-pick-manifest` and writes the * picked version. The sidecar loader pairs `topLevel[i]` against * `entries[j]` by `${name}@${version}` equality, so a range-form @@ -199,7 +237,7 @@ export const ToolPackageManifest = type({ // when building the manifest; the validator is the second line of // defense for any third-party hub or hand-edited file that slips a // duplicate through. - topLevel: ToolPackagePinArray, + topLevel: ToolPackageTopLevelArray, entries: ToolPackageManifestEntry.array(), }); export type ToolPackageManifest = typeof ToolPackageManifest.infer; diff --git a/vendor/intx-types/src/workflow-run-id.ts b/vendor/intx-types/src/workflow-run-id.ts index 2b3728c92..1bc1a2238 100644 --- a/vendor/intx-types/src/workflow-run-id.ts +++ b/vendor/intx-types/src/workflow-run-id.ts @@ -1,7 +1,7 @@ -// Canonical runId derivation for a workflow deployment's runs. +// Canonical runId derivation for a workflow deployment's top-level run. // -// Every run of a workflow deployment shares ONE stable runId: the -// deployment's mail address (`ins_<deploymentId>@<domain>`). The +// A workflow deployment has ONE addressable top-level run, whose stable runId +// is the deployment's mail address (`ins_<deploymentId>@<domain>`). The // supervisor's dispatch loop keys its per-run state, its grants barrier, // and its terminal wait on this id. Every producer of a run's grants -- // the hub-api trigger route and the sidecar's mail-deliver path -- must @@ -13,11 +13,12 @@ // their derivations cannot diverge. It exists to end the divergence that // let the mail's Message-ID (a per-message identifier) masquerade as the // runId: the runId is a property of the deployment, not of the individual -// message that triggers a run. +// trigger occurrence. Internal section/body runs still receive their own +// synthetic run ids and are not externally addressable. /** - * The stable runId for every run of a workflow deployment: its mail - * address. Callers hold the deployment mail address in different forms -- + * The stable runId for a workflow deployment's one addressable top-level run: + * its mail address. Callers hold the deployment mail address in different forms -- * a routing recipient, a supervisor binding, a route-derived address -- * and route it through this one function so the runId contract is stated * in exactly one place.