From d407a0029633334d89d5291f2de30a0898e89f76 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 13:40:57 -0700 Subject: [PATCH 01/59] Remove single-agent session mode; add closed director registry Orchestrator is the only product path (CL-5814): drop the first-run mode picker, Settings session-mode rows, and dual-mode prompt/tool branching. Legacy sessionMode on disk still loads without error and is ignored. Also land the Level-1 closed director registry (16 ids + resolve + intent defaults) so leaf packages can fill in place (CL-5818). --- docs/ARCHITECTURE.md | 2 +- docs/IMPLEMENTATION.md | 5 +- docs/PRODUCT.md | 4 +- src/agent/directors/index.ts | 21 +++ src/agent/directors/registry.test.ts | 79 +++++++++ src/agent/directors/registry.ts | 233 +++++++++++++++++++++++++++ src/agent/directors/types.ts | 84 ++++++++++ src/agent/prompts.test.ts | 5 - src/agent/prompts.ts | 38 +---- src/agent/tool-search.test.ts | 26 ++- src/agent/tools.ts | 2 +- src/config/session-mode.test.ts | 38 +++-- src/config/session-mode.ts | 32 ++-- src/config/settings.ts | 42 +++-- src/prompts.test.ts | 35 ++-- src/settings.test.ts | 16 +- src/tui/command-surfaces.test.ts | 22 +-- src/tui/command-surfaces.ts | 46 ------ src/tui/runner-host.test.ts | 3 - src/tui/runner.ts | 49 +----- src/tui/session-mode-prompt.ts | 62 ------- tests/unit/config.test.ts | 2 +- tests/unit/tui/agent-tools.test.ts | 15 +- 23 files changed, 549 insertions(+), 312 deletions(-) create mode 100644 src/agent/directors/index.ts create mode 100644 src/agent/directors/registry.test.ts create mode 100644 src/agent/directors/registry.ts create mode 100644 src/agent/directors/types.ts delete mode 100644 src/tui/session-mode-prompt.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6521c665e..93bc68dc0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -327,7 +327,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. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 3e7e12c71..8060c4ca4 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -119,7 +119,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 @@ -209,9 +208,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 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. 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). diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 9f59ff64b..bafbca40e 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -38,7 +38,7 @@ The evidence is in how the product fails today: the personas already produce exc 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). +9. **Orchestrator-only (TUI + exec)** — The primary session is always the orchestrator: it can act directly and delegates via `task` / `search_agents`. 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 @@ -135,7 +135,7 @@ Capabilities beyond the core toolset are opt-in plugins, enabled per workspace t ## 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. +In the TUI, the primary session is always **orchestrator**: it can act directly and delegates work via `task` / `search_agents`. Single-agent session mode is gone (CL-5814). 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. diff --git a/src/agent/directors/index.ts b/src/agent/directors/index.ts new file mode 100644 index 000000000..cac696ad1 --- /dev/null +++ b/src/agent/directors/index.ts @@ -0,0 +1,21 @@ +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, + isDirectorId, + listDirectors, + resolveDirector, +} from "./registry.js"; diff --git a/src/agent/directors/registry.test.ts b/src/agent/directors/registry.test.ts new file mode 100644 index 000000000..23f38b9ec --- /dev/null +++ b/src/agent/directors/registry.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test"; + +import { DIRECTOR_IDS } from "./types.js"; +import { + DIRECTOR_REGISTRY, + INTENT_DEFAULT_DIRECTOR, + isDirectorId, + listDirectors, + 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("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(), + ); + }); +}); diff --git a/src/agent/directors/registry.ts b/src/agent/directors/registry.ts new file mode 100644 index 000000000..f63c06372 --- /dev/null +++ b/src/agent/directors/registry.ts @@ -0,0 +1,233 @@ +import { + DIRECTOR_IDS, + type DirectorId, + type DirectorPackage, + type ResolveDirectorInput, + type ResolveDirectorResult, + type TaskIntent, +} from "./types.js"; + +/** + * Closed v1 registry. Packages are filled in by later director tickets; + * Level 1 only owns the closed id set + resolve rules (CL-5818). + */ +const PLACEHOLDER_REPORT = { + requiredSections: ["Summary", "Findings", "Blockers", "Paths"], +} as const; + +function placeholder(pkg: Omit & { report?: DirectorPackage["report"] }): DirectorPackage { + return { + ...pkg, + report: pkg.report ?? PLACEHOLDER_REPORT, + }; +} + +/** Intent → default director when `task(agent=…)` is omitted. No general director. */ +export const INTENT_DEFAULT_DIRECTOR: Readonly, DirectorId>> = { + implement: "implement", + explore: "explore", + plan: "plan", + review: "critique", +}; + +/** + * Placeholder packages so the closed set typechecks and resolve works before + * leaf tickets land full prompts. Leaf PRs replace entries in place. + */ +export const DIRECTOR_REGISTRY: Readonly> = { + skywalker: placeholder({ + id: "skywalker", + primaryIntent: "Orchestrate only — triage and dispatch; do not implement product code", + outOfLane: ["product edits", "deep repo walks when dispatch is available"], + description: "Primary orchestration director (Karen-shaped)", + systemPrompt: "Placeholder — CL-5817 fills Skywalker from karen.md.", + spawn: { maySpawn: true }, + modelRole: "orchestrator", + }), + implement: placeholder({ + id: "implement", + primaryIntent: "Ship product code with tests", + outOfLane: ["architecture gates", "docs-only work"], + description: "Implementation leaf", + systemPrompt: "Placeholder — CL-5825 fills Implement.", + spawn: { maySpawn: false }, + modelRole: "implement", + }), + explore: placeholder({ + id: "explore", + primaryIntent: "Map and read the codebase; no product edits", + outOfLane: ["product write paths", "drive-by fixes"], + description: "Read-only exploration leaf", + systemPrompt: "Placeholder — CL-5823 fills Explore.", + tools: { deny: ["write_file", "edit_file", "delete_file"] }, + spawn: { maySpawn: false }, + modelRole: "explore", + }), + plan: placeholder({ + id: "plan", + primaryIntent: "Author eng change plans; do not implement", + outOfLane: ["shipping code", "architecture gate sign-off"], + description: "Planning leaf", + systemPrompt: "Placeholder — CL-5838 fills Plan.", + spawn: { maySpawn: false }, + modelRole: "plan", + }), + intern: placeholder({ + id: "intern", + primaryIntent: "Mechanical shell/commands only", + outOfLane: ["design judgment", "product edits without explicit brief"], + description: "Mechanical intern leaf", + systemPrompt: "Placeholder — CL-5822 fills Intern.", + spawn: { maySpawn: false }, + modelRole: "implement", + }), + critique: placeholder({ + id: "critique", + primaryIntent: "Evidence-based code review; never fix product code", + outOfLane: ["applying fixes", "architecture ownership"], + description: "Code quality review leaf", + systemPrompt: "Placeholder — CL-5819 fills Critique.", + tools: { deny: ["write_file", "edit_file", "delete_file"] }, + spawn: { maySpawn: false }, + modelRole: "review", + }), + greybeard: placeholder({ + id: "greybeard", + primaryIntent: "Architecture review; limited spawn", + outOfLane: ["shipping product code", "pedantic style-only nitpicking"], + description: "Architecture review leaf", + systemPrompt: "Placeholder — CL-5821 fills Greybeard.", + spawn: { maySpawn: true, allowlist: ["intern", "explore", "critique"] }, + modelRole: "review", + }), + neckbeard: placeholder({ + id: "neckbeard", + primaryIntent: "Adversarial pedantic review; never fix", + outOfLane: ["applying fixes", "product implementation"], + description: "Adversarial review leaf", + systemPrompt: "Placeholder — CL-5820 fills Neckbeard.", + tools: { deny: ["write_file", "edit_file", "delete_file"] }, + spawn: { maySpawn: false }, + modelRole: "review", + }), + bruckheimer: placeholder({ + id: "bruckheimer", + primaryIntent: "Product discovery docs", + outOfLane: ["shipping product code", "architecture gates"], + description: "Product discovery leaf", + systemPrompt: "Placeholder — CL-5824 fills Bruckheimer.", + spawn: { maySpawn: false }, + modelRole: "docs", + }), + gaasbot: placeholder({ + id: "gaasbot", + primaryIntent: "CTO advice; not a gate", + outOfLane: ["blocking merges", "shipping product code as implementer"], + description: "CTO advice leaf", + systemPrompt: "Placeholder — CL-5826 fills Gaasbot.", + spawn: { maySpawn: false }, + modelRole: "plan", + }), + draper: placeholder({ + id: "draper", + primaryIntent: "Product visual/CBS critique from a development perspective", + outOfLane: ["shipping product code", "marketing copy pipeline"], + description: "Visual/CBS critique leaf (dev-scoped)", + systemPrompt: "Placeholder — CL-5830 fills Draper.", + tools: { deny: ["write_file", "edit_file", "delete_file"] }, + spawn: { maySpawn: false }, + modelRole: "review", + }), + emil: placeholder({ + id: "emil", + primaryIntent: "Design-engineering + laws from a development perspective", + outOfLane: ["shipping product code without design brief", "marketing content"], + description: "Design-engineering leaf (dev-scoped)", + systemPrompt: "Placeholder — CL-5827 fills Emil.", + tools: { deny: ["write_file", "edit_file", "delete_file"] }, + spawn: { maySpawn: false }, + modelRole: "review", + }), + "brand-reviewer": placeholder({ + id: "brand-reviewer", + primaryIntent: "Own DESIGN.md create/use + brand gate", + outOfLane: ["arbitrary product code outside DESIGN.md"], + description: "DESIGN.md brand gate leaf", + systemPrompt: "Placeholder — CL-5829 fills Brand Reviewer.", + spawn: { maySpawn: false }, + modelRole: "docs", + }), + shakespeare: placeholder({ + id: "shakespeare", + primaryIntent: "Maintain product/architecture/implementation docs; scribe baked in", + outOfLane: ["shipping product code", "architecture gates"], + description: "Docs scribe leaf", + systemPrompt: "Placeholder — CL-5845 fills Shakespeare (scribe core).", + spawn: { maySpawn: false }, + modelRole: "docs", + }), + testsmith: placeholder({ + id: "testsmith", + primaryIntent: "Test design only; do not run or fix product", + outOfLane: ["runtime verification", "product implementation"], + description: "Test design leaf", + systemPrompt: "Placeholder — CL-5842 fills Testsmith.", + spawn: { maySpawn: false }, + modelRole: "test", + }), + tester: placeholder({ + id: "tester", + primaryIntent: "Runtime verify; never fix product code", + outOfLane: ["applying product fixes", "test design authorship"], + description: "Runtime verification leaf", + systemPrompt: "Placeholder — CL-5844 fills Tester.", + tools: { deny: ["write_file", "edit_file", "delete_file"] }, + spawn: { maySpawn: false }, + modelRole: "test", + }), +}; + +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 has no director.", + hint: "Reclassify the work to implement, explore, plan, or review (or pass agent=…).", + }; + } + const id = INTENT_DEFAULT_DIRECTOR[intent]; + return { ok: true, package: DIRECTOR_REGISTRY[id] }; +} diff --git a/src/agent/directors/types.ts b/src/agent/directors/types.ts new file mode 100644 index 000000000..4408d3e6a --- /dev/null +++ b/src/agent/directors/types.ts @@ -0,0 +1,84 @@ +// 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 always allowed when present in the session registry. */ + readonly allow?: readonly string[]; + /** Tools denied even if present in the session registry. */ + 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 leaf 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 leaf may load dynamically (ordered). */ + readonly optionalSkills?: readonly string[]; + readonly tools?: ToolEnvelope; + 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/prompts.test.ts b/src/agent/prompts.test.ts index 0a4a0db42..9a787dcb9 100644 --- a/src/agent/prompts.test.ts +++ b/src/agent/prompts.test.ts @@ -81,11 +81,6 @@ 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)", () => { const prompt = buildSubAgentSystemPrompt(undefined, undefined, undefined, { orchestrator: false, diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index e3f1b2f26..c0d3e82ef 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -38,14 +38,7 @@ 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(" "); - } +export function buildChatRole(_sessionMode: SessionMode = "orchestrator"): string { return [ `You are ${PRODUCT_NAME}, an orchestrator in a terminal harness.`, "The operator chats with you and may queue more work while workers run.", @@ -66,7 +59,6 @@ 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.", @@ -83,12 +75,8 @@ export function buildHarnessFacts( ...(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; treat them as additional queue items — 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 +89,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:", "", @@ -138,8 +125,9 @@ 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.", @@ -148,8 +136,7 @@ export function buildGuidelines(opts: { subAgent?: boolean; sessionMode?: Sessio "- 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.", - ] - : []), + ]), ].join("\n"); } @@ -262,16 +249,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", diff --git a/src/agent/tool-search.test.ts b/src/agent/tool-search.test.ts index f41d3d1ec..b8d1f1032 100644 --- a/src/agent/tool-search.test.ts +++ b/src/agent/tool-search.test.ts @@ -63,15 +63,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"); }); @@ -89,8 +86,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"); }); @@ -160,15 +156,15 @@ 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", () => { diff --git a/src/agent/tools.ts b/src/agent/tools.ts index dc297f77f..50b37631c 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -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. 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..91fb70d99 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 @@ -435,7 +435,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 +477,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", // Reject any other key so local settings can never smuggle credentials. "+": "reject", @@ -500,7 +504,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 +572,15 @@ export function isLocalSettings(value: unknown): value is LocalSettings { if (!LocalSettingsSchema.allows(value)) return false; const s = value as Record; 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 +742,8 @@ export async function loadSettings(path: string): Promise { 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 +814,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 | undefined, }; } @@ -804,8 +823,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/prompts.test.ts b/src/prompts.test.ts index 20797b89c..9ea2bbf9b 100644 --- a/src/prompts.test.ts +++ b/src/prompts.test.ts @@ -33,16 +33,14 @@ 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 Corbits Code as orchestrator", () => { const orchestrator = buildChatRole("orchestrator"); expect(orchestrator).toContain("Corbits Code"); expect(orchestrator).toContain("orchestrator"); expect(orchestrator).toContain("delegate"); expect(orchestrator).toContain("Match their tone"); + // Mode arg is ignored — product is orchestrator-only (CL-5814). + expect(buildChatRole()).toContain("orchestrator"); }); test("harness facts state only the non-derivable tool and safety rules", () => { @@ -82,8 +80,6 @@ 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", () => { @@ -93,21 +89,7 @@ test("chat system prompt satisfies system prompt quality markers", () => { } }); -test("single session mode satisfies system prompt quality markers", () => { - const prompt = buildChatSystemPrompt(undefined, undefined, undefined, [], "single"); - 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 +129,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", () => { diff --git a/src/settings.test.ts b/src/settings.test.ts index a7f299bea..1db2e9129 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 { @@ -733,18 +733,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"); diff --git a/src/tui/command-surfaces.test.ts b/src/tui/command-surfaces.test.ts index 57fd6e72b..e2e3a7248 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, @@ -94,7 +92,6 @@ function settingsDeps(overrides?: Partial): { readonly snapshot: () => SettingsSnapshot readonly calls: { compaction: string[] - sessionMode: Array<{ mode: string; scope: string }> waitForApproval: boolean[] telemetry: boolean[] showPromptCost: boolean[] @@ -103,7 +100,6 @@ function settingsDeps(overrides?: Partial): { 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 +112,6 @@ function settingsDeps(overrides?: Partial): { 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 +179,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 +200,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() diff --git a/src/tui/command-surfaces.ts b/src/tui/command-surfaces.ts index 77412d713..b4fb48ccf 100644 --- a/src/tui/command-surfaces.ts +++ b/src/tui/command-surfaces.ts @@ -10,7 +10,6 @@ import type { KeyEvent } from "@opentui/core" -import type { SessionMode } from "../config/session-mode.js" import { maskEcho, maskSecret } from "./provider-setup.js" import { residualIdFromSelection, type ResidualCatalogEntry } from "./residuals.js" import { @@ -65,15 +64,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 @@ -150,8 +143,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 @@ -268,14 +259,6 @@ const COMPACTION_OPTIONS: readonly CycleOption[] = [ { id: "llm", label: "summarize" }, { id: "pruning", label: "drop" }, ] -const SESSION_MODE_OPTIONS: readonly CycleOption[] = [ - { id: "single", label: "single" }, - { id: "orchestrator", label: "orchestrator" }, -] -const SESSION_SCOPE_OPTIONS: readonly CycleOption[] = [ - { 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 +296,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")}`, diff --git a/src/tui/runner-host.test.ts b/src/tui/runner-host.test.ts index 1d09aea09..18d941006 100644 --- a/src/tui/runner-host.test.ts +++ b/src/tui/runner-host.test.ts @@ -205,14 +205,11 @@ describe("mountRunnerHost command surfaces", () => { settings: { read: () => ({ compactionMode: "llm", - sessionMode: "orchestrator", - sessionModeScope: "global", waitForApproval: true, telemetryEnabled: false, showPromptCost: false, }), setCompactionMode: () => {}, - setSessionMode: () => {}, setWaitForApproval: () => {}, setTelemetryEnabled: () => {}, setShowPromptCost: () => {}, diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 666525c7d..820882569 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -45,7 +45,6 @@ import { import { addProviderSelectorChoices, providerChoices } from "./provider-setup.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"; @@ -131,8 +130,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, @@ -1142,27 +1140,10 @@ export async function runTUI(initialConfig: Config): Promise { 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 +1162,7 @@ export async function runTUI(initialConfig: Config): Promise { 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, @@ -2312,8 +2293,6 @@ export async function runTUI(initialConfig: Config): Promise { settings: { read: () => ({ compactionMode: liveCompactionMode, - sessionMode: liveSessionMode ?? "orchestrator", - sessionModeScope: liveSessionModeScope, waitForApproval: resolveWaitForApproval(liveToolWatchdog), telemetryEnabled: liveTelemetryIntent, showPromptCost: liveShowPromptCost, @@ -2325,22 +2304,6 @@ export async function runTUI(initialConfig: Config): Promise { 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) => ({ 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 { - 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/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/tui/agent-tools.test.ts b/tests/unit/tui/agent-tools.test.ts index ba4103019..c7439063e 100644 --- a/tests/unit/tui/agent-tools.test.ts +++ b/tests/unit/tui/agent-tools.test.ts @@ -288,20 +288,7 @@ const subAgentDeps = { profiles: () => [], }; -test("single session mode omits task and search_agents from registered tools", async () => { - const toolset = await createAgentToolset({ - cwd: "/fake", - permissionGate: fakePermissionGate, - onOperatorGate: async () => ({ kind: "option", index: 0 }), - sessionMode: "single", - subAgent: subAgentDeps, - }); - const names = toolset.dynamicRunner.currentDefinitions().map((d) => d.name); - expect(names).not.toContain("task"); - expect(names).not.toContain("search_agents"); -}); - -test("orchestrator session mode registers task and search_agents", async () => { +test("default session registers task and search_agents", async () => { const toolset = await createAgentToolset({ cwd: "/fake", permissionGate: fakePermissionGate, From 64b7e891e4fff70f61fb7f92db35358b2c7d0a4f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 13:45:00 -0700 Subject: [PATCH 02/59] Note single-agent session mode removal in Unreleased Document the CL-5814 product break: orchestrator-only primary, legacy sessionMode ignored, first-run picker and Settings rows gone. --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a466952e9..d2aa5d866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,13 @@ 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). + ### Providers - **Named API-key instances.** First-class API-key providers (OpenAI key, From 08fdc2157401f434289c16c826a90cb0fe95b776 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 13:30:40 -0700 Subject: [PATCH 03/59] Quiet transcript for task/manage_tasks and live fleet dispatches Panel-owned tools no longer paint stream rows; fleet report only announces terminal/stall transitions so the board owns live state. --- src/subagent/fleet-report.test.ts | 4 +- src/subagent/fleet-report.ts | 26 ++++---- src/tui/runtime-bridge.test.ts | 105 ++++++++++++++++++------------ src/tui/runtime-bridge.ts | 22 +++++-- 4 files changed, 95 insertions(+), 62 deletions(-) diff --git a/src/subagent/fleet-report.test.ts b/src/subagent/fleet-report.test.ts index 2be646abc..eb674d12b 100644 --- a/src/subagent/fleet-report.test.ts +++ b/src/subagent/fleet-report.test.ts @@ -77,14 +77,14 @@ describe("observeFleet", () => { 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", () => { diff --git a/src/subagent/fleet-report.ts b/src/subagent/fleet-report.ts index e95210882..0e8fa99f7 100644 --- a/src/subagent/fleet-report.ts +++ b/src/subagent/fleet-report.ts @@ -5,8 +5,8 @@ * 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. + * produced, a lane stalled or failed, and the moment the fleet runs dry. + * Live dispatches stay on the fleet board — they are not re-announced here. * * Pure and stateless per call — the caller keeps the returned watch and hands * it back on the next observation. No painting, no store access. @@ -175,16 +175,18 @@ export function observeFleet( } 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, - ); + // Live dispatches already appear on the fleet board (CL-5846). Transcript + // notices only for terminal / stall transitions — not "dispatched X". + const announced = changes.filter((c) => c.kind !== "dispatched"); + if (announced.length === 0 && !(running === 0 && previous.running > 0)) { + return { watch, updates: [] }; + } + + const lines: string[] = + announced.length > COALESCE_ABOVE + ? [tally(announced)] + : announced.map((c) => c.line); // 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 @@ -210,14 +212,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`); return parts.join(", "); } diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index effec6e99..a1c492182 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -655,13 +655,10 @@ describe("committed inference retry", () => { }) describe("parallel sub-agent dispatch on the live session bridge", () => { - // The live main-session path tracks a call's row by callId in its own map - // (applyToolCall/applyToolResult), independent of tool-rows.ts's name-based - // pendingCallIndex — this pins that down so a future change to either path - // cannot silently reintroduce CL-5562's misattribution on the parent - // transcript specifically (the observe overlay and resumed history are - // covered separately in tool-rows.test.ts / history-hydrate.test.ts). - test("three parallel task calls resolve to three rows, each with its own result", async () => { + // Task dispatches no longer paint transcript rows (fleet board owns live + // state — CL-5846). This pins that three parallel task calls leave the + // stream clean of Task tool rows, while a non-panel tool still paints. + test("three parallel task calls paint no transcript tool rows", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -671,6 +668,7 @@ describe("parallel sub-agent dispatch on the live session bridge", () => { }) const bridge = attachSessionBridge(shell, createRecordingPort()) try { + const before = streamRowCount(shell) const events = [ { type: "inference.start", data: {} }, { @@ -698,10 +696,8 @@ describe("parallel sub-agent dispatch on the live session bridge", () => { for (const event of events) bridge.handle(event) const toolRows = shell.streamLog.filter((r) => r.role === "tool") - expect(toolRows.length).toBe(3) - expect(toolRows.every((r) => r.pending !== true)).toBe(true) - expect(toolRows.every((r) => r.failed !== true)).toBe(true) - expect(toolRows.map((r) => r.text)).toEqual(["done c1", "done c2", "done c3"]) + expect(toolRows.length).toBe(0) + expect(streamRowCount(shell)).toBe(before) } finally { bridge.dispose() shell.dispose() @@ -726,7 +722,7 @@ describe("syncAgentProgress", () => { } } - test("updates the dispatch row in place without appending or removing rows", async () => { + test("task dispatches paint no transcript rows for progress to rewrite (fleet board owns live state)", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -734,8 +730,6 @@ describe("syncAgentProgress", () => { wireKeys: false, run: "busy", }) - // Padding rows ahead of the dispatch: proves churn stays bounded by - // outstanding task calls, not by transcript length. for (let i = 0; i < 40; i++) { appendStreamRow(shell, { role: "assistant", text: `filler ${i}` }) } @@ -744,6 +738,7 @@ describe("syncAgentProgress", () => { now: () => nowMs, }) try { + const before = streamRowCount(shell) bridge.handle({ type: "inference.tool_call.end", data: { @@ -753,32 +748,12 @@ describe("syncAgentProgress", () => { }, }) await h.renderOnce() - const rowCountBefore = streamRowCount(shell) - const removeSpy = spyOn(shell.transcript, "remove") + expect(streamRowCount(shell)).toBe(before) nowMs = 42_000 bridge.syncAgentProgress([taskSession({ lastActivityAt: nowMs })]) - bridge.syncAgentProgress([ - taskSession({ currentToolName: "grep", lastActivityAt: nowMs }), - ]) - - expect(streamRowCount(shell)).toBe(rowCountBefore) - // One rewrite per changed tick, never proportional to the 40 padding rows. - expect(removeSpy.mock.calls.length).toBeLessThanOrEqual(2) - - const row = shell.streamLog[rowCountBefore - 1]! - expect(row.pending).toBe(true) - expect(row.agentWorking).toBe(true) - expect(row.stat).toContain("grep") - - nowMs = 72_000 - bridge.syncAgentProgress([ - taskSession({ currentToolName: "grep", lastActivityAt: 42_000 }), - ]) - const stalledRow = shell.streamLog[rowCountBefore - 1]! - expect(stalledRow.agentWorking).toBe(false) - - removeSpy.mockRestore() + // No transcript Task row exists; progress is a no-op for stream log. + expect(streamRowCount(shell)).toBe(before) } finally { bridge.dispose() shell.dispose() @@ -788,7 +763,7 @@ describe("syncAgentProgress", () => { ) }) - test("a finished session's row is left to the tool-result path", async () => { + test("a finished task result is dropped with the call (no unpaired terminal Task row)", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -798,6 +773,7 @@ describe("syncAgentProgress", () => { }) const bridge = attachSessionBridge(shell, createRecordingPort()) try { + const before = streamRowCount(shell) bridge.handle({ type: "inference.tool_call.end", data: { @@ -810,10 +786,9 @@ describe("syncAgentProgress", () => { type: "tool.done", data: { result: { callId: "task-1", name: "task", content: "done", isError: false } }, }) - const index = shell.streamLog.length - 1 + expect(streamRowCount(shell)).toBe(before) bridge.syncAgentProgress([taskSession({ status: "done" })]) - expect(shell.streamLog[index]!.pending).not.toBe(true) - expect(shell.streamLog[index]!.agentWorking).toBeUndefined() + expect(streamRowCount(shell)).toBe(before) } finally { bridge.dispose() shell.dispose() @@ -865,6 +840,54 @@ describe("task checklist calls stay out of the transcript", () => { ) }) + test("a task dispatch call and its result paint no rows (fleet board owns live state)", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }) + const bridge = attachSessionBridge(shell, createRecordingPort()) + try { + appendStreamRow(shell, { role: "assistant", text: "spinning workers" }) + const before = streamRowCount(shell) + + bridge.handle({ + type: "inference.tool_call.end", + data: { + name: "task", + callId: "task-1", + arguments: { + description: "explore auth", + prompt: "map auth callers", + intent: "explore", + }, + }, + }) + bridge.handle({ + type: "tool.done", + data: { + result: { + callId: "task-1", + name: "task", + content: "Summary: auth is in src/auth", + isError: false, + }, + }, + }) + + // Live Task rows restate what the fleet board already shows. + expect(streamRowCount(shell)).toBe(before) + } finally { + bridge.dispose() + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + test("an errored manage_tasks result is dropped rather than left unpaired", async () => { await withTestRenderer( async (h) => { diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 3507ff5f5..dd601f5a8 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -77,9 +77,10 @@ import { type AgentProgressSession, } from "./agent-progress.js" -/** Tool name a sub-agent dispatch call carries — its row gets live progress. */ +/** Tool name a sub-agent dispatch call carries (fleet board owns live state). */ const TASK_TOOL_NAME = "task" + /** * Tool name the task checklist is written through. Its calls paint no * transcript row: the list they write is live state owned by the task panel, @@ -88,6 +89,17 @@ const TASK_TOOL_NAME = "task" */ const MANAGE_TASKS_TOOL_NAME = "manage_tasks" +/** + * Tools whose live work is already owned by standing chrome (fleet board / + * task panel). Call + result paint no transcript rows — the board is the + * live surface; re-announcing the same dispatch as a `● Task` line is noise + * (CL-5846 first cut). + */ +const PANEL_OWNED_TOOL_NAMES: ReadonlySet = new Set([ + MANAGE_TASKS_TOOL_NAME, + TASK_TOOL_NAME, +]) + /** A sub-agent session as `syncAgentProgress` needs it: identified, and live-readable. */ export type TaskProgressSession = AgentProgressSession & { readonly id: string } import { @@ -543,9 +555,10 @@ function applyToolCall( bag: BridgeBag, event: Extract, ): void { - if (event.name === MANAGE_TASKS_TOOL_NAME) { + if (PANEL_OWNED_TOOL_NAMES.has(event.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. Live Task state + // lives on the fleet board; manage_tasks lives on the task panel. if (event.callId !== undefined) bag.panelOnlyCallIds.add(event.callId) return } @@ -562,9 +575,6 @@ function applyToolCall( appendStreamRow(shell, row) } if (event.callId !== undefined) bag.toolRows.set(event.callId, index) - if (event.callId !== undefined && event.name === TASK_TOOL_NAME) { - bag.taskCallIds.add(event.callId) - } bag.lastToolRow = index } From 7b892835bb99d07c77de40af33f17f672141e015 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 14:44:12 -0700 Subject: [PATCH 04/59] Fail closed director fleet and task intent contracts Require real director prompts (not placeholders) and assert task resolves directors and intents before the registry and dispatch path ship full packages. --- src/agent/directors/registry.test.ts | 12 +++++ tests/unit/subagent.test.ts | 72 +++++++++++++++++++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/agent/directors/registry.test.ts b/src/agent/directors/registry.test.ts index 23f38b9ec..6180227cb 100644 --- a/src/agent/directors/registry.test.ts +++ b/src/agent/directors/registry.test.ts @@ -18,6 +18,18 @@ describe("director registry", () => { } }); + 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); diff --git a/tests/unit/subagent.test.ts b/tests/unit/subagent.test.ts index 9e5d01428..edf328e8c 100644 --- a/tests/unit/subagent.test.ts +++ b/tests/unit/subagent.test.ts @@ -186,16 +186,86 @@ 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).toEqual({ + mode: "exclude", + tools: ["write_file", "edit_file", "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("orchestrator profile installs nestedDispatch so task can be re-dispatched", async () => { let received: RunSubAgentParams | undefined; const tool = createTaskTool({ permissionGate: testPermissionGate, From 9c269beececfc5fc1c2f3715fdad06b97413bed8 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 14:44:41 -0700 Subject: [PATCH 05/59] Wire live director packages into registry and task dispatch Replace placeholder registry entries with the closed sixteen packages, project them as default profiles, and resolve named or intent-only task dispatches from the fleet. --- CHANGELOG.md | 9 + src/agent/default-agents.ts | 28 +- src/agent/directors/brand-reviewer/index.ts | 1 + .../directors/brand-reviewer/package.test.ts | 53 ++++ src/agent/directors/brand-reviewer/package.ts | 75 ++++++ src/agent/directors/bruckheimer/index.ts | 1 + .../directors/bruckheimer/package.test.ts | 48 ++++ src/agent/directors/bruckheimer/package.ts | 35 +++ src/agent/directors/critique/index.ts | 1 + src/agent/directors/critique/package.test.ts | 66 +++++ src/agent/directors/critique/package.ts | 48 ++++ src/agent/directors/draper/index.ts | 1 + src/agent/directors/draper/package.test.ts | 52 ++++ src/agent/directors/draper/package.ts | 65 +++++ src/agent/directors/emil/index.ts | 1 + src/agent/directors/emil/package.test.ts | 52 ++++ src/agent/directors/emil/package.ts | 79 ++++++ src/agent/directors/explore/index.ts | 1 + src/agent/directors/explore/package.test.ts | 40 +++ src/agent/directors/explore/package.ts | 29 +++ src/agent/directors/gaasbot/index.ts | 1 + src/agent/directors/gaasbot/package.test.ts | 52 ++++ src/agent/directors/gaasbot/package.ts | 37 +++ src/agent/directors/greybeard/index.ts | 1 + src/agent/directors/greybeard/package.test.ts | 65 +++++ src/agent/directors/greybeard/package.ts | 42 +++ src/agent/directors/implement/index.ts | 1 + src/agent/directors/implement/package.test.ts | 44 ++++ src/agent/directors/implement/package.ts | 33 +++ src/agent/directors/index.ts | 3 + src/agent/directors/intern/index.ts | 1 + src/agent/directors/intern/package.test.ts | 49 ++++ src/agent/directors/intern/package.ts | 39 +++ src/agent/directors/neckbeard/index.ts | 1 + src/agent/directors/neckbeard/package.test.ts | 58 +++++ src/agent/directors/neckbeard/package.ts | 34 +++ src/agent/directors/plan/index.ts | 1 + src/agent/directors/plan/package.test.ts | 53 ++++ src/agent/directors/plan/package.ts | 27 ++ src/agent/directors/registry.test.ts | 23 ++ src/agent/directors/registry.ts | 239 +++++------------- src/agent/directors/shakespeare/index.ts | 1 + .../directors/shakespeare/package.test.ts | 66 +++++ src/agent/directors/shakespeare/package.ts | 84 ++++++ src/agent/directors/skywalker/index.ts | 1 + src/agent/directors/skywalker/package.test.ts | 82 ++++++ src/agent/directors/skywalker/package.ts | 118 +++++++++ src/agent/directors/tester/index.ts | 1 + src/agent/directors/tester/package.test.ts | 51 ++++ src/agent/directors/tester/package.ts | 39 +++ src/agent/directors/testsmith/index.ts | 1 + src/agent/directors/testsmith/package.test.ts | 57 +++++ src/agent/directors/testsmith/package.ts | 41 +++ src/subagent/task-tool.ts | 151 +++++++---- 54 files changed, 1943 insertions(+), 239 deletions(-) create mode 100644 src/agent/directors/brand-reviewer/index.ts create mode 100644 src/agent/directors/brand-reviewer/package.test.ts create mode 100644 src/agent/directors/brand-reviewer/package.ts create mode 100644 src/agent/directors/bruckheimer/index.ts create mode 100644 src/agent/directors/bruckheimer/package.test.ts create mode 100644 src/agent/directors/bruckheimer/package.ts create mode 100644 src/agent/directors/critique/index.ts create mode 100644 src/agent/directors/critique/package.test.ts create mode 100644 src/agent/directors/critique/package.ts create mode 100644 src/agent/directors/draper/index.ts create mode 100644 src/agent/directors/draper/package.test.ts create mode 100644 src/agent/directors/draper/package.ts create mode 100644 src/agent/directors/emil/index.ts create mode 100644 src/agent/directors/emil/package.test.ts create mode 100644 src/agent/directors/emil/package.ts create mode 100644 src/agent/directors/explore/index.ts create mode 100644 src/agent/directors/explore/package.test.ts create mode 100644 src/agent/directors/explore/package.ts create mode 100644 src/agent/directors/gaasbot/index.ts create mode 100644 src/agent/directors/gaasbot/package.test.ts create mode 100644 src/agent/directors/gaasbot/package.ts create mode 100644 src/agent/directors/greybeard/index.ts create mode 100644 src/agent/directors/greybeard/package.test.ts create mode 100644 src/agent/directors/greybeard/package.ts create mode 100644 src/agent/directors/implement/index.ts create mode 100644 src/agent/directors/implement/package.test.ts create mode 100644 src/agent/directors/implement/package.ts create mode 100644 src/agent/directors/intern/index.ts create mode 100644 src/agent/directors/intern/package.test.ts create mode 100644 src/agent/directors/intern/package.ts create mode 100644 src/agent/directors/neckbeard/index.ts create mode 100644 src/agent/directors/neckbeard/package.test.ts create mode 100644 src/agent/directors/neckbeard/package.ts create mode 100644 src/agent/directors/plan/index.ts create mode 100644 src/agent/directors/plan/package.test.ts create mode 100644 src/agent/directors/plan/package.ts create mode 100644 src/agent/directors/shakespeare/index.ts create mode 100644 src/agent/directors/shakespeare/package.test.ts create mode 100644 src/agent/directors/shakespeare/package.ts create mode 100644 src/agent/directors/skywalker/index.ts create mode 100644 src/agent/directors/skywalker/package.test.ts create mode 100644 src/agent/directors/skywalker/package.ts create mode 100644 src/agent/directors/tester/index.ts create mode 100644 src/agent/directors/tester/package.test.ts create mode 100644 src/agent/directors/tester/package.ts create mode 100644 src/agent/directors/testsmith/index.ts create mode 100644 src/agent/directors/testsmith/package.test.ts create mode 100644 src/agent/directors/testsmith/package.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d2aa5d866..1bd083592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,15 @@ mid-session switches. mode picker and Settings → Session rows are removed. Legacy `sessionMode` in settings files still loads without error and is ignored (CL-5814). +### Added + +- **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()`. + ### Providers - **Named API-key instances.** First-class API-key providers (OpenAI key, diff --git a/src/agent/default-agents.ts b/src/agent/default-agents.ts index af496019b..8200efd3c 100644 --- a/src/agent/default-agents.ts +++ b/src/agent/default-agents.ts @@ -1,28 +1,8 @@ +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/. +// Default agent profiles = closed director fleet (CL-5818). 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/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..5623b0b45 --- /dev/null +++ b/src/agent/directors/brand-reviewer/package.test.ts @@ -0,0 +1,53 @@ +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("does not deny write_file/edit_file (DESIGN.md lane)", () => { + const deny = brandReviewerPackage.tools?.deny ?? []; + expect(deny).not.toContain("write_file"); + expect(deny).not.toContain("edit_file"); + }); + + test("systemPrompt restricts writes to DESIGN.md", () => { + expect(brandReviewerPackage.systemPrompt).toMatch(/DESIGN\.md/); + expect(brandReviewerPackage.systemPrompt).toMatch(/only/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..80fd9cc41 --- /dev/null +++ b/src/agent/directors/brand-reviewer/package.ts @@ -0,0 +1,75 @@ +import type { DirectorPackage } from "../types.js"; + +/** + * Brand Reviewer — owns DESIGN.md create/use + brand consistency gate for UI. CL-5829. + * Write tools allowed; prompt hard-restricts file writes to DESIGN.md only. + */ +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 leaf", + // Allow write/edit so DESIGN.md can be created/updated; prompt forbids other paths. + // No tools.deny on write_file/edit_file — product restriction is prompt policy. + spawn: { maySpawn: false }, + nudge: { maxTurns: 40 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + modelRole: "docs", + systemPrompt: `You are BrandReviewerDirector, a leaf director 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 policy (hard) + +You MAY use write_file / edit_file **only** on DESIGN.md (repo root or the path the brief names as the project DESIGN.md). +- Never write, edit, or delete product source, stylesheets, components, tests, or other docs. +- If a fix requires product code changes, report Findings + Blockers and name implement (or draper/emil for critique) — do not patch code yourself. +- delete_file is out of lane unless the brief explicitly asks to remove a DESIGN.md draft and only that path. + +# 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 inside the DESIGN.md write lane.`, +}; \ No newline at end of file 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..adc9f3452 --- /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("does not deny product write tools (discovery docs allowed)", () => { + const deny = bruckheimerPackage.tools?.deny ?? []; + expect(deny).not.toContain("write_file"); + expect(deny).not.toContain("edit_file"); + expect(deny).not.toContain("delete_file"); + }); + + 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..335645c48 --- /dev/null +++ b/src/agent/directors/bruckheimer/package.ts @@ -0,0 +1,35 @@ +import type { DirectorPackage } from "../types.js"; + +/** + * Product discovery leaf (CL-5824). + * Invent/capture product shape in docs — not implement features, not architecture gate. + * Write access kept open so discovery can land PRODUCT/ARCHITECTURE notes; prompt forbids shipping product code. + */ +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 leaf — user/product shape docs, not code", + // No tools.deny: discovery may write PRODUCT.md / discovery notes. Prompt forbids product code. + spawn: { maySpawn: false }, + nudge: { maxTurns: 40 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + modelRole: "docs", + systemPrompt: `You are BruckheimerDirector, a leaf director 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. Prefer PRODUCT.md and related discovery docs over code. + +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..81b902f1f --- /dev/null +++ b/src/agent/directors/critique/package.test.ts @@ -0,0 +1,66 @@ +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(/tmp\/critique-tests/); + expect(critiquePackage.systemPrompt).toMatch(/permanent tests/i); + }); + + test("spawn.maySpawn is false", () => { + expect(critiquePackage.spawn.maySpawn).toBe(false); + }); + + test("tools.deny blocks product write paths", () => { + const deny = critiquePackage.tools?.deny ?? []; + expect(deny).toContain("write_file"); + expect(deny).toContain("edit_file"); + expect(deny).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..d467362b2 --- /dev/null +++ b/src/agent/directors/critique/package.ts @@ -0,0 +1,48 @@ +import type { DirectorPackage } from "../types.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: { deny: ["write_file", "edit_file", "delete_file"] }, + spawn: { maySpawn: false }, + nudge: { maxTurns: 45 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + modelRole: "review", + systemPrompt: `You are CritiqueDirector, a leaf director 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: use_skill("style"); use_skill("philosophy"). 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). + +tmp/critique-tests/: the only write surface you may mention for throwaway repro scaffolding if the parent explicitly grants it. Product paths stay read-only. tools.deny blocks write_file, edit_file, delete_file — do not attempt product edits. + +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..66c7935c0 --- /dev/null +++ b/src/agent/directors/draper/package.test.ts @@ -0,0 +1,52 @@ +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.deny blocks product write paths", () => { + const deny = draperPackage.tools?.deny ?? []; + expect(deny).toContain("write_file"); + expect(deny).toContain("edit_file"); + expect(deny).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..fd9ab3de5 --- /dev/null +++ b/src/agent/directors/draper/package.ts @@ -0,0 +1,65 @@ +import type { DirectorPackage } from "../types.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 paths hard-denied. + tools: { deny: ["write_file", "edit_file", "delete_file"] }, + spawn: { maySpawn: false }, + nudge: { maxTurns: 40 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + modelRole: "review", + systemPrompt: `You are DraperDirector, a leaf director 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..908a239cd --- /dev/null +++ b/src/agent/directors/emil/package.test.ts @@ -0,0 +1,52 @@ +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.deny blocks product write paths", () => { + const deny = emilPackage.tools?.deny ?? []; + expect(deny).toContain("write_file"); + expect(deny).toContain("edit_file"); + expect(deny).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..656125d26 --- /dev/null +++ b/src/agent/directors/emil/package.ts @@ -0,0 +1,79 @@ +import type { DirectorPackage } from "../types.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 — no product write paths. + tools: { deny: ["write_file", "edit_file", "delete_file"] }, + spawn: { maySpawn: false }, + nudge: { maxTurns: 40 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + modelRole: "review", + systemPrompt: `You are EmilDirector, a leaf director 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..c10f1a1a4 --- /dev/null +++ b/src/agent/directors/explore/package.test.ts @@ -0,0 +1,40 @@ +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("spawn.maySpawn is false", () => { + expect(explorePackage.spawn.maySpawn).toBe(false); + }); + + test("tools.deny blocks product write paths", () => { + const deny = explorePackage.tools?.deny ?? []; + expect(deny).toContain("write_file"); + expect(deny).toContain("edit_file"); + expect(deny).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..64f295a61 --- /dev/null +++ b/src/agent/directors/explore/package.ts @@ -0,0 +1,29 @@ +import type { DirectorPackage } from "../types.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 leaf director 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. + +Deliver a scannable map: key paths, symbols, call flow, ownership. 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: { deny: ["write_file", "edit_file", "delete_file"] }, + 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..f6e97b203 --- /dev/null +++ b/src/agent/directors/gaasbot/package.test.ts @@ -0,0 +1,52 @@ +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 deny = gaasbotPackage.tools?.deny ?? []; + expect(deny).toContain("write_file"); + expect(deny).toContain("edit_file"); + expect(deny).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..0590c1edb --- /dev/null +++ b/src/agent/directors/gaasbot/package.ts @@ -0,0 +1,37 @@ +import type { DirectorPackage } from "../types.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: { deny: ["write_file", "edit_file", "delete_file"] }, + spawn: { maySpawn: false }, + nudge: { maxTurns: 35 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + modelRole: "plan", + systemPrompt: `You are GaasbotDirector, a leaf director 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..b0c619783 --- /dev/null +++ b/src/agent/directors/greybeard/package.test.ts @@ -0,0 +1,65 @@ +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("denies product write tools", () => { + const deny = greybeardPackage.tools?.deny ?? []; + expect(deny).toContain("write_file"); + expect(deny).toContain("edit_file"); + expect(deny).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..ae9d9d014 --- /dev/null +++ b/src/agent/directors/greybeard/package.ts @@ -0,0 +1,42 @@ +import type { DirectorPackage } from "../types.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: { deny: ["write_file", "edit_file", "delete_file"] }, + spawn: { + maySpawn: true, + allowlist: ["intern", "explore", "critique"], + }, + nudge: { maxTurns: 50 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + modelRole: "review", + systemPrompt: `You are GreybeardDirector, a leaf director 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. + +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/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..22a816924 --- /dev/null +++ b/src/agent/directors/implement/package.test.ts @@ -0,0 +1,44 @@ +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("does not deny product write tools", () => { + const deny = implementPackage.tools?.deny ?? []; + expect(deny).not.toContain("write_file"); + expect(deny).not.toContain("edit_file"); + expect(deny).not.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"]); + }); +}); diff --git a/src/agent/directors/implement/package.ts b/src/agent/directors/implement/package.ts new file mode 100644 index 000000000..acbcdd149 --- /dev/null +++ b/src/agent/directors/implement/package.ts @@ -0,0 +1,33 @@ +import type { DirectorPackage } from "../types.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"], + // Full product write access — no tools.deny for write_file/edit_file/delete_file + spawn: { maySpawn: false }, + nudge: { maxTurns: 60 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + modelRole: "implement", + systemPrompt: `You are ImplementDirector, a leaf director 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: use_skill("style"); use_skill("philosophy"). +Follow AGENTS.md and /docs. Touch only what the brief requires. +Prefer typed success_criteria from the brief as your done gate. +Run typecheck/tests when practical. Do not spawn sub-agents. + +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 index cac696ad1..6e7d0e553 100644 --- a/src/agent/directors/index.ts +++ b/src/agent/directors/index.ts @@ -15,7 +15,10 @@ export { export { DIRECTOR_REGISTRY, INTENT_DEFAULT_DIRECTOR, + directorProfiles, isDirectorId, listDirectors, + packageToCapabilities, + packageToProfile, resolveDirector, } from "./registry.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..075b2ccd2 --- /dev/null +++ b/src/agent/directors/intern/package.test.ts @@ -0,0 +1,49 @@ +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.deny blocks product writes, search, and task", () => { + const deny = internPackage.tools?.deny ?? []; + for (const name of ["write_file", "edit_file", "delete_file", "grep", "search_files", "task"]) { + expect(deny).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..33b121ab7 --- /dev/null +++ b/src/agent/directors/intern/package.ts @@ -0,0 +1,39 @@ +import type { DirectorPackage } from "../types.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: { + // Deny product writes + exploration/search by default (CL-5822). + deny: ["write_file", "edit_file", "delete_file", "grep", "search_files", "task"], + }, + spawn: { maySpawn: false }, + nudge: { maxTurns: 20 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + // Cheap/luna still uses implement role tag per registry placeholder. + modelRole: "implement", + systemPrompt: `You are InternDirector, a leaf director 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..4ff068eeb --- /dev/null +++ b/src/agent/directors/neckbeard/package.test.ts @@ -0,0 +1,58 @@ +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 deny = neckbeardPackage.tools?.deny ?? []; + expect(deny).toContain("write_file"); + expect(deny).toContain("edit_file"); + expect(deny).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..ebfbedb1a --- /dev/null +++ b/src/agent/directors/neckbeard/package.ts @@ -0,0 +1,34 @@ +import type { DirectorPackage } from "../types.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: { deny: ["write_file", "edit_file", "delete_file"] }, + spawn: { maySpawn: false }, + nudge: { maxTurns: 40 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + modelRole: "review", + systemPrompt: `You are NeckbeardDirector, a leaf director 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..1926c5930 --- /dev/null +++ b/src/agent/directors/plan/package.test.ts @@ -0,0 +1,53 @@ +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("denies product write tools", () => { + const deny = planPackage.tools?.deny ?? []; + expect(deny).toContain("write_file"); + expect(deny).toContain("edit_file"); + expect(deny).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..9f1f7d95f --- /dev/null +++ b/src/agent/directors/plan/package.ts @@ -0,0 +1,27 @@ +import type { DirectorPackage } from "../types.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: { deny: ["write_file", "edit_file", "delete_file"] }, + spawn: { maySpawn: false }, + nudge: { maxTurns: 40 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + modelRole: "plan", + systemPrompt: `You are PlanDirector, a leaf director 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 index 6180227cb..635180ac8 100644 --- a/src/agent/directors/registry.test.ts +++ b/src/agent/directors/registry.test.ts @@ -4,8 +4,10 @@ import { DIRECTOR_IDS } from "./types.js"; import { DIRECTOR_REGISTRY, INTENT_DEFAULT_DIRECTOR, + directorProfiles, isDirectorId, listDirectors, + packageToProfile, resolveDirector, } from "./registry.js"; @@ -88,4 +90,25 @@ describe("director registry", () => { ["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).toBe(DIRECTOR_REGISTRY.explore.systemPrompt); + expect(explore.capabilities).toEqual({ + mode: "exclude", + tools: ["write_file", "edit_file", "delete_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); + }); + + test("directorProfiles covers closed set", () => { + const profiles = directorProfiles(); + expect(profiles).toHaveLength(16); + expect(new Set(profiles.map((p) => p.id)).size).toBe(16); + }); }); diff --git a/src/agent/directors/registry.ts b/src/agent/directors/registry.ts index f63c06372..e44f89bba 100644 --- a/src/agent/directors/registry.ts +++ b/src/agent/directors/registry.ts @@ -1,3 +1,20 @@ +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 { DIRECTOR_IDS, type DirectorId, @@ -7,21 +24,6 @@ import { type TaskIntent, } from "./types.js"; -/** - * Closed v1 registry. Packages are filled in by later director tickets; - * Level 1 only owns the closed id set + resolve rules (CL-5818). - */ -const PLACEHOLDER_REPORT = { - requiredSections: ["Summary", "Findings", "Blockers", "Paths"], -} as const; - -function placeholder(pkg: Omit & { report?: DirectorPackage["report"] }): DirectorPackage { - return { - ...pkg, - report: pkg.report ?? PLACEHOLDER_REPORT, - }; -} - /** Intent → default director when `task(agent=…)` is omitted. No general director. */ export const INTENT_DEFAULT_DIRECTOR: Readonly, DirectorId>> = { implement: "implement", @@ -31,160 +33,26 @@ export const INTENT_DEFAULT_DIRECTOR: Readonly> = { - skywalker: placeholder({ - id: "skywalker", - primaryIntent: "Orchestrate only — triage and dispatch; do not implement product code", - outOfLane: ["product edits", "deep repo walks when dispatch is available"], - description: "Primary orchestration director (Karen-shaped)", - systemPrompt: "Placeholder — CL-5817 fills Skywalker from karen.md.", - spawn: { maySpawn: true }, - modelRole: "orchestrator", - }), - implement: placeholder({ - id: "implement", - primaryIntent: "Ship product code with tests", - outOfLane: ["architecture gates", "docs-only work"], - description: "Implementation leaf", - systemPrompt: "Placeholder — CL-5825 fills Implement.", - spawn: { maySpawn: false }, - modelRole: "implement", - }), - explore: placeholder({ - id: "explore", - primaryIntent: "Map and read the codebase; no product edits", - outOfLane: ["product write paths", "drive-by fixes"], - description: "Read-only exploration leaf", - systemPrompt: "Placeholder — CL-5823 fills Explore.", - tools: { deny: ["write_file", "edit_file", "delete_file"] }, - spawn: { maySpawn: false }, - modelRole: "explore", - }), - plan: placeholder({ - id: "plan", - primaryIntent: "Author eng change plans; do not implement", - outOfLane: ["shipping code", "architecture gate sign-off"], - description: "Planning leaf", - systemPrompt: "Placeholder — CL-5838 fills Plan.", - spawn: { maySpawn: false }, - modelRole: "plan", - }), - intern: placeholder({ - id: "intern", - primaryIntent: "Mechanical shell/commands only", - outOfLane: ["design judgment", "product edits without explicit brief"], - description: "Mechanical intern leaf", - systemPrompt: "Placeholder — CL-5822 fills Intern.", - spawn: { maySpawn: false }, - modelRole: "implement", - }), - critique: placeholder({ - id: "critique", - primaryIntent: "Evidence-based code review; never fix product code", - outOfLane: ["applying fixes", "architecture ownership"], - description: "Code quality review leaf", - systemPrompt: "Placeholder — CL-5819 fills Critique.", - tools: { deny: ["write_file", "edit_file", "delete_file"] }, - spawn: { maySpawn: false }, - modelRole: "review", - }), - greybeard: placeholder({ - id: "greybeard", - primaryIntent: "Architecture review; limited spawn", - outOfLane: ["shipping product code", "pedantic style-only nitpicking"], - description: "Architecture review leaf", - systemPrompt: "Placeholder — CL-5821 fills Greybeard.", - spawn: { maySpawn: true, allowlist: ["intern", "explore", "critique"] }, - modelRole: "review", - }), - neckbeard: placeholder({ - id: "neckbeard", - primaryIntent: "Adversarial pedantic review; never fix", - outOfLane: ["applying fixes", "product implementation"], - description: "Adversarial review leaf", - systemPrompt: "Placeholder — CL-5820 fills Neckbeard.", - tools: { deny: ["write_file", "edit_file", "delete_file"] }, - spawn: { maySpawn: false }, - modelRole: "review", - }), - bruckheimer: placeholder({ - id: "bruckheimer", - primaryIntent: "Product discovery docs", - outOfLane: ["shipping product code", "architecture gates"], - description: "Product discovery leaf", - systemPrompt: "Placeholder — CL-5824 fills Bruckheimer.", - spawn: { maySpawn: false }, - modelRole: "docs", - }), - gaasbot: placeholder({ - id: "gaasbot", - primaryIntent: "CTO advice; not a gate", - outOfLane: ["blocking merges", "shipping product code as implementer"], - description: "CTO advice leaf", - systemPrompt: "Placeholder — CL-5826 fills Gaasbot.", - spawn: { maySpawn: false }, - modelRole: "plan", - }), - draper: placeholder({ - id: "draper", - primaryIntent: "Product visual/CBS critique from a development perspective", - outOfLane: ["shipping product code", "marketing copy pipeline"], - description: "Visual/CBS critique leaf (dev-scoped)", - systemPrompt: "Placeholder — CL-5830 fills Draper.", - tools: { deny: ["write_file", "edit_file", "delete_file"] }, - spawn: { maySpawn: false }, - modelRole: "review", - }), - emil: placeholder({ - id: "emil", - primaryIntent: "Design-engineering + laws from a development perspective", - outOfLane: ["shipping product code without design brief", "marketing content"], - description: "Design-engineering leaf (dev-scoped)", - systemPrompt: "Placeholder — CL-5827 fills Emil.", - tools: { deny: ["write_file", "edit_file", "delete_file"] }, - spawn: { maySpawn: false }, - modelRole: "review", - }), - "brand-reviewer": placeholder({ - id: "brand-reviewer", - primaryIntent: "Own DESIGN.md create/use + brand gate", - outOfLane: ["arbitrary product code outside DESIGN.md"], - description: "DESIGN.md brand gate leaf", - systemPrompt: "Placeholder — CL-5829 fills Brand Reviewer.", - spawn: { maySpawn: false }, - modelRole: "docs", - }), - shakespeare: placeholder({ - id: "shakespeare", - primaryIntent: "Maintain product/architecture/implementation docs; scribe baked in", - outOfLane: ["shipping product code", "architecture gates"], - description: "Docs scribe leaf", - systemPrompt: "Placeholder — CL-5845 fills Shakespeare (scribe core).", - spawn: { maySpawn: false }, - modelRole: "docs", - }), - testsmith: placeholder({ - id: "testsmith", - primaryIntent: "Test design only; do not run or fix product", - outOfLane: ["runtime verification", "product implementation"], - description: "Test design leaf", - systemPrompt: "Placeholder — CL-5842 fills Testsmith.", - spawn: { maySpawn: false }, - modelRole: "test", - }), - tester: placeholder({ - id: "tester", - primaryIntent: "Runtime verify; never fix product code", - outOfLane: ["applying product fixes", "test design authorship"], - description: "Runtime verification leaf", - systemPrompt: "Placeholder — CL-5844 fills Tester.", - tools: { deny: ["write_file", "edit_file", "delete_file"] }, - spawn: { maySpawn: false }, - modelRole: "test", - }), + 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 { @@ -224,10 +92,43 @@ export function resolveDirector(input: ResolveDirectorInput): ResolveDirectorRes if (intent === "general") { return { ok: false, - error: "intent=general has no director.", - hint: "Reclassify the work to implement, explore, plan, or review (or pass agent=…).", + 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. */ +export function packageToCapabilities(pkg: DirectorPackage): CapabilityFilter | undefined { + const deny = pkg.tools?.deny; + if (deny !== undefined && deny.length > 0) { + return { mode: "exclude", tools: [...deny] }; + } + const allow = pkg.tools?.allow; + if (allow !== undefined && allow.length > 0) { + return { mode: "allow", tools: [...allow] }; + } + 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, + systemPromptRole: pkg.systemPrompt, + // 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 } : {}), + }; +} + +/** All closed directors as agent profiles (replaces hand-written default-agents stubs). */ +export function directorProfiles(): AgentProfile[] { + return listDirectors().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..b1b0170b3 --- /dev/null +++ b/src/agent/directors/shakespeare/package.test.ts @@ -0,0 +1,66 @@ +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("does not deny product write tools (docs writes allowed)", () => { + expect(shakespearePackage.tools).toBeUndefined(); + const deny = shakespearePackage.tools?.deny ?? []; + expect(deny).not.toContain("write_file"); + expect(deny).not.toContain("edit_file"); + expect(deny).not.toContain("delete_file"); + }); + + 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..a15c3161d --- /dev/null +++ b/src/agent/directors/shakespeare/package.ts @@ -0,0 +1,84 @@ +import type { DirectorPackage } from "../types.js"; + +/** + * Shakespeare: docs-maintenance leaf with scribe core baked into systemPrompt. + * Prompt-first — does not require use_skill("scribe"). + */ +const SHAKESPEARE_SYSTEM_PROMPT = `You are Shakespeare, a leaf director 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. + +# Tools and lane + +You may write and edit PRODUCT.md, ARCHITECTURE.md, and IMPLEMENTATION.md (and docs/ equivalents). 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 left undefined so write_file/edit_file remain available for docs + 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..914cfd6d9 --- /dev/null +++ b/src/agent/directors/skywalker/package.test.ts @@ -0,0 +1,82 @@ +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("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("denies product write tools", () => { + const deny = skywalkerPackage.tools?.deny ?? []; + expect(deny).toContain("write_file"); + expect(deny).toContain("edit_file"); + expect(deny).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("general catch-all leaf"); + }); + + test("nudge maxTurns", () => { + expect(skywalkerPackage.nudge?.maxTurns).toBe(100); + }); +}); diff --git a/src/agent/directors/skywalker/package.ts b/src/agent/directors/skywalker/package.ts new file mode 100644 index 000000000..093d87f6e --- /dev/null +++ b/src/agent/directors/skywalker/package.ts @@ -0,0 +1,118 @@ +// Skywalker: primary orchestration director (Karen-shaped). CL-5817. + +import type { DirectorPackage } from "../types.js"; + +const SKYWALKER_SYSTEM_PROMPT = `You are Corbits Code, SkywalkerDirector — the primary orchestrator. + +PRIMARY INTENT: orchestrate. Classify every request. Delegate scoped work via task to the closed director set. Track the fleet. Synthesize. Do not become the implementer/reviewer by default. + +Closed directors (use search_agents / registry): implement, explore, plan, intern, critique, greybeard, neckbeard, bruckheimer, gaasbot, draper, emil, brand-reviewer, shakespeare, testsmith, tester. +No general leaf. If unsure, reclassify — do not spawn a blob agent. + +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. + +# 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 leaves for scope when needed. +3. Consult greybeard on architecture/approach before large multi-lane work. +4. Use plan leaf 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. + +# Non-negotiables + +- NEVER implement product features yourself (zero product Write/Edit). +- Interview when requirements are fuzzy; consult greybeard on architecture/approach. +- Use plan leaf or dispatch skill for multi-lane eng plans; clarify before large dispatch. +- Exception for write tools: only synthesis under tmp/, dispatch plans under dispatch/ — never product source. +- Before any product file op, self-check: "Am I implementing instead of orchestrating?" If yes, STOP and spawn implement. + +# 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 leaf must meet +- do_not — hard constraints +- report_focus — what the parent needs back +- agent — specialist id when known + +# Report shape + +When finishing a turn that closes work (or reporting a leaf 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 repo walks when dispatch is available", + "being the reviewer/implementer by default", + "general catch-all leaf", + ], + description: "Primary orchestration director (Karen-shaped)", + systemPrompt: SKYWALKER_SYSTEM_PROMPT, + optionalSkills: ["dispatch", "style", "philosophy", "interview"], + tools: { + // Product lock; tmp/dispatch exception is policy in prompt only. + deny: ["write_file", "edit_file", "delete_file"], + }, + 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..c50969274 --- /dev/null +++ b/src/agent/directors/tester/package.test.ts @@ -0,0 +1,51 @@ +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.deny blocks product write paths", () => { + const deny = testerPackage.tools?.deny ?? []; + expect(deny).toContain("write_file"); + expect(deny).toContain("edit_file"); + expect(deny).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..38525f39f --- /dev/null +++ b/src/agent/directors/tester/package.ts @@ -0,0 +1,39 @@ +import type { DirectorPackage } from "../types.js"; + +/** + * Tester: runtime verification leaf — 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 leaf — run tests, report, never fix", + systemPrompt: `You are TesterDirector, a leaf director 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: { + deny: ["write_file", "edit_file", "delete_file"], + }, + 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..8014a1fda --- /dev/null +++ b/src/agent/directors/testsmith/package.test.ts @@ -0,0 +1,57 @@ +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.deny blocks product write paths", () => { + const deny = testsmithPackage.tools?.deny ?? []; + expect(deny).toContain("write_file"); + expect(deny).toContain("edit_file"); + expect(deny).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..ac3a25c35 --- /dev/null +++ b/src/agent/directors/testsmith/package.ts @@ -0,0 +1,41 @@ +import type { DirectorPackage } from "../types.js"; + +/** + * Testsmith: test design leaf — 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 leaf — strategy and cases in the report only", + systemPrompt: `You are TestsmithDirector, a leaf director 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: { + deny: ["write_file", "edit_file", "delete_file"], + }, + spawn: { maySpawn: false }, + nudge: { maxTurns: 40 }, + report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, + modelRole: "test", +}; diff --git a/src/subagent/task-tool.ts b/src/subagent/task-tool.ts index b9f2df055..2d8eb96f5 100644 --- a/src/subagent/task-tool.ts +++ b/src/subagent/task-tool.ts @@ -14,6 +14,11 @@ 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 type { Settings } from "../config/settings.js"; import { resolveSubAgentMaxTurns, @@ -241,7 +246,9 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { let systemPromptRole: string | undefined; let orchestrator = false; let profileMaxTurns: number | undefined; + let resolvedDirectorId: 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,56 +307,104 @@ 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; + resolvedDirectorId = pkg.id; + systemPromptRole = pkg.systemPrompt; + const caps = packageToCapabilities(pkg); + if (caps !== undefined) capabilities = caps; + if (pkg.nudge?.maxTurns !== undefined) profileMaxTurns = pkg.nudge.maxTurns; + if (pkg.spawn.maySpawn && deps.allowOrchestrator !== false) { + orchestrator = true; + } + 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.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 general leaf). + const resolved = resolveDirector({ intent }); + if (!resolved.ok) { + return taskToolResult(call.id, `Error: ${resolved.error} ${resolved.hint}`); + } + const pkg = resolved.package; + resolvedDirectorId = pkg.id; + systemPromptRole = pkg.systemPrompt; + const caps = packageToCapabilities(pkg); + if (caps !== undefined) capabilities = caps; + if (pkg.nudge?.maxTurns !== undefined) profileMaxTurns = pkg.nudge.maxTurns; + if (pkg.spawn.maySpawn && deps.allowOrchestrator !== false) { + orchestrator = true; } } @@ -411,8 +466,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, From ad48615dc8fb4cae474e560eb1f314c0cb571b87 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 17:11:22 -0700 Subject: [PATCH 06/59] Fill closed director packages with tool envelopes and write-path authz Skywalker is the primary system role. Leaves mount small tool allowlists instead of deny-everything, and docs directors lock write_file/edit_file paths at the permission gate so prompt policy is not the only fence. --- CHANGELOG.md | 4 ++ .../directors/brand-reviewer/package.test.ts | 13 ++-- src/agent/directors/brand-reviewer/package.ts | 18 ++--- .../directors/bruckheimer/package.test.ts | 10 +-- src/agent/directors/bruckheimer/package.ts | 11 +-- src/agent/directors/critique/package.test.ts | 13 ++-- src/agent/directors/critique/package.ts | 5 +- src/agent/directors/draper/package.test.ts | 11 +-- src/agent/directors/draper/package.ts | 5 +- src/agent/directors/emil/package.test.ts | 11 +-- src/agent/directors/emil/package.ts | 5 +- src/agent/directors/explore/package.test.ts | 12 ++-- src/agent/directors/explore/package.ts | 3 +- src/agent/directors/gaasbot/package.test.ts | 9 +-- src/agent/directors/gaasbot/package.ts | 3 +- src/agent/directors/greybeard/package.test.ts | 12 ++-- src/agent/directors/greybeard/package.ts | 3 +- src/agent/directors/implement/package.test.ts | 10 +-- src/agent/directors/implement/package.ts | 7 +- src/agent/directors/intern/package.test.ts | 9 ++- src/agent/directors/intern/package.ts | 7 +- src/agent/directors/neckbeard/package.test.ts | 9 +-- src/agent/directors/neckbeard/package.ts | 3 +- src/agent/directors/plan/package.test.ts | 11 +-- src/agent/directors/plan/package.ts | 3 +- src/agent/directors/registry.test.ts | 16 +++-- src/agent/directors/registry.ts | 13 ++-- .../directors/shakespeare/package.test.ts | 15 +++-- src/agent/directors/shakespeare/package.ts | 10 +-- src/agent/directors/skywalker/package.test.ts | 12 ++-- src/agent/directors/skywalker/package.ts | 25 +++++-- src/agent/directors/tester/package.test.ts | 12 ++-- src/agent/directors/tester/package.ts | 5 +- src/agent/directors/testsmith/package.test.ts | 11 +-- src/agent/directors/testsmith/package.ts | 5 +- src/agent/directors/tool-sets.ts | 48 +++++++++++++ src/agent/directors/types.ts | 11 ++- src/agent/profile-types.ts | 5 ++ src/agent/prompt-contract.ts | 5 +- src/agent/prompts.ts | 19 ++++-- src/permission/gate.test.ts | 67 +++++++++++++++++++ src/permission/gate.ts | 27 ++++++++ src/permission/write-path-policy.test.ts | 41 ++++++++++++ src/permission/write-path-policy.ts | 57 ++++++++++++++++ src/prompts.test.ts | 11 +-- src/subagent/identity-context.ts | 10 ++- src/subagent/run.ts | 12 +++- src/subagent/task-tool.ts | 11 +++ src/subagent/types.ts | 5 ++ tests/unit/subagent.test.ts | 9 +-- 50 files changed, 507 insertions(+), 162 deletions(-) create mode 100644 src/agent/directors/tool-sets.ts create mode 100644 src/permission/write-path-policy.test.ts create mode 100644 src/permission/write-path-policy.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bd083592..f5fc3eb18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,10 @@ mid-session switches. 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()`. +- **Skywalker is the primary system role.** `buildChatRole()` returns + `createSkywalkerSystemPrompt()` so the main session gets the closed + orchestrate-only identity (classify → dispatch → synthesize), not the short + generic orchestrator blurb. ### Providers diff --git a/src/agent/directors/brand-reviewer/package.test.ts b/src/agent/directors/brand-reviewer/package.test.ts index 5623b0b45..927846c77 100644 --- a/src/agent/directors/brand-reviewer/package.test.ts +++ b/src/agent/directors/brand-reviewer/package.test.ts @@ -19,15 +19,16 @@ describe("brandReviewerPackage", () => { expect(brandReviewerPackage.spawn.maySpawn).toBe(false); }); - test("does not deny write_file/edit_file (DESIGN.md lane)", () => { - const deny = brandReviewerPackage.tools?.deny ?? []; - expect(deny).not.toContain("write_file"); - expect(deny).not.toContain("edit_file"); + test("tools.allow includes write tools; writePaths lock DESIGN.md", () => { + const allow = brandReviewerPackage.tools?.allow ?? []; + expect(allow).toContain("write_file"); + expect(allow).toContain("edit_file"); + expect(brandReviewerPackage.writePaths).toEqual(["DESIGN.md"]); }); - test("systemPrompt restricts writes to DESIGN.md", () => { + test("systemPrompt mentions DESIGN.md and authz path locks", () => { expect(brandReviewerPackage.systemPrompt).toMatch(/DESIGN\.md/); - expect(brandReviewerPackage.systemPrompt).toMatch(/only/i); + expect(brandReviewerPackage.systemPrompt).toMatch(/authz/i); }); test("report.requiredSections covers the leaf envelope", () => { diff --git a/src/agent/directors/brand-reviewer/package.ts b/src/agent/directors/brand-reviewer/package.ts index 80fd9cc41..f541703c0 100644 --- a/src/agent/directors/brand-reviewer/package.ts +++ b/src/agent/directors/brand-reviewer/package.ts @@ -1,8 +1,9 @@ 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. - * Write tools allowed; prompt hard-restricts file writes to DESIGN.md only. + * Write path lock is authz (writePaths), not prompt policy. */ export const brandReviewerPackage: DirectorPackage = { id: "brand-reviewer", @@ -14,8 +15,8 @@ export const brandReviewerPackage: DirectorPackage = { "architecture gates", ], description: "DESIGN.md brand gate leaf", - // Allow write/edit so DESIGN.md can be created/updated; prompt forbids other paths. - // No tools.deny on write_file/edit_file — product restriction is prompt policy. + tools: { allow: DOCS_TOOLS }, + writePaths: ["DESIGN.md"], spawn: { maySpawn: false }, nudge: { maxTurns: 40 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, @@ -24,12 +25,7 @@ export const brandReviewerPackage: DirectorPackage = { 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 policy (hard) - -You MAY use write_file / edit_file **only** on DESIGN.md (repo root or the path the brief names as the project DESIGN.md). -- Never write, edit, or delete product source, stylesheets, components, tests, or other docs. -- If a fix requires product code changes, report Findings + Blockers and name implement (or draper/emil for critique) — do not patch code yourself. -- delete_file is out of lane unless the brief explicitly asks to remove a DESIGN.md draft and only that path. +Write tools are mounted; path locks are enforced by authz (DESIGN.md only). 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 @@ -71,5 +67,5 @@ Missing brand sources, ambiguous scope, product-code asks. ## Paths DESIGN.md path and UI files reviewed. -Never spawn. Never commit. Stay inside the DESIGN.md write lane.`, -}; \ No newline at end of file +Never spawn. Never commit. Stay on the DESIGN.md lane.`, +}; diff --git a/src/agent/directors/bruckheimer/package.test.ts b/src/agent/directors/bruckheimer/package.test.ts index adc9f3452..9e89d1170 100644 --- a/src/agent/directors/bruckheimer/package.test.ts +++ b/src/agent/directors/bruckheimer/package.test.ts @@ -19,11 +19,11 @@ describe("bruckheimerPackage", () => { expect(bruckheimerPackage.spawn.maySpawn).toBe(false); }); - test("does not deny product write tools (discovery docs allowed)", () => { - const deny = bruckheimerPackage.tools?.deny ?? []; - expect(deny).not.toContain("write_file"); - expect(deny).not.toContain("edit_file"); - expect(deny).not.toContain("delete_file"); + test("tools.allow includes write tools; writePaths lock discovery docs", () => { + const allow = bruckheimerPackage.tools?.allow ?? []; + expect(allow).toContain("write_file"); + expect(allow).toContain("edit_file"); + expect(bruckheimerPackage.writePaths).toEqual(["PRODUCT.md", "docs/*"]); }); test("report requires envelope sections", () => { diff --git a/src/agent/directors/bruckheimer/package.ts b/src/agent/directors/bruckheimer/package.ts index 335645c48..d39801714 100644 --- a/src/agent/directors/bruckheimer/package.ts +++ b/src/agent/directors/bruckheimer/package.ts @@ -1,9 +1,9 @@ import type { DirectorPackage } from "../types.js"; +import { DOCS_TOOLS } from "../tool-sets.js"; /** * Product discovery leaf (CL-5824). - * Invent/capture product shape in docs — not implement features, not architecture gate. - * Write access kept open so discovery can land PRODUCT/ARCHITECTURE notes; prompt forbids shipping product code. + * Write path lock is authz (writePaths), not prompt policy. */ export const bruckheimerPackage: DirectorPackage = { id: "bruckheimer", @@ -16,16 +16,17 @@ export const bruckheimerPackage: DirectorPackage = { "running the fleet", ], description: "Product discovery leaf — user/product shape docs, not code", - // No tools.deny: discovery may write PRODUCT.md / discovery notes. Prompt forbids product code. + tools: { allow: DOCS_TOOLS }, + writePaths: ["PRODUCT.md", "docs/*"], spawn: { maySpawn: false }, nudge: { maxTurns: 40 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, modelRole: "docs", systemPrompt: `You are BruckheimerDirector, a leaf director 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. Prefer PRODUCT.md and related discovery docs over 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. -You are not an implementer. You are not the architecture gate (that is Greybeard). You do not ship features or product code. +Write tools are mounted; path locks are enforced by authz (PRODUCT.md and docs/*). 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. diff --git a/src/agent/directors/critique/package.test.ts b/src/agent/directors/critique/package.test.ts index 81b902f1f..f653565ee 100644 --- a/src/agent/directors/critique/package.test.ts +++ b/src/agent/directors/critique/package.test.ts @@ -18,7 +18,6 @@ describe("critiquePackage", () => { 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(/tmp\/critique-tests/); expect(critiquePackage.systemPrompt).toMatch(/permanent tests/i); }); @@ -26,11 +25,13 @@ describe("critiquePackage", () => { expect(critiquePackage.spawn.maySpawn).toBe(false); }); - test("tools.deny blocks product write paths", () => { - const deny = critiquePackage.tools?.deny ?? []; - expect(deny).toContain("write_file"); - expect(deny).toContain("edit_file"); - expect(deny).toContain("delete_file"); + test("tools.allow is review surface without product writes", () => { + const allow = critiquePackage.tools?.allow ?? []; + expect(allow).toContain("read_file"); + expect(allow).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", () => { diff --git a/src/agent/directors/critique/package.ts b/src/agent/directors/critique/package.ts index d467362b2..80b1eaadd 100644 --- a/src/agent/directors/critique/package.ts +++ b/src/agent/directors/critique/package.ts @@ -1,4 +1,5 @@ import type { DirectorPackage } from "../types.js"; +import { REVIEW_TOOLS } from "../tool-sets.js"; /** * Critique leaf (CL-5819). @@ -16,7 +17,7 @@ export const critiquePackage: DirectorPackage = { ], description: "Code quality review leaf", optionalSkills: ["style", "philosophy"], - tools: { deny: ["write_file", "edit_file", "delete_file"] }, + tools: { allow: REVIEW_TOOLS }, spawn: { maySpawn: false }, nudge: { maxTurns: 45 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, @@ -34,7 +35,7 @@ Evidence rules: - 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). -tmp/critique-tests/: the only write surface you may mention for throwaway repro scaffolding if the parent explicitly grants it. Product paths stay read-only. tools.deny blocks write_file, edit_file, delete_file — do not attempt product edits. +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) diff --git a/src/agent/directors/draper/package.test.ts b/src/agent/directors/draper/package.test.ts index 66c7935c0..b4bc70b41 100644 --- a/src/agent/directors/draper/package.test.ts +++ b/src/agent/directors/draper/package.test.ts @@ -19,11 +19,12 @@ describe("draperPackage", () => { expect(draperPackage.spawn.maySpawn).toBe(false); }); - test("tools.deny blocks product write paths", () => { - const deny = draperPackage.tools?.deny ?? []; - expect(deny).toContain("write_file"); - expect(deny).toContain("edit_file"); - expect(deny).toContain("delete_file"); + 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", () => { diff --git a/src/agent/directors/draper/package.ts b/src/agent/directors/draper/package.ts index fd9ab3de5..99deaf476 100644 --- a/src/agent/directors/draper/package.ts +++ b/src/agent/directors/draper/package.ts @@ -1,4 +1,5 @@ import type { DirectorPackage } from "../types.js"; +import { REVIEW_TOOLS } from "../tool-sets.js"; /** * Draper — product visual / CBS critique (dev-scoped). CL-5830. @@ -14,8 +15,8 @@ export const draperPackage: DirectorPackage = { "applying product fixes", ], description: "Visual/CBS critique leaf (dev-scoped)", - // Read-only critique — product write paths hard-denied. - tools: { deny: ["write_file", "edit_file", "delete_file"] }, + // Read-only critique — product write tools not mounted. + tools: { allow: REVIEW_TOOLS }, spawn: { maySpawn: false }, nudge: { maxTurns: 40 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, diff --git a/src/agent/directors/emil/package.test.ts b/src/agent/directors/emil/package.test.ts index 908a239cd..a21b77f38 100644 --- a/src/agent/directors/emil/package.test.ts +++ b/src/agent/directors/emil/package.test.ts @@ -19,11 +19,12 @@ describe("emilPackage", () => { expect(emilPackage.spawn.maySpawn).toBe(false); }); - test("tools.deny blocks product write paths", () => { - const deny = emilPackage.tools?.deny ?? []; - expect(deny).toContain("write_file"); - expect(deny).toContain("edit_file"); - expect(deny).toContain("delete_file"); + 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", () => { diff --git a/src/agent/directors/emil/package.ts b/src/agent/directors/emil/package.ts index 656125d26..a05ef1931 100644 --- a/src/agent/directors/emil/package.ts +++ b/src/agent/directors/emil/package.ts @@ -1,4 +1,5 @@ import type { DirectorPackage } from "../types.js"; +import { REVIEW_TOOLS } from "../tool-sets.js"; /** * Emil — design-engineering + software-laws critique (dev-scoped). CL-5827. @@ -14,8 +15,8 @@ export const emilPackage: DirectorPackage = { "suggesting full rewrites as implementer", ], description: "Design-engineering leaf (dev-scoped)", - // Critique only — no product write paths. - tools: { deny: ["write_file", "edit_file", "delete_file"] }, + // Critique only — write tools not mounted. + tools: { allow: REVIEW_TOOLS }, spawn: { maySpawn: false }, nudge: { maxTurns: 40 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, diff --git a/src/agent/directors/explore/package.test.ts b/src/agent/directors/explore/package.test.ts index c10f1a1a4..8ba9c85b7 100644 --- a/src/agent/directors/explore/package.test.ts +++ b/src/agent/directors/explore/package.test.ts @@ -19,11 +19,13 @@ describe("explorePackage", () => { expect(explorePackage.spawn.maySpawn).toBe(false); }); - test("tools.deny blocks product write paths", () => { - const deny = explorePackage.tools?.deny ?? []; - expect(deny).toContain("write_file"); - expect(deny).toContain("edit_file"); - expect(deny).toContain("delete_file"); + 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", () => { diff --git a/src/agent/directors/explore/package.ts b/src/agent/directors/explore/package.ts index 64f295a61..846b6d267 100644 --- a/src/agent/directors/explore/package.ts +++ b/src/agent/directors/explore/package.ts @@ -1,4 +1,5 @@ import type { DirectorPackage } from "../types.js"; +import { READ_TOOLS } from "../tool-sets.js"; export const explorePackage: DirectorPackage = { id: "explore", @@ -21,7 +22,7 @@ Deliver a scannable map: key paths, symbols, call flow, ownership. Cite paths. N OUT OF LANE → report Blockers naming the right director: implement, plan, critique, greybeard, intern. Report: Summary, Findings, Blockers, Paths.`, - tools: { deny: ["write_file", "edit_file", "delete_file"] }, + tools: { allow: READ_TOOLS }, spawn: { maySpawn: false }, nudge: { maxTurns: 35 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, diff --git a/src/agent/directors/gaasbot/package.test.ts b/src/agent/directors/gaasbot/package.test.ts index f6e97b203..b857d7d5f 100644 --- a/src/agent/directors/gaasbot/package.test.ts +++ b/src/agent/directors/gaasbot/package.test.ts @@ -20,10 +20,11 @@ describe("gaasbotPackage", () => { }); test("denies product write tools (advice only)", () => { - const deny = gaasbotPackage.tools?.deny ?? []; - expect(deny).toContain("write_file"); - expect(deny).toContain("edit_file"); - expect(deny).toContain("delete_file"); + 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", () => { diff --git a/src/agent/directors/gaasbot/package.ts b/src/agent/directors/gaasbot/package.ts index 0590c1edb..fee249959 100644 --- a/src/agent/directors/gaasbot/package.ts +++ b/src/agent/directors/gaasbot/package.ts @@ -1,4 +1,5 @@ import type { DirectorPackage } from "../types.js"; +import { REVIEW_TOOLS } from "../tool-sets.js"; /** * CTO advice leaf (CL-5826). @@ -16,7 +17,7 @@ export const gaasbotPackage: DirectorPackage = { ], description: "CTO advice leaf — strategic counsel, not a gate", optionalSkills: ["philosophy"], - tools: { deny: ["write_file", "edit_file", "delete_file"] }, + tools: { allow: REVIEW_TOOLS }, spawn: { maySpawn: false }, nudge: { maxTurns: 35 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, diff --git a/src/agent/directors/greybeard/package.test.ts b/src/agent/directors/greybeard/package.test.ts index b0c619783..2fbe1f579 100644 --- a/src/agent/directors/greybeard/package.test.ts +++ b/src/agent/directors/greybeard/package.test.ts @@ -32,11 +32,13 @@ describe("greybeardPackage", () => { expect(allow).not.toContain("plan"); }); - test("denies product write tools", () => { - const deny = greybeardPackage.tools?.deny ?? []; - expect(deny).toContain("write_file"); - expect(deny).toContain("edit_file"); - expect(deny).toContain("delete_file"); + 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", () => { diff --git a/src/agent/directors/greybeard/package.ts b/src/agent/directors/greybeard/package.ts index ae9d9d014..4e94bbb6b 100644 --- a/src/agent/directors/greybeard/package.ts +++ b/src/agent/directors/greybeard/package.ts @@ -1,4 +1,5 @@ import type { DirectorPackage } from "../types.js"; +import { ORCHESTRATOR_TOOLS } from "../tool-sets.js"; /** * Architecture review leaf with limited spawn (CL-5821). @@ -13,7 +14,7 @@ export const greybeardPackage: DirectorPackage = { ], description: "Architecture review leaf", optionalSkills: ["style", "philosophy"], - tools: { deny: ["write_file", "edit_file", "delete_file"] }, + tools: { allow: ORCHESTRATOR_TOOLS }, spawn: { maySpawn: true, allowlist: ["intern", "explore", "critique"], diff --git a/src/agent/directors/implement/package.test.ts b/src/agent/directors/implement/package.test.ts index 22a816924..1c66e6537 100644 --- a/src/agent/directors/implement/package.test.ts +++ b/src/agent/directors/implement/package.test.ts @@ -19,11 +19,11 @@ describe("implementPackage", () => { expect(implementPackage.spawn.maySpawn).toBe(false); }); - test("does not deny product write tools", () => { - const deny = implementPackage.tools?.deny ?? []; - expect(deny).not.toContain("write_file"); - expect(deny).not.toContain("edit_file"); - expect(deny).not.toContain("delete_file"); + 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", () => { diff --git a/src/agent/directors/implement/package.ts b/src/agent/directors/implement/package.ts index acbcdd149..7d0702aee 100644 --- a/src/agent/directors/implement/package.ts +++ b/src/agent/directors/implement/package.ts @@ -1,4 +1,5 @@ import type { DirectorPackage } from "../types.js"; +import { IMPLEMENT_TOOLS } from "../tool-sets.js"; export const implementPackage: DirectorPackage = { id: "implement", @@ -12,7 +13,7 @@ export const implementPackage: DirectorPackage = { ], description: "Implementation leaf — edit, verify, report", optionalSkills: ["style", "philosophy", "typescript"], - // Full product write access — no tools.deny for write_file/edit_file/delete_file + tools: { allow: IMPLEMENT_TOOLS }, spawn: { maySpawn: false }, nudge: { maxTurns: 60 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, @@ -25,7 +26,9 @@ You are not a reviewer, not an orchestrator, not a doc-only planner. Before substantial repo work: use_skill("style"); use_skill("philosophy"). Follow AGENTS.md and /docs. Touch only what the brief requires. Prefer typed success_criteria from the brief as your done gate. -Run typecheck/tests when practical. Do not spawn sub-agents. +Stop when success_criteria are met — do not invent architecture or expand the brief. +Run typecheck/tests when practical; put failures under Blockers, not silent patches outside scope. +Do not spawn sub-agents. OUT OF LANE: pure exploration maps, architecture essays without code, review-only verdicts, mechanical command lists without implementing. diff --git a/src/agent/directors/intern/package.test.ts b/src/agent/directors/intern/package.test.ts index 075b2ccd2..3f8d0a234 100644 --- a/src/agent/directors/intern/package.test.ts +++ b/src/agent/directors/intern/package.test.ts @@ -21,10 +21,13 @@ describe("internPackage", () => { expect(internPackage.nudge?.maxTurns).toBe(20); }); - test("tools.deny blocks product writes, search, and task", () => { - const deny = internPackage.tools?.deny ?? []; + 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(deny).toContain(name); + expect(allow).not.toContain(name); } }); diff --git a/src/agent/directors/intern/package.ts b/src/agent/directors/intern/package.ts index 33b121ab7..85ebf1aa4 100644 --- a/src/agent/directors/intern/package.ts +++ b/src/agent/directors/intern/package.ts @@ -1,4 +1,5 @@ import type { DirectorPackage } from "../types.js"; +import { INTERN_TOOLS } from "../tool-sets.js"; /** * Mechanical intern leaf (CL-5822). @@ -18,14 +19,10 @@ export const internPackage: DirectorPackage = { ], description: "Mechanical intern leaf", optionalSkills: [], - tools: { - // Deny product writes + exploration/search by default (CL-5822). - deny: ["write_file", "edit_file", "delete_file", "grep", "search_files", "task"], - }, + tools: { allow: INTERN_TOOLS }, spawn: { maySpawn: false }, nudge: { maxTurns: 20 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, - // Cheap/luna still uses implement role tag per registry placeholder. modelRole: "implement", systemPrompt: `You are InternDirector, a leaf director in Corbits Code. diff --git a/src/agent/directors/neckbeard/package.test.ts b/src/agent/directors/neckbeard/package.test.ts index 4ff068eeb..7047c147e 100644 --- a/src/agent/directors/neckbeard/package.test.ts +++ b/src/agent/directors/neckbeard/package.test.ts @@ -25,10 +25,11 @@ describe("neckbeardPackage", () => { }); test("denies product write tools", () => { - const deny = neckbeardPackage.tools?.deny ?? []; - expect(deny).toContain("write_file"); - expect(deny).toContain("edit_file"); - expect(deny).toContain("delete_file"); + 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", () => { diff --git a/src/agent/directors/neckbeard/package.ts b/src/agent/directors/neckbeard/package.ts index ebfbedb1a..051cc2074 100644 --- a/src/agent/directors/neckbeard/package.ts +++ b/src/agent/directors/neckbeard/package.ts @@ -1,4 +1,5 @@ import type { DirectorPackage } from "../types.js"; +import { REVIEW_TOOLS } from "../tool-sets.js"; /** * Adversarial pedantic review leaf (CL-5820). @@ -15,7 +16,7 @@ export const neckbeardPackage: DirectorPackage = { ], description: "Adversarial review leaf", optionalSkills: ["style", "philosophy"], - tools: { deny: ["write_file", "edit_file", "delete_file"] }, + tools: { allow: REVIEW_TOOLS }, spawn: { maySpawn: false }, nudge: { maxTurns: 40 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, diff --git a/src/agent/directors/plan/package.test.ts b/src/agent/directors/plan/package.test.ts index 1926c5930..ba6656850 100644 --- a/src/agent/directors/plan/package.test.ts +++ b/src/agent/directors/plan/package.test.ts @@ -19,11 +19,12 @@ describe("planPackage", () => { expect(planPackage.spawn.maySpawn).toBe(false); }); - test("denies product write tools", () => { - const deny = planPackage.tools?.deny ?? []; - expect(deny).toContain("write_file"); - expect(deny).toContain("edit_file"); - expect(deny).toContain("delete_file"); + 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", () => { diff --git a/src/agent/directors/plan/package.ts b/src/agent/directors/plan/package.ts index 9f1f7d95f..dc19537b0 100644 --- a/src/agent/directors/plan/package.ts +++ b/src/agent/directors/plan/package.ts @@ -1,4 +1,5 @@ import type { DirectorPackage } from "../types.js"; +import { REVIEW_TOOLS } from "../tool-sets.js"; export const planPackage: DirectorPackage = { id: "plan", @@ -10,7 +11,7 @@ export const planPackage: DirectorPackage = { ], description: "Planning leaf — eng plans only; Greybeard reviews", optionalSkills: ["style", "philosophy", "interview"], - tools: { deny: ["write_file", "edit_file", "delete_file"] }, + tools: { allow: REVIEW_TOOLS }, spawn: { maySpawn: false }, nudge: { maxTurns: 40 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, diff --git a/src/agent/directors/registry.test.ts b/src/agent/directors/registry.test.ts index 635180ac8..3a715dc76 100644 --- a/src/agent/directors/registry.test.ts +++ b/src/agent/directors/registry.test.ts @@ -95,15 +95,23 @@ describe("director registry", () => { const explore = packageToProfile(DIRECTOR_REGISTRY.explore); expect(explore.id).toBe("explore"); expect(explore.systemPromptRole).toBe(DIRECTOR_REGISTRY.explore.systemPrompt); - expect(explore.capabilities).toEqual({ - mode: "exclude", - tools: ["write_file", "edit_file", "delete_file"], - }); + 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).toEqual([ + "PRODUCT.md", + "ARCHITECTURE.md", + "IMPLEMENTATION.md", + ]); + expect(shakespeare.capabilities?.mode).toBe("allow"); + expect(shakespeare.capabilities?.tools).toContain("write_file"); }); test("directorProfiles covers closed set", () => { diff --git a/src/agent/directors/registry.ts b/src/agent/directors/registry.ts index e44f89bba..372b1eec0 100644 --- a/src/agent/directors/registry.ts +++ b/src/agent/directors/registry.ts @@ -100,16 +100,16 @@ export function resolveDirector(input: ResolveDirectorInput): ResolveDirectorRes return { ok: true, package: DIRECTOR_REGISTRY[id] }; } -/** Map package tool envelope → profile capability filter. */ +/** Map package tool envelope → profile capability filter. Prefer allow (small mount). */ export function packageToCapabilities(pkg: DirectorPackage): CapabilityFilter | undefined { - const deny = pkg.tools?.deny; - if (deny !== undefined && deny.length > 0) { - return { mode: "exclude", tools: [...deny] }; - } 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; } @@ -125,6 +125,9 @@ export function packageToProfile(pkg: DirectorPackage): AgentProfile { 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] } + : {}), }; } diff --git a/src/agent/directors/shakespeare/package.test.ts b/src/agent/directors/shakespeare/package.test.ts index b1b0170b3..3be5819f5 100644 --- a/src/agent/directors/shakespeare/package.test.ts +++ b/src/agent/directors/shakespeare/package.test.ts @@ -32,12 +32,15 @@ describe("shakespearePackage", () => { expect(shakespearePackage.spawn.maySpawn).toBe(false); }); - test("does not deny product write tools (docs writes allowed)", () => { - expect(shakespearePackage.tools).toBeUndefined(); - const deny = shakespearePackage.tools?.deny ?? []; - expect(deny).not.toContain("write_file"); - expect(deny).not.toContain("edit_file"); - expect(deny).not.toContain("delete_file"); + test("tools.allow includes write tools; writePaths lock docs", () => { + const allow = shakespearePackage.tools?.allow ?? []; + expect(allow).toContain("write_file"); + expect(allow).toContain("edit_file"); + expect(shakespearePackage.writePaths).toEqual([ + "PRODUCT.md", + "ARCHITECTURE.md", + "IMPLEMENTATION.md", + ]); }); test("report.requiredSections includes Summary, Findings, Blockers, Paths", () => { diff --git a/src/agent/directors/shakespeare/package.ts b/src/agent/directors/shakespeare/package.ts index a15c3161d..e4bd8355e 100644 --- a/src/agent/directors/shakespeare/package.ts +++ b/src/agent/directors/shakespeare/package.ts @@ -1,8 +1,9 @@ import type { DirectorPackage } from "../types.js"; +import { DOCS_TOOLS } from "../tool-sets.js"; /** * Shakespeare: docs-maintenance leaf with scribe core baked into systemPrompt. - * Prompt-first — does not require use_skill("scribe"). + * Write path lock is authz (writePaths), not prompt policy. */ const SHAKESPEARE_SYSTEM_PROMPT = `You are Shakespeare, a leaf director in Corbits Code. @@ -56,9 +57,7 @@ Scan for thin sections, undefined references, missing failure modes/constraints, Confirm what changed and where. Summarize consistency/gap follow-ups. -# Tools and lane - -You may write and edit PRODUCT.md, ARCHITECTURE.md, and IMPLEMENTATION.md (and docs/ equivalents). Do not implement product source code, run the fleet, or act as tester/reviewer. +Write tools are mounted; path locks are enforced by authz (PRODUCT/ARCHITECTURE/IMPLEMENTATION only). 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. @@ -76,7 +75,8 @@ export const shakespearePackage: DirectorPackage = { description: "Docs maintenance leaf — PRODUCT / ARCHITECTURE / IMPLEMENTATION", systemPrompt: SHAKESPEARE_SYSTEM_PROMPT, optionalSkills: ["style", "philosophy"], - // tools left undefined so write_file/edit_file remain available for docs + tools: { allow: DOCS_TOOLS }, + writePaths: ["PRODUCT.md", "ARCHITECTURE.md", "IMPLEMENTATION.md"], spawn: { maySpawn: false }, nudge: { maxTurns: 50 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, diff --git a/src/agent/directors/skywalker/package.test.ts b/src/agent/directors/skywalker/package.test.ts index 914cfd6d9..b68909ff0 100644 --- a/src/agent/directors/skywalker/package.test.ts +++ b/src/agent/directors/skywalker/package.test.ts @@ -39,11 +39,13 @@ describe("skywalkerPackage", () => { ]); }); - test("denies product write tools", () => { - const deny = skywalkerPackage.tools?.deny ?? []; - expect(deny).toContain("write_file"); - expect(deny).toContain("edit_file"); - expect(deny).toContain("delete_file"); + 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", () => { diff --git a/src/agent/directors/skywalker/package.ts b/src/agent/directors/skywalker/package.ts index 093d87f6e..0490f3eb5 100644 --- a/src/agent/directors/skywalker/package.ts +++ b/src/agent/directors/skywalker/package.ts @@ -1,6 +1,7 @@ // Skywalker: primary orchestration director (Karen-shaped). CL-5817. import type { DirectorPackage } from "../types.js"; +import { ORCHESTRATOR_TOOLS } from "../tool-sets.js"; const SKYWALKER_SYSTEM_PROMPT = `You are Corbits Code, SkywalkerDirector — the primary orchestrator. @@ -9,6 +10,23 @@ PRIMARY INTENT: orchestrate. Classify every request. Delegate scoped work via ta Closed directors (use search_agents / registry): implement, explore, plan, intern, critique, greybeard, neckbeard, bruckheimer, gaasbot, draper, emil, brand-reviewer, shakespeare, testsmith, tester. No general leaf. 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 + 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. @@ -44,7 +62,7 @@ Clear and short. No dispatch for pure questions. - NEVER implement product features yourself (zero product Write/Edit). - Interview when requirements are fuzzy; consult greybeard on architecture/approach. - Use plan leaf or dispatch skill for multi-lane eng plans; clarify before large dispatch. -- Exception for write tools: only synthesis under tmp/, dispatch plans under dispatch/ — never product source. +- Product file mutation tools are not mounted for this director. 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. # Spawn graph @@ -86,10 +104,7 @@ export const skywalkerPackage: DirectorPackage = { description: "Primary orchestration director (Karen-shaped)", systemPrompt: SKYWALKER_SYSTEM_PROMPT, optionalSkills: ["dispatch", "style", "philosophy", "interview"], - tools: { - // Product lock; tmp/dispatch exception is policy in prompt only. - deny: ["write_file", "edit_file", "delete_file"], - }, + tools: { allow: ORCHESTRATOR_TOOLS }, spawn: { maySpawn: true, allowlist: [ diff --git a/src/agent/directors/tester/package.test.ts b/src/agent/directors/tester/package.test.ts index c50969274..20ddcd1db 100644 --- a/src/agent/directors/tester/package.test.ts +++ b/src/agent/directors/tester/package.test.ts @@ -21,11 +21,13 @@ describe("testerPackage", () => { expect(testerPackage.spawn.maySpawn).toBe(false); }); - test("tools.deny blocks product write paths", () => { - const deny = testerPackage.tools?.deny ?? []; - expect(deny).toContain("write_file"); - expect(deny).toContain("edit_file"); - expect(deny).toContain("delete_file"); + 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", () => { diff --git a/src/agent/directors/tester/package.ts b/src/agent/directors/tester/package.ts index 38525f39f..66dbe4208 100644 --- a/src/agent/directors/tester/package.ts +++ b/src/agent/directors/tester/package.ts @@ -1,4 +1,5 @@ import type { DirectorPackage } from "../types.js"; +import { READ_TOOLS } from "../tool-sets.js"; /** * Tester: runtime verification leaf — run tests and report; never fix product code. @@ -29,9 +30,7 @@ If tests fail: document failures, suspected area, and blockers. Do not write_fil 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: { - deny: ["write_file", "edit_file", "delete_file"], - }, + tools: { allow: READ_TOOLS }, spawn: { maySpawn: false }, nudge: { maxTurns: 40 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, diff --git a/src/agent/directors/testsmith/package.test.ts b/src/agent/directors/testsmith/package.test.ts index 8014a1fda..cb8b6c324 100644 --- a/src/agent/directors/testsmith/package.test.ts +++ b/src/agent/directors/testsmith/package.test.ts @@ -21,11 +21,12 @@ describe("testsmithPackage", () => { expect(testsmithPackage.spawn.maySpawn).toBe(false); }); - test("tools.deny blocks product write paths", () => { - const deny = testsmithPackage.tools?.deny ?? []; - expect(deny).toContain("write_file"); - expect(deny).toContain("edit_file"); - expect(deny).toContain("delete_file"); + 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", () => { diff --git a/src/agent/directors/testsmith/package.ts b/src/agent/directors/testsmith/package.ts index ac3a25c35..76212eebc 100644 --- a/src/agent/directors/testsmith/package.ts +++ b/src/agent/directors/testsmith/package.ts @@ -1,4 +1,5 @@ import type { DirectorPackage } from "../types.js"; +import { READ_TOOLS } from "../tool-sets.js"; /** * Testsmith: test design leaf — strategy and cases only; never implements product @@ -31,9 +32,7 @@ OUT OF LANE: product Write/Edit, fixing production code, becoming the implemente 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: { - deny: ["write_file", "edit_file", "delete_file"], - }, + tools: { allow: READ_TOOLS }, spawn: { maySpawn: false }, nudge: { maxTurns: 40 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, diff --git a/src/agent/directors/tool-sets.ts b/src/agent/directors/tool-sets.ts new file mode 100644 index 000000000..c6022ea5b --- /dev/null +++ b/src/agent/directors/tool-sets.ts @@ -0,0 +1,48 @@ +// 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. + +/** 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 + skills. */ +export const IMPLEMENT_TOOLS = [ + ...READ_TOOLS, + "write_file", + "edit_file", + "delete_file", + "use_skill", +] as const; + +/** Docs leaves that may write only under writePaths authz. */ +export const DOCS_TOOLS = [ + ...READ_TOOLS, + "write_file", + "edit_file", + "use_skill", +] as const; + +/** Review / counsel: read + skills, no writes. */ +export const REVIEW_TOOLS = [...READ_TOOLS, "use_skill"] as const; + +/** Mechanical intern: shell-first, minimal surface. */ +export const INTERN_TOOLS = ["run_shell", "read_file", "list_dir"] as const; + +/** Orchestrator (Skywalker / greybeard spawn path): dispatch, no product writes. */ +export const ORCHESTRATOR_TOOLS = [ + ...READ_TOOLS, + "use_skill", + "tool_search", + "search_agents", + "task", + "ask_operator", +] as const; diff --git a/src/agent/directors/types.ts b/src/agent/directors/types.ts index 4408d3e6a..9127f9191 100644 --- a/src/agent/directors/types.ts +++ b/src/agent/directors/types.ts @@ -28,9 +28,9 @@ export type TaskIntent = "explore" | "implement" | "plan" | "review" | "general" export type ModelRole = "orchestrator" | "implement" | "explore" | "review" | "plan" | "docs" | "test"; export type ToolEnvelope = { - /** Tools always allowed when present in the session registry. */ + /** Tools mounted when present — prefer small allowlists over deny-everything. */ readonly allow?: readonly string[]; - /** Tools denied even if present in the session registry. */ + /** Tools denied even if present in the session registry. Prefer allow when possible. */ readonly deny?: readonly string[]; }; @@ -68,6 +68,13 @@ export type DirectorPackage = { /** Optional skills the leaf 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). Bare filenames match + * that basename at any depth under the worker cwd. 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; 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..5ee21eb0b 100644 --- a/src/agent/prompt-contract.ts +++ b/src/agent/prompt-contract.ts @@ -2,7 +2,8 @@ // 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", "Response style:", "Tool choice:", "Ask vs proceed:", @@ -13,4 +14,4 @@ export const CHAT_PROMPT_QUALITY_MARKERS = [ "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 +] as const; diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index c0d3e82ef..c035c0a23 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 @@ -39,12 +40,9 @@ function formatDateDDMMYYYY(date: Date): string { } export function buildChatRole(_sessionMode: SessionMode = "orchestrator"): string { - 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(" "); + // 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 @@ -99,8 +97,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." + : "- 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. As Skywalker, product Write/Edit is out of lane — spawn implement (or a docs director) instead.", "- 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 ? [] 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..99ff5942c --- /dev/null +++ b/src/permission/write-path-policy.test.ts @@ -0,0 +1,41 @@ +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 any depth under cwd", () => { + expect(matchesWritePathAllowlist("PRODUCT.md", ["PRODUCT.md"], cwd)).toBe(true); + 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); + }); +}); + +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..ad064f61b --- /dev/null +++ b/src/permission/write-path-policy.ts @@ -0,0 +1,57 @@ +import { basename, relative, 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. + * + * Patterns: + * - bare filename (`PRODUCT.md`) matches that basename at any depth under cwd + * - relative globs (`docs/*`, `DESIGN.md`) use matchesPattern against the + * workspace-relative path + */ +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 = subject; + if (abs === absCwd) { + rel = "."; + } else if (abs.startsWith(absCwd + sep)) { + rel = abs.slice(absCwd.length + 1); + } else { + // Outside cwd — still try pattern match on the raw subject / relative form. + try { + rel = relative(absCwd, abs); + } catch { + rel = subject; + } + } + + const base = basename(rel); + for (const pattern of allowlist) { + if (matchesPattern(rel, pattern)) return true; + if (matchesPattern(subject, pattern)) return true; + if (matchesPattern(base, pattern)) return true; + // Bare filename: match any depth with that exact basename. + if (!pattern.includes("/") && !pattern.includes("*") && !pattern.includes("?") && base === 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/prompts.test.ts b/src/prompts.test.ts index 9ea2bbf9b..bbd9bc574 100644 --- a/src/prompts.test.ts +++ b/src/prompts.test.ts @@ -33,14 +33,15 @@ test("chat prompt orders base, then tools, then context", () => { expect(prompt.indexOf("Tools:")).toBeLessThan(prompt.indexOf("Active context:")); }); -test("agent identity is Corbits Code as orchestrator", () => { +test("agent identity is Corbits Code as Skywalker orchestrator", () => { const orchestrator = buildChatRole("orchestrator"); expect(orchestrator).toContain("Corbits Code"); - expect(orchestrator).toContain("orchestrator"); - expect(orchestrator).toContain("delegate"); - expect(orchestrator).toContain("Match their tone"); + expect(orchestrator).toContain("SkywalkerDirector"); + 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("orchestrator"); + expect(buildChatRole()).toContain("SkywalkerDirector"); }); test("harness facts state only the non-derivable tool and safety rules", () => { 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(); diff --git a/src/subagent/run.ts b/src/subagent/run.ts index 0dd4853f8..93b6a9b61 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -429,9 +429,15 @@ export async function runSubAgent(params: RunSubAgentParams): Promise { 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 diff --git a/src/subagent/task-tool.ts b/src/subagent/task-tool.ts index 2d8eb96f5..4aa477cd9 100644 --- a/src/subagent/task-tool.ts +++ b/src/subagent/task-tool.ts @@ -244,6 +244,7 @@ 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; @@ -319,6 +320,9 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { systemPromptRole = pkg.systemPrompt; 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; @@ -362,6 +366,9 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { 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; } @@ -402,6 +409,9 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { systemPromptRole = pkg.systemPrompt; 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; @@ -616,6 +626,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/types.ts b/src/subagent/types.ts index 2599842e0..9dc0b1786 100644 --- a/src/subagent/types.ts +++ b/src/subagent/types.ts @@ -93,6 +93,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/tests/unit/subagent.test.ts b/tests/unit/subagent.test.ts index edf328e8c..b33bfba97 100644 --- a/tests/unit/subagent.test.ts +++ b/tests/unit/subagent.test.ts @@ -238,10 +238,11 @@ test("intent maps to closed director without profiles", async () => { }); expect(result).toContain("ok"); expect(received?.systemPromptRole).toContain("PRIMARY INTENT"); - expect(received?.capabilities).toEqual({ - mode: "exclude", - tools: ["write_file", "edit_file", "delete_file"], - }); + 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 () => { From ed54677e8225087eb88475cb9feb72167b04f95d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 17:14:06 -0700 Subject: [PATCH 07/59] Document the closed director fleet and lock Phase 5 acceptance tests PRODUCT, ARCHITECTURE, and IMPLEMENTATION now describe Skywalker, the 16-package registry, spawn matrix, intent map, and writePaths. Registry tests cover greybeard spawn, review envelopes, and primary stance. --- CHANGELOG.md | 6 +++ docs/ARCHITECTURE.md | 68 ++++++++++++++++++++++++++-- docs/IMPLEMENTATION.md | 25 +++++++++- docs/PRODUCT.md | 15 ++++-- src/agent/directors/registry.test.ts | 62 +++++++++++++++++++++++++ 5 files changed, 168 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5fc3eb18..42bd3cea2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,12 @@ mid-session switches. `createSkywalkerSystemPrompt()` so the main session gets the closed orchestrate-only identity (classify → dispatch → synthesize), not the short generic orchestrator blurb. +- **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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 93bc68dc0..8dab1c55a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -227,6 +227,68 @@ 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. +#### 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 general leaf: `task(intent="general")` fails closed and the primary reclassifies. + +**Primary** + +| Director | Owns | Does not own | +|---|---|---| +| skywalker | Orchestrate only — classify, dispatch, track fleet, synthesize | Product tree edits; being the implementer/reviewer by default | + +**Engineering leaves** + +| 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 write paths | +| 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 leaves | no `task` | + +**Tool envelopes** prefer small `tools.allow` mounts over deny-everything. Docs directors may write only under package `writePaths` (enforced by the permission gate, not prompt policy): shakespeare → PRODUCT/ARCHITECTURE/IMPLEMENTATION; brand-reviewer → DESIGN.md; bruckheimer → PRODUCT.md + docs/*. + +**Typical chain:** bruckheimer → plan → greybeard → implement (+ intern) → critique (+ optional neckbeard), with skywalker coordinating throughout. + **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`. **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,11 +299,11 @@ 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 agent's identity is **Corbits Code**. The primary session role is **SkywalkerDirector** (`buildChatRole` → `createSkywalkerSystemPrompt`): orchestrate-only — classify, dispatch closed directors via `task`, track the fleet, synthesize. 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. +- `buildChatRole` — Skywalker primary identity (orchestrate; do not implement product work by default). - `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. +- `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): 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). **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`. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 8060c4ca4..307b57122 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() closed fleet + 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 leaf write allowlists matcher.ts Approval glob matching store.ts Per-directory approval persistence types.ts Approval / scope / request / outcome types @@ -141,6 +150,18 @@ 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. +2. `packageToProfile` maps envelope (`tools.allow`/`deny`) to `AgentProfile.capabilities`, `spawn.maySpawn` → `orchestrator`, and optional `writePaths`. +3. `directorProfiles()` is the default profile catalog (`default-agents.ts`); plugin agent profiles still load and can override by id. +4. Primary chat role is Skywalker: `buildChatRole()` → `createSkywalkerSystemPrompt()`. +5. Leaf `writePaths` (shakespeare docs trio, brand-reviewer `DESIGN.md`, bruckheimer PRODUCT + docs/*) are enforced in the permission gate via ALS identity (`identity-context.ts` + `write-path-policy.ts`). + +Intent defaults: implement/explore/plan → same-named director; review → critique; general → error. Spawn: skywalker full fleet; greybeard intern/explore/critique only; all other leaves no `task`. + ### 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. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index bafbca40e..86bba1f36 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -135,15 +135,24 @@ Capabilities beyond the core toolset are opt-in plugins, enabled per workspace t ## Multi-agent (sub-agents) -In the TUI, the primary session is always **orchestrator**: it can act directly and delegates work via `task` / `search_agents`. Single-agent session mode is gone (CL-5814). +The primary session is always **orchestrator** (single-agent mode is gone). Its identity is **Skywalker**: classify work, dispatch a **closed fleet of 16 directors**, track the fleet, and synthesize — not implement product code by default. -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. +| 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 general leaf**. `task(intent=…)` maps implement/explore/plan/review→critique; `general` is refused. Named `task(agent=…)` selects a director package without requiring a plugin profile. Only skywalker (full fleet) and greybeard (intern/explore/critique) may spawn nested workers. + +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. 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. 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/src/agent/directors/registry.test.ts b/src/agent/directors/registry.test.ts index 3a715dc76..b2f2afbf3 100644 --- a/src/agent/directors/registry.test.ts +++ b/src/agent/directors/registry.test.ts @@ -119,4 +119,66 @@ describe("director registry", () => { expect(profiles).toHaveLength(16); expect(new Set(profiles.map((p) => p.id)).size).toBe(16); }); + + // 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("docs writePaths: shakespeare trio + brand DESIGN.md + bruckheimer PRODUCT", () => { + expect(DIRECTOR_REGISTRY.shakespeare.writePaths).toEqual([ + "PRODUCT.md", + "ARCHITECTURE.md", + "IMPLEMENTATION.md", + ]); + expect(DIRECTOR_REGISTRY["brand-reviewer"].writePaths).toEqual(["DESIGN.md"]); + expect(DIRECTOR_REGISTRY.bruckheimer.writePaths).toEqual(["PRODUCT.md", "docs/*"]); + }); + + 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).toMatch(/No general leaf/i); + expect(s.tools?.allow).toContain("task"); + expect(s.tools?.allow).not.toContain("write_file"); + expect(s.spawn.allowlist).toHaveLength(15); + }); }); From 7c2e9bb6849be9af25d12c703f03c4bc6918bb11 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 18:14:28 -0700 Subject: [PATCH 08/59] Enforce director spawn allowlist and refuse bare task Close the two dogfood gaps greybeard flagged: nested directors pass spawn.allowlist into nested task so greybeard cannot mint implement, and task without agent or intent fails closed instead of a generic leaf. --- CHANGELOG.md | 3 + docs/ARCHITECTURE.md | 2 +- docs/IMPLEMENTATION.md | 11 +-- docs/PRODUCT.md | 2 +- src/perf/permission-subagent-spans.test.ts | 8 +-- src/subagent/index.test.ts | 49 ++++++++----- src/subagent/run.ts | 1 + src/subagent/task-tool-worktree.test.ts | 10 +-- src/subagent/task-tool.ts | 39 ++++++++++ src/subagent/types.ts | 5 ++ src/telemetry/ai-observability.test.ts | 2 +- tests/unit/subagent-session-store.test.ts | 9 +-- tests/unit/subagent.test.ts | 82 +++++++++++++++++++++- 13 files changed, 182 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42bd3cea2..917053a89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,9 @@ mid-session switches. 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()`. +- **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). - **Skywalker is the primary system role.** `buildChatRole()` returns `createSkywalkerSystemPrompt()` so the main session gets the closed orchestrate-only identity (classify → dispatch → synthesize), not the short diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8dab1c55a..4a4b5da46 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -229,7 +229,7 @@ Profiles with `orchestrator: true` may themselves call `task` (one hop only): ne #### 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 general leaf: `task(intent="general")` fails closed and the primary reclassifies. +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 general leaf: `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). **Primary** diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 307b57122..d26ac4890 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -154,13 +154,14 @@ docs/ 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. +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`. -3. `directorProfiles()` is the default profile catalog (`default-agents.ts`); plugin agent profiles still load and can override by id. -4. Primary chat role is Skywalker: `buildChatRole()` → `createSkywalkerSystemPrompt()`. -5. Leaf `writePaths` (shakespeare docs trio, brand-reviewer `DESIGN.md`, bruckheimer PRODUCT + docs/*) are enforced in the permission gate via ALS identity (`identity-context.ts` + `write-path-policy.ts`). +3. Nested spawn: packages with `spawn.allowlist` forward that list into nested `task` (`spawnAllowlist` on nestedDispatch). Off-list `agent` is refused. Primary omits the list so plugin profiles stay reachable. +4. `directorProfiles()` is the default profile catalog (`default-agents.ts`); plugin agent profiles still load and can override by id. +5. Primary chat role is Skywalker: `buildChatRole()` → `createSkywalkerSystemPrompt()` (never-implement stance is prompt-first; product write tools may still be mounted on the primary session until a separate tool-envelope pass). +6. Leaf `writePaths` (shakespeare docs trio, brand-reviewer `DESIGN.md`, bruckheimer PRODUCT + docs/*) are enforced in the permission gate via ALS identity (`identity-context.ts` + `write-path-policy.ts`). -Intent defaults: implement/explore/plan → same-named director; review → critique; general → error. Spawn: skywalker full fleet; greybeard intern/explore/critique only; all other leaves no `task`. +Intent defaults: implement/explore/plan → same-named director; review → critique; general → error. Spawn: skywalker full fleet; greybeard intern/explore/critique only; all other leaves no `task`. `modelRole` / `optionalSkills` are package fields for later wiring (CL-5816), not resolved at spawn yet. ### Auto Mode diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 86bba1f36..9fe093a79 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -144,7 +144,7 @@ The primary session is always **orchestrator** (single-agent mode is gone). Its | Design | draper, emil, brand-reviewer | | Docs / QA | shakespeare, testsmith, tester | -There is **no general leaf**. `task(intent=…)` maps implement/explore/plan/review→critique; `general` is refused. Named `task(agent=…)` selects a director package without requiring a plugin profile. Only skywalker (full fleet) and greybeard (intern/explore/critique) may spawn nested workers. +There is **no general leaf**. `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. Nested spawn is runtime-enforced: only skywalker (full fleet allowlist) and greybeard (intern/explore/critique) may spawn; other leaves have no `task`. Primary omits an allowlist so plugin profiles remain reachable from the main session. 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. 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/subagent/index.test.ts b/src/subagent/index.test.ts index cc7c60aef..1595822c7 100644 --- a/src/subagent/index.test.ts +++ b/src/subagent/index.test.ts @@ -1235,13 +1235,19 @@ describe("createTaskTool", () => { getWorkdirBase: () => "/repo/.corbits", provider, maxTurns: 25, + profiles: [{ id: "leaf" }], run: async (params) => { captured = params; return "done"; }, } as Parameters[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 +1262,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 +1285,7 @@ describe("createTaskTool", () => { cwd: "/repo", getWorkdirBase: () => "/repo/.corbits", provider, + profiles: [{ id: "leaf" }], run: async (params) => { captured = params; return "done"; @@ -1284,6 +1296,7 @@ describe("createTaskTool", () => { description: "Long job", prompt: "Work", maxTurns: 50, + agent: "leaf", }); expect(captured?.maxTurns).toBe(50); @@ -1401,6 +1414,7 @@ describe("createTaskTool", () => { description: "Too long", prompt: "Work", maxTurns: 101, + intent: "explore", }); expect(result).toContain("Error:"); @@ -1463,7 +1477,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 +1502,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 +1522,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 +1562,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 +1584,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 +1604,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 +1617,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 +1641,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 +1674,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 +1704,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 +1760,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(); @@ -2047,7 +2062,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 +2089,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 +2113,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/run.ts b/src/subagent/run.ts index 93b6a9b61..3ac8a066f 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -353,6 +353,7 @@ export async function runSubAgent(params: RunSubAgentParams): Promise { ...(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 ? [ 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 4aa477cd9..e0a619eae 100644 --- a/src/subagent/task-tool.ts +++ b/src/subagent/task-tool.ts @@ -165,12 +165,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 @@ -248,6 +255,8 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { let orchestrator = false; let profileMaxTurns: number | undefined; let resolvedDirectorId: string | 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; @@ -326,6 +335,9 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { 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); @@ -415,6 +427,30 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { 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 general leaf: 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.', + ); + } + + // 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}.`, + ); } } @@ -527,6 +563,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 diff --git a/src/subagent/types.ts b/src/subagent/types.ts index 9dc0b1786..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. */ 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 { { 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/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 b33bfba97..a2df5a9f3 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,7 +136,7 @@ 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"); }); @@ -267,6 +268,78 @@ test("intent general is refused (no general director)", async () => { expect(ran).toBe(false); }); +test("bare task without agent or intent is refused (no general leaf)", 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("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, @@ -381,6 +454,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."); @@ -416,6 +490,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, { @@ -423,6 +498,7 @@ test("handler omits context and goals when empty", async () => { context: " ", prompt: "Review the function signatures.", goals: [], + intent: "explore", }); expect(receivedNoContext?.context).toBeUndefined(); From 95b17af329fb385f9f6653a86731a9bced0c23f0 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 18:53:53 -0700 Subject: [PATCH 09/59] Name primary Skywalker and strip product write tools from the orchestrator Wire director identity headers (agent id, modelRole, optionalSkills) into profiles and spawn, drive leaf effort from modelRole, inject arch/runtime into the env block, and make never-implement structural on the primary tool surface instead of prompt-only. --- CHANGELOG.md | 13 ++++-- docs/ARCHITECTURE.md | 6 +-- docs/IMPLEMENTATION.md | 7 ++-- docs/PRODUCT.md | 2 +- src/agent/directors/identity.test.ts | 34 +++++++++++++++ src/agent/directors/identity.ts | 42 +++++++++++++++++++ src/agent/directors/index.ts | 6 +++ src/agent/directors/registry.test.ts | 14 ++++++- src/agent/directors/registry.ts | 5 ++- src/agent/directors/skywalker/package.test.ts | 2 + src/agent/directors/skywalker/package.ts | 12 ++++-- src/agent/environment.ts | 12 +++++- src/agent/prompt-contract.ts | 5 ++- src/agent/prompts.test.ts | 4 ++ src/agent/prompts.ts | 9 +++- src/agent/tool-search.test.ts | 12 +++++- src/agent/tool-search.ts | 15 ++++++- src/agent/tools.ts | 10 ++++- src/prompts.test.ts | 17 ++++++-- src/provider/reasoning-effort.ts | 12 ++++-- src/subagent/task-tool.ts | 23 +++++++--- .../reactor-permission-multi-turn.test.ts | 25 ++++++----- tests/unit/tui/agent-tools.test.ts | 5 ++- 23 files changed, 243 insertions(+), 49 deletions(-) create mode 100644 src/agent/directors/identity.test.ts create mode 100644 src/agent/directors/identity.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 917053a89..8b5290133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,13 +54,18 @@ mid-session switches. 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). -- **Skywalker is the primary system role.** `buildChatRole()` returns - `createSkywalkerSystemPrompt()` so the main session gets the closed - orchestrate-only identity (classify → dispatch → synthesize), not the short - generic orchestrator blurb. - **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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4a4b5da46..8f16c273b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -299,12 +299,12 @@ 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**. The primary session role is **SkywalkerDirector** (`buildChatRole` → `createSkywalkerSystemPrompt`): orchestrate-only — classify, dispatch closed directors via `task`, track the fleet, synthesize. 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: +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). 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` — Skywalker primary identity (orchestrate; do not implement product work by default). -- `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`. +- `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): 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). +- `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` on leaves, 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). **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`. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index d26ac4890..2d9ce2bf5 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -155,13 +155,14 @@ docs/ 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`. +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. Primary omits the list so plugin profiles stay reachable. 4. `directorProfiles()` is the default profile catalog (`default-agents.ts`); plugin agent profiles still load and can override by id. -5. Primary chat role is Skywalker: `buildChatRole()` → `createSkywalkerSystemPrompt()` (never-implement stance is prompt-first; product write tools may still be mounted on the primary session until a separate tool-envelope pass). +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. 6. Leaf `writePaths` (shakespeare docs trio, brand-reviewer `DESIGN.md`, bruckheimer PRODUCT + docs/*) are enforced in the permission gate via ALS identity (`identity-context.ts` + `write-path-policy.ts`). +7. Spawn effort: pin > package `modelRole` default (`defaultEffortForDirector`; intern=low) > orchestrator/leaf binary > parent inheritance. Optional skills are listed in the identity header (model loads via `use_skill`); not auto-injected as full skill bodies. -Intent defaults: implement/explore/plan → same-named director; review → critique; general → error. Spawn: skywalker full fleet; greybeard intern/explore/critique only; all other leaves no `task`. `modelRole` / `optionalSkills` are package fields for later wiring (CL-5816), not resolved at spawn yet. +Intent defaults: implement/explore/plan → same-named director; review → critique; general → error. Spawn: skywalker full fleet; greybeard intern/explore/critique only; all other leaves no `task`. Live `` injects cwd, platform, arch, runtime, date, and git status on every chat and leaf prompt. ### Auto Mode diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 9fe093a79..4fa330ec8 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -135,7 +135,7 @@ Capabilities beyond the core toolset are opt-in plugins, enabled per workspace t ## Multi-agent (sub-agents) -The primary session is always **orchestrator** (single-agent mode is gone). Its identity is **Skywalker**: classify work, dispatch a **closed fleet of 16 directors**, track the fleet, and synthesize — not implement product code by default. +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 are not mounted on the primary session — implement/docs leaves own durable writes. | Lane | Directors | |---|---| 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..14ef38cbb --- /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 (do not load skills unless the brief requires)." + : `Optional skills (load via use_skill when the job needs them): ${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 leaf: 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; + +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/index.ts b/src/agent/directors/index.ts index 6e7d0e553..d4bf9a2bf 100644 --- a/src/agent/directors/index.ts +++ b/src/agent/directors/index.ts @@ -22,3 +22,9 @@ export { packageToProfile, resolveDirector, } from "./registry.js"; + +export { + MODEL_ROLE_DEFAULT_EFFORT, + defaultEffortForDirector, + formatDirectorSystemPrompt, +} from "./identity.js"; diff --git a/src/agent/directors/registry.test.ts b/src/agent/directors/registry.test.ts index b2f2afbf3..a47b958f4 100644 --- a/src/agent/directors/registry.test.ts +++ b/src/agent/directors/registry.test.ts @@ -94,7 +94,9 @@ describe("director registry", () => { test("packageToProfile maps envelope and spawn", () => { const explore = packageToProfile(DIRECTOR_REGISTRY.explore); expect(explore.id).toBe("explore"); - expect(explore.systemPromptRole).toBe(DIRECTOR_REGISTRY.explore.systemPrompt); + 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"); @@ -176,9 +178,19 @@ describe("director registry", () => { 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 general leaf/i); expect(s.tools?.allow).toContain("task"); expect(s.tools?.allow).not.toContain("write_file"); expect(s.spawn.allowlist).toHaveLength(15); }); + + 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 index 372b1eec0..e6880a3a9 100644 --- a/src/agent/directors/registry.ts +++ b/src/agent/directors/registry.ts @@ -15,6 +15,7 @@ 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, @@ -118,8 +119,8 @@ export function packageToProfile(pkg: DirectorPackage): AgentProfile { const capabilities = packageToCapabilities(pkg); return { id: pkg.id, - description: pkg.description, - systemPromptRole: pkg.systemPrompt, + 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, diff --git a/src/agent/directors/skywalker/package.test.ts b/src/agent/directors/skywalker/package.test.ts index b68909ff0..9ae9eb124 100644 --- a/src/agent/directors/skywalker/package.test.ts +++ b/src/agent/directors/skywalker/package.test.ts @@ -9,6 +9,8 @@ describe("skywalkerPackage", () => { 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"); }); diff --git a/src/agent/directors/skywalker/package.ts b/src/agent/directors/skywalker/package.ts index 0490f3eb5..bc82f43c6 100644 --- a/src/agent/directors/skywalker/package.ts +++ b/src/agent/directors/skywalker/package.ts @@ -3,11 +3,14 @@ import type { DirectorPackage } from "../types.js"; import { ORCHESTRATOR_TOOLS } from "../tool-sets.js"; -const SKYWALKER_SYSTEM_PROMPT = `You are Corbits Code, SkywalkerDirector — the primary orchestrator. +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 task leaf). Nested specialists use task(agent="…"). PRIMARY INTENT: orchestrate. Classify every request. Delegate scoped work via task to the closed director set. Track the fleet. Synthesize. Do not become the implementer/reviewer by default. -Closed directors (use search_agents / registry): implement, explore, plan, intern, critique, greybeard, neckbeard, bruckheimer, gaasbot, draper, emil, brand-reviewer, shakespeare, testsmith, tester. +Closed directors (use search_agents / registry; each id matches task(agent="")): implement, explore, plan, intern, critique, greybeard, neckbeard, bruckheimer, gaasbot, draper, emil, brand-reviewer, shakespeare, testsmith, tester. No general leaf. If unsure, reclassify — do not spawn a blob agent. Quick routing: @@ -62,8 +65,9 @@ Clear and short. No dispatch for pure questions. - NEVER implement product features yourself (zero product Write/Edit). - Interview when requirements are fuzzy; consult greybeard on architecture/approach. - Use plan leaf or dispatch skill for multi-lane eng plans; clarify before large dispatch. -- Product file mutation tools are not mounted for this director. Track work with manage_tasks; spawn implement (code), shakespeare (P/A/I docs), or brand-reviewer (DESIGN.md) for durable artifacts. +- 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: dispatch, style, philosophy, interview (use_skill). # Spawn graph @@ -75,7 +79,7 @@ When spawning, prefer a typed brief: - success_criteria — done-definition the leaf must meet - do_not — hard constraints - report_focus — what the parent needs back -- agent — specialist id when known +- agent — specialist id when known (must match a closed director id above) # Report shape 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 { export async function gatherEnvironment(cwd: string, date = new Date()): Promise { 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/prompt-contract.ts b/src/agent/prompt-contract.ts index 5ee21eb0b..db259c99c 100644 --- a/src/agent/prompt-contract.ts +++ b/src/agent/prompt-contract.ts @@ -4,14 +4,15 @@ export const CHAT_PROMPT_QUALITY_MARKERS = [ "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", + "never shell-write (echo/heredoc/sed/rm)", ] as const; diff --git a/src/agent/prompts.test.ts b/src/agent/prompts.test.ts index 9a787dcb9..dc56d8608 100644 --- a/src/agent/prompts.test.ts +++ b/src/agent/prompts.test.ts @@ -14,6 +14,10 @@ import { CORE_TOOL_NAMES, CATALOG_TOOL_NAMES } from "./tool-search.js"; const REGISTERED_TOOL_NAMES = new Set([ ...CORE_TOOL_NAMES, ...CATALOG_TOOL_NAMES, + // Product mutation tools mount on leaves, not primary CORE/CATALOG ads. + "write_file", + "edit_file", + "delete_file", "web_fetch", "web_search", ]); diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index c035c0a23..195ea62db 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -60,6 +60,11 @@ export function buildHarnessFacts( return [ "Harness facts:", "- Change files with write_file/edit_file and remove files with delete_file; shell file-writes and deletions are blocked.", + ...(subAgent + ? [] + : [ + "- 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/docs) for durable edits.", + ]), "- 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.", @@ -105,7 +110,7 @@ export function buildGuidelines(opts: { subAgent?: boolean; sessionMode?: Sessio "- read_file for file contents; grep or search_files to locate code; lsp for symbols, types, references, or call flow before opening large files.", 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." - : "- 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. As Skywalker, product Write/Edit is out of lane — spawn implement (or a docs director) instead.", + : "- 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 ? [] @@ -228,6 +233,8 @@ export function buildEnvironmentContext(env: EnvironmentInfo): string { "", `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) { diff --git a/src/agent/tool-search.test.ts b/src/agent/tool-search.test.ts index b8d1f1032..9c81fc647 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"; @@ -77,6 +78,14 @@ 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("lsp is advertised only when a language server was detected at startup", () => { expect( coreToolNamesForSessionMode("orchestrator", { languageServerAvailable: true }), @@ -171,7 +180,8 @@ describe("advertisedTools", () => { 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..b2db52a18 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 @@ -67,8 +77,9 @@ export function advertisedToolNamesForSessionMode( // Built-in file/search 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. export const CATALOG_TOOL_NAMES: readonly string[] = [ - "write_file", "search_files", "grep", "list_dir", diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 50b37631c..2f8b292bf 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 { @@ -338,7 +338,13 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise !primaryDenied.has(tool.definition.name)); + + const dynamicRunner = createDynamicToolRunner(primaryTools, toolWatchdog); runnerRef = dynamicRunner; const connectedClients: MCPClient[] = []; diff --git a/src/prompts.test.ts b/src/prompts.test.ts index bbd9bc574..ecf317b0b 100644 --- a/src/prompts.test.ts +++ b/src/prompts.test.ts @@ -33,20 +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 as Skywalker orchestrator", () => { +test("agent identity is Skywalker orchestrator", () => { const orchestrator = buildChatRole("orchestrator"); + expect(orchestrator).toContain("You are Skywalker"); expect(orchestrator).toContain("Corbits Code"); - expect(orchestrator).toContain("SkywalkerDirector"); + 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("SkywalkerDirector"); + 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("not mounted on the primary Skywalker session"); expect(facts).toContain("blocked"); expect(facts).toContain("15s timeout"); expect(facts).toContain("find, rg, and grep -r"); @@ -170,6 +172,8 @@ test("when an env is supplied, the prompt ends with a live 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", @@ -181,6 +185,8 @@ test("when an env is supplied, the prompt ends with a live block instead", expect(prompt).toContain(""); 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:"); @@ -190,16 +196,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, }); 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/subagent/task-tool.ts b/src/subagent/task-tool.ts index e0a619eae..cbacdbcfd 100644 --- a/src/subagent/task-tool.ts +++ b/src/subagent/task-tool.ts @@ -19,6 +19,11 @@ import { 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, @@ -255,6 +260,7 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { 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; @@ -325,8 +331,9 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { return taskToolResult(call.id, `Error: ${resolved.error} ${resolved.hint}`); } const pkg = resolved.package; + resolvedPackage = pkg; resolvedDirectorId = pkg.id; - systemPromptRole = pkg.systemPrompt; + systemPromptRole = formatDirectorSystemPrompt(pkg); const caps = packageToCapabilities(pkg); if (caps !== undefined) capabilities = caps; if (pkg.writePaths !== undefined && pkg.writePaths.length > 0) { @@ -417,8 +424,9 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { return taskToolResult(call.id, `Error: ${resolved.error} ${resolved.hint}`); } const pkg = resolved.package; + resolvedPackage = pkg; resolvedDirectorId = pkg.id; - systemPromptRole = pkg.systemPrompt; + systemPromptRole = formatDirectorSystemPrompt(pkg); const caps = packageToCapabilities(pkg); if (caps !== undefined) capabilities = caps; if (pkg.writePaths !== undefined && pkg.writePaths.length > 0) { @@ -454,13 +462,18 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { } } - // 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/leaf > 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), 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/tui/agent-tools.test.ts b/tests/unit/tui/agent-tools.test.ts index c7439063e..20c7cc999 100644 --- a/tests/unit/tui/agent-tools.test.ts +++ b/tests/unit/tui/agent-tools.test.ts @@ -177,8 +177,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 () => { From ba9b98dc24d89f7188227da110b07ae150dcc72d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 19:32:06 -0700 Subject: [PATCH 10/59] Move orchestration chrome above the prompt and fix primary write-tool honesty Stack agents and tasks under the transcript so fleet work sits next to the prompt, and stop telling Skywalker that product write tools are how files change when those tools are unmounted. --- docs/ARCHITECTURE.md | 6 +++--- docs/IMPLEMENTATION.md | 4 ++-- docs/PRODUCT.md | 2 +- docs/TUI.md | 13 +++++++------ src/agent/directors/critique/package.test.ts | 3 ++- src/agent/directors/critique/package.ts | 2 +- src/agent/directors/identity.ts | 4 ++-- src/agent/directors/implement/package.ts | 2 +- src/agent/directors/skywalker/package.ts | 3 ++- src/agent/directors/tool-sets.ts | 15 ++++++--------- src/agent/prompts.ts | 16 +++++++++++----- src/prompts.test.ts | 8 +++++++- src/tui/geometry.test.ts | 20 ++++++++++++++++++++ src/tui/geometry/zones.ts | 7 ++++--- src/tui/shell.ts | 10 ++++++---- 15 files changed, 75 insertions(+), 40 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8f16c273b..c792e6f85 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -289,7 +289,7 @@ Every shipped specialist is a **director package** — a prompt-first `DirectorP **Typical chain:** bruckheimer → plan → greybeard → implement (+ intern) → critique (+ optional neckbeard), with skywalker coordinating throughout. -**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`. +**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`, 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`. **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. @@ -299,12 +299,12 @@ Data-only agent plugins (`src/plugins/data-only-agent.ts`) synthesize `agentPlug ### System Prompt (`src/agent/prompts.ts`) -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). 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: +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` — 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): dedicated tools over shell (`read_file`/`edit_file`/`write_file` on leaves, 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). +- `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`. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 2d9ce2bf5..0d56427dd 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -158,9 +158,9 @@ Sixteen packages under `src/agent/directors//` register in `DIRECTOR_REGISTR 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. Primary omits the list so plugin profiles stay reachable. 4. `directorProfiles()` is the default profile catalog (`default-agents.ts`); 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. +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; leaf `writePaths` only gate path-keyed product tools. 6. Leaf `writePaths` (shakespeare docs trio, brand-reviewer `DESIGN.md`, bruckheimer PRODUCT + docs/*) are enforced in the permission gate via ALS identity (`identity-context.ts` + `write-path-policy.ts`). -7. Spawn effort: pin > package `modelRole` default (`defaultEffortForDirector`; intern=low) > orchestrator/leaf binary > parent inheritance. Optional skills are listed in the identity header (model loads via `use_skill`); not auto-injected as full skill bodies. +7. Spawn effort: pin > package `modelRole` default (`defaultEffortForDirector`; intern=low; plan/review/orchestrator=high; implement/explore/docs/test=medium) > orchestrator/leaf binary > parent inheritance. Optional skills are listed in the identity header for awareness; leaves 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 leaves no `task`. Live `` injects cwd, platform, arch, runtime, date, and git status on every chat and leaf prompt. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 4fa330ec8..f3cb628ee 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -135,7 +135,7 @@ Capabilities beyond the core toolset are opt-in plugins, enabled per workspace t ## Multi-agent (sub-agents) -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 are not mounted on the primary session — implement/docs leaves own durable writes. +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/docs leaves 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, and package `writePaths` only constrains path-keyed product tools (not shell). Yolo / skip-permissions still bypasses the write-path gate when enabled. | Lane | Directors | |---|---| diff --git a/docs/TUI.md b/docs/TUI.md index 79eb5afe5..800560fc1 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -133,11 +133,11 @@ 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 +The `task` chrome zone renders a standing panel in the bottom chrome above the +prompt, one row per task the task tool has written (`manage_tasks`) — distinct +from the `agents` panel above 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`). @@ -185,7 +185,8 @@ 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 +The `agents` chrome zone renders a standing panel in the bottom chrome above +the prompt (above the task list when both are live), 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 diff --git a/src/agent/directors/critique/package.test.ts b/src/agent/directors/critique/package.test.ts index f653565ee..95c6dc156 100644 --- a/src/agent/directors/critique/package.test.ts +++ b/src/agent/directors/critique/package.test.ts @@ -28,7 +28,8 @@ describe("critiquePackage", () => { test("tools.allow is review surface without product writes", () => { const allow = critiquePackage.tools?.allow ?? []; expect(allow).toContain("read_file"); - expect(allow).toContain("use_skill"); + 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"); diff --git a/src/agent/directors/critique/package.ts b/src/agent/directors/critique/package.ts index 80b1eaadd..82b6f734f 100644 --- a/src/agent/directors/critique/package.ts +++ b/src/agent/directors/critique/package.ts @@ -26,7 +26,7 @@ export const critiquePackage: DirectorPackage = { 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: use_skill("style"); use_skill("philosophy"). Read the code under review; do not invent defects from vibes. +Before substantial review work: follow style and philosophy conventions (baked; use_skill is not mounted on leaves). 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). diff --git a/src/agent/directors/identity.ts b/src/agent/directors/identity.ts index 14ef38cbb..7a06e60bc 100644 --- a/src/agent/directors/identity.ts +++ b/src/agent/directors/identity.ts @@ -12,8 +12,8 @@ export function formatDirectorSystemPrompt(pkg: DirectorPackage): string { pkg.optionalSkills === undefined ? null : pkg.optionalSkills.length === 0 - ? "Optional skills: none by default (do not load skills unless the brief requires)." - : `Optional skills (load via use_skill when the job needs them): ${pkg.optionalSkills.join(", ")}.`; + ? "Optional skills: none by default." + : `Optional skills (names for awareness; guidance is baked into this prompt — use_skill is not mounted on leaves): ${pkg.optionalSkills.join(", ")}.`; const header = [ `Identity: agent id \`${pkg.id}\` — spawn as task(agent="${pkg.id}").`, `Model role: ${pkg.modelRole}.`, diff --git a/src/agent/directors/implement/package.ts b/src/agent/directors/implement/package.ts index 7d0702aee..262dc07ba 100644 --- a/src/agent/directors/implement/package.ts +++ b/src/agent/directors/implement/package.ts @@ -23,7 +23,7 @@ export const implementPackage: DirectorPackage = { 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: use_skill("style"); use_skill("philosophy"). +Before substantial repo work: follow style and philosophy conventions (baked; use_skill is not mounted on leaves). Follow AGENTS.md and /docs. Touch only what the brief requires. Prefer typed success_criteria from the brief as your done gate. Stop when success_criteria are met — do not invent architecture or expand the brief. diff --git a/src/agent/directors/skywalker/package.ts b/src/agent/directors/skywalker/package.ts index bc82f43c6..5c62d9c41 100644 --- a/src/agent/directors/skywalker/package.ts +++ b/src/agent/directors/skywalker/package.ts @@ -67,7 +67,8 @@ Clear and short. No dispatch for pure questions. - Use plan leaf 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: dispatch, style, philosophy, interview (use_skill). +- Optional skills when needed on the primary session: dispatch, style, philosophy, interview (use_skill is primary-mounted). + # Spawn graph diff --git a/src/agent/directors/tool-sets.ts b/src/agent/directors/tool-sets.ts index c6022ea5b..49e46d1f6 100644 --- a/src/agent/directors/tool-sets.ts +++ b/src/agent/directors/tool-sets.ts @@ -1,6 +1,8 @@ // 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 = [ @@ -14,13 +16,12 @@ export const READ_TOOLS = [ "web_search", ] as const; -/** Implement: read + full file mutation + skills. */ +/** Implement: read + full file mutation. */ export const IMPLEMENT_TOOLS = [ ...READ_TOOLS, "write_file", "edit_file", "delete_file", - "use_skill", ] as const; /** Docs leaves that may write only under writePaths authz. */ @@ -28,21 +29,17 @@ export const DOCS_TOOLS = [ ...READ_TOOLS, "write_file", "edit_file", - "use_skill", ] as const; -/** Review / counsel: read + skills, no writes. */ -export const REVIEW_TOOLS = [...READ_TOOLS, "use_skill"] 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; -/** Orchestrator (Skywalker / greybeard spawn path): dispatch, no product writes. */ +/** Nested orchestrator surface (greybeard / package filter): dispatch only. */ export const ORCHESTRATOR_TOOLS = [ ...READ_TOOLS, - "use_skill", - "tool_search", "search_agents", "task", - "ask_operator", ] as const; diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index 195ea62db..71f1f3b3a 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -59,11 +59,13 @@ export function buildHarnessFacts( const subAgent = opts.subAgent ?? false; 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/docs) 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.", @@ -154,12 +156,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:", @@ -395,7 +401,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/prompts.test.ts b/src/prompts.test.ts index ecf317b0b..3dab6fcdc 100644 --- a/src/prompts.test.ts +++ b/src/prompts.test.ts @@ -47,7 +47,7 @@ test("agent identity is Skywalker orchestrator", () => { 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"); @@ -65,6 +65,12 @@ test("harness facts state only the non-derivable tool and safety rules", () => { 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("guidelines cover response style, tool choice, ask vs proceed, and scope", () => { const guidelines = buildGuidelines(); expect(guidelines).toContain("Response style:"); diff --git a/src/tui/geometry.test.ts b/src/tui/geometry.test.ts index 0c6e0f8d8..f17638405 100644 --- a/src/tui/geometry.test.ts +++ b/src/tui/geometry.test.ts @@ -204,6 +204,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: 2 }, + }); + 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 }, diff --git a/src/tui/geometry/zones.ts b/src/tui/geometry/zones.ts index 030de6f23..508ef5f88 100644 --- a/src/tui/geometry/zones.ts +++ b/src/tui/geometry/zones.ts @@ -208,13 +208,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/shell.ts b/src/tui/shell.ts index b160de37f..e9bbd8d29 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -1779,7 +1779,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 @@ -4466,7 +4468,7 @@ function renderAgentsRows( destroySubtree(child) } for (const row of rows) { - // Green for working, not the task zone's bronze immediately above it — + // Green for working, not the task zone's bronze immediately below 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. @@ -5445,10 +5447,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) From ce6ee8be6706569043357ddaa43ab29cf1ccc01f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 19:37:55 -0700 Subject: [PATCH 11/59] Stop painting the same fleet work three times Transcript fleet notices drop per-lane done walls; chrome shows either the board or the checklist, not both; done-only task lists collapse. --- docs/TUI.md | 64 ++++++++++++++----------------- src/subagent/fleet-report.test.ts | 50 +++++++++--------------- src/subagent/fleet-report.ts | 58 ++++++++++++---------------- src/tui/chrome-state.test.ts | 26 +++++-------- src/tui/chrome-state.ts | 31 ++++++++++----- 5 files changed, 103 insertions(+), 126 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 800560fc1..4945291dc 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -134,17 +134,22 @@ 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 in the bottom chrome above the -prompt, one row per task the task tool has written (`manage_tasks`) — distinct -from the `agents` panel above 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 +prompt, one row per open task the task tool has written (`manage_tasks`) — +distinct from the `agents` panel. 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: `formatTasksPanel` (`src/tui/chrome-state.ts`) and `formatAgentsPanel` are separate formatters feeding separate zones with separate row types (`TaskPanelRow` vs. `AgentPanelRow`). +**One live surface at a time (CL-5846).** While any fleet lane is painted on +the agents board, `formatChromeZones` suppresses the task checklist — the same +work must not stand in two chrome lists. When the board is empty, the checklist +returns for open work. A list that is only done/cancelled collapses to null +(no permanent wall of `[x]` rows); while open work remains, recently-done rows +trail so the operator can see items flip complete without a second status log. + 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 +`[-]` cancelled) ahead of the title. Open work is listed first. 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 @@ -282,36 +287,23 @@ same last-resort floor every other optional zone shares. ### 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. +The agents panel is the standing picture of live work. Parent prose owns +success narratives. Transcript fleet notices exist only for attention the +board cannot keep (CL-5846): a lane **failed** or **stalled** while other work +is still running, and **one** dry-fleet line when the last lane finishes +(`fleet · N done — nothing running`). Per-lane `done — summary` walls and +live `dispatched` re-announcements are never printed — they restated the +board and the parent and turned the transcript into a second status log. + +`src/subagent/fleet-report.ts` is pure: it reads the same sub-agent session +store the panel reads and the same `agentProgress()` stall definition so the +two surfaces never disagree about whether a lane is stalled. 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 diff --git a/src/subagent/fleet-report.test.ts b/src/subagent/fleet-report.test.ts index eb674d12b..9d1742f55 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,17 +62,21 @@ 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(["fleet · 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"); @@ -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) => diff --git a/src/subagent/fleet-report.ts b/src/subagent/fleet-report.ts index 0e8fa99f7..9e09cf71a 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, and the moment the fleet runs dry. - * Live dispatches stay on the fleet board — they are not re-announced here. + * Live lanes already paint on the fleet board. Parent prose already narrates + * phase plans. This module only emits transcript lines for attention the board + * cannot keep: a lane failed or stalled, and the single moment the fleet runs + * dry. Per-lane "done — summary" walls are intentionally never printed — they + * restate the board 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. @@ -102,11 +102,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 +149,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", @@ -175,32 +170,29 @@ export function observeFleet( } const watch: FleetWatch = { lanes: marks, running, seeded: true }; + 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(`${PREFIX} · ${idleSummary(lanes)} — nothing running`, MAX_UPDATE_CHARS), + ], + }; + } - // Live dispatches already appear on the fleet board (CL-5846). Transcript - // notices only for terminal / stall transitions — not "dispatched X". - const announced = changes.filter((c) => c.kind !== "dispatched"); - if (announced.length === 0 && !(running === 0 && previous.running > 0)) { + const attention = changes.filter((c) => c.kind === "failed" || c.kind === "stalled"); + if (attention.length === 0) { return { watch, updates: [] }; } const lines: string[] = - announced.length > COALESCE_ABOVE - ? [tally(announced)] - : announced.map((c) => c.line); - - // 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); - } - } + attention.length > COALESCE_ABOVE + ? [tally(attention)] + : attention.map((c) => c.line); return { watch, diff --git a/src/tui/chrome-state.test.ts b/src/tui/chrome-state.test.ts index da862e19b..41951e88c 100644 --- a/src/tui/chrome-state.test.ts +++ b/src/tui/chrome-state.test.ts @@ -38,7 +38,7 @@ describe("formatChromeZones", () => { expect(out.agents).toBeNull() }) - test("full state formats both zones", () => { + test("full state: fleet board wins — checklist suppressed while lanes run", () => { const state: ChromeLiveState = { task: [ { title: "chrome live helper", status: "doing" }, @@ -64,11 +64,8 @@ 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" }, - ]) + // One live surface (CL-5846): board owns the chrome while lanes run. + expect(out.task).toBeNull() // Hybrid: board FLEET header + kind; tail is `state · agentProgress.stat` // (elapsed · tool), not the branch's tool-first wording. expect(out.agents).toEqual([ @@ -116,7 +113,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 +121,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", () => { @@ -359,10 +353,8 @@ describe("chromeFromSession", () => { ]) const zones = formatChromeZones(state, NOW) - expect(zones.task).toEqual([ - { label: "wire catalogs", status: "doing" }, - { label: "export index", status: "todo" }, - ]) + // Fleet board owns chrome while lanes run; checklist is suppressed (CL-5846). + expect(zones.task).toBeNull() 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" }, diff --git a/src/tui/chrome-state.ts b/src/tui/chrome-state.ts index 415c4e250..5f0bd8f37 100644 --- a/src/tui/chrome-state.ts +++ b/src/tui/chrome-state.ts @@ -131,15 +131,19 @@ export type FormattedChromeZones = { * * Empty / partial / inactive inputs yield null for the corresponding zone * so geometry collapses that strip (idleDefault 0). + * + * When a live fleet board is up, the manage_tasks checklist is suppressed — + * two standing lists of the same work is the visual mess CL-5846 removes. + * Checklist returns once no lane is running. */ export function formatChromeZones( state: ChromeLiveState, nowMs: number = Date.now(), ): FormattedChromeZones { - return { - task: formatTasksPanel(state.task), - agents: formatAgentsPanel(state.agents, state.observe, nowMs), - } + const agents = formatAgentsPanel(state.agents, state.observe, nowMs) + // One live work surface: fleet board while lanes run; checklist otherwise. + const task = agents !== null ? null : formatTasksPanel(state.task) + return { task, agents } } /** @@ -156,9 +160,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 +175,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 } From bca77a2a3ad59659cdd5a91dfb59325dc767397c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 21:45:56 -0700 Subject: [PATCH 12/59] Tighten docs writePaths and drop dead fleet task tracking Docs directors no longer mount run_shell so writePaths cannot be bypassed via shell. Bare allowlist names match only the workspace root file. Remove unused taskCallIds machinery from the runtime bridge. --- src/agent/directors/tool-sets.test.ts | 36 +++++++++++++++ src/agent/directors/tool-sets.ts | 15 ++++++- src/permission/write-path-policy.test.ts | 8 +++- src/permission/write-path-policy.ts | 27 +++++++----- src/tui/runtime-bridge.ts | 56 ++---------------------- 5 files changed, 75 insertions(+), 67 deletions(-) create mode 100644 src/agent/directors/tool-sets.test.ts diff --git a/src/agent/directors/tool-sets.test.ts b/src/agent/directors/tool-sets.test.ts new file mode 100644 index 000000000..479e80b4b --- /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 (writePaths gate only locks file writes)", () => { + 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 index 49e46d1f6..40ed58e90 100644 --- a/src/agent/directors/tool-sets.ts +++ b/src/agent/directors/tool-sets.ts @@ -24,9 +24,20 @@ export const IMPLEMENT_TOOLS = [ "delete_file", ] as const; -/** Docs leaves that may write only under writePaths authz. */ +/** + * Docs leaves that may write only under writePaths authz. + * Read/search/lsp/web + file writes — no run_shell. The writePaths gate only + * locks write_file/edit_file/delete_file, so a shell here would bypass it; + * docs leaves that need a shell mount IMPLEMENT_TOOLS instead. + */ export const DOCS_TOOLS = [ - ...READ_TOOLS, + "read_file", + "grep", + "search_files", + "list_dir", + "lsp", + "web_fetch", + "web_search", "write_file", "edit_file", ] as const; diff --git a/src/permission/write-path-policy.test.ts b/src/permission/write-path-policy.test.ts index 99ff5942c..fffb7f7bb 100644 --- a/src/permission/write-path-policy.test.ts +++ b/src/permission/write-path-policy.test.ts @@ -8,9 +8,13 @@ import { const cwd = resolve("/tmp/write-path-policy-fixture"); describe("matchesWritePathAllowlist", () => { - test("bare filename matches any depth under cwd", () => { + test("bare filename matches only the workspace-root file", () => { expect(matchesWritePathAllowlist("PRODUCT.md", ["PRODUCT.md"], cwd)).toBe(true); - expect(matchesWritePathAllowlist("docs/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); }); diff --git a/src/permission/write-path-policy.ts b/src/permission/write-path-policy.ts index ad064f61b..449f14c8d 100644 --- a/src/permission/write-path-policy.ts +++ b/src/permission/write-path-policy.ts @@ -1,4 +1,4 @@ -import { basename, relative, resolve, sep } from "node:path"; +import { relative, resolve, sep } from "node:path"; import { matchesPattern } from "./matcher.js"; /** @@ -8,9 +8,13 @@ import { matchesPattern } from "./matcher.js"; * gate; skipPermissions (yolo) bypasses the whole gate before this runs. * * Patterns: - * - bare filename (`PRODUCT.md`) matches that basename at any depth under cwd - * - relative globs (`docs/*`, `DESIGN.md`) use matchesPattern against the - * workspace-relative path + * - 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/*`, double-star-then-PRODUCT.md) use matchesPattern + * against the workspace-relative path; use a double-star prefix to match a + * basename at any depth. */ export function matchesWritePathAllowlist( subject: string, @@ -36,15 +40,18 @@ export function matchesWritePathAllowlist( } } - const base = basename(rel); 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; if (matchesPattern(subject, pattern)) return true; - if (matchesPattern(base, pattern)) return true; - // Bare filename: match any depth with that exact basename. - if (!pattern.includes("/") && !pattern.includes("*") && !pattern.includes("?") && base === pattern) { - return true; - } } return false; } diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index dd601f5a8..0df3638cc 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -72,7 +72,6 @@ import { import type { StreamRow } from "./stream.js" import { advanceRevealChars, flattenReasoningText, type Thought } from "./thinking.js" import { - agentProgress, fleetProgress, type AgentProgressSession, } from "./agent-progress.js" @@ -369,12 +368,6 @@ type BridgeBag = { toolRows: Map /** Row of the newest in-flight call, for results that carry no call id. */ lastToolRow: number - /** - * callIds of outstanding `task` calls — a subset of `toolRows`' keys. Kept - * separate so `syncAgentProgress` never has to walk every in-flight tool to - * find the handful that are sub-agent dispatches. - */ - taskCallIds: Set /** * Last sub-agent session list the host synced. Retained rather than consumed * and dropped because the status ticker recomputes fleet state at paint time @@ -598,7 +591,6 @@ function applyToolResult( event.callId !== undefined ? bag.toolRows.get(event.callId) : undefined if (event.callId !== undefined) { bag.toolRows.delete(event.callId) - bag.taskCallIds.delete(event.callId) } const index = tracked ?? bag.lastToolRow const call = streamRowAt(shell, index) @@ -609,48 +601,6 @@ function applyToolResult( replaceStreamRowAt(shell, index, mergeToolRows(call, result)) } -/** - * 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. - */ -function syncAgentProgress( - shell: AppShell, - bag: BridgeBag, - sessions: readonly TaskProgressSession[], - nowMs: number, -): void { - if (bag.taskCallIds.size === 0) return - for (const callId of bag.taskCallIds) { - const index = bag.toolRows.get(callId) - if (index === undefined) { - bag.taskCallIds.delete(callId) - continue - } - const row = streamRowAt(shell, index) - if (row === undefined || row.pending !== true) { - bag.taskCallIds.delete(callId) - continue - } - const session = sessions.find((s) => s.id === callId) - if (session === undefined) continue - const progress = agentProgress(session, nowMs) - if (progress === null) continue - if (row.stat === progress.stat && row.agentWorking === progress.working) continue - replaceStreamRowAt(shell, index, { - ...row, - stat: progress.stat, - agentWorking: progress.working, - }) - } -} - /** * Retract everything the failed attempt painted, then forget the row * bookkeeping that pointed into it — a rolled-back tool call has no row left @@ -664,7 +614,6 @@ function rollbackAttempt(shell: AppShell, bag: BridgeBag): void { for (const [callId, index] of [...bag.toolRows]) { if (index >= boundary) { bag.toolRows.delete(callId) - bag.taskCallIds.delete(callId) } } if (bag.lastToolRow >= boundary) bag.lastToolRow = -1 @@ -786,7 +735,6 @@ export function attachSessionBridge( now, toolRows: new Map(), lastToolRow: -1, - taskCallIds: new Set(), agentSessions: [], panelOnlyCallIds: new Set(), attemptRow: null, @@ -1178,8 +1126,10 @@ export function attachSessionBridge( }, syncAgentProgress: (sessions) => { if (bag.disposed) return + // Live task state is owned by the fleet board, which recomputes from this + // session list at paint time. Task calls paint no transcript rows (they + // are panel-owned), so there is no per-call row to refresh here. bag.agentSessions = sessions - syncAgentProgress(shell, bag, sessions, now()) }, dispose: () => { bag.disposed = true From 24d9a8d8b840b75501e52ed5370de4e4284e02e3 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 21:55:39 -0700 Subject: [PATCH 13/59] Close write-path allowlist escape holes The write-path allowlist matched the raw subject in addition to the resolved workspace-relative path, so a glob like docs/* could accept docs/../src/hack.ts (the subject string starts with docs/ even though the resolved path escapes to src/). It also fell through to pattern matching for paths resolving outside cwd. Resolve the subject against cwd first and hard-deny anything outside cwd without ever matching the raw subject. Globs now match only the workspace-relative path, and bare names still compare root-only. Also compose DOCS_TOOLS from READ_TOOLS minus run_shell so it tracks the read surface instead of duplicating it. Add tests for traversal escape, outside-cwd absolute paths, and the existing positive cases. --- src/agent/directors/tool-sets.ts | 11 ++++------- src/permission/write-path-policy.test.ts | 24 +++++++++++++++++++++++ src/permission/write-path-policy.ts | 25 ++++++++++++------------ 3 files changed, 41 insertions(+), 19 deletions(-) diff --git a/src/agent/directors/tool-sets.ts b/src/agent/directors/tool-sets.ts index 40ed58e90..5e81e9b3a 100644 --- a/src/agent/directors/tool-sets.ts +++ b/src/agent/directors/tool-sets.ts @@ -29,15 +29,12 @@ export const IMPLEMENT_TOOLS = [ * Read/search/lsp/web + file writes — no run_shell. The writePaths gate only * locks write_file/edit_file/delete_file, so a shell here would bypass it; * docs leaves that need a shell mount IMPLEMENT_TOOLS instead. + * + * 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_file", - "grep", - "search_files", - "list_dir", - "lsp", - "web_fetch", - "web_search", + ...READ_TOOLS.filter((t) => t !== "run_shell"), "write_file", "edit_file", ] as const; diff --git a/src/permission/write-path-policy.test.ts b/src/permission/write-path-policy.test.ts index fffb7f7bb..ca0f14064 100644 --- a/src/permission/write-path-policy.test.ts +++ b/src/permission/write-path-policy.test.ts @@ -32,6 +32,30 @@ describe("matchesWritePathAllowlist", () => { 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", () => { diff --git a/src/permission/write-path-policy.ts b/src/permission/write-path-policy.ts index 449f14c8d..6c6edee01 100644 --- a/src/permission/write-path-policy.ts +++ b/src/permission/write-path-policy.ts @@ -1,4 +1,4 @@ -import { relative, resolve, sep } from "node:path"; +import { resolve, sep } from "node:path"; import { matchesPattern } from "./matcher.js"; /** @@ -7,14 +7,19 @@ import { matchesPattern } from "./matcher.js"; * 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/*`, double-star-then-PRODUCT.md) use matchesPattern - * against the workspace-relative path; use a double-star prefix to match a - * basename at any depth. + * - 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, @@ -26,18 +31,15 @@ export function matchesWritePathAllowlist( const absCwd = resolve(cwd); const abs = resolve(cwd, subject); - let rel = subject; + let rel: string; if (abs === absCwd) { rel = "."; } else if (abs.startsWith(absCwd + sep)) { rel = abs.slice(absCwd.length + 1); } else { - // Outside cwd — still try pattern match on the raw subject / relative form. - try { - rel = relative(absCwd, abs); - } catch { - rel = subject; - } + // 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) { @@ -51,7 +53,6 @@ export function matchesWritePathAllowlist( continue; } if (matchesPattern(rel, pattern)) return true; - if (matchesPattern(subject, pattern)) return true; } return false; } From 4252aba86aa71cba55ded14e158d8044b92af769 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 22:50:02 -0700 Subject: [PATCH 14/59] Fix writePaths doc and add registry run_shell guard test --- src/agent/directors/registry.test.ts | 9 +++++++++ src/agent/directors/types.ts | 8 +++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/agent/directors/registry.test.ts b/src/agent/directors/registry.test.ts index a47b958f4..37b11383a 100644 --- a/src/agent/directors/registry.test.ts +++ b/src/agent/directors/registry.test.ts @@ -185,6 +185,15 @@ describe("director registry", () => { 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]); diff --git a/src/agent/directors/types.ts b/src/agent/directors/types.ts index 9127f9191..bdf4e6e12 100644 --- a/src/agent/directors/types.ts +++ b/src/agent/directors/types.ts @@ -70,9 +70,11 @@ export type DirectorPackage = { readonly tools?: ToolEnvelope; /** * Authz write-path allowlist for write_file/edit_file/delete_file. - * Enforced by the permission gate (not prompt policy). Bare filenames match - * that basename at any depth under the worker cwd. Omitted = no path lock - * (tool allow/deny alone decides whether writes exist). + * 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; From f977b0f60e78feeb96dc8bdece138e7a182f8f13 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 10 Aug 2026 02:08:55 -0700 Subject: [PATCH 15/59] Re-vendor Interchange types at ad0f99e7 Bumps the vendor pin table to ad0f99e7977b3ad4f28d8cc8d446ac52a4a2d685 (2026-08-10) and pulls the verbatim @intx/types package forward. Also fixes a storage-isogit package-name typo in the pin table. --- docs/VENDORING.md | 6 +- vendor/intx-types/src/authz.ts | 4 + vendor/intx-types/src/credential-cipher.ts | 42 ++++++++ vendor/intx-types/src/credentials.test.ts | 49 +++++++++ vendor/intx-types/src/credentials.ts | 34 ++++++ vendor/intx-types/src/grants.test.ts | 64 +++++++++-- vendor/intx-types/src/grants.ts | 13 ++- vendor/intx-types/src/index.ts | 4 + vendor/intx-types/src/mediated-credential.ts | 102 ++++++++++++++++++ vendor/intx-types/src/message-id.ts | 8 +- vendor/intx-types/src/package-json.test.ts | 76 +++++++++++++ vendor/intx-types/src/package-json.ts | 46 +++++++- vendor/intx-types/src/providers.ts | 6 ++ vendor/intx-types/src/runtime-capabilities.ts | 50 +++++++++ vendor/intx-types/src/sidecar-allocation.ts | 32 ++++++ vendor/intx-types/src/sidecar-placement.ts | 12 +++ vendor/intx-types/src/sidecar.test.ts | 53 +++++++++ vendor/intx-types/src/sidecar.ts | 61 ++++++++++- vendor/intx-types/src/tenants.ts | 12 ++- vendor/intx-types/src/tool-packages.ts | 46 +++++++- vendor/intx-types/src/workflow-run-id.ts | 13 +-- 21 files changed, 702 insertions(+), 31 deletions(-) create mode 100644 vendor/intx-types/src/credential-cipher.ts create mode 100644 vendor/intx-types/src/mediated-credential.ts create mode 100644 vendor/intx-types/src/package-json.test.ts create mode 100644 vendor/intx-types/src/sidecar-allocation.ts create mode 100644 vendor/intx-types/src/sidecar-placement.ts 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/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:` 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; + decrypt(blob: string, aad: string): Promise; +} + +/** + * 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 | 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; +} + +/** + * 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; +} + +/** + * 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; +} 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(); + 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, +): RuntimeCapabilities { + const snapshot: Partial = { ...overrides }; + + return { + resolve(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//` 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", + "config?": TenantConfig, }); export const TenantResponse = type({ @@ -17,7 +25,7 @@ export const TenantResponse = type({ slug: "string", domain: "string", "parentId?": "string | null", - "config?": "Record", + "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(); + 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_@`). The +// A workflow deployment has ONE addressable top-level run, whose stable runId +// is the deployment's mail address (`ins_@`). 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. From abd70505de4205579c05bf2792f67fc78d4a1f50 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 10 Aug 2026 02:09:05 -0700 Subject: [PATCH 16/59] Re-apply local inference patches at ad0f99e7 Carries the 17 locally-patched markers forward onto ad0f99e7 (startupDeliveries, ADAPTIVE_THINKING_MODELS, deps context-transforms forwarding, stream-terminal detector, abort-reason classification, getTurnsRevision, snapshot memoization, etc.). See vendor/intx-inference/PATCHES.md. --- vendor/intx-inference/README.md | 10 +- .../intx-inference/src/providers/anthropic.ts | 11 +- vendor/intx-inference/src/providers/index.ts | 6 +- vendor/intx-inference/src/reactor.test.ts | 117 ++++++++++++++++++ vendor/intx-inference/src/reactor.ts | 33 ++++- 5 files changed, 162 insertions(+), 15 deletions(-) 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 = 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 = 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(); + const { reactor, waitFor } = createTestReactor({ + director: directorFromTable({ + "message.received": (_e, _s, caps) => caps.infer(), + }), + inferenceRunner: async function* (opts) { + inferenceStarted.resolve(true); + await new Promise((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(); + const loadStarted = Promise.withResolvers(); + 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 }); } From d18ea5c8b8206e8bc6a52e1681cdc115fc516f46 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 10 Aug 2026 12:57:51 -0700 Subject: [PATCH 17/59] Paint the fleet board as a dual-column rail beside chat Wide terminals (>=100 cols) put live workers on a right rail so the transcript stays readable; narrow width still stacks. Document the layout and keep geometry/chrome tests covering dual vs stack. --- docs/TUI.md | 116 +++++++++++++--------- src/subagent/fleet-report.test.ts | 14 +-- src/subagent/fleet-report.ts | 37 ++++--- src/tui/agent-progress.test.ts | 6 +- src/tui/agent-progress.ts | 12 ++- src/tui/chrome-state.test.ts | 114 +++++++++++++--------- src/tui/chrome-state.ts | 73 +++++--------- src/tui/commands/built-in.test.ts | 4 +- src/tui/geometry.test.ts | 143 ++++++++++++++++++++++++--- src/tui/geometry/index.ts | 6 ++ src/tui/geometry/resolve.ts | 157 +++++++++++++++++++++++++++--- src/tui/geometry/zones.ts | 30 +++++- src/tui/keybindings.test.ts | 7 +- src/tui/keybindings.ts | 2 +- src/tui/landing.test.ts | 5 + src/tui/notice-line.test.ts | 2 +- src/tui/notice-line.ts | 5 +- src/tui/product-host.ts | 22 +---- src/tui/runner-host.test.ts | 6 +- src/tui/runtime-channels.test.ts | 40 +++++--- src/tui/session-chrome.test.ts | 25 ++--- src/tui/session-chrome.ts | 18 ++-- src/tui/shell.test.ts | 6 +- src/tui/shell.ts | 79 ++++++++++++--- src/tui/wave6.test.ts | 72 ++++++++++++-- 25 files changed, 704 insertions(+), 297 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 4945291dc..5bca8941b 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -38,12 +38,25 @@ 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 — **except** the +fleet rail. When the terminal is at least `DUAL_MIN_COLUMNS` (100) wide +and the agents zone has rows, `resolveGeometry` switches to `layoutMode: +"dual"`: the transcript (chat) keeps the left column and the agents board +sits as a right rail of width clamped between `RAIL_WIDTH_MIN` (28) and +`RAIL_WIDTH_MAX` (48), targeting `RAIL_WIDTH_FRACTION` (0.38) of content +width with a one-column `RAIL_GUTTER` between them. Dual mode excludes +agents from the vertical chrome sum so the transcript residual is not +shrunk by the board — the rail shares the transcript's vertical residual +instead. Below the dual threshold, or with zero running agents, layout +stays `"stack"` (full-width y-stack, agents under the transcript, above +the task list and prompt). The shell paints dual by absolutely positioning +`agentsBox` beside the transcript (`applyLayout` in `src/tui/shell.ts`); +stack keeps it in the flex column. 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 @@ -73,7 +86,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 +112,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 @@ -168,41 +181,53 @@ 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 is **hidden by default** (CL-5847): a fresh shell does not paint the +checklist even when `manage_tasks` has open work. `toggleTasksPanel` (bound to +Alt+T) opts in for the shell's lifetime — it flips a hidden flag held on the +shell 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 +default is opt-in because the checklist's chrome owns too much of the screen to +force into view; the operator toggles it on when they want it, and the fleet +board's CL-5846 one-live-surface rules still apply once it is shown. 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 in the bottom chrome above -the prompt (above the task list when both are live), 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. +The `agents` chrome zone is the **fleet board**: a standing picture of live +workers. On a dual-width terminal it paints as the right rail beside chat; +on a narrow terminal it stacks under the transcript and above the task list +and prompt. `formatAgentsPanel` (`src/tui/chrome-state.ts`) always leads +with a one-line `FLEET` header (`N lanes · N working` / trouble counts +first), then one single-line row per currently-running sub-agent. The +marker is `●` for a live lane and `!` for one that has gone quiet, painted +in bronze (`UI.inFlight`) for live work and red (`UI.action`) for a stalled +one — the marker already names the state, the hue only carries the urgency. +Past the marker each row reads `agentId description` with a right-aligned +tail `· · `: the clock/tool/stall computation is +shared with the transcript task trailer (`agentProgress` in +`src/tui/agent-progress.ts`). The panel does not compute progress a second +way. Only a fan-out past `AGENTS_PANEL_MAX_VISIBLE` adds a trailing +`+N more` row (or a header `+N hidden` under a tight height), painted in +dim because it is chrome about the strip, not a lane in it. + +Tool names on the board come from the **subagent store** +(`currentToolName` + `currentToolStartedAt`), not from the +`subagent.progress` event channel. Progress pings carry a name with no +clock and can fire on completion, so painting from them produced false +stalls; the host paints zones straight from store-backed chrome state. +fleet header row: each row is one lane, led by a health marker that is what +makes the strip read as a strip of workers rather than a list of titles +(`formatAgentsPanel` in `src/tui/chrome-state.ts`). The marker is `●` for a +live lane and `!` for one that has gone quiet, painted in bronze +(`UI.inFlight`) for live work and red (`UI.action`) for a stalled one — the +marker already names the state, the hue only carries the urgency. Past the +marker each row reads `agentId: description · · `: a clock +tail from the same `agentProgress()` clock/tool/stall computation used to +trail a task row in the transcript (`src/tui/agent-progress.ts`), with no +state word prefix — the marker already carries it. The panel does not +compute progress a second way. Only a fan-out past `AGENTS_PANEL_MAX_VISIBLE` +adds a trailing `+N more` row, painted in dim because it is chrome about the +strip, not a lane in it. `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 @@ -289,11 +314,12 @@ same last-resort floor every other optional zone shares. The agents panel is the standing picture of live work. Parent prose owns success narratives. Transcript fleet notices exist only for attention the -board cannot keep (CL-5846): a lane **failed** or **stalled** while other work -is still running, and **one** dry-fleet line when the last lane finishes -(`fleet · N done — nothing running`). Per-lane `done — summary` walls and +strip cannot keep (CL-5846): a lane **failed** or **went quiet** 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 — they restated the -board and the parent and turned the transcript into a second status log. +strip and the parent and turned the transcript into a second status log. +A stall reads `desc went quiet (clock)`. `src/subagent/fleet-report.ts` is pure: it reads the same sub-agent session store the panel reads and the same `agentProgress()` stall definition so the @@ -553,7 +579,7 @@ 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/src/subagent/fleet-report.test.ts b/src/subagent/fleet-report.test.ts index 9d1742f55..0c82a4a02 100644 --- a/src/subagent/fleet-report.test.ts +++ b/src/subagent/fleet-report.test.ts @@ -62,7 +62,7 @@ describe("observeFleet", () => { [lane({ id: "api", status: "done", report: "done" }), lane({ id: "docs", status: "done" })], T0 + 1000, ); - expect(updates).toEqual(["fleet · 2 done — nothing running"]); + expect(updates).toEqual(["2 done · nothing running"]); }); test("a failure names what went wrong while the fleet is still live", () => { @@ -92,11 +92,11 @@ describe("observeFleet", () => { 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([]); }); @@ -120,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"]); }); }); @@ -135,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 9e09cf71a..de757d52b 100644 --- a/src/subagent/fleet-report.ts +++ b/src/subagent/fleet-report.ts @@ -1,11 +1,11 @@ /** * What the orchestrator says to the operator about the fleet, unprompted. * - * Live lanes already paint on the fleet board. Parent prose already narrates - * phase plans. This module only emits transcript lines for attention the board - * cannot keep: a lane failed or stalled, and the single moment the fleet runs + * 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 board and the parent and turn the transcript into a second + * 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 @@ -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 @@ -161,12 +159,10 @@ 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 }; @@ -179,7 +175,7 @@ export function observeFleet( return { watch, updates: [ - clip(`${PREFIX} · ${idleSummary(lanes)} — nothing running`, MAX_UPDATE_CHARS), + clip(`${idleSummary(lanes)} · nothing running`, MAX_UPDATE_CHARS), ], }; } @@ -196,7 +192,7 @@ export function observeFleet( return { watch, - updates: lines.map((line) => clip(`${PREFIX} · ${line}`, MAX_UPDATE_CHARS)), + updates: lines.map((line) => clip(line, MAX_UPDATE_CHARS)), }; } @@ -206,10 +202,10 @@ function tally(changes: readonly Change[]): string { const parts: string[] = []; 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`); + // stalled changes are not operator-facing; tally fails/done only + void count("stalled"); return parts.join(", "); } @@ -230,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; @@ -243,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); @@ -255,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/tui/agent-progress.test.ts b/src/tui/agent-progress.test.ts index 35175648f..aa5947067 100644 --- a/src/tui/agent-progress.test.ts +++ b/src/tui/agent-progress.test.ts @@ -83,7 +83,7 @@ describe("agentProgress", () => { 30_000, ) expect(progress).toEqual({ - stat: "0:31 · quiet 0:31", + stat: "0:31", state: "stalled", working: false, stalled: true, @@ -179,9 +179,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..3a1dbc22f 100644 --- a/src/tui/agent-progress.ts +++ b/src/tui/agent-progress.ts @@ -139,12 +139,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 +201,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 41951e88c..e7ae74615 100644 --- a/src/tui/chrome-state.test.ts +++ b/src/tui/chrome-state.test.ts @@ -64,15 +64,13 @@ describe("formatChromeZones", () => { ], } const out = formatChromeZones(state, NOW) - // One live surface (CL-5846): board owns the chrome while lanes run. + // One live surface (CL-5846): fleet board owns chrome while lanes run. expect(out.task).toBeNull() - // 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" }, { - label: "explore: map setChromeZones callers", - tail: " · working · 0:05 · grep", + label: "● explore map setChromeZones callers", + tail: " · 0:05 · grep", stalled: false, kind: "lane", }, @@ -168,8 +166,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" }, ]) }) @@ -186,7 +184,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( [ { @@ -201,16 +199,15 @@ describe("formatAgentsPanel", () => { 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: " · 1: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", () => { @@ -222,7 +219,8 @@ describe("formatAgentsPanel", () => { 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", () => { @@ -280,10 +278,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) => + 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", () => { @@ -306,11 +304,9 @@ describe("formatAgentsPanel", () => { lastActivityAt: NOW - 250_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) }) }) @@ -357,7 +353,12 @@ describe("chromeFromSession", () => { expect(zones.task).toBeNull() 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" }, + { + label: "● explore map callers", + tail: " · 0:05 · grep", + stalled: false, + kind: "lane", + }, ]) }) @@ -400,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() }) }) @@ -441,14 +438,12 @@ 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]?.label.startsWith("● ")).toBe(true) expect(rows?.[1]?.tail).toContain("run_shell 1:30") expect(rows?.[1]?.tail).not.toContain("stalled") @@ -484,10 +479,11 @@ 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] }, @@ -497,15 +493,41 @@ describe("lane state survives the mapping hops", () => { expect(annotated.agents?.[0]?.currentToolStartedAt).toBe(NOW - 90_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 … · " 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, + } + 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", () => { @@ -514,10 +536,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) @@ -535,8 +557,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 5f0bd8f37..8b231baad 100644 --- a/src/tui/chrome-state.ts +++ b/src/tui/chrome-state.ts @@ -95,9 +95,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 @@ -367,21 +367,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. @@ -417,10 +402,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 = @@ -437,14 +425,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", } @@ -456,35 +444,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, + _toolByDescription?: ReadonlyMap, ): 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/commands/built-in.test.ts b/src/tui/commands/built-in.test.ts index 67e3894af..d9f17ee03 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", }); }); diff --git a/src/tui/geometry.test.ts b/src/tui/geometry.test.ts index f17638405..ae8350d50 100644 --- a/src/tui/geometry.test.ts +++ b/src/tui/geometry.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { AGENTS_PANEL_MAX_VISIBLE, COLLAPSE_ORDER, + DUAL_MIN_COLUMNS, FLEET_BOARD_CAP_FRACTION, FLEET_TRANSCRIPT_FLOOR, IDLE_TRANSCRIPT_FLOOR, @@ -9,6 +10,9 @@ import { PROMPT_BASE_ROWS, PROMPT_CAP_FRACTION, PROMPT_IDLE_ROWS, + RAIL_GUTTER, + RAIL_WIDTH_MAX, + RAIL_WIDTH_MIN, SIDE_MARGIN, TASKS_PANEL_MAX_VISIBLE, ZONE_IDS, @@ -72,6 +76,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"); + // No agents → stack defaults for dual fields. + 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 +110,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 +129,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 +144,26 @@ 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 dual terminal honours the agents row request (capped by residual)", () => { 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("dual"); + // Dual caps agents to transcript residual, not to 1. expect(tall.heights.agents).toBe(14); + expect(tall.heights.agents).toBeLessThanOrEqual(tall.transcriptHeight); }); - test("with a fleet running the transcript yields its idle floor to the board", () => { + test("with a fleet running on a narrow terminal the agents zone stacks", () => { 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); @@ -209,7 +229,7 @@ describe("resolveGeometry — task panel", () => { // Agents sit above the task list; both sit in the bottom chrome, not // above the conversation residual. const layout = idle80x24({ - visibility: { task: 3, agents: 2 }, + visibility: { task: 3, agents: 1 }, }); const transcript = layout.regions.transcript; const agents = layout.regions.agents; @@ -455,6 +475,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", () => { @@ -467,3 +491,100 @@ describe("resolveGeometry — resize / residual", () => { expect(sum).toBe(18); }); }); + +describe("resolveGeometry — dual column fleet rail", () => { + test("wide terminal with agents → dual: rail width clamped, columns sum to contentWidth", () => { + const layout = resolveGeometry({ + terminal: { columns: 120, rows: 32 }, + visibility: { agents: 8 }, + }); + expect(layout.layoutMode).toBe("dual"); + expect(layout.railWidth).toBeGreaterThanOrEqual(RAIL_WIDTH_MIN); + expect(layout.railWidth).toBeLessThanOrEqual(RAIL_WIDTH_MAX); + expect(layout.railGutter).toBe(RAIL_GUTTER); + expect(layout.chatWidth + layout.railGutter + layout.railWidth).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 rail sits to the right of chat at the same y. + expect(agents!.x).toBeGreaterThan(transcript!.x); + expect(agents!.y).toBe(transcript!.y); + expect(transcript!.width).toBe(layout.chatWidth); + expect(agents!.width).toBe(layout.railWidth); + expect(agents!.height).toBe(layout.heights.agents); + + // Prompt stays full content width under both columns. + expect(prompt!.width).toBe(layout.contentWidth); + expect(prompt!.x).toBe(layout.sideMargin); + }); + + test("dual agents height does not reduce transcript vs stack baseline", () => { + const dual = resolveGeometry({ + terminal: { columns: 120, rows: 32 }, + visibility: { agents: 8 }, + }); + // Same width/rows with no agents: stack residual is the dual baseline. + const stack = resolveGeometry({ + terminal: { columns: 120, rows: 32 }, + visibility: { agents: 0 }, + }); + expect(dual.layoutMode).toBe("dual"); + expect(stack.layoutMode).toBe("stack"); + // Dual excludes agents from chrome, so transcript matches idle residual. + expect(dual.transcriptHeight).toBe(stack.transcriptHeight); + expect(dual.chromeHeight).toBe(stack.chromeHeight); + expect(dual.heights.agents).toBe(8); + expect(dual.heights.agents).toBeLessThanOrEqual(dual.transcriptHeight); + }); + + test("narrow terminal with agents → stack, full-width regions, railWidth 0", () => { + expect(80).toBeLessThan(DUAL_MIN_COLUMNS); + 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); + // Stack: agents sit below transcript. + expect(layout.regions.agents!.y).toBeGreaterThan(layout.regions.transcript!.y); + // Stack agents consume vertical chrome. + expect(layout.chromeHeight).toBeGreaterThan(PROMPT_IDLE_ROWS); + }); + + test("no agents → always stack 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(); + }); + + test("dual threshold is DUAL_MIN_COLUMNS (100)", () => { + const justBelow = resolveGeometry({ + terminal: { columns: DUAL_MIN_COLUMNS - 1, rows: 32 }, + visibility: { agents: 4 }, + }); + const atThreshold = resolveGeometry({ + terminal: { columns: DUAL_MIN_COLUMNS, rows: 32 }, + visibility: { agents: 4 }, + }); + expect(justBelow.layoutMode).toBe("stack"); + expect(atThreshold.layoutMode).toBe("dual"); + }); +}); + diff --git a/src/tui/geometry/index.ts b/src/tui/geometry/index.ts index b93038911..6148828e9 100644 --- a/src/tui/geometry/index.ts +++ b/src/tui/geometry/index.ts @@ -1,6 +1,7 @@ export { AGENTS_PANEL_MAX_VISIBLE, COLLAPSE_ORDER, + DUAL_MIN_COLUMNS, FLEET_BOARD_CAP_FRACTION, FLEET_FLOOR_MIN_LANES, FLEET_TRANSCRIPT_FLOOR, @@ -14,6 +15,10 @@ export { PROMPT_CAP_FRACTION, PROMPT_IDLE_INPUT_ROWS, PROMPT_IDLE_ROWS, + RAIL_GUTTER, + RAIL_WIDTH_FRACTION, + RAIL_WIDTH_MAX, + RAIL_WIDTH_MIN, TASKS_PANEL_MAX_VISIBLE, ZONE_IDS, ZONE_REGISTRY, @@ -40,6 +45,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..fe4a633d5 100644 --- a/src/tui/geometry/resolve.ts +++ b/src/tui/geometry/resolve.ts @@ -4,6 +4,7 @@ import { resolveContentWidth, resolveSideMargin } from "./margins.js"; import { COLLAPSE_ORDER, + DUAL_MIN_COLUMNS, FLEET_BOARD_CAP_FRACTION, IDLE_TRANSCRIPT_FLOOR, OVERLAY_MAX_FRACTION, @@ -13,6 +14,10 @@ import { PROMPT_BASE_ROWS, PROMPT_CAP_FRACTION, PROMPT_IDLE_ROWS, + RAIL_GUTTER, + RAIL_WIDTH_FRACTION, + RAIL_WIDTH_MAX, + RAIL_WIDTH_MIN, ZONE_REGISTRY, type ZoneId, } from "./zones.js"; @@ -82,6 +87,9 @@ export type Rect = { readonly height: number; }; +/** Vertical stack (narrow / no agents) vs chat+rail dual column. */ +export type LayoutMode = "stack" | "dual"; + export type GeometryLayout = { readonly terminal: TerminalSize; readonly transcriptHeight: number; @@ -100,6 +108,17 @@ export type GeometryLayout = { readonly sideMargin: number; /** Zone width after both gutters. */ readonly contentWidth: number; + /** + * `"dual"` when the terminal is wide enough and the agents zone is on — + * chat left, fleet rail right. Otherwise `"stack"` (full-width y-stack). + */ + readonly layoutMode: LayoutMode; + /** Transcript / chat column width. Equals contentWidth in stack mode. */ + readonly chatWidth: number; + /** Fleet rail width. 0 in stack mode. */ + readonly railWidth: number; + /** Columns between chat and rail. 1 in dual, 0 in stack. */ + readonly railGutter: number; }; type MutableHeights = Record; @@ -171,15 +190,49 @@ export function desiredHeights(input: GeometryInput): MutableHeights { return heights; } -function sumChrome(heights: MutableHeights): number { +/** + * Vertical chrome budget: every non-residual zone. In dual layout the agents + * zone sits beside the transcript and does not consume vertical residual. + */ +function sumChrome(heights: MutableHeights, layoutMode: LayoutMode): number { let total = 0; for (const id of PAINT_ORDER) { if (id === "transcript" || id === "overlay_host") continue; + if (layoutMode === "dual" && id === "agents") continue; total += heights[id]; } return total; } +/** Dual when wide enough and the agents zone has rows to paint. */ +function resolveLayoutMode( + columns: number, + agentsRows: number, +): LayoutMode { + return columns >= DUAL_MIN_COLUMNS && agentsRows > 0 ? "dual" : "stack"; +} + +/** + * Split content width into chat + gutter + rail for dual, or full-width chat + * for stack. Rail is ~RAIL_WIDTH_FRACTION of content, clamped to [min, max]. + */ +function resolveColumnWidths( + contentWidth: number, + layoutMode: LayoutMode, +): { chatWidth: number; railWidth: number; railGutter: number } { + if (layoutMode !== "dual") { + return { chatWidth: contentWidth, railWidth: 0, railGutter: 0 }; + } + const railWidth = clamp( + Math.round(contentWidth * RAIL_WIDTH_FRACTION), + RAIL_WIDTH_MIN, + RAIL_WIDTH_MAX, + ); + const railGutter = RAIL_GUTTER; + const chatWidth = Math.max(1, contentWidth - railWidth - railGutter); + return { chatWidth, railWidth, railGutter }; +} + function transcriptFloorFor(mode: OverlayMode, terminalRows: number): number { if (mode === "full_shell") return 0; if (mode === "inset") { @@ -219,9 +272,16 @@ function desiredOverlayHeight( /** * One collapse step: reduce the next collapsible zone. * Returns the zone id that was reduced, or null if nothing left to cut. + * In dual layout the agents rail does not free vertical residual, so it is + * skipped (its height is capped to transcript residual after collapse). */ -function collapseOnce(heights: MutableHeights, collapsed: ZoneId[]): ZoneId | null { +function collapseOnce( + heights: MutableHeights, + collapsed: ZoneId[], + layoutMode: LayoutMode, +): ZoneId | null { for (const id of COLLAPSE_ORDER) { + if (layoutMode === "dual" && id === "agents") continue; const h = heights[id]; if (h <= 0) continue; @@ -293,14 +353,22 @@ function collapseOnce(heights: MutableHeights, collapsed: ZoneId[]): ZoneId | nu function assignRects( heights: MutableHeights, terminal: TerminalSize, + layoutMode: LayoutMode, + chatWidth: number, + railWidth: number, + railGutter: number, ): Partial> { const regions: Partial> = {}; const x = resolveSideMargin(terminal.columns); - const width = resolveContentWidth(terminal.columns); + const contentWidth = resolveContentWidth(terminal.columns); let y = 0; for (const id of PAINT_ORDER) { + // Dual: agents is placed beside the transcript after the vertical pass. + if (layoutMode === "dual" && id === "agents") continue; const height = heights[id]; if (height <= 0) continue; + const width = + layoutMode === "dual" && id === "transcript" ? chatWidth : contentWidth; regions[id] = { x, y, @@ -309,6 +377,19 @@ function assignRects( }; y += height; } + + // Dual rail: same y as transcript, to its right past the gutter. + if (layoutMode === "dual" && heights.agents > 0) { + const transcript = regions.transcript; + const agentsY = transcript?.y ?? 0; + regions.agents = { + x: x + chatWidth + railGutter, + y: agentsY, + width: railWidth, + height: heights.agents, + }; + } + return regions; } @@ -328,6 +409,8 @@ 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); // Full-shell modal: hide transcript and bottom chrome; overlay owns residual. if (mode === "full_shell") { @@ -341,9 +424,18 @@ export function resolveGeometry(input: GeometryInput): GeometryLayout { heights.progress_divider = 0; heights.notice = 0; heights.prompt = 0; - const chrome = sumChrome(heights); + const layoutMode: LayoutMode = "stack"; + const chrome = sumChrome(heights, layoutMode); heights.overlay_host = Math.max(0, terminal.rows - chrome); - const regions = assignRects(heights, terminal); + const columns = resolveColumnWidths(contentWidth, layoutMode); + const regions = assignRects( + heights, + terminal, + layoutMode, + columns.chatWidth, + columns.railWidth, + columns.railGutter, + ); return { terminal, transcriptHeight: 0, @@ -354,11 +446,21 @@ export function resolveGeometry(input: GeometryInput): GeometryLayout { collapsed, overlayMode: mode, transcriptFloor: floor, - sideMargin: resolveSideMargin(terminal.columns), - contentWidth: resolveContentWidth(terminal.columns), + sideMargin, + contentWidth, + layoutMode, + chatWidth: columns.chatWidth, + railWidth: columns.railWidth, + railGutter: columns.railGutter, }; } + // Dual eligibility is fixed from the desired agents budget + width so + // collapse / residual accounting stay consistent for the whole resolve. + // Final layoutMode re-checks agents height after residual cap. + const dualEligible = resolveLayoutMode(terminal.columns, heights.agents) === "dual"; + const layoutModeForChrome: LayoutMode = dualEligible ? "dual" : "stack"; + // Cap prompt growth against floor before overlay allocation. const promptCap = Math.max( PROMPT_BASE_ROWS, @@ -380,7 +482,7 @@ export function resolveGeometry(input: GeometryInput): GeometryLayout { // of dropping every optional zone. const maxIters = 128; for (let i = 0; i < maxIters; i++) { - const chrome = sumChrome(heights); + const chrome = sumChrome(heights, layoutModeForChrome); const overlay = desiredOverlayHeight( { ...input, terminal }, mode, @@ -394,7 +496,7 @@ export function resolveGeometry(input: GeometryInput): GeometryLayout { break; } // Need more space: collapse one zone, then retry. - const cut = collapseOnce(heights, collapsed); + const cut = collapseOnce(heights, collapsed, layoutModeForChrome); if (cut === null) { // Nothing left — relax the transcript floor rather than leave the // overlay under its own render minimum; accept best effort past that. @@ -404,24 +506,43 @@ 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, layoutModeForChrome) - heights.overlay_host, + ); break; } } // Final consistency: residual must sum exactly to terminal.rows. - const chromeHeight = sumChrome(heights); + // Dual: agents is outside the vertical chrome budget, so transcript keeps + // the full residual that stack would give without an agents strip. + const chromeHeight = sumChrome(heights, layoutModeForChrome); const overlayHeight = heights.overlay_host; 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; } - const regions = assignRects(heights, terminal); + // Dual rail height: paint/clamp budget is min(requested, transcript residual). + // Stack already fraction-capped agents in desiredHeights / collapse. + if (dualEligible && heights.agents > 0) { + heights.agents = Math.min(heights.agents, heights.transcript); + } + + const layoutMode = resolveLayoutMode(terminal.columns, heights.agents); + const columns = resolveColumnWidths(contentWidth, layoutMode); + const regions = assignRects( + heights, + terminal, + layoutMode, + columns.chatWidth, + columns.railWidth, + columns.railGutter, + ); return { terminal, @@ -433,7 +554,11 @@ export function resolveGeometry(input: GeometryInput): GeometryLayout { collapsed, overlayMode: mode, transcriptFloor: floor, - sideMargin: resolveSideMargin(terminal.columns), - contentWidth: resolveContentWidth(terminal.columns), + sideMargin, + contentWidth, + layoutMode, + chatWidth: columns.chatWidth, + railWidth: columns.railWidth, + railGutter: columns.railGutter, }; } diff --git a/src/tui/geometry/zones.ts b/src/tui/geometry/zones.ts index 508ef5f88..fa0a92f94 100644 --- a/src/tui/geometry/zones.ts +++ b/src/tui/geometry/zones.ts @@ -41,11 +41,32 @@ export type ZoneDeclaration = { */ export const AGENTS_PANEL_MAX_VISIBLE = 13; +/** + * Terminal columns at or above which the agents zone may sit as a right rail + * beside the transcript instead of stacking under it. Below this width the + * shell always y-stacks (narrow single-column fallback). + */ +export const DUAL_MIN_COLUMNS = 100; + +/** Target share of content width claimed by the fleet rail in dual layout. */ +export const RAIL_WIDTH_FRACTION = 0.38; + +/** Hard floor on rail width in dual layout (columns). */ +export const RAIL_WIDTH_MIN = 28; + +/** Hard ceiling on rail width in dual layout (columns). */ +export const RAIL_WIDTH_MAX = 48; + +/** Columns of gutter between chat column and rail in dual layout. */ +export const RAIL_GUTTER = 1; + /** * Share of the terminal the fleet board may take before it starts hiding * lanes. The board is sized to its content, so a single lane costs two rows * and a dozen costs thirteen; this only bounds the large fan-out, and the - * transcript keeps everything the board does not ask for. + * transcript keeps everything the board does not ask for. In dual layout the + * board shares the transcript's vertical residual, so this fraction is the + * stack-mode bound only. */ export const FLEET_BOARD_CAP_FRACTION = 0.62; @@ -105,10 +126,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 / fleet rail. Stack mode: bounded strip under the transcript + // (max = visible lanes + trailing "+N more" + header slack). Dual mode: same + // height budget sits beside the transcript and does not consume vertical chrome. agents: { id: "agents", min: 0, diff --git a/src/tui/keybindings.test.ts b/src/tui/keybindings.test.ts index 5a68e64a2..a4a39429c 100644 --- a/src/tui/keybindings.test.ts +++ b/src/tui/keybindings.test.ts @@ -312,11 +312,12 @@ const PROBES: Readonly { 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": { @@ -513,7 +514,7 @@ const PROBES: Readonly { 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/notice-line.test.ts b/src/tui/notice-line.test.ts index 53296ba83..01e8ab555 100644 --- a/src/tui/notice-line.test.ts +++ b/src/tui/notice-line.test.ts @@ -45,7 +45,7 @@ describe("composeNoticeLine", () => { ) expect(line).toContain("queue 2") expect(line).toContain("pinned") - expect(line).toContain("interrupt") + expect(line).not.toContain("interrupt") expect(line).toContain("1 image") }) diff --git a/src/tui/notice-line.ts b/src/tui/notice-line.ts index da0717c14..0188b3fc6 100644 --- a/src/tui/notice-line.ts +++ b/src/tui/notice-line.ts @@ -4,7 +4,7 @@ * 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. @@ -54,7 +54,8 @@ export function composeNoticeLine(state: NoticeState): string { const segments: string[] = [] if (state.queue > 0) segments.push(`queue ${state.queue}`) 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"}`, diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index 976bfffa3..b9f6994bc 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" @@ -342,19 +340,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() 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 +398,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 +464,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. @@ -623,7 +608,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/runner-host.test.ts b/src/tui/runner-host.test.ts index 18d941006..ff4b9cd6c 100644 --- a/src/tui/runner-host.test.ts +++ b/src/tui/runner-host.test.ts @@ -6,7 +6,7 @@ import type { KeyEvent } from "@opentui/core" import type { CostSummary } from "../cost/cost-summary.js" import type { SubAgentSession } from "../subagent/session-store.js" import { createHarness } from "./harness.js" -import { acceptOverlaySelection, closeInsetOverlay, runOverlayAction } from "./shell.js" +import { acceptOverlaySelection, closeInsetOverlay, runOverlayAction, toggleTasksPanel } from "./shell.js" import { mountRunnerHost, observeSessionFromSubAgents, @@ -174,6 +174,10 @@ describe("mountRunnerHost chrome wiring", () => { liveTasks = [{ title: "wire task panel", status: "doing" }] notify?.() + // CL-5847: the panel is hidden by default, so the live push lands in + // tasksRaw underneath without showing. It stays hidden until opt-in. + expect(host.shell.taskBox.visible).toBe(false) + toggleTasksPanel(host.shell) expect(host.shell.taskBox.visible).toBe(true) await harness.renderOnce() const frame = harness.captureCharFrame() diff --git a/src/tui/runtime-channels.test.ts b/src/tui/runtime-channels.test.ts index 857f1d14c..2f07d9b5d 100644 --- a/src/tui/runtime-channels.test.ts +++ b/src/tui/runtime-channels.test.ts @@ -196,21 +196,22 @@ describe("permission.grant channel", () => { }) }) -describe("subagent.progress channel", () => { +describe("agents chrome (store-driven tool state)", () => { test("the live tool name reaches the agents chrome zone", async () => { - const { host, emitter, frame, cleanup } = await mountHeadless({ + 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. const painted = await frame() @@ -225,15 +226,17 @@ describe("subagent.progress channel", () => { }) test("a later chrome push keeps the live tool name", async () => { - const { host, emitter, frame, cleanup } = await mountHeadless() + 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() @@ -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/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/shell.test.ts b/src/tui/shell.test.ts index 473725956..b35863266 100644 --- a/src/tui/shell.test.ts +++ b/src/tui/shell.test.ts @@ -468,11 +468,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 e9bbd8d29..ec1d80515 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -1690,7 +1690,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 +1712,8 @@ 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 + const dualRail = layout.layoutMode === "dual" && agentsH > 0 + // Height/position for dual finalized after pad + transcript body are known. shell.agentsBox.visible = agentsH > 0 // Both pads are taken out of the transcript residual, never out of chrome, @@ -1758,6 +1762,27 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void { shell.transcript.visible = transcriptBody > 0 syncTranscriptSpacer(shell) + // Fleet rail: absolute beside the transcript in dual mode; full-width strip + // in the flex stack otherwise. Absolute escapes root padding (floatOverlayHost). + if (dualRail) { + const railH = Math.max(1, Math.min(agentsH, transcriptBody > 0 ? transcriptBody : agentsH)) + shell.agentsBox.position = "absolute" + shell.agentsBox.left = layout.sideMargin + layout.chatWidth + layout.railGutter + shell.agentsBox.width = layout.railWidth + shell.agentsBox.top = padH + shell.agentsBox.height = railH + shell.agentsBox.zIndex = 1 + shell.transcript.width = layout.chatWidth + } else { + 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 @@ -1781,7 +1806,10 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void { // the foot and covering it would hide the thing the operator types into. // 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 + // Dual: agents is absolute beside the transcript, so it does not add to the + // vertical stack before the prompt. + const stackAgentsH = dualRail ? 0 : agentsH + const promptTop = padH + transcriptBody + stackAgentsH + 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 @@ -1807,6 +1835,19 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void { repaintTranscriptWindow(shell) } + // Dual/stack flip or rail resize changes the column budget the board fits to. + // Content may be unchanged, so setChromeZones would skip the rebuild — do it + // here when the layout mode or rail width moved. + if (widthChanged && bag !== undefined && bag.chrome.agents.length > 0) { + const agentsWidth = + dualRail && layout.railWidth > 0 ? layout.railWidth : layout.contentWidth + renderAgentsRows( + shell, + clampBoardRows(bag.chrome.agents, agentsH), + agentsWidth, + ) + } + paintChrome(shell) } @@ -3326,8 +3367,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) @@ -4468,18 +4509,18 @@ function renderAgentsRows( destroySubtree(child) } for (const row of rows) { - // Green for working, not the task zone's bronze immediately below 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) } @@ -4559,12 +4600,17 @@ 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. Dual mode fits rows to the + // rail width; stack uses full content width. if (agentsChanged || !budgetUnchanged) { + const agentsWidth = + shell.layout.layoutMode === "dual" && shell.layout.railWidth > 0 + ? shell.layout.railWidth + : shell.layout.contentWidth renderAgentsRows( shell, clampBoardRows(bag.chrome.agents, shell.layout.heights.agents), - shell.layout.contentWidth, + agentsWidth, ) } if (budgetUnchanged) paintChrome(shell) @@ -6096,7 +6142,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/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" }] }) From d05bbbce05ff43535f305faeb25b5899378955db Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 10 Aug 2026 13:10:28 -0700 Subject: [PATCH 18/59] State the product one-liner under What It Is Lead PRODUCT with the local multi-agent harness framing so progress and cost visibility sit next to implement/verify/land. --- docs/PRODUCT.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index f3cb628ee..d72ba48b3 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. From c7a5ef2f1867bb2c699a56be118b777da1735209 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 10 Aug 2026 13:12:35 -0700 Subject: [PATCH 19/59] Tune director prompts for done-gates, finish bias, and effort Implement stops on success_criteria with a criteria report map; explore prefers one pass then a scannable Findings map; Skywalker scales fan-out, requires typed multi-leaf briefs, and defaults critique after multi-file ship. Critique stays correctness-only. Harness facts surface turn budgets and no early-stop on compaction. --- src/agent/directors/critique/package.test.ts | 12 +++++++++ src/agent/directors/critique/package.ts | 5 ++++ src/agent/directors/explore/package.test.ts | 21 +++++++++++++++ src/agent/directors/explore/package.ts | 4 ++- src/agent/directors/implement/package.test.ts | 21 +++++++++++++++ src/agent/directors/implement/package.ts | 9 ++++--- src/agent/directors/skywalker/package.test.ts | 27 +++++++++++++++++++ src/agent/directors/skywalker/package.ts | 17 ++++++++++++ src/agent/prompts.ts | 2 ++ src/prompts.test.ts | 19 +++++++++++++ 10 files changed, 133 insertions(+), 4 deletions(-) diff --git a/src/agent/directors/critique/package.test.ts b/src/agent/directors/critique/package.test.ts index 95c6dc156..d5c794e34 100644 --- a/src/agent/directors/critique/package.test.ts +++ b/src/agent/directors/critique/package.test.ts @@ -21,6 +21,18 @@ describe("critiquePackage", () => { 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("spawn.maySpawn is false", () => { expect(critiquePackage.spawn.maySpawn).toBe(false); }); diff --git a/src/agent/directors/critique/package.ts b/src/agent/directors/critique/package.ts index 82b6f734f..186e8d64a 100644 --- a/src/agent/directors/critique/package.ts +++ b/src/agent/directors/critique/package.ts @@ -35,6 +35,11 @@ Evidence rules: - 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. + Write tools are not mounted. Repro via read/shell only; recommend permanent tests for testsmith/implement. OUT OF LANE → refuse or reclassify under Blockers: diff --git a/src/agent/directors/explore/package.test.ts b/src/agent/directors/explore/package.test.ts index 8ba9c85b7..0c76294a2 100644 --- a/src/agent/directors/explore/package.test.ts +++ b/src/agent/directors/explore/package.test.ts @@ -15,6 +15,27 @@ describe("explorePackage", () => { 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); }); diff --git a/src/agent/directors/explore/package.ts b/src/agent/directors/explore/package.ts index 846b6d267..47554a08c 100644 --- a/src/agent/directors/explore/package.ts +++ b/src/agent/directors/explore/package.ts @@ -17,7 +17,9 @@ PRIMARY INTENT: explore and map the codebase to answer the brief. Read, search, Prefer grep/search_files/lsp over shell walks. Shell find/rg -r are blocked by harness — do not work around. -Deliver a scannable map: key paths, symbols, call flow, ownership. Cite paths. No drive-by refactors, no feature work, no review severity theater. +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. diff --git a/src/agent/directors/implement/package.test.ts b/src/agent/directors/implement/package.test.ts index 1c66e6537..f5643115a 100644 --- a/src/agent/directors/implement/package.test.ts +++ b/src/agent/directors/implement/package.test.ts @@ -41,4 +41,25 @@ describe("implementPackage", () => { 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/); + }); }); diff --git a/src/agent/directors/implement/package.ts b/src/agent/directors/implement/package.ts index 262dc07ba..0440d77a5 100644 --- a/src/agent/directors/implement/package.ts +++ b/src/agent/directors/implement/package.ts @@ -25,11 +25,14 @@ 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 leaves). Follow AGENTS.md and /docs. Touch only what the brief requires. -Prefer typed success_criteria from the brief as your done gate. -Stop when success_criteria are met — do not invent architecture or expand the brief. -Run typecheck/tests when practical; put failures under Blockers, not silent patches outside scope. 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. + 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/skywalker/package.test.ts b/src/agent/directors/skywalker/package.test.ts index 9ae9eb124..c8d6fb5e0 100644 --- a/src/agent/directors/skywalker/package.test.ts +++ b/src/agent/directors/skywalker/package.test.ts @@ -83,4 +83,31 @@ describe("skywalkerPackage", () => { test("nudge maxTurns", () => { expect(skywalkerPackage.nudge?.maxTurns).toBe(100); }); + + 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 leaf"); + expect(p).toContain("2–4 leaves"); + expect(p).toContain("split ownership by path/package"); + }); + + test("systemPrompt requires brief completeness for multi-leaf", () => { + 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-leaf"); + }); + + test("systemPrompt has critique-after-implement verify path", () => { + const p = skywalkerPackage.systemPrompt; + expect(p).toContain("Verify after ship"); + expect(p).toContain("multi-file implement"); + expect(p).toContain("critique"); + expect(p).toContain("greybeard"); + expect(p).toContain("correctness/brief gaps"); + }); }); diff --git a/src/agent/directors/skywalker/package.ts b/src/agent/directors/skywalker/package.ts index 5c62d9c41..f54342541 100644 --- a/src/agent/directors/skywalker/package.ts +++ b/src/agent/directors/skywalker/package.ts @@ -29,10 +29,27 @@ Quick routing: - gaasbot = risk counsel - bruckheimer = product discovery docs - intern = exact shell / mechanical ops +- After multi-file implement landings → default a critique leaf (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. +# Effort scaling (IMPLEMENTATION / ORCHESTRATION) + +Scale fan-out to the ask — do not spawn 10+ leaves for a simple request: +- Simple (answer, one-path lookup, tiny fix): 0–1 leaf, few tools; often answer without fleet +- Medium: 2–4 leaves with distinct path/package ownership +- Complex: more leaves only with named lanes and clear non-overlap +Cap default fan-out. Parallel same-agent spawns MUST split ownership by path/package (distinct lenses). + +# Brief completeness + +For multi-step or multi-leaf dispatch, prefer typed spawn with success_criteria, do_not, and report_focus (plus intent/agent). Do not fire multi-leaf waves with one-line vague briefs — flesh the brief first. + +# Verify after ship + +After multi-file implement landings, default a critique leaf (or greybeard when architecture is in play) on the diff/criteria in a fresh context. Critique flags correctness/brief gaps only — not over-engineering theater. + # Mandatory workflow for every request Before responding, classify: diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index 71f1f3b3a..9390b99b3 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -74,6 +74,7 @@ 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.", @@ -148,6 +149,7 @@ export function buildGuidelines(opts: { subAgent?: boolean; sessionMode?: Sessio "- 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 leaf reports.", ]), ].join("\n"); } diff --git a/src/prompts.test.ts b/src/prompts.test.ts index 3dab6fcdc..a4c2cccf4 100644 --- a/src/prompts.test.ts +++ b/src/prompts.test.ts @@ -71,6 +71,16 @@ test("leaf harness facts advertise product write tools", () => { 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:"); @@ -91,6 +101,15 @@ test("orchestrator guidelines teach the typed task spawn contract", () => { expect(guidelines).toContain("intent"); }); +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 leaf reports"); + // Leaf guidelines omit primary orchestration compaction guidance. + expect(buildGuidelines({ subAgent: true })).not.toContain("token fear"); +}); + test("chat system prompt satisfies system prompt quality markers", () => { const prompt = buildChatSystemPrompt(); for (const marker of CHAT_PROMPT_QUALITY_MARKERS) { From 0e64131b27ba366f7c33697072561e9963dd3c0e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 10 Aug 2026 18:23:36 -0700 Subject: [PATCH 20/59] Close the API-contract verify loop and add stock-gate eval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement and critique treat sync→async signature drift as blocking; Skywalker skips explore/critique on tiny green ships and re-dispatches implement on blocking findings. complex-jwt states the sync Response contract; complex-stock-gate adds multi-file mutable-state coverage. --- evals/capability/README.md | 4 +- evals/capability/cases/complex-jwt/case.json | 2 +- evals/capability/cases/complex-jwt/verify.sh | 8 + .../cases/complex-stock-gate/case.json | 9 ++ .../cases/complex-stock-gate/verify.sh | 152 ++++++++++++++++++ src/agent/directors/critique/package.test.ts | 21 +++ src/agent/directors/critique/package.ts | 7 + src/agent/directors/implement/package.test.ts | 8 + src/agent/directors/implement/package.ts | 2 + src/agent/directors/skywalker/package.test.ts | 28 +++- src/agent/directors/skywalker/package.ts | 7 +- 11 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 evals/capability/cases/complex-stock-gate/case.json create mode 100755 evals/capability/cases/complex-stock-gate/verify.sh diff --git a/evals/capability/README.md b/evals/capability/README.md index ad375b7c5..87e808b60 100644 --- a/evals/capability/README.md +++ b/evals/capability/README.md @@ -11,13 +11,15 @@ One run can **try different things**: multiple cases × multiple provider/model | 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 | | 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 | | bait | `edit-bait` | `tests/fixtures/multiline-edit` | Multi-line source edit; catches sed/heredoc editing | | bait | `subagent-bait` | `tests/fixtures/slow-command` | Subagent must wait on a ~20s command; catches stall gaps | + Bait cases exist to **reproduce known misbehaviors** so behavior changes can be confirmed against them. Each declares the behavior metric it baits in `case.json` (`bait: { metric, threshold }`): the case misbehaves when the 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-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/src/agent/directors/critique/package.test.ts b/src/agent/directors/critique/package.test.ts index d5c794e34..6ed02370c 100644 --- a/src/agent/directors/critique/package.test.ts +++ b/src/agent/directors/critique/package.test.ts @@ -33,6 +33,27 @@ describe("critiquePackage", () => { 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); }); diff --git a/src/agent/directors/critique/package.ts b/src/agent/directors/critique/package.ts index 186e8d64a..940a492a0 100644 --- a/src/agent/directors/critique/package.ts +++ b/src/agent/directors/critique/package.ts @@ -40,6 +40,13 @@ Correctness-only / anti-over-engineering: - 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: diff --git a/src/agent/directors/implement/package.test.ts b/src/agent/directors/implement/package.test.ts index f5643115a..7519777a2 100644 --- a/src/agent/directors/implement/package.test.ts +++ b/src/agent/directors/implement/package.test.ts @@ -62,4 +62,12 @@ describe("implementPackage", () => { 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 index 0440d77a5..7842f22a6 100644 --- a/src/agent/directors/implement/package.ts +++ b/src/agent/directors/implement/package.ts @@ -33,6 +33,8 @@ VERIFY: Run typecheck/tests when practical; put failures under Blockers, not sil 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/skywalker/package.test.ts b/src/agent/directors/skywalker/package.test.ts index c8d6fb5e0..a7cbdc821 100644 --- a/src/agent/directors/skywalker/package.test.ts +++ b/src/agent/directors/skywalker/package.test.ts @@ -93,6 +93,14 @@ describe("skywalkerPackage", () => { expect(p).toContain("split ownership by path/package"); }); + test("systemPrompt simple path skips explore+critique for tiny work", () => { + const p = skywalkerPackage.systemPrompt; + expect(p).toContain("one implement leaf"); + expect(p).toContain("skip explore and skip critique"); + expect(p).toContain("tests green"); + expect(p).toContain("Do not always explore→implement→critique"); + }); + test("systemPrompt requires brief completeness for multi-leaf", () => { const p = skywalkerPackage.systemPrompt; expect(p).toContain("Brief completeness"); @@ -102,12 +110,28 @@ describe("skywalkerPackage", () => { expect(p).toContain("multi-leaf"); }); + 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("multi-file implement"); + expect(p).toContain("public-API"); expect(p).toContain("critique"); - expect(p).toContain("greybeard"); + 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 index f54342541..8c9564ad7 100644 --- a/src/agent/directors/skywalker/package.ts +++ b/src/agent/directors/skywalker/package.ts @@ -38,6 +38,7 @@ Parallelize independent lanes. manage_tasks for your checklist. ask_operator whe Scale fan-out to the ask — do not spawn 10+ leaves for a simple request: - Simple (answer, one-path lookup, tiny fix): 0–1 leaf, few tools; often answer without fleet +- Tiny single-file / one-route asks: **one implement leaf**; 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 leaves with distinct path/package ownership - Complex: more leaves only with named lanes and clear non-overlap Cap default fan-out. Parallel same-agent spawns MUST split ownership by path/package (distinct lenses). @@ -45,10 +46,14 @@ Cap default fan-out. Parallel same-agent spawns MUST split ownership by path/pac # Brief completeness For multi-step or multi-leaf dispatch, prefer typed spawn with success_criteria, do_not, and report_focus (plus intent/agent). Do not fire multi-leaf 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 -After multi-file implement landings, default a critique leaf (or greybeard when architecture is in play) on the diff/criteria in a fresh context. Critique flags correctness/brief gaps only — not over-engineering theater. +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 From 240168de0e38d05d7ff09c4a0b105c4fe7844cc6 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 10 Aug 2026 19:03:53 -0700 Subject: [PATCH 21/59] Changelog and complex-stock-gate case for API-contract iter1 Document director API-contract / simple-path loop in Unreleased; keep the new stock-gate case and jwt sync grader wording with the shipped packages. --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b5290133..10139df0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,14 @@ mid-session switches. ### Added +- **Capability eval: `complex-stock-gate`.** Multi-file stock-gated `POST /orders` + (404/409/201 + stock decrement) on the demo-comparison fixture; sync API grader. +- **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, and re-dispatches implement on blocking critique findings. + 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=…)` From af3a077e95225e2f2a0b8c20d8e9a2f9f0ef065f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 12 Aug 2026 14:30:40 -0700 Subject: [PATCH 22/59] Add idempotent-orders complex case and Skywalker web_fetch path Third complex eval stresses header-driven multi-file state (Idempotency-Key). Skywalker Fetch URLs section steers primary at mounted web_fetch instead of shell thrash on URL-read tasks. --- evals/capability/README.md | 2 + .../cases/complex-idempotent-orders/case.json | 9 ++ .../cases/complex-idempotent-orders/verify.sh | 121 ++++++++++++++++++ src/agent/directors/skywalker/package.test.ts | 8 ++ src/agent/directors/skywalker/package.ts | 7 + 5 files changed, 147 insertions(+) create mode 100644 evals/capability/cases/complex-idempotent-orders/case.json create mode 100755 evals/capability/cases/complex-idempotent-orders/verify.sh diff --git a/evals/capability/README.md b/evals/capability/README.md index 87e808b60..2df5e1e50 100644 --- a/evals/capability/README.md +++ b/evals/capability/README.md @@ -13,6 +13,8 @@ One run can **try different things**: multiple cases × multiple provider/model | simple | `simple-health` | `tests/fixtures/multi-file-service` | Single-file route + test | | 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 | + | 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 | 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/src/agent/directors/skywalker/package.test.ts b/src/agent/directors/skywalker/package.test.ts index a7cbdc821..1ca8ae23a 100644 --- a/src/agent/directors/skywalker/package.test.ts +++ b/src/agent/directors/skywalker/package.test.ts @@ -101,6 +101,14 @@ describe("skywalkerPackage", () => { 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-leaf", () => { const p = skywalkerPackage.systemPrompt; expect(p).toContain("Brief completeness"); diff --git a/src/agent/directors/skywalker/package.ts b/src/agent/directors/skywalker/package.ts index 8c9564ad7..c7b0227a4 100644 --- a/src/agent/directors/skywalker/package.ts +++ b/src/agent/directors/skywalker/package.ts @@ -34,6 +34,13 @@ Quick routing: 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+ leaves for a simple request: From e5d02b52a49d153638676400320dabb1d0551243 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 12 Aug 2026 14:41:01 -0700 Subject: [PATCH 23/59] Changelog for complex-idempotent-orders and web_fetch routing Document the third complex capability case and Skywalker primary URL path alongside the API-contract loop notes. --- CHANGELOG.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10139df0d..f0c528374 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,11 +50,15 @@ mid-session switches. - **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. - **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, and re-dispatches implement on blocking critique findings. - complex-jwt case prompt states the sync Response contract. + 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 From 46afbbed93f3a8227e37a613e422c8f13a371a14 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 12 Aug 2026 15:28:56 -0700 Subject: [PATCH 24/59] Harden capability evals and add SWE-style complex cases Raise the agent timeout default, require web_fetch on web-bait, and seed hermetic skill stubs so eval workdirs stop spamming missing-skill noise. Add complex-bugfix, complex-pagination, and complex-rename-user plus a buggy-service fixture patterned after SWE-bench / Terminal-Bench shapes. --- CHANGELOG.md | 8 + evals/capability/README.md | 5 + .../capability/cases/complex-bugfix/case.json | 9 + .../capability/cases/complex-bugfix/verify.sh | 116 +++++++++++++ .../cases/complex-pagination/case.json | 9 + .../cases/complex-pagination/verify.sh | 159 ++++++++++++++++++ .../cases/complex-rename-user/case.json | 9 + .../cases/complex-rename-user/verify.sh | 150 +++++++++++++++++ evals/capability/cases/web-bait/case.json | 5 +- evals/capability/lib.test.ts | 100 +++++++++++ evals/capability/lib.ts | 106 ++++++++++++ scripts/eval-capability.ts | 69 +++++++- tests/fixtures/buggy-service/README.md | 3 + tests/fixtures/buggy-service/package.json | 14 ++ tests/fixtures/buggy-service/src/index.ts | 12 ++ .../buggy-service/src/middleware/auth.ts | 9 + .../buggy-service/src/middleware/logger.ts | 3 + .../fixtures/buggy-service/src/routes/post.ts | 32 ++++ .../fixtures/buggy-service/src/routes/user.ts | 15 ++ .../buggy-service/src/services/post.ts | 14 ++ .../buggy-service/src/services/user.ts | 14 ++ .../fixtures/buggy-service/src/types/index.ts | 12 ++ .../fixtures/buggy-service/tests/post.test.ts | 36 ++++ .../fixtures/buggy-service/tests/user.test.ts | 16 ++ tests/fixtures/buggy-service/tsconfig.json | 11 ++ 25 files changed, 929 insertions(+), 7 deletions(-) create mode 100644 evals/capability/cases/complex-bugfix/case.json create mode 100755 evals/capability/cases/complex-bugfix/verify.sh create mode 100644 evals/capability/cases/complex-pagination/case.json create mode 100755 evals/capability/cases/complex-pagination/verify.sh create mode 100644 evals/capability/cases/complex-rename-user/case.json create mode 100755 evals/capability/cases/complex-rename-user/verify.sh create mode 100644 tests/fixtures/buggy-service/README.md create mode 100644 tests/fixtures/buggy-service/package.json create mode 100644 tests/fixtures/buggy-service/src/index.ts create mode 100644 tests/fixtures/buggy-service/src/middleware/auth.ts create mode 100644 tests/fixtures/buggy-service/src/middleware/logger.ts create mode 100644 tests/fixtures/buggy-service/src/routes/post.ts create mode 100644 tests/fixtures/buggy-service/src/routes/user.ts create mode 100644 tests/fixtures/buggy-service/src/services/post.ts create mode 100644 tests/fixtures/buggy-service/src/services/user.ts create mode 100644 tests/fixtures/buggy-service/src/types/index.ts create mode 100644 tests/fixtures/buggy-service/tests/post.test.ts create mode 100644 tests/fixtures/buggy-service/tests/user.test.ts create mode 100644 tests/fixtures/buggy-service/tsconfig.json diff --git a/CHANGELOG.md b/CHANGELOG.md index f0c528374..d37e5def7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,14 @@ mid-session switches. (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 diff --git a/evals/capability/README.md b/evals/capability/README.md index 2df5e1e50..701a14a99 100644 --- a/evals/capability/README.md +++ b/evals/capability/README.md @@ -2,6 +2,8 @@ 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. @@ -14,6 +16,9 @@ One run can **try different things**: multiple cases × multiple provider/model | 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 | | 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` | 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-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-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/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..e7c3bb5dc 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, @@ -170,6 +171,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", () => { diff --git a/evals/capability/lib.ts b/evals/capability/lib.ts index 515967bec..f049d4a2f 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 }); diff --git a/scripts/eval-capability.ts b/scripts/eval-capability.ts index 7aa0b8426..83d84eb74 100755 --- a/scripts/eval-capability.ts +++ b/scripts/eval-capability.ts @@ -29,6 +29,7 @@ import { expandMatrix, makeResultKey, evaluateSoftBudget, + checkBehaviorRequirements, httpFixtureEnv, withEnv, detectProviderFallback, @@ -87,7 +88,7 @@ function printUsage(): void { --baseline Compare to prior results JSON --ask-permissions Do not pass --dangerously-skip-permissions --max-turns Soft turn budget (case fails if turnsUsed exceeds; not a hard kill) - --agent-timeout-ms Wall-clock limit for runExec (default 600000) + --agent-timeout-ms Wall-clock limit for runExec (default 1200000) --verify-timeout-ms Wall-clock limit for verify.sh (default 120000) --repeats Runs per case×variant cell (default 1; gate runs use 5) --dry-run List cases × variants only @@ -104,7 +105,7 @@ function parseArgs(argv: readonly string[]): CliOptions { repeats: 1, dryRun: 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++) { @@ -254,10 +255,45 @@ async function withTimeout(promise: Promise, 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 { + 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"); + } +} + 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); // Sibling of the workdir so the agent and verify.sh never see the capture. const capturePath = `${work}-run-summary.json`; await installRunCaptureHook(work, capturePath); @@ -535,6 +571,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 = httpFixture !== null ? httpFixtureEnv(httpFixture) : {}; const verify = await runVerify(caseDef, workdir, opts.verifyTimeoutMs, verifyEnv); if (verify.output.trim().length > 0) { @@ -545,8 +591,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 +604,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 +617,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 +650,7 @@ async function runCase( repeat, behaviors, providerFallback, - textPreview: preview, + textPreview, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); 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 = 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 = 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 = 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"] +} From 927de84feddd77bb94d94cc04eadc29d598a8064 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 12 Aug 2026 15:38:21 -0700 Subject: [PATCH 25/59] Advertise web_fetch on the wire and expand capability cases web_fetch/web_search were registered but only discoverable via tool_search, so models thrashed tool_search on web-bait despite Skywalker saying they were mounted. Put both in CATALOG_TOOL_NAMES. Harden the eval harness (20m timeout, requireBehaviors, skill stubs) and add complex-bugfix/pagination/rename-user. --- CHANGELOG.md | 8 ++++++++ src/agent/prompts.test.ts | 7 ++----- src/agent/tool-search.test.ts | 29 +++++++++++++++++++++-------- src/agent/tool-search.ts | 9 +++++++-- 4 files changed, 38 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d37e5def7..97e454e80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,14 @@ mid-session switches. mode picker and Settings → Session rows are removed. Legacy `sessionMode` in settings files still loads without error and is ignored (CL-5814). +### Fixed + +- **`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 - **Capability eval: `complex-stock-gate`.** Multi-file stock-gated `POST /orders` diff --git a/src/agent/prompts.test.ts b/src/agent/prompts.test.ts index dc56d8608..3b04fee28 100644 --- a/src/agent/prompts.test.ts +++ b/src/agent/prompts.test.ts @@ -8,9 +8,8 @@ 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, @@ -18,8 +17,6 @@ const REGISTERED_TOOL_NAMES = new Set([ "write_file", "edit_file", "delete_file", - "web_fetch", - "web_search", ]); const REFERENCED_TOOL_NAMES = [ diff --git a/src/agent/tool-search.test.ts b/src/agent/tool-search.test.ts index 9c81fc647..53d492dc3 100644 --- a/src/agent/tool-search.test.ts +++ b/src/agent/tool-search.test.ts @@ -22,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", @@ -42,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", () => { @@ -51,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", () => { @@ -86,6 +91,14 @@ describe("createToolIndex", () => { 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 }), @@ -123,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 () => { diff --git a/src/agent/tool-search.ts b/src/agent/tool-search.ts index b2db52a18..b25486811 100644 --- a/src/agent/tool-search.ts +++ b/src/agent/tool-search.ts @@ -74,15 +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[] = [ "search_files", "grep", "list_dir", + "web_fetch", + "web_search", ]; // The maximal set of built-in tools — every gate open — in a deterministic @@ -159,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: { From 71aa4c82c7ff2d7a8097f0d3ebd5e808b4d53310 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 12 Aug 2026 17:11:36 -0700 Subject: [PATCH 26/59] Add SWE-bench Lite one-shot smoke on prepaid xAI Pin public smoke runs to xai/thegreataxios + grok-4.5, load a single Lite instance, run product exec, and capture preds.jsonl under evals/public/results for later official Docker grading. --- .gitignore | 1 + CHANGELOG.md | 5 + evals/public/README.md | 63 +++++ package.json | 1 + scripts/eval-public-swe-one.ts | 482 +++++++++++++++++++++++++++++++++ 5 files changed, 552 insertions(+) create mode 100644 evals/public/README.md create mode 100644 scripts/eval-public-swe-one.ts diff --git a/.gitignore b/.gitignore index 4441bf82d..86d124d62 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ worktrees 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 97e454e80..31271559b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,11 @@ mid-session switches. ### 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`), pinned to prepaid `xai/thegreataxios` + `grok-4.5`, 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 diff --git a/evals/public/README.md b/evals/public/README.md new file mode 100644 index 000000000..673fa211d --- /dev/null +++ b/evals/public/README.md @@ -0,0 +1,63 @@ +# 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 (this machine) + +- Use prepaid **`xai/thegreataxios`** + **`grok-4.5`** unless explicitly overridden. +- 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 + +# Default instance: psf__requests-3362 (small repo, single failing test) +bun scripts/eval-public-swe-one.ts \ + --provider xai/thegreataxios \ + --model grok-4.5 + +# Pick any Lite instance_id +bun scripts/eval-public-swe-one.ts --instance pallets__flask-4992 +``` + +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 under the prepaid xAI profile?** + +## 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..bed99f829 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "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/scripts/eval-public-swe-one.ts b/scripts/eval-public-swe-one.ts new file mode 100644 index 000000000..3c40f3e0c --- /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: + * - pins provider/model (default xai/thegreataxios + grok-4.5) + * - 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 + * bun scripts/eval-public-swe-one.ts --instance psf__requests-3362 + * bun scripts/eval-public-swe-one.ts --provider xai/thegreataxios --model grok-4.5 + * + * Optional official grading (heavy; needs Docker resources): + * bun scripts/eval-public-swe-one.ts --instance … --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_PROVIDER = "xai/thegreataxios"; +const DEFAULT_MODEL = "grok-4.5"; +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; + allowOtherProvider: 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 [options] + +One public SWE-bench Lite instance via Corbits product exec. + +Options: + --instance SWE-bench instance_id (default: ${DEFAULT_INSTANCE}) + --provider Must be ${DEFAULT_PROVIDER} unless --allow-other-provider + --model Model id (default: ${DEFAULT_MODEL}) + --subset HF dataset id (default: ${DEFAULT_SUBSET}) + --split Dataset split (default: ${DEFAULT_SPLIT}) + --timeout-ms Agent wall-clock timeout (default: ${DEFAULT_AGENT_TIMEOUT_MS}) + --out Results directory (default: evals/public/results/) + --evaluate After the agent, attempt official SWE-bench Docker eval (heavy) + --allow-other-provider Permit a non-default provider (not recommended here) + --dry-run Load instance + print plan; do not clone or run the agent + -h, --help Show this help +`); +} + +function parseArgs(argv: string[]): CliOptions { + const opts: CliOptions = { + provider: DEFAULT_PROVIDER, + model: DEFAULT_MODEL, + instanceId: DEFAULT_INSTANCE, + subset: DEFAULT_SUBSET, + split: DEFAULT_SPLIT, + agentTimeoutMs: DEFAULT_AGENT_TIMEOUT_MS, + evaluate: false, + allowOtherProvider: 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 "--allow-other-provider": + opts.allowOtherProvider = true; + break; + case "--dry-run": + opts.dryRun = true; + break; + default: + throw new Error(`unknown arg: ${a}`); + } + } + 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 { + 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 { + 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 { + // 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(p: Promise, ms: number, label: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + p, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +async function main(): Promise { + const opts = parseArgs(process.argv.slice(2)); + if (opts.help) { + printHelp(); + process.exit(0); + } + + if (!opts.allowOtherProvider && opts.provider !== DEFAULT_PROVIDER) { + throw new Error( + `provider must be ${DEFAULT_PROVIDER} for this prepaid smoke ` + + `(got ${opts.provider}). Pass --allow-other-provider to override.`, + ); + } + + 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 — check xAI OAuth profile xai/thegreataxios"); + } + 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 + } + } +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(2); +}); From 860d8415ab1893b479d1ac8134ce0e1efc7b7dac Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 18 Aug 2026 12:01:11 -0700 Subject: [PATCH 27/59] Restore live Task rows and drop FLEET board chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sub-agent work paints as ● Task transcript rows with clock and tool again. The standing FLEET agents board and dual-rail chrome are off so checklist, board, and Task rows no longer restate the same fleet. --- CHANGELOG.md | 5 + docs/TUI.md | 217 ++++++++------------------------- src/tui/chrome-state.test.ts | 65 +++++----- src/tui/chrome-state.ts | 19 ++- src/tui/runtime-bridge.test.ts | 105 +++++++--------- src/tui/runtime-bridge.ts | 80 ++++++++---- 6 files changed, 206 insertions(+), 285 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31271559b..ca8336b71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,11 @@ mid-session switches. ### Fixed +- **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 diff --git a/docs/TUI.md b/docs/TUI.md index 5bca8941b..5e0a1b1dc 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -38,20 +38,12 @@ 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 — **except** the -fleet rail. When the terminal is at least `DUAL_MIN_COLUMNS` (100) wide -and the agents zone has rows, `resolveGeometry` switches to `layoutMode: -"dual"`: the transcript (chat) keeps the left column and the agents board -sits as a right rail of width clamped between `RAIL_WIDTH_MIN` (28) and -`RAIL_WIDTH_MAX` (48), targeting `RAIL_WIDTH_FRACTION` (0.38) of content -width with a one-column `RAIL_GUTTER` between them. Dual mode excludes -agents from the vertical chrome sum so the transcript residual is not -shrunk by the board — the rail shares the transcript's vertical residual -instead. Below the dual threshold, or with zero running agents, layout -stays `"stack"` (full-width y-stack, agents under the transcript, above -the task list and prompt). The shell paints dual by absolutely positioning -`agentsBox` beside the transcript (`applyLayout` in `src/tui/shell.ts`); -stack keeps it in the flex column. The side gutter is one column per side +as a single column of content rather than stacked panes. Dual-column +geometry (`layoutMode: "dual"`, `DUAL_MIN_COLUMNS` 100) still exists in +`resolveGeometry` for a right-rail agents board, but the live agents +zone stays empty — fleet status paints as `● Task …` transcript rows +instead (see Agents below) — so dual never engages and layout stays +`"stack"`. 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 @@ -148,25 +140,26 @@ removed line), not a decision marker, and no decision-marker shares that row. The `task` chrome zone renders a standing panel in the bottom chrome above the prompt, one row per open task the task tool has written (`manage_tasks`) — -distinct from the `agents` panel. 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: `formatTasksPanel` (`src/tui/chrome-state.ts`) and -`formatAgentsPanel` are separate formatters feeding separate zones with -separate row types (`TaskPanelRow` vs. `AgentPanelRow`). - -**One live surface at a time (CL-5846).** While any fleet lane is painted on -the agents board, `formatChromeZones` suppresses the task checklist — the same -work must not stand in two chrome lists. When the board is empty, the checklist -returns for open work. A list that is only done/cancelled collapses to null -(no permanent wall of `[x]` rows); while open work remains, recently-done rows -trail so the operator can see items flip complete without a second status log. +distinct from live sub-agent progress. 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: `formatTasksPanel` (`src/tui/chrome-state.ts`) feeds the +checklist zone; live workers paint as `● Task …` transcript rows (see below). + +**One live surface at a time.** While any sub-agent session is `running`, +`formatChromeZones` suppresses the task checklist and keeps the agents zone +empty — the same work must not stand as a FLEET board *and* a checklist *and* +live Task rows. When no lane is running, the checklist returns for open work +(if the operator has opted in with Alt+T). A list that is only done/cancelled +collapses to null (no permanent wall of `[x]` rows); while open work remains, +recently-done rows trail so the operator can see items flip complete without a +second status log. Each row shows a bracket status marker (`[ ]` todo, `[~]` doing, `[x]` done, `[-]` cancelled) ahead of the title. Open work is listed first. 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. +bounded to `TASKS_PANEL_MAX_VISIBLE` rows: 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. Two independent mechanisms keep the task panel from ever costing the prompt box a row on a short terminal, and they guarantee different things. @@ -188,154 +181,50 @@ shell 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 default is opt-in because the checklist's chrome owns too much of the screen to -force into view; the operator toggles it on when they want it, and the fleet -board's CL-5846 one-live-surface rules still apply once it is shown. +force into view; the operator toggles it on when they want it, and live Task +rows still win while a fleet is running. The task tool writes state through `ChatDirectorImpl` (`src/agent/director.ts`), which calls `onTasksChange` on every `manage_tasks` tool call and on session -The `agents` chrome zone is the **fleet board**: a standing picture of live -workers. On a dual-width terminal it paints as the right rail beside chat; -on a narrow terminal it stacks under the transcript and above the task list -and prompt. `formatAgentsPanel` (`src/tui/chrome-state.ts`) always leads -with a one-line `FLEET` header (`N lanes · N working` / trouble counts -first), then one single-line row per currently-running sub-agent. The -marker is `●` for a live lane and `!` for one that has gone quiet, painted -in bronze (`UI.inFlight`) for live work and red (`UI.action`) for a stalled -one — the marker already names the state, the hue only carries the urgency. -Past the marker each row reads `agentId description` with a right-aligned -tail `· · `: the clock/tool/stall computation is -shared with the transcript task trailer (`agentProgress` in -`src/tui/agent-progress.ts`). The panel does not compute progress a second -way. Only a fan-out past `AGENTS_PANEL_MAX_VISIBLE` adds a trailing -`+N more` row (or a header `+N hidden` under a tight height), painted in -dim because it is chrome about the strip, not a lane in it. - -Tool names on the board come from the **subagent store** -(`currentToolName` + `currentToolStartedAt`), not from the -`subagent.progress` event channel. Progress pings carry a name with no -clock and can fire on completion, so painting from them produced false -stalls; the host paints zones straight from store-backed chrome state. -fleet header row: each row is one lane, led by a health marker that is what -makes the strip read as a strip of workers rather than a list of titles -(`formatAgentsPanel` in `src/tui/chrome-state.ts`). The marker is `●` for a -live lane and `!` for one that has gone quiet, painted in bronze -(`UI.inFlight`) for live work and red (`UI.action`) for a stalled one — the -marker already names the state, the hue only carries the urgency. Past the -marker each row reads `agentId: description · · `: a clock -tail from the same `agentProgress()` clock/tool/stall computation used to -trail a task row in the transcript (`src/tui/agent-progress.ts`), with no -state word prefix — the marker already carries it. The panel does not -compute progress a second way. Only a fan-out past `AGENTS_PANEL_MAX_VISIBLE` -adds a trailing `+N more` row, painted in dim because it is chrome about the -strip, not a lane in it. - -`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 — the checklist is the +only surface for that list. + +## 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 `agents: null`. Dual geometry remains in +`resolveGeometry` but never engages without agents rows. ### Unprompted fleet reports -The agents panel is the standing picture of live work. Parent prose owns -success narratives. Transcript fleet notices exist only for attention the -strip cannot keep (CL-5846): a lane **failed** or **went quiet** 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 — they restated the -strip and the parent and turned the transcript into a second status log. -A stall reads `desc went quiet (clock)`. +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 the panel reads and the same `agentProgress()` stall definition so the -two surfaces never disagree about whether a lane is stalled. 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 +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 diff --git a/src/tui/chrome-state.test.ts b/src/tui/chrome-state.test.ts index e7ae74615..1cab8b76f 100644 --- a/src/tui/chrome-state.test.ts +++ b/src/tui/chrome-state.test.ts @@ -38,7 +38,7 @@ describe("formatChromeZones", () => { expect(out.agents).toBeNull() }) - test("full state: fleet board wins — checklist suppressed while lanes run", () => { + test("running agents: agents zone stays null; checklist suppressed", () => { const state: ChromeLiveState = { task: [ { title: "chrome live helper", status: "doing" }, @@ -64,20 +64,38 @@ describe("formatChromeZones", () => { ], } const out = formatChromeZones(state, NOW) - // One live surface (CL-5846): fleet board owns chrome while lanes run. + // Transcript Task rows own live lane status — no FLEET board / agents zone. + // Checklist is suppressed while any lane is running. expect(out.task).toBeNull() - expect(out.agents).toEqual([ - { label: "FLEET 1 lane · 1 working", tail: "", stalled: false, kind: "header" }, + expect(out.agents).toBeNull() + }) + + test("idle with checklist still formats tasks when no lane is running", () => { + const out = formatChromeZones( { - label: "● explore map setChromeZones callers", - tail: " · 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).toEqual([ + { label: "chrome live helper", status: "doing" }, + { label: "wire chrome zone", status: "todo" }, ]) }) - test("observe overrides the agents panel", () => { + test("observe does not force an agents panel via formatChromeZones", () => { const out = formatChromeZones( { agents: [ @@ -95,13 +113,10 @@ describe("formatChromeZones", () => { }, NOW, ) - expect(out.agents).toEqual([ - { - label: "observe: explore — map callers of openListOverlay", - tail: "", - stalled: false, - }, - ]) + // Agents zone is always null from formatChromeZones; observe is not a + // chrome-zone surface here (dual-rail never engages). + expect(out.agents).toBeNull() + expect(out.task).toBeNull() }) }) @@ -349,17 +364,9 @@ describe("chromeFromSession", () => { ]) const zones = formatChromeZones(state, NOW) - // Fleet board owns chrome while lanes run; checklist is suppressed (CL-5846). + // Running lanes suppress checklist; agents zone stays null (no FLEET board). expect(zones.task).toBeNull() - expect(zones.agents).toEqual([ - { label: "FLEET 1 lane · 1 working", tail: "", stalled: false, kind: "header" }, - { - label: "● explore map callers", - tail: " · 0:05 · grep", - stalled: false, - kind: "lane", - }, - ]) + expect(zones.agents).toBeNull() }) test("falls back agent id; empty bags hide", () => { @@ -378,7 +385,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" }, }) @@ -386,9 +393,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() }) }) diff --git a/src/tui/chrome-state.ts b/src/tui/chrome-state.ts index 8b231baad..daebd213e 100644 --- a/src/tui/chrome-state.ts +++ b/src/tui/chrome-state.ts @@ -132,17 +132,24 @@ export type FormattedChromeZones = { * Empty / partial / inactive inputs yield null for the corresponding zone * so geometry collapses that strip (idleDefault 0). * - * When a live fleet board is up, the manage_tasks checklist is suppressed — - * two standing lists of the same work is the visual mess CL-5846 removes. - * Checklist returns once no lane is running. + * Live sub-agent work paints as `● Task …` transcript rows (runtime-bridge), + * not as a standing FLEET board or dual-rail agents zone — those restated the + * same lanes above the chat and made progress harder to read. The agents zone + * stays empty so dual layout never engages. The manage_tasks checklist is + * also suppressed while any lane is running; it returns once the fleet is dry. */ export function formatChromeZones( state: ChromeLiveState, nowMs: number = Date.now(), ): FormattedChromeZones { - const agents = formatAgentsPanel(state.agents, state.observe, nowMs) - // One live work surface: fleet board while lanes run; checklist otherwise. - const task = agents !== null ? null : formatTasksPanel(state.task) + void nowMs + const hasLiveAgents = + state.agents !== null && + state.agents !== undefined && + state.agents.some((s) => s.status === "running") + // Fleet board chrome is off: transcript Task rows own live lane status. + const agents = null + const task = hasLiveAgents ? null : formatTasksPanel(state.task) return { task, agents } } diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index a1c492182..effec6e99 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -655,10 +655,13 @@ describe("committed inference retry", () => { }) describe("parallel sub-agent dispatch on the live session bridge", () => { - // Task dispatches no longer paint transcript rows (fleet board owns live - // state — CL-5846). This pins that three parallel task calls leave the - // stream clean of Task tool rows, while a non-panel tool still paints. - test("three parallel task calls paint no transcript tool rows", async () => { + // The live main-session path tracks a call's row by callId in its own map + // (applyToolCall/applyToolResult), independent of tool-rows.ts's name-based + // pendingCallIndex — this pins that down so a future change to either path + // cannot silently reintroduce CL-5562's misattribution on the parent + // transcript specifically (the observe overlay and resumed history are + // covered separately in tool-rows.test.ts / history-hydrate.test.ts). + test("three parallel task calls resolve to three rows, each with its own result", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -668,7 +671,6 @@ describe("parallel sub-agent dispatch on the live session bridge", () => { }) const bridge = attachSessionBridge(shell, createRecordingPort()) try { - const before = streamRowCount(shell) const events = [ { type: "inference.start", data: {} }, { @@ -696,8 +698,10 @@ describe("parallel sub-agent dispatch on the live session bridge", () => { for (const event of events) bridge.handle(event) const toolRows = shell.streamLog.filter((r) => r.role === "tool") - expect(toolRows.length).toBe(0) - expect(streamRowCount(shell)).toBe(before) + expect(toolRows.length).toBe(3) + expect(toolRows.every((r) => r.pending !== true)).toBe(true) + expect(toolRows.every((r) => r.failed !== true)).toBe(true) + expect(toolRows.map((r) => r.text)).toEqual(["done c1", "done c2", "done c3"]) } finally { bridge.dispose() shell.dispose() @@ -722,7 +726,7 @@ describe("syncAgentProgress", () => { } } - test("task dispatches paint no transcript rows for progress to rewrite (fleet board owns live state)", async () => { + test("updates the dispatch row in place without appending or removing rows", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -730,6 +734,8 @@ describe("syncAgentProgress", () => { wireKeys: false, run: "busy", }) + // Padding rows ahead of the dispatch: proves churn stays bounded by + // outstanding task calls, not by transcript length. for (let i = 0; i < 40; i++) { appendStreamRow(shell, { role: "assistant", text: `filler ${i}` }) } @@ -738,7 +744,6 @@ describe("syncAgentProgress", () => { now: () => nowMs, }) try { - const before = streamRowCount(shell) bridge.handle({ type: "inference.tool_call.end", data: { @@ -748,12 +753,32 @@ describe("syncAgentProgress", () => { }, }) await h.renderOnce() - expect(streamRowCount(shell)).toBe(before) + const rowCountBefore = streamRowCount(shell) + const removeSpy = spyOn(shell.transcript, "remove") nowMs = 42_000 bridge.syncAgentProgress([taskSession({ lastActivityAt: nowMs })]) - // No transcript Task row exists; progress is a no-op for stream log. - expect(streamRowCount(shell)).toBe(before) + bridge.syncAgentProgress([ + taskSession({ currentToolName: "grep", lastActivityAt: nowMs }), + ]) + + expect(streamRowCount(shell)).toBe(rowCountBefore) + // One rewrite per changed tick, never proportional to the 40 padding rows. + expect(removeSpy.mock.calls.length).toBeLessThanOrEqual(2) + + const row = shell.streamLog[rowCountBefore - 1]! + expect(row.pending).toBe(true) + expect(row.agentWorking).toBe(true) + expect(row.stat).toContain("grep") + + nowMs = 72_000 + bridge.syncAgentProgress([ + taskSession({ currentToolName: "grep", lastActivityAt: 42_000 }), + ]) + const stalledRow = shell.streamLog[rowCountBefore - 1]! + expect(stalledRow.agentWorking).toBe(false) + + removeSpy.mockRestore() } finally { bridge.dispose() shell.dispose() @@ -763,7 +788,7 @@ describe("syncAgentProgress", () => { ) }) - test("a finished task result is dropped with the call (no unpaired terminal Task row)", async () => { + test("a finished session's row is left to the tool-result path", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -773,7 +798,6 @@ describe("syncAgentProgress", () => { }) const bridge = attachSessionBridge(shell, createRecordingPort()) try { - const before = streamRowCount(shell) bridge.handle({ type: "inference.tool_call.end", data: { @@ -786,9 +810,10 @@ describe("syncAgentProgress", () => { type: "tool.done", data: { result: { callId: "task-1", name: "task", content: "done", isError: false } }, }) - expect(streamRowCount(shell)).toBe(before) + const index = shell.streamLog.length - 1 bridge.syncAgentProgress([taskSession({ status: "done" })]) - expect(streamRowCount(shell)).toBe(before) + expect(shell.streamLog[index]!.pending).not.toBe(true) + expect(shell.streamLog[index]!.agentWorking).toBeUndefined() } finally { bridge.dispose() shell.dispose() @@ -840,54 +865,6 @@ describe("task checklist calls stay out of the transcript", () => { ) }) - test("a task dispatch call and its result paint no rows (fleet board owns live state)", async () => { - await withTestRenderer( - async (h) => { - const shell = createAppShell(h.renderer, { - terminal: { columns: 80, rows: 24 }, - wireKeys: false, - run: "busy", - }) - const bridge = attachSessionBridge(shell, createRecordingPort()) - try { - appendStreamRow(shell, { role: "assistant", text: "spinning workers" }) - const before = streamRowCount(shell) - - bridge.handle({ - type: "inference.tool_call.end", - data: { - name: "task", - callId: "task-1", - arguments: { - description: "explore auth", - prompt: "map auth callers", - intent: "explore", - }, - }, - }) - bridge.handle({ - type: "tool.done", - data: { - result: { - callId: "task-1", - name: "task", - content: "Summary: auth is in src/auth", - isError: false, - }, - }, - }) - - // Live Task rows restate what the fleet board already shows. - expect(streamRowCount(shell)).toBe(before) - } finally { - bridge.dispose() - shell.dispose() - } - }, - { width: 80, height: 24 }, - ) - }) - test("an errored manage_tasks result is dropped rather than left unpaired", async () => { await withTestRenderer( async (h) => { diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 0df3638cc..977f2f87f 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -72,14 +72,14 @@ import { import type { StreamRow } from "./stream.js" import { advanceRevealChars, flattenReasoningText, type Thought } from "./thinking.js" import { + agentProgress, fleetProgress, type AgentProgressSession, } from "./agent-progress.js" -/** Tool name a sub-agent dispatch call carries (fleet board owns live state). */ +/** Tool name a sub-agent dispatch call carries — its row gets live progress. */ const TASK_TOOL_NAME = "task" - /** * Tool name the task checklist is written through. Its calls paint no * transcript row: the list they write is live state owned by the task panel, @@ -88,17 +88,6 @@ const TASK_TOOL_NAME = "task" */ const MANAGE_TASKS_TOOL_NAME = "manage_tasks" -/** - * Tools whose live work is already owned by standing chrome (fleet board / - * task panel). Call + result paint no transcript rows — the board is the - * live surface; re-announcing the same dispatch as a `● Task` line is noise - * (CL-5846 first cut). - */ -const PANEL_OWNED_TOOL_NAMES: ReadonlySet = new Set([ - MANAGE_TASKS_TOOL_NAME, - TASK_TOOL_NAME, -]) - /** A sub-agent session as `syncAgentProgress` needs it: identified, and live-readable. */ export type TaskProgressSession = AgentProgressSession & { readonly id: string } import { @@ -368,6 +357,12 @@ type BridgeBag = { toolRows: Map /** Row of the newest in-flight call, for results that carry no call id. */ lastToolRow: number + /** + * callIds of outstanding `task` calls — a subset of `toolRows`' keys. Kept + * separate so `syncAgentProgress` never has to walk every in-flight tool to + * find the handful that are sub-agent dispatches. + */ + taskCallIds: Set /** * Last sub-agent session list the host synced. Retained rather than consumed * and dropped because the status ticker recomputes fleet state at paint time @@ -375,8 +370,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 /** @@ -548,10 +544,10 @@ function applyToolCall( bag: BridgeBag, event: Extract, ): void { - if (PANEL_OWNED_TOOL_NAMES.has(event.name)) { + 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. Live Task state - // lives on the fleet board; manage_tasks lives on the task panel. + // 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 } @@ -568,6 +564,9 @@ function applyToolCall( appendStreamRow(shell, row) } if (event.callId !== undefined) bag.toolRows.set(event.callId, index) + if (event.callId !== undefined && event.name === TASK_TOOL_NAME) { + bag.taskCallIds.add(event.callId) + } bag.lastToolRow = index } @@ -591,6 +590,7 @@ function applyToolResult( event.callId !== undefined ? bag.toolRows.get(event.callId) : undefined if (event.callId !== undefined) { bag.toolRows.delete(event.callId) + bag.taskCallIds.delete(event.callId) } const index = tracked ?? bag.lastToolRow const call = streamRowAt(shell, index) @@ -601,6 +601,44 @@ function applyToolResult( replaceStreamRowAt(shell, index, mergeToolRows(call, result)) } +/** + * 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`; 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, + bag: BridgeBag, + sessions: readonly TaskProgressSession[], + nowMs: number, +): void { + if (bag.taskCallIds.size === 0) return + for (const callId of bag.taskCallIds) { + const index = bag.toolRows.get(callId) + if (index === undefined) { + bag.taskCallIds.delete(callId) + continue + } + const row = streamRowAt(shell, index) + if (row === undefined || row.pending !== true) { + bag.taskCallIds.delete(callId) + continue + } + const session = sessions.find((s) => s.id === callId) + if (session === undefined) continue + const progress = agentProgress(session, nowMs) + if (progress === null) continue + if (row.stat === progress.stat && row.agentWorking === progress.working) continue + replaceStreamRowAt(shell, index, { + ...row, + stat: progress.stat, + agentWorking: progress.working, + }) + } +} + /** * Retract everything the failed attempt painted, then forget the row * bookkeeping that pointed into it — a rolled-back tool call has no row left @@ -614,6 +652,7 @@ function rollbackAttempt(shell: AppShell, bag: BridgeBag): void { for (const [callId, index] of [...bag.toolRows]) { if (index >= boundary) { bag.toolRows.delete(callId) + bag.taskCallIds.delete(callId) } } if (bag.lastToolRow >= boundary) bag.lastToolRow = -1 @@ -735,6 +774,7 @@ export function attachSessionBridge( now, toolRows: new Map(), lastToolRow: -1, + taskCallIds: new Set(), agentSessions: [], panelOnlyCallIds: new Set(), attemptRow: null, @@ -1126,10 +1166,8 @@ export function attachSessionBridge( }, syncAgentProgress: (sessions) => { if (bag.disposed) return - // Live task state is owned by the fleet board, which recomputes from this - // session list at paint time. Task calls paint no transcript rows (they - // are panel-owned), so there is no per-call row to refresh here. bag.agentSessions = sessions + syncAgentProgress(shell, bag, sessions, now()) }, dispose: () => { bag.disposed = true From bf69cc5c99bc84534a00dedc4197f3890f5b305c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 18 Aug 2026 12:18:32 -0700 Subject: [PATCH 28/59] Remove dual-column fleet rail; stack layout only Drop DUAL_MIN_COLUMNS / RAIL_WIDTH_* and dual agents-box positioning. Geometry always stacks; live fleet status stays on Task transcript rows. --- CHANGELOG.md | 5 ++ docs/TUI.md | 25 +++--- src/tui/chrome-state.test.ts | 2 +- src/tui/geometry.test.ts | 81 +++++++------------- src/tui/geometry/index.ts | 5 -- src/tui/geometry/resolve.ts | 144 ++++++----------------------------- src/tui/geometry/zones.ts | 29 +------ src/tui/shell.ts | 54 ++++--------- 8 files changed, 88 insertions(+), 257 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca8336b71..f24549d7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,11 @@ mid-session switches. 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 diff --git a/docs/TUI.md b/docs/TUI.md index 5e0a1b1dc..929160f66 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -38,17 +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. Dual-column -geometry (`layoutMode: "dual"`, `DUAL_MIN_COLUMNS` 100) still exists in -`resolveGeometry` for a right-rail agents board, but the live agents -zone stays empty — fleet status paints as `● Task …` transcript rows -instead (see Agents below) — so dual never engages and layout stays -`"stack"`. 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. +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 @@ -202,8 +201,8 @@ operator-preferred Amp/Codex-style lines: `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 `agents: null`. Dual geometry remains in -`resolveGeometry` but never engages without agents rows. +`formatChromeZones` always returns `agents: null`, and geometry is stack-only +(`layoutMode: "stack"`, `railWidth: 0`). ### Unprompted fleet reports diff --git a/src/tui/chrome-state.test.ts b/src/tui/chrome-state.test.ts index 1cab8b76f..9c2680b1c 100644 --- a/src/tui/chrome-state.test.ts +++ b/src/tui/chrome-state.test.ts @@ -114,7 +114,7 @@ describe("formatChromeZones", () => { NOW, ) // Agents zone is always null from formatChromeZones; observe is not a - // chrome-zone surface here (dual-rail never engages). + // chrome-zone surface here (agents zone stays empty; stack-only layout). expect(out.agents).toBeNull() expect(out.task).toBeNull() }) diff --git a/src/tui/geometry.test.ts b/src/tui/geometry.test.ts index ae8350d50..231b191af 100644 --- a/src/tui/geometry.test.ts +++ b/src/tui/geometry.test.ts @@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"; import { AGENTS_PANEL_MAX_VISIBLE, COLLAPSE_ORDER, - DUAL_MIN_COLUMNS, FLEET_BOARD_CAP_FRACTION, FLEET_TRANSCRIPT_FLOOR, IDLE_TRANSCRIPT_FLOOR, @@ -10,9 +9,6 @@ import { PROMPT_BASE_ROWS, PROMPT_CAP_FRACTION, PROMPT_IDLE_ROWS, - RAIL_GUTTER, - RAIL_WIDTH_MAX, - RAIL_WIDTH_MIN, SIDE_MARGIN, TASKS_PANEL_MAX_VISIBLE, ZONE_IDS, @@ -76,7 +72,7 @@ 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"); - // No agents → stack defaults for dual fields. + // Stack-only: full-width chat, no rail. expect(layout.layoutMode).toBe("stack"); expect(layout.chatWidth).toBe(layout.contentWidth); expect(layout.railWidth).toBe(0); @@ -144,19 +140,21 @@ describe("resolveGeometry — agents panel", () => { expect(layout.transcriptHeight).toBeGreaterThanOrEqual(layout.transcriptFloor); }); - test("a taller dual terminal honours the agents row request (capped by residual)", () => { + 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, }); - expect(tall.layoutMode).toBe("dual"); - // Dual caps agents to transcript residual, not to 1. + expect(tall.layoutMode).toBe("stack"); + expect(tall.railWidth).toBe(0); expect(tall.heights.agents).toBe(14); - expect(tall.heights.agents).toBeLessThanOrEqual(tall.transcriptHeight); + 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 on a narrow terminal the agents zone stacks", () => { + test("with a fleet running the agents zone stacks under the transcript", () => { const fleet = resolveGeometry({ terminal: { columns: 80, rows: 24 }, visibility: { agents: 1 }, @@ -492,19 +490,16 @@ describe("resolveGeometry — resize / residual", () => { }); }); -describe("resolveGeometry — dual column fleet rail", () => { - test("wide terminal with agents → dual: rail width clamped, columns sum to contentWidth", () => { +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("dual"); - expect(layout.railWidth).toBeGreaterThanOrEqual(RAIL_WIDTH_MIN); - expect(layout.railWidth).toBeLessThanOrEqual(RAIL_WIDTH_MAX); - expect(layout.railGutter).toBe(RAIL_GUTTER); - expect(layout.chatWidth + layout.railGutter + layout.railWidth).toBe( - layout.contentWidth, - ); + 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; @@ -513,39 +508,32 @@ describe("resolveGeometry — dual column fleet rail", () => { expect(agents).toBeDefined(); expect(prompt).toBeDefined(); - // Agents rail sits to the right of chat at the same y. - expect(agents!.x).toBeGreaterThan(transcript!.x); - expect(agents!.y).toBe(transcript!.y); - expect(transcript!.width).toBe(layout.chatWidth); - expect(agents!.width).toBe(layout.railWidth); + // 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); - - // Prompt stays full content width under both columns. expect(prompt!.width).toBe(layout.contentWidth); expect(prompt!.x).toBe(layout.sideMargin); }); - test("dual agents height does not reduce transcript vs stack baseline", () => { - const dual = resolveGeometry({ + test("agents height reduces transcript vs idle baseline (stack chrome)", () => { + const withAgents = resolveGeometry({ terminal: { columns: 120, rows: 32 }, visibility: { agents: 8 }, }); - // Same width/rows with no agents: stack residual is the dual baseline. - const stack = resolveGeometry({ + const idle = resolveGeometry({ terminal: { columns: 120, rows: 32 }, visibility: { agents: 0 }, }); - expect(dual.layoutMode).toBe("dual"); - expect(stack.layoutMode).toBe("stack"); - // Dual excludes agents from chrome, so transcript matches idle residual. - expect(dual.transcriptHeight).toBe(stack.transcriptHeight); - expect(dual.chromeHeight).toBe(stack.chromeHeight); - expect(dual.heights.agents).toBe(8); - expect(dual.heights.agents).toBeLessThanOrEqual(dual.transcriptHeight); + 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", () => { - expect(80).toBeLessThan(DUAL_MIN_COLUMNS); const layout = resolveGeometry({ terminal: { columns: 80, rows: 24 }, visibility: { agents: 5 }, @@ -556,13 +544,11 @@ describe("resolveGeometry — dual column fleet rail", () => { expect(layout.chatWidth).toBe(layout.contentWidth); expect(layout.regions.transcript?.width).toBe(layout.contentWidth); expect(layout.regions.agents?.width).toBe(layout.contentWidth); - // Stack: agents sit below transcript. expect(layout.regions.agents!.y).toBeGreaterThan(layout.regions.transcript!.y); - // Stack agents consume vertical chrome. expect(layout.chromeHeight).toBeGreaterThan(PROMPT_IDLE_ROWS); }); - test("no agents → always stack even on a wide terminal", () => { + test("no agents → stack with railWidth 0 even on a wide terminal", () => { const layout = resolveGeometry({ terminal: { columns: 120, rows: 40 }, visibility: { agents: 0 }, @@ -573,18 +559,5 @@ describe("resolveGeometry — dual column fleet rail", () => { expect(layout.chatWidth).toBe(layout.contentWidth); expect(layout.regions.agents).toBeUndefined(); }); - - test("dual threshold is DUAL_MIN_COLUMNS (100)", () => { - const justBelow = resolveGeometry({ - terminal: { columns: DUAL_MIN_COLUMNS - 1, rows: 32 }, - visibility: { agents: 4 }, - }); - const atThreshold = resolveGeometry({ - terminal: { columns: DUAL_MIN_COLUMNS, rows: 32 }, - visibility: { agents: 4 }, - }); - expect(justBelow.layoutMode).toBe("stack"); - expect(atThreshold.layoutMode).toBe("dual"); - }); }); diff --git a/src/tui/geometry/index.ts b/src/tui/geometry/index.ts index 6148828e9..9a88956e4 100644 --- a/src/tui/geometry/index.ts +++ b/src/tui/geometry/index.ts @@ -1,7 +1,6 @@ export { AGENTS_PANEL_MAX_VISIBLE, COLLAPSE_ORDER, - DUAL_MIN_COLUMNS, FLEET_BOARD_CAP_FRACTION, FLEET_FLOOR_MIN_LANES, FLEET_TRANSCRIPT_FLOOR, @@ -15,10 +14,6 @@ export { PROMPT_CAP_FRACTION, PROMPT_IDLE_INPUT_ROWS, PROMPT_IDLE_ROWS, - RAIL_GUTTER, - RAIL_WIDTH_FRACTION, - RAIL_WIDTH_MAX, - RAIL_WIDTH_MIN, TASKS_PANEL_MAX_VISIBLE, ZONE_IDS, ZONE_REGISTRY, diff --git a/src/tui/geometry/resolve.ts b/src/tui/geometry/resolve.ts index fe4a633d5..5f0f4af5f 100644 --- a/src/tui/geometry/resolve.ts +++ b/src/tui/geometry/resolve.ts @@ -4,7 +4,6 @@ import { resolveContentWidth, resolveSideMargin } from "./margins.js"; import { COLLAPSE_ORDER, - DUAL_MIN_COLUMNS, FLEET_BOARD_CAP_FRACTION, IDLE_TRANSCRIPT_FLOOR, OVERLAY_MAX_FRACTION, @@ -14,10 +13,6 @@ import { PROMPT_BASE_ROWS, PROMPT_CAP_FRACTION, PROMPT_IDLE_ROWS, - RAIL_GUTTER, - RAIL_WIDTH_FRACTION, - RAIL_WIDTH_MAX, - RAIL_WIDTH_MIN, ZONE_REGISTRY, type ZoneId, } from "./zones.js"; @@ -87,8 +82,8 @@ export type Rect = { readonly height: number; }; -/** Vertical stack (narrow / no agents) vs chat+rail dual column. */ -export type LayoutMode = "stack" | "dual"; +/** Full-width y-stack only. Kept for API stability with shell callers. */ +export type LayoutMode = "stack"; export type GeometryLayout = { readonly terminal: TerminalSize; @@ -108,16 +103,13 @@ export type GeometryLayout = { readonly sideMargin: number; /** Zone width after both gutters. */ readonly contentWidth: number; - /** - * `"dual"` when the terminal is wide enough and the agents zone is on — - * chat left, fleet rail right. Otherwise `"stack"` (full-width y-stack). - */ + /** Always `"stack"` — dual-column rail was removed. */ readonly layoutMode: LayoutMode; - /** Transcript / chat column width. Equals contentWidth in stack mode. */ + /** Transcript / chat column width. Equals contentWidth. */ readonly chatWidth: number; - /** Fleet rail width. 0 in stack mode. */ + /** Always 0 — no fleet rail. */ readonly railWidth: number; - /** Columns between chat and rail. 1 in dual, 0 in stack. */ + /** Always 0 — no fleet rail gutter. */ readonly railGutter: number; }; @@ -190,49 +182,16 @@ export function desiredHeights(input: GeometryInput): MutableHeights { return heights; } -/** - * Vertical chrome budget: every non-residual zone. In dual layout the agents - * zone sits beside the transcript and does not consume vertical residual. - */ -function sumChrome(heights: MutableHeights, layoutMode: LayoutMode): number { +/** Vertical chrome budget: every non-residual zone. */ +function sumChrome(heights: MutableHeights): number { let total = 0; for (const id of PAINT_ORDER) { if (id === "transcript" || id === "overlay_host") continue; - if (layoutMode === "dual" && id === "agents") continue; total += heights[id]; } return total; } -/** Dual when wide enough and the agents zone has rows to paint. */ -function resolveLayoutMode( - columns: number, - agentsRows: number, -): LayoutMode { - return columns >= DUAL_MIN_COLUMNS && agentsRows > 0 ? "dual" : "stack"; -} - -/** - * Split content width into chat + gutter + rail for dual, or full-width chat - * for stack. Rail is ~RAIL_WIDTH_FRACTION of content, clamped to [min, max]. - */ -function resolveColumnWidths( - contentWidth: number, - layoutMode: LayoutMode, -): { chatWidth: number; railWidth: number; railGutter: number } { - if (layoutMode !== "dual") { - return { chatWidth: contentWidth, railWidth: 0, railGutter: 0 }; - } - const railWidth = clamp( - Math.round(contentWidth * RAIL_WIDTH_FRACTION), - RAIL_WIDTH_MIN, - RAIL_WIDTH_MAX, - ); - const railGutter = RAIL_GUTTER; - const chatWidth = Math.max(1, contentWidth - railWidth - railGutter); - return { chatWidth, railWidth, railGutter }; -} - function transcriptFloorFor(mode: OverlayMode, terminalRows: number): number { if (mode === "full_shell") return 0; if (mode === "inset") { @@ -272,16 +231,12 @@ function desiredOverlayHeight( /** * One collapse step: reduce the next collapsible zone. * Returns the zone id that was reduced, or null if nothing left to cut. - * In dual layout the agents rail does not free vertical residual, so it is - * skipped (its height is capped to transcript residual after collapse). */ function collapseOnce( heights: MutableHeights, collapsed: ZoneId[], - layoutMode: LayoutMode, ): ZoneId | null { for (const id of COLLAPSE_ORDER) { - if (layoutMode === "dual" && id === "agents") continue; const h = heights[id]; if (h <= 0) continue; @@ -353,49 +308,29 @@ function collapseOnce( function assignRects( heights: MutableHeights, terminal: TerminalSize, - layoutMode: LayoutMode, - chatWidth: number, - railWidth: number, - railGutter: number, ): Partial> { const regions: Partial> = {}; const x = resolveSideMargin(terminal.columns); const contentWidth = resolveContentWidth(terminal.columns); let y = 0; for (const id of PAINT_ORDER) { - // Dual: agents is placed beside the transcript after the vertical pass. - if (layoutMode === "dual" && id === "agents") continue; const height = heights[id]; if (height <= 0) continue; - const width = - layoutMode === "dual" && id === "transcript" ? chatWidth : contentWidth; regions[id] = { x, y, - width, + width: contentWidth, height, }; y += height; } - - // Dual rail: same y as transcript, to its right past the gutter. - if (layoutMode === "dual" && heights.agents > 0) { - const transcript = regions.transcript; - const agentsY = transcript?.y ?? 0; - regions.agents = { - x: x + chatWidth + railGutter, - y: agentsY, - width: railWidth, - height: heights.agents, - }; - } - return regions; } /** * 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 = { @@ -411,6 +346,7 @@ export function resolveGeometry(input: GeometryInput): GeometryLayout { 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") { @@ -424,18 +360,9 @@ export function resolveGeometry(input: GeometryInput): GeometryLayout { heights.progress_divider = 0; heights.notice = 0; heights.prompt = 0; - const layoutMode: LayoutMode = "stack"; - const chrome = sumChrome(heights, layoutMode); + const chrome = sumChrome(heights); heights.overlay_host = Math.max(0, terminal.rows - chrome); - const columns = resolveColumnWidths(contentWidth, layoutMode); - const regions = assignRects( - heights, - terminal, - layoutMode, - columns.chatWidth, - columns.railWidth, - columns.railGutter, - ); + const regions = assignRects(heights, terminal); return { terminal, transcriptHeight: 0, @@ -449,18 +376,12 @@ export function resolveGeometry(input: GeometryInput): GeometryLayout { sideMargin, contentWidth, layoutMode, - chatWidth: columns.chatWidth, - railWidth: columns.railWidth, - railGutter: columns.railGutter, + chatWidth: contentWidth, + railWidth: 0, + railGutter: 0, }; } - // Dual eligibility is fixed from the desired agents budget + width so - // collapse / residual accounting stay consistent for the whole resolve. - // Final layoutMode re-checks agents height after residual cap. - const dualEligible = resolveLayoutMode(terminal.columns, heights.agents) === "dual"; - const layoutModeForChrome: LayoutMode = dualEligible ? "dual" : "stack"; - // Cap prompt growth against floor before overlay allocation. const promptCap = Math.max( PROMPT_BASE_ROWS, @@ -482,7 +403,7 @@ export function resolveGeometry(input: GeometryInput): GeometryLayout { // of dropping every optional zone. const maxIters = 128; for (let i = 0; i < maxIters; i++) { - const chrome = sumChrome(heights, layoutModeForChrome); + const chrome = sumChrome(heights); const overlay = desiredOverlayHeight( { ...input, terminal }, mode, @@ -496,7 +417,7 @@ export function resolveGeometry(input: GeometryInput): GeometryLayout { break; } // Need more space: collapse one zone, then retry. - const cut = collapseOnce(heights, collapsed, layoutModeForChrome); + const cut = collapseOnce(heights, collapsed); if (cut === null) { // Nothing left — relax the transcript floor rather than leave the // overlay under its own render minimum; accept best effort past that. @@ -508,16 +429,14 @@ export function resolveGeometry(input: GeometryInput): GeometryLayout { ); heights.transcript = Math.max( 0, - terminal.rows - sumChrome(heights, layoutModeForChrome) - heights.overlay_host, + terminal.rows - sumChrome(heights) - heights.overlay_host, ); break; } } // Final consistency: residual must sum exactly to terminal.rows. - // Dual: agents is outside the vertical chrome budget, so transcript keeps - // the full residual that stack would give without an agents strip. - const chromeHeight = sumChrome(heights, layoutModeForChrome); + const chromeHeight = sumChrome(heights); const overlayHeight = heights.overlay_host; heights.transcript = Math.max(0, terminal.rows - chromeHeight - overlayHeight); @@ -527,22 +446,7 @@ export function resolveGeometry(input: GeometryInput): GeometryLayout { heights.transcript += terminal.rows - assigned; } - // Dual rail height: paint/clamp budget is min(requested, transcript residual). - // Stack already fraction-capped agents in desiredHeights / collapse. - if (dualEligible && heights.agents > 0) { - heights.agents = Math.min(heights.agents, heights.transcript); - } - - const layoutMode = resolveLayoutMode(terminal.columns, heights.agents); - const columns = resolveColumnWidths(contentWidth, layoutMode); - const regions = assignRects( - heights, - terminal, - layoutMode, - columns.chatWidth, - columns.railWidth, - columns.railGutter, - ); + const regions = assignRects(heights, terminal); return { terminal, @@ -557,8 +461,8 @@ export function resolveGeometry(input: GeometryInput): GeometryLayout { sideMargin, contentWidth, layoutMode, - chatWidth: columns.chatWidth, - railWidth: columns.railWidth, - railGutter: columns.railGutter, + chatWidth: contentWidth, + railWidth: 0, + railGutter: 0, }; } diff --git a/src/tui/geometry/zones.ts b/src/tui/geometry/zones.ts index fa0a92f94..af58b765b 100644 --- a/src/tui/geometry/zones.ts +++ b/src/tui/geometry/zones.ts @@ -41,32 +41,11 @@ export type ZoneDeclaration = { */ export const AGENTS_PANEL_MAX_VISIBLE = 13; -/** - * Terminal columns at or above which the agents zone may sit as a right rail - * beside the transcript instead of stacking under it. Below this width the - * shell always y-stacks (narrow single-column fallback). - */ -export const DUAL_MIN_COLUMNS = 100; - -/** Target share of content width claimed by the fleet rail in dual layout. */ -export const RAIL_WIDTH_FRACTION = 0.38; - -/** Hard floor on rail width in dual layout (columns). */ -export const RAIL_WIDTH_MIN = 28; - -/** Hard ceiling on rail width in dual layout (columns). */ -export const RAIL_WIDTH_MAX = 48; - -/** Columns of gutter between chat column and rail in dual layout. */ -export const RAIL_GUTTER = 1; - /** * Share of the terminal the fleet board may take before it starts hiding * lanes. The board is sized to its content, so a single lane costs two rows * and a dozen costs thirteen; this only bounds the large fan-out, and the - * transcript keeps everything the board does not ask for. In dual layout the - * board shares the transcript's vertical residual, so this fraction is the - * stack-mode bound only. + * transcript keeps everything the board does not ask for. */ export const FLEET_BOARD_CAP_FRACTION = 0.62; @@ -126,9 +105,9 @@ export const ZONE_REGISTRY: { readonly [K in ZoneId]: ZoneDeclaration } = { idleDefault: 0, alwaysOn: false, }, - // Live agents / fleet rail. Stack mode: bounded strip under the transcript - // (max = visible lanes + trailing "+N more" + header slack). Dual mode: same - // height budget sits beside the transcript and does not consume vertical chrome. + // 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, diff --git a/src/tui/shell.ts b/src/tui/shell.ts index ec1d80515..a4b6119a0 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -1712,8 +1712,6 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void { shell.taskBox.visible = taskH > 0 const agentsH = Math.max(0, h.agents) - const dualRail = layout.layoutMode === "dual" && agentsH > 0 - // Height/position for dual finalized after pad + transcript body are known. shell.agentsBox.visible = agentsH > 0 // Both pads are taken out of the transcript residual, never out of chrome, @@ -1762,26 +1760,15 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void { shell.transcript.visible = transcriptBody > 0 syncTranscriptSpacer(shell) - // Fleet rail: absolute beside the transcript in dual mode; full-width strip - // in the flex stack otherwise. Absolute escapes root padding (floatOverlayHost). - if (dualRail) { - const railH = Math.max(1, Math.min(agentsH, transcriptBody > 0 ? transcriptBody : agentsH)) - shell.agentsBox.position = "absolute" - shell.agentsBox.left = layout.sideMargin + layout.chatWidth + layout.railGutter - shell.agentsBox.width = layout.railWidth - shell.agentsBox.top = padH - shell.agentsBox.height = railH - shell.agentsBox.zIndex = 1 - shell.transcript.width = layout.chatWidth - } else { - 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%" - } + // 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 @@ -1806,10 +1793,7 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void { // the foot and covering it would hide the thing the operator types into. // Stack: topPad, transcript, agents, task, then prompt (notice omitted — // same as before; it is transient chrome between task and prompt). - // Dual: agents is absolute beside the transcript, so it does not add to the - // vertical stack before the prompt. - const stackAgentsH = dualRail ? 0 : agentsH - const promptTop = padH + transcriptBody + stackAgentsH + taskH + 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 @@ -1835,16 +1819,13 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void { repaintTranscriptWindow(shell) } - // Dual/stack flip or rail resize changes the column budget the board fits to. - // Content may be unchanged, so setChromeZones would skip the rebuild — do it - // here when the layout mode or rail width moved. + // 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) { - const agentsWidth = - dualRail && layout.railWidth > 0 ? layout.railWidth : layout.contentWidth renderAgentsRows( shell, clampBoardRows(bag.chrome.agents, agentsH), - agentsWidth, + layout.contentWidth, ) } @@ -4600,17 +4581,12 @@ 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. Dual mode fits rows to the - // rail width; stack uses full content width. + // and tears down the renderables underneath it. Full content width (stack). if (agentsChanged || !budgetUnchanged) { - const agentsWidth = - shell.layout.layoutMode === "dual" && shell.layout.railWidth > 0 - ? shell.layout.railWidth - : shell.layout.contentWidth renderAgentsRows( shell, clampBoardRows(bag.chrome.agents, shell.layout.heights.agents), - agentsWidth, + shell.layout.contentWidth, ) } if (budgetUnchanged) paintChrome(shell) From ced731a8676a90d0bc98f0d465b12194d8988750 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 18 Aug 2026 12:29:25 -0700 Subject: [PATCH 29/59] Stop treating Grok thinking gaps as sub-agent stalls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workbench fleets on grok-4.6 routinely sit 60–120s between tool cycles while the model thinks. Raise DEFAULT_STALL_MS to 120s and give Grok the same 5-minute salvage timeout as other families so healthy inference is not painted or killed as hung. --- CHANGELOG.md | 5 +++++ src/agent/model-family-policy.test.ts | 4 ++-- src/agent/model-family-policy.ts | 14 +++++++------ src/tui/agent-progress.test.ts | 14 ++++++------- src/tui/agent-progress.ts | 3 ++- src/tui/chrome-state.test.ts | 30 +++++++++++++++++---------- 6 files changed, 43 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f24549d7f..0204b764c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,11 @@ mid-session switches. ### Fixed +- **Grok sub-agent stall false positives.** Live fleets on grok-4.6 show routine + 60–120s gaps between tool cycles while the model thinks. UI stall paint was + 30s and Grok's salvage kill was 90s, so healthy thinking looked hung and got + nudged/stopped mid-inference. `DEFAULT_STALL_MS` is 120s; Grok shares the + default 5-minute `subAgentStallTimeoutMs`. - **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) 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..1e362cb4c 100644 --- a/src/agent/model-family-policy.ts +++ b/src/agent/model-family-policy.ts @@ -55,18 +55,20 @@ const DEFAULT_POLICY: Omit = { // 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–120s gaps between tool +// cycles while the model thinks, so the old 90s 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 = { 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/tui/agent-progress.test.ts b/src/tui/agent-progress.test.ts index aa5947067..e3a0640e3 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", + stat: "2:01", state: "stalled", working: false, stalled: true, @@ -101,16 +101,16 @@ 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) }) diff --git a/src/tui/agent-progress.ts b/src/tui/agent-progress.ts index 3a1dbc22f..124a05e7d 100644 --- a/src/tui/agent-progress.ts +++ b/src/tui/agent-progress.ts @@ -57,7 +57,8 @@ export type AgentProgress = { }; /** Silence after which a running worker reads as hung rather than thinking. */ -export const DEFAULT_STALL_MS = 30_000; +export const DEFAULT_STALL_MS = 120_000; + /** * Second, far longer bound: how long one tool call may stay outstanding before diff --git a/src/tui/chrome-state.test.ts b/src/tui/chrome-state.test.ts index 9c2680b1c..62a038daf 100644 --- a/src/tui/chrome-state.test.ts +++ b/src/tui/chrome-state.test.ts @@ -207,8 +207,8 @@ describe("formatAgentsPanel", () => { currentToolStartedAt: null, description: "quiet worker", status: "running", - startedAt: NOW - 60_000, - lastActivityAt: NOW - 40_000, + startedAt: NOW - 180_000, + lastActivityAt: NOW - 130_000, }, ], undefined, @@ -216,7 +216,7 @@ describe("formatAgentsPanel", () => { ) expect(rows?.[1]).toEqual({ label: "! a quiet worker", - tail: " · 1:00", + tail: " · 3:00", stalled: true, kind: "lane", }) @@ -229,7 +229,7 @@ describe("formatAgentsPanel", () => { 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 - 130_000 }, ], undefined, NOW, @@ -430,9 +430,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 (120s) so laneState reaches the in_tool branch, + // still under IN_TOOL_STALL_MS (10 min). + currentToolStartedAt: NOW - 180_000, + startedAt: NOW - 200_000, + lastActivityAt: NOW - 180_000, } test("the panel and the transcript row agree that the lane is in a tool", () => { @@ -449,10 +451,10 @@ describe("lane state survives the mapping hops", () => { expect(rows?.[1]?.kind).toBe("lane") expect(rows?.[1]?.stalled).toBe(false) expect(rows?.[1]?.label.startsWith("● ")).toBe(true) - expect(rows?.[1]?.tail).toContain("run_shell 1:30") + 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)", () => { @@ -472,7 +474,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 - 130_000, + } expect(laneState(silent, NOW)).toBe("stalled") const rows = formatAgentsPanel( @@ -495,7 +502,7 @@ describe("lane state survives the mapping hops", () => { 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 never fills a gap when no call is outstanding", () => { @@ -519,6 +526,7 @@ describe("lane state survives the mapping hops", () => { currentToolName: null, currentToolPreview: null, currentToolStartedAt: null, + lastActivityAt: NOW - 130_000, } const rows = formatAgentsPanel( chromeFromSession({ agents: [silent] }).agents, From a454f560896b2b3c5765f1df9b86e03f9e4ab7a4 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 18 Aug 2026 12:39:53 -0700 Subject: [PATCH 30/59] Keep Grok thinking from looking stalled mid-inference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parent stall notice was 90s while grok-4.6 routinely stays quiet 60–120s between sparse reasoning summaries. Raise the notice to 180s and request detailed reasoning summaries on the grok-responses path so thinking activity keeps the clocks moving. --- CHANGELOG.md | 9 +++++++- src/provider/grok-responses-adapter.test.ts | 24 +++++++++++++++++++++ src/provider/grok-responses-adapter.ts | 5 ++++- src/tui/stall-watchdog.ts | 9 +++++--- 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0204b764c..d21740c64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,7 +57,10 @@ mid-session switches. 60–120s gaps between tool cycles while the model thinks. UI stall paint was 30s and Grok's salvage kill was 90s, so healthy thinking looked hung and got nudged/stopped mid-inference. `DEFAULT_STALL_MS` is 120s; Grok shares the - default 5-minute `subAgentStallTimeoutMs`. + default 5-minute `subAgentStallTimeoutMs`. Parent stall notice is 180s. + 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) @@ -141,6 +144,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/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/tui/stall-watchdog.ts b/src/tui/stall-watchdog.ts index e7c3cfd33..417472abd 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–120s of true client silence mid-think is routine; +// the notice sits above that band. +export const STALL_NOTICE_MS = 180_000 export type ShouldAbortForStallArgs = { readonly status: TurnStatus From 57914684a558e4b80d4cc8e0d02f5f486d2a7e56 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 18 Aug 2026 12:50:54 -0700 Subject: [PATCH 31/59] Stop Skywalker dig fleets from cascading into stall floods Why/how/stall questions were treated 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. Cap concurrent leaves at 4, answer digs directly or with one leaf, and forbid re-fan-out diagnostic waves on stall/salvage. --- CHANGELOG.md | 7 +++++++ src/agent/directors/skywalker/package.test.ts | 11 +++++++++++ src/agent/directors/skywalker/package.ts | 17 +++++++++++++++-- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d21740c64..21251e3b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,13 @@ mid-session switches. ### Fixed +- **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–120s gaps between tool cycles while the model thinks. UI stall paint was 30s and Grok's salvage kill was 90s, so healthy thinking looked hung and got diff --git a/src/agent/directors/skywalker/package.test.ts b/src/agent/directors/skywalker/package.test.ts index 1ca8ae23a..d820ce6cb 100644 --- a/src/agent/directors/skywalker/package.test.ts +++ b/src/agent/directors/skywalker/package.test.ts @@ -78,6 +78,7 @@ describe("skywalkerPackage", () => { ); expect(skywalkerPackage.outOfLane).toContain("product edits"); expect(skywalkerPackage.outOfLane).toContain("general catch-all leaf"); + expect(skywalkerPackage.outOfLane).toContain("diagnostic fleets for why/how/stall questions"); }); test("nudge maxTurns", () => { @@ -91,6 +92,16 @@ describe("skywalkerPackage", () => { expect(p).toContain("0–1 leaf"); expect(p).toContain("2–4 leaves"); expect(p).toContain("split ownership by path/package"); + expect(p).toContain("at most 4 concurrent leaves"); + }); + + 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 leaf"); + expect(p).toContain("Do not reclassify COMMUNICATION as ORCHESTRATION"); }); test("systemPrompt simple path skips explore+critique for tiny work", () => { diff --git a/src/agent/directors/skywalker/package.ts b/src/agent/directors/skywalker/package.ts index c7b0227a4..66fd0f97d 100644 --- a/src/agent/directors/skywalker/package.ts +++ b/src/agent/directors/skywalker/package.ts @@ -48,8 +48,18 @@ Scale fan-out to the ask — do not spawn 10+ leaves for a simple request: - Tiny single-file / one-route asks: **one implement leaf**; 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 leaves with distinct path/package ownership - Complex: more leaves only with named lanes and clear non-overlap +Hard cap: **at most 4 concurrent leaves** 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 leaf 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 leaves stall, loop, or salvage: synthesize what returned, report Blockers, and change approach — do **not** re-fan-out another diagnostic wave on the same topic. +- 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-leaf dispatch, prefer typed spawn with success_criteria, do_not, and report_focus (plus intent/agent). Do not fire multi-leaf waves with one-line vague briefs — flesh the brief first. @@ -87,7 +97,9 @@ Track with manage_tasks. Parallelize independent lanes. Escalate blockers with a ## If COMMUNICATION → answer directly -Clear and short. No dispatch for pure questions. +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 leaf — 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 @@ -131,9 +143,10 @@ export const skywalkerPackage: DirectorPackage = { primaryIntent: "Orchestrate only — triage and dispatch; do not implement product code", outOfLane: [ "product edits", - "deep repo walks when dispatch is available", + "deep multi-path repo walks when a single explore leaf or mounted tools suffice", "being the reviewer/implementer by default", "general catch-all leaf", + "diagnostic fleets for why/how/stall questions", ], description: "Primary orchestration director (Karen-shaped)", systemPrompt: SKYWALKER_SYSTEM_PROMPT, From 3aceec1da64b996d66bd63ff5985ed89a8ab876b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 18 Aug 2026 12:56:23 -0700 Subject: [PATCH 32/59] Put MCP auth attention on the prompt box as mcp ! A server waiting on authorization no longer takes a notice-row sentence. The top rule carries a compact mcp ! immediately left of the model label; /mcp still names the servers. --- docs/TUI.md | 4 ++- src/tui/notice-line.test.ts | 16 ------------ src/tui/notice-line.ts | 29 ++++---------------- src/tui/prompt-border.test.ts | 31 ++++++++++++++++++++++ src/tui/prompt-border.ts | 45 +++++++++++++++++++++++--------- src/tui/prompt-chrome.test.ts | 23 ++++++++++++++++ src/tui/runtime-channels.test.ts | 11 ++++---- src/tui/runtime-notices.test.ts | 2 +- src/tui/runtime-notices.ts | 4 +-- src/tui/shell.ts | 9 +++++-- 10 files changed, 111 insertions(+), 63 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 929160f66..b574b9ebb 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -66,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 diff --git a/src/tui/notice-line.test.ts b/src/tui/notice-line.test.ts index 01e8ab555..f2aaa6f77 100644 --- a/src/tui/notice-line.test.ts +++ b/src/tui/notice-line.test.ts @@ -8,26 +8,10 @@ const state = (over: Partial = {}): NoticeState => ({ 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("") }) diff --git a/src/tui/notice-line.ts b/src/tui/notice-line.ts index 0188b3fc6..9eab14079 100644 --- a/src/tui/notice-line.ts +++ b/src/tui/notice-line.ts @@ -9,6 +9,11 @@ * 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 @@ -27,29 +32,8 @@ export type NoticeState = { /** 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. - */ -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}` -} - -/** - * 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}`) @@ -61,9 +45,6 @@ export function composeNoticeLine(state: NoticeState): string { `${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/prompt-border.test.ts b/src/tui/prompt-border.test.ts index 8bb2e8934..61928d6f7 100644 --- a/src/tui/prompt-border.test.ts +++ b/src/tui/prompt-border.test.ts @@ -138,6 +138,37 @@ 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("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..a28e33c7c 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,26 @@ 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 !" + +/** 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 +183,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 +200,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..b608a70f3 100644 --- a/src/tui/prompt-chrome.test.ts +++ b/src/tui/prompt-chrome.test.ts @@ -8,6 +8,7 @@ import { noticeText, setPromptModelLabel, setPromptWorkspace, + setMcpNeedsAuth, setShellBridgeHooks, setShellExitHandler, setStatusFlash, @@ -136,6 +137,28 @@ 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("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/runtime-channels.test.ts b/src/tui/runtime-channels.test.ts index 2f07d9b5d..df5ee8a00 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() } 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/shell.ts b/src/tui/shell.ts index a4b6119a0..58829c279 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -73,6 +73,7 @@ import type { RampPhase, StallAge } from "./ramp.js" import type { ActivityState } from "./session-chrome.js" import { BORDER, + MCP_ATTENTION_LABEL, composeCostContextMeter, composeRule, composeWorkspaceLabel, @@ -659,7 +660,7 @@ 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[] /** * Clock, motion and content state for the bottom-left status slot. The bridge @@ -846,7 +847,6 @@ export function noticeText(shell: AppShell): string { pinned: !isTranscriptFollowing(shell), flash: shell.statusFlash, attachments: shell.pendingAttachments.length, - mcpNeedsAuth: shell.mcpNeedsAuth, }) } @@ -1532,6 +1532,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), ) @@ -1580,6 +1584,7 @@ export function paintPromptBorder(shell: AppShell): void { const top = composeRule({ width, corners: [BORDER.topLeft, BORDER.topRight], + ...(shell.mcpNeedsAuth.length > 0 ? { attention: MCP_ATTENTION_LABEL } : {}), ...(shell.modelLabel !== null ? { label: shell.modelLabel } : {}), }) shell.promptTopRule.content = new StyledText(ruleChunks(shell, top)) From 6683700d07f4d0b55ceba6be0ecced3f7284d210 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 18 Aug 2026 13:03:13 -0700 Subject: [PATCH 33/59] Paint live reasoning as a short wrapped preview Streaming thinking was a one-line sideways marquee onto the newest tokens. Show up to three inset wrapped lines of the newest revealed prose instead; expand still opens the full block. --- CHANGELOG.md | 4 +++ src/tui/collapse.test.ts | 33 +++++++++++++------- src/tui/runtime-bridge.ts | 5 +-- src/tui/stream.ts | 13 ++++---- src/tui/thinking-reveal.test.ts | 41 ++++++++++++++---------- src/tui/thinking.ts | 55 +++++++++++++++++---------------- src/tui/width-columns.test.ts | 8 +++-- 7 files changed, 93 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21251e3b1..f531651bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,10 @@ mid-session switches. ### 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 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/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 977f2f87f..dad07d43b 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. */ diff --git a/src/tui/stream.ts b/src/tui/stream.ts index 106b49f34..1b74b1895 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 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/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", () => { From 31c2073fb83fd734de9433d466820aa3b74b76bf Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 18 Aug 2026 13:10:29 -0700 Subject: [PATCH 34/59] Raise stall paint to 5 minutes for Grok think gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task rows and the parent stall notice were still firing inside healthy 60–180s Responses quiet gaps. Align DEFAULT_STALL_MS and STALL_NOTICE_MS with the 5-minute sub-agent stall nudge so UI stop marking thinking as hung. --- CHANGELOG.md | 10 +++++----- src/agent/model-family-policy.ts | 4 ++-- src/tui/agent-progress.test.ts | 14 ++++++++++++++ src/tui/agent-progress.ts | 12 ++++++++++-- src/tui/chrome-state.test.ts | 18 +++++++++--------- src/tui/stall-watchdog.ts | 6 +++--- 6 files changed, 43 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f531651bd..b49ba6c26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,11 +65,11 @@ mid-session switches. 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–120s gaps between tool cycles while the model thinks. UI stall paint was - 30s and Grok's salvage kill was 90s, so healthy thinking looked hung and got - nudged/stopped mid-inference. `DEFAULT_STALL_MS` is 120s; Grok shares the - default 5-minute `subAgentStallTimeoutMs`. Parent stall notice is 180s. - The grok-responses path asks for `reasoning.summary: "detailed"` so summary + 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 / diff --git a/src/agent/model-family-policy.ts b/src/agent/model-family-policy.ts index 1e362cb4c..83d295d65 100644 --- a/src/agent/model-family-policy.ts +++ b/src/agent/model-family-policy.ts @@ -57,8 +57,8 @@ const DEFAULT_POLICY: Omit = { // through Linear lookups and code reads (CL-5611), well inside the healthy // 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–120s gaps between tool -// cycles while the model thinks, so the old 90s kill was false-positive +// 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 = { diff --git a/src/tui/agent-progress.test.ts b/src/tui/agent-progress.test.ts index e3a0640e3..d16ab2cfe 100644 --- a/src/tui/agent-progress.test.ts +++ b/src/tui/agent-progress.test.ts @@ -114,6 +114,20 @@ describe("agentProgress", () => { 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", () => { diff --git a/src/tui/agent-progress.ts b/src/tui/agent-progress.ts index 124a05e7d..f32009095 100644 --- a/src/tui/agent-progress.ts +++ b/src/tui/agent-progress.ts @@ -56,8 +56,16 @@ export type AgentProgress = { readonly stalled: boolean; }; -/** Silence after which a running worker reads as hung rather than thinking. */ -export const DEFAULT_STALL_MS = 120_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; /** diff --git a/src/tui/chrome-state.test.ts b/src/tui/chrome-state.test.ts index 62a038daf..a24d0af87 100644 --- a/src/tui/chrome-state.test.ts +++ b/src/tui/chrome-state.test.ts @@ -208,7 +208,7 @@ describe("formatAgentsPanel", () => { description: "quiet worker", status: "running", startedAt: NOW - 180_000, - lastActivityAt: NOW - 130_000, + lastActivityAt: NOW - 310_000, }, ], undefined, @@ -229,7 +229,7 @@ describe("formatAgentsPanel", () => { 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 - 180_000, lastActivityAt: NOW - 130_000 }, + { agentId: "quiet", description: "silent", status: "running", currentToolStartedAt: null, startedAt: NOW - 180_000, lastActivityAt: NOW - 310_000 }, ], undefined, NOW, @@ -315,8 +315,8 @@ 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) // header + 2 lanes + more (bodyBudget 3, one spent on more → 2 lanes shown) @@ -430,11 +430,11 @@ describe("lane state survives the mapping hops", () => { status: "running" as const, currentToolName: "run_shell", currentToolPreview: null as string | null, - // Past DEFAULT_STALL_MS (120s) so laneState reaches the in_tool branch, - // still under IN_TOOL_STALL_MS (10 min). + // 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 - 180_000, + lastActivityAt: NOW - 310_000, } test("the panel and the transcript row agree that the lane is in a tool", () => { @@ -478,7 +478,7 @@ describe("lane state survives the mapping hops", () => { ...inTool, currentToolName: null, currentToolStartedAt: null, - lastActivityAt: NOW - 130_000, + lastActivityAt: NOW - 310_000, } expect(laneState(silent, NOW)).toBe("stalled") @@ -526,7 +526,7 @@ describe("lane state survives the mapping hops", () => { currentToolName: null, currentToolPreview: null, currentToolStartedAt: null, - lastActivityAt: NOW - 130_000, + lastActivityAt: NOW - 310_000, } const rows = formatAgentsPanel( chromeFromSession({ agents: [silent] }).agents, diff --git a/src/tui/stall-watchdog.ts b/src/tui/stall-watchdog.ts index 417472abd..e4c8009cd 100644 --- a/src/tui/stall-watchdog.ts +++ b/src/tui/stall-watchdog.ts @@ -11,9 +11,9 @@ export const STALL_TIMEOUT_MS = 900_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–120s of true client silence mid-think is routine; -// the notice sits above that band. -export const STALL_NOTICE_MS = 180_000 +// 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 From 246a9327bf4a6d968b3b03ec3469f05041796d47 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 18 Aug 2026 14:09:22 -0700 Subject: [PATCH 35/59] Exit on one Ctrl+C and always start fresh unless resume is explicit Ctrl+C now tears down the process, primary agent, and workers in one press. Plain corbits always opens a new session; resume / --resume open the picker. --- docs/ARCHITECTURE.md | 4 +- docs/IMPLEMENTATION.md | 3 +- docs/PRODUCT.md | 4 +- docs/TUI.md | 19 ++---- src/config.test.ts | 83 ++++++++++++++++++------ src/config/index.ts | 45 +++++++------ src/index.ts | 5 +- src/tui/keybindings.test.ts | 7 +- src/tui/keybindings.ts | 2 +- src/tui/product-host.ts | 3 +- src/tui/prompt-slash-exit.test.ts | 58 +++-------------- src/tui/runner-host.test.ts | 2 +- src/tui/runner-host.ts | 6 +- src/tui/runner.ts | 20 ++++-- src/tui/runtime-bridge.test.ts | 26 ++++---- src/tui/runtime-shutdown.test.ts | 53 +++++++++++++++ src/tui/runtime-shutdown.ts | 33 ++++++++++ src/tui/shell.ts | 44 ++----------- tests/integration/rawmode-sigint.test.ts | 5 +- 19 files changed, 237 insertions(+), 185 deletions(-) create mode 100644 src/tui/runtime-shutdown.test.ts create mode 100644 src/tui/runtime-shutdown.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c792e6f85..5fc65d9e8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -388,12 +388,12 @@ 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). +- **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 one Ctrl+C, owned by the shell). - **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. -Known keybindings: `Ctrl+C` interrupts the in-flight run, and quits on a second press inside a two-second window. +Known keybindings: `Ctrl+C` exits this CLI process and stops its in-flight run and workers. ### Skills (`src/extensions/skills.ts`) diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 0d56427dd..39d77026f 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -303,7 +303,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; worktrees of the same git root share sessions) | +| `--resume` | — | Open the interactive session picker | | `resume ` | — | Reopen a specific session | | `resume --pick` / `--list` | — | Interactive session picker | | `--cwd ` | `process.cwd()` | Working directory | diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index d72ba48b3..b85f44415 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -77,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 diff --git a/docs/TUI.md b/docs/TUI.md index b574b9ebb..d716b93be 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -460,19 +460,12 @@ The prompt repaints on every keystroke (`onFrame` in `shell.ts` calls not on a debounce) — anything added to the prompt's paint path must stay cheap, because it runs at typing speed. -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 -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 -(`"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. +Ctrl+C exits the current CLI process and stops its primary agent and active +sub-agents (`handleCtrlC`, `shell.ts`; `createRuntimeShutdown`, +`runtime-shutdown.ts`). It does not clear a draft or act as an in-session +interrupt. Alt+Enter remains the explicit stop-and-reinject gesture for +replacing an in-flight run without leaving the CLI. See "Queue-and-steer vs. +stop-and-reinject" above for the two mid-run message gestures. ## Overflows, scrolling, and key macros 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..67eb4e385 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] - 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 + resume / continue interactive session picker + --resume interactive session picker resume reopen a specific session resume --pick / --list interactive session picker @@ -373,6 +373,7 @@ Flags: --provider configured provider name --model model for the active provider --profile settings profile + --resume interactive session picker --force override an existing run state --dangerously-skip-permissions --auto / --no-auto auto mode on/off @@ -426,7 +427,7 @@ export async function loadConfig( // `corbits resume` / `continue` reopen a prior session for this project key // (shared across worktrees of the same git root — 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"); @@ -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// (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// (shared across worktrees of the same git root). 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/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/tui/keybindings.test.ts b/src/tui/keybindings.test.ts index a4a39429c..307df3b3c 100644 --- a/src/tui/keybindings.test.ts +++ b/src/tui/keybindings.test.ts @@ -496,10 +496,8 @@ const PROBES: Readonly { if (hasExited === undefined) throw new Error("Ctrl+D must be probed on a mounted host") shellFocusPrompt(shell) @@ -824,4 +822,3 @@ describe("help stays reachable as a command", () => { } }) }) - diff --git a/src/tui/keybindings.ts b/src/tui/keybindings.ts index 903aa3131..fc4ded07b 100644 --- a/src/tui/keybindings.ts +++ b/src/tui/keybindings.ts @@ -22,7 +22,7 @@ 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: "Ctrl+C", description: "interrupt the run, or clear the prompt when idle; press twice to exit" }, + { keys: "Ctrl+C", description: "exit this CLI process and stop its active work" }, { 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" }, diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index b9f6994bc..415627294 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -265,8 +265,7 @@ export async function mountProductHost( const renderer = config.createRenderer ? await config.createRenderer() : await createCliRenderer({ - // Leaves Ctrl+C entirely to shell.ts's own double-tap-to-quit - // gesture (CTRL_C_EXIT_WINDOW_MS). index.ts's SIGINT handler also + // Leaves Ctrl+C entirely to shell.ts's own exit path. index.ts's SIGINT handler also // depends on this staying false: Ctrl+C only reaches it as a real // OS signal when nothing already consumed it as a keypress. exitOnCtrlC: false, diff --git a/src/tui/prompt-slash-exit.test.ts b/src/tui/prompt-slash-exit.test.ts index 839e1e518..7fdb0ae8c 100644 --- a/src/tui/prompt-slash-exit.test.ts +++ b/src/tui/prompt-slash-exit.test.ts @@ -1,5 +1,5 @@ /** - * Integration: `/` command popup and the double Ctrl+C exit, both driven + * Integration: `/` command popup and Ctrl+C exit, both driven * through the wired key path on a headless shell. */ import { describe, expect, test } from "bun:test" @@ -7,14 +7,10 @@ import { describe, expect, test } from "bun:test" import { withTestRenderer } from "./harness" import type { PaletteCommand } from "./command-catalog" import { - CTRL_C_EXIT_WINDOW_MS, createAppShell, - handleCtrlC, isSlashPopupOpen, - noticeText, setShellExitHandler, setShellRunState, - setStatusFlash, type AppShell, } from "./shell" @@ -155,7 +151,7 @@ describe("slash command popup", () => { }) describe("Ctrl+C exit", () => { - test("first press interrupts a busy run, second exits via the handler", async () => { + test("one press exits a busy run via the handler", async () => { await withShell(async ({ shell, press }) => { setShellRunState(shell, "busy") let exits = 0 @@ -163,59 +159,21 @@ describe("Ctrl+C exit", () => { exits += 1 }) press("Ctrl+C") - expect(exits).toBe(0) - expect(shell.session.run).not.toBe("busy") - press("Ctrl+C") expect(exits).toBe(1) + expect(shell.session.run).toBe("busy") }) }) - test("the exit notice clears itself when the arming window lapses", async () => { - await withShell(async ({ shell }) => { - const lapse: (() => void)[] = [] - handleCtrlC(shell, 0, { - schedule: (fn, ms) => { - expect(ms).toBe(CTRL_C_EXIT_WINDOW_MS) - lapse.push(fn) - return () => {} - }, - }) - expect(shell.statusFlash).toBe("press ctrl+c again to exit") - expect(noticeText(shell)).toContain("press ctrl+c again to exit") - - lapse[0]?.() - expect(shell.statusFlash).toBeNull() - // The row has nothing left to say, so it is given back to the transcript. - expect(noticeText(shell)).toBe("") - }) - }) - - test("a lapsed window never clears a flash set after it", async () => { - await withShell(async ({ shell }) => { - const lapse: (() => void)[] = [] - handleCtrlC(shell, 0, { - schedule: (fn) => { - lapse.push(fn) - return () => {} - }, - }) - setStatusFlash(shell, "copied 3 lines") - lapse[0]?.() - expect(shell.statusFlash).toBe("copied 3 lines") - }) - }) - - test("a press outside the window re-arms instead of exiting", async () => { - await withShell(async ({ shell }) => { + test("one press exits an idle run with a non-empty prompt", async () => { + await withShell(async ({ shell, press }) => { + shell.prompt.value = "unsent text" let exits = 0 setShellExitHandler(shell, () => { exits += 1 }) - handleCtrlC(shell, 0) - handleCtrlC(shell, CTRL_C_EXIT_WINDOW_MS + 1) - expect(exits).toBe(0) - handleCtrlC(shell, CTRL_C_EXIT_WINDOW_MS + 2) + press("Ctrl+C") expect(exits).toBe(1) + expect(shell.prompt.value).toBe("unsent text") }) }) }) diff --git a/src/tui/runner-host.test.ts b/src/tui/runner-host.test.ts index ff4b9cd6c..104310a41 100644 --- a/src/tui/runner-host.test.ts +++ b/src/tui/runner-host.test.ts @@ -471,7 +471,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..d57c98a41 100644 --- a/src/tui/runner-host.ts +++ b/src/tui/runner-host.ts @@ -328,10 +328,8 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise ...(deps.fetchBranch !== undefined ? { fetchBranch: deps.fetchBranch } : {}), }) - // Quitting is Ctrl+C twice, the binding this interface has always used. The - // host claims no key of its own: a second exit chord split the one thing - // every operator already knows across two keys, and Ctrl+D stays the - // prompt's delete-character-under-cursor. + // The shell owns Ctrl+C exit. The host claims no second exit chord, and + // Ctrl+D stays the prompt's delete-character-under-cursor. const dispose = (): void => { stopBranchWatch() diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 820882569..57e7309b4 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -160,6 +160,7 @@ 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, @@ -2343,7 +2344,16 @@ export async function runTUI(initialConfig: Config): Promise { }, }); - 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)); @@ -2506,6 +2516,9 @@ export async function runTUI(initialConfig: Config): Promise { }); 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(); @@ -2572,11 +2585,6 @@ export async function runTUI(initialConfig: Config): Promise { 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..9023e9d26 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -6,7 +6,12 @@ import { mapReactorLike, type TaskProgressSession, } from "./runtime-bridge" -import { appendStreamRow, createAppShell, streamRowCount } from "./shell" +import { + appendStreamRow, + createAppShell, + setShellExitHandler, + streamRowCount, +} from "./shell" import { withTestRenderer } from "./harness" import { badgeCount } from "./session-queue" @@ -142,7 +147,7 @@ describe("attachSessionBridge", () => { ) }) - test("Ctrl+C hits port.interrupt and keeps pending for the next turn", async () => { + test("Ctrl+C exits without handing pending messages to the active run", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -153,21 +158,20 @@ describe("attachSessionBridge", () => { const port = createRecordingPort() const bridge = attachSessionBridge(shell, port) try { + let exits = 0 + setShellExitHandler(shell, () => { + exits += 1 + }) bridge.submit("a", "queue") bridge.submit("b", "steer") expect(badgeCount(shell.session)).toBe(2) port.clear() h.pressKey("c", { ctrl: true }) await h.renderOnce() - expect(port.calls.some((c) => c.op === "interrupt")).toBe(true) - expect(shell.session.interruptFlash).toBe(true) - expect(shell.session.run).toBe("idle") - // Handed over, not thrown away — and handed over here rather than - // left waiting on an idle event the stop may never produce. - expect( - port.calls.flatMap((c) => (c.op === "deliver" ? [c.item.text] : [])), - ).toEqual(["b", "a"]) - expect(badgeCount(shell.session)).toBe(0) + expect(exits).toBe(1) + expect(port.calls).toEqual([]) + expect(shell.session.run).toBe("busy") + expect(badgeCount(shell.session)).toBe(2) } finally { bridge.dispose() shell.dispose() 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 +} + +/** Start every process-owned teardown path once, even when exit races a signal. */ +export function createRuntimeShutdown(deps: RuntimeShutdownDeps): () => Promise { + let started = false + let completion = Promise.resolve() + + return (): Promise => { + 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/shell.ts b/src/tui/shell.ts index 58829c279..19d67db2e 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -212,7 +212,7 @@ import { const shellExitHandlers = new WeakMap void>() /** - * Register the host's quit path (the same one Ctrl+C twice runs) so a bare `exit` / + * Register the host's quit path (the same one Ctrl+C runs) so a bare `exit` / * `quit` typed at the prompt tears down through finalize instead of a second, * cleanup-skipping exit route. */ @@ -3964,7 +3964,7 @@ export function handleOverlayAnswerKey( * toggling opener. * * Only pickers appear here. An opener that performs an action (Ctrl+P attaches - * an image, Ctrl+C interrupts, the expand key expands a row) has nothing to + * an image, Ctrl+C exits, the expand key expands a row) has nothing to * toggle, and a decision surface — a permission or operator question — is * deliberately absent: re-pressing whatever chord happened to be underneath it * must not count as an answer. Those leave via a choice or Esc. @@ -5169,44 +5169,12 @@ export function handleSlashPopupKey(shell: AppShell, key: KeyEvent): boolean { return true } -/** Window in which a second Ctrl+C is read as "yes, quit". */ -export const CTRL_C_EXIT_WINDOW_MS = 2000 - -const ctrlCArmedAt = new WeakMap() - /** - * Ctrl+C: interrupt / clear, and quit on a second press inside the window. - * The double press replaces the old Ink y/n exit confirm — same intent (an - * explicit second confirmation), no modal. Quitting routes through the - * registered exit handler so host finalize still runs. + * Ctrl+C ends this CLI process. The exit handler wakes the runner's normal + * finalize path, which owns persistence and runtime teardown. */ -export function handleCtrlC( - shell: AppShell, - now = Date.now(), - options?: FlashOptions, -): void { - const armedAt = ctrlCArmedAt.get(shell) - if (armedAt !== undefined && now - armedAt <= CTRL_C_EXIT_WINDOW_MS) { - ctrlCArmedAt.delete(shell) - const onExit = shellExitHandlers.get(shell) - if (onExit !== undefined) { - onExit() - return - } - } - ctrlCArmedAt.set(shell, now) - - if (shell.session.run === "busy" || badgeCount(shell.session) > 0) { - interruptShell(shell) - } else if (shell.prompt.value.length > 0) { - shell.prompt.value = "" - } - // The notice is exactly as true as the arming window is open, so it expires - // with it rather than waiting for some later flash to overwrite it. - setStatusFlash(shell, "press ctrl+c again to exit", { - ttlMs: CTRL_C_EXIT_WINDOW_MS, - ...(options?.schedule !== undefined ? { schedule: options.schedule } : {}), - }) +export function handleCtrlC(shell: AppShell): void { + shellExitHandlers.get(shell)?.() } /** diff --git a/tests/integration/rawmode-sigint.test.ts b/tests/integration/rawmode-sigint.test.ts index 59085f1b2..64e666b4b 100644 --- a/tests/integration/rawmode-sigint.test.ts +++ b/tests/integration/rawmode-sigint.test.ts @@ -4,9 +4,8 @@ import { describe, expect, test } from "bun:test"; // stdin.setRawMode(true) clears ISIG on this platform, so a real Ctrl+C // keypress never reaches process.on("SIGINT") during an interactive TUI // session -- only out-of-band kill(2) signals do. If a future Bun upgrade -// changes that, the in-session double-tap-to-quit gesture (shell.ts, -// CTRL_C_EXIT_WINDOW_MS) would silently start racing a process-level exit -// on the very first Ctrl+C. This test pins the assumption against a real +// changes that, the in-session exit path would race the process-level exit. +// This test pins the assumption against a real // forked pty rather than trusting it to hold forever. describe("integration — raw-mode stdin and SIGINT", () => { test("Ctrl+C is delivered as a stdin byte, not as SIGINT, while raw mode is active", async () => { From d76efe530f267666ba2068120bc8115fd5e87737 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 18 Aug 2026 15:47:15 -0700 Subject: [PATCH 36/59] Restore Ctrl+C interrupt with double-press to exit One press stops a busy run or clears an idle draft; a second press within two seconds exits. Keep resume opening the session picker. --- docs/ARCHITECTURE.md | 4 +- docs/TUI.md | 19 ++++--- src/tui/keybindings.test.ts | 6 ++- src/tui/keybindings.ts | 2 +- src/tui/product-host.ts | 3 +- src/tui/prompt-slash-exit.test.ts | 58 ++++++++++++++++++--- src/tui/runner-host.ts | 6 ++- src/tui/runtime-bridge.test.ts | 26 ++++------ src/tui/shell.ts | 66 +++++++++++++++++++++--- tests/integration/rawmode-sigint.test.ts | 5 +- 10 files changed, 148 insertions(+), 47 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5fc65d9e8..c792e6f85 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -388,12 +388,12 @@ 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 one Ctrl+C, owned by the shell). +- **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`) 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. -Known keybindings: `Ctrl+C` exits this CLI process and stops its in-flight run and workers. +Known keybindings: `Ctrl+C` interrupts the in-flight run, and quits on a second press inside a two-second window. ### Skills (`src/extensions/skills.ts`) diff --git a/docs/TUI.md b/docs/TUI.md index d716b93be..b574b9ebb 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -460,12 +460,19 @@ The prompt repaints on every keystroke (`onFrame` in `shell.ts` calls not on a debounce) — anything added to the prompt's paint path must stay cheap, because it runs at typing speed. -Ctrl+C exits the current CLI process and stops its primary agent and active -sub-agents (`handleCtrlC`, `shell.ts`; `createRuntimeShutdown`, -`runtime-shutdown.ts`). It does not clear a draft or act as an in-session -interrupt. Alt+Enter remains the explicit stop-and-reinject gesture for -replacing an in-flight run without leaving the CLI. See "Queue-and-steer vs. -stop-and-reinject" above for the two mid-run message gestures. +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 +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 +(`"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. ## Overflows, scrolling, and key macros diff --git a/src/tui/keybindings.test.ts b/src/tui/keybindings.test.ts index 307df3b3c..8b22ae843 100644 --- a/src/tui/keybindings.test.ts +++ b/src/tui/keybindings.test.ts @@ -496,8 +496,10 @@ const PROBES: Readonly { if (hasExited === undefined) throw new Error("Ctrl+D must be probed on a mounted host") shellFocusPrompt(shell) diff --git a/src/tui/keybindings.ts b/src/tui/keybindings.ts index fc4ded07b..903aa3131 100644 --- a/src/tui/keybindings.ts +++ b/src/tui/keybindings.ts @@ -22,7 +22,7 @@ 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: "Ctrl+C", description: "exit this CLI process and stop its active work" }, + { 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" }, diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index 415627294..b9f6994bc 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -265,7 +265,8 @@ export async function mountProductHost( const renderer = config.createRenderer ? await config.createRenderer() : await createCliRenderer({ - // Leaves Ctrl+C entirely to shell.ts's own exit path. index.ts's SIGINT handler also + // Leaves Ctrl+C entirely to shell.ts's own double-tap-to-quit + // gesture (CTRL_C_EXIT_WINDOW_MS). index.ts's SIGINT handler also // depends on this staying false: Ctrl+C only reaches it as a real // OS signal when nothing already consumed it as a keypress. exitOnCtrlC: false, diff --git a/src/tui/prompt-slash-exit.test.ts b/src/tui/prompt-slash-exit.test.ts index 7fdb0ae8c..839e1e518 100644 --- a/src/tui/prompt-slash-exit.test.ts +++ b/src/tui/prompt-slash-exit.test.ts @@ -1,5 +1,5 @@ /** - * Integration: `/` command popup and Ctrl+C exit, both driven + * Integration: `/` command popup and the double Ctrl+C exit, both driven * through the wired key path on a headless shell. */ import { describe, expect, test } from "bun:test" @@ -7,10 +7,14 @@ import { describe, expect, test } from "bun:test" import { withTestRenderer } from "./harness" import type { PaletteCommand } from "./command-catalog" import { + CTRL_C_EXIT_WINDOW_MS, createAppShell, + handleCtrlC, isSlashPopupOpen, + noticeText, setShellExitHandler, setShellRunState, + setStatusFlash, type AppShell, } from "./shell" @@ -151,7 +155,7 @@ describe("slash command popup", () => { }) describe("Ctrl+C exit", () => { - test("one press exits a busy run via the handler", async () => { + test("first press interrupts a busy run, second exits via the handler", async () => { await withShell(async ({ shell, press }) => { setShellRunState(shell, "busy") let exits = 0 @@ -159,21 +163,59 @@ describe("Ctrl+C exit", () => { exits += 1 }) press("Ctrl+C") + expect(exits).toBe(0) + expect(shell.session.run).not.toBe("busy") + press("Ctrl+C") expect(exits).toBe(1) - expect(shell.session.run).toBe("busy") }) }) - test("one press exits an idle run with a non-empty prompt", async () => { - await withShell(async ({ shell, press }) => { - shell.prompt.value = "unsent text" + test("the exit notice clears itself when the arming window lapses", async () => { + await withShell(async ({ shell }) => { + const lapse: (() => void)[] = [] + handleCtrlC(shell, 0, { + schedule: (fn, ms) => { + expect(ms).toBe(CTRL_C_EXIT_WINDOW_MS) + lapse.push(fn) + return () => {} + }, + }) + expect(shell.statusFlash).toBe("press ctrl+c again to exit") + expect(noticeText(shell)).toContain("press ctrl+c again to exit") + + lapse[0]?.() + expect(shell.statusFlash).toBeNull() + // The row has nothing left to say, so it is given back to the transcript. + expect(noticeText(shell)).toBe("") + }) + }) + + test("a lapsed window never clears a flash set after it", async () => { + await withShell(async ({ shell }) => { + const lapse: (() => void)[] = [] + handleCtrlC(shell, 0, { + schedule: (fn) => { + lapse.push(fn) + return () => {} + }, + }) + setStatusFlash(shell, "copied 3 lines") + lapse[0]?.() + expect(shell.statusFlash).toBe("copied 3 lines") + }) + }) + + test("a press outside the window re-arms instead of exiting", async () => { + await withShell(async ({ shell }) => { let exits = 0 setShellExitHandler(shell, () => { exits += 1 }) - press("Ctrl+C") + handleCtrlC(shell, 0) + handleCtrlC(shell, CTRL_C_EXIT_WINDOW_MS + 1) + expect(exits).toBe(0) + handleCtrlC(shell, CTRL_C_EXIT_WINDOW_MS + 2) expect(exits).toBe(1) - expect(shell.prompt.value).toBe("unsent text") }) }) }) diff --git a/src/tui/runner-host.ts b/src/tui/runner-host.ts index d57c98a41..45b381f55 100644 --- a/src/tui/runner-host.ts +++ b/src/tui/runner-host.ts @@ -328,8 +328,10 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise ...(deps.fetchBranch !== undefined ? { fetchBranch: deps.fetchBranch } : {}), }) - // The shell owns Ctrl+C exit. The host claims no second exit chord, and - // Ctrl+D stays the prompt's delete-character-under-cursor. + // Quitting is Ctrl+C twice, the binding this interface has always used. The + // host claims no key of its own: a second exit chord split the one thing + // every operator already knows across two keys, and Ctrl+D stays the + // prompt's delete-character-under-cursor. const dispose = (): void => { stopBranchWatch() diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index 9023e9d26..effec6e99 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -6,12 +6,7 @@ import { mapReactorLike, type TaskProgressSession, } from "./runtime-bridge" -import { - appendStreamRow, - createAppShell, - setShellExitHandler, - streamRowCount, -} from "./shell" +import { appendStreamRow, createAppShell, streamRowCount } from "./shell" import { withTestRenderer } from "./harness" import { badgeCount } from "./session-queue" @@ -147,7 +142,7 @@ describe("attachSessionBridge", () => { ) }) - test("Ctrl+C exits without handing pending messages to the active run", async () => { + test("Ctrl+C hits port.interrupt and keeps pending for the next turn", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -158,20 +153,21 @@ describe("attachSessionBridge", () => { const port = createRecordingPort() const bridge = attachSessionBridge(shell, port) try { - let exits = 0 - setShellExitHandler(shell, () => { - exits += 1 - }) bridge.submit("a", "queue") bridge.submit("b", "steer") expect(badgeCount(shell.session)).toBe(2) port.clear() h.pressKey("c", { ctrl: true }) await h.renderOnce() - expect(exits).toBe(1) - expect(port.calls).toEqual([]) - expect(shell.session.run).toBe("busy") - expect(badgeCount(shell.session)).toBe(2) + expect(port.calls.some((c) => c.op === "interrupt")).toBe(true) + expect(shell.session.interruptFlash).toBe(true) + expect(shell.session.run).toBe("idle") + // Handed over, not thrown away — and handed over here rather than + // left waiting on an idle event the stop may never produce. + expect( + port.calls.flatMap((c) => (c.op === "deliver" ? [c.item.text] : [])), + ).toEqual(["b", "a"]) + expect(badgeCount(shell.session)).toBe(0) } finally { bridge.dispose() shell.dispose() diff --git a/src/tui/shell.ts b/src/tui/shell.ts index 19d67db2e..b42610068 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -73,7 +73,7 @@ import type { RampPhase, StallAge } from "./ramp.js" import type { ActivityState } from "./session-chrome.js" import { BORDER, - MCP_ATTENTION_LABEL, + composeAttentionLabel, composeCostContextMeter, composeRule, composeWorkspaceLabel, @@ -212,7 +212,7 @@ import { const shellExitHandlers = new WeakMap void>() /** - * Register the host's quit path (the same one Ctrl+C runs) so a bare `exit` / + * Register the host's quit path (the same one Ctrl+C twice runs) so a bare `exit` / * `quit` typed at the prompt tears down through finalize instead of a second, * cleanup-skipping exit route. */ @@ -662,6 +662,12 @@ export type AppShell = { statusFlash: string | null /** 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 @@ -863,6 +869,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 @@ -1581,10 +1594,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], - ...(shell.mcpNeedsAuth.length > 0 ? { attention: MCP_ATTENTION_LABEL } : {}), + ...(attention !== undefined ? { attention } : {}), ...(shell.modelLabel !== null ? { label: shell.modelLabel } : {}), }) shell.promptTopRule.content = new StyledText(ruleChunks(shell, top)) @@ -3964,7 +3981,7 @@ export function handleOverlayAnswerKey( * toggling opener. * * Only pickers appear here. An opener that performs an action (Ctrl+P attaches - * an image, Ctrl+C exits, the expand key expands a row) has nothing to + * an image, Ctrl+C interrupts, the expand key expands a row) has nothing to * toggle, and a decision surface — a permission or operator question — is * deliberately absent: re-pressing whatever chord happened to be underneath it * must not count as an answer. Those leave via a choice or Esc. @@ -5169,12 +5186,44 @@ export function handleSlashPopupKey(shell: AppShell, key: KeyEvent): boolean { return true } +/** Window in which a second Ctrl+C is read as "yes, quit". */ +export const CTRL_C_EXIT_WINDOW_MS = 2000 + +const ctrlCArmedAt = new WeakMap() + /** - * Ctrl+C ends this CLI process. The exit handler wakes the runner's normal - * finalize path, which owns persistence and runtime teardown. + * Ctrl+C: interrupt / clear, and quit on a second press inside the window. + * The double press replaces the old Ink y/n exit confirm — same intent (an + * explicit second confirmation), no modal. Quitting routes through the + * registered exit handler so host finalize still runs. */ -export function handleCtrlC(shell: AppShell): void { - shellExitHandlers.get(shell)?.() +export function handleCtrlC( + shell: AppShell, + now = Date.now(), + options?: FlashOptions, +): void { + const armedAt = ctrlCArmedAt.get(shell) + if (armedAt !== undefined && now - armedAt <= CTRL_C_EXIT_WINDOW_MS) { + ctrlCArmedAt.delete(shell) + const onExit = shellExitHandlers.get(shell) + if (onExit !== undefined) { + onExit() + return + } + } + ctrlCArmedAt.set(shell, now) + + if (shell.session.run === "busy" || badgeCount(shell.session) > 0) { + interruptShell(shell) + } else if (shell.prompt.value.length > 0) { + shell.prompt.value = "" + } + // The notice is exactly as true as the arming window is open, so it expires + // with it rather than waiting for some later flash to overwrite it. + setStatusFlash(shell, "press ctrl+c again to exit", { + ttlMs: CTRL_C_EXIT_WINDOW_MS, + ...(options?.schedule !== undefined ? { schedule: options.schedule } : {}), + }) } /** @@ -6020,6 +6069,7 @@ export function createAppShell( copyTargets: null, statusFlash: null, mcpNeedsAuth: [], + pluginNeedsAttention: false, lockupNowMs: 0, lockupAnimating: false, lockupPhase: null, diff --git a/tests/integration/rawmode-sigint.test.ts b/tests/integration/rawmode-sigint.test.ts index 64e666b4b..59085f1b2 100644 --- a/tests/integration/rawmode-sigint.test.ts +++ b/tests/integration/rawmode-sigint.test.ts @@ -4,8 +4,9 @@ import { describe, expect, test } from "bun:test"; // stdin.setRawMode(true) clears ISIG on this platform, so a real Ctrl+C // keypress never reaches process.on("SIGINT") during an interactive TUI // session -- only out-of-band kill(2) signals do. If a future Bun upgrade -// changes that, the in-session exit path would race the process-level exit. -// This test pins the assumption against a real +// changes that, the in-session double-tap-to-quit gesture (shell.ts, +// CTRL_C_EXIT_WINDOW_MS) would silently start racing a process-level exit +// on the very first Ctrl+C. This test pins the assumption against a real // forked pty rather than trusting it to hold forever. describe("integration — raw-mode stdin and SIGINT", () => { test("Ctrl+C is delivered as a stdin byte, not as SIGINT, while raw mode is active", async () => { From 50bbb1fa06efb3acbd2a75bcb7961b8b7c57ffcb Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 18 Aug 2026 17:32:37 -0700 Subject: [PATCH 37/59] Restore soft steer on Enter and follow-up on Alt+Enter Enter was wrongly labeled as steer-while-queuing-everything and Alt+Enter as reinject. Match Pi-style soft steer at tool.boundary vs follow-up at idle. --- docs/PRODUCT.md | 2 +- docs/TUI.md | 57 ++++++++++++----------- src/tui/demo.ts | 4 +- src/tui/keybindings.test.ts | 10 ++-- src/tui/keybindings.ts | 4 +- src/tui/notice-line.test.ts | 18 +++++--- src/tui/notice-line.ts | 8 +++- src/tui/prompt-input.ts | 6 +-- src/tui/runtime-bridge.test.ts | 83 ++++++++++++++++++++++++++-------- src/tui/runtime-bridge.ts | 41 +++++++++++++---- src/tui/session-queue.test.ts | 32 ++++++++++++- src/tui/session-queue.ts | 44 ++++++++++++++++-- src/tui/shell.test.ts | 14 +++--- src/tui/shell.ts | 41 +++++++++++------ src/tui/stream.test.ts | 18 ++++++++ src/tui/stream.ts | 14 ++++-- 16 files changed, 284 insertions(+), 112 deletions(-) diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index b85f44415..fda29cf27 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -42,7 +42,7 @@ 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. +8. **Mid-run steering** — Two modes while the agent is running: **Enter** soft-steers — delivers at the next tool boundary without stopping the current run; **Alt+Enter** queues a follow-up delivered only when the run goes idle (does not interrupt). Idle Alt+Enter is a no-op. **Ctrl+C** stops the run outright. The notice row shows distinct `steer N` / `follow-up M` badges. 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`. 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 diff --git a/docs/TUI.md b/docs/TUI.md index b574b9ebb..5734b09de 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -381,23 +381,20 @@ 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 **tool.boundary**. 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 when the run goes **idle**. 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.) Interrupting (Ctrl+C) never discards a queued or steered message. It used to — the transcript literally said `interrupt — discarded N pending`, and an @@ -408,17 +405,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 @@ -464,7 +463,7 @@ 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 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/keybindings.test.ts b/src/tui/keybindings.test.ts index 8b22ae843..02dc95d4a 100644 --- a/src/tui/keybindings.test.ts +++ b/src/tui/keybindings.test.ts @@ -464,17 +464,15 @@ const PROBES: Readonly = {}): NoticeState => ({ - queue: 0, + steer: 0, + followUp: 0, interrupt: false, pinned: false, flash: null, @@ -18,16 +19,20 @@ describe("composeNoticeLine", () => { 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).not.toContain("interrupt") expect(line).toContain("1 image") @@ -41,11 +46,10 @@ 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") expect(line).not.toContain("^C") }) }) - diff --git a/src/tui/notice-line.ts b/src/tui/notice-line.ts index 9eab14079..36d614a9b 100644 --- a/src/tui/notice-line.ts +++ b/src/tui/notice-line.ts @@ -25,7 +25,10 @@ const SEP = " " 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 readonly interrupt: boolean /** Transcript scrolled off the tail (non-default follow state). */ readonly pinned: boolean @@ -36,7 +39,8 @@ export type NoticeState = { 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.pinned) segments.push("pinned") // "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. 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/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index effec6e99..2f33d9a94 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -6,6 +6,7 @@ import { mapReactorLike, type TaskProgressSession, } from "./runtime-bridge" +import { DEFAULT_STALL_MS } from "./agent-progress" import { appendStreamRow, createAppShell, streamRowCount } from "./shell" import { withTestRenderer } from "./harness" import { badgeCount } from "./session-queue" @@ -91,8 +92,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 +101,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 +111,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 +242,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 +253,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 +268,54 @@ 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("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 +814,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 dad07d43b..1dedc87bf 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -671,8 +671,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()) @@ -718,13 +739,15 @@ function applyInbound( 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 } @@ -936,11 +959,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 @@ -985,8 +1007,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/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 b35863266..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() } diff --git a/src/tui/shell.ts b/src/tui/shell.ts index b42610068..249c797da 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -163,7 +163,9 @@ import { enqueue, enqueueSteer, interrupt, + queueCount, setRunState, + steerCount, type RunState, type SessionQueueState, } from "./session-queue.js" @@ -848,7 +850,8 @@ 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), interrupt: shell.session.interruptFlash, pinned: !isTranscriptFollowing(shell), flash: shell.statusFlash, @@ -3231,13 +3234,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, @@ -3255,7 +3261,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. @@ -3280,6 +3289,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) @@ -5959,11 +5969,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 } } @@ -5971,9 +5983,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") } 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 1b74b1895..101e577e7 100644 --- a/src/tui/stream.ts +++ b/src/tui/stream.ts @@ -671,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)) { From a711cbea9ad7d8e8c81d72dea77e2cd3f549416c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 18 Aug 2026 17:34:01 -0700 Subject: [PATCH 38/59] Keep workers running across soft steer and follow-up Soft steer and follow-up must never call interrupt(); only Ctrl+C hard-stop closes the agent and tears down in-flight task workers. --- src/tui/runner.ts | 12 +- src/tui/steer-worker-invariant.test.ts | 167 +++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 src/tui/steer-worker-invariant.test.ts diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 57e7309b4..5ebc239ee 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -1682,9 +1682,15 @@ export async function runTUI(initialConfig: Config): Promise { }, }; - // 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 () => { 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 }, + ) + }) +}) From 08620c5680f44f2ce6ee818590d69edf3d59e5a2 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 18 Aug 2026 21:50:28 -0700 Subject: [PATCH 39/59] Stop headless MCP connect from advertising interactive OAuth Headless exec was registering an onAuthURL callback it could never complete, so OAuth-backed MCP servers hung automation. Gate the callback on interactiveAuth: TUI keeps the browser flow; exec and other headless hosts reuse stored tokens only. --- src/agent/tools.ts | 7 ++++++- src/exec/runner.ts | 1 + src/tui/runner.ts | 1 + tests/unit/tui/agent-tools.test.ts | 33 ++++++++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 2f8b292bf..aa901b71c 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -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 @@ -360,7 +363,9 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise 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/exec/runner.ts b/src/exec/runner.ts index 8e92447f1..8f6497909 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -591,6 +591,7 @@ export async function runExec(config: Config): Promise { if (agentToolset.connectMCP !== undefined) { await agentToolset .connectMCP({ + interactiveAuth: false, onStatus: (status) => { if (status.state === "connected") { connectedMcp = [ diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 5ebc239ee..25cc5210c 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -2469,6 +2469,7 @@ export async function runTUI(initialConfig: Config): Promise { void toolset .connectMCP( { + interactiveAuth: true, onStatus: (status) => { mcpStates.set(status.name, status); emitter.emit("mcp.status", status); diff --git a/tests/unit/tui/agent-tools.test.ts b/tests/unit/tui/agent-tools.test.ts index 20c7cc999..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); @@ -304,6 +317,26 @@ test("default session registers task and search_agents", async () => { expect(names).toContain("search_agents"); }); +test("headless MCP connection does not wait for interactive OAuth", async () => { + mockConnectMCPServer.mockClear(); + const toolset = await createAgentToolset({ + cwd: "/fake", + permissionGate: fakePermissionGate, + onOperatorGate: async () => ({ kind: "cancel" }), + mcpServers: [{ name: "granola", url: "https://example.test/mcp" }], + mcpServersSource: "global", + }); + + 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 () => { mockDispose.mockClear(); From 6ee4476a612ec30398df92ae11e7d25c24d35d3c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 18 Aug 2026 21:51:18 -0700 Subject: [PATCH 40/59] Surface plugin load warnings as plugin ! and in /plugins Standing plugin diagnostics were easy to miss as startup notices. Keep them as a prompt attention mark shared with mcp !\, attribute warnings per plugin, and show them in the /plugins surface. Also document the current Enter steer / Alt+Enter queue / Ctrl+C stop mid-run gestures. --- docs/IMPLEMENTATION.md | 6 +-- src/plugins/diagnostics.test.ts | 36 ++++++++++++++++++ src/plugins/diagnostics.ts | 31 +++++++++++++++ src/tui/command-surfaces.test.ts | 46 +++++++++++++++++++++++ src/tui/command-surfaces.ts | 45 +++++++++++++++++++++- src/tui/landing.test.ts | 32 ++++++++++------ src/tui/plugin-diagnostics-sink.test.ts | 16 ++++++++ src/tui/prompt-border.test.ts | 25 +++++++++++++ src/tui/prompt-border.ts | 17 +++++++++ src/tui/prompt-chrome.test.ts | 32 ++++++++++++++++ src/tui/runner.ts | 50 ++++++++++++++++--------- 11 files changed, 302 insertions(+), 34 deletions(-) diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 39d77026f..1b11bf444 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -185,8 +185,8 @@ Unmatched shell auto-allows. Writes under the session state root (`~/.corbits/pr `ChatInputProps` carries `isProcessing?: boolean` and `onInterrupt?: (message: string) => void`. When `isProcessing` is true: -- **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`. +- **Enter** soft-steers — enqueues kind `"steer"` and delivers at the next tool.boundary (does not interrupt). +- **Alt+Enter** queues a follow-up (kind `"queue"`) delivered only when the run goes idle. **Ctrl+C** stops the run. `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. @@ -369,7 +369,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 tool.boundary), Alt+Enter queues a follow-up (drain on idle), Ctrl+C stops. ### Lifecycle Hooks 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 :`; 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([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/tui/command-surfaces.test.ts b/src/tui/command-surfaces.test.ts index e2e3a7248..c8e6b9aa2 100644 --- a/src/tui/command-surfaces.test.ts +++ b/src/tui/command-surfaces.test.ts @@ -84,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. */ @@ -396,6 +411,37 @@ function pluginActionDeps(overrides?: Partial): { } 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 b4fb48ccf..8fafbb75d 100644 --- a/src/tui/command-surfaces.ts +++ b/src/tui/command-surfaces.ts @@ -10,6 +10,7 @@ import type { KeyEvent } from "@opentui/core" +import { formatPluginWarningsSummary } from "../plugins/diagnostics.js" import { maskEcho, maskSecret } from "./provider-setup.js" import { residualIdFromSelection, type ResidualCatalogEntry } from "./residuals.js" import { @@ -54,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`. */ @@ -98,6 +104,12 @@ export type PluginsSurfaceDeps = { readonly webProviders: () => readonly WebProviderChoice[] readonly currentWebProvider: () => string | undefined readonly setWebProvider: (id: string | undefined) => Promise | 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. */ @@ -176,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})` : "" @@ -186,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}` } @@ -208,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 } } @@ -749,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" }) } @@ -760,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) { @@ -788,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/landing.test.ts b/src/tui/landing.test.ts index 6e6daab82..acd56b733 100644 --- a/src/tui/landing.test.ts +++ b/src/tui/landing.test.ts @@ -14,6 +14,8 @@ import { noticeText, paintChrome, setChromeZones, + setPluginNeedsAttention, + setPromptModelLabel, setPromptWorkspace, isLanding, paintLanding, @@ -626,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 }, @@ -644,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() } @@ -661,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 }, @@ -670,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 { 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/prompt-border.test.ts b/src/tui/prompt-border.test.ts index 61928d6f7..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, @@ -151,6 +154,28 @@ describe("composeRule", () => { 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 ! ─╮") diff --git a/src/tui/prompt-border.ts b/src/tui/prompt-border.ts index a28e33c7c..91d5c436d 100644 --- a/src/tui/prompt-border.ts +++ b/src/tui/prompt-border.ts @@ -89,6 +89,23 @@ type 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, diff --git a/src/tui/prompt-chrome.test.ts b/src/tui/prompt-chrome.test.ts index b608a70f3..1b9e0bd0c 100644 --- a/src/tui/prompt-chrome.test.ts +++ b/src/tui/prompt-chrome.test.ts @@ -9,6 +9,7 @@ import { setPromptModelLabel, setPromptWorkspace, setMcpNeedsAuth, + setPluginNeedsAttention, setShellBridgeHooks, setShellExitHandler, setStatusFlash, @@ -159,6 +160,37 @@ describe("mcp attention rides the top border", () => { }) }) +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/runner.ts b/src/tui/runner.ts index 25cc5210c..91c7567ca 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -67,6 +67,7 @@ import { createPluginLoadDiagnostics, emitPluginWarningLog, formatPluginWarningsSummary, + warningsForPluginEntry, } from "../plugins/diagnostics.js"; import { isPluginTrusted, @@ -166,6 +167,7 @@ import { attachClipboardImage, setEffortCycleHandler, setMentionSuggestionSource, + setPluginNeedsAttention, setPromptModelLabel, setPromptRecognitionSource, setSentMessageHistory, @@ -551,16 +553,22 @@ export async function runTUI(initialConfig: Config): Promise { 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.`, @@ -835,8 +843,7 @@ export async function runTUI(initialConfig: Config): Promise { ]); 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 @@ -930,6 +937,7 @@ export async function runTUI(initialConfig: Config): Promise { diagnostics: trustDiag, }); trustGrantMessage = formatPluginWarningsSummary(trustDiag.warnings); + notePluginWarnings(trustDiag.warnings); if (full !== null) { livePluginModules = livePluginModules.map((m) => m.manifest?.id === id ? full : m, @@ -982,6 +990,7 @@ export async function runTUI(initialConfig: Config): Promise { // 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})` }; } @@ -1072,6 +1081,7 @@ export async function runTUI(initialConfig: Config): Promise { if (!livePluginPaths.includes(abs)) livePluginPaths.push(abs); await persistPluginSettings(); const warnings = formatPluginWarningsSummary(addDiag.warnings); + notePluginWarnings(addDiag.warnings); return { ok: true, message: @@ -1119,10 +1129,7 @@ export async function runTUI(initialConfig: Config): Promise { { 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; @@ -2243,6 +2250,10 @@ export async function runTUI(initialConfig: Config): Promise { 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, @@ -2257,6 +2268,7 @@ export async function runTUI(initialConfig: Config): Promise { ...(p.needsTrust === true && mod?.pluginPath !== undefined ? { originPath: mod.pluginPath } : {}), + ...(attributed.length > 0 ? { warnings: attributed } : {}), }; }); }, @@ -2273,6 +2285,7 @@ export async function runTUI(initialConfig: Config): Promise { webProviders: () => webPluginCandidates.map((c) => ({ id: c.id, name: c.name })), currentWebProvider: () => pluginsAdmin.getWebOverride(), setWebProvider: (id) => pluginsAdmin.setWebOverride(id), + loadWarnings: () => standingPluginWarnings, }, mcp: { list: () => @@ -2507,14 +2520,17 @@ export async function runTUI(initialConfig: Config): Promise { }); }); - // 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: { From c7b193ff1c69d96f1bc1dfa9ad9d590ba946cec6 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 18 Aug 2026 22:07:45 -0700 Subject: [PATCH 41/59] Align stale chrome and adapter tests with current product behavior Fleet board chrome and stall bang blink are intentionally off; transcript Task rows and working chrome own that status. The duplicate grok-responses unit test still expected summary auto after the adapter moved to detailed. --- src/tui/product-host.test.ts | 13 +++++---- src/tui/ramp-paint.test.ts | 34 ++++++++--------------- src/tui/runtime-channels.test.ts | 21 +++++++------- tests/unit/grok-responses-adapter.test.ts | 2 +- 4 files changed, 30 insertions(+), 40 deletions(-) diff --git a/src/tui/product-host.test.ts b/src/tui/product-host.test.ts index 1f911ec85..4f622c94b 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() } 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() - 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/runtime-channels.test.ts b/src/tui/runtime-channels.test.ts index df5ee8a00..c4754b6ee 100644 --- a/src/tui/runtime-channels.test.ts +++ b/src/tui/runtime-channels.test.ts @@ -197,8 +197,8 @@ describe("permission.grant channel", () => { }) }) -describe("agents chrome (store-driven tool state)", () => { - test("the live tool name reaches the agents chrome zone", async () => { +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: [ @@ -213,20 +213,19 @@ describe("agents chrome (store-driven tool state)", () => { }, }) try { - // 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 () => { + test("a later chrome push still leaves the agents zone empty", async () => { const { host, frame, cleanup } = await mountHeadless() try { host.setChrome({ @@ -241,8 +240,8 @@ describe("agents chrome (store-driven tool state)", () => { ], }) 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() } 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>; From 0814b303914d1102c975081f47e096a3e2ce0270 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 00:05:50 -0700 Subject: [PATCH 42/59] Park unused chrome task and agents strips --- src/tui/chrome-state.test.ts | 21 ++++++++----------- src/tui/chrome-state.ts | 40 ++++++++++++++++++------------------ src/tui/runner-host.test.ts | 33 ++++++++++------------------- 3 files changed, 39 insertions(+), 55 deletions(-) diff --git a/src/tui/chrome-state.test.ts b/src/tui/chrome-state.test.ts index a24d0af87..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("running agents: agents zone stays null; checklist suppressed", () => { + test("running agents: both zones stay null", () => { const state: ChromeLiveState = { task: [ { title: "chrome live helper", status: "doing" }, @@ -64,13 +64,12 @@ describe("formatChromeZones", () => { ], } const out = formatChromeZones(state, NOW) - // Transcript Task rows own live lane status — no FLEET board / agents zone. - // Checklist is suppressed while any lane is running. + // Both strips parked — live work stays on transcript ● Task rows. expect(out.task).toBeNull() expect(out.agents).toBeNull() }) - test("idle with checklist still formats tasks when no lane is running", () => { + test("idle with open checklist still returns null (zones parked)", () => { const out = formatChromeZones( { task: [ @@ -89,10 +88,7 @@ describe("formatChromeZones", () => { NOW, ) expect(out.agents).toBeNull() - expect(out.task).toEqual([ - { label: "chrome live helper", status: "doing" }, - { label: "wire chrome zone", status: "todo" }, - ]) + expect(out.task).toBeNull() }) test("observe does not force an agents panel via formatChromeZones", () => { @@ -113,8 +109,7 @@ describe("formatChromeZones", () => { }, NOW, ) - // Agents zone is always null from formatChromeZones; observe is not a - // chrome-zone surface here (agents zone stays empty; stack-only layout). + // Both zones always null from formatChromeZones (parked pending rebuild). expect(out.agents).toBeNull() expect(out.task).toBeNull() }) @@ -364,7 +359,7 @@ describe("chromeFromSession", () => { ]) const zones = formatChromeZones(state, NOW) - // Running lanes suppress checklist; agents zone stays null (no FLEET board). + // Both chrome strips parked pending rebuild. expect(zones.task).toBeNull() expect(zones.agents).toBeNull() }) diff --git a/src/tui/chrome-state.ts b/src/tui/chrome-state.ts index daebd213e..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 { @@ -129,28 +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). - * - * Live sub-agent work paints as `● Task …` transcript rows (runtime-bridge), - * not as a standing FLEET board or dual-rail agents zone — those restated the - * same lanes above the chat and made progress harder to read. The agents zone - * stays empty so dual layout never engages. The manage_tasks checklist is - * also suppressed while any lane is running; it returns once the fleet is dry. + * 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 { + void state void nowMs - const hasLiveAgents = - state.agents !== null && - state.agents !== undefined && - state.agents.some((s) => s.status === "running") - // Fleet board chrome is off: transcript Task rows own live lane status. - const agents = null - const task = hasLiveAgents ? null : formatTasksPanel(state.task) - return { task, agents } + return { task: null, agents: null } } /** diff --git a/src/tui/runner-host.test.ts b/src/tui/runner-host.test.ts index 104310a41..47a49698f 100644 --- a/src/tui/runner-host.test.ts +++ b/src/tui/runner-host.test.ts @@ -6,7 +6,7 @@ import type { KeyEvent } from "@opentui/core" import type { CostSummary } from "../cost/cost-summary.js" import type { SubAgentSession } from "../subagent/session-store.js" import { createHarness } from "./harness.js" -import { acceptOverlaySelection, closeInsetOverlay, runOverlayAction, toggleTasksPanel } from "./shell.js" +import { acceptOverlaySelection, closeInsetOverlay, runOverlayAction } from "./shell.js" import { mountRunnerHost, observeSessionFromSubAgents, @@ -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,20 +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?.() - // CL-5847: the panel is hidden by default, so the live push lands in - // tasksRaw underneath without showing. It stays hidden until opt-in. expect(host.shell.taskBox.visible).toBe(false) - toggleTasksPanel(host.shell) - expect(host.shell.taskBox.visible).toBe(true) 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() From 90c32225274764dd1fb119fbb42700ea6f5e9939 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 00:06:33 -0700 Subject: [PATCH 43/59] Stop restating settled permission prompts in the chat --- CHANGELOG.md | 12 ++++ docs/TUI.md | 71 +++++++++--------- src/tui/gate-wire.test.ts | 90 +++++++++-------------- src/tui/gate-wire.ts | 147 ++++++-------------------------------- src/tui/overlays.test.ts | 2 +- src/tui/overlays.ts | 16 ++--- 6 files changed, 112 insertions(+), 226 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b49ba6c26..1497d9ae2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,18 @@ 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. +## [Unreleased] + +### TUI + +- **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. + ## [0.2.98] - 2026-08-17 Corrupt resume state no longer kills sessions, Codex quota errors name the diff --git a/docs/TUI.md b/docs/TUI.md index 5734b09de..fc4289144 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -139,28 +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 in the bottom chrome above the -prompt, one row per open task the task tool has written (`manage_tasks`) — -distinct from live sub-agent progress. 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: `formatTasksPanel` (`src/tui/chrome-state.ts`) feeds the -checklist zone; live workers paint as `● Task …` transcript rows (see below). - -**One live surface at a time.** While any sub-agent session is `running`, -`formatChromeZones` suppresses the task checklist and keeps the agents zone -empty — the same work must not stand as a FLEET board *and* a checklist *and* -live Task rows. When no lane is running, the checklist returns for open work -(if the operator has opted in with Alt+T). A list that is only done/cancelled -collapses to null (no permanent wall of `[x]` rows); while open work remains, -recently-done rows trail so the operator can see items flip complete without a -second status log. - -Each row shows a bracket status marker (`[ ]` todo, `[~]` doing, `[x]` done, -`[-]` cancelled) ahead of the title. Open work is listed first. The panel is -bounded to `TASKS_PANEL_MAX_VISIBLE` rows: 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. @@ -175,20 +167,17 @@ 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 **hidden by default** (CL-5847): a fresh shell does not paint the -checklist even when `manage_tasks` has open work. `toggleTasksPanel` (bound to -Alt+T) opts in for the shell's lifetime — it flips a hidden flag held on the -shell 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 -default is opt-in because the checklist's chrome owns too much of the screen to -force into view; the operator toggles it on when they want it, and live Task -rows still win while a fleet is running. +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 -hydrate. `manage_tasks` calls paint no transcript rows — the checklist is the -only surface for that list. +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) @@ -203,8 +192,10 @@ operator-preferred Amp/Codex-style lines: `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 `agents: null`, and geometry is stack-only -(`layoutMode: "stack"`, `railWidth: 0`). +`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 @@ -252,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 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/overlays.test.ts b/src/tui/overlays.test.ts index 6a3ceaf56..5ab49fe16 100644 --- a/src/tui/overlays.test.ts +++ b/src/tui/overlays.test.ts @@ -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..76ef4cb18 100644 --- a/src/tui/overlays.ts +++ b/src/tui/overlays.ts @@ -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 From 0660a64bf34a4c67026e36f3405d1c36250fbb1b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 08:49:40 -0700 Subject: [PATCH 44/59] Fix same-turn MCP dispatch after tool_search @intx/agent snapshots dispatch names at createAgent. MCP tools arrive later, and the post-connect reload waited for every server including OAuth-blocked ones. tool_search listed them; invoke returned unknown tool. Fall through to the live runner until upstream consults current definitions. --- CHANGELOG.md | 10 +++ docs/MCP.md | 3 +- src/agent/live-tool-dispatch.test.ts | 70 +++++++++++++++++ src/agent/live-tool-dispatch.ts | 80 ++++++++++++++++++++ src/exec/runner.ts | 4 +- src/subagent/run.ts | 9 +-- src/tui/runner.ts | 14 ++-- tests/integration/harness.ts | 9 ++- tests/integration/mcp-late-dispatch.test.ts | 83 +++++++++++++++++++++ 9 files changed, 266 insertions(+), 16 deletions(-) create mode 100644 src/agent/live-tool-dispatch.test.ts create mode 100644 src/agent/live-tool-dispatch.ts create mode 100644 tests/integration/mcp-late-dispatch.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1497d9ae2..c8783f871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,16 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ## [Unreleased] +### 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 - **Settled permission and operator prompts no longer recap into the chat.** 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/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([["tool_search", live]]); + expect(fallbackLiveToolBundle(one)).toBe(live); + + const many = new Map([ + ["tool_search", live], + ["present", other], + ]); + expect(fallbackLiveToolBundle(many)).toBeUndefined(); + + const none = new Map([["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(); + 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(); + 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; + return ( + typeof candidate.addTools === "function" && + typeof candidate.currentDefinitions === "function" && + typeof candidate.run === "function" + ); +} + +export function fallbackLiveToolBundle(map: Map): 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(iterable?: Iterable | null): Map { + const map = new OriginalMap(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(iterable?: Iterable | null): Map { + return createLiveDispatchMap(iterable); + }, + { prototype: OriginalMap.prototype }, +) as unknown as MapConstructor; + +export function withLiveToolDispatchMap(fn: () => T): T { + const previous = globalThis.Map; + globalThis.Map = LiveDispatchMap; + try { + return fn(); + } finally { + globalThis.Map = previous; + } +} + +export function createAgentWithLiveToolDispatch( + def: AgentDefinition, + env: EnvReq, +): Promise { + return withLiveToolDispatchMap(() => createAgent(def, env)); +} diff --git a/src/exec/runner.ts b/src/exec/runner.ts index 8f6497909..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 { const withLiveCreds = sources.map((s) => s.id === liveSource.id ? { ...s, apiKey: liveSource.apiKey } : s, ); - return createAgent(def, { + return createAgentWithLiveToolDispatch(def, { sources: withLiveCreds, defaultSource, storage, diff --git a/src/subagent/run.ts b/src/subagent/run.ts index 3ac8a066f..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 { }), }); - let agent: Awaited> | null = null; + let agent: Awaited> | null = null; let streamPromise: Promise | undefined; let closeOnAbort: (() => void) | undefined; // Declared before try (same reasoning as closeOnAbort above): assigned once @@ -380,8 +380,7 @@ export async function runSubAgent(params: RunSubAgentParams): Promise { }), }); - - let agentHandle: Awaited> | null = null; + let agentHandle: Awaited> | null = null; const requestContinuation = (): void => { try { agentHandle?.deliver(buildCompactionContinuationMessage()); @@ -483,7 +482,7 @@ export async function runSubAgent(params: RunSubAgentParams): Promise { 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/tui/runner.ts b/src/tui/runner.ts index 91c7567ca..2a3233575 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, @@ -154,6 +153,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"; @@ -1417,7 +1417,7 @@ export async function runTUI(initialConfig: Config): Promise { 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, @@ -2474,10 +2474,12 @@ export async function runTUI(initialConfig: Config): Promise { // 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( 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 => 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); + } + }); +}); From 070b736f3289bad4d1ac219581b4b2f1c9851b7e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 09:24:24 -0700 Subject: [PATCH 45/59] Record task tool calls in capability eval behavior metrics Older result files omit the field; parse defaults from the per-name map so the frozen baseline still loads. --- CHANGELOG.md | 6 ++++ evals/capability/README.md | 3 +- evals/capability/behaviors.test.ts | 45 +++++++++++++++++++++++++++++ evals/capability/behaviors.ts | Bin 10132 -> 10605 bytes evals/capability/lib.test.ts | 1 + 5 files changed, 54 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8783f871..83017efe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,12 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename still open still writes the full payload into the transcript, because that text would otherwise be unreachable before approval. +### Evals + +- **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. + ## [0.2.98] - 2026-08-17 Corrupt resume state no longer kills sessions, Codex quota errors name the diff --git a/evals/capability/README.md b/evals/capability/README.md index 701a14a99..6c4c2bdc4 100644 --- a/evals/capability/README.md +++ b/evals/capability/README.md @@ -26,7 +26,6 @@ One run can **try different things**: multiple cases × multiple provider/model | bait | `edit-bait` | `tests/fixtures/multiline-edit` | Multi-line source edit; catches sed/heredoc editing | | bait | `subagent-bait` | `tests/fixtures/slow-command` | Subagent must wait on a ~20s command; catches stall gaps | - Bait cases exist to **reproduce known misbehaviors** so behavior changes can be confirmed against them. Each declares the behavior metric it baits in `case.json` (`bait: { metric, threshold }`): the case misbehaves when the @@ -81,6 +80,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 | @@ -242,6 +242,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 90c2a8b4aa37f168f28463eda7726e5dfb730972..8ff04db9217ccf7ee15659f6e3b14d5ce814d7ee 100644 GIT binary patch delta 359 zcmbQ@|2AmDOE#90#NzD9J!}?W#^y6@$;=Rz3|AeLRmVG@Q6@P*ued}Z#4*@AKE&VO zC*IG|*Hyt*K?$UNvlPFu08@;?W;e^)RNMoJcYc{oE&Q|1qFS51-GL7s?+F z>}nuWK|xPXuOP9gI5kBF%mq0e=%@&UtE?0t@_JAygp7i{JzNgzk7B1vKcG#~s9_Qd PQfUCPpmuYBnhPTUQp$MN delta 78 zcmV-U0I~n=Qj|}y=?1e#2Sx+4qY7&Svn~#U0khB#5D) = {}): BehaviorMetrics { maxChainSegmentsPerCommand: 0, networkCommandCount: 0, webFetchToolCallCount: 0, + taskToolCallCount: 0, editViaShellCount: 0, repeatedSearchCount: 0, longestToolOnlyStreak: 0, From 5642bd67622e8078a6c10b12db6481792c16df6f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 09:27:55 -0700 Subject: [PATCH 46/59] Add capability eval smokes for dispatch spawn and token recall Informational only until a deliberate baseline refreeze. The dispatch case requires at least one task call plus a working route; the recall case does not assert that compaction fired. --- CHANGELOG.md | 6 ++ evals/capability/README.md | 3 + .../cases/complex-dispatch-spawn/case.json | 10 ++ .../cases/complex-dispatch-spawn/verify.sh | 101 ++++++++++++++++++ .../complex-recall-after-bulk-read/case.json | 9 ++ .../complex-recall-after-bulk-read/verify.sh | 24 +++++ .../fixtures/large-read/src/secrets/token.txt | 1 + 7 files changed, 154 insertions(+) create mode 100644 evals/capability/cases/complex-dispatch-spawn/case.json create mode 100755 evals/capability/cases/complex-dispatch-spawn/verify.sh create mode 100644 evals/capability/cases/complex-recall-after-bulk-read/case.json create mode 100755 evals/capability/cases/complex-recall-after-bulk-read/verify.sh create mode 100644 tests/fixtures/large-read/src/secrets/token.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 83017efe3..37505fdc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,12 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename - **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. ## [0.2.98] - 2026-08-17 diff --git a/evals/capability/README.md b/evals/capability/README.md index 6c4c2bdc4..2fb38e42a 100644 --- a/evals/capability/README.md +++ b/evals/capability/README.md @@ -19,6 +19,8 @@ One run can **try different things**: multiple cases × multiple provider/model | 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` | @@ -178,6 +180,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) 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-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/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 From fc9cc4ced278b56d322770240dec27572b87c266 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 16:12:32 -0700 Subject: [PATCH 47/59] Fingerprint repeated eval tool calls as a JSON name-args tuple A NUL delimiter made git treat the metrics module as binary. A JSON array owns the name/args boundary without a magic separator. --- evals/capability/behaviors.ts | 5 ++++- src/tui/geometry.test.ts | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/evals/capability/behaviors.ts b/evals/capability/behaviors.ts index 8ff04db92..a904f4e08 100644 --- a/evals/capability/behaviors.ts +++ b/evals/capability/behaviors.ts @@ -225,7 +225,10 @@ export function deriveBehaviorMetrics(summary: CapturedRunSummary): BehaviorMetr } for (const call of turn.toolCalls) { toolCallsByName[call.name] = (toolCallsByName[call.name] ?? 0) + 1; - const signature = `${call.name}\0${normalizeToolArguments(call.arguments)}`; + const signature = JSON.stringify([ + call.name, + normalizeToolArguments(call.arguments), + ]); if (seenCalls.has(signature)) repeatedSearchCount++; else seenCalls.add(signature); diff --git a/src/tui/geometry.test.ts b/src/tui/geometry.test.ts index 231b191af..4c0e8ec1b 100644 --- a/src/tui/geometry.test.ts +++ b/src/tui/geometry.test.ts @@ -560,4 +560,3 @@ describe("resolveGeometry — stack-only layout", () => { expect(layout.regions.agents).toBeUndefined(); }); }); - From bbcdcd74cf418bb903ae418af61f119bddec109f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 17:50:40 -0700 Subject: [PATCH 48/59] Align director write paths and refuse nested Skywalker Shakespeare's write lock now covers the canonical docs/ trio. Bruckheimer is limited to product docs. Skywalker stays the primary session and cannot be spawned as a task leaf. --- CHANGELOG.md | 10 ++++++++ docs/ARCHITECTURE.md | 4 ++-- docs/IMPLEMENTATION.md | 8 +++---- docs/PRODUCT.md | 2 +- src/agent/default-agents.ts | 5 ++-- .../directors/bruckheimer/package.test.ts | 15 +++++++++++- src/agent/directors/bruckheimer/package.ts | 4 ++-- src/agent/directors/registry.test.ts | 18 +++++++++++---- src/agent/directors/registry.ts | 6 +++-- .../directors/shakespeare/package.test.ts | 16 +++++++++++++ src/agent/directors/shakespeare/package.ts | 11 +++++++-- src/agent/prompts.ts | 2 +- src/subagent/task-tool.ts | 8 +++++++ tests/unit/subagent.test.ts | 23 +++++++++++++++++++ 14 files changed, 111 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37505fdc5..aa164e437 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,16 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename still open still writes the full payload into the transcript, because that text would otherwise be unreachable before approval. +### Directors + +- **Docs leaves write only their lane.** Shakespeare can update PRODUCT, + ARCHITECTURE, and IMPLEMENTATION at repo root and under `docs/`. + Bruckheimer is limited to `PRODUCT.md` and `docs/PRODUCT.md` — not the rest + of `docs/`. +- **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. + ### Evals - **Capability eval records `task` tool calls.** `taskToolCallCount` is derived diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c792e6f85..df4ebb38f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -229,7 +229,7 @@ Profiles with `orchestrator: true` may themselves call `task` (one hop only): ne #### 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 general leaf: `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). +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 general leaf: `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** @@ -285,7 +285,7 @@ Every shipped specialist is a **director package** — a prompt-first `DirectorP | greybeard | intern, explore, critique only | | All other leaves | no `task` | -**Tool envelopes** prefer small `tools.allow` mounts over deny-everything. Docs directors may write only under package `writePaths` (enforced by the permission gate, not prompt policy): shakespeare → PRODUCT/ARCHITECTURE/IMPLEMENTATION; brand-reviewer → DESIGN.md; bruckheimer → PRODUCT.md + docs/*. +**Tool envelopes** prefer small `tools.allow` mounts over deny-everything. Docs directors may write only under package `writePaths` (enforced by the permission gate, not prompt policy): shakespeare → PRODUCT/ARCHITECTURE/IMPLEMENTATION at repo root and under `docs/`; brand-reviewer → DESIGN.md; bruckheimer → PRODUCT.md + docs/PRODUCT.md. **Typical chain:** bruckheimer → plan → greybeard → implement (+ intern) → critique (+ optional neckbeard), with skywalker coordinating throughout. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 1b11bf444..31d764a57 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -71,7 +71,7 @@ src/ 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() closed fleet + 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 @@ -156,10 +156,10 @@ Sixteen packages under `src/agent/directors//` register in `DIRECTOR_REGISTR 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. Primary omits the list so plugin profiles stay reachable. -4. `directorProfiles()` is the default profile catalog (`default-agents.ts`); plugin agent profiles still load and can override by id. +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 nested leaf). 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; leaf `writePaths` only gate path-keyed product tools. -6. Leaf `writePaths` (shakespeare docs trio, brand-reviewer `DESIGN.md`, bruckheimer PRODUCT + docs/*) are enforced in the permission gate via ALS identity (`identity-context.ts` + `write-path-policy.ts`). +6. Leaf `writePaths` (shakespeare docs trio at root and under `docs/`, brand-reviewer `DESIGN.md`, bruckheimer PRODUCT.md + docs/PRODUCT.md) are enforced in the permission gate via ALS identity (`identity-context.ts` + `write-path-policy.ts`). 7. Spawn effort: pin > package `modelRole` default (`defaultEffortForDirector`; intern=low; plan/review/orchestrator=high; implement/explore/docs/test=medium) > orchestrator/leaf binary > parent inheritance. Optional skills are listed in the identity header for awareness; leaves 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 leaves no `task`. Live `` injects cwd, platform, arch, runtime, date, and git status on every chat and leaf prompt. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index fda29cf27..d78c55b48 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -151,7 +151,7 @@ The primary session is always **orchestrator** (single-agent mode is gone). Its | Design | draper, emil, brand-reviewer | | Docs / QA | shakespeare, testsmith, tester | -There is **no general leaf**. `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. Nested spawn is runtime-enforced: only skywalker (full fleet allowlist) and greybeard (intern/explore/critique) may spawn; other leaves have no `task`. Primary omits an allowlist so plugin profiles remain reachable from the main session. +There is **no general leaf**. `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 task leaf. Nested spawn is runtime-enforced: only skywalker (full fleet allowlist) and greybeard (intern/explore/critique) may spawn; other leaves have no `task`. Primary omits an allowlist so plugin profiles remain reachable from the main session. 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. diff --git a/src/agent/default-agents.ts b/src/agent/default-agents.ts index 8200efd3c..c688568d4 100644 --- a/src/agent/default-agents.ts +++ b/src/agent/default-agents.ts @@ -1,8 +1,9 @@ import { directorProfiles } from "./directors/registry.js"; import type { AgentPlugin } from "./profile-types.js"; -// Default agent profiles = closed director fleet (CL-5818). Repositories can -// override any id via .agents/agents/ or agent-kind plugins (higher precedence). +// 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: directorProfiles(), }; diff --git a/src/agent/directors/bruckheimer/package.test.ts b/src/agent/directors/bruckheimer/package.test.ts index 9e89d1170..994e09987 100644 --- a/src/agent/directors/bruckheimer/package.test.ts +++ b/src/agent/directors/bruckheimer/package.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { matchesWritePathAllowlist } from "../../../permission/write-path-policy.js"; import { bruckheimerPackage } from "./package.js"; +const cwd = resolve("/tmp/bruckheimer-write-path-fixture"); + describe("bruckheimerPackage", () => { test("id matches directory", () => { expect(bruckheimerPackage.id).toBe("bruckheimer"); @@ -23,7 +27,16 @@ describe("bruckheimerPackage", () => { const allow = bruckheimerPackage.tools?.allow ?? []; expect(allow).toContain("write_file"); expect(allow).toContain("edit_file"); - expect(bruckheimerPackage.writePaths).toEqual(["PRODUCT.md", "docs/*"]); + expect(bruckheimerPackage.writePaths).toEqual(["PRODUCT.md", "docs/PRODUCT.md"]); + }); + + test("writePaths match product docs only, not architecture or TUI", () => { + const allow = bruckheimerPackage.writePaths ?? []; + expect(matchesWritePathAllowlist("PRODUCT.md", allow, cwd)).toBe(true); + expect(matchesWritePathAllowlist("docs/PRODUCT.md", allow, cwd)).toBe(true); + expect(matchesWritePathAllowlist("docs/ARCHITECTURE.md", allow, cwd)).toBe(false); + expect(matchesWritePathAllowlist("docs/IMPLEMENTATION.md", allow, cwd)).toBe(false); + expect(matchesWritePathAllowlist("docs/TUI.md", allow, cwd)).toBe(false); }); test("report requires envelope sections", () => { diff --git a/src/agent/directors/bruckheimer/package.ts b/src/agent/directors/bruckheimer/package.ts index d39801714..124879c6a 100644 --- a/src/agent/directors/bruckheimer/package.ts +++ b/src/agent/directors/bruckheimer/package.ts @@ -17,7 +17,7 @@ export const bruckheimerPackage: DirectorPackage = { ], description: "Product discovery leaf — user/product shape docs, not code", tools: { allow: DOCS_TOOLS }, - writePaths: ["PRODUCT.md", "docs/*"], + writePaths: ["PRODUCT.md", "docs/PRODUCT.md"], spawn: { maySpawn: false }, nudge: { maxTurns: 40 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, @@ -26,7 +26,7 @@ export const bruckheimerPackage: DirectorPackage = { 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; path locks are enforced by authz (PRODUCT.md and docs/*). You are not an implementer. You are not the architecture gate (that is Greybeard). You do not ship features or product code. +Write tools are mounted; path locks are enforced by authz (PRODUCT.md and docs/PRODUCT.md). 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. diff --git a/src/agent/directors/registry.test.ts b/src/agent/directors/registry.test.ts index 37b11383a..14b144e30 100644 --- a/src/agent/directors/registry.test.ts +++ b/src/agent/directors/registry.test.ts @@ -111,15 +111,19 @@ describe("director registry", () => { "PRODUCT.md", "ARCHITECTURE.md", "IMPLEMENTATION.md", + "docs/PRODUCT.md", + "docs/ARCHITECTURE.md", + "docs/IMPLEMENTATION.md", ]); expect(shakespeare.capabilities?.mode).toBe("allow"); expect(shakespeare.capabilities?.tools).toContain("write_file"); }); - test("directorProfiles covers closed set", () => { + test("directorProfiles is the spawn catalog (closed set minus skywalker)", () => { const profiles = directorProfiles(); - expect(profiles).toHaveLength(16); - expect(new Set(profiles.map((p) => p.id)).size).toBe(16); + 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. @@ -156,9 +160,15 @@ describe("director registry", () => { "PRODUCT.md", "ARCHITECTURE.md", "IMPLEMENTATION.md", + "docs/PRODUCT.md", + "docs/ARCHITECTURE.md", + "docs/IMPLEMENTATION.md", ]); expect(DIRECTOR_REGISTRY["brand-reviewer"].writePaths).toEqual(["DESIGN.md"]); - expect(DIRECTOR_REGISTRY.bruckheimer.writePaths).toEqual(["PRODUCT.md", "docs/*"]); + expect(DIRECTOR_REGISTRY.bruckheimer.writePaths).toEqual([ + "PRODUCT.md", + "docs/PRODUCT.md", + ]); }); test("implement mounts product writes; intern is shell-only; other leaves do not spawn", () => { diff --git a/src/agent/directors/registry.ts b/src/agent/directors/registry.ts index e6880a3a9..a1ef361c0 100644 --- a/src/agent/directors/registry.ts +++ b/src/agent/directors/registry.ts @@ -132,7 +132,9 @@ export function packageToProfile(pkg: DirectorPackage): AgentProfile { }; } -/** All closed directors as agent profiles (replaces hand-written default-agents stubs). */ +/** Spawnable director profiles (closed set minus primary skywalker). */ export function directorProfiles(): AgentProfile[] { - return listDirectors().map(packageToProfile); + return listDirectors() + .filter((pkg) => pkg.id !== "skywalker") + .map(packageToProfile); } diff --git a/src/agent/directors/shakespeare/package.test.ts b/src/agent/directors/shakespeare/package.test.ts index 3be5819f5..47b53b1ca 100644 --- a/src/agent/directors/shakespeare/package.test.ts +++ b/src/agent/directors/shakespeare/package.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { matchesWritePathAllowlist } from "../../../permission/write-path-policy.js"; import { shakespearePackage } from "./package.js"; +const cwd = resolve("/tmp/shakespeare-write-path-fixture"); + describe("shakespearePackage", () => { test("id matches directory / registry id", () => { expect(shakespearePackage.id).toBe("shakespeare"); @@ -40,9 +44,21 @@ describe("shakespearePackage", () => { "PRODUCT.md", "ARCHITECTURE.md", "IMPLEMENTATION.md", + "docs/PRODUCT.md", + "docs/ARCHITECTURE.md", + "docs/IMPLEMENTATION.md", ]); }); + test("writePaths match the docs trio at root and under docs/, not TUI", () => { + const allow = shakespearePackage.writePaths ?? []; + expect(matchesWritePathAllowlist("docs/ARCHITECTURE.md", allow, cwd)).toBe(true); + expect(matchesWritePathAllowlist("docs/PRODUCT.md", allow, cwd)).toBe(true); + expect(matchesWritePathAllowlist("docs/IMPLEMENTATION.md", allow, cwd)).toBe(true); + expect(matchesWritePathAllowlist("ARCHITECTURE.md", allow, cwd)).toBe(true); + expect(matchesWritePathAllowlist("docs/TUI.md", allow, cwd)).toBe(false); + }); + test("report.requiredSections includes Summary, Findings, Blockers, Paths", () => { const sections = shakespearePackage.report.requiredSections; expect(sections).toContain("Summary"); diff --git a/src/agent/directors/shakespeare/package.ts b/src/agent/directors/shakespeare/package.ts index e4bd8355e..6eb6fa0ce 100644 --- a/src/agent/directors/shakespeare/package.ts +++ b/src/agent/directors/shakespeare/package.ts @@ -57,7 +57,7 @@ Scan for thin sections, undefined references, missing failure modes/constraints, Confirm what changed and where. Summarize consistency/gap follow-ups. -Write tools are mounted; path locks are enforced by authz (PRODUCT/ARCHITECTURE/IMPLEMENTATION only). Do not implement product source code, run the fleet, or act as tester/reviewer. +Write tools are mounted; path locks are enforced by authz (PRODUCT/ARCHITECTURE/IMPLEMENTATION at repo root and under docs/). 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. @@ -76,7 +76,14 @@ export const shakespearePackage: DirectorPackage = { systemPrompt: SHAKESPEARE_SYSTEM_PROMPT, optionalSkills: ["style", "philosophy"], tools: { allow: DOCS_TOOLS }, - writePaths: ["PRODUCT.md", "ARCHITECTURE.md", "IMPLEMENTATION.md"], + writePaths: [ + "PRODUCT.md", + "ARCHITECTURE.md", + "IMPLEMENTATION.md", + "docs/PRODUCT.md", + "docs/ARCHITECTURE.md", + "docs/IMPLEMENTATION.md", + ], spawn: { maySpawn: false }, nudge: { maxTurns: 50 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index 9390b99b3..eb131edf0 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -64,7 +64,7 @@ export function buildHarnessFacts( "- 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/docs) for durable edits.", + "- 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.", diff --git a/src/subagent/task-tool.ts b/src/subagent/task-tool.ts index cbacdbcfd..e805d6c40 100644 --- a/src/subagent/task-tool.ts +++ b/src/subagent/task-tool.ts @@ -447,6 +447,14 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { ); } + // Skywalker is the primary session identity, not a nested leaf. + if (agentId === "skywalker" || resolvedDirectorId === "skywalker") { + return taskToolResult( + call.id, + "Error: skywalker is the primary session identity, not a task leaf. 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 = diff --git a/tests/unit/subagent.test.ts b/tests/unit/subagent.test.ts index a2df5a9f3..c91b24f94 100644 --- a/tests/unit/subagent.test.ts +++ b/tests/unit/subagent.test.ts @@ -320,6 +320,29 @@ test("spawnAllowlist rejects children outside the parent director matrix", async expect(ran).toBe(true); }); +test("task refuses skywalker as a nested leaf", 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({ From ffd58a9e8263067a70e698ff2e98b5867ab385d6 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 19:54:02 -0700 Subject: [PATCH 49/59] Guide sub-agents after failed tool calls --- src/subagent/nudge-director.test.ts | 133 ++++++++++++++++++++++++++++ src/subagent/nudge-director.ts | 11 ++- 2 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 src/subagent/nudge-director.test.ts diff --git a/src/subagent/nudge-director.test.ts b/src/subagent/nudge-director.test.ts new file mode 100644 index 000000000..87a538af6 --- /dev/null +++ b/src/subagent/nudge-director.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "bun:test"; +import type { + ReactorAction, + ReactorCapabilities, + ReactorInboundEvent, + ReactorState, +} from "@intx/types/runtime"; +import { SubAgentDirector } from "./nudge-director.js"; + +const state = { turns: [] } 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[]): 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: `${id}.ts` }, + })), + }, + usage: { input: 0, output: 0 }, + source: "test", + } 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 actions(result: ReactorAction | ReactorAction[]): ReactorAction[] { + return Array.isArray(result) ? result : [result]; +} + +function inferAction(result: ReactorAction | ReactorAction[]): Extract { + const infer = actions(result).find( + (action): action is Extract => action.type === "infer", + ); + if (infer === undefined) throw new Error("expected infer action"); + return infer; +} + +function ephemeralTexts(infer: Extract): 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(); + }); +}); diff --git a/src/subagent/nudge-director.ts b/src/subagent/nudge-director.ts index 51d1d0196..60931cabd 100644 --- a/src/subagent/nudge-director.ts +++ b/src/subagent/nudge-director.ts @@ -33,6 +33,9 @@ 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."; @@ -245,6 +248,9 @@ export class SubAgentDirector extends DefaultDirector { if (event.type === "tool.done") { this.lastActivityAt = this.now(); this.consecutiveStalls = 0; + if (event.result.isError === true) { + this.pendingNudgeText = TOOL_FAILURE_RECOVERY_NUDGE; + } } const base = await super.decide(event, state, capabilities); const actions = this.applyPendingNudge( @@ -297,9 +303,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[], From d0798e96c59bf3b3fa68b69442ff9bf0244cf10d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 20:05:53 -0700 Subject: [PATCH 50/59] Preserve sub-agent nudges through compaction --- src/subagent/nudge-director.test.ts | 109 +++++++++++++++++++++++++++- src/subagent/nudge-director.ts | 17 +++-- 2 files changed, 115 insertions(+), 11 deletions(-) diff --git a/src/subagent/nudge-director.test.ts b/src/subagent/nudge-director.test.ts index 87a538af6..6fcabc37d 100644 --- a/src/subagent/nudge-director.test.ts +++ b/src/subagent/nudge-director.test.ts @@ -5,9 +5,16 @@ import type { 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 { @@ -26,7 +33,11 @@ function capabilities(): ReactorCapabilities { }; } -function inferenceDone(callIds: string[]): ReactorInboundEvent { +function inferenceDone( + callIds: string[], + inputTokens = 0, + pathForId: (id: string) => string = (id) => `${id}.ts`, +): ReactorInboundEvent { return { type: "inference.done", turn: { @@ -37,11 +48,11 @@ function inferenceDone(callIds: string[]): ReactorInboundEvent { type: "tool_call", id, name: "read_file", - arguments: { path: `${id}.ts` }, + arguments: { path: pathForId(id) }, })), }, - usage: { input: 0, output: 0 }, - source: "test", + usage: { input: inputTokens, output: 1, cacheRead: 0, cacheWrite: 0, thinking: 0 }, + source: { model: "test-model" }, } as unknown as ReactorInboundEvent; } @@ -52,6 +63,13 @@ function toolDone(callId: string, isError = false): ReactorInboundEvent { } 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]; } @@ -130,4 +148,87 @@ describe("SubAgentDirector tool failure recovery", () => { ); 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"); + }); }); diff --git a/src/subagent/nudge-director.ts b/src/subagent/nudge-director.ts index 60931cabd..07cb3740f 100644 --- a/src/subagent/nudge-director.ts +++ b/src/subagent/nudge-director.ts @@ -148,7 +148,7 @@ export class SubAgentDirector extends DefaultDirector { capabilities: ReactorCapabilities, ): Promise { 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; @@ -248,16 +248,19 @@ export class SubAgentDirector extends DefaultDirector { if (event.type === "tool.done") { this.lastActivityAt = this.now(); this.consecutiveStalls = 0; - if (event.result.isError === true) { + 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); } /** From bd8f5d152c4e549bd49a17cae77f0f2405185d0a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 20:41:16 -0700 Subject: [PATCH 51/59] Preserve sub-agent nudges through overflow recovery --- src/subagent/nudge-director.test.ts | 113 ++++++++++++++++++++++++++++ src/subagent/nudge-director.ts | 31 ++++++-- 2 files changed, 138 insertions(+), 6 deletions(-) diff --git a/src/subagent/nudge-director.test.ts b/src/subagent/nudge-director.test.ts index 6fcabc37d..ffe9beaca 100644 --- a/src/subagent/nudge-director.test.ts +++ b/src/subagent/nudge-director.test.ts @@ -82,6 +82,14 @@ function inferAction(result: ReactorAction | ReactorAction[]): Extract): string[] | undefined { const options = infer.options as | { ephemeralTurns?: Array<{ content: Array<{ text?: string }> }> } @@ -231,4 +239,109 @@ describe("SubAgentDirector tool failure recovery", () => { 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(); + }); }); diff --git a/src/subagent/nudge-director.ts b/src/subagent/nudge-director.ts index 07cb3740f..c3e5e98df 100644 --- a/src/subagent/nudge-director.ts +++ b/src/subagent/nudge-director.ts @@ -95,12 +95,21 @@ 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; @@ -153,7 +162,15 @@ export class SubAgentDirector extends DefaultDirector { 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; @@ -163,6 +180,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); @@ -318,6 +336,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; const rewritten = [...actions]; rewritten[inferIndex] = capabilities.infer(withEphemeralNudge(existing.options, text)); From 9f1c38ac305b3a86770dd54c5cb8b66e1e7c07ee Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 21:58:42 -0700 Subject: [PATCH 52/59] Remove write-path locks from shipped directors --- CHANGELOG.md | 9 +++--- docs/ARCHITECTURE.md | 4 +-- docs/IMPLEMENTATION.md | 4 +-- docs/PRODUCT.md | 2 +- .../directors/brand-reviewer/package.test.ts | 8 +++--- src/agent/directors/brand-reviewer/package.ts | 4 +-- .../directors/bruckheimer/package.test.ts | 17 ++--------- src/agent/directors/bruckheimer/package.ts | 4 +-- src/agent/directors/registry.test.ts | 28 ++++--------------- .../directors/shakespeare/package.test.ts | 24 ++-------------- src/agent/directors/shakespeare/package.ts | 11 +------- src/agent/directors/tool-sets.test.ts | 2 +- src/agent/directors/tool-sets.ts | 8 +++--- 13 files changed, 32 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa164e437..faee0f908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,10 +35,11 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ### Directors -- **Docs leaves write only their lane.** Shakespeare can update PRODUCT, - ARCHITECTURE, and IMPLEMENTATION at repo root and under `docs/`. - Bruckheimer is limited to `PRODUCT.md` and `docs/PRODUCT.md` — not the rest - of `docs/`. +- **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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index df4ebb38f..058de5681 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -263,7 +263,7 @@ Every shipped specialist is a **director package** — a prompt-first `DirectorP | Director | Owns | |---|---| -| shakespeare | Docs maintain (scribe core baked into prompt); PRODUCT/ARCHITECTURE/IMPLEMENTATION write paths | +| 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 | @@ -285,7 +285,7 @@ Every shipped specialist is a **director package** — a prompt-first `DirectorP | greybeard | intern, explore, critique only | | All other leaves | no `task` | -**Tool envelopes** prefer small `tools.allow` mounts over deny-everything. Docs directors may write only under package `writePaths` (enforced by the permission gate, not prompt policy): shakespeare → PRODUCT/ARCHITECTURE/IMPLEMENTATION at repo root and under `docs/`; brand-reviewer → DESIGN.md; bruckheimer → PRODUCT.md + docs/PRODUCT.md. +**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. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 31d764a57..0e1112ff4 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -158,8 +158,8 @@ Sixteen packages under `src/agent/directors//` register in `DIRECTOR_REGISTR 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 nested leaf). 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; leaf `writePaths` only gate path-keyed product tools. -6. Leaf `writePaths` (shakespeare docs trio at root and under `docs/`, brand-reviewer `DESIGN.md`, bruckheimer PRODUCT.md + docs/PRODUCT.md) are enforced in the permission gate via ALS identity (`identity-context.ts` + `write-path-policy.ts`). +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/leaf binary > parent inheritance. Optional skills are listed in the identity header for awareness; leaves 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 leaves no `task`. Live `` injects cwd, platform, arch, runtime, date, and git status on every chat and leaf prompt. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index d78c55b48..1c3b7ca4b 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -142,7 +142,7 @@ Capabilities beyond the core toolset are opt-in plugins, enabled per workspace t ## Multi-agent (sub-agents) -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/docs leaves 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, and package `writePaths` only constrains path-keyed product tools (not shell). Yolo / skip-permissions still bypasses the write-path gate when enabled. +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/docs leaves 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. | Lane | Directors | |---|---| diff --git a/src/agent/directors/brand-reviewer/package.test.ts b/src/agent/directors/brand-reviewer/package.test.ts index 927846c77..6d59124bd 100644 --- a/src/agent/directors/brand-reviewer/package.test.ts +++ b/src/agent/directors/brand-reviewer/package.test.ts @@ -19,16 +19,16 @@ describe("brandReviewerPackage", () => { expect(brandReviewerPackage.spawn.maySpawn).toBe(false); }); - test("tools.allow includes write tools; writePaths lock DESIGN.md", () => { + 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).toEqual(["DESIGN.md"]); + expect(brandReviewerPackage.writePaths).toBeUndefined(); }); - test("systemPrompt mentions DESIGN.md and authz path locks", () => { + test("systemPrompt mentions DESIGN.md", () => { expect(brandReviewerPackage.systemPrompt).toMatch(/DESIGN\.md/); - expect(brandReviewerPackage.systemPrompt).toMatch(/authz/i); + expect(brandReviewerPackage.systemPrompt).not.toMatch(/authz/i); }); test("report.requiredSections covers the leaf envelope", () => { diff --git a/src/agent/directors/brand-reviewer/package.ts b/src/agent/directors/brand-reviewer/package.ts index f541703c0..72bf829b3 100644 --- a/src/agent/directors/brand-reviewer/package.ts +++ b/src/agent/directors/brand-reviewer/package.ts @@ -3,7 +3,6 @@ import { DOCS_TOOLS } from "../tool-sets.js"; /** * Brand Reviewer — owns DESIGN.md create/use + brand consistency gate for UI. CL-5829. - * Write path lock is authz (writePaths), not prompt policy. */ export const brandReviewerPackage: DirectorPackage = { id: "brand-reviewer", @@ -16,7 +15,6 @@ export const brandReviewerPackage: DirectorPackage = { ], description: "DESIGN.md brand gate leaf", tools: { allow: DOCS_TOOLS }, - writePaths: ["DESIGN.md"], spawn: { maySpawn: false }, nudge: { maxTurns: 40 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, @@ -25,7 +23,7 @@ export const brandReviewerPackage: DirectorPackage = { 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; path locks are enforced by authz (DESIGN.md only). If a fix requires product code changes, report Findings + Blockers and name implement (or draper/emil for critique) — do not patch code yourself. +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 diff --git a/src/agent/directors/bruckheimer/package.test.ts b/src/agent/directors/bruckheimer/package.test.ts index 994e09987..c6c2a8d79 100644 --- a/src/agent/directors/bruckheimer/package.test.ts +++ b/src/agent/directors/bruckheimer/package.test.ts @@ -1,10 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { resolve } from "node:path"; -import { matchesWritePathAllowlist } from "../../../permission/write-path-policy.js"; import { bruckheimerPackage } from "./package.js"; -const cwd = resolve("/tmp/bruckheimer-write-path-fixture"); - describe("bruckheimerPackage", () => { test("id matches directory", () => { expect(bruckheimerPackage.id).toBe("bruckheimer"); @@ -23,20 +19,11 @@ describe("bruckheimerPackage", () => { expect(bruckheimerPackage.spawn.maySpawn).toBe(false); }); - test("tools.allow includes write tools; writePaths lock discovery docs", () => { + 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).toEqual(["PRODUCT.md", "docs/PRODUCT.md"]); - }); - - test("writePaths match product docs only, not architecture or TUI", () => { - const allow = bruckheimerPackage.writePaths ?? []; - expect(matchesWritePathAllowlist("PRODUCT.md", allow, cwd)).toBe(true); - expect(matchesWritePathAllowlist("docs/PRODUCT.md", allow, cwd)).toBe(true); - expect(matchesWritePathAllowlist("docs/ARCHITECTURE.md", allow, cwd)).toBe(false); - expect(matchesWritePathAllowlist("docs/IMPLEMENTATION.md", allow, cwd)).toBe(false); - expect(matchesWritePathAllowlist("docs/TUI.md", allow, cwd)).toBe(false); + expect(bruckheimerPackage.writePaths).toBeUndefined(); }); test("report requires envelope sections", () => { diff --git a/src/agent/directors/bruckheimer/package.ts b/src/agent/directors/bruckheimer/package.ts index 124879c6a..7f8e1b724 100644 --- a/src/agent/directors/bruckheimer/package.ts +++ b/src/agent/directors/bruckheimer/package.ts @@ -3,7 +3,6 @@ import { DOCS_TOOLS } from "../tool-sets.js"; /** * Product discovery leaf (CL-5824). - * Write path lock is authz (writePaths), not prompt policy. */ export const bruckheimerPackage: DirectorPackage = { id: "bruckheimer", @@ -17,7 +16,6 @@ export const bruckheimerPackage: DirectorPackage = { ], description: "Product discovery leaf — user/product shape docs, not code", tools: { allow: DOCS_TOOLS }, - writePaths: ["PRODUCT.md", "docs/PRODUCT.md"], spawn: { maySpawn: false }, nudge: { maxTurns: 40 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, @@ -26,7 +24,7 @@ export const bruckheimerPackage: DirectorPackage = { 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; path locks are enforced by authz (PRODUCT.md and docs/PRODUCT.md). You are not an implementer. You are not the architecture gate (that is Greybeard). You do not ship features or product code. +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. diff --git a/src/agent/directors/registry.test.ts b/src/agent/directors/registry.test.ts index 14b144e30..6424e17a0 100644 --- a/src/agent/directors/registry.test.ts +++ b/src/agent/directors/registry.test.ts @@ -107,14 +107,7 @@ describe("director registry", () => { expect(grey.maxTurns).toBe(DIRECTOR_REGISTRY.greybeard.nudge?.maxTurns); const shakespeare = packageToProfile(DIRECTOR_REGISTRY.shakespeare); - expect(shakespeare.writePaths).toEqual([ - "PRODUCT.md", - "ARCHITECTURE.md", - "IMPLEMENTATION.md", - "docs/PRODUCT.md", - "docs/ARCHITECTURE.md", - "docs/IMPLEMENTATION.md", - ]); + expect(shakespeare.writePaths).toBeUndefined(); expect(shakespeare.capabilities?.mode).toBe("allow"); expect(shakespeare.capabilities?.tools).toContain("write_file"); }); @@ -155,20 +148,11 @@ describe("director registry", () => { } }); - test("docs writePaths: shakespeare trio + brand DESIGN.md + bruckheimer PRODUCT", () => { - expect(DIRECTOR_REGISTRY.shakespeare.writePaths).toEqual([ - "PRODUCT.md", - "ARCHITECTURE.md", - "IMPLEMENTATION.md", - "docs/PRODUCT.md", - "docs/ARCHITECTURE.md", - "docs/IMPLEMENTATION.md", - ]); - expect(DIRECTOR_REGISTRY["brand-reviewer"].writePaths).toEqual(["DESIGN.md"]); - expect(DIRECTOR_REGISTRY.bruckheimer.writePaths).toEqual([ - "PRODUCT.md", - "docs/PRODUCT.md", - ]); + 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", () => { diff --git a/src/agent/directors/shakespeare/package.test.ts b/src/agent/directors/shakespeare/package.test.ts index 47b53b1ca..41501eb47 100644 --- a/src/agent/directors/shakespeare/package.test.ts +++ b/src/agent/directors/shakespeare/package.test.ts @@ -1,10 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { resolve } from "node:path"; -import { matchesWritePathAllowlist } from "../../../permission/write-path-policy.js"; import { shakespearePackage } from "./package.js"; -const cwd = resolve("/tmp/shakespeare-write-path-fixture"); - describe("shakespearePackage", () => { test("id matches directory / registry id", () => { expect(shakespearePackage.id).toBe("shakespeare"); @@ -36,27 +32,11 @@ describe("shakespearePackage", () => { expect(shakespearePackage.spawn.maySpawn).toBe(false); }); - test("tools.allow includes write tools; writePaths lock docs", () => { + 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).toEqual([ - "PRODUCT.md", - "ARCHITECTURE.md", - "IMPLEMENTATION.md", - "docs/PRODUCT.md", - "docs/ARCHITECTURE.md", - "docs/IMPLEMENTATION.md", - ]); - }); - - test("writePaths match the docs trio at root and under docs/, not TUI", () => { - const allow = shakespearePackage.writePaths ?? []; - expect(matchesWritePathAllowlist("docs/ARCHITECTURE.md", allow, cwd)).toBe(true); - expect(matchesWritePathAllowlist("docs/PRODUCT.md", allow, cwd)).toBe(true); - expect(matchesWritePathAllowlist("docs/IMPLEMENTATION.md", allow, cwd)).toBe(true); - expect(matchesWritePathAllowlist("ARCHITECTURE.md", allow, cwd)).toBe(true); - expect(matchesWritePathAllowlist("docs/TUI.md", allow, cwd)).toBe(false); + expect(shakespearePackage.writePaths).toBeUndefined(); }); test("report.requiredSections includes Summary, Findings, Blockers, Paths", () => { diff --git a/src/agent/directors/shakespeare/package.ts b/src/agent/directors/shakespeare/package.ts index 6eb6fa0ce..7e4bb7196 100644 --- a/src/agent/directors/shakespeare/package.ts +++ b/src/agent/directors/shakespeare/package.ts @@ -3,7 +3,6 @@ import { DOCS_TOOLS } from "../tool-sets.js"; /** * Shakespeare: docs-maintenance leaf with scribe core baked into systemPrompt. - * Write path lock is authz (writePaths), not prompt policy. */ const SHAKESPEARE_SYSTEM_PROMPT = `You are Shakespeare, a leaf director in Corbits Code. @@ -57,7 +56,7 @@ Scan for thin sections, undefined references, missing failure modes/constraints, Confirm what changed and where. Summarize consistency/gap follow-ups. -Write tools are mounted; path locks are enforced by authz (PRODUCT/ARCHITECTURE/IMPLEMENTATION at repo root and under docs/). Do not implement product source code, run the fleet, or act as tester/reviewer. +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. @@ -76,14 +75,6 @@ export const shakespearePackage: DirectorPackage = { systemPrompt: SHAKESPEARE_SYSTEM_PROMPT, optionalSkills: ["style", "philosophy"], tools: { allow: DOCS_TOOLS }, - writePaths: [ - "PRODUCT.md", - "ARCHITECTURE.md", - "IMPLEMENTATION.md", - "docs/PRODUCT.md", - "docs/ARCHITECTURE.md", - "docs/IMPLEMENTATION.md", - ], spawn: { maySpawn: false }, nudge: { maxTurns: 50 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, diff --git a/src/agent/directors/tool-sets.test.ts b/src/agent/directors/tool-sets.test.ts index 479e80b4b..92b268ae6 100644 --- a/src/agent/directors/tool-sets.test.ts +++ b/src/agent/directors/tool-sets.test.ts @@ -6,7 +6,7 @@ import { } from "./tool-sets.js"; describe("DOCS_TOOLS", () => { - test("excludes run_shell (writePaths gate only locks file writes)", () => { + test("excludes run_shell and delete_file as envelope policy", () => { expect(DOCS_TOOLS).not.toContain("run_shell"); expect(DOCS_TOOLS).not.toContain("delete_file"); }); diff --git a/src/agent/directors/tool-sets.ts b/src/agent/directors/tool-sets.ts index 5e81e9b3a..e750cc33d 100644 --- a/src/agent/directors/tool-sets.ts +++ b/src/agent/directors/tool-sets.ts @@ -25,10 +25,10 @@ export const IMPLEMENT_TOOLS = [ ] as const; /** - * Docs leaves that may write only under writePaths authz. - * Read/search/lsp/web + file writes — no run_shell. The writePaths gate only - * locks write_file/edit_file/delete_file, so a shell here would bypass it; - * docs leaves that need a shell mount IMPLEMENT_TOOLS instead. + * 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. From b207592589484d127e10538b844508b756819d53 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 21:59:46 -0700 Subject: [PATCH 53/59] Show slash command descriptions under the list --- docs/TUI.md | 27 ++++-- src/tui/command-catalog.test.ts | 20 ++++ src/tui/command-catalog.ts | 8 +- src/tui/palette-paint.test.ts | 154 ++++++++++++++++++++++++++++++ src/tui/prompt-slash-exit.test.ts | 35 ++++++- src/tui/shell.ts | 6 ++ 6 files changed, 238 insertions(+), 12 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index fc4289144..d0e1d53ee 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -282,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 @@ -331,9 +340,9 @@ 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 +(printable keys claimed by the picker's own `>` filter row); 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 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. 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/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/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 + readonly frame: () => string } function withShell(fn: (ctx: Ctx) => Promise): Promise { @@ -48,6 +54,7 @@ function withShell(fn: (ctx: Ctx) => Promise): Promise { dispatched, press: (key) => h.pressKey(key as Parameters[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/shell.ts b/src/tui/shell.ts index 249c797da..f401de586 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -3791,6 +3791,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}` } : {}), From fd9ac4e9f9d50d56ed5eced0abee9dceb4351498 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 14:32:39 -0700 Subject: [PATCH 54/59] Stop treating mid-run worker narration as a finished report A tool-less turn after tools is complete only when the four-heading envelope is present. Missing headings get one wrap-up nudge, then an incomplete-report salvage so identical re-dispatch is blocked. Greybeard is told to review itself and never spawn a parallel diagnostic fleet. --- docs/ARCHITECTURE.md | 2 +- src/agent/directors/greybeard/package.test.ts | 6 + src/agent/directors/greybeard/package.ts | 4 +- src/subagent/brief-dispatch.ts | 14 +- src/subagent/index.test.ts | 138 ++++++++++++++++++ src/subagent/index.ts | 1 + src/subagent/nudge-director.test.ts | 136 +++++++++++++++++ src/subagent/nudge-director.ts | 38 ++++- src/subagent/report.ts | 8 + src/subagent/stop-policy.ts | 88 ++++++++--- src/subagent/thrash.test.ts | 26 ++++ src/subagent/thrash.ts | 49 ++++--- 12 files changed, 470 insertions(+), 40 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 058de5681..dd16bc876 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. diff --git a/src/agent/directors/greybeard/package.test.ts b/src/agent/directors/greybeard/package.test.ts index 2fbe1f579..7dbbe8939 100644 --- a/src/agent/directors/greybeard/package.test.ts +++ b/src/agent/directors/greybeard/package.test.ts @@ -32,6 +32,12 @@ describe("greybeardPackage", () => { 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"); diff --git a/src/agent/directors/greybeard/package.ts b/src/agent/directors/greybeard/package.ts index 4e94bbb6b..bc565644b 100644 --- a/src/agent/directors/greybeard/package.ts +++ b/src/agent/directors/greybeard/package.ts @@ -22,7 +22,7 @@ export const greybeardPackage: DirectorPackage = { nudge: { maxTurns: 50 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, modelRole: "review", - systemPrompt: `You are GreybeardDirector, a leaf director in Corbits Code. + 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. @@ -30,6 +30,8 @@ Load style and philosophy when reviewing plans or approaches — skills are acti 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) 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([ "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/index.test.ts b/src/subagent/index.test.ts index 1595822c7..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((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({ @@ -1983,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 }); 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 index ffe9beaca..a2fca75eb 100644 --- a/src/subagent/nudge-director.test.ts +++ b/src/subagent/nudge-director.test.ts @@ -56,6 +56,20 @@ function inferenceDone( } 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", @@ -345,3 +359,125 @@ describe("SubAgentDirector tool failure recovery", () => { 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 c3e5e98df..24c021ede 100644 --- a/src/subagent/nudge-director.ts +++ b/src/subagent/nudge-director.ts @@ -40,6 +40,7 @@ const TOOL_FAILURE_RECOVERY_NUDGE = 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. @@ -47,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", @@ -113,6 +118,9 @@ export class SubAgentDirector extends DefaultDirector { // 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 @@ -209,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") { @@ -221,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 @@ -228,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 @@ -241,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" @@ -252,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), 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/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/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 { @@ -94,6 +95,17 @@ function pathFromArgs(args: Record): string | null { return typeof path === "string" && path.length > 0 ? path : null; } +function searchKey(name: string, args: Record): 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 { * 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; From 3531b989b968b1f57eb62ce677c3bded844c8385 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 14:35:10 -0700 Subject: [PATCH 55/59] Ship default action skills as first-party slash commands Vanilla installs no longer need a sibling agents tree for default skills. Recipes spawn closed directors; convention skills stay use_skill-only. --- .gitignore | 2 +- CHANGELOG.md | 40 + docs/ARCHITECTURE.md | 41 +- docs/IMPLEMENTATION.md | 30 +- docs/PLUGINS.md | 52 +- docs/PRODUCT.md | 18 +- docs/TUI.md | 35 +- package.json | 4 +- plugins/corbits-skills/manifest.json | 7 + .../corbits-skills/skills/ast-grep/SKILL.md | 415 +++++++++++ .../skills/create-issue/SKILL.md | 507 +++++++++++++ .../corbits-skills/skills/dispatch/SKILL.md | 213 ++++++ .../corbits-skills/skills/git-rebase/SKILL.md | 699 ++++++++++++++++++ .../corbits-skills/skills/implement/SKILL.md | 89 +++ .../corbits-skills/skills/interview/SKILL.md | 146 ++++ .../skills/linear-issue-workflow/SKILL.md | 160 ++++ plugins/corbits-skills/skills/opsh/SKILL.md | 488 ++++++++++++ .../corbits-skills/skills/philosophy/SKILL.md | 114 +++ plugins/corbits-skills/skills/plan/SKILL.md | 16 + .../skills/pull-request-review/SKILL.md | 107 +++ .../corbits-skills/skills/refactor/SKILL.md | 41 + plugins/corbits-skills/skills/review/SKILL.md | 35 + plugins/corbits-skills/skills/scribe/SKILL.md | 14 + plugins/corbits-skills/skills/style/SKILL.md | 333 +++++++++ .../corbits-skills/skills/typescript/SKILL.md | 629 ++++++++++++++++ scripts/copy-repo-plugins.ts | 17 + scripts/release.sh | 11 + src/agent/director.test.ts | 44 ++ src/agent/director.ts | 51 ++ src/agent/directors/brand-reviewer/package.ts | 4 +- src/agent/directors/bruckheimer/package.ts | 6 +- src/agent/directors/critique/package.ts | 4 +- src/agent/directors/draper/package.ts | 2 +- src/agent/directors/emil/package.ts | 2 +- src/agent/directors/explore/package.ts | 2 +- src/agent/directors/gaasbot/package.ts | 2 +- src/agent/directors/identity.ts | 4 +- src/agent/directors/implement/package.ts | 4 +- src/agent/directors/intern/package.ts | 2 +- src/agent/directors/neckbeard/package.ts | 2 +- src/agent/directors/plan/package.ts | 2 +- src/agent/directors/registry.test.ts | 2 +- src/agent/directors/shakespeare/package.ts | 2 +- src/agent/directors/skywalker/package.test.ts | 33 +- src/agent/directors/skywalker/package.ts | 61 +- src/agent/directors/tester/package.ts | 6 +- src/agent/directors/testsmith/package.ts | 6 +- src/agent/directors/types.ts | 4 +- src/agent/look-tour.test.ts | 10 + src/agent/look-tour.ts | 7 + src/agent/prompts.test.ts | 16 +- src/agent/prompts.ts | 16 +- src/config/index.ts | 8 +- src/config/settings.ts | 17 + src/plugins/loader.ts | 64 +- src/plugins/manifest.ts | 4 + src/plugins/register.ts | 19 +- src/plugins/skill-commands.ts | 16 +- src/prompts.test.ts | 14 +- src/session/index.ts | 2 +- src/session/project-key.test.ts | 66 +- src/session/project-key.ts | 35 +- src/session/runtime-assembly.test.ts | 40 + src/session/runtime-assembly.ts | 10 +- src/session/session-dir.test.ts | 43 +- src/settings.test.ts | 27 + src/subagent/task-tool.ts | 18 +- src/tui/commands/built-in.test.ts | 10 +- src/tui/commands/built-in.ts | 9 +- src/tui/commands/registry.test.ts | 16 + src/tui/commands/registry.ts | 3 + src/tui/model-catalog.test.ts | 22 +- src/tui/model-catalog.ts | 9 +- src/tui/notice-line.test.ts | 38 +- src/tui/notice-line.ts | 23 + src/tui/overlay-paint.test.ts | 20 +- src/tui/overlays.test.ts | 4 +- src/tui/overlays.ts | 25 +- src/tui/pick-session.ts | 2 +- src/tui/product-host.test.ts | 75 +- src/tui/product-host.ts | 15 +- src/tui/runner-host.test.ts | 33 +- src/tui/runner-host.ts | 5 + src/tui/runner.ts | 25 +- src/tui/runtime-bridge.test.ts | 79 +- src/tui/runtime-bridge.ts | 7 +- src/tui/shell.ts | 52 +- tests/unit/corbits-skills-catalog.test.ts | 154 ++++ tests/unit/plugin-loader-path.test.ts | 11 + tests/unit/plugin-register.test.ts | 66 +- tests/unit/plugin-repo-locator.test.ts | 151 ++++ tests/unit/skill-commands.test.ts | 11 + tests/unit/subagent.test.ts | 10 +- 93 files changed, 5507 insertions(+), 308 deletions(-) create mode 100644 plugins/corbits-skills/manifest.json create mode 100644 plugins/corbits-skills/skills/ast-grep/SKILL.md create mode 100644 plugins/corbits-skills/skills/create-issue/SKILL.md create mode 100644 plugins/corbits-skills/skills/dispatch/SKILL.md create mode 100644 plugins/corbits-skills/skills/git-rebase/SKILL.md create mode 100644 plugins/corbits-skills/skills/implement/SKILL.md create mode 100644 plugins/corbits-skills/skills/interview/SKILL.md create mode 100644 plugins/corbits-skills/skills/linear-issue-workflow/SKILL.md create mode 100644 plugins/corbits-skills/skills/opsh/SKILL.md create mode 100644 plugins/corbits-skills/skills/philosophy/SKILL.md create mode 100644 plugins/corbits-skills/skills/plan/SKILL.md create mode 100644 plugins/corbits-skills/skills/pull-request-review/SKILL.md create mode 100644 plugins/corbits-skills/skills/refactor/SKILL.md create mode 100644 plugins/corbits-skills/skills/review/SKILL.md create mode 100644 plugins/corbits-skills/skills/scribe/SKILL.md create mode 100644 plugins/corbits-skills/skills/style/SKILL.md create mode 100644 plugins/corbits-skills/skills/typescript/SKILL.md create mode 100644 scripts/copy-repo-plugins.ts create mode 100644 src/agent/look-tour.test.ts create mode 100644 src/agent/look-tour.ts create mode 100644 tests/unit/corbits-skills-catalog.test.ts create mode 100644 tests/unit/plugin-repo-locator.test.ts diff --git a/.gitignore b/.gitignore index 86d124d62..af38d1bef 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,7 @@ tmp/ .tmp* worktree worktrees -dispatch/ +/dispatch/ evals/capability/results/* !evals/capability/results/baseline-0286.json evals/public/results/* diff --git a/CHANGELOG.md b/CHANGELOG.md index faee0f908..2cef09277 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,12 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ### 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 @@ -44,6 +50,32 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename 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. + ### Evals - **Capability eval records `task` tool calls.** `taskToolCallCount` is derived @@ -56,6 +88,14 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename 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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dd16bc876..1c950f0a9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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`) @@ -229,7 +229,7 @@ Profiles with `orchestrator: true` may themselves call `task` (one hop only): ne #### 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 general leaf: `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. +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** @@ -237,7 +237,7 @@ Every shipped specialist is a **director package** — a prompt-first `DirectorP |---|---|---| | skywalker | Orchestrate only — classify, dispatch, track fleet, synthesize | Product tree edits; being the implementer/reviewer by default | -**Engineering leaves** +**Engineering directors** | Director | Owns | Does not own | |---|---|---| @@ -283,13 +283,13 @@ Every shipped specialist is a **director package** — a prompt-first `DirectorP |---|---| | skywalker (primary session) | Full closed fleet | | greybeard | intern, explore, critique only | -| All other leaves | no `task` | +| 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`, 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`. +**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. @@ -308,7 +308,7 @@ The primary session identity is **Skywalker** (`buildChatRole` → `createSkywal **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. @@ -397,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 @@ -405,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 0e1112ff4..28b736eb1 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -107,7 +107,7 @@ src/ command.ts Chained-command split + command scopes auto-shell-policy.ts Auto-mode run_shell deny/ask rule table gate.ts Permission gate evaluation (+ director writePaths) - write-path-policy.ts Basename/glob match for leaf write allowlists + 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 @@ -156,13 +156,13 @@ Sixteen packages under `src/agent/directors//` register in `DIRECTOR_REGISTR 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 nested leaf). Primary omits the list so plugin profiles stay reachable. +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/leaf binary > parent inheritance. Optional skills are listed in the identity header for awareness; leaves do not mount `use_skill` (guidance is baked into package system prompts). Primary mounts `use_skill` for its own skill list. +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 leaves no `task`. Live `` injects cwd, platform, arch, runtime, date, and git status on every chat and leaf prompt. +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 @@ -183,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 tool.boundary (does not interrupt). -- **Alt+Enter** queues a follow-up (kind `"queue"`) delivered only when the run goes idle. **Ctrl+C** stops the run. +- **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. + +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. @@ -231,7 +233,7 @@ 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. Always applies — the primary session is always orchestrator-capable (CL-5814). + 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` 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). @@ -290,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. @@ -303,7 +305,7 @@ 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` | — | Open the session picker 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 | @@ -338,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 @@ -369,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. Mid-run gestures: Enter soft-steers (drain at tool.boundary), Alt+Enter queues a follow-up (drain on idle), Ctrl+C stops. +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 @@ -388,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/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 1c3b7ca4b..73bbedc6e 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -21,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. @@ -42,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** soft-steers — delivers at the next tool boundary without stopping the current run; **Alt+Enter** queues a follow-up delivered only when the run goes idle (does not interrupt). Idle Alt+Enter is a no-op. **Ctrl+C** stops the run outright. The notice row shows distinct `steer N` / `follow-up M` badges. 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`. Single-agent session mode, the first-run mode picker, and Settings → Session are gone (CL-5814). Legacy `sessionMode` values on disk are ignored. +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 @@ -99,7 +99,9 @@ is the direct, explicit resume path. 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). @@ -136,13 +138,13 @@ 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) -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/docs leaves 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. +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 | |---|---| @@ -151,7 +153,7 @@ The primary session is always **orchestrator** (single-agent mode is gone). Its | Design | draper, emil, brand-reviewer | | Docs / QA | shakespeare, testsmith, tester | -There is **no general leaf**. `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 task leaf. Nested spawn is runtime-enforced: only skywalker (full fleet allowlist) and greybeard (intern/explore/critique) may spawn; other leaves have no `task`. Primary omits an allowlist so plugin profiles remain reachable from the main session. +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 fans work out to short-lived **sub-agents** — child agents with their own loop, tools, and checklist — while the primary session stays focused. @@ -159,7 +161,7 @@ Corbits Code fans work out to short-lived **sub-agents** — child agents with t - **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 and fleet board show 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 d0e1d53ee..2b8213c00 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -339,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 picker's own `>` filter row); 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. @@ -396,15 +399,23 @@ the chord to point an operator at when Shift+Enter doesn't respond. Two mid-run gestures, two delivery times (CL-6290): - **Enter, mid-run** — soft steer: enqueues kind `"steer"` and delivers at the - next **tool.boundary**. The transcript row says `[will steer next]` while - pending and `[steering]` once delivered (`submitPrompt`, - `drainSteersAtBoundary` in `runtime-bridge.ts`). + 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 when the run goes **idle**. 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.) + 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 diff --git a/package.json b/package.json index bed99f829..dbe053eb9 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,8 @@ "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", 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..b452c35ec --- /dev/null +++ b/plugins/corbits-skills/skills/style/SKILL.md @@ -0,0 +1,333 @@ +--- +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 + +Agents must only operate within git repositories. Before performing any work: + +1. Verify the current working directory is inside a git repository +2. If not in a git repository, refuse to proceed + +Without a git repository, it's too hard to succeed with agents - changes can't be tracked, reviewed, or safely reverted. + +## 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/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/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/package.ts b/src/agent/directors/brand-reviewer/package.ts index 72bf829b3..44e045bef 100644 --- a/src/agent/directors/brand-reviewer/package.ts +++ b/src/agent/directors/brand-reviewer/package.ts @@ -13,13 +13,13 @@ export const brandReviewerPackage: DirectorPackage = { "marketing publish pipeline", "architecture gates", ], - description: "DESIGN.md brand gate leaf", + 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 leaf director in Corbits Code. + 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. diff --git a/src/agent/directors/bruckheimer/package.ts b/src/agent/directors/bruckheimer/package.ts index 7f8e1b724..4fa3bc89a 100644 --- a/src/agent/directors/bruckheimer/package.ts +++ b/src/agent/directors/bruckheimer/package.ts @@ -2,7 +2,7 @@ import type { DirectorPackage } from "../types.js"; import { DOCS_TOOLS } from "../tool-sets.js"; /** - * Product discovery leaf (CL-5824). + * Product discovery specialist (CL-5824). */ export const bruckheimerPackage: DirectorPackage = { id: "bruckheimer", @@ -14,13 +14,13 @@ export const bruckheimerPackage: DirectorPackage = { "hard merge blockers as Greybeard", "running the fleet", ], - description: "Product discovery leaf — user/product shape docs, not code", + 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 leaf director in Corbits Code. + 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. diff --git a/src/agent/directors/critique/package.ts b/src/agent/directors/critique/package.ts index 940a492a0..f63164d31 100644 --- a/src/agent/directors/critique/package.ts +++ b/src/agent/directors/critique/package.ts @@ -22,11 +22,11 @@ export const critiquePackage: DirectorPackage = { nudge: { maxTurns: 45 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, modelRole: "review", - systemPrompt: `You are CritiqueDirector, a leaf director in Corbits Code. + 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 leaves). Read the code under review; do not invent defects from vibes. +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). diff --git a/src/agent/directors/draper/package.ts b/src/agent/directors/draper/package.ts index 99deaf476..08459d8c9 100644 --- a/src/agent/directors/draper/package.ts +++ b/src/agent/directors/draper/package.ts @@ -21,7 +21,7 @@ export const draperPackage: DirectorPackage = { nudge: { maxTurns: 40 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, modelRole: "review", - systemPrompt: `You are DraperDirector, a leaf director in Corbits Code. + 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. diff --git a/src/agent/directors/emil/package.ts b/src/agent/directors/emil/package.ts index a05ef1931..ec50a61a1 100644 --- a/src/agent/directors/emil/package.ts +++ b/src/agent/directors/emil/package.ts @@ -21,7 +21,7 @@ export const emilPackage: DirectorPackage = { nudge: { maxTurns: 40 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, modelRole: "review", - systemPrompt: `You are EmilDirector, a leaf director in Corbits Code. + 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. diff --git a/src/agent/directors/explore/package.ts b/src/agent/directors/explore/package.ts index 47554a08c..ebc512d01 100644 --- a/src/agent/directors/explore/package.ts +++ b/src/agent/directors/explore/package.ts @@ -11,7 +11,7 @@ export const explorePackage: DirectorPackage = { "review severity theater", ], description: "Read-only exploration leaf", - systemPrompt: `You are ExploreDirector, a leaf director in Corbits Code. + 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. diff --git a/src/agent/directors/gaasbot/package.ts b/src/agent/directors/gaasbot/package.ts index fee249959..6d8cb9cca 100644 --- a/src/agent/directors/gaasbot/package.ts +++ b/src/agent/directors/gaasbot/package.ts @@ -22,7 +22,7 @@ export const gaasbotPackage: DirectorPackage = { nudge: { maxTurns: 35 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, modelRole: "plan", - systemPrompt: `You are GaasbotDirector, a leaf director in Corbits Code. + 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. diff --git a/src/agent/directors/identity.ts b/src/agent/directors/identity.ts index 7a06e60bc..bc0d1f9b2 100644 --- a/src/agent/directors/identity.ts +++ b/src/agent/directors/identity.ts @@ -13,7 +13,7 @@ export function formatDirectorSystemPrompt(pkg: DirectorPackage): string { ? 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 leaves): ${pkg.optionalSkills.join(", ")}.`; + : `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}.`, @@ -24,7 +24,7 @@ export function formatDirectorSystemPrompt(pkg: DirectorPackage): string { /** * Product default reasoning effort by package modelRole (CL-5816 slice). - * Intern is the cheap leaf: same implement role, lower effort budget. + * Intern is the cheap worker: same implement role, lower effort budget. */ export const MODEL_ROLE_DEFAULT_EFFORT = { orchestrator: "high", diff --git a/src/agent/directors/implement/package.ts b/src/agent/directors/implement/package.ts index 7842f22a6..1637d4c06 100644 --- a/src/agent/directors/implement/package.ts +++ b/src/agent/directors/implement/package.ts @@ -18,12 +18,12 @@ export const implementPackage: DirectorPackage = { nudge: { maxTurns: 60 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, modelRole: "implement", - systemPrompt: `You are ImplementDirector, a leaf director in Corbits Code. + 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 leaves). +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. diff --git a/src/agent/directors/intern/package.ts b/src/agent/directors/intern/package.ts index 85ebf1aa4..795ed003d 100644 --- a/src/agent/directors/intern/package.ts +++ b/src/agent/directors/intern/package.ts @@ -24,7 +24,7 @@ export const internPackage: DirectorPackage = { nudge: { maxTurns: 20 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, modelRole: "implement", - systemPrompt: `You are InternDirector, a leaf director in Corbits Code. + 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. diff --git a/src/agent/directors/neckbeard/package.ts b/src/agent/directors/neckbeard/package.ts index 051cc2074..6088017f1 100644 --- a/src/agent/directors/neckbeard/package.ts +++ b/src/agent/directors/neckbeard/package.ts @@ -21,7 +21,7 @@ export const neckbeardPackage: DirectorPackage = { nudge: { maxTurns: 40 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, modelRole: "review", - systemPrompt: `You are NeckbeardDirector, a leaf director in Corbits Code. + 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). diff --git a/src/agent/directors/plan/package.ts b/src/agent/directors/plan/package.ts index dc19537b0..155b0af38 100644 --- a/src/agent/directors/plan/package.ts +++ b/src/agent/directors/plan/package.ts @@ -16,7 +16,7 @@ export const planPackage: DirectorPackage = { nudge: { maxTurns: 40 }, report: { requiredSections: ["Summary", "Findings", "Blockers", "Paths"] }, modelRole: "plan", - systemPrompt: `You are PlanDirector, a leaf director in Corbits Code. + 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). diff --git a/src/agent/directors/registry.test.ts b/src/agent/directors/registry.test.ts index 6424e17a0..0d6825968 100644 --- a/src/agent/directors/registry.test.ts +++ b/src/agent/directors/registry.test.ts @@ -173,7 +173,7 @@ describe("director registry", () => { const s = DIRECTOR_REGISTRY.skywalker; expect(s.systemPrompt).toContain("NEVER implement"); expect(s.systemPrompt).toContain("You are Skywalker"); - expect(s.systemPrompt).toMatch(/No general leaf/i); + 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); diff --git a/src/agent/directors/shakespeare/package.ts b/src/agent/directors/shakespeare/package.ts index 7e4bb7196..c883756e7 100644 --- a/src/agent/directors/shakespeare/package.ts +++ b/src/agent/directors/shakespeare/package.ts @@ -4,7 +4,7 @@ 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 leaf director in Corbits Code. +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. diff --git a/src/agent/directors/skywalker/package.test.ts b/src/agent/directors/skywalker/package.test.ts index d820ce6cb..4545ed319 100644 --- a/src/agent/directors/skywalker/package.test.ts +++ b/src/agent/directors/skywalker/package.test.ts @@ -77,7 +77,10 @@ describe("skywalkerPackage", () => { "Orchestrate only — triage and dispatch; do not implement product code", ); expect(skywalkerPackage.outOfLane).toContain("product edits"); - expect(skywalkerPackage.outOfLane).toContain("general catch-all leaf"); + 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"); }); @@ -85,14 +88,22 @@ describe("skywalkerPackage", () => { 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 leaf"); - expect(p).toContain("2–4 leaves"); + 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 concurrent leaves"); + expect(p).toContain("at most 4 workers at once"); }); test("systemPrompt anti-cascade keeps digs out of fleets", () => { @@ -100,13 +111,14 @@ describe("skywalkerPackage", () => { expect(p).toContain("Anti-cascade"); expect(p).toContain("COMMUNICATION first"); expect(p).toContain("Never spawn parallel"); - expect(p).toContain("one explore leaf"); + 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 leaf"); + 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"); @@ -120,13 +132,18 @@ describe("skywalkerPackage", () => { expect(p).toContain("curl/wget"); }); - test("systemPrompt requires brief completeness for multi-leaf", () => { + 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-leaf"); + 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", () => { diff --git a/src/agent/directors/skywalker/package.ts b/src/agent/directors/skywalker/package.ts index 66fd0f97d..b6fd451cb 100644 --- a/src/agent/directors/skywalker/package.ts +++ b/src/agent/directors/skywalker/package.ts @@ -1,4 +1,4 @@ -// Skywalker: primary orchestration director (Karen-shaped). CL-5817. +// Skywalker: primary orchestration director. Chains specialists into a workflow. import type { DirectorPackage } from "../types.js"; import { ORCHESTRATOR_TOOLS } from "../tool-sets.js"; @@ -6,12 +6,25 @@ 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 task leaf). Nested specialists use task(agent="…"). +Agent id: skywalker (primary session; not a spawned worker). Start specialists with task(agent="…"). -PRIMARY INTENT: orchestrate. Classify every request. Delegate scoped work via task to the closed director set. Track the fleet. Synthesize. Do not become the implementer/reviewer by default. +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 general leaf. If unsure, reclassify — do not spawn a blob agent. +No catch-all worker. If unsure, reclassify — do not spawn a blob agent. Quick routing: - explore = map/read codebase @@ -29,7 +42,7 @@ Quick routing: - gaasbot = risk counsel - bruckheimer = product discovery docs - intern = exact shell / mechanical ops -- After multi-file implement landings → default a critique leaf (or greybeard when architecture is in play) on the diff/criteria in a fresh context +- 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. @@ -43,26 +56,27 @@ When the operator (or brief) gives an http(s) URL to read: # Effort scaling (IMPLEMENTATION / ORCHESTRATION) -Scale fan-out to the ask — do not spawn 10+ leaves for a simple request: -- Simple (answer, one-path lookup, tiny fix): 0–1 leaf, few tools; often answer without fleet -- Tiny single-file / one-route asks: **one implement leaf**; 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 leaves with distinct path/package ownership -- Complex: more leaves only with named lanes and clear non-overlap -Hard cap: **at most 4 concurrent leaves** unless the operator explicitly asks for a wider fan-out. Prefer synthesizing early returns over launching a second wave. +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 leaf if a single unknown path blocks the answer. +- 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 leaves stall, loop, or salvage: synthesize what returned, report Blockers, and change approach — do **not** re-fan-out another diagnostic wave on the same topic. +- 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-leaf dispatch, prefer typed spawn with success_criteria, do_not, and report_focus (plus intent/agent). Do not fire multi-leaf waves with one-line vague briefs — flesh the brief first. +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 @@ -83,9 +97,9 @@ Before responding, classify: ## If IMPLEMENTATION → dispatch; NEVER implement directly 1. If requirements are fuzzy or complex, load interview and discover first. -2. Use explore leaves for scope when needed. +2. Use explore workers for scope when needed. 3. Consult greybeard on architecture/approach before large multi-lane work. -4. Use plan leaf or the dispatch skill for multi-lane eng plans; clarify before large dispatch. +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. @@ -98,14 +112,14 @@ Track with manage_tasks. Parallelize independent lanes. Escalate blockers with a ## 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 leaf — not a fleet. Prefer reading/searching yourself with mounted tools over spawning. +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 leaf or dispatch skill for multi-lane eng plans; clarify before large dispatch. +- 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). @@ -118,14 +132,14 @@ You may spawn: implement, explore, plan, intern, critique, greybeard, neckbeard, When spawning, prefer a typed brief: - intent — explore | implement | plan | review -- success_criteria — done-definition the leaf must meet +- 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 leaf synthesis), use: +When finishing a turn that closes work (or reporting a worker synthesis), use: ## Summary ## Findings @@ -143,12 +157,13 @@ export const skywalkerPackage: DirectorPackage = { primaryIntent: "Orchestrate only — triage and dispatch; do not implement product code", outOfLane: [ "product edits", - "deep multi-path repo walks when a single explore leaf or mounted tools suffice", + "deep multi-path repo walks when a single explore worker or mounted tools suffice", "being the reviewer/implementer by default", - "general catch-all leaf", + "catch-all worker", "diagnostic fleets for why/how/stall questions", + "searching the repo yourself after a worker stops without finishing", ], - description: "Primary orchestration director (Karen-shaped)", + description: "Primary orchestration director — chains specialists into a workflow", systemPrompt: SKYWALKER_SYSTEM_PROMPT, optionalSkills: ["dispatch", "style", "philosophy", "interview"], tools: { allow: ORCHESTRATOR_TOOLS }, diff --git a/src/agent/directors/tester/package.ts b/src/agent/directors/tester/package.ts index 66dbe4208..ced70bca3 100644 --- a/src/agent/directors/tester/package.ts +++ b/src/agent/directors/tester/package.ts @@ -2,7 +2,7 @@ import type { DirectorPackage } from "../types.js"; import { READ_TOOLS } from "../tool-sets.js"; /** - * Tester: runtime verification leaf — run tests and report; never fix product code. + * Tester: runtime verification specialist — run tests and report; never fix product code. */ export const testerPackage: DirectorPackage = { id: "tester", @@ -14,8 +14,8 @@ export const testerPackage: DirectorPackage = { "orchestration", "docs-only work", ], - description: "Runtime verify leaf — run tests, report, never fix", - systemPrompt: `You are TesterDirector, a leaf director in Corbits Code. + 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. diff --git a/src/agent/directors/testsmith/package.ts b/src/agent/directors/testsmith/package.ts index 76212eebc..6274f4760 100644 --- a/src/agent/directors/testsmith/package.ts +++ b/src/agent/directors/testsmith/package.ts @@ -2,7 +2,7 @@ import type { DirectorPackage } from "../types.js"; import { READ_TOOLS } from "../tool-sets.js"; /** - * Testsmith: test design leaf — strategy and cases only; never implements product + * Testsmith: test design specialist — strategy and cases only; never implements product * and is not the runtime verifier (that is tester). */ export const testsmithPackage: DirectorPackage = { @@ -16,8 +16,8 @@ export const testsmithPackage: DirectorPackage = { "fixing failing product code", "orchestration", ], - description: "Test design leaf — strategy and cases in the report only", - systemPrompt: `You are TestsmithDirector, a leaf director in Corbits Code. + 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). diff --git a/src/agent/directors/types.ts b/src/agent/directors/types.ts index bdf4e6e12..cf51462b5 100644 --- a/src/agent/directors/types.ts +++ b/src/agent/directors/types.ts @@ -48,7 +48,7 @@ export type NudgePolicy = { }; export type ReportContract = { - /** Required top-level sections in the leaf report. */ + /** Required top-level sections in the worker report. */ readonly requiredSections: readonly string[]; }; @@ -65,7 +65,7 @@ export type DirectorPackage = { readonly description: string; /** Opinionated core prompt (prompt-first). */ readonly systemPrompt: string; - /** Optional skills the leaf may load dynamically (ordered). */ + /** Optional skills the worker may load dynamically (ordered). */ readonly optionalSkills?: readonly string[]; readonly tools?: ToolEnvelope; /** 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/prompts.test.ts b/src/agent/prompts.test.ts index 3b04fee28..d72f08db3 100644 --- a/src/agent/prompts.test.ts +++ b/src/agent/prompts.test.ts @@ -13,7 +13,7 @@ import { CORE_TOOL_NAMES, CATALOG_TOOL_NAMES } from "./tool-search.js"; const REGISTERED_TOOL_NAMES = new Set([ ...CORE_TOOL_NAMES, ...CATALOG_TOOL_NAMES, - // Product mutation tools mount on leaves, not primary CORE/CATALOG ads. + // Product mutation tools mount on workers, not primary CORE/CATALOG ads. "write_file", "edit_file", "delete_file", @@ -82,7 +82,7 @@ describe("shared discipline block appears exactly once per built prompt", () => 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, @@ -90,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, @@ -108,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", () => { @@ -132,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 eb131edf0..4b17b3d47 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -82,7 +82,7 @@ export function buildHarnessFacts( ? [ "- Only the core tools below are loaded. Use tool_search to load extra capabilities from plugins or integrations when needed.", "- 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.", + "- 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.", @@ -144,12 +144,12 @@ export function buildGuidelines(opts: { subAgent?: boolean; sessionMode?: Sessio "", "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 leaf reports.", + "- If context is compacted automatically, do not stop tasks early due to token fear; persist progress via manage_tasks and worker reports.", ]), ].join("\n"); } @@ -329,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`, "", @@ -372,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.", diff --git a/src/config/index.ts b/src/config/index.ts index 67eb4e385..aec733045 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -361,7 +361,7 @@ Usage: corbits exec|run [flags] <prompt> corbits resume|continue [session-id] [flags] -Continue verbs (project-keyed; worktrees of the same git root share sessions): +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 @@ -425,7 +425,7 @@ 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: "id" | "pick" | undefined; let resumeSessionId: string | undefined; @@ -651,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; @@ -665,7 +665,7 @@ 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\` to choose one.`, + `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; diff --git a/src/config/settings.ts b/src/config/settings.ts index 91fb70d99..856a9b01d 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -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, 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 a4c2cccf4..4864e0a8b 100644 --- a/src/prompts.test.ts +++ b/src/prompts.test.ts @@ -62,6 +62,8 @@ 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"); }); @@ -105,7 +107,7 @@ 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 leaf reports"); + expect(guidelines).toContain("manage_tasks and worker reports"); // Leaf guidelines omit primary orchestration compaction guidance. expect(buildGuidelines({ subAgent: true })).not.toContain("token fear"); }); @@ -287,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")); }); @@ -299,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 @@ -329,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", () => { @@ -342,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/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 1db2e9129..2be4d543a 100644 --- a/src/settings.test.ts +++ b/src/settings.test.ts @@ -27,6 +27,7 @@ import { markLastChangelogVersion, pushRecentModel, toggleFavoriteModel, + setDefaultModel, listRecentModels, listFavoriteModels, } from "./config/settings.js"; @@ -1032,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/task-tool.ts b/src/subagent/task-tool.ts index e805d6c40..24d5e2d3f 100644 --- a/src/subagent/task-tool.ts +++ b/src/subagent/task-tool.ts @@ -80,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: { @@ -114,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", @@ -128,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", @@ -418,7 +418,7 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { } } } else if (intent !== undefined) { - // intent-only dispatch maps to closed directors (no general leaf). + // 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}`); @@ -440,18 +440,18 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { } } } else { - // No general leaf: bare task (no agent, no intent) is refused. Reclassify. + // 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 nested leaf. + // 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 task leaf. Pass task(agent=…) for a specialist (implement, explore, plan, critique, …).", + "Error: skywalker is the primary session identity, not a spawned worker. Pass task(agent=…) for a specialist (implement, explore, plan, critique, …).", ); } @@ -470,7 +470,7 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { } } - // Role-based effort: pin > package modelRole default > orchestrator/leaf > parent. + // 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. { diff --git a/src/tui/commands/built-in.test.ts b/src/tui/commands/built-in.test.ts index d9f17ee03..67115fb0c 100644 --- a/src/tui/commands/built-in.test.ts +++ b/src/tui/commands/built-in.test.ts @@ -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/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 d58988784..d7e71b6cf 100644 --- a/src/tui/notice-line.test.ts +++ b/src/tui/notice-line.test.ts @@ -1,10 +1,15 @@ 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 => ({ steer: 0, followUp: 0, + waitingOn: null, interrupt: false, pinned: false, flash: null, @@ -38,6 +43,16 @@ describe("composeNoticeLine", () => { 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", @@ -53,3 +68,24 @@ describe("composeNoticeLine", () => { expect(line).not.toContain("^C") }) }) + +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 36d614a9b..b771c7dda 100644 --- a/src/tui/notice-line.ts +++ b/src/tui/notice-line.ts @@ -24,11 +24,18 @@ const SEP = " " +export const STEER_WAIT_NOTICE_MS = 3_000 + export type NoticeState = { /** 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 @@ -37,10 +44,26 @@ export type NoticeState = { readonly attachments: number } +/** + * 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. + */ +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 +} + export function composeNoticeLine(state: NoticeState): string { const segments: string[] = [] 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") // "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. 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 5ab49fe16..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", }, ]) diff --git a/src/tui/overlays.ts b/src/tui/overlays.ts index 76ef4cb18..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]", ] } @@ -195,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( @@ -218,6 +220,9 @@ export function openModelPickerOverlay( ...(opts?.addProviderHint !== undefined ? { addProviderHint: opts.addProviderHint } : {}), + ...(opts?.setDefaultHint !== undefined + ? { setDefaultHint: opts.setDefaultHint } + : {}), }) } 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/product-host.test.ts b/src/tui/product-host.test.ts index 4f622c94b..e0aeaf7f1 100644 --- a/src/tui/product-host.test.ts +++ b/src/tui/product-host.test.ts @@ -385,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 { @@ -456,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() @@ -488,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() @@ -588,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 b9f6994bc..2d2a14f7f 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -137,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 @@ -510,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 @@ -560,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 @@ -570,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 @@ -586,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 }, } diff --git a/src/tui/runner-host.test.ts b/src/tui/runner-host.test.ts index 47a49698f..37c90acb6 100644 --- a/src/tui/runner-host.test.ts +++ b/src/tui/runner-host.test.ts @@ -245,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() @@ -278,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) @@ -319,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[] = [] 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 2a3233575..9bffe0a9a 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -35,6 +35,7 @@ import { toolWatchdogFromSettings, markLastChangelogVersion, toggleFavoriteModel, + setDefaultModel, type ModelRef, type ResolvedProvider, type Settings, @@ -42,6 +43,7 @@ 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 { resolveWaitForApproval, type ToolWatchdogConfig } from "./tool-execution-watchdog.js"; @@ -83,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, @@ -1139,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); @@ -2194,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); diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index 2f33d9a94..534b50fcc 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -7,7 +7,8 @@ import { type TaskProgressSession, } from "./runtime-bridge" import { DEFAULT_STALL_MS } from "./agent-progress" -import { appendStreamRow, createAppShell, streamRowCount } from "./shell" +import { appendStreamRow, createAppShell, paintChrome, streamRowCount } from "./shell" +import { STEER_WAIT_NOTICE_MS } from "./notice-line" import { withTestRenderer } from "./harness" import { badgeCount } from "./session-queue" @@ -292,6 +293,82 @@ describe("attachSessionBridge", () => { ) }) + 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) => { diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 1dedc87bf..8ff0866ff 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -569,6 +569,7 @@ function applyToolCall( bag.taskCallIds.add(event.callId) } bag.lastToolRow = index + shell.inFlightTool = { name: event.name, startedAt: bag.now() } } /** @@ -593,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) { @@ -735,7 +737,10 @@ 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") { diff --git a/src/tui/shell.ts b/src/tui/shell.ts index f401de586..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, @@ -677,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. @@ -852,6 +857,11 @@ export function noticeText(shell: AppShell): string { return composeNoticeLine({ 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, @@ -1347,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 } @@ -1946,6 +1971,7 @@ type PriorOverlaySnapshot = { readonly titleText: string readonly onCancel: (() => void) | null readonly addProviderHint: boolean + readonly setDefaultHint: boolean } type ShellInternals = { @@ -1974,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 @@ -3531,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 } /** @@ -3570,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. @@ -3601,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 @@ -3623,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) { @@ -4073,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 } @@ -4107,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, { @@ -6088,6 +6126,7 @@ export function createAppShell( mcpNeedsAuth: [], pluginNeedsAttention: false, lockupNowMs: 0, + inFlightTool: null, lockupAnimating: false, lockupPhase: null, lockupChangedMs: 0, @@ -6141,6 +6180,7 @@ export function createAppShell( overlayDescribe: null, overlayOnAction: null, overlayAddProviderHint: false, + overlaySetDefaultHint: false, inputSuspended: false, overlayAnswer: null, overlayTitleText: "", 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/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.test.ts b/tests/unit/subagent.test.ts index c91b24f94..4db6644b8 100644 --- a/tests/unit/subagent.test.ts +++ b/tests/unit/subagent.test.ts @@ -141,13 +141,13 @@ test("handler reports runner failures without throwing", async () => { 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`"); }); @@ -268,7 +268,7 @@ test("intent general is refused (no general director)", async () => { expect(ran).toBe(false); }); -test("bare task without agent or intent is refused (no general leaf)", async () => { +test("bare task without agent or intent is refused (no catch-all worker)", async () => { let ran = false; const tool = createTaskTool({ permissionGate: testPermissionGate, @@ -320,7 +320,7 @@ test("spawnAllowlist rejects children outside the parent director matrix", async expect(ran).toBe(true); }); -test("task refuses skywalker as a nested leaf", async () => { +test("task refuses skywalker as a spawned worker", async () => { let ran = false; const tool = createTaskTool({ permissionGate: testPermissionGate, From beb93159e95c6418cc4affb284903df752306b47 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler <sawyer@dirtroad.dev> Date: Thu, 20 Aug 2026 20:41:42 -0700 Subject: [PATCH 56/59] Require provider and model flags for the public SWE smoke The one-shot smoke defaulted to a personal prepaid provider profile. Callers now pass --provider and --model so local eval accounts stay off the repo. --- CHANGELOG.md | 2 +- evals/public/README.md | 11 ++--- scripts/eval-public-swe-one.test.ts | 47 ++++++++++++++++++++++ scripts/eval-public-swe-one.ts | 62 ++++++++++++++--------------- 4 files changed, 85 insertions(+), 37 deletions(-) create mode 100644 scripts/eval-public-swe-one.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cef09277..e8cc21d63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -172,7 +172,7 @@ mid-session switches. - **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`), pinned to prepaid `xai/thegreataxios` + `grok-4.5`, and + `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` diff --git a/evals/public/README.md b/evals/public/README.md index 673fa211d..0c675e625 100644 --- a/evals/public/README.md +++ b/evals/public/README.md @@ -6,9 +6,9 @@ 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 (this machine) +## Constraints -- Use prepaid **`xai/thegreataxios`** + **`grok-4.5`** unless explicitly overridden. +- 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. @@ -21,11 +21,12 @@ bun scripts/eval-public-swe-one.ts --dry-run # Default instance: psf__requests-3362 (small repo, single failing test) bun scripts/eval-public-swe-one.ts \ - --provider xai/thegreataxios \ + --provider xai \ --model grok-4.5 # Pick any Lite instance_id -bun scripts/eval-public-swe-one.ts --instance pallets__flask-4992 +bun scripts/eval-public-swe-one.ts --instance pallets__flask-4992 \ + --provider <provider> --model <model> ``` What it does: @@ -47,7 +48,7 @@ What it does **not** do yet: 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 under the prepaid xAI profile?** +real public issue?** ## vs competitors diff --git a/scripts/eval-public-swe-one.test.ts b/scripts/eval-public-swe-one.test.ts new file mode 100644 index 000000000..0f7a45f23 --- /dev/null +++ b/scripts/eval-public-swe-one.test.ts @@ -0,0 +1,47 @@ +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 does not require provider or model", () => { + const opts = parseArgs(["--dry-run"]); + expect(opts.dryRun).toBe(true); + expect(opts.provider).not.toBe("xai/thegreataxios"); + expect(opts.model).not.toBe("xai/thegreataxios"); + }); + + 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"]); + const dry = parseArgs(["--dry-run"]); + expect(help.provider).not.toBe("xai/thegreataxios"); + expect(help.model).not.toBe("xai/thegreataxios"); + expect(dry.provider).not.toBe("xai/thegreataxios"); + expect(dry.model).not.toBe("xai/thegreataxios"); + }); +}); diff --git a/scripts/eval-public-swe-one.ts b/scripts/eval-public-swe-one.ts index 3c40f3e0c..88c7de665 100644 --- a/scripts/eval-public-swe-one.ts +++ b/scripts/eval-public-swe-one.ts @@ -3,17 +3,17 @@ * One-shot public SWE-bench smoke: Corbits as the agent on a single Lite instance. * * Intentionally narrow: - * - pins provider/model (default xai/thegreataxios + grok-4.5) + * - 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 - * bun scripts/eval-public-swe-one.ts --instance psf__requests-3362 - * bun scripts/eval-public-swe-one.ts --provider xai/thegreataxios --model grok-4.5 + * 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 * * Optional official grading (heavy; needs Docker resources): - * bun scripts/eval-public-swe-one.ts --instance … --evaluate + * bun scripts/eval-public-swe-one.ts --instance … --provider <name> --model <id> --evaluate */ import { mkdir, writeFile, readFile, mkdtemp, rm, cp } from "node:fs/promises"; @@ -25,8 +25,6 @@ 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_PROVIDER = "xai/thegreataxios"; -const DEFAULT_MODEL = "grok-4.5"; const DEFAULT_INSTANCE = "psf__requests-3362"; const DEFAULT_SUBSET = "princeton-nlp/SWE-bench_Lite"; const DEFAULT_SPLIT = "test"; @@ -40,7 +38,6 @@ type CliOptions = { split: string; agentTimeoutMs: number; evaluate: boolean; - allowOtherProvider: boolean; dryRun: boolean; outDir: string; help: boolean; @@ -59,35 +56,33 @@ type SweInstance = { }; function printHelp(): void { - console.log(`Usage: bun scripts/eval-public-swe-one.ts [options] + 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> Must be ${DEFAULT_PROVIDER} unless --allow-other-provider - --model <id> Model id (default: ${DEFAULT_MODEL}) + --provider <name> Provider name (required except --help / --dry-run) + --model <id> Model id (required except --help / --dry-run) --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) - --allow-other-provider Permit a non-default provider (not recommended here) --dry-run Load instance + print plan; do not clone or run the agent -h, --help Show this help `); } -function parseArgs(argv: string[]): CliOptions { +export function parseArgs(argv: string[]): CliOptions { const opts: CliOptions = { - provider: DEFAULT_PROVIDER, - model: DEFAULT_MODEL, + provider: "", + model: "", instanceId: DEFAULT_INSTANCE, subset: DEFAULT_SUBSET, split: DEFAULT_SPLIT, agentTimeoutMs: DEFAULT_AGENT_TIMEOUT_MS, evaluate: false, - allowOtherProvider: false, dryRun: false, outDir: "", help: false, @@ -131,9 +126,6 @@ function parseArgs(argv: string[]): CliOptions { case "--evaluate": opts.evaluate = true; break; - case "--allow-other-provider": - opts.allowOtherProvider = true; - break; case "--dry-run": opts.dryRun = true; break; @@ -141,6 +133,17 @@ function parseArgs(argv: string[]): CliOptions { throw new Error(`unknown arg: ${a}`); } } + if (!opts.help && !opts.dryRun) { + 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; } @@ -314,13 +317,6 @@ async function main(): Promise<void> { process.exit(0); } - if (!opts.allowOtherProvider && opts.provider !== DEFAULT_PROVIDER) { - throw new Error( - `provider must be ${DEFAULT_PROVIDER} for this prepaid smoke ` + - `(got ${opts.provider}). Pass --allow-other-provider to override.`, - ); - } - const runId = new Date().toISOString().replace(/[:.]/g, "-"); const outDir = opts.outDir.length > 0 @@ -370,7 +366,9 @@ async function main(): Promise<void> { const config = await loadConfig(argv, { allowUnconfigured: false }); if (!config.configured) { - throw new Error("Provider not configured — check xAI OAuth profile xai/thegreataxios"); + throw new Error( + `Provider not configured for --provider ${opts.provider} --model ${opts.model}`, + ); } const resolvedProvider = config.providerName; const resolvedModel = config.model; @@ -476,7 +474,9 @@ async function main(): Promise<void> { } } -main().catch((err) => { - console.error(err instanceof Error ? err.message : err); - process.exit(2); -}); +if (import.meta.main) { + main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(2); + }); +} From 8152ca5bec4513805e1b8f8f64c0dfac825d0b38 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler <sawyer@dirtroad.dev> Date: Thu, 20 Aug 2026 21:26:51 -0700 Subject: [PATCH 57/59] Require provider and model flags for all eval runners Local settings must not pick the model. Capability eval and SWE dry-run now require --provider/--model the same as a real SWE run. --- CHANGELOG.md | 4 ++ evals/capability/README.md | 29 +++++++++------ evals/capability/lib.test.ts | 10 +++++ evals/capability/lib.ts | 24 +++++++----- evals/public/README.md | 2 +- scripts/eval-capability.test.ts | 57 +++++++++++++++++++++++++++++ scripts/eval-capability.ts | 49 ++++++++++++++++++++----- scripts/eval-public-swe-one.test.ts | 16 ++++---- scripts/eval-public-swe-one.ts | 8 ++-- 9 files changed, 157 insertions(+), 42 deletions(-) create mode 100644 scripts/eval-capability.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e8cc21d63..9e5e1327a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,10 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ### Evals +- **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. diff --git a/evals/capability/README.md b/evals/capability/README.md index 2fb38e42a..d8ec1acde 100644 --- a/evals/capability/README.md +++ b/evals/capability/README.md @@ -91,35 +91,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 <name>` and `--model <id>` 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 <name> --model <id> # 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 <name> --model <id> \ + --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 <name> --model <id> --repeats 5 \ --out evals/capability/results/candidate.json \ --baseline evals/capability/results/baseline-0286.json ``` @@ -129,8 +134,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 @@ -148,8 +153,8 @@ Flags: | Flag | Meaning | |------|---------| | `--case <id\|all>` | Case id or `all` (default) | -| `--provider` / `--model` | Single-variant override via `loadConfig` | -| `--matrix <cells>` | Multi-variant: `p:m,p2:m2` or `label=p:m` (comma-separated) | +| `--provider <name>` / `--model <id>` | Required unless `--matrix`. Single-variant via `loadConfig`. Not inferred from local settings | +| `--matrix <cells>` | Alternative to `--provider`/`--model`. Multi-variant: `p:m,p2:m2` or `label=p:m` (comma-separated). Every cell must include both sides | | `--config <path>` | Settings file override (CI injection) | | `--out <path>` | Write machine-readable results JSON | | `--baseline <path>` | Compare this run to a prior results file (improve/regress + metric deltas) | @@ -158,7 +163,7 @@ Flags: | `--agent-timeout-ms <n>` | Wall-clock limit for `runExec` (default `600000`, env `CORBITS_EVAL_AGENT_TIMEOUT_MS`) | | `--verify-timeout-ms <n>` | Wall-clock limit for `verify.sh` (default `120000`, env `CORBITS_EVAL_VERIFY_TIMEOUT_MS`) | | `--repeats <n>` | 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 diff --git a/evals/capability/lib.test.ts b/evals/capability/lib.test.ts index d0761420e..9f164cbf5 100644 --- a/evals/capability/lib.test.ts +++ b/evals/capability/lib.test.ts @@ -309,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 f049d4a2f..f18765740 100644 --- a/evals/capability/lib.ts +++ b/evals/capability/lib.ts @@ -527,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, @@ -549,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("="); @@ -575,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 index 0c675e625..13956901b 100644 --- a/evals/public/README.md +++ b/evals/public/README.md @@ -17,7 +17,7 @@ without vendoring a full leaderboard runner into product CI. ```bash # Dry plan (loads HF row, prints prompt) -bun scripts/eval-public-swe-one.ts --dry-run +bun scripts/eval-public-swe-one.ts --dry-run --provider <name> --model <id> # Default instance: psf__requests-3362 (small repo, single failing test) bun scripts/eval-public-swe-one.ts \ diff --git a/scripts/eval-capability.test.ts b/scripts/eval-capability.test.ts new file mode 100644 index 000000000..a1440163c --- /dev/null +++ b/scripts/eval-capability.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; + +import { parseArgs } from "./eval-capability.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("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"); + }); +}); diff --git a/scripts/eval-capability.ts b/scripts/eval-capability.ts index 83d84eb74..d21baaa9c 100755 --- a/scripts/eval-capability.ts +++ b/scripts/eval-capability.ts @@ -69,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. @@ -77,12 +78,13 @@ 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 @@ -91,19 +93,20 @@ function printUsage(): void { --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 ?? 1_200_000), verifyTimeoutMs: Number(process.env.CORBITS_EVAL_VERIFY_TIMEOUT_MS ?? 120_000), @@ -118,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(); @@ -183,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[], @@ -510,7 +535,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(); @@ -684,6 +711,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 index 0f7a45f23..967b573eb 100644 --- a/scripts/eval-public-swe-one.test.ts +++ b/scripts/eval-public-swe-one.test.ts @@ -10,11 +10,16 @@ describe("parseArgs", () => { expect(opts.model).not.toBe("xai/thegreataxios"); }); - test("--dry-run does not require provider or model", () => { - const opts = parseArgs(["--dry-run"]); + 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).not.toBe("xai/thegreataxios"); - expect(opts.model).not.toBe("xai/thegreataxios"); + expect(opts.provider).toBe("foo"); + expect(opts.model).toBe("bar"); }); test("agent run without --provider throws", () => { @@ -38,10 +43,7 @@ describe("parseArgs", () => { test("parsed defaults never equal xai/thegreataxios", () => { const help = parseArgs(["--help"]); - const dry = parseArgs(["--dry-run"]); expect(help.provider).not.toBe("xai/thegreataxios"); expect(help.model).not.toBe("xai/thegreataxios"); - expect(dry.provider).not.toBe("xai/thegreataxios"); - expect(dry.model).not.toBe("xai/thegreataxios"); }); }); diff --git a/scripts/eval-public-swe-one.ts b/scripts/eval-public-swe-one.ts index 88c7de665..89825ddcf 100644 --- a/scripts/eval-public-swe-one.ts +++ b/scripts/eval-public-swe-one.ts @@ -10,7 +10,7 @@ * 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 + * 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 @@ -62,8 +62,8 @@ 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 / --dry-run) - --model <id> Model id (required except --help / --dry-run) + --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}) @@ -133,7 +133,7 @@ export function parseArgs(argv: string[]): CliOptions { throw new Error(`unknown arg: ${a}`); } } - if (!opts.help && !opts.dryRun) { + if (!opts.help) { if (!opts.provider && !opts.model) { throw new Error("missing required --provider and --model"); } From 158b11e2e1d83f64f9f76960bfc2744a8301e6b6 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler <sawyer@dirtroad.dev> Date: Fri, 21 Aug 2026 08:52:00 -0700 Subject: [PATCH 58/59] Stop refusing work when a folder is not a git repository Required style skill told models to refuse if cwd had no .git. Eval fixtures are tmp copies, so GPT stopped on simple-health. Edits are allowed without a repo; eval workdirs get an unsigned fixture commit so isolated workers have HEAD. --- CHANGELOG.md | 8 +++ evals/capability/README.md | 2 + plugins/corbits-skills/skills/style/SKILL.md | 9 +-- scripts/eval-capability.test.ts | 75 +++++++++++++++++++- scripts/eval-capability.ts | 33 +++++++++ 5 files changed, 121 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e5e1327a..795041137 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,9 +75,17 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename 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` diff --git a/evals/capability/README.md b/evals/capability/README.md index d8ec1acde..678301eba 100644 --- a/evals/capability/README.md +++ b/evals/capability/README.md @@ -8,6 +8,8 @@ Local, multi-model capability checks against the **product** agent path (`corbit 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 | diff --git a/plugins/corbits-skills/skills/style/SKILL.md b/plugins/corbits-skills/skills/style/SKILL.md index b452c35ec..104a718aa 100644 --- a/plugins/corbits-skills/skills/style/SKILL.md +++ b/plugins/corbits-skills/skills/style/SKILL.md @@ -10,12 +10,13 @@ General guidelines for writing clean, maintainable code. ## Git Repository Requirement -Agents must only operate within git repositories. Before performing any work: +Prefer a git repository so changes can be tracked, reviewed, and reverted. -1. Verify the current working directory is inside a git repository -2. If not in a git repository, refuse to proceed +Edits, tests, and reports are allowed in a folder that is not a git repository. Do not refuse the task. -Without a git repository, it's too hard to succeed with agents - changes can't be tracked, reviewed, or safely reverted. +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 diff --git a/scripts/eval-capability.test.ts b/scripts/eval-capability.test.ts index a1440163c..3506e40ec 100644 --- a/scripts/eval-capability.test.ts +++ b/scripts/eval-capability.test.ts @@ -1,6 +1,13 @@ -import { describe, expect, test } from "bun:test"; +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 { parseArgs } from "./eval-capability.ts"; +import { initEvalGitRepo, parseArgs } from "./eval-capability.ts"; + +const execFileAsync = promisify(execFile); describe("parseArgs", () => { test("--help does not require provider or model", () => { @@ -55,3 +62,67 @@ describe("parseArgs", () => { 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 d21baaa9c..2844b14b1 100755 --- a/scripts/eval-capability.ts +++ b/scripts/eval-capability.ts @@ -312,6 +312,38 @@ async function seedEvalSkillStubs(workdir: string): Promise<void> { } } +/** + * 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}-`)); @@ -319,6 +351,7 @@ async function prepareWorkdir(caseDef: EvalCase): Promise<{ workdir: string; cap // 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); From 043bc031db4d297940bca4ff79fa0dc3cab140a7 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler <sawyer@dirtroad.dev> Date: Fri, 21 Aug 2026 15:03:35 -0700 Subject: [PATCH 59/59] Cut changelog as Corbits Code 0.2.99 Rename Unreleased to 0.2.99 with an operator-facing lead. Write tools stay off the primary. --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 795041137..1d6fd518a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,9 @@ 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. -## [Unreleased] +## [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